Compare commits

..

1 Commits

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

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

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

Each drain reads the console and clears it in ONE chrome script. Splitting
the read from Services.console.reset() left a window between the two round
trips in which an error was logged into a buffer about to be discarded, and
destroyed unread rather than deferred to the next drain; a probe of 100
sequenced throws at 20ms spacing lost one. With the drain atomic the same
probe accounts for every throw that falls inside the observed window, on two
consecutive runs.

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

Two limits are documented rather than papered over. Error capture is
poll-based, so an error is attributed to a step and not to a moment within
it, and the observed window ends ~1.5s after the last step returns, measured:
errors at +0.5s, +1.0s and +1.5s are reported and +1.6s and later never are,
because the browser is torn down. Nothing is stubbed; the container runs with
--network none instead, which proves no request escaped, cannot report which
were attempted, and runs only the failure branches of network-dependent code.
2026-08-12 09:23:11 +00:00
58 changed files with 612 additions and 6993 deletions

View File

@@ -1,13 +1,8 @@
# node:22-slim (22.x LTS), 2026-02-24 # node:22-slim (22.x LTS), 2026-02-24
FROM node@sha256:5373f1906319b3a1f291da5d102f4ce5c77ccbe29eb637f072b6c7b70443fc36 AS base FROM node@sha256:5373f1906319b3a1f291da5d102f4ce5c77ccbe29eb637f072b6c7b70443fc36
WORKDIR /app WORKDIR /app
# Marks "already inside the lint container" for script/lint, which otherwise
# shells out to docker to build the lint stage below. Nothing outside this
# image sets it.
ENV AUTISTMASK_LINT_NATIVE=1
# script/bootstrap installs all prerequisites (make via apt here; node # script/bootstrap installs all prerequisites (make via apt here; node
# is already in the base image, yarn comes via corepack) and runs # is already in the base image, yarn comes via corepack) and runs
# yarn install --frozen-lockfile. Dependency manifests are copied first # yarn install --frozen-lockfile. Dependency manifests are copied first
@@ -18,17 +13,5 @@ RUN script/bootstrap
COPY . . COPY . .
# Lint stage — fail fast on static analysis and formatting, before the tests
# and the build. This is also the stage script/lint builds from a host, which
# is how linting stays on the pinned ESLint rather than the host's.
FROM base AS lint
RUN make lint
# Full check and build. The COPY --from is a no-op file copy whose only job is
# to make BuildKit finish the lint stage before this one starts; without it the
# stages run in parallel and a lint failure would not fail the build early.
FROM base AS check
COPY --from=lint /app/package.json /dev/null
RUN make check RUN make check
RUN make build RUN make build

126
README.md
View File

@@ -88,13 +88,7 @@ provide:
- `script/test-e2e-firefox` — run the Firefox browser end-to-end suite (docker - `script/test-e2e-firefox` — run the Firefox browser end-to-end suite (docker
required; builds its own pinned image, see required; builds its own pinned image, see
[End-to-End Tests](#end-to-end-tests)) [End-to-End Tests](#end-to-end-tests))
- `script/lint` — run ESLint (`eslint.config.js`) and then `prettier --check`, - `script/lint` — run the linter
failing on either. It never writes: `--fix` is not in this path, so
`make check` stays non-mutating. Linting runs in the container — the script
builds the Dockerfile's `lint` stage — because an ESLint result that depends
on whichever ESLint the host happens to have is not a result. Docker is
therefore required to lint; inside that image `AUTISTMASK_LINT_NATIVE=1` makes
the same script lint in place instead of recursing.
- `script/fmt` — format all files (writes) - `script/fmt` — format all files (writes)
- `script/fmt-check` — check formatting (read-only) - `script/fmt-check` — check formatting (read-only)
- `script/check` — run test, test-verify-build, lint, and fmt-check - `script/check` — run test, test-verify-build, lint, and fmt-check
@@ -163,51 +157,6 @@ fixtures in `tests/e2e/network.js`, so the run is deterministic and fully
offline; unrecognised outbound requests are reported as failures rather than offline; unrecognised outbound requests are reported as failures rather than
silently allowed. silently allowed.
It also covers the confirmation screen, for both a native ETH send and an ERC-20
send: Send disabled while the fee estimate is in flight, enabled once it lands,
the fee block quoting the expected cost and the reserve separately, the distinct
message for an estimate that failed, and the view height staying constant across
every one of those transitions. The load-bearing one is that the spend gate uses
the **reserve** and not the displayed **estimate** — the two are stubbed far
apart on purpose, and the funded and refused sends sit on opposite sides of the
reserve while sitting on the same side of the estimate, so swapping the two in
`src/popup/views/confirmTx.js` fails the suite instead of passing it. That is
what [#154](https://git.eeqj.de/sneak/AutistMask/issues/154) was, and it was
previously correct by reading only.
It also covers the **dApp approval round trips** — the one place where the
content script, the inpage provider, the background worker and the approval
popup all have to work together. A local test page is served by the route
handler on a reserved-TLD origin, gets `window.ethereum` from the shipped
`MAIN`-world content script like any other page, and drives
`eth_requestAccounts`, `personal_sign`, `eth_signTypedData_v4` and
`eth_sendTransaction` through the real prompts. Every signature is recovered in
the runner and compared against the active address, the transaction assertions
run against the raw signed transaction captured at `eth_sendRawTransaction`
rather than against anything the extension reported, rejecting each prompt is
required to return a rejection to the page rather than hang or resolve, and the
password is required to be absent from every message the approval window sends
to the background — with the message that would carry it required to be present,
so that check cannot pass by observing nothing. That last one is the standing
floor under [#157](https://git.eeqj.de/sneak/AutistMask/issues/157).
Three limits of that coverage, none of them papered over. The RPC is stubbed
throughout, so this is **not** a real dApp against a real network with real
funds; that remains a human pass before 1.0.0. The site-connection prompt is
raised through `chrome.action.openPopup()`, and headless Chromium's
browser-action popup is not a page Playwright can see or click, so that one
prompt is driven at the URL the extension itself puts on the action — the same
page and the same approval id, but whether a real toolbar click shows it is not
observable here. And the EIP-1193 error code does not survive the last hop: the
rejection that crosses the boundary carries code 4001 and is asserted to, but
`src/content/inpage.js` rebuilds it as `new Error(message)`, so the calling page
catches an error with no `code` property.
Any test that drives a failure path on purpose declares the `console.error` it
is about to provoke, via `errors.expect()`. That is not a mute: the declaration
consumes exactly one matching record, and a declaration nothing matched fails
its test just as an undeclared error does.
That reporting has one bound worth knowing. Observation ends when the browser That reporting has one bound worth knowing. Observation ends when the browser
context is torn down, and nothing can watch traffic after that, so the run keeps context is torn down, and nothing can watch traffic after that, so the run keeps
collecting for a fixed grace period after the last test returns collecting for a fixed grace period after the last test returns
@@ -241,12 +190,9 @@ being intercepted, run with `E2E_TRACE_NETWORK=1` and every routed request is
printed, tagged `[sw]` or `[page]`. printed, tagged `[sw]` or `[page]`.
**Any uncaught page error or `console.error` fails the run.** That is the point: **Any uncaught page error or `console.error` fails the run.** That is the point:
this suite exists because a `ReferenceError` from a used-but-not-imported a `ReferenceError` from a used-but-not-imported identifier is invisible to
identifier shipped twice, fatal in a browser and invisible to a `make check` `make check` (`script/lint` is only `prettier --check`) but fatal in a browser,
that was `prettier --check` only. ESLint's `no-undef` now catches that exact and this suite exists because exactly that class of bug shipped twice.
class before a browser is involved, so this suite is no longer the only thing
standing between it and a release — but a static rule only sees identifiers, and
the runtime errors this suite catches are broader than one rule.
### Firefox (`make test-e2e-firefox`) ### Firefox (`make test-e2e-firefox`)
@@ -297,23 +243,15 @@ Two limits are worth knowing, both real differences from the Chrome suite:
- **Error capture is poll-based, not event-streamed.** The console is drained at - **Error capture is poll-based, not event-streamed.** The console is drained at
each step boundary, so an error is attributed to the step it was drained each step boundary, so an error is attributed to the step it was drained
after, not to a moment within it. The window that is drained runs from add-on after, not to a moment within it. The window that is drained runs from add-on
install to **≈1.5s** after the last step returns — a 500ms settle, a 1000ms install to **≈1.5s** after the last step returns, then the browser is torn
tail sleep and two drain round trips — and then the browser is torn down. That down; measured with throws scheduled at fixed offsets, errors at +0.5s, +1.0s
cut-off is not a hard boundary: with throws scheduled at fixed offsets, three and +1.5s are reported and +1.6s and later never are. Within that window
runs reported everything up to +1.5s and one of the three also reported +1.6s, nothing is dropped — each drain reads and clears the console in a single
so an error landing near it may or may not be seen, and anything well past it chrome round trip, so an error logged mid-drain lands in that batch or the
is not. Inside the window there is no race — each drain reads and clears the next one rather than being destroyed unread; a probe of 100 throws at 20ms
console in a single chrome round trip, so an error logged mid-drain lands in spacing accounts for every one that falls inside the window, twice running.
that batch or the next rather than being destroyed unread — but there is a What poll-based costs is location, not coverage: an error cannot be placed
**capacity limit**: `nsIConsoleService` keeps a ring buffer of 250 messages within a step the way the Chrome suite's `pageerror` events place it.
and silently evicts the oldest, so more than 250 console messages between two
drains destroys the excess unread. 400 throws inside one step are reported as
exactly the newest 250, three runs running. That buffer is shared with
Firefox's own console noise; a clean run peaks at 4 of 250 at the install
drain and 0 at every later drain, so the three steps here have wide headroom,
but a step that logs heavily could evict unread errors. What poll-based costs
is location, not coverage: an error cannot be placed within a step the way the
Chrome suite's `pageerror` events place it.
- **Nothing is stubbed, which inverts the coverage of network-dependent code.** - **Nothing is stubbed, which inverts the coverage of network-dependent code.**
There is no fixture layer; the container runs with `--network none` instead, There is no fixture layer; the container runs with `--network none` instead,
so the run is offline and deterministic and no request can escape. The so the run is offline and deterministic and no request can escape. The
@@ -647,21 +585,6 @@ ExportPrivKey and ShowRecoveryPhrase — are deliberately absent from that list,
so the popup can never reopen onto one of them with no password prompt in front so the popup can never reopen onto one of them with no password prompt in front
of it. of it.
A reopened popup renders the wallet list and the one screen it restores onto,
and nothing else, so every screen on the stack behind that one is still the
blank template from `index.html`. "Back" therefore renders its target rather
than only unhiding it, through the same dispatch and data guards as the restore
(`src/popup/viewRouter.js`), and falls back to Home when the state the target
would render is gone.
It renders only a screen this page load has not rendered yet. Forward navigation
renders as it goes, and `viewRouter.js` records every screen that reaches
`showView()`, so "Back" onto a screen already on the page unhides it and nothing
more — rendering it a second time would re-fetch and overwrite what it holds,
such as an edit typed into Settings and not yet saved. Home is the one screen
"Back" always re-renders, so the wallet list reflects anything that changed
while the user was away from it.
Every screen that holds secret material in the page registers a cleanup with Every screen that holds secret material in the page registers a cleanup with
`onViewLeave()` (`src/popup/views/helpers.js`), which `showView()` runs on every `onViewLeave()` (`src/popup/views/helpers.js`), which `showView()` runs on every
exit from that screen rather than only on its "Back" button, so nothing secret exit from that screen rather than only on its "Back" button, so nothing secret
@@ -1015,12 +938,7 @@ on ConfirmTx, DeleteWallet, ApproveTx and ApproveSign.
- "Hide fake tokens impersonating a known symbol" checkbox - "Hide fake tokens impersonating a known symbol" checkbox
- "Hide tokens with fewer than 1,000 holders" checkbox - "Hide tokens with fewer than 1,000 holders" checkbox
- "Hide transactions from detected fraud contracts" checkbox - "Hide transactions from detected fraud contracts" checkbox
- "Hide dust transactions below N gwei" checkbox + threshold input. The - "Hide dust transactions below N gwei" checkbox + threshold input
threshold is plain decimal digits, a whole number of gwei, zero or
greater (zero hides nothing). Anything else — a fraction, a negative,
a value carrying its unit, hex (`0x10`) or exponent (`1e3`) notation —
is refused with a flash message and the field snaps back to the stored
threshold, so a number the user did not type is never stored.
- Allowed Sites: list with remove buttons - Allowed Sites: list with remove buttons
- Denied Sites: list with remove buttons - Denied Sites: list with remove buttons
- About: project link, license, author, version, release date, and the - About: project link, license, author, version, release date, and the
@@ -1182,12 +1100,7 @@ on ConfirmTx, DeleteWallet, ApproveTx and ApproveSign.
- **When**: A connected website requests a transaction via - **When**: A connected website requests a transaction via
`eth_sendTransaction`. Always opened in a separate popup window by the `eth_sendTransaction`. Always opened in a separate popup window by the
background script (`windows.create()`), because the request is triggered background script (`windows.create()`), because the request is triggered
programmatically rather than by a user gesture. The background populates the programmatically rather than by a user gesture.
transaction (nonce, gas limit, fees, chain id) against the RPC node _before_
opening the window, so the screen shows a complete transaction and the signed
artifact can be compared with it field for field. A request that cannot be
populated — unreachable node, reverting gas estimate — opens no window and is
failed back to the site.
- **Elements**: - **Elements**:
- "Transaction Request" heading - "Transaction Request" heading
- Phishing warning banner (shown when the hostname is on the phishing - Phishing warning banner (shown when the hostname is on the phishing
@@ -1199,16 +1112,13 @@ on ConfirmTx, DeleteWallet, ApproveTx and ApproveSign.
- Contract: color dot + full address + etherscan link (or "contract - Contract: color dot + full address + etherscan link (or "contract
creation"), token symbol label if known creation"), token symbol label if known
- Value: amount in ETH (4 decimal places, USD in parentheses) - Value: amount in ETH (4 decimal places, USD in parentheses)
- Network fee (max): gas limit × fee per gas in ETH (4 decimal places, USD
in parentheses), with the gas limit and the fee per gas in gwei below it
- Network and nonce
- Raw data: full calldata displayed inline (shown if present) - Raw data: full calldata displayed inline (shown if present)
- Password input and an error line - Password input and an error line
- "Confirm" / "Reject" buttons - "Confirm" / "Reject" buttons
- **Transitions**: - **Transitions**:
- "Confirm" (correct password) → decrypts and signs the transaction it was - "Confirm" (correct password) → decrypts and signs in the popup, hands the
shown, exactly as shown, hands the signed transaction to the background to signed transaction to the background to broadcast, then → **WaitTx** in
broadcast, then → **WaitTx** in the same popup window the same popup window
- "Confirm" (wrong password) → error line, no screen change - "Confirm" (wrong password) → error line, no screen change
- "Reject" → closes popup (returns rejection to background) - "Reject" → closes popup (returns rejection to background)
- Popup window closed without answering → the request is rejected with - Popup window closed without answering → the request is rejected with

165
TODO.md
View File

@@ -32,100 +32,19 @@ The backlog lives on the
[Gitea tracker](https://git.eeqj.de/sneak/AutistMask/issues), which is [Gitea tracker](https://git.eeqj.de/sneak/AutistMask/issues), which is
authoritative; this file does not duplicate it. Full policy file set present. authoritative; this file does not duplicate it. Full policy file set present.
Real-browser end-to-end suites (`make test-e2e` for Chrome, Real-browser end-to-end suites (`make test-e2e` for Chrome,
`make test-e2e-firefox` for Firefox) sit alongside `make check`, which now does `make test-e2e-firefox` for Firefox) now sit alongside `make check`, which
static analysis as well as formatting. cannot see a runtime `ReferenceError` in a popup view.
# Next Step # Next Step
Pre-1.0 security review of the extension (key handling, DEBUG mode policy, RPC Land [#152](https://git.eeqj.de/sneak/AutistMask/issues/152): add ESLint to
input validation) before any 1.0rc tag. Individual filed issues are parts of it, `script/lint`. `make check` is `prettier --check` only today and cannot catch
but the review is broader than any of them. undefined identifiers, which is how
[#150](https://git.eeqj.de/sneak/AutistMask/issues/150) and
[#151](https://git.eeqj.de/sneak/AutistMask/issues/151) shipped.
# Completed Steps # Completed Steps
- 2026-08-14: `make check` does static analysis. `script/lint` ran
`prettier --check .`, byte-identical to `script/fmt-check`, so a wallet with
two shipped used-but-not-imported crashes behind it was green. ESLint is now
pinned in `package.json` with `@eslint/js` recommended as the base, flat
config in `eslint.config.js`, `no-undef` and `no-unused-vars` error-level, and
globals declared per tree — browser for the popup and content scripts, service
worker for `src/background/` and `src/shared/`, jest for `tests/`, node for
`build.js`. It found 41 unused bindings and 53 undefined identifiers; all are
fixed, and dropping a call to an unimported `foo()` into any `src/` file fails
`make lint`. Linting is also containerized now: `script/lint` builds the
Dockerfile's new `lint` stage, so the ESLint that decides whether this repo is
green is the pinned one and not the host's
([#152](https://git.eeqj.de/sneak/AutistMask/issues/152)).
- 2026-08-12: EIP-1193 error codes now reach the page. `src/content/inpage.js`
rebuilt every failure as `new Error(error.message)`, so the code the
background produced and the content script relayed intact was dropped in the
last hop and a dApp checking `err.code === 4001` saw `undefined` — a wallet
the user deliberately declined was indistinguishable from one that broke. The
provider now rejects with a `ProviderRpcError` carrying `code` and, where the
boundary sent one, `data`, passed through verbatim rather than matched against
a list, so 4001, 4100 and 4902 all arrive and a future code needs no edit
here. An error the background sent with no code stays a plain `Error` with no
`code` property, and `message` is unchanged in every case. All four request
entry points (`request`, `enable`, `send`, `sendAsync`) are covered by
`tests/inpageErrors.test.js`, and the e2e probe that printed the missing code
now requires it on the page's Error as well as on the wire, for all four
rejected flows ([#274](https://git.eeqj.de/sneak/AutistMask/issues/274)).
- 2026-08-12: "Back" now renders the screen it lands on instead of only unhiding
it. A reopened popup renders the wallet list and the one screen it restores
onto, so every screen further down the stack was still the blank template from
`index.html`, and Back walked straight onto it — an empty address, no
balances, no QR code. The Back path now goes through the same per-view
dispatch and data guards as the restore (`src/popup/viewRouter.js`, shared
with `restoreView()`), falling back to Home when the state the target would
render is gone. It renders only a view this page load has not rendered yet:
`viewRouter.js` records every view that reaches `showView()`, which is where
forward navigation and the restore both end, so Back onto a view already on
the page unhides it and nothing more. That is what keeps a second render from
re-fetching and overwriting what the view holds — an unsaved edit in Settings,
a transaction list already loaded. Home is the exception and is always
re-rendered, as it was before. Covered by unit tests on the real `goBack()`
and by three end-to-end cases against the real popup, each demonstrated
failing on the unfixed build
([#268](https://git.eeqj.de/sneak/AutistMask/issues/268)).
- 2026-08-12: `KNOWN_SYMBOLS` now maps a symbol to the set of contract addresses
that bear it, not to one of them. A ticker is not unique: seven of the 512
bundled tokens — `FRAX`, `REUSD`, `TON`, `EURE`, `MSUSD`, `MUSD` and `JPYC`
share a symbol with another bundled entry at a different real contract, and
the table, built from the list first-wins, kept only the earlier one. The
other seven were judged spoofs of their own symbol at their own address and
hidden from the balance list, the history and the send selector, so a holder
could not spend them. Both contracts of each pair come from the same CoinGecko
fetch of 2026-02-27, so neither was stale and neither was dropped.
`isSpoofedSymbol()` asks set membership instead of equality, which does not
loosen the rule — a contract outside the set is still a spoof — and a test now
walks `TOKENS` asserting no bundled token is filtered at its own address,
which is the walk the suite lacked
([#276](https://git.eeqj.de/sneak/AutistMask/issues/276)).
- 2026-08-12: The dApp approval round trips are driven end to end in the
browser. A test page served by the harness speaks EIP-1193 to the real inpage
provider through the real content script, background worker and approval popup
for `eth_requestAccounts`, `personal_sign`, `eth_signTypedData_v4` and
`eth_sendTransaction`. Every signature is recovered and compared against the
active address, the transaction is checked against the bytes handed to the
stubbed RPC, each rejection must reach the page as a rejection, and the
password must appear in no message the approval window sends — the assertion
that gives [#157](https://git.eeqj.de/sneak/AutistMask/issues/157) a permanent
floor. This does not discharge a real dApp with real funds against mainnet
([#183](https://git.eeqj.de/sneak/AutistMask/issues/183)).
- 2026-08-12: The known-symbol spoof rule now judges the symbol a user actually
sees. `isSpoofedSymbol()` normalizes before the lookup — NFKC, then every
character that paints nothing removed (the format and default-ignorable
characters, plus U+007F), then trimmed — so `" ETH "`, a no-break space, a
zero-width space, a Hangul filler, a variation selector, a DELETE and a
fullwidth `` are all caught on the balance list, the history and the
send selector at once. Confusables that are distinct letters (Cyrillic `Е`),
bidi reordering and the visible C0/C1 controls — which measure 48.00px, a box,
in the pinned e2e Chromium where an invisible prefix measures 32.00px — stay
knowingly open and are asserted as open in the suite. No bundled symbol
contains whitespace or a non-ASCII character, so nothing legitimate is newly
filtered; the balance list's token-type gate also became case-insensitive,
which no longer drops a real holding if an explorer writes `erc-20`
([#260](https://git.eeqj.de/sneak/AutistMask/issues/260)).
- 2026-08-12: A containerized Firefox end-to-end harness - 2026-08-12: A containerized Firefox end-to-end harness
(`make test-e2e-firefox`) drives the real popup in a real Firefox with the MV2 (`make test-e2e-firefox`) drives the real popup in a real Firefox with the MV2
build installed as a temporary add-on. Zero npm dependencies — a WebDriver build installed as a temporary add-on. Zero npm dependencies — a WebDriver
@@ -133,65 +52,14 @@ but the review is broader than any of them.
geckodriver 0.36.0 all pinned by digest. Uncaught errors are read from the geckodriver 0.36.0 all pinned by digest. Uncaught errors are read from the
privileged console service in Marionette's chrome context, because BiDi privileged console service in Marionette's chrome context, because BiDi
`log.entryAdded` reports nothing at all for extension pages; each drain reads `log.entryAdded` reports nothing at all for extension pages; each drain reads
and clears the console in one chrome round trip, so no error is destroyed and clears the console in one chrome round trip, so nothing logged between two
unread by the drain itself, and errors logged during add-on install and drains is destroyed unread, and errors logged during add-on install and
background startup are folded into step 1 instead of being cleared. The two background startup are folded into step 1 instead of being cleared.
measured limits are documented rather than claimed away: the console ring Demonstrated discriminating by exiting 1 on a `throw` at the top of
buffer holds 250 messages (a clean run peaks at 4), and the drained window `src/background/index.js`, on a build with one import removed, on a
ends ≈1.5s after the last step returns. Demonstrated discriminating by exiting `setTimeout` throw whose UI assertions all pass, on an unhandled
1 on a `throw` at the top of `src/background/index.js`, on a build with one `Promise.reject` and on an undefined identifier in `home.js`, and 0 on the
import removed, on a `setTimeout` throw whose UI assertions all pass, on an branch as it stands ([#184](https://git.eeqj.de/sneak/AutistMask/issues/184)).
unhandled `Promise.reject` and on an undefined identifier in `home.js`, and 0
on the branch as it stands
([#184](https://git.eeqj.de/sneak/AutistMask/issues/184)).
- 2026-08-12: The transaction a dApp asks for is now populated in the background
before the approval window opens, so the object the user is shown is the
object the signed artifact is verified against — nonce, gas limit and every
fee field are compared exactly instead of being left to the ceilings, which
stay as a backstop against what a lying RPC node can talk the wallet into
displaying. The approval also pins the address it was raised for, so an
address switch between approval and signing refuses rather than signing from
an account the screen never named, and a request naming an address that is not
the active one is refused outright. The approval screen now shows the fee, gas
limit, network and nonce it vouches for
([#216](https://git.eeqj.de/sneak/AutistMask/issues/216)).
- 2026-08-12: The restored navigation stack is filtered against
`RESTORABLE_VIEWS` on load, truncated at the first entry the popup would not
render so that every surviving entry keeps the Back target it had. Back after
reopening can no longer land on a view the popup declined to restore, such as
`export-privkey` or `show-phrase`
([#224](https://git.eeqj.de/sneak/AutistMask/issues/224)). Restorable views in
the stack are still unhidden without being re-rendered; that is tracked
separately in ([#268](https://git.eeqj.de/sneak/AutistMask/issues/268)).
- 2026-08-12: One wording for a rejected password on every screen that asks for
one — the send confirmation and the delete-wallet confirmation no longer say
"Wrong password." (a fragment, which `RULES.md` Language & Labeling forbids)
and the two reveal screens no longer say "not correct", so all five
`decryptWithPassword` call sites now show the sentence the dApp approval paths
introduced. Strings only, no behaviour change, and each error container
measured at a 360px viewport in the pinned Playwright container
([#172](https://git.eeqj.de/sneak/AutistMask/issues/172)).
- 2026-08-12: Closed the empty-array hole in the end-to-end unstubbed-request
guard. `batch.every()` is vacuously true on `[]`, so a POST with body `[]` was
answered `200 []` instead of failing the suite; the guard now rejects an empty
batch, demonstrated green-before/red-after with a throwaway probe. The comment
claiming `postData()` returns `null` for undecodable bodies was corrected to
the two real paths — an absent or empty body decodes to `null`, a binary body
decodes lossily into invalid JSON
([#187](https://git.eeqj.de/sneak/AutistMask/issues/187)).
- 2026-08-12: The transaction confirmation screen has browser coverage. The
end-to-end suite reaches ConfirmTx for both the native ETH and the ERC-20 path
off a funded-balance fixture, and asserts the pending, funded, over-balance
and estimate-failed states, the fee block quoting the estimate and the reserve
separately, and a constant view height across every one of those transitions.
The load-bearing assertion is that the spend gate reads the reserve and not
the displayed estimate: swapping the two fails the suite
([#238](https://git.eeqj.de/sneak/AutistMask/issues/238)).
- 2026-08-12: The dust threshold field now explains a rejection instead of
snapping back in silence, with the parse in a pure, unit-tested module that
accepts plain decimal digits only — hex and exponent notation are refused
rather than read as 16 and 1000
([#233](https://git.eeqj.de/sneak/AutistMask/issues/233)).
- 2026-08-12: Approval verification became an allowlist — transaction type - 2026-08-12: Approval verification became an allowlist — transaction type
restricted to 0/1/2 so an EIP-7702 delegation can no longer ride along on an restricted to 0/1/2 so an EIP-7702 delegation can no longer ride along on an
approved transfer, every consequential field compared, the artifact approved transfer, every consequential field compared, the artifact
@@ -376,6 +244,9 @@ but the review is broader than any of them.
Only work that has no issue of its own belongs here; everything else is on the Only work that has no issue of its own belongs here; everything else is on the
tracker. tracker.
- Pre-1.0 security review of the extension (key handling, DEBUG mode policy, RPC
input validation) before any 1.0rc tag. Individual filed issues are parts of
it, but the review is broader than any of them.
- Decide whether docker-in-docker makes `make test-e2e` and - Decide whether docker-in-docker makes `make test-e2e` and
`make test-e2e-firefox` runnable in the Gitea workflow. Extending the Chrome `make test-e2e-firefox` runnable in the Gitea workflow. Extending the Chrome
suite itself is tracked as suite itself is tracked as

View File

@@ -63,7 +63,7 @@ function getBuildInfo() {
commitHash = execSync("git rev-parse --short HEAD", { commitHash = execSync("git rev-parse --short HEAD", {
encoding: "utf8", encoding: "utf8",
}).trim(); }).trim();
} catch { } catch (_) {
// not a git repo or git not available // not a git repo or git not available
} }
let commitHashFull = "unknown"; let commitHashFull = "unknown";
@@ -71,7 +71,7 @@ function getBuildInfo() {
commitHashFull = execSync("git rev-parse HEAD", { commitHashFull = execSync("git rev-parse HEAD", {
encoding: "utf8", encoding: "utf8",
}).trim(); }).trim();
} catch { } catch (_) {
// not a git repo or git not available // not a git repo or git not available
} }
return { return {

View File

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

View File

@@ -1,144 +0,0 @@
// ESLint flat config. Static analysis for make check; formatting stays with
// prettier (script/fmt-check), so nothing here touches style.
//
// The sources are CommonJS and are bundled per entrypoint by build.js, so the
// globals differ by tree and are declared per tree below. Getting that wrong in
// either direction defeats the point: too few globals buries a real no-undef in
// false positives, too many hides the next unimported identifier.
const js = require("@eslint/js");
const globals = require("globals");
// The extension APIs. MV3 Chrome exposes `chrome`; Firefox exposes both, and
// the code feature-detects between them.
const extensionGlobals = {
chrome: "readonly",
browser: "readonly",
};
const commonjs = {
ecmaVersion: 2024,
sourceType: "commonjs",
};
module.exports = [
{
ignores: [
"dist/",
"node_modules/",
// Emitted by build.js, not authored here.
"src/popup/styles/",
],
},
js.configs.recommended,
{
rules: {
// The two rules this config exists for. Both are already
// error-level in the recommended set; restated so a future
// recommended-set change cannot silently downgrade them.
"no-undef": "error",
// `_`-prefixed arguments are the deliberate "present for the
// interface, unused here" marker: the popup views share one
// init(ctx) signature and three of the eight do not read ctx.
// An unused catch binding is written `catch {`, which the repo
// already does, so caught errors stay checked.
"no-unused-vars": ["error", { argsIgnorePattern: "^_" }],
// Off: it flags `password = null` and `decryptedSecret = null` in
// approval.js and confirmTx.js, which are the best-effort wipes of
// decrypted key material after use. The assignments are dead by
// construction — that is the point of them — and satisfying the
// rule would mean deleting the wipes.
"no-useless-assignment": "off",
// Off: it requires every rethrow to carry `{ cause }`. That is a
// change to what the wallet's error paths actually throw, which is
// not this config's business; adopting it is its own decision.
"preserve-caught-error": "off",
},
},
// Popup and content scripts: page/window context.
{
files: ["src/popup/**/*.js", "src/content/**/*.js"],
languageOptions: {
...commonjs,
globals: { ...globals.browser, ...extensionGlobals },
},
},
// MV3 background: a service worker, with no window and no document.
{
files: ["src/background/**/*.js"],
languageOptions: {
...commonjs,
globals: { ...globals.serviceworker, ...extensionGlobals },
},
},
// src/shared is bundled into both, so it may only use what both provide:
// the service worker globals are the intersection, plus the extension APIs.
{
files: ["src/shared/**/*.js"],
languageOptions: {
...commonjs,
globals: { ...globals.serviceworker, ...extensionGlobals },
},
},
// src/shared/ens.js is the documented exception to the line above: its own
// header says POPUP ONLY, it caches in localStorage, and only popup views
// require it. Linting it as a service worker would be wrong about the file.
{
files: ["src/shared/ens.js"],
languageOptions: {
...commonjs,
globals: { ...globals.browser, ...extensionGlobals },
},
},
// Unit tests: jest on node.
{
files: ["tests/**/*.test.js"],
languageOptions: {
...commonjs,
globals: { ...globals.node, ...globals.jest },
},
},
// The build script is a plain node program.
{
files: ["build.js"],
languageOptions: {
...commonjs,
globals: { ...globals.node },
},
},
// The e2e harnesses are node programs that also carry, inline, the
// callbacks they ship into the browser via page.evaluate — so both
// contexts really are present in the same file and both sets of globals
// are in scope somewhere in it.
{
files: ["tests/e2e/**/*.js"],
languageOptions: {
...commonjs,
globals: {
...globals.node,
...globals.browser,
...extensionGlobals,
},
},
},
// This config file itself.
{
files: ["eslint.config.js"],
languageOptions: {
...commonjs,
globals: { ...globals.node },
},
},
];

View File

@@ -9,16 +9,13 @@
"test": "jest --forceExit", "test": "jest --forceExit",
"test:verbose": "jest --forceExit --verbose", "test:verbose": "jest --forceExit --verbose",
"build": "node build.js", "build": "node build.js",
"lint": "eslint . && prettier --check .", "lint": "prettier --check .",
"fmt": "prettier --write .", "fmt": "prettier --write .",
"fmt-check": "prettier --check ." "fmt-check": "prettier --check ."
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "10.0.1",
"@tailwindcss/cli": "^4.2.1", "@tailwindcss/cli": "^4.2.1",
"esbuild": "^0.27.3", "esbuild": "^0.27.3",
"eslint": "10.8.1",
"globals": "17.11.0",
"jest": "^30.2.0", "jest": "^30.2.0",
"playwright-core": "1.56.0", "playwright-core": "1.56.0",
"prettier": "^3.8.1", "prettier": "^3.8.1",

View File

@@ -1,41 +1,13 @@
#!/bin/sh #!/bin/sh
# script/lint: run the linter (eslint, then prettier --check). # script/lint: run the linter.
#
# Linting is containerized. ESLint results depend on the ESLint version, and
# the pinned one is the one in the image; a host's own install must not be
# able to decide whether this repo is green. From a host this therefore builds
# the Dockerfile's `lint` stage, which runs this same script inside the image.
#
# AUTISTMASK_LINT_NATIVE is set only in that image (see the Dockerfile) and is
# what stops the recursion, so `make check` inside the CI build lints in place
# instead of trying to reach a docker daemon it does not have.
set -eu set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() { main() {
cd "$ROOT" cd "$ROOT"
echo "Linting..."
if [ "${AUTISTMASK_LINT_NATIVE:-}" = "1" ]; then yarn run lint 2>&1
echo "Linting..."
yarn run lint 2>&1
return 0
fi
if ! command -v docker >/dev/null 2>&1; then
echo "lint: docker is required; linting does not run on the host" >&2
exit 1
fi
echo "Linting in the pinned container..."
# --progress=plain: the default progress renderer collapses the lint
# output on success, and a lint run whose output cannot be seen is not
# evidence that it ran.
#
# --output=type=cacheonly: the exit status is the whole result; exporting
# an image afterwards costs about ten times the lint itself.
docker build --progress=plain --target lint \
--output=type=cacheonly . 2>&1
} }
main "$@" main "$@"

View File

@@ -5,9 +5,8 @@
# #
# Deliberately NOT called by script/check or script/test: REPO_POLICIES.md # Deliberately NOT called by script/check or script/test: REPO_POLICIES.md
# caps make test at 20 seconds and a browser suite does not fit. Run it # caps make test at 20 seconds and a browser suite does not fit. Run it
# yourself before touching popup views. ESLint's no-undef now catches a # yourself before touching popup views; it is the only check that can see
# used-but-not-imported identifier in make check, but only this suite sees # a used-but-not-imported identifier blow up at runtime.
# what a view actually does when it runs.
set -eu set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"

View File

@@ -18,14 +18,11 @@ const {
verifySignature, verifySignature,
failureIsRetryable, failureIsRetryable,
describeTxFailure, describeTxFailure,
sameAddress,
ApprovalMismatchError,
TX_STAGE_SIGN, TX_STAGE_SIGN,
TX_STAGE_VERIFY, TX_STAGE_VERIFY,
TX_STAGE_BROADCAST, TX_STAGE_BROADCAST,
TX_STAGE_INFLIGHT, TX_STAGE_INFLIGHT,
} = require("../shared/approvalVerify"); } = require("../shared/approvalVerify");
const { prepareApprovalTx } = require("../shared/approvalTx");
const { const {
isPhishingDomain, isPhishingDomain,
refreshPhishingListOnSchedule, refreshPhishingListOnSchedule,
@@ -80,14 +77,6 @@ async function getActiveAddress() {
return null; return null;
} }
// Whether a request names a signing address other than the active one. Such a
// request is refused rather than quietly signed as whichever address happens
// to be active: the page asked for account A and would otherwise be handed
// something from account B.
function namesAnotherAddress(requested, activeAddress) {
return !!requested && !sameAddress(requested, activeAddress);
}
async function getRpcUrl() { async function getRpcUrl() {
const s = await getState(); const s = await getState();
return s.rpcUrl || DEFAULT_RPC_URL; return s.rpcUrl || DEFAULT_RPC_URL;
@@ -236,21 +225,13 @@ function requestApproval(origin, hostname) {
// Uses windows.create() directly because tx approvals are triggered programmatically // Uses windows.create() directly because tx approvals are triggered programmatically
// (from a dApp RPC call), not from a user gesture, so action.openPopup() is // (from a dApp RPC call), not from a user gesture, so action.openPopup() is
// unreliable in this context. // unreliable in this context.
// function requestTxApproval(origin, hostname, txParams) {
// `approvedTx` is the fully populated transaction (see approvalTx.js): the
// object the popup displays, the object it signs, and the object the artifact
// is verified against. `approvedFrom` is the address that is active now, and
// it is pinned here rather than read again at signing time — an address switch
// between approval and signing must refuse, not sign from an account this
// screen never named.
function requestTxApproval(origin, hostname, approvedTx, approvedFrom) {
return new Promise((resolve) => { return new Promise((resolve) => {
const id = crypto.randomUUID(); const id = crypto.randomUUID();
pendingApprovals[id] = { pendingApprovals[id] = {
origin, origin,
hostname, hostname,
approvedTx, txParams,
approvedFrom,
resolve, resolve,
type: "tx", type: "tx",
}; };
@@ -263,14 +244,13 @@ function requestTxApproval(origin, hostname, approvedTx, approvedFrom) {
// Uses windows.create() directly because sign approvals are triggered programmatically // Uses windows.create() directly because sign approvals are triggered programmatically
// (from a dApp RPC call), not from a user gesture, so action.openPopup() is // (from a dApp RPC call), not from a user gesture, so action.openPopup() is
// unreliable in this context. // unreliable in this context.
function requestSignApproval(origin, hostname, signParams, approvedFrom) { function requestSignApproval(origin, hostname, signParams) {
return new Promise((resolve) => { return new Promise((resolve) => {
const id = crypto.randomUUID(); const id = crypto.randomUUID();
pendingApprovals[id] = { pendingApprovals[id] = {
origin, origin,
hostname, hostname,
signParams, signParams,
approvedFrom,
resolve, resolve,
type: "sign", type: "sign",
}; };
@@ -522,16 +502,6 @@ async function handleRpc(method, params, origin) {
? { method, message: params[0], from: params[1] } ? { method, message: params[0], from: params[1] }
: { method, message: params[1], from: params[0] }; : { method, message: params[1], from: params[0] };
if (namesAnotherAddress(signParams.from, activeAddress)) {
return {
error: {
code: 4100,
message:
"This site asked to sign as an address that is not the active one.",
},
};
}
if (method === "eth_sign") { if (method === "eth_sign") {
signParams.dangerWarning = signParams.dangerWarning =
"\u26a0\ufe0f DANGER: This site is requesting to sign a raw hash. " + "\u26a0\ufe0f DANGER: This site is requesting to sign a raw hash. " +
@@ -543,7 +513,6 @@ async function handleRpc(method, params, origin) {
origin, origin,
hostname, hostname,
signParams, signParams,
activeAddress,
); );
if (decision.error) return { error: decision.error }; if (decision.error) return { error: decision.error };
return { result: decision.signature }; return { result: decision.signature };
@@ -565,20 +534,10 @@ async function handleRpc(method, params, origin) {
} }
const signParams = { method, typedData: params[1], from: params[0] }; const signParams = { method, typedData: params[1], from: params[0] };
if (namesAnotherAddress(signParams.from, activeAddress)) {
return {
error: {
code: 4100,
message:
"This site asked to sign as an address that is not the active one.",
},
};
}
const decision = await requestSignApproval( const decision = await requestSignApproval(
origin, origin,
hostname, hostname,
signParams, signParams,
activeAddress,
); );
if (decision.error) return { error: decision.error }; if (decision.error) return { error: decision.error };
return { result: decision.signature }; return { result: decision.signature };
@@ -600,51 +559,7 @@ async function handleRpc(method, params, origin) {
} }
const txParams = params?.[0] || {}; const txParams = params?.[0] || {};
if (namesAnotherAddress(txParams.from, activeAddress)) { const decision = await requestTxApproval(origin, hostname, txParams);
return {
error: {
code: 4100,
message:
"This site asked to send from an address that is not the active one.",
},
};
}
// Populate here, before any window opens, so that the transaction the
// user is shown is a complete one and is the same object the signed
// artifact is checked against. A failure raises no approval at all and
// is reported to the requesting page; see approvalTx.js.
let approvedTx;
try {
approvedTx = await prepareApprovalTx(
getProvider(await getRpcUrl()),
activeAddress,
txParams,
);
} catch (e) {
return { error: { message: e.message } };
}
// Population is a network round trip, and the user can switch address
// during it. Raising the approval anyway would put an account on the
// screen that the wallet is no longer on, and it could never be signed
// — the signing handler refuses exactly that. Refuse it here instead,
// while the page is still waiting and nothing has been displayed.
if (!sameAddress(await getActiveAddress(), activeAddress)) {
return {
error: {
message:
"The active address changed while this transaction was being prepared, so it was not sent.",
},
};
}
const decision = await requestTxApproval(
origin,
hostname,
approvedTx,
activeAddress,
);
if (decision.error) return { error: decision.error }; if (decision.error) return { error: decision.error };
return { result: decision.txHash }; return { result: decision.txHash };
} }
@@ -895,16 +810,11 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
}; };
if (approval.type === "tx") { if (approval.type === "tx") {
resp.type = "tx"; resp.type = "tx";
// The populated transaction, and the address it was raised resp.txParams = approval.txParams;
// for. The popup displays and signs exactly this and does not
// populate or re-read anything itself.
resp.approvedTx = approval.approvedTx;
resp.approvedFrom = approval.approvedFrom;
} }
if (approval.type === "sign") { if (approval.type === "sign") {
resp.type = "sign"; resp.type = "sign";
resp.signParams = approval.signParams; resp.signParams = approval.signParams;
resp.approvedFrom = approval.approvedFrom;
} }
// Flag if the requesting domain is on the phishing blocklist. // Flag if the requesting domain is on the phishing blocklist.
resp.isPhishingDomain = isPhishingDomain(approval.hostname); resp.isPhishingDomain = isPhishingDomain(approval.hostname);
@@ -978,27 +888,14 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
try { try {
await loadState(); await loadState();
const activeAddress = await getActiveAddress(); const activeAddress = await getActiveAddress();
// An address switch between approval and signing refuses. The
// approval named one account; signing from whichever account
// is active now would send funds from an account this screen
// never showed. A switch normally rejects every pending
// approval on its way through broadcastAccountsChanged(), so
// this is the case where that did not reach the approval —
// and it is a refusal, not a retry, because the transaction
// the user saw is no longer the transaction that would go out.
if (!sameAddress(activeAddress, approval.approvedFrom)) {
throw new ApprovalMismatchError(
"The active address changed after this transaction was approved, so it was not sent.",
);
}
// The popup holds the secret, but the background stays the // The popup holds the secret, but the background stays the
// authority on what is broadcast: the raw transaction must be // authority on what is broadcast: the raw transaction must be
// the transaction that was displayed, signed by the address // the approved one, signed by the approved address, on the
// the approval named, on the network that is selected. // network that is selected.
verifySignedTx( verifySignedTx(
msg.rawSignedTx, msg.rawSignedTx,
approval.approvedTx, approval.txParams,
approval.approvedFrom, activeAddress,
currentNetwork().chainId, currentNetwork().chainId,
); );
} catch (e) { } catch (e) {
@@ -1035,10 +932,10 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
sendResponse({ txHash: tx.hash }); sendResponse({ txHash: tx.hash });
} catch (e) { } catch (e) {
// Terminal, never retried: the node may have accepted the // Terminal, never retried: the node may have accepted the
// transaction and still failed to answer, so the wallet cannot // transaction and still failed to answer, and the popup's
// tell a transaction that never left from one already in the // retry re-signs at a freshly fetched nonce rather than
// mempool. The page has been given its outcome for this // re-broadcasting these bytes. Retrying would send the
// request; a second attempt would report a second one. // approved transfer a second time.
const outcome = describeTxFailure(TX_STAGE_BROADCAST, e); const outcome = describeTxFailure(TX_STAGE_BROADCAST, e);
settleApproval( settleApproval(
msg.id, msg.id,
@@ -1101,24 +998,12 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
(async () => { (async () => {
try { try {
const activeAddress = await getActiveAddress(); const activeAddress = await getActiveAddress();
// Same as the transaction path: the address the approval named
// is the one that must have signed, and a switch since then is
// a refusal rather than a signature from another account.
if (!sameAddress(activeAddress, approval.approvedFrom)) {
throw new ApprovalMismatchError(
"The active address changed after this request was approved, so it was not signed.",
);
}
// The popup holds the secret, but the background stays the // The popup holds the secret, but the background stays the
// authority on what is handed back to the page: the signature // authority on what is handed back to the page: the signature
// must cover the approved payload and recover to the address // must cover the approved payload and recover to the approved
// the approval named. // address.
const signature = msg.signature; const signature = msg.signature;
verifySignature( verifySignature(approval.signParams, signature, activeAddress);
approval.signParams,
signature,
approval.approvedFrom,
);
settleApproval(msg.id, { signature }, { holdsClaim: true }); settleApproval(msg.id, { signature }, { holdsClaim: true });
sendResponse({ signature }); sendResponse({ signature });
} catch (e) { } catch (e) {

View File

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

View File

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

View File

@@ -1496,33 +1496,6 @@
<div class="text-xs text-muted mb-1">Value</div> <div class="text-xs text-muted mb-1">Value</div>
<div id="approve-tx-value" class="text-xs font-bold"></div> <div id="approve-tx-value" class="text-xs font-bold"></div>
</div> </div>
<div class="mb-3">
<div class="text-xs text-muted mb-1">Network fee (max)</div>
<div
id="approve-tx-fee"
class="text-xs font-bold min-h-[1rem]"
></div>
<div
id="approve-tx-fee-detail"
class="text-xs text-muted min-h-[1rem]"
></div>
</div>
<div class="mb-3 flex justify-between">
<div>
<div class="text-xs text-muted mb-1">Network</div>
<div
id="approve-tx-network"
class="text-xs min-h-[1rem]"
></div>
</div>
<div>
<div class="text-xs text-muted mb-1">Nonce</div>
<div
id="approve-tx-nonce"
class="text-xs min-h-[1rem]"
></div>
</div>
</div>
<div id="approve-tx-data-section" class="mb-3 hidden"> <div id="approve-tx-data-section" class="mb-3 hidden">
<div class="text-xs text-muted mb-1">Raw data</div> <div class="text-xs text-muted mb-1">Raw data</div>
<div id="approve-tx-data" class="text-xs break-all"></div> <div id="approve-tx-data" class="text-xs break-all"></div>

View File

@@ -9,16 +9,16 @@ const {
$, $,
showView, showView,
updateDebugBanner, updateDebugBanner,
setBackRenderer, setRenderMain,
pushCurrentView, pushCurrentView,
goBack, goBack,
clearViewStack,
} = require("./views/helpers"); } = require("./views/helpers");
const { applyTheme } = require("./theme"); const { applyTheme } = require("./theme");
// Renders a view the popup lands on without having navigated to it forward: // Views that can be fully re-rendered from persisted state. All others fall
// on restore here, and on Back. Only the views that can be fully re-rendered // back to the nearest restorable parent; see the module for why the
// from persisted state (RESTORABLE_VIEWS, src/popup/restorableViews.js) go // secret-bearing views are absent.
// through it; anything else falls back to the nearest restorable parent. const { RESTORABLE_VIEWS } = require("./restorableViews");
const { renderView, makeBackRenderer } = require("./viewRouter");
const home = require("./views/home"); const home = require("./views/home");
const welcome = require("./views/welcome"); const welcome = require("./views/welcome");
@@ -108,22 +108,91 @@ const ctx = {
}, },
}; };
// The view modules the router renders through, keyed as it expects them. function needsAddress(view) {
const viewModules = { return (
main: { show: () => fallbackView() }, view === "address" ||
addressDetail, view === "address-token" ||
addressToken, view === "receive" ||
receive, view === "transaction"
settings, );
settingsAddToken, }
confirmTx,
transactionDetail, function hasValidAddress() {
txStatus, return (
}; state.selectedWallet !== null &&
state.selectedAddress !== null &&
state.wallets[state.selectedWallet] &&
state.wallets[state.selectedWallet].addresses[state.selectedAddress]
);
}
function restoreView() { function restoreView() {
if (!renderView(state.currentView, state, viewModules)) { const view = state.currentView;
fallbackView(); if (!view || !RESTORABLE_VIEWS.has(view)) {
return fallbackView();
}
if (needsAddress(view) && !hasValidAddress()) {
return fallbackView();
}
if (view === "address-token" && !state.selectedToken) {
return fallbackView();
}
switch (view) {
case "address":
addressDetail.show();
break;
case "address-token":
addressToken.show();
break;
case "receive":
receive.show();
break;
case "settings":
settings.show();
break;
case "settings-addtoken":
settingsAddToken.show();
break;
case "confirm-tx":
if (state.viewData && state.viewData.pendingTx) {
confirmTx.restore();
} else {
fallbackView();
}
break;
case "transaction":
if (state.viewData && state.viewData.tx) {
transactionDetail.render();
} else {
fallbackView();
}
break;
case "wait-tx":
// Resumes the receipt poll from the persisted broadcast time.
if (!txStatus.restoreWait()) {
fallbackView();
}
break;
case "success-tx":
if (state.viewData && state.viewData.hash) {
txStatus.renderSuccess();
} else {
fallbackView();
}
break;
case "error-tx":
if (state.viewData && state.viewData.message) {
txStatus.renderError();
} else {
fallbackView();
}
break;
default:
fallbackView();
break;
} }
} }
@@ -178,7 +247,7 @@ async function init() {
settings.show(); settings.show();
}); });
setBackRenderer(makeBackRenderer(state, viewModules)); setRenderMain(renderWalletList);
welcome.init(ctx); welcome.init(ctx);
addWallet.init(ctx); addWallet.init(ctx);

View File

@@ -1,167 +0,0 @@
// Rendering a view the popup lands on without having navigated to it
// forward: on restore, and on Back. In both cases the view may never have
// been rendered in this page load — a reopened popup renders only the
// wallet list and the view it restores onto, so every other view is still
// the blank static template from index.html — so unhiding it is not enough.
//
// Forward navigation renders as it goes and must NOT come through here:
// rendering a second time would re-fetch and clobber whatever the view has
// in flight.
//
// The view modules are injected and nothing here touches the DOM, so the
// dispatch and its data guards can be tested directly; src/popup/index.js
// cannot be required outside a browser.
const { RESTORABLE_VIEWS } = require("./restorableViews");
// The views this page load has rendered.
//
// The Back path cannot otherwise tell its two cases apart. A view the popup
// never rendered is still the blank template from index.html and has to be
// rendered; a view already on the page must NOT be rendered again, because
// a second render re-fetches and overwrites whatever the user has typed
// into it and not yet saved.
//
// Registration is showView() in views/helpers.js, which is the last thing
// every render path runs — restoreView()'s, the Back path's, and every
// forward show(). That is the point of putting it there rather than in the
// individual views: a view added later registers itself with no one having
// to remember it, so this cannot decay.
//
// Module scope is page-load scope: the popup loads this module once per
// page load, and a reopened popup gets a fresh, empty set — which is
// exactly the state that makes the Back path render.
const renderedViews = new Set();
function markViewRendered(view) {
if (view) renderedViews.add(view);
}
// Begin a fresh page-load scope. The popup gets one by being loaded; the
// unit tests, which simulate several page loads against one module
// instance, ask for one.
function resetRenderedViews() {
renderedViews.clear();
}
// Home is the exception: Back re-renders it every time, which is what the
// popup did before this router existed (index.js registered
// renderWalletList() as setRenderMain(), and goBack() called it on every
// Back onto "main"). It must stay that way — the wallet list has to reflect
// what changed while the user was away from it, such as a wallet renamed or
// an address removed in Settings — and Home holds no unsaved input to lose.
const ALWAYS_RENDER_ON_BACK = new Set(["main"]);
// Views that render an address the user picked and cannot be rendered
// without one.
const ADDRESS_VIEWS = new Set([
"address",
"address-token",
"receive",
"transaction",
]);
function needsAddress(view) {
return ADDRESS_VIEWS.has(view);
}
function hasValidAddress(state) {
return Boolean(
state.selectedWallet !== null &&
state.selectedAddress !== null &&
state.wallets[state.selectedWallet] &&
state.wallets[state.selectedWallet].addresses[state.selectedAddress],
);
}
// Render `view` from persisted state. Each view module shows itself, so a
// true return means the view is both rendered and on screen.
//
// Returns false when the view is not one the popup renders from state, or
// when the state it would render is gone — a token no longer selected, a
// transaction no longer persisted. The caller falls back rather than
// putting an empty template on screen.
function renderView(view, state, views) {
if (!view || !RESTORABLE_VIEWS.has(view)) return false;
if (needsAddress(view) && !hasValidAddress(state)) return false;
if (view === "address-token" && !state.selectedToken) return false;
const data = state.viewData || {};
switch (view) {
case "main":
views.main.show();
return true;
case "address":
views.addressDetail.show();
return true;
case "address-token":
views.addressToken.show();
return true;
case "receive":
views.receive.show();
return true;
case "settings":
views.settings.show();
return true;
case "settings-addtoken":
views.settingsAddToken.show();
return true;
case "confirm-tx":
if (!data.pendingTx) return false;
views.confirmTx.restore();
return true;
case "transaction":
if (!data.tx) return false;
views.transactionDetail.render();
return true;
case "wait-tx":
// Resumes the receipt poll from the persisted broadcast time,
// and answers false when there is nothing resumable left.
return Boolean(views.txStatus.restoreWait());
case "success-tx":
if (!data.hash) return false;
views.txStatus.renderSuccess();
return true;
case "error-tx":
if (!data.message) return false;
views.txStatus.renderError();
return true;
default:
return false;
}
}
// The Back-path renderer, registered with setBackRenderer() in
// views/helpers.js.
//
// Returns false — leaving goBack() to unhide the view, as it always did —
// in the two cases where the view is known to be on the page already:
//
// - It is not one the popup renders from persisted state. The restored
// stack is filtered against RESTORABLE_VIEWS, so such a view can only
// be on the stack from this page load, where forward navigation
// rendered it on the way in.
// - This page load has rendered it. Re-rendering would re-fetch and
// clobber what it holds; Home is rendered anyway, see above.
//
// What is left is the case the router exists for: a view on the stack that
// this page load has never rendered, whose template is still blank.
function makeBackRenderer(state, views) {
return function renderBack(view) {
if (!RESTORABLE_VIEWS.has(view)) return false;
if (renderedViews.has(view) && !ALWAYS_RENDER_ON_BACK.has(view)) {
return false;
}
if (!renderView(view, state, views)) {
views.main.show();
}
return true;
};
}
module.exports = {
renderView,
makeBackRenderer,
markViewRendered,
resetRenderedViews,
};

View File

@@ -194,7 +194,7 @@ async function importPrivateKey(ctx) {
let addr; let addr;
try { try {
addr = addressFromPrivateKey(key); addr = addressFromPrivateKey(key);
} catch { } catch (e) {
showFlash("Invalid private key."); showFlash("Invalid private key.");
return; return;
} }
@@ -246,7 +246,7 @@ async function importXprvKey(ctx) {
let result; let result;
try { try {
result = hdWalletFromXprv(xprv); result = hdWalletFromXprv(xprv);
} catch { } catch (e) {
showFlash( showFlash(
"That extended private key is not valid. Please check it and try again.", "That extended private key is not valid. Please check it and try again.",
); );

View File

@@ -12,7 +12,7 @@ const {
goBack, goBack,
pushCurrentView, pushCurrentView,
} = require("./helpers"); } = require("./helpers");
const { state, saveState } = require("../../shared/state"); const { state, currentAddress, saveState } = require("../../shared/state");
const { formatUsd, getAddressValueUsd } = require("../../shared/prices"); const { formatUsd, getAddressValueUsd } = require("../../shared/prices");
const { const {
fetchRecentTransactions, fetchRecentTransactions,
@@ -44,6 +44,7 @@ function show() {
state.selectedToken = null; state.selectedToken = null;
const wallet = state.wallets[state.selectedWallet]; const wallet = state.wallets[state.selectedWallet];
const addr = wallet.addresses[state.selectedAddress]; const addr = wallet.addresses[state.selectedAddress];
const wi = state.selectedWallet;
const ai = state.selectedAddress; const ai = state.selectedAddress;
$("address-title").textContent = $("address-title").textContent =
wallet.name + " \u2014 Address " + (ai + 1); wallet.name + " \u2014 Address " + (ai + 1);
@@ -245,6 +246,7 @@ function renderTransactions(txs) {
row.addEventListener("click", () => { row.addEventListener("click", () => {
const idx = parseInt(row.dataset.tx, 10); const idx = parseInt(row.dataset.tx, 10);
const tx = loadedTxs[idx]; const tx = loadedTxs[idx];
const counterparty = tx.direction === "sent" ? tx.to : tx.from;
tx.fromEns = ensNameMap.get(tx.from) || null; tx.fromEns = ensNameMap.get(tx.from) || null;
tx.toEns = ensNameMap.get(tx.to) || null; tx.toEns = ensNameMap.get(tx.to) || null;
ctx.showTransactionDetail(tx); ctx.showTransactionDetail(tx);

View File

@@ -16,9 +16,13 @@ const {
goBack, goBack,
pushCurrentView, pushCurrentView,
} = require("./helpers"); } = require("./helpers");
const { state, saveState } = require("../../shared/state"); const { state, currentAddress, saveState } = require("../../shared/state");
const { TOKEN_BY_ADDRESS, resolveSymbol } = require("../../shared/tokenList"); const { TOKEN_BY_ADDRESS, resolveSymbol } = require("../../shared/tokenList");
const { formatUsd, getPrice } = require("../../shared/prices"); const {
formatUsd,
getPrice,
getAddressValueUsd,
} = require("../../shared/prices");
const { const {
fetchRecentTransactions, fetchRecentTransactions,
filterTransactions, filterTransactions,

View File

@@ -9,8 +9,7 @@ const {
attachCopyHandlers, attachCopyHandlers,
onViewLeave, onViewLeave,
} = require("./helpers"); } = require("./helpers");
const { state, saveState } = require("../../shared/state"); const { state, saveState, currentNetwork } = require("../../shared/state");
const { networkByChainId } = require("../../shared/networks");
const { const {
formatEther, formatEther,
formatUnits, formatUnits,
@@ -24,6 +23,7 @@ const { TOKEN_BY_ADDRESS } = require("../../shared/tokenList");
const { decryptWithPassword } = require("../../shared/vault"); const { decryptWithPassword } = require("../../shared/vault");
const { getSignerForAddress } = require("../../shared/wallet"); const { getSignerForAddress } = require("../../shared/wallet");
const { walletDefect } = require("../../shared/walletDefects"); const { walletDefect } = require("../../shared/walletDefects");
const { getProvider } = require("../../shared/balances");
const { describeSigningFailure } = require("../../shared/approvalVerify"); const { describeSigningFailure } = require("../../shared/approvalVerify");
const txStatus = require("./txStatus"); const txStatus = require("./txStatus");
const uniswap = require("../../shared/uniswap"); const uniswap = require("../../shared/uniswap");
@@ -159,61 +159,21 @@ function showPhishingWarning(elementId, isPhishing) {
} }
} }
// The fields of the approved transaction the value and recipient lines do not
// already carry: network, gas limit, fee per gas, the most the fee can come to,
// and the nonce. The background compares every one of them against the signed
// artifact, so every one of them has to be on the screen — a number that is
// verified but never displayed is verified against nothing the user agreed to.
function showTxFee(approvedTx, ethPrice) {
const network = networkByChainId(approvedTx.chainId);
$("approve-tx-network").textContent = network
? network.name
: "Unknown network (chain id " + BigInt(approvedTx.chainId) + ")";
const gasLimit = BigInt(approvedTx.gasLimit);
const feePerGas = BigInt(approvedTx.maxFeePerGas || approvedTx.gasPrice);
const maxFeeEth = formatTxValue(formatEther(gasLimit * feePerGas));
const usdStr = formatUsd(
ethPrice ? parseFloat(maxFeeEth) * ethPrice : null,
);
$("approve-tx-fee").textContent =
maxFeeEth + " ETH" + (usdStr ? " (" + usdStr + ")" : "");
let detail =
gasLimit.toString() +
" gas at up to " +
formatUnits(feePerGas, 9) +
" gwei";
if (approvedTx.maxPriorityFeePerGas) {
detail +=
", " +
formatUnits(approvedTx.maxPriorityFeePerGas, 9) +
" gwei priority";
}
$("approve-tx-fee-detail").textContent = detail;
$("approve-tx-nonce").textContent = BigInt(approvedTx.nonce).toString();
}
function showTxApproval(details) { function showTxApproval(details) {
showPhishingWarning( showPhishingWarning(
"approve-tx-phishing-warning", "approve-tx-phishing-warning",
details.isPhishingDomain, details.isPhishingDomain,
); );
// The transaction the background populated. It is displayed as it stands, pendingTxParams = details.txParams;
// signed as it stands, and verified against as it stands — the popup fills
// nothing in, so there is no number on this screen that the background
// cannot compare with the artifact it gets back.
pendingTxParams = details.approvedTx;
const approvedTx = details.approvedTx;
const toAddr = approvedTx.to; const toAddr = details.txParams.to;
const token = toAddr ? TOKEN_BY_ADDRESS.get(toAddr.toLowerCase()) : null; const token = toAddr ? TOKEN_BY_ADDRESS.get(toAddr.toLowerCase()) : null;
const ethValue = formatEther(approvedTx.value || "0"); const ethValue = formatEther(details.txParams.value || "0");
// Build txInfo for status screens // Build txInfo for status screens
pendingTxDetails = { pendingTxDetails = {
from: details.approvedFrom, from: state.activeAddress,
to: toAddr || "", to: toAddr || "",
amount: formatTxValue(ethValue), amount: formatTxValue(ethValue),
token: "ETH", token: "ETH",
@@ -221,7 +181,7 @@ function showTxApproval(details) {
}; };
// If this is an ERC-20 call, try to extract the real recipient and amount // If this is an ERC-20 call, try to extract the real recipient and amount
const decoded = decodeCalldata(approvedTx.data, toAddr || ""); const decoded = decodeCalldata(details.txParams.data, toAddr || "");
if (decoded && decoded.details) { if (decoded && decoded.details) {
let decodedTokenAddr = null; let decodedTokenAddr = null;
let decodedTokenSymbol = null; let decodedTokenSymbol = null;
@@ -259,7 +219,7 @@ function showTxApproval(details) {
} }
$("approve-tx-hostname").textContent = details.hostname; $("approve-tx-hostname").textContent = details.hostname;
$("approve-tx-from").innerHTML = approvalAddressHtml(details.approvedFrom); $("approve-tx-from").innerHTML = approvalAddressHtml(state.activeAddress);
// Show token symbol next to contract address if known // Show token symbol next to contract address if known
const symbol = toAddr ? tokenLabel(toAddr) : null; const symbol = toAddr ? tokenLabel(toAddr) : null;
@@ -275,7 +235,7 @@ function showTxApproval(details) {
} }
const ethValueFormatted = formatTxValue( const ethValueFormatted = formatTxValue(
formatEther(approvedTx.value || "0"), formatEther(details.txParams.value || "0"),
); );
const ethPrice = getPrice("ETH"); const ethPrice = getPrice("ETH");
const ethUsd = ethPrice ? parseFloat(ethValueFormatted) * ethPrice : null; const ethUsd = ethPrice ? parseFloat(ethValueFormatted) * ethPrice : null;
@@ -283,8 +243,6 @@ function showTxApproval(details) {
$("approve-tx-value").textContent = $("approve-tx-value").textContent =
ethValueFormatted + " ETH" + (usdStr ? " (" + usdStr + ")" : ""); ethValueFormatted + " ETH" + (usdStr ? " (" + usdStr + ")" : "");
showTxFee(approvedTx, ethPrice);
// Decode calldata (reuse decoded from above) // Decode calldata (reuse decoded from above)
const decodedEl = $("approve-tx-decoded"); const decodedEl = $("approve-tx-decoded");
if (decoded) { if (decoded) {
@@ -313,8 +271,8 @@ function showTxApproval(details) {
} }
// Always show raw data when present // Always show raw data when present
if (approvedTx.data && approvedTx.data !== "0x") { if (details.txParams.data && details.txParams.data !== "0x") {
$("approve-tx-data").textContent = approvedTx.data; $("approve-tx-data").textContent = details.txParams.data;
$("approve-tx-data-section").classList.remove("hidden"); $("approve-tx-data-section").classList.remove("hidden");
} else { } else {
$("approve-tx-data-section").classList.add("hidden"); $("approve-tx-data-section").classList.add("hidden");
@@ -325,11 +283,7 @@ function showTxApproval(details) {
showView("approve-tx"); showView("approve-tx");
attachCopyHandlers("view-approve-tx"); attachCopyHandlers("view-approve-tx");
gateOnWalletDefect( gateOnWalletDefect("approve-tx-error", "btn-approve-tx");
"approve-tx-error",
"btn-approve-tx",
details.approvedFrom,
);
} }
function decodeHexMessage(hex) { function decodeHexMessage(hex) {
@@ -388,12 +342,9 @@ function showSignApproval(details) {
const sp = details.signParams; const sp = details.signParams;
pendingSignParams = sp; pendingSignParams = sp;
pendingSignFrom = details.approvedFrom;
$("approve-sign-hostname").textContent = details.hostname; $("approve-sign-hostname").textContent = details.hostname;
$("approve-sign-from").innerHTML = approvalAddressHtml( $("approve-sign-from").innerHTML = approvalAddressHtml(sp.from);
details.approvedFrom,
);
const isTyped = const isTyped =
sp.method === "eth_signTypedData_v4" || sp.method === "eth_signTypedData_v4" ||
@@ -432,11 +383,7 @@ function showSignApproval(details) {
showView("approve-sign"); showView("approve-sign");
attachCopyHandlers("view-approve-sign"); attachCopyHandlers("view-approve-sign");
gateOnWalletDefect( gateOnWalletDefect("approve-sign-error", "btn-approve-sign");
"approve-sign-error",
"btn-approve-sign",
details.approvedFrom,
);
} }
function show(id) { function show(id) {
@@ -471,15 +418,11 @@ function show(id) {
let approvalId = null; let approvalId = null;
let pendingTxDetails = null; let pendingTxDetails = null;
// The exact objects shown to the user, kept so the popup signs what it // The exact parameters shown to the user, kept so the popup signs what it
// displayed rather than re-fetching or re-populating anything at approval // displayed rather than re-fetching anything at approval time. Both are
// time. All are repopulated by show() when the popup is closed and reopened. // repopulated by show() when the popup is closed and reopened.
let pendingTxParams = null; let pendingTxParams = null;
let pendingSignParams = null; let pendingSignParams = null;
// The address the approval was raised for. Signing uses this rather than the
// active address, so that an address switch since the approval fails here
// instead of producing a signature from an account the screen never named.
let pendingSignFrom = null;
// Approve buttons stay disabled and muted while the popup derives the key and // Approve buttons stay disabled and muted while the popup derives the key and
// signs, which is slow enough (Argon2id) that a double click is likely. // signs, which is slow enough (Argon2id) that a double click is likely.
@@ -494,13 +437,12 @@ function setSignButtonBusy(busy) {
} }
// Say so on the approval screen itself, and disable the approve button, when // Say so on the approval screen itself, and disable the approve button, when
// the address the approval was raised for belongs to a wallet whose keys // the active address belongs to a wallet whose keys cannot be derived. Without
// cannot be derived. Without this the screen would take a password and fail // this the screen would take a password and fail after deriving it. Reject
// after deriving it. Reject stays available; the wallet is not touched. // stays available; the wallet is not touched. Returns true when it gated.
// Returns true when it gated. function gateOnWalletDefect(errorId, buttonId) {
function gateOnWalletDefect(errorId, buttonId, address) { const active = findActiveWallet();
const owner = findWalletFor(address); const defect = active ? walletDefect(active.wallet) : null;
const defect = owner ? walletDefect(owner.wallet) : null;
if (!defect) return false; if (!defect) return false;
showError(errorId, defect.shortMessage); showError(errorId, defect.shortMessage);
$(buttonId).disabled = true; $(buttonId).disabled = true;
@@ -508,14 +450,12 @@ function gateOnWalletDefect(errorId, buttonId, address) {
return true; return true;
} }
// Locate the wallet and the address index owning an address. Returns null when // Locate the wallet and the address index owning the currently active
// no wallet holds it. Approvals look up the address they were raised for, not // address. Returns null when no wallet holds it.
// whichever address is active now: the approval named one account, and signing function findActiveWallet() {
// with another is what verification refuses.
function findWalletFor(address) {
for (const wallet of state.wallets) { for (const wallet of state.wallets) {
for (let i = 0; i < wallet.addresses.length; i++) { for (let i = 0; i < wallet.addresses.length; i++) {
if (wallet.addresses[i].address === address) { if (wallet.addresses[i].address === state.activeAddress) {
return { wallet, addrIndex: i }; return { wallet, addrIndex: i };
} }
} }
@@ -537,7 +477,7 @@ function clearSignPassword() {
hideError("approve-sign-error"); hideError("approve-sign-error");
} }
function init(_ctx) { function init(ctx) {
onViewLeave("approve-tx", clearTxPassword); onViewLeave("approve-tx", clearTxPassword);
onViewLeave("approve-sign", clearSignPassword); onViewLeave("approve-sign", clearSignPassword);
@@ -577,12 +517,12 @@ function init(_ctx) {
hideError("approve-tx-error"); hideError("approve-tx-error");
setTxButtonBusy(true); setTxButtonBusy(true);
const active = findWalletFor(pendingTxParams.from); const active = findActiveWallet();
if (!active) { if (!active) {
password = null; password = null;
showError( showError(
"approve-tx-error", "approve-tx-error",
"No wallet was found for the address this transaction was approved for.", "No wallet was found for the active address.",
); );
setTxButtonBusy(false); setTxButtonBusy(false);
return; return;
@@ -629,16 +569,15 @@ function init(_ctx) {
active.addrIndex, active.addrIndex,
decryptedSecret, decryptedSecret,
); );
// Sign the approved transaction exactly as it was displayed. The const provider = getProvider(state.rpcUrl);
// background populated it before this screen was drawn and checks const connected = signer.connect(provider);
// the artifact against it field for field, so there is nothing to // This is the sequence ethers' own sendTransaction() runs
// fill in here and no provider to fill it in from. The copy is // internally, so nonce, gas, fee and chain id population are
// because ethers may strip `from` off what it is handed, and the // identical to when the background did the signing.
// approval has to survive a retry intact; keeping `from` on it const populated =
// makes ethers refuse a key that is not the approved address. await connected.populateTransaction(pendingTxParams);
payload.rawSignedTx = await signer.signTransaction({ delete populated.from;
...pendingTxParams, payload.rawSignedTx = await connected.signTransaction(populated);
});
} catch (e) { } catch (e) {
payload.error = payload.error =
e.shortMessage || e.message || "Transaction signing failed."; e.shortMessage || e.message || "Transaction signing failed.";
@@ -687,12 +626,12 @@ function init(_ctx) {
hideError("approve-sign-error"); hideError("approve-sign-error");
setSignButtonBusy(true); setSignButtonBusy(true);
const active = findWalletFor(pendingSignFrom); const active = findActiveWallet();
if (!active) { if (!active) {
password = null; password = null;
showError( showError(
"approve-sign-error", "approve-sign-error",
"No wallet was found for the address this request was approved for.", "No wallet was found for the active address.",
); );
setSignButtonBusy(false); setSignButtonBusy(false);
return; return;

View File

@@ -2,12 +2,20 @@
// Shows transaction details, warnings, errors. On Sign & Send, // Shows transaction details, warnings, errors. On Sign & Send,
// reads inline password, decrypts secret, signs and broadcasts. // reads inline password, decrypts secret, signs and broadcasts.
const { parseEther, parseUnits, formatEther, Contract } = require("ethers"); const {
parseEther,
parseUnits,
formatEther,
formatUnits,
Contract,
} = require("ethers");
const { const {
$, $,
showError, showError,
hideError, hideError,
showView, showView,
showFlash,
flashCopyFeedback,
addressTitle, addressTitle,
escapeHtml, escapeHtml,
renderAddressHtml, renderAddressHtml,
@@ -15,7 +23,7 @@ const {
goBack, goBack,
onViewLeave, onViewLeave,
} = require("./helpers"); } = require("./helpers");
const { state } = require("../../shared/state"); const { state, currentNetwork } = require("../../shared/state");
const { getSignerForAddress } = require("../../shared/wallet"); const { getSignerForAddress } = require("../../shared/wallet");
const { decryptWithPassword } = require("../../shared/vault"); const { decryptWithPassword } = require("../../shared/vault");
const { formatUsd, getPrice } = require("../../shared/prices"); const { formatUsd, getPrice } = require("../../shared/prices");
@@ -391,7 +399,7 @@ function clearPassword() {
hideError("confirm-tx-password-error"); hideError("confirm-tx-password-error");
} }
function init(_ctx) { function init(ctx) {
onViewLeave("confirm-tx", clearPassword); onViewLeave("confirm-tx", clearPassword);
$("btn-confirm-send").addEventListener("click", async () => { $("btn-confirm-send").addEventListener("click", async () => {
@@ -413,11 +421,8 @@ function init(_ctx) {
wallet.encryptedSecret, wallet.encryptedSecret,
password, password,
); );
} catch { } catch (e) {
showError( showError("confirm-tx-password-error", "Wrong password.");
"confirm-tx-password-error",
"That password is incorrect. Please try again.",
);
return; return;
} }

View File

@@ -73,9 +73,8 @@ function init(_ctx) {
// Verify password against the wallet's encrypted data // Verify password against the wallet's encrypted data
try { try {
await decryptWithPassword(wallet.encryptedSecret, pw); await decryptWithPassword(wallet.encryptedSecret, pw);
} catch { } catch (_e) {
$("delete-wallet-flash").textContent = $("delete-wallet-flash").textContent = "Wrong password.";
"That password is incorrect. Please try again.";
$("delete-wallet-flash").style.visibility = "visible"; $("delete-wallet-flash").style.visibility = "visible";
btn.disabled = false; btn.disabled = false;
btn.classList.remove("text-muted"); btn.classList.remove("text-muted");

View File

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

View File

@@ -1,9 +1,12 @@
// Shared DOM helpers used by all views. // Shared DOM helpers used by all views.
const { isDebug } = require("../../shared/log"); const { isDebug } = require("../../shared/log");
const { formatUsd, getPrice } = require("../../shared/prices"); const {
formatUsd,
getPrice,
getAddressValueUsd,
} = require("../../shared/prices");
const { state, saveState, currentNetwork } = require("../../shared/state"); const { state, saveState, currentNetwork } = require("../../shared/state");
const { markViewRendered } = require("../viewRouter");
// When views are added, removed, or transitions between them change, // When views are added, removed, or transitions between them change,
// update the view-navigation documentation in README.md to match. // update the view-navigation documentation in README.md to match.
@@ -73,10 +76,6 @@ function showView(name) {
} }
clearFlash(); clearFlash();
state.currentView = name; state.currentView = name;
// A view's show() ends here, so this is where the Back path learns the
// view is no longer the blank template from index.html and must not be
// rendered a second time. See viewRouter.js.
markViewRendered(name);
saveState(); saveState();
updateDebugBanner(name); updateDebugBanner(name);
} }
@@ -112,19 +111,12 @@ function updateDebugBanner(viewName) {
} }
} }
// Callback that renders a view being navigated BACK onto. Set once by // Callback to re-render the main/home view when navigating back to it.
// index.js via setBackRenderer(), which routes the view through the same // Set once by index.js via setRenderMain().
// per-view render and data guards restoreView() uses. let _renderMain = null;
//
// It answers true when it took the navigation — the view is rendered and
// shown, or its backing data was gone and it fell back — and false for a
// view the popup does not render from persisted state. Those can only be
// on the stack from this page load, because the stack is filtered on load,
// so they have already been rendered and only need unhiding.
let _renderBack = null;
function setBackRenderer(fn) { function setRenderMain(fn) {
_renderBack = fn; _renderMain = fn;
} }
// Push the current view onto the navigation stack so goBack() can // Push the current view onto the navigation stack so goBack() can
@@ -144,11 +136,9 @@ function goBack() {
} else { } else {
target = "main"; target = "main";
} }
// A popped view is landed on, not navigated to. If the popup has been if (target === "main" && _renderMain) {
// closed and reopened since the view was pushed, nothing has ever _renderMain();
// rendered it in this page load and its template is still blank, so it }
// has to be rendered here rather than merely unhidden.
if (_renderBack && _renderBack(target)) return;
showView(target); showView(target);
} }
@@ -480,7 +470,7 @@ module.exports = {
showView, showView,
onViewLeave, onViewLeave,
updateDebugBanner, updateDebugBanner,
setBackRenderer, setRenderMain,
pushCurrentView, pushCurrentView,
goBack, goBack,
clearViewStack, clearViewStack,

View File

@@ -2,6 +2,7 @@ const {
$, $,
showView, showView,
showFlash, showFlash,
flashCopyFeedback,
balanceLinesForAddress, balanceLinesForAddress,
isoDate, isoDate,
timeAgo, timeAgo,

View File

@@ -57,7 +57,7 @@ function show() {
attachCopyHandlers("view-receive"); attachCopyHandlers("view-receive");
} }
function init(_ctx) { function init(ctx) {
$("btn-receive-copy").addEventListener("click", () => { $("btn-receive-copy").addEventListener("click", () => {
const addr = $("receive-address-block").dataset.full; const addr = $("receive-address-block").dataset.full;
if (addr) { if (addr) {

View File

@@ -4,6 +4,7 @@ const {
$, $,
showFlash, showFlash,
addressTitle, addressTitle,
escapeHtml,
renderAddressHtml, renderAddressHtml,
attachCopyHandlers, attachCopyHandlers,
goBack, goBack,
@@ -209,7 +210,7 @@ function init(_ctx) {
} }
resolvedTo = resolved; resolvedTo = resolved;
ensName = to; ensName = to;
} catch { } catch (e) {
showFlash("Failed to resolve ENS name."); showFlash("Failed to resolve ENS name.");
return; return;
} }

View File

@@ -9,11 +9,8 @@ const {
pushCurrentView, pushCurrentView,
} = require("./helpers"); } = require("./helpers");
const { applyTheme } = require("../theme"); const { applyTheme } = require("../theme");
const {
DUST_THRESHOLD_MESSAGE,
parseDustThresholdGwei,
} = require("../dustThreshold");
const { state, saveState, currentNetwork } = require("../../shared/state"); const { state, saveState, currentNetwork } = require("../../shared/state");
const { NETWORKS, SUPPORTED_CHAIN_IDS } = require("../../shared/networks");
const { onChainSwitch } = require("../../shared/chainSwitch"); const { onChainSwitch } = require("../../shared/chainSwitch");
const { log, debugFetch, setRuntimeDebug } = require("../../shared/log"); const { log, debugFetch, setRuntimeDebug } = require("../../shared/log");
const deleteWallet = require("./deleteWallet"); const deleteWallet = require("./deleteWallet");
@@ -332,14 +329,13 @@ function init(ctx) {
$("settings-dust-threshold").value = state.dustThresholdGwei; $("settings-dust-threshold").value = state.dustThresholdGwei;
$("settings-dust-threshold").addEventListener("change", async () => { $("settings-dust-threshold").addEventListener("change", async () => {
const val = parseDustThresholdGwei($("settings-dust-threshold").value); const raw = $("settings-dust-threshold").value.trim();
// Rejected input is never coerced. The field is put back to the const val = Number(raw);
// stored threshold so it never shows a value the wallet is not // 0 is accepted and means "hide nothing". Empty, negative,
// using, and the message says what the field wants so the snap-back // fractional and non-numeric input is rejected outright rather than
// is explained rather than silent. // coerced, and the field is put back to the stored threshold so it
if (val === null) { // never shows a value the wallet is not using.
showFlash(DUST_THRESHOLD_MESSAGE); if (raw !== "" && Number.isInteger(val) && val >= 0) {
} else {
state.dustThresholdGwei = val; state.dustThresholdGwei = val;
await saveState(); await saveState();
} }

View File

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

View File

@@ -23,6 +23,8 @@ const makeBlockie = require("ethereum-blockies-base64");
const { log, debugFetch } = require("../../shared/log"); const { log, debugFetch } = require("../../shared/log");
const { decodeCalldata } = require("./approval"); const { decodeCalldata } = require("./approval");
let ctx;
/** /**
* Determine a human-readable transaction type string from tx fields. * Determine a human-readable transaction type string from tx fields.
*/ */
@@ -164,7 +166,7 @@ function render() {
if (el) el.classList.add("hidden"); if (el) el.classList.add("hidden");
} }
loadFullTxDetails(tx.hash, tx.to); loadFullTxDetails(tx.hash, tx.to, tx.isContractCall);
const isoStr = isoDate(tx.timestamp); const isoStr = isoDate(tx.timestamp);
$("tx-detail-time").innerHTML = $("tx-detail-time").innerHTML =
@@ -272,7 +274,7 @@ function populateOnChainDetails(txData) {
} }
} }
async function loadFullTxDetails(txHash, toAddress) { async function loadFullTxDetails(txHash, toAddress, isContractCall) {
const section = $("tx-detail-calldata-section"); const section = $("tx-detail-calldata-section");
const actionEl = $("tx-detail-calldata-action"); const actionEl = $("tx-detail-calldata-action");
const detailsEl = $("tx-detail-calldata-details"); const detailsEl = $("tx-detail-calldata-details");
@@ -347,9 +349,8 @@ async function loadFullTxDetails(txHash, toAddress) {
} }
} }
// The ctx this view is initialized with is unused: this module is the leaf of
// the navigation, and the other views reach it through their own ctx.
function init(_ctx) { function init(_ctx) {
ctx = _ctx;
$("btn-tx-back").addEventListener("click", () => { $("btn-tx-back").addEventListener("click", () => {
goBack(); goBack();
}); });

View File

@@ -12,7 +12,7 @@ const {
clearViewStack, clearViewStack,
} = require("./helpers"); } = require("./helpers");
const { TOKEN_BY_ADDRESS } = require("../../shared/tokenList"); const { TOKEN_BY_ADDRESS } = require("../../shared/tokenList");
const { state, currentNetwork } = require("../../shared/state"); const { state, saveState, currentNetwork } = require("../../shared/state");
const { getProvider } = require("../../shared/balances"); const { getProvider } = require("../../shared/balances");
const { log } = require("../../shared/log"); const { log } = require("../../shared/log");
@@ -229,6 +229,10 @@ function tokenLabel(address) {
return t ? t.symbol : null; return t ? t.symbol : null;
} }
function etherscanTokenLink(address) {
return `${currentNetwork().explorerUrl}/token/${address}`;
}
function decodedDetailsHtml(decoded) { function decodedDetailsHtml(decoded) {
if (!decoded || !decoded.details) return ""; if (!decoded || !decoded.details) return "";
let html = `<div class="border border-border border-dashed p-2 mb-3">`; let html = `<div class="border border-border border-dashed p-2 mb-3">`;

View File

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

View File

@@ -7,13 +7,6 @@
// the signer from the artifact and checks it against the approval it is // the signer from the artifact and checks it against the approval it is
// holding before acting on it. All recovery is delegated to ethers. // holding before acting on it. All recovery is delegated to ethers.
// //
// What the artifact is checked against is the transaction the background
// populated and the popup displayed (see approvalTx.js), not the request the
// dApp made. The two differ in every field a dApp normally leaves out — nonce,
// gas limit, fees — and those are the fields the user reads off the approval
// screen, so comparing against the request would leave the numbers on screen
// vouched for by nothing.
//
// The check is an allowlist, in both directions, because a denylist cannot be // The check is an allowlist, in both directions, because a denylist cannot be
// correct against a transaction format that keeps gaining fields: // correct against a transaction format that keeps gaining fields:
// //
@@ -38,12 +31,14 @@
// never a warning: what the user approved is what gets broadcast, or nothing // never a warning: what the user approved is what gets broadcast, or nothing
// does. // does.
// //
// The approved transaction is required to fix every field its type serializes, // Fields the approval does not carry are not treated as zero. The popup
// so there is no "the approval did not say" branch to fall through: a quantity // populates nonce, gas limit, fee and chain id through populateTransaction()
// the approval does not carry is a refusal, because an artifact that cannot be // when the requesting page did not fix them, so there is no approved value to
// compared with what was displayed has not been checked. The chain id is // compare against; treating absent as zero would refuse every legitimate
// checked against the selected network as well as against the approval, which // transaction. Those fields are instead held to the absolute ceilings below,
// is what makes a cross-chain replay impossible. // and the chain id is always checked against the selected network rather than
// against the approval alone, which is what makes a cross-chain replay
// impossible.
// //
// Every failure message is a full sentence, because these strings are shown to // Every failure message is a full sentence, because these strings are shown to
// the user and returned to the dApp. // the user and returned to the dApp.
@@ -118,17 +113,6 @@ const FORBIDDEN_FIELDS = [
}, },
]; ];
// Absolute ceilings — a BACKSTOP, not the primary control.
//
// The primary control is equality: every field of the artifact is compared
// with the populated transaction the user was shown, so nothing the popup
// signs can differ from the screen. What equality cannot bound is the
// populated transaction itself, which is built from what the configured RPC
// node answered — a node that reports an absurd fee gets that fee displayed,
// and a user who does not read the fee line would approve it. These ceilings
// bound that, and they are therefore applied where the transaction is
// populated (approvalTx.js) as well as here.
//
// Above the block gas limit of every supported network (see networks.js), so // Above the block gas limit of every supported network (see networks.js), so
// no transaction that could ever be included is refused by it. // no transaction that could ever be included is refused by it.
const MAX_GAS_LIMIT = 100000000n; const MAX_GAS_LIMIT = 100000000n;
@@ -240,151 +224,40 @@ function normalizeData(v) {
return String(v).toLowerCase(); return String(v).toLowerCase();
} }
// How each field of an approved transaction is compared with the artifact. // Quantity fields the requesting page may fix in the approval. Each is
// There is an entry here for every field any allowed type serializes — a test // compared exactly when the approval carries it, and left to the ceilings
// pins that against SERIALIZED_FIELDS — so the comparison loop covers the // above when it does not.
// whole of what gets signed and cannot silently skip a field for want of a const APPROVED_QUANTITIES = [
// comparator. {
// key: "nonce",
// `kind` decides how the two sides are made comparable. A `quantity` must be
// fixed by the approval: it is one of the numbers on the approval screen, and
// an absent one means the artifact cannot be checked against what was
// displayed. `to`, `value`, `data` and `accessList` have canonical absent
// forms — contract creation, zero, "0x" and the empty list — so they are
// normalized on both sides instead.
const APPROVED_FIELDS = {
chainId: {
kind: "quantity",
label: "network",
message:
"The signed transaction is for a different network than the one that was approved.",
},
nonce: {
kind: "quantity",
label: "nonce", label: "nonce",
message: "The signed transaction does not carry the approved nonce.", message: "The signed transaction does not carry the approved nonce.",
}, },
gasLimit: { {
kind: "quantity", key: "gasLimit",
label: "gas limit", label: "gas limit",
message: message:
"The signed transaction does not carry the approved gas limit.", "The signed transaction does not carry the approved gas limit.",
}, },
gasPrice: { {
kind: "quantity", key: "gasPrice",
label: "gas price", label: "gas price",
message: message:
"The signed transaction does not carry the approved gas price.", "The signed transaction does not carry the approved gas price.",
}, },
maxFeePerGas: { {
kind: "quantity", key: "maxFeePerGas",
label: "maximum fee per gas", label: "maximum fee per gas",
message: message:
"The signed transaction does not carry the approved maximum fee per gas.", "The signed transaction does not carry the approved maximum fee per gas.",
}, },
maxPriorityFeePerGas: { {
kind: "quantity", key: "maxPriorityFeePerGas",
label: "maximum priority fee per gas", label: "maximum priority fee per gas",
message: message:
"The signed transaction does not carry the approved maximum priority fee per gas.", "The signed transaction does not carry the approved maximum priority fee per gas.",
}, },
to: { ];
kind: "address",
label: "recipient",
message:
"The signed transaction does not go to the approved recipient.",
},
value: {
kind: "value",
label: "value",
message: "The signed transaction does not carry the approved value.",
},
data: {
kind: "data",
label: "call data",
message:
"The signed transaction does not carry the approved call data.",
},
accessList: {
kind: "accessList",
label: "access list",
message:
"The signed transaction does not carry the approved access list.",
},
};
// Compare one field of the artifact with the approved transaction. A field
// with no entry in the table above is refused rather than skipped: the loop
// below runs over the fields the type serializes, so an unmatched key means
// something that gets signed has no comparator at all.
function assertFieldMatches(key, parsed, approvedTx) {
const field = APPROVED_FIELDS[key];
if (!field) {
throw refuse(
"The signed transaction carries a field this wallet cannot compare with the approval.",
);
}
switch (field.kind) {
case "quantity": {
if (!present(approvedTx[key])) {
throw refuse(
"The approved transaction fixes no " +
field.label +
", so the signed transaction cannot be checked" +
" against what was shown.",
);
}
const approved = normalizeQuantity(approvedTx[key], field.label);
if (normalizeQuantity(parsed[key], field.label) !== approved) {
throw refuse(field.message);
}
return;
}
case "address":
if (!sameAddress(parsed[key], approvedTx[key])) {
throw refuse(field.message);
}
return;
case "value":
if (normalizeValue(parsed[key]) !== normalizeValue(approvedTx[key]))
throw refuse(field.message);
return;
case "data":
if (normalizeData(parsed[key]) !== normalizeData(approvedTx[key]))
throw refuse(field.message);
return;
default:
if (
normalizeAccessList(parsed[key]) !==
normalizeAccessList(approvedTx[key])
) {
throw refuse(field.message);
}
}
}
// The ceilings, applied to a transaction that is either about to be displayed
// or about to be broadcast. See MAX_GAS_LIMIT above for what they are for:
// they bound what the RPC node can talk this wallet into showing the user,
// which is the one thing comparing the artifact with the screen cannot do.
function assertWithinCeilings(tx) {
if (
present(tx.gasLimit) &&
normalizeQuantity(tx.gasLimit, "gas limit") > MAX_GAS_LIMIT
) {
throw refuse(
"The signed transaction sets a gas limit no network this wallet supports can accept.",
);
}
for (const key of ["gasPrice", "maxFeePerGas", "maxPriorityFeePerGas"]) {
if (!present(tx[key])) continue;
if (normalizeQuantity(tx[key], "fee per gas") > MAX_FEE_PER_GAS) {
throw refuse(
"The signed transaction sets a fee per gas far above any plausible value.",
);
}
}
}
// Refuse a field only a transaction type this wallet does not sign can carry. // Refuse a field only a transaction type this wallet does not sign can carry.
// The type allowlist keeps these unreachable in production, which is exactly // The type allowlist keeps these unreachable in production, which is exactly
@@ -444,28 +317,10 @@ function assertCanonicalBytes(parsed, rawSignedTx) {
// signed by the address the approval was raised for, on the network that is // signed by the address the approval was raised for, on the network that is
// selected. Returns the parsed ethers Transaction on success, throws // selected. Returns the parsed ethers Transaction on success, throws
// otherwise. // otherwise.
// function verifySignedTx(rawSignedTx, txParams, expectedFrom, selectedChainId) {
// `approvedTx` is the populated transaction the approval screen displayed, and
// `expectedFrom` is the address that was active when the approval was raised —
// not whichever address is active now. An address switch between approval and
// signing therefore refuses here rather than producing a transaction from an
// account the approval did not name.
function verifySignedTx(
rawSignedTx,
approvedTx,
expectedFrom,
selectedChainId,
) {
if (typeof rawSignedTx !== "string" || !rawSignedTx.startsWith("0x")) { if (typeof rawSignedTx !== "string" || !rawSignedTx.startsWith("0x")) {
throw refuse("The signed transaction is missing or malformed."); throw refuse("The signed transaction is missing or malformed.");
} }
// Nothing to compare against is a refusal like any other: an approval that
// does not carry the transaction it displayed cannot vouch for one.
if (!approvedTx || typeof approvedTx !== "object") {
throw refuse(
"There is no approved transaction to check the signed transaction against.",
);
}
let parsed; let parsed;
try { try {
@@ -505,15 +360,46 @@ function verifySignedTx(
"The signed transaction is for a different network than the one that is selected.", "The signed transaction is for a different network than the one that is selected.",
); );
} }
if (
present(txParams.chainId) &&
parsed.chainId !== normalizeQuantity(txParams.chainId, "network")
) {
throw refuse(
"The signed transaction is for a different network than the one that was approved.",
);
}
// The approved fee mechanism, named before the type comparison below if (!sameAddress(parsed.to, txParams.to)) {
// subsumes it: the fee the user agreed to is only meaningful under the throw refuse(
// mechanism it was quoted in, and saying so is more use than "a different "The signed transaction does not go to the approved recipient.",
// transaction type". );
}
if (normalizeValue(parsed.value) !== normalizeValue(txParams.value)) {
throw refuse(
"The signed transaction does not carry the approved value.",
);
}
if (normalizeData(parsed.data) !== normalizeData(txParams.data)) {
throw refuse(
"The signed transaction does not carry the approved call data.",
);
}
if (
normalizeAccessList(parsed.accessList) !==
normalizeAccessList(txParams.accessList)
) {
throw refuse(
"The signed transaction does not carry the approved access list.",
);
}
// An approval that fixed EIP-1559 fees must not be signed as a legacy
// transaction, and vice versa: the fee the user agreed to is only
// meaningful under the mechanism it was quoted in.
const approvedEip1559 = const approvedEip1559 =
present(approvedTx.maxFeePerGas) || present(txParams.maxFeePerGas) ||
present(approvedTx.maxPriorityFeePerGas); present(txParams.maxPriorityFeePerGas);
const approvedLegacy = present(approvedTx.gasPrice); const approvedLegacy = present(txParams.gasPrice);
const signedEip1559 = parsed.type === 2; const signedEip1559 = parsed.type === 2;
if ( if (
(approvedEip1559 && !signedEip1559) || (approvedEip1559 && !signedEip1559) ||
@@ -524,32 +410,27 @@ function verifySignedTx(
); );
} }
// The type decides which fields are compared, so it is compared first and for (const field of APPROVED_QUANTITIES) {
// against the approval, not merely checked for membership of the if (!present(txParams[field.key])) continue;
// allowlist above. const approved = normalizeQuantity(txParams[field.key], field.label);
if (!present(approvedTx.type)) { if (normalizeQuantity(parsed[field.key], field.label) !== approved) {
throw refuse( throw refuse(field.message);
"The approved transaction fixes no transaction type, so the signed transaction cannot be checked against what was shown.", }
);
}
if (
BigInt(parsed.type) !==
normalizeQuantity(approvedTx.type, "transaction type")
) {
throw refuse(
"The signed transaction does not use the approved transaction type.",
);
} }
// Every field this type serializes, compared with the transaction the user if (parsed.gasLimit > MAX_GAS_LIMIT) {
// was shown. Driving the loop off SERIALIZED_FIELDS is what keeps this throw refuse(
// exhaustive: the same table decides what assertNothingUnchecked() rebuilds "The signed transaction sets a gas limit no network this wallet supports can accept.",
// from, so a field that gets signed and is not compared here cannot exist. );
for (const key of SERIALIZED_FIELDS[parsed.type]) { }
assertFieldMatches(key, parsed, approvedTx); for (const key of ["gasPrice", "maxFeePerGas", "maxPriorityFeePerGas"]) {
const fee = parsed[key];
if (fee !== null && fee !== undefined && fee > MAX_FEE_PER_GAS) {
throw refuse(
"The signed transaction sets a fee per gas far above any plausible value.",
);
}
} }
assertWithinCeilings(parsed);
assertNothingUnchecked(parsed); assertNothingUnchecked(parsed);
assertCanonicalBytes(parsed, rawSignedTx); assertCanonicalBytes(parsed, rawSignedTx);
@@ -623,10 +504,10 @@ function errorText(err) {
// approval. Anything else failed before the check ran and is retryable. // approval. Anything else failed before the check ran and is retryable.
// - broadcast: always terminal. A broadcast that throws after the node // - broadcast: always terminal. A broadcast that throws after the node
// accepted the transaction is routine (a timeout, a dropped response, a // accepted the transaction is routine (a timeout, a dropped response, a
// node answering "already known"), so the wallet cannot tell a transaction // node answering "already known"), and the popup's retry does not
// that never left from one that is already in the mempool. The approval is // re-broadcast these bytes — it re-runs populateTransaction() and signs
// spent and the requesting page has been given its outcome; a second // again at a freshly fetched pending-tag nonce. Retrying would therefore
// attempt against it would report a second outcome for one request. // put a second transaction on the chain for one approval.
function describeTxFailure(stage, err) { function describeTxFailure(stage, err) {
const error = errorText(err); const error = errorText(err);
const retryable = const retryable =
@@ -672,7 +553,6 @@ module.exports = {
assertNoForbiddenFields, assertNoForbiddenFields,
assertNothingUnchecked, assertNothingUnchecked,
assertCanonicalBytes, assertCanonicalBytes,
assertWithinCeilings,
sameAddress, sameAddress,
failureIsRetryable, failureIsRetryable,
describeTxFailure, describeTxFailure,
@@ -681,7 +561,6 @@ module.exports = {
ALLOWED_TX_TYPES, ALLOWED_TX_TYPES,
SERIALIZED_FIELDS, SERIALIZED_FIELDS,
FORBIDDEN_FIELDS, FORBIDDEN_FIELDS,
APPROVED_FIELDS,
TX_STAGE_SIGN, TX_STAGE_SIGN,
TX_STAGE_VERIFY, TX_STAGE_VERIFY,
TX_STAGE_BROADCAST, TX_STAGE_BROADCAST,

View File

@@ -66,12 +66,7 @@ async function fetchTokenBalances(address, blockscoutUrl, trackedTokens) {
const balances = []; const balances = [];
for (const item of items) { for (const item of items) {
// Case-insensitive: the token type is an explorer's label, not a if (item.token?.type !== "ERC-20") continue;
// protocol value, and an exact comparison silently drops a real
// holding if one ever writes "erc-20". Which types are admitted
// is unchanged.
const type = String(item.token?.type || "").toUpperCase();
if (type !== "ERC-20") continue;
const decimals = parseInt(item.token.decimals || "18", 10); const decimals = parseInt(item.token.decimals || "18", 10);
const bal = formatTokenBalance(item.value || "0", decimals); const bal = formatTokenBalance(item.value || "0", decimals);
if (bal === "0.0") continue; if (bal === "0.0") continue;

View File

@@ -21,7 +21,7 @@ async function refreshPrices() {
const fetched = await getTopTokenPrices(25); const fetched = await getTopTokenPrices(25);
Object.assign(prices, fetched); Object.assign(prices, fetched);
lastFetchedAt = now; lastFetchedAt = now;
} catch { } catch (e) {
// prices stay stale on error // prices stay stale on error
} }
} }

View File

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

View File

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

View File

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

View File

@@ -82,7 +82,7 @@ function toFixedPoint(value) {
if (text === "") return null; if (text === "") return null;
try { try {
return parseUnits(text, SCALE_DECIMALS); return parseUnits(text, SCALE_DECIMALS);
} catch { } catch (e) {
return null; return null;
} }
} }

View File

@@ -102,11 +102,6 @@ function decodeV2SwapExactIn(input) {
// Decode V2_SWAP_EXACT_OUT (command 0x09) input bytes. // Decode V2_SWAP_EXACT_OUT (command 0x09) input bytes.
// ABI: (address recipient, uint256 amountOut, uint256 amountInMax, // ABI: (address recipient, uint256 amountOut, uint256 amountInMax,
// address[] path, bool payerIsUser) // address[] path, bool payerIsUser)
//
// Nothing calls this: decode() has no 0x09 arm, so a V2 exact-out swap gets
// its command name and no token or amount detail. Kept for the fix, which is
// https://git.eeqj.de/sneak/AutistMask/issues/283.
// eslint-disable-next-line no-unused-vars
function decodeV2SwapExactOut(input) { function decodeV2SwapExactOut(input) {
try { try {
const d = coder.decode( const d = coder.decode(

View File

@@ -57,7 +57,7 @@ async function cryptoBackend() {
try { try {
await WebAssembly.compile(EMPTY_WASM_MODULE); await WebAssembly.compile(EMPTY_WASM_MODULE);
return "wasm"; return "wasm";
} catch { } catch (_) {
return "asmjs"; return "asmjs";
} }
} }

View File

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

View File

@@ -11,7 +11,6 @@ const {
assertNoForbiddenFields, assertNoForbiddenFields,
assertNothingUnchecked, assertNothingUnchecked,
assertCanonicalBytes, assertCanonicalBytes,
assertWithinCeilings,
sameAddress, sameAddress,
failureIsRetryable, failureIsRetryable,
describeTxFailure, describeTxFailure,
@@ -19,14 +18,12 @@ const {
ALLOWED_TX_TYPES, ALLOWED_TX_TYPES,
SERIALIZED_FIELDS, SERIALIZED_FIELDS,
FORBIDDEN_FIELDS, FORBIDDEN_FIELDS,
APPROVED_FIELDS,
TX_STAGE_SIGN, TX_STAGE_SIGN,
TX_STAGE_VERIFY, TX_STAGE_VERIFY,
TX_STAGE_BROADCAST, TX_STAGE_BROADCAST,
MAX_GAS_LIMIT, MAX_GAS_LIMIT,
MAX_FEE_PER_GAS, MAX_FEE_PER_GAS,
} = require("../src/shared/approvalVerify"); } = require("../src/shared/approvalVerify");
const { prepareApprovalTx } = require("../src/shared/approvalTx");
const { getSignerForAddress } = require("../src/shared/wallet"); const { getSignerForAddress } = require("../src/shared/wallet");
// Fixed test keys — never used for anything but these tests. // Fixed test keys — never used for anything but these tests.
@@ -45,10 +42,7 @@ const OTHER_RECIPIENT = "0xdAC17F958D2ee523a2206206994597C13D831ec7";
const SELECTED = "0x1"; const SELECTED = "0x1";
const SEPOLIA = "0xaa36a7"; const SEPOLIA = "0xaa36a7";
// Parameters as a dApp would supply them over eth_sendTransaction. Note what // Approved parameters as a dApp would supply them over eth_sendTransaction.
// is missing: nonce, gas limit and fees. The background fills those in before
// the approval screen is drawn, which is why the approval below and not this
// object is what every comparison runs against.
const TX_PARAMS = { const TX_PARAMS = {
from: signer.address, from: signer.address,
to: RECIPIENT, to: RECIPIENT,
@@ -67,8 +61,8 @@ const POPULATED = {
type: 2, type: 2,
}; };
// Build a signable transaction from a request. The background populates the // Build a signable transaction from approved params. The popup does the same
// same fields through populateTransaction(); here they are fixed so the test // thing through populateTransaction(); here the fields are fixed so the test
// needs no provider. `overrides` stands in for what a tampered or misbuilt // needs no provider. `overrides` stands in for what a tampered or misbuilt
// popup would put on the wire. // popup would put on the wire.
function txFor(params, overrides) { function txFor(params, overrides) {
@@ -81,21 +75,6 @@ function txFor(params, overrides) {
}; };
} }
// The populated transaction the approval screen displayed, which is the object
// the artifact is verified against. Built from the same fields as the signable
// transaction above, because that is the point: displayed and verified are one
// object.
function approvedFor(params, overrides) {
return {
from: signer.address,
accessList: [],
...txFor(params, overrides),
};
}
// The ordinary case: the dApp's request, populated.
const APPROVED = approvedFor(TX_PARAMS);
async function signedFor(params, withWallet, overrides) { async function signedFor(params, withWallet, overrides) {
return (withWallet || signer).signTransaction(txFor(params, overrides)); return (withWallet || signer).signTransaction(txFor(params, overrides));
} }
@@ -129,7 +108,7 @@ describe("sameAddress", () => {
describe("verifySignedTx", () => { describe("verifySignedTx", () => {
test("accepts the approved transaction signed by the approved address", async () => { test("accepts the approved transaction signed by the approved address", async () => {
const raw = await signedFor(TX_PARAMS); const raw = await signedFor(TX_PARAMS);
const parsed = verifySignedTx(raw, APPROVED, signer.address, SELECTED); const parsed = verifySignedTx(raw, TX_PARAMS, signer.address, SELECTED);
expect(parsed.from).toBe(signer.address); expect(parsed.from).toBe(signer.address);
expect(parsed.hash).toBe(Transaction.from(raw).hash); expect(parsed.hash).toBe(Transaction.from(raw).hash);
}); });
@@ -138,23 +117,23 @@ describe("verifySignedTx", () => {
const params = { to: undefined, value: "0x0", data: "0x600160005500" }; const params = { to: undefined, value: "0x0", data: "0x600160005500" };
const raw = await signedFor(params); const raw = await signedFor(params);
expect(() => expect(() =>
verifySignedTx(raw, approvedFor(params), signer.address, SELECTED), verifySignedTx(raw, params, signer.address, SELECTED),
).not.toThrow(); ).not.toThrow();
}); });
test("accepts an absent value as zero", async () => { test("accepts an absent value as zero", async () => {
const params = { to: RECIPIENT, data: "0x" }; const approved = { to: RECIPIENT, data: "0x" };
const raw = await signedFor(params); const raw = await signedFor(approved);
expect(() => expect(() =>
verifySignedTx(raw, approvedFor(params), signer.address, SELECTED), verifySignedTx(raw, approved, signer.address, SELECTED),
).not.toThrow(); ).not.toThrow();
}); });
test("accepts call data whose case differs from the approval", async () => { test("accepts call data whose case differs from the approval", async () => {
const params = { to: RECIPIENT, value: "0x0", data: "0xDEADBEEF" }; const approved = { to: RECIPIENT, value: "0x0", data: "0xDEADBEEF" };
const raw = await signedFor(params); const raw = await signedFor(approved);
expect(() => expect(() =>
verifySignedTx(raw, approvedFor(params), signer.address, SELECTED), verifySignedTx(raw, approved, signer.address, SELECTED),
).not.toThrow(); ).not.toThrow();
}); });
@@ -164,7 +143,7 @@ describe("verifySignedTx", () => {
to: OTHER_RECIPIENT, to: OTHER_RECIPIENT,
}); });
expect(() => expect(() =>
verifySignedTx(raw, APPROVED, signer.address, SELECTED), verifySignedTx(raw, TX_PARAMS, signer.address, SELECTED),
).toThrow(/approved recipient/); ).toThrow(/approved recipient/);
}); });
@@ -174,64 +153,47 @@ describe("verifySignedTx", () => {
value: "0x4563918244f40000", value: "0x4563918244f40000",
}); });
expect(() => expect(() =>
verifySignedTx(raw, APPROVED, signer.address, SELECTED), verifySignedTx(raw, TX_PARAMS, signer.address, SELECTED),
).toThrow(/approved value/); ).toThrow(/approved value/);
}); });
test("rejects substituted call data", async () => { test("rejects substituted call data", async () => {
const raw = await signedFor({ ...TX_PARAMS, data: "0xc0ffee" }); const raw = await signedFor({ ...TX_PARAMS, data: "0xc0ffee" });
expect(() => expect(() =>
verifySignedTx(raw, APPROVED, signer.address, SELECTED), verifySignedTx(raw, TX_PARAMS, signer.address, SELECTED),
).toThrow(/approved call data/); ).toThrow(/approved call data/);
}); });
test("rejects a transaction signed by a different address", async () => { test("rejects a transaction signed by a different address", async () => {
const raw = await signedFor(TX_PARAMS, other); const raw = await signedFor(TX_PARAMS, other);
expect(() => expect(() =>
verifySignedTx(raw, APPROVED, signer.address, SELECTED), verifySignedTx(raw, TX_PARAMS, signer.address, SELECTED),
).toThrow(/different address/); ).toThrow(/different address/);
}); });
// The address the approval named, not whichever address is active when the
// artifact comes back: an approval raised for one account cannot be
// satisfied by a signature from another, whatever the wallet switched to
// in between.
test("rejects a signature from the address that is active now", async () => {
const raw = await signedFor(TX_PARAMS, other);
expect(() =>
verifySignedTx(raw, APPROVED, signer.address, SELECTED),
).toThrow(/different address than the one that was approved/);
// The same artifact against the same approval, verified for the other
// address, is what would have happened had expectedFrom been read from
// the wallet's current state.
expect(() =>
verifySignedTx(raw, APPROVED, other.address, SELECTED),
).not.toThrow();
});
test("rejects an unsigned transaction", () => { test("rejects an unsigned transaction", () => {
const unsigned = Transaction.from(txFor(TX_PARAMS)).unsignedSerialized; const unsigned = Transaction.from(txFor(TX_PARAMS)).unsignedSerialized;
expect(() => expect(() =>
verifySignedTx(unsigned, APPROVED, signer.address, SELECTED), verifySignedTx(unsigned, TX_PARAMS, signer.address, SELECTED),
).toThrow(/no valid signature/); ).toThrow(/no valid signature/);
}); });
test("rejects a missing or malformed payload", () => { test("rejects a missing or malformed payload", () => {
expect(() => expect(() =>
verifySignedTx(undefined, APPROVED, signer.address, SELECTED), verifySignedTx(undefined, TX_PARAMS, signer.address, SELECTED),
).toThrow(/missing or malformed/); ).toThrow(/missing or malformed/);
expect(() => expect(() =>
verifySignedTx("nope", APPROVED, signer.address, SELECTED), verifySignedTx("nope", TX_PARAMS, signer.address, SELECTED),
).toThrow(/missing or malformed/); ).toThrow(/missing or malformed/);
expect(() => expect(() =>
verifySignedTx("0xc0ffee", APPROVED, signer.address, SELECTED), verifySignedTx("0xc0ffee", TX_PARAMS, signer.address, SELECTED),
).toThrow(/could not be decoded/); ).toThrow(/could not be decoded/);
}); });
test("every rejection message is a full sentence", async () => { test("every rejection message is a full sentence", async () => {
const raw = await signedFor({ ...TX_PARAMS, to: OTHER_RECIPIENT }); const raw = await signedFor({ ...TX_PARAMS, to: OTHER_RECIPIENT });
try { try {
verifySignedTx(raw, APPROVED, signer.address, SELECTED); verifySignedTx(raw, TX_PARAMS, signer.address, SELECTED);
throw new Error("expected a rejection"); throw new Error("expected a rejection");
} catch (e) { } catch (e) {
expect(e.message).toMatch(/^[A-Z].*\.$/); expect(e.message).toMatch(/^[A-Z].*\.$/);
@@ -239,113 +201,20 @@ describe("verifySignedTx", () => {
}); });
}); });
// The defect this file's approvals now stand against: for every field the dApp
// left out, the old comparison had nothing to compare and skipped the field,
// so the fee and the nonce the user read off the screen were checked by the
// ceilings alone. A populated approval fixes all of them, and an approval that
// does not fix one is a refusal rather than a pass.
describe("verifySignedTx against what was displayed", () => {
test("a fee differing from the displayed one is refused", async () => {
// Ten times the fee the screen showed, and far below the ceiling: the
// artifact the old comparison would have accepted.
const inflated = 20000000000n;
expect(inflated).toBeLessThan(MAX_FEE_PER_GAS);
const raw = await signedWith({ maxFeePerGas: inflated });
expect(() =>
verifySignedTx(raw, APPROVED, signer.address, SELECTED),
).toThrow(/approved maximum fee per gas/);
});
test("a nonce differing from the displayed one is refused", async () => {
const raw = await signedWith({ nonce: 8 });
expect(() =>
verifySignedTx(raw, APPROVED, signer.address, SELECTED),
).toThrow(/approved nonce/);
});
test("a gas limit differing from the displayed one is refused", async () => {
const raw = await signedWith({ gasLimit: 250000n });
expect(() =>
verifySignedTx(raw, APPROVED, signer.address, SELECTED),
).toThrow(/approved gas limit/);
});
test("an approval fixing no quantity is refused, not waved through", async () => {
const raw = await signedWith({});
for (const key of [
"chainId",
"nonce",
"gasLimit",
"maxFeePerGas",
"maxPriorityFeePerGas",
]) {
const incomplete = { ...APPROVED };
delete incomplete[key];
let thrown;
try {
verifySignedTx(raw, incomplete, signer.address, SELECTED);
throw new Error("expected a rejection for " + key);
} catch (e) {
thrown = e;
}
expect(thrown.message).toMatch(/fixes no /);
expect(thrown.approvalMismatch).toBe(true);
}
});
test("no approved transaction at all is refused", async () => {
const raw = await signedWith({});
for (const approved of [undefined, null, "0xdeadbeef"]) {
expect(() =>
verifySignedTx(raw, approved, signer.address, SELECTED),
).toThrow(/no approved transaction/);
}
});
test("an approval fixing no transaction type is refused", async () => {
const raw = await signedWith({});
const incomplete = { ...APPROVED };
delete incomplete.type;
expect(() =>
verifySignedTx(raw, incomplete, signer.address, SELECTED),
).toThrow(/fixes no transaction type/);
});
test("an artifact of a type other than the approved one is refused", async () => {
// Same fee mechanism on both sides, so only the type differs: a type 1
// artifact against a type 2 approval.
const approved = approvedFor(TX_PARAMS, {
type: 1,
gasPrice: 2000000000n,
maxFeePerGas: null,
maxPriorityFeePerGas: null,
});
const raw = await signedWith({
type: 0,
gasPrice: 2000000000n,
maxFeePerGas: null,
maxPriorityFeePerGas: null,
});
expect(() =>
verifySignedTx(raw, approved, signer.address, SELECTED),
).toThrow(/approved transaction type/);
});
});
// One case per consequential field: the field alone differs from what was // One case per consequential field: the field alone differs from what was
// approved, and that alone must refuse the signature. // approved, and that alone must refuse the signature.
describe("verifySignedTx field comparison", () => { describe("verifySignedTx field comparison", () => {
test("rejects a chain id that is not the selected network", async () => { test("rejects a chain id that is not the selected network", async () => {
const raw = await signedWith({ chainId: 11155111 }); const raw = await signedWith({ chainId: 11155111 });
expect(() => expect(() =>
verifySignedTx(raw, APPROVED, signer.address, SELECTED), verifySignedTx(raw, TX_PARAMS, signer.address, SELECTED),
).toThrow(/different network than the one that is selected/); ).toThrow(/different network than the one that is selected/);
}); });
test("rejects a chain id that is not the approved one", async () => { test("rejects a chain id that is not the approved one", async () => {
// Selected network and signed chain id agree; the approval was raised // Selected network and signed chain id agree; the dApp asked for a
// for a different chain, so the artifact is not what was approved. // different chain, so the artifact is not what was approved.
const approved = { ...APPROVED, chainId: SEPOLIA }; const approved = { ...TX_PARAMS, chainId: SEPOLIA };
const raw = await signedWith({}); const raw = await signedWith({});
expect(() => expect(() =>
verifySignedTx(raw, approved, signer.address, SELECTED), verifySignedTx(raw, approved, signer.address, SELECTED),
@@ -355,59 +224,58 @@ describe("verifySignedTx field comparison", () => {
test("refuses when the selected network is unknown", async () => { test("refuses when the selected network is unknown", async () => {
const raw = await signedWith({}); const raw = await signedWith({});
expect(() => expect(() =>
verifySignedTx(raw, APPROVED, signer.address, undefined), verifySignedTx(raw, TX_PARAMS, signer.address, undefined),
).toThrow(/selected network is unknown/); ).toThrow(/selected network is unknown/);
}); });
test("rejects a substituted nonce", async () => { test("rejects a substituted nonce", async () => {
const approved = { ...TX_PARAMS, nonce: 7 };
const raw = await signedWith({ nonce: 8 }); const raw = await signedWith({ nonce: 8 });
expect(() => expect(() =>
verifySignedTx(raw, APPROVED, signer.address, SELECTED), verifySignedTx(raw, approved, signer.address, SELECTED),
).toThrow(/approved nonce/); ).toThrow(/approved nonce/);
}); });
test("rejects a substituted gas limit", async () => { test("rejects a substituted gas limit", async () => {
const approved = { ...TX_PARAMS, gasLimit: "0x186a0" };
const raw = await signedWith({ gasLimit: 250000n }); const raw = await signedWith({ gasLimit: 250000n });
expect(() => expect(() =>
verifySignedTx(raw, APPROVED, signer.address, SELECTED), verifySignedTx(raw, approved, signer.address, SELECTED),
).toThrow(/approved gas limit/); ).toThrow(/approved gas limit/);
}); });
test("rejects a substituted maximum fee per gas", async () => { test("rejects a substituted maximum fee per gas", async () => {
const approved = { ...TX_PARAMS, maxFeePerGas: "0x77359400" };
const raw = await signedWith({ maxFeePerGas: 900000000000n }); const raw = await signedWith({ maxFeePerGas: 900000000000n });
expect(() => expect(() =>
verifySignedTx(raw, APPROVED, signer.address, SELECTED), verifySignedTx(raw, approved, signer.address, SELECTED),
).toThrow(/approved maximum fee per gas/); ).toThrow(/approved maximum fee per gas/);
}); });
test("rejects a substituted maximum priority fee per gas", async () => { test("rejects a substituted maximum priority fee per gas", async () => {
const approved = { ...TX_PARAMS, maxPriorityFeePerGas: "0x3b9aca00" };
const raw = await signedWith({ maxPriorityFeePerGas: 1500000000n }); const raw = await signedWith({ maxPriorityFeePerGas: 1500000000n });
expect(() => expect(() =>
verifySignedTx(raw, APPROVED, signer.address, SELECTED), verifySignedTx(raw, approved, signer.address, SELECTED),
).toThrow(/approved maximum priority fee per gas/); ).toThrow(/approved maximum priority fee per gas/);
}); });
test("rejects a substituted legacy gas price", async () => { test("rejects a substituted legacy gas price", async () => {
const approved = { ...TX_PARAMS, gasPrice: "0x77359400" };
const legacy = { const legacy = {
type: 0, type: 0,
gasPrice: 2000000000n, gasPrice: 9000000000n,
maxFeePerGas: null, maxFeePerGas: null,
maxPriorityFeePerGas: null, maxPriorityFeePerGas: null,
}; };
const approved = approvedFor(TX_PARAMS, legacy); const raw = await signedWith(legacy);
const raw = await signedWith({ ...legacy, gasPrice: 9000000000n });
expect(() => expect(() =>
verifySignedTx(raw, approved, signer.address, SELECTED), verifySignedTx(raw, approved, signer.address, SELECTED),
).toThrow(/approved gas price/); ).toThrow(/approved gas price/);
}); });
test("rejects an approved legacy fee signed as an EIP-1559 fee", async () => { test("rejects an approved legacy fee signed as an EIP-1559 fee", async () => {
const approved = approvedFor(TX_PARAMS, { const approved = { ...TX_PARAMS, gasPrice: "0x77359400" };
type: 0,
gasPrice: 2000000000n,
maxFeePerGas: null,
maxPriorityFeePerGas: null,
});
const raw = await signedWith({}); const raw = await signedWith({});
expect(() => expect(() =>
verifySignedTx(raw, approved, signer.address, SELECTED), verifySignedTx(raw, approved, signer.address, SELECTED),
@@ -415,6 +283,7 @@ describe("verifySignedTx field comparison", () => {
}); });
test("rejects an approved EIP-1559 fee signed as a legacy fee", async () => { test("rejects an approved EIP-1559 fee signed as a legacy fee", async () => {
const approved = { ...TX_PARAMS, maxFeePerGas: "0x77359400" };
const raw = await signedWith({ const raw = await signedWith({
type: 0, type: 0,
gasPrice: 2000000000n, gasPrice: 2000000000n,
@@ -422,72 +291,36 @@ describe("verifySignedTx field comparison", () => {
maxPriorityFeePerGas: null, maxPriorityFeePerGas: null,
}); });
expect(() => expect(() =>
verifySignedTx(raw, APPROVED, signer.address, SELECTED), verifySignedTx(raw, approved, signer.address, SELECTED),
).toThrow(/approved fee mechanism/); ).toThrow(/approved fee mechanism/);
}); });
// The ceilings are a backstop against what the RPC node can talk the
// wallet into populating and displaying, so they are checked against an
// approval that carries the absurd value too — equality alone would accept
// it, which is exactly what the ceiling is there for.
test("rejects a gas limit above anything a supported network accepts", async () => { test("rejects a gas limit above anything a supported network accepts", async () => {
const overrides = { gasLimit: MAX_GAS_LIMIT + 1n }; const raw = await signedWith({ gasLimit: MAX_GAS_LIMIT + 1n });
const raw = await signedWith(overrides);
expect(() => expect(() =>
verifySignedTx( verifySignedTx(raw, TX_PARAMS, signer.address, SELECTED),
raw,
approvedFor(TX_PARAMS, overrides),
signer.address,
SELECTED,
),
).toThrow(/gas limit no network this wallet supports/); ).toThrow(/gas limit no network this wallet supports/);
}); });
test("rejects an absurd fee per gas even when it was displayed", async () => { test("rejects an absurd fee per gas the approval never fixed", async () => {
const overrides = { const raw = await signedWith({
maxFeePerGas: MAX_FEE_PER_GAS + 1n, maxFeePerGas: MAX_FEE_PER_GAS + 1n,
maxPriorityFeePerGas: MAX_FEE_PER_GAS + 1n, maxPriorityFeePerGas: MAX_FEE_PER_GAS + 1n,
}; });
const raw = await signedWith(overrides);
expect(() => expect(() =>
verifySignedTx( verifySignedTx(raw, TX_PARAMS, signer.address, SELECTED),
raw,
approvedFor(TX_PARAMS, overrides),
signer.address,
SELECTED,
),
).toThrow(/fee per gas far above any plausible value/); ).toThrow(/fee per gas far above any plausible value/);
}); });
test("assertWithinCeilings is the same check on either side of the screen", () => {
expect(() =>
assertWithinCeilings({ gasLimit: MAX_GAS_LIMIT + 1n }),
).toThrow(/gas limit no network this wallet supports/);
for (const key of [
"gasPrice",
"maxFeePerGas",
"maxPriorityFeePerGas",
]) {
expect(() =>
assertWithinCeilings({ [key]: MAX_FEE_PER_GAS + 1n }),
).toThrow(/fee per gas far above any plausible value/);
}
expect(() =>
assertWithinCeilings({
gasLimit: MAX_GAS_LIMIT,
maxFeePerGas: MAX_FEE_PER_GAS,
maxPriorityFeePerGas: MAX_FEE_PER_GAS,
}),
).not.toThrow();
// Nothing to bound is not a failure: a type 2 approval carries no gas
// price, and a bare object must not be refused for lacking one.
expect(() => assertWithinCeilings({})).not.toThrow();
});
test("every field mismatch is a refusal, not a warning", async () => { test("every field mismatch is a refusal, not a warning", async () => {
const raw = await signedWith({ nonce: 8 }); const raw = await signedWith({ nonce: 8 });
try { try {
verifySignedTx(raw, APPROVED, signer.address, SELECTED); verifySignedTx(
raw,
{ ...TX_PARAMS, nonce: 7 },
signer.address,
SELECTED,
);
throw new Error("expected a rejection"); throw new Error("expected a rejection");
} catch (e) { } catch (e) {
expect(e.approvalMismatch).toBe(true); expect(e.approvalMismatch).toBe(true);
@@ -498,17 +331,17 @@ describe("verifySignedTx field comparison", () => {
// The transaction type decides which fields exist, so an artifact of a type // The transaction type decides which fields exist, so an artifact of a type
// this wallet does not sign carries consequences the approval cannot describe // this wallet does not sign carries consequences the approval cannot describe
// and none of the field comparisons can see. The refusal has to come from the // and none of the field comparisons can see. The approval used here is the
// type allowlist rather than from a field comparison, so these run against an // ordinary dApp shape with no fee fields — the common case, since
// approval whose every other field matches the artifact exactly. // populateTransaction() fills them — which is exactly the case the
// fee-mechanism check cannot catch by accident.
describe("verifySignedTx transaction type", () => { describe("verifySignedTx transaction type", () => {
const BARE_REQUEST = { const BARE_APPROVAL = {
from: signer.address, from: signer.address,
to: RECIPIENT, to: RECIPIENT,
value: "0x2386f26fc10000", value: "0x2386f26fc10000",
data: "0x", data: "0x",
}; };
const BARE_APPROVAL = approvedFor(BARE_REQUEST);
// An EIP-7702 artifact that pays the approved amount to the approved // An EIP-7702 artifact that pays the approved amount to the approved
// recipient and, in the same transaction, installs the attacker's code at // recipient and, in the same transaction, installs the attacker's code at
@@ -520,7 +353,7 @@ describe("verifySignedTx transaction type", () => {
chainId: 1, chainId: 1,
nonce: 8, nonce: 8,
}); });
const raw = await signedFor(BARE_REQUEST, signer, { const raw = await signedFor(BARE_APPROVAL, signer, {
type: 4, type: 4,
authorizationList: [authorization], authorizationList: [authorization],
}); });
@@ -533,7 +366,7 @@ describe("verifySignedTx transaction type", () => {
}); });
test("refuses a type 3 blob artifact", async () => { test("refuses a type 3 blob artifact", async () => {
const raw = await signedFor(BARE_REQUEST, signer, { const raw = await signedFor(BARE_APPROVAL, signer, {
type: 3, type: 3,
maxFeePerBlobGas: 1000000000n, maxFeePerBlobGas: 1000000000n,
blobVersionedHashes: ["0x01" + "ab".repeat(31)], blobVersionedHashes: ["0x01" + "ab".repeat(31)],
@@ -557,7 +390,7 @@ describe("verifySignedTx transaction type", () => {
chainId: 1, chainId: 1,
nonce: 8, nonce: 8,
}); });
const raw = await signedFor(BARE_REQUEST, signer, { const raw = await signedFor(BARE_APPROVAL, signer, {
type: 4, type: 4,
authorizationList: [authorization], authorizationList: [authorization],
}); });
@@ -571,45 +404,40 @@ describe("verifySignedTx transaction type", () => {
}); });
test("accepts a legacy type 0 transaction", async () => { test("accepts a legacy type 0 transaction", async () => {
const legacy = { const approved = { ...BARE_APPROVAL, gasPrice: "0x77359400" };
const raw = await signedFor(approved, signer, {
type: 0, type: 0,
gasPrice: 2000000000n, gasPrice: 2000000000n,
maxFeePerGas: null, maxFeePerGas: null,
maxPriorityFeePerGas: null, maxPriorityFeePerGas: null,
}; });
const raw = await signedFor(BARE_REQUEST, signer, legacy);
expect(() => expect(() =>
verifySignedTx( verifySignedTx(raw, approved, signer.address, SELECTED),
raw,
approvedFor(BARE_REQUEST, legacy),
signer.address,
SELECTED,
),
).not.toThrow(); ).not.toThrow();
}); });
test("accepts a type 1 transaction whose access list is the approved one", async () => { test("accepts a type 1 transaction whose access list is the approved one", async () => {
const overrides = { const accessList = [{ address: OTHER_RECIPIENT, storageKeys: [] }];
const approved = {
...BARE_APPROVAL,
gasPrice: "0x77359400",
accessList,
};
const raw = await signedFor(approved, signer, {
type: 1, type: 1,
gasPrice: 2000000000n, gasPrice: 2000000000n,
maxFeePerGas: null, maxFeePerGas: null,
maxPriorityFeePerGas: null, maxPriorityFeePerGas: null,
accessList: [{ address: OTHER_RECIPIENT, storageKeys: [] }], accessList,
}; });
const raw = await signedFor(BARE_REQUEST, signer, overrides);
expect(Transaction.from(raw).type).toBe(1); expect(Transaction.from(raw).type).toBe(1);
expect(() => expect(() =>
verifySignedTx( verifySignedTx(raw, approved, signer.address, SELECTED),
raw,
approvedFor(BARE_REQUEST, overrides),
signer.address,
SELECTED,
),
).not.toThrow(); ).not.toThrow();
}); });
test("refuses an access list the approval never carried", async () => { test("refuses an access list the approval never carried", async () => {
const raw = await signedFor(BARE_REQUEST, signer, { const raw = await signedFor(BARE_APPROVAL, signer, {
accessList: [{ address: OTHER_RECIPIENT, storageKeys: [] }], accessList: [{ address: OTHER_RECIPIENT, storageKeys: [] }],
}); });
expect(() => expect(() =>
@@ -618,11 +446,8 @@ describe("verifySignedTx transaction type", () => {
}); });
test("treats an absent access list and an empty one as the same thing", async () => { test("treats an absent access list and an empty one as the same thing", async () => {
const approved = { ...BARE_APPROVAL }; const approved = { ...BARE_APPROVAL, accessList: [] };
delete approved.accessList; const raw = await signedFor(BARE_APPROVAL, signer, {});
expect(approved.accessList).toBeUndefined();
const raw = await signedFor(BARE_REQUEST, signer, {});
expect(Transaction.from(raw).accessList).toEqual([]);
expect(() => expect(() =>
verifySignedTx(raw, approved, signer.address, SELECTED), verifySignedTx(raw, approved, signer.address, SELECTED),
).not.toThrow(); ).not.toThrow();
@@ -673,25 +498,6 @@ describe("verifySignedTx exhaustiveness", () => {
expect(exposed.filter((name) => !accounted.has(name))).toEqual([]); expect(exposed.filter((name) => !accounted.has(name))).toEqual([]);
}); });
// The comparison loop runs over the fields a type serializes and refuses a
// field it has no comparator for. That refusal is unreachable only while
// the table covers the whole of SERIALIZED_FIELDS, so the coverage is
// pinned here rather than assumed: adding a field to a type without a
// comparator would otherwise turn every transaction of that type into a
// refusal, and adding a comparator without the field would be a check that
// never runs.
test("every field a type serializes has a comparator", () => {
const serialized = new Set(
Object.values(SERIALIZED_FIELDS).flat().sort(),
);
expect([...serialized].filter((key) => !APPROVED_FIELDS[key])).toEqual(
[],
);
expect(
Object.keys(APPROVED_FIELDS).filter((key) => !serialized.has(key)),
).toEqual([]);
});
// The two layers behind the type allowlist. Nothing reachable through // The two layers behind the type allowlist. Nothing reachable through
// verifySignedTx can trip either of them while the allowlist holds — that // verifySignedTx can trip either of them while the allowlist holds — that
// is what they are for — so they are exercised directly rather than taken // is what they are for — so they are exercised directly rather than taken
@@ -747,30 +553,49 @@ describe("verifySignedTx exhaustiveness", () => {
test("an accepted artifact of each allowed type rebuilds byte for byte", async () => { test("an accepted artifact of each allowed type rebuilds byte for byte", async () => {
const shapes = [ const shapes = [
{ {
type: 0, approved: { ...TX_PARAMS, gasPrice: "0x77359400" },
gasPrice: 2000000000n, overrides: {
maxFeePerGas: null, type: 0,
maxPriorityFeePerGas: null, gasPrice: 2000000000n,
maxFeePerGas: null,
maxPriorityFeePerGas: null,
},
}, },
{ {
type: 1, approved: {
gasPrice: 2000000000n, ...TX_PARAMS,
maxFeePerGas: null, gasPrice: "0x77359400",
maxPriorityFeePerGas: null, accessList: [
accessList: [ {
{ address: RECIPIENT,
address: RECIPIENT, storageKeys: ["0x" + "11".repeat(32)],
storageKeys: ["0x" + "11".repeat(32)], },
}, ],
], },
overrides: {
type: 1,
gasPrice: 2000000000n,
maxFeePerGas: null,
maxPriorityFeePerGas: null,
accessList: [
{
address: RECIPIENT,
storageKeys: ["0x" + "11".repeat(32)],
},
],
},
}, },
{}, { approved: TX_PARAMS, overrides: {} },
]; ];
for (const overrides of shapes) { for (const shape of shapes) {
const raw = await signedFor(TX_PARAMS, signer, overrides); const raw = await signedFor(
shape.approved,
signer,
shape.overrides,
);
const parsed = verifySignedTx( const parsed = verifySignedTx(
raw, raw,
approvedFor(TX_PARAMS, overrides), shape.approved,
signer.address, signer.address,
SELECTED, SELECTED,
); );
@@ -819,7 +644,7 @@ describe("verifySignedTx canonical encoding", () => {
test("refuses an artifact that is not its own canonical encoding", async () => { test("refuses an artifact that is not its own canonical encoding", async () => {
const mutated = await nonCanonical(); const mutated = await nonCanonical();
expect(() => expect(() =>
verifySignedTx(mutated, APPROVED, signer.address, SELECTED), verifySignedTx(mutated, TX_PARAMS, signer.address, SELECTED),
).toThrow(/not encoded canonically/); ).toThrow(/not encoded canonically/);
}); });
@@ -834,7 +659,7 @@ describe("verifySignedTx canonical encoding", () => {
const raw = await signedWith({}); const raw = await signedWith({});
const upper = "0x" + raw.slice(2).toUpperCase(); const upper = "0x" + raw.slice(2).toUpperCase();
expect(() => expect(() =>
verifySignedTx(upper, APPROVED, signer.address, SELECTED), verifySignedTx(upper, TX_PARAMS, signer.address, SELECTED),
).not.toThrow(); ).not.toThrow();
}); });
}); });
@@ -845,33 +670,46 @@ describe("verifySignedTx normalization", () => {
test("accepts a decimal chain id against a hex selected network", async () => { test("accepts a decimal chain id against a hex selected network", async () => {
const raw = await signedWith({}); const raw = await signedWith({});
expect(() => expect(() =>
verifySignedTx(raw, APPROVED, signer.address, 1), verifySignedTx(raw, TX_PARAMS, signer.address, 1),
).not.toThrow(); ).not.toThrow();
expect(() => expect(() =>
verifySignedTx(raw, APPROVED, signer.address, "1"), verifySignedTx(raw, TX_PARAMS, signer.address, "1"),
).not.toThrow(); ).not.toThrow();
}); });
// The approved transaction crosses to the popup as JSON, so it comes back test("accepts an approved chain id written in hex", async () => {
// spelled in hex quantities rather than in the bigints it was populated
// with. None of that is tampering.
test("accepts an approval spelled as the wire spells it", async () => {
const raw = await signedWith({}); const raw = await signedWith({});
const wire = { const approved = { ...TX_PARAMS, chainId: "0x1" };
...APPROVED,
chainId: "0x1",
nonce: "0x7",
gasLimit: "0x186a0",
maxFeePerGas: "0x77359400",
maxPriorityFeePerGas: "0x3b9aca00",
value: "0x2386f26fc10000",
};
expect(() => expect(() =>
verifySignedTx(raw, wire, signer.address, SELECTED), verifySignedTx(raw, approved, signer.address, SELECTED),
).not.toThrow(); ).not.toThrow();
}); });
test("accepts quantities spelled as hex, decimal, number and bigint", async () => { test("accepts a hex nonce against a numeric one", async () => {
const raw = await signedWith({ nonce: 7 });
expect(() =>
verifySignedTx(
raw,
{ ...TX_PARAMS, nonce: "0x7" },
signer.address,
SELECTED,
),
).not.toThrow();
});
test("accepts a decimal gas limit against a hex one", async () => {
const raw = await signedWith({ gasLimit: 100000n });
expect(() =>
verifySignedTx(
raw,
{ ...TX_PARAMS, gasLimit: "100000" },
signer.address,
SELECTED,
),
).not.toThrow();
});
test("accepts fee fields spelled as hex, decimal, number and bigint", async () => {
const raw = await signedWith({}); const raw = await signedWith({});
for (const maxFee of [ for (const maxFee of [
"0x77359400", "0x77359400",
@@ -882,7 +720,7 @@ describe("verifySignedTx normalization", () => {
expect(() => expect(() =>
verifySignedTx( verifySignedTx(
raw, raw,
{ ...APPROVED, maxFeePerGas: maxFee }, { ...TX_PARAMS, maxFeePerGas: maxFee },
signer.address, signer.address,
SELECTED, SELECTED,
), ),
@@ -890,19 +728,24 @@ describe("verifySignedTx normalization", () => {
} }
}); });
test("accepts an approval that fixes no nonce, gas or fee at all", async () => {
const raw = await signedWith({});
expect(() =>
verifySignedTx(raw, TX_PARAMS, signer.address, SELECTED),
).not.toThrow();
});
test("accepts an approval whose recipient case differs", async () => { test("accepts an approval whose recipient case differs", async () => {
const raw = await signedWith({}); const raw = await signedWith({});
const approved = { ...APPROVED, to: RECIPIENT.toLowerCase() }; const approved = { ...TX_PARAMS, to: RECIPIENT.toLowerCase() };
expect(() => expect(() =>
verifySignedTx(raw, approved, signer.address, SELECTED), verifySignedTx(raw, approved, signer.address, SELECTED),
).not.toThrow(); ).not.toThrow();
}); });
test("accepts absent call data against 0x", async () => { test("accepts absent call data against 0x", async () => {
const params = { to: RECIPIENT, value: "0x0" }; const approved = { to: RECIPIENT, value: "0x0" };
const approved = approvedFor(params); const raw = await signedFor({ ...approved, data: "0x" });
delete approved.data;
const raw = await signedFor({ ...params, data: "0x" });
expect(() => expect(() =>
verifySignedTx(raw, approved, signer.address, SELECTED), verifySignedTx(raw, approved, signer.address, SELECTED),
).not.toThrow(); ).not.toThrow();
@@ -913,7 +756,7 @@ describe("verifySignedTx normalization", () => {
expect(() => expect(() =>
verifySignedTx( verifySignedTx(
raw, raw,
{ ...APPROVED, maxFeePerGas: "cheap" }, { ...TX_PARAMS, maxFeePerGas: "cheap" },
signer.address, signer.address,
SELECTED, SELECTED,
), ),
@@ -931,7 +774,7 @@ describe("verifySignedTx normalization", () => {
try { try {
verifySignedTx( verifySignedTx(
raw, raw,
{ ...APPROVED, value }, { ...TX_PARAMS, value },
signer.address, signer.address,
SELECTED, SELECTED,
); );
@@ -950,7 +793,7 @@ describe("verifySignedTx normalization", () => {
expect(() => expect(() =>
verifySignedTx( verifySignedTx(
raw, raw,
{ ...APPROVED, accessList: ["nope"] }, { ...TX_PARAMS, accessList: ["nope"] },
signer.address, signer.address,
SELECTED, SELECTED,
), ),
@@ -1091,7 +934,7 @@ describe("signing failure and retry", () => {
test("a mismatch spends the approval", async () => { test("a mismatch spends the approval", async () => {
const raw = await signedFor({ ...TX_PARAMS, to: OTHER_RECIPIENT }); const raw = await signedFor({ ...TX_PARAMS, to: OTHER_RECIPIENT });
try { try {
verifySignedTx(raw, APPROVED, signer.address, SELECTED); verifySignedTx(raw, TX_PARAMS, signer.address, SELECTED);
throw new Error("expected a rejection"); throw new Error("expected a rejection");
} catch (e) { } catch (e) {
expect(failureIsRetryable(e)).toBe(false); expect(failureIsRetryable(e)).toBe(false);
@@ -1164,7 +1007,7 @@ describe("signing failure and retry", () => {
const raw = await signedFor({ ...TX_PARAMS, to: OTHER_RECIPIENT }); const raw = await signedFor({ ...TX_PARAMS, to: OTHER_RECIPIENT });
let outcome; let outcome;
try { try {
verifySignedTx(raw, APPROVED, signer.address, SELECTED); verifySignedTx(raw, TX_PARAMS, signer.address, SELECTED);
} catch (e) { } catch (e) {
outcome = describeTxFailure(TX_STAGE_VERIFY, e); outcome = describeTxFailure(TX_STAGE_VERIFY, e);
} }
@@ -1218,13 +1061,12 @@ describe("signing failure and retry", () => {
}); });
}); });
// End-to-end over the messaging boundary, without a browser: the background // End-to-end over the messaging boundary, without a browser: run the exact
// populates the transaction, the object that produces crosses to the popup as // sequence the approval popup runs, then hand the artifact to the exact check
// JSON and is signed there, and the artifact goes back to the exact check the // the background runs before it broadcasts or resolves. Only what the popup
// background runs before it broadcasts. Only what each side puts on the wire is // puts on the wire is passed along, so this also pins down that the wire
// passed along, so this also pins down that the wire payloads are sufficient on // payload is sufficient on its own.
// their own. describe("popup signing sequence to background verification", () => {
describe("background population to popup signing to verification", () => {
// Stand-in for the JSON-RPC provider. populateTransaction only needs the // Stand-in for the JSON-RPC provider. populateTransaction only needs the
// nonce, the gas estimate, the network and the fee data. // nonce, the gas estimate, the network and the fee data.
const fakeProvider = { const fakeProvider = {
@@ -1242,52 +1084,33 @@ describe("background population to popup signing to verification", () => {
// through getSignerForAddress() the way the popup does. // through getSignerForAddress() the way the popup does.
const walletData = { type: "privkey" }; const walletData = { type: "privkey" };
// What the background does before the approval window opens. async function popupSignsTx(txParams) {
async function backgroundPrepares(txParams) {
const approvedTx = await prepareApprovalTx(
fakeProvider,
signer.address,
txParams,
);
// Extension messaging is JSON; the popup sees the other side of it.
return JSON.parse(JSON.stringify(approvedTx));
}
// What the popup does with it: signs it as given, populating nothing.
async function popupSigns(approvedTx) {
const localSigner = getSignerForAddress(walletData, 0, SIGNER_KEY); const localSigner = getSignerForAddress(walletData, 0, SIGNER_KEY);
return localSigner.signTransaction({ ...approvedTx }); const connected = localSigner.connect(fakeProvider);
const populated = await connected.populateTransaction(txParams);
delete populated.from;
return connected.signTransaction(populated);
} }
test("the populated transaction is what gets signed and what gets checked", async () => { test("a populated, signed transaction is accepted and broadcastable", async () => {
const approvedTx = await backgroundPrepares(TX_PARAMS); const rawSignedTx = await popupSignsTx(TX_PARAMS);
const rawSignedTx = await popupSigns(approvedTx);
const parsed = verifySignedTx( const parsed = verifySignedTx(
rawSignedTx, rawSignedTx,
approvedTx, TX_PARAMS,
signer.address, signer.address,
SELECTED, SELECTED,
); );
expect(parsed.nonce).toBe(7); expect(parsed.nonce).toBe(7);
expect(parsed.chainId).toBe(1n); expect(parsed.chainId).toBe(1n);
expect(parsed.gasLimit).toBe(21000n); expect(parsed.gasLimit).toBe(21000n);
expect(parsed.maxFeePerGas).toBe(2000000000n);
expect(parsed.to).toBe(RECIPIENT); expect(parsed.to).toBe(RECIPIENT);
expect(parsed.value).toBe(BigInt(TX_PARAMS.value)); expect(parsed.value).toBe(BigInt(TX_PARAMS.value));
expect(parsed.data).toBe(TX_PARAMS.data); expect(parsed.data).toBe(TX_PARAMS.data);
expect(parsed.signature).not.toBeNull(); expect(parsed.signature).not.toBeNull();
// Every field the screen shows, and the artifact, are the same numbers.
expect(BigInt(approvedTx.nonce)).toBe(BigInt(parsed.nonce));
expect(BigInt(approvedTx.gasLimit)).toBe(parsed.gasLimit);
expect(BigInt(approvedTx.maxFeePerGas)).toBe(parsed.maxFeePerGas);
expect(BigInt(approvedTx.maxPriorityFeePerGas)).toBe(
parsed.maxPriorityFeePerGas,
);
}); });
test("the wire payload carries no password and no secret", async () => { test("the wire payload carries no password and no secret", async () => {
const approvedTx = await backgroundPrepares(TX_PARAMS); const rawSignedTx = await popupSignsTx(TX_PARAMS);
const rawSignedTx = await popupSigns(approvedTx);
const payload = { const payload = {
type: "AUTISTMASK_TX_RESPONSE", type: "AUTISTMASK_TX_RESPONSE",
id: "test-approval-id", id: "test-approval-id",
@@ -1305,54 +1128,20 @@ describe("background population to popup signing to verification", () => {
expect(wire).not.toContain(SIGNER_KEY.slice(2).toLowerCase()); expect(wire).not.toContain(SIGNER_KEY.slice(2).toLowerCase());
}); });
// The popup is the component whose compromise this check exists to detect,
// so it is given the approved transaction and signs something else.
test("a popup that signs a different fee than it was given is refused", async () => {
const approvedTx = await backgroundPrepares(TX_PARAMS);
const rawSignedTx = await popupSigns({
...approvedTx,
maxFeePerGas: "0x3b9aca000",
});
expect(() =>
verifySignedTx(rawSignedTx, approvedTx, signer.address, SELECTED),
).toThrow(/approved maximum fee per gas/);
});
test("a popup that signs a different nonce than it was given is refused", async () => {
const approvedTx = await backgroundPrepares(TX_PARAMS);
const rawSignedTx = await popupSigns({ ...approvedTx, nonce: "0x8" });
expect(() =>
verifySignedTx(rawSignedTx, approvedTx, signer.address, SELECTED),
).toThrow(/approved nonce/);
});
test("the background rejects a transaction the popup did not approve", async () => { test("the background rejects a transaction the popup did not approve", async () => {
const approvedTx = await backgroundPrepares(TX_PARAMS); const rawSignedTx = await popupSignsTx({
const rawSignedTx = await popupSigns({ ...TX_PARAMS,
...approvedTx,
to: OTHER_RECIPIENT, to: OTHER_RECIPIENT,
}); });
expect(() => expect(() =>
verifySignedTx(rawSignedTx, approvedTx, signer.address, SELECTED), verifySignedTx(rawSignedTx, TX_PARAMS, signer.address, SELECTED),
).toThrow(/approved recipient/); ).toThrow(/approved recipient/);
}); });
test("the background rejects a transaction populated on another network", async () => { test("the background rejects a transaction populated on another network", async () => {
const approvedTx = await backgroundPrepares(TX_PARAMS); const rawSignedTx = await popupSignsTx(TX_PARAMS);
const rawSignedTx = await popupSigns(approvedTx);
expect(() => expect(() =>
verifySignedTx(rawSignedTx, approvedTx, signer.address, SEPOLIA), verifySignedTx(rawSignedTx, TX_PARAMS, signer.address, SEPOLIA),
).toThrow(/different network than the one that is selected/); ).toThrow(/different network than the one that is selected/);
}); });
// ethers refuses to sign for an address that is not the key's own, so a
// popup working from the approved object cannot quietly sign as whichever
// address the user has switched to.
test("the approved from stops the popup signing with another key", async () => {
const approvedTx = await backgroundPrepares(TX_PARAMS);
const otherSigner = getSignerForAddress(walletData, 0, OTHER_KEY);
await expect(
otherSigner.signTransaction({ ...approvedTx }),
).rejects.toThrow(/from address mismatch/);
});
}); });

View File

@@ -1,329 +0,0 @@
// Back after reopening the popup (#268).
//
// A reopened popup renders the wallet list and the one view it restores
// onto; every other view is still the blank static template from
// index.html. goBack() used to only unhide its target, so Back landed on
// that blank template for any view the popup had not rendered in this page
// load. These tests drive the real goBack() with the real router wired to
// recording view modules, so what is asserted is which view render ran —
// the thing that was missing.
//
// The rendering itself is asserted against the real popup in a real
// browser by tests/e2e/run.js; here the DOM is a stub, because goBack()
// only needs showView() to work.
const els = new Map();
function fakeEl() {
return {
textContent: "",
innerHTML: "",
classList: {
toggle() {},
add() {},
remove() {},
contains: () => false,
},
remove() {},
};
}
globalThis.document = {
getElementById(id) {
if (!els.has(id)) els.set(id, fakeEl());
return els.get(id);
},
};
// helpers.js pulls in state.js, which reads chrome.storage.local at load.
globalThis.chrome = {
storage: { local: { get: async () => ({}), set: async () => {} } },
};
const {
showView,
goBack,
setBackRenderer,
pushCurrentView,
} = require("../src/popup/views/helpers");
const {
makeBackRenderer,
markViewRendered,
resetRenderedViews,
} = require("../src/popup/viewRouter");
const { state } = require("../src/shared/state");
const ADDRESS = "0x1111111111111111111111111111111111111111";
const TOKEN = "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48";
let calls;
// Stand-ins for the view modules. Each records itself and then shows its
// view, which is what every real view render ends with — so the assertions
// can tell "rendered and shown" apart from "merely unhidden".
function recorder(name, view) {
return () => {
calls.push(name);
showView(view);
};
}
function makeViews() {
return {
main: { show: recorder("main", "main") },
addressDetail: { show: recorder("addressDetail", "address") },
addressToken: { show: recorder("addressToken", "address-token") },
receive: { show: recorder("receive", "receive") },
settings: { show: recorder("settings", "settings") },
settingsAddToken: {
show: recorder("settingsAddToken", "settings-addtoken"),
},
confirmTx: { restore: recorder("confirmTx", "confirm-tx") },
transactionDetail: {
render: recorder("transactionDetail", "transaction"),
},
txStatus: {
restoreWait: () => {
calls.push("waitTx");
showView("wait-tx");
return true;
},
renderSuccess: recorder("successTx", "success-tx"),
renderError: recorder("errorTx", "error-tx"),
},
};
}
// The popup as it stands just after a reopen: one wallet with one address,
// the view the popup restored onto, and the stack behind it.
//
// A reopen is a fresh page load, so the record of what has been rendered
// starts empty — that emptiness is what makes the Back path render at all.
// Returns the view modules so a test can drive forward navigation through
// the same recorders the router renders through.
function reopenedOn(view, stack, extra) {
calls = [];
resetRenderedViews();
state.wallets = [
{
name: "Wallet 1",
addresses: [{ address: ADDRESS, balance: "0", tokenBalances: [] }],
},
];
state.selectedWallet = 0;
state.selectedAddress = 0;
state.selectedToken = null;
state.viewData = null;
state.currentView = view;
state.viewStack = stack.slice();
Object.assign(state, extra || {});
// Restoring onto a view renders it, so the reopened popup has that one
// view on the page and nothing else.
markViewRendered(view);
const views = makeViews();
setBackRenderer(makeBackRenderer(state, views));
return views;
}
// The reproduction from the issue, step for step.
describe("Back onto a view the reopened popup never rendered", () => {
test("Back from settings renders the address detail underneath", () => {
reopenedOn("settings", ["main", "address"]);
goBack();
expect(calls).toEqual(["addressDetail"]);
expect(state.currentView).toBe("address");
expect(state.viewStack).toEqual(["main"]);
});
test("Back onto the token detail renders it", () => {
reopenedOn("settings", ["main", "address", "address-token"], {
selectedToken: TOKEN,
});
goBack();
expect(calls).toEqual(["addressToken"]);
expect(state.currentView).toBe("address-token");
});
test("Back onto Receive renders it", () => {
reopenedOn("settings", ["main", "address", "receive"]);
goBack();
expect(calls).toEqual(["receive"]);
expect(state.currentView).toBe("receive");
});
test("Back onto the transaction detail renders it", () => {
reopenedOn("settings", ["main", "transaction"], {
viewData: { tx: { hash: "0xdead" } },
});
goBack();
expect(calls).toEqual(["transactionDetail"]);
expect(state.currentView).toBe("transaction");
});
test("Back onto the transaction confirmation restores it", () => {
reopenedOn("settings", ["main", "confirm-tx"], {
viewData: { pendingTx: { to: ADDRESS, amount: "1" } },
});
goBack();
expect(calls).toEqual(["confirmTx"]);
expect(state.currentView).toBe("confirm-tx");
});
test("Back onto the success screen renders it", () => {
reopenedOn("settings", ["main", "success-tx"], {
viewData: { hash: "0xdead" },
});
goBack();
expect(calls).toEqual(["successTx"]);
expect(state.currentView).toBe("success-tx");
});
test("Back onto the failure screen renders it", () => {
reopenedOn("settings", ["main", "error-tx"], {
viewData: { message: "execution reverted" },
});
goBack();
expect(calls).toEqual(["errorTx"]);
expect(state.currentView).toBe("error-tx");
});
test("Back onto Home renders the wallet list", () => {
reopenedOn("settings", ["main"]);
goBack();
expect(calls).toEqual(["main"]);
expect(state.currentView).toBe("main");
});
test("Back with an empty stack renders Home", () => {
reopenedOn("settings", []);
goBack();
expect(calls).toEqual(["main"]);
expect(state.currentView).toBe("main");
});
});
// The guards are restoreView()'s, so a popped view whose backing data is
// gone lands on Home rather than on an empty template.
describe("Back onto a view whose backing data is gone", () => {
test("the token detail with no token selected falls back to Home", () => {
reopenedOn("settings", ["main", "address-token"]);
goBack();
expect(calls).toEqual(["main"]);
expect(state.currentView).toBe("main");
});
test("the transaction detail with no transaction falls back to Home", () => {
reopenedOn("settings", ["main", "transaction"]);
goBack();
expect(calls).toEqual(["main"]);
expect(state.currentView).toBe("main");
});
test("the confirmation with no pending transaction falls back to Home", () => {
reopenedOn("settings", ["main", "confirm-tx"]);
goBack();
expect(calls).toEqual(["main"]);
expect(state.currentView).toBe("main");
});
test("an address view with no address selected falls back to Home", () => {
reopenedOn("settings", ["main", "receive"], {
selectedAddress: null,
});
goBack();
expect(calls).toEqual(["main"]);
expect(state.currentView).toBe("main");
});
test("the success screen with no transaction hash falls back to Home", () => {
reopenedOn("settings", ["main", "success-tx"]);
goBack();
expect(calls).toEqual(["main"]);
expect(state.currentView).toBe("main");
});
test("the failure screen with no message falls back to Home", () => {
reopenedOn("settings", ["main", "error-tx"]);
goBack();
expect(calls).toEqual(["main"]);
expect(state.currentView).toBe("main");
});
test("a wait that can no longer be resumed falls back to Home", () => {
reopenedOn("settings", ["main", "wait-tx"]);
const views = makeViews();
views.txStatus.restoreWait = () => false;
setBackRenderer(makeBackRenderer(state, views));
goBack();
expect(calls).toEqual(["main"]);
expect(state.currentView).toBe("main");
});
});
// Forward navigation renders as it goes, and a second render would re-fetch
// and clobber whatever the view holds — an unsaved edit, a request in
// flight. So the Back path renders only a view this page load has never
// rendered, and merely unhides every other one: the views it does not
// render from persisted state, and the views already on the page.
describe("what the Back path leaves alone", () => {
test("forward navigation renders nothing by itself", () => {
reopenedOn("address", ["main"]);
pushCurrentView();
showView("send");
expect(calls).toEqual([]);
expect(state.viewStack).toEqual(["main", "address"]);
});
test("Back onto a live-session view only unhides it", () => {
reopenedOn("confirm-tx", ["main", "address", "send"]);
goBack();
expect(calls).toEqual([]);
expect(state.currentView).toBe("send");
});
test("Back renders its target exactly once", () => {
reopenedOn("settings", ["main", "address"]);
goBack();
expect(calls.filter((c) => c === "addressDetail")).toHaveLength(1);
});
test("Back onto a view this page load already rendered only unhides it", () => {
const views = reopenedOn("main", []);
pushCurrentView();
views.addressDetail.show();
pushCurrentView();
views.settings.show();
calls = [];
goBack();
expect(calls).toEqual([]);
expect(state.currentView).toBe("address");
});
// The unit mirror of the regression the browser suite pins: Settings
// reassigns its fields from persisted state on every render, so a
// re-render on the way back discards an edit the user has not saved.
test("Back onto Settings visited earlier in this page load does not re-render it", () => {
const views = reopenedOn("main", []);
pushCurrentView();
views.settings.show();
pushCurrentView();
views.settingsAddToken.show();
calls = [];
goBack();
expect(calls).toEqual([]);
expect(state.currentView).toBe("settings");
});
// Home is the deliberate exception, unchanged from the popup's
// behaviour before the router existed: it re-renders on every Back so
// the wallet list reflects what changed while the user was away.
test("Back onto Home renders it again even when it is already on the page", () => {
const views = reopenedOn("main", []);
pushCurrentView();
views.addressDetail.show();
calls = [];
goBack();
expect(calls).toEqual(["main"]);
expect(state.currentView).toBe("main");
});
});

View File

@@ -8,24 +8,16 @@
// already saw — which means the entry being present is not by itself proof // already saw — which means the entry being present is not by itself proof
// that no attempt is running. A second response carrying the same id (a // that no attempt is running. A second response carrying the same id (a
// reloaded approval window re-rendering a live Approve button, a popup that // reloaded approval window re-rendering a live Approve button, a popup that
// emits the message twice) must not start a second verify and broadcast: the // emits the message twice) must not start a second verify and broadcast: with
// same approved transaction signed twice verifies twice, and the transfer // the ordinary dApp approval shape the page fixes no nonce, so two artifacts
// would go out twice. // signed at different nonces both verify, and the approved transfer would go
// // out twice.
// It also covers what the approval is verified against. The approval now
// carries the transaction the background populated and the screen displayed,
// and the address that was active when it was raised — so a fee, a nonce or an
// address that moved between approval and signing is refused rather than
// signed.
const { Network, Wallet } = require("ethers"); const { Wallet } = require("ethers");
const SIGNER_KEY = const SIGNER_KEY =
"0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d"; "0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d";
const OTHER_KEY =
"0x5de4111afa1a4b94908f83103eb1f1706367c2e68ca870fc3fb9a804cdab365a";
const signer = new Wallet(SIGNER_KEY); const signer = new Wallet(SIGNER_KEY);
const other = new Wallet(OTHER_KEY);
const RECIPIENT = "0x66133E8ea0f5D1d612D2502a968757D1048c214a"; const RECIPIENT = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
const ORIGIN = "https://dapp.example"; const ORIGIN = "https://dapp.example";
@@ -41,16 +33,9 @@ const TX_PARAMS = {
data: "0x", data: "0x",
}; };
// The nonce the stubbed node reports, and so the nonce the background // The fields the popup's populateTransaction() would fill in. The nonce is a
// populates the approval with. // parameter because the duplicate case turns on the two artifacts differing
const NONCE = 7; // in exactly the field nothing constrains.
// "Hello AutistMask" as the hex string a dApp passes to personal_sign.
const MESSAGE = "0x48656c6c6f204175746973744d61736b";
// The transaction the background populates and the approval screen displays.
// The nonce is a parameter because the duplicate case turns on two artifacts
// differing in a field the dApp fixed nothing for.
function populated(nonce) { function populated(nonce) {
return { return {
type: 2, type: 2,
@@ -65,26 +50,8 @@ function populated(nonce) {
}; };
} }
function signedAtNonce(nonce, withWallet) { function signedAtNonce(nonce) {
return (withWallet || signer).signTransaction(populated(nonce)); return signer.signTransaction(populated(nonce));
}
// The node the background populates against. Its answers are the numbers the
// approval screen shows, so they are also the numbers every artifact below is
// signed at.
function fakeProvider(broadcastTransaction, overrides) {
return {
broadcastTransaction,
getNetwork: async () => Network.from(1),
getTransactionCount: async () => NONCE,
estimateGas: async () => 100000n,
getFeeData: async () => ({
gasPrice: 2000000000n,
maxFeePerGas: 2000000000n,
maxPriorityFeePerGas: 1000000000n,
}),
...(overrides || {}),
};
} }
// A promise whose settlement the test controls, so a broadcast can be held in // A promise whose settlement the test controls, so a broadcast can be held in
@@ -118,7 +85,7 @@ function loadBackground(options) {
currentNetwork: () => ({ chainId: "0x1" }), currentNetwork: () => ({ chainId: "0x1" }),
})); }));
jest.doMock("../src/shared/balances", () => ({ jest.doMock("../src/shared/balances", () => ({
getProvider: () => fakeProvider(broadcastTransaction, opts.provider), getProvider: () => ({ broadcastTransaction }),
refreshBalances: jest.fn(async () => {}), refreshBalances: jest.fn(async () => {}),
})); }));
jest.doMock("../src/shared/phishingDomains", () => ({ jest.doMock("../src/shared/phishingDomains", () => ({
@@ -204,7 +171,7 @@ function loadBackground(options) {
// Raise a pending transaction approval the way a dApp does, and dig the // Raise a pending transaction approval the way a dApp does, and dig the
// approval id back out of the popup URL the background opened. // approval id back out of the popup URL the background opened.
function requestTx(txParams) { function requestTx() {
let rpcResult = null; let rpcResult = null;
const sendResponse = jest.fn((r) => { const sendResponse = jest.fn((r) => {
rpcResult = r; rpcResult = r;
@@ -213,7 +180,7 @@ function loadBackground(options) {
{ {
type: "AUTISTMASK_RPC", type: "AUTISTMASK_RPC",
method: "eth_sendTransaction", method: "eth_sendTransaction",
params: [txParams || TX_PARAMS], params: [TX_PARAMS],
}, },
{ origin: ORIGIN }, { origin: ORIGIN },
sendResponse, sendResponse,
@@ -224,30 +191,6 @@ function loadBackground(options) {
}; };
} }
// The same for a message-signing approval, which pins the signing address
// at approval time in exactly the same way.
function requestSign(from) {
let rpcResult = null;
messageListener(
{
type: "AUTISTMASK_RPC",
method: "personal_sign",
params: [MESSAGE, from || signer.address],
},
{ origin: ORIGIN },
(r) => {
rpcResult = r;
},
);
return {
id: () =>
new URL(created[created.length - 1].url).searchParams.get(
"approval",
),
result: () => rpcResult,
};
}
// The user closes the approval popup. `created` is index-aligned with the // The user closes the approval popup. `created` is index-aligned with the
// ids the window stub hands back, so window 1 is the first popup opened. // ids the window stub hands back, so window 1 is the first popup opened.
function closeWindow(windowId) { function closeWindow(windowId) {
@@ -257,27 +200,18 @@ function loadBackground(options) {
return { return {
send, send,
requestTx, requestTx,
requestSign,
closeWindow, closeWindow,
broadcastTransaction, broadcastTransaction,
loadState, loadState,
created, created,
removed, removed,
// The user switching account in the toolbar popup, as the background
// sees it: the persisted active address changes underneath a pending
// approval.
setActiveAddress: (address) => {
persisted.activeAddress = address;
},
fromPopup: { url: EXT_URL + "src/popup/index.html" }, fromPopup: { url: EXT_URL + "src/popup/index.html" },
}; };
} }
// Let the handler's promise chain run to the next suspension point. Raising a // Let the handler's promise chain run to the next suspension point.
// transaction approval now populates it against the node first, which is
// several awaits deep before the window is opened.
async function settle() { async function settle() {
for (let i = 0; i < 50; i++) await Promise.resolve(); for (let i = 0; i < 10; i++) await Promise.resolve();
} }
afterEach(() => { afterEach(() => {
@@ -310,11 +244,9 @@ describe("one approval, one broadcast", () => {
await settle(); await settle();
expect(bg.broadcastTransaction).toHaveBeenCalledTimes(1); expect(bg.broadcastTransaction).toHaveBeenCalledTimes(1);
// A reloaded approval window signs the same approval again, at another // A reloaded approval window signs the same approval again. Nothing
// nonce. The claim is taken before anything is verified, so what this // in the approval fixes a nonce, so this artifact verifies just as
// asserts is the interlock and not the nonce comparison: the refusal // well as the first one.
// below is the claim's own message, which a verification failure does
// not produce.
const second = bg.send( const second = bg.send(
{ {
type: "AUTISTMASK_TX_RESPONSE", type: "AUTISTMASK_TX_RESPONSE",
@@ -444,319 +376,6 @@ describe("one approval, one broadcast", () => {
}); });
}); });
// The approval carries the transaction the user was shown and the address it
// was raised for, and the artifact is checked against both. Every case here is
// one the old comparison — against the dApp's request, for the address that is
// active now — would have broadcast.
describe("what the approval is verified against", () => {
// The approval screen showed the populated fee. An artifact at ten times
// that fee, still far below the ceilings, is what the ceilings alone could
// not catch.
test("a fee differing from the displayed one is refused, not sent", async () => {
const bg = loadBackground();
const pending = bg.requestTx();
await settle();
const id = pending.id();
const raw = await signer.signTransaction({
...populated(NONCE),
maxFeePerGas: 20000000000n,
});
const answer = bg.send(
{
type: "AUTISTMASK_TX_RESPONSE",
id,
approved: true,
rawSignedTx: raw,
},
{ url: bg.fromPopup.url },
);
await settle();
expect(bg.broadcastTransaction).not.toHaveBeenCalled();
expect(answer.sendResponse).toHaveBeenCalledWith(
expect.objectContaining({
error: expect.stringMatching(/approved maximum fee per gas/),
retryable: false,
stage: "verify",
}),
);
expect(pending.result()).toEqual({
error: {
message: expect.stringMatching(/approved maximum fee per gas/),
},
});
});
test("a nonce differing from the displayed one is refused, not sent", async () => {
const bg = loadBackground();
const pending = bg.requestTx();
await settle();
const id = pending.id();
const answer = bg.send(
{
type: "AUTISTMASK_TX_RESPONSE",
id,
approved: true,
rawSignedTx: await signedAtNonce(NONCE + 1),
},
{ url: bg.fromPopup.url },
);
await settle();
expect(bg.broadcastTransaction).not.toHaveBeenCalled();
expect(answer.sendResponse).toHaveBeenCalledWith(
expect.objectContaining({
error: expect.stringMatching(/approved nonce/),
retryable: false,
stage: "verify",
}),
);
});
// The address switch. The approval named one account; the wallet is on
// another by the time the artifact arrives. Both halves are covered: the
// popup signing as the account that is active now, and the popup correctly
// signing as the approved account while the wallet has moved on.
test("an artifact signed by the address that is active now is refused", async () => {
const bg = loadBackground();
const pending = bg.requestTx();
await settle();
const id = pending.id();
bg.setActiveAddress(other.address);
const answer = bg.send(
{
type: "AUTISTMASK_TX_RESPONSE",
id,
approved: true,
rawSignedTx: await signedAtNonce(NONCE, other),
},
{ url: bg.fromPopup.url },
);
await settle();
expect(bg.broadcastTransaction).not.toHaveBeenCalled();
expect(answer.sendResponse).toHaveBeenCalledWith(
expect.objectContaining({ retryable: false, stage: "verify" }),
);
expect(pending.result()).toEqual({
error: { message: expect.stringMatching(/active address changed/) },
});
});
test("an address switch refuses even the correctly signed artifact", async () => {
const bg = loadBackground();
const pending = bg.requestTx();
await settle();
const id = pending.id();
bg.setActiveAddress(other.address);
const answer = bg.send(
{
type: "AUTISTMASK_TX_RESPONSE",
id,
approved: true,
rawSignedTx: await signedAtNonce(NONCE),
},
{ url: bg.fromPopup.url },
);
await settle();
expect(bg.broadcastTransaction).not.toHaveBeenCalled();
expect(answer.sendResponse).toHaveBeenCalledWith(
expect.objectContaining({
error: expect.stringMatching(/active address changed/),
retryable: false,
stage: "verify",
}),
);
// A refusal, so the approval is spent: the same artifact offered again
// finds nothing to answer.
const retry = bg.send(
{
type: "AUTISTMASK_TX_RESPONSE",
id,
approved: true,
rawSignedTx: await signedAtNonce(NONCE),
},
{ url: bg.fromPopup.url },
);
await settle();
expect(retry.sendResponse).not.toHaveBeenCalled();
expect(bg.broadcastTransaction).not.toHaveBeenCalled();
});
test("a switch back to the approved address still sends", async () => {
const bg = loadBackground();
const pending = bg.requestTx();
await settle();
const id = pending.id();
bg.setActiveAddress(other.address);
bg.setActiveAddress(signer.address);
bg.broadcastTransaction.mockResolvedValue({ hash: "0xfeed" });
bg.send(
{
type: "AUTISTMASK_TX_RESPONSE",
id,
approved: true,
rawSignedTx: await signedAtNonce(NONCE),
},
{ url: bg.fromPopup.url },
);
await settle();
expect(bg.broadcastTransaction).toHaveBeenCalledTimes(1);
expect(pending.result()).toEqual({ result: "0xfeed" });
});
// The popup is handed the populated transaction and the address it is for,
// and nothing else it would have to fetch or decide.
test("the popup is given the transaction it is to sign", async () => {
const bg = loadBackground();
const pending = bg.requestTx();
await settle();
const details = bg.send(
{ type: "AUTISTMASK_GET_APPROVAL", id: pending.id() },
{ url: bg.fromPopup.url },
);
const shown = details.sendResponse.mock.calls[0][0];
expect(shown.type).toBe("tx");
expect(shown.approvedFrom).toBe(signer.address);
expect(shown.approvedTx).toEqual({
type: 2,
from: signer.address,
chainId: "0x1",
nonce: "0x7",
gasLimit: "0x186a0",
maxFeePerGas: "0x77359400",
maxPriorityFeePerGas: "0x3b9aca00",
to: RECIPIENT,
value: TX_PARAMS.value,
data: "0x",
accessList: [],
});
});
// A request naming an account the wallet is not on is refused outright
// rather than signed as whichever account is active.
test("a request from another address raises no approval at all", async () => {
const bg = loadBackground();
const pending = bg.requestTx({ ...TX_PARAMS, from: other.address });
await settle();
expect(pending.result()).toEqual({
error: {
code: 4100,
message: expect.stringMatching(/not the active one/),
},
});
expect(bg.created).toEqual([]);
});
// Message signing pins the address the same way, and refuses the same way.
// A signature is not a transaction, but a permit signed by an account the
// approval did not name spends that account's tokens all the same.
test("a sign approval refuses a signature after an address switch", async () => {
const bg = loadBackground();
const pending = bg.requestSign();
await settle();
bg.setActiveAddress(other.address);
const answer = bg.send(
{
type: "AUTISTMASK_SIGN_RESPONSE",
id: pending.id(),
approved: true,
signature: await signer.signMessage(
Buffer.from(MESSAGE.slice(2), "hex"),
),
},
{ url: bg.fromPopup.url },
);
await settle();
expect(answer.sendResponse).toHaveBeenCalledWith(
expect.objectContaining({
error: expect.stringMatching(/active address changed/),
retryable: false,
}),
);
expect(pending.result()).toEqual({
error: { message: expect.stringMatching(/active address changed/) },
});
});
test("a sign request from another address raises no approval at all", async () => {
const bg = loadBackground();
const pending = bg.requestSign(other.address);
await settle();
expect(pending.result()).toEqual({
error: {
code: 4100,
message: expect.stringMatching(/not the active one/),
},
});
expect(bg.created).toEqual([]);
});
// Population is a network round trip with the user's hands free. An
// approval raised for the address that was active when it started could
// never be signed once the wallet has moved off it, so it is never raised.
test("an address switch during population raises no approval", async () => {
let bg;
bg = loadBackground({
provider: {
// The user switches account in the toolbar popup while the
// node is being asked for a gas estimate.
estimateGas: async () => {
bg.setActiveAddress(other.address);
return 100000n;
},
},
});
const pending = bg.requestTx();
await settle();
expect(pending.result()).toEqual({
error: {
message: expect.stringMatching(
/active address changed while this transaction was being prepared/,
),
},
});
expect(bg.created).toEqual([]);
});
// Population happens before the window exists, so its failure is a failure
// of the request: no approval, no window, and the error goes back to the
// page the click came from.
test("a transaction that cannot be prepared opens no window", async () => {
const bg = loadBackground({
provider: {
estimateGas: async () => {
throw new Error("execution reverted");
},
},
});
const pending = bg.requestTx();
await settle();
expect(pending.result()).toEqual({
error: {
message: expect.stringMatching(
/could not be prepared.*execution reverted/,
),
},
});
expect(bg.created).toEqual([]);
});
});
// The interlock must not cost the retry the approval exists to allow. // The interlock must not cost the retry the approval exists to allow.
describe("the interlock releases a failed attempt", () => { describe("the interlock releases a failed attempt", () => {
test("a retryable failure before the broadcast leaves the approval usable", async () => { test("a retryable failure before the broadcast leaves the approval usable", async () => {

View File

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

View File

@@ -88,7 +88,7 @@ class Driver {
let parsed; let parsed;
try { try {
parsed = JSON.parse(text); parsed = JSON.parse(text);
} catch { } catch (_) {
throw new Error( throw new Error(
method + " " + path + ": non-JSON response: " + text, method + " " + path + ": non-JSON response: " + text,
); );
@@ -397,10 +397,8 @@ class ConsoleErrors {
// in a single chrome round trip. Poll-based, so an error is attributed // in a single chrome round trip. Poll-based, so an error is attributed
// to the step that was running when it was drained, not to the moment // to the step that was running when it was drained, not to the moment
// inside that step at which it happened — see the limitation note in // inside that step at which it happened — see the limitation note in
// run.js. An error that arrives mid-drain is not lost — it makes this // run.js. Nothing between two takes is lost, though: an error that
// batch or the next one — but the console service ring buffer holds // arrives mid-drain either makes this batch or the next one.
// only 250 messages, so more than that between two takes evicts the
// oldest unread. A clean run peaks at 4.
async take() { async take() {
const found = await this.driver.executeChrome(DRAIN_ERRORS_SCRIPT, [ const found = await this.driver.executeChrome(DRAIN_ERRORS_SCRIPT, [
this.originPrefix, this.originPrefix,
@@ -420,7 +418,7 @@ async function waitForDriverReady(base, timeoutMs) {
const body = await res.json(); const body = await res.json();
if (body && body.value && body.value.ready !== false) return; if (body && body.value && body.value.ready !== false) return;
} }
} catch { } catch (_) {
// not listening yet // not listening yet
} }
if (Date.now() >= deadline) { if (Date.now() >= deadline) {

View File

@@ -23,17 +23,13 @@
// drained at each step boundary, so an error is attributed to the step it // drained at each step boundary, so an error is attributed to the step it
// was drained after, never to a moment within that step. What is drained // was drained after, never to a moment within that step. What is drained
// covers the whole run from add-on install to the last drain below, which // covers the whole run from add-on install to the last drain below, which
// lands ~1.5s after the last step returns (500ms settle + 1000ms sleep + // measures out at ~1.5s after the last step returns — errors at +0.5s,
// two drain round trips). That cut-off jitters run to run: three runs of // +1.0s and +1.5s are reported, +1.6s and later never are, because the
// throws at fixed offsets reported everything to +1.5s and one of them // browser is torn down first. Nothing inside that window is dropped: the
// also +1.6s, and past it the browser is torn down first. Inside the // drain reads and clears in one chrome round trip, so there is no gap for
// window there is no race — the drain reads and clears in one chrome // an error to be destroyed unread in. The Chrome harness receives
// round trip — but there is a capacity limit: nsIConsoleService keeps // pageerror events as they happen and can say more. Do not read a green
// only the newest 250 messages, so 400 throws in one step report as // Firefox run as the same claim.
// exactly 250. A clean run peaks at 4 of 250, so that is headroom today
// and not a guarantee for a step that logs heavily. The Chrome harness
// receives pageerror events as they happen and can say more. Do not read
// a green Firefox run as the same claim.
"use strict"; "use strict";

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

@@ -56,7 +56,7 @@ global.chrome = {
}; };
const { isSpoofedSymbol } = require("../src/shared/symbolSpoof"); const { isSpoofedSymbol } = require("../src/shared/symbolSpoof");
const { TOKENS, KNOWN_SYMBOLS } = require("../src/shared/tokenList"); const { KNOWN_SYMBOLS } = require("../src/shared/tokenList");
const { filterTransactions } = require("../src/shared/transactions"); const { filterTransactions } = require("../src/shared/transactions");
const { const {
fetchTokenBalances, fetchTokenBalances,
@@ -124,301 +124,6 @@ describe("the shared rule", () => {
}); });
}); });
// Issue #260: the symbol is whatever the ERC-20 contract returns, and HTML
// collapses leading and trailing whitespace, so a token calling itself
// `" ETH "` reaches the user's eye as `ETH` while missing a raw
// KNOWN_SYMBOLS lookup. Normalizing inside the shared rule fixes all three
// surfaces at once, which is what consolidating the rule bought.
//
// Every character under test here is built from its code point rather than
// pasted in: most of them are invisible, and an invisible character in a
// test file is unreviewable.
const cp = (...codes) => String.fromCodePoint(...codes);
const NBSP = cp(0x00a0); // no-break space
const FIGURE_SPACE = cp(0x2007);
const IDEOGRAPHIC_SPACE = cp(0x3000);
const ZWSP = cp(0x200b); // zero-width space
const BOM = cp(0xfeff); // zero-width no-break space
const WORD_JOINER = cp(0x2060);
const SOFT_HYPHEN = cp(0x00ad);
const LRM = cp(0x200e); // left-to-right mark
const RLO = cp(0x202e); // right-to-left override
const HANGUL_FILLER = cp(0x3164);
const CHOSEONG_FILLER = cp(0x115f);
const VS16 = cp(0xfe0f); // variation selector-16
const VS1 = cp(0xfe00); // variation selector-1
const NEL = cp(0x0085); // next line, a C1 control
const DEL = cp(0x007f);
const FULLWIDTH_ETH = cp(0xff25, 0xff34, 0xff28);
const FULLWIDTH_USDC = cp(0xff55, 0xff53, 0xff44, 0xff43); // lowercase
const CYRILLIC_CAPITAL_IE = cp(0x0415);
describe("the shared rule: symbols that render as a known symbol", () => {
test("ASCII padding does not buy a pass", () => {
expect(isSpoofedSymbol(" ETH ", FAKE_ETH_CONTRACT)).toBe(true);
expect(isSpoofedSymbol("\tETH\n", FAKE_ETH_CONTRACT)).toBe(true);
expect(isSpoofedSymbol(" usdc ", FAKE_ETH_CONTRACT)).toBe(true);
});
test("non-breaking and other Unicode spaces do not either", () => {
expect(isSpoofedSymbol(NBSP + "ETH" + NBSP, FAKE_ETH_CONTRACT)).toBe(
true,
);
expect(
isSpoofedSymbol(
FIGURE_SPACE + "ETH" + IDEOGRAPHIC_SPACE,
FAKE_ETH_CONTRACT,
),
).toBe(true);
});
// These render as nothing at all, in any position, so they are removed
// wherever they sit rather than only at the ends.
test("zero-width characters are stripped wherever they sit", () => {
expect(isSpoofedSymbol("E" + ZWSP + "TH", FAKE_ETH_CONTRACT)).toBe(
true,
);
expect(isSpoofedSymbol(BOM + "ETH", FAKE_ETH_CONTRACT)).toBe(true);
expect(
isSpoofedSymbol("ET" + WORD_JOINER + "H", FAKE_ETH_CONTRACT),
).toBe(true);
expect(
isSpoofedSymbol("E" + SOFT_HYPHEN + "TH", FAKE_ETH_CONTRACT),
).toBe(true);
});
// An LRM is invisible and, in all-Latin text, moves nothing: dropping it
// leaves exactly the string the user saw.
test("an invisible bidi mark does not hide a known symbol", () => {
expect(isSpoofedSymbol(LRM + "ETH", FAKE_ETH_CONTRACT)).toBe(true);
});
// Invisibility is not confined to \p{Cf}. A Hangul filler is Lo and a
// variation selector is Mn, yet each of these four measures 32.00px in
// the repo's pinned e2e Chromium at 16px sans-serif — exactly the width
// of a plain `ETH` — so each reaches the user's eye as `ETH`. They are
// caught by \p{Default_Ignorable_Code_Point}, not by \p{Cf}.
test("invisible non-format characters are stripped too", () => {
expect(isSpoofedSymbol(HANGUL_FILLER + "ETH", FAKE_ETH_CONTRACT)).toBe(
true,
);
expect(
isSpoofedSymbol(CHOSEONG_FILLER + "ETH", FAKE_ETH_CONTRACT),
).toBe(true);
expect(isSpoofedSymbol("ETH" + VS16, FAKE_ETH_CONTRACT)).toBe(true);
expect(isSpoofedSymbol("E" + VS1 + "TH", FAKE_ETH_CONTRACT)).toBe(true);
});
// Nor is it confined to the Unicode classes. U+007F is a control (Cc)
// and is not default-ignorable, so neither class reaches it, but it
// measures 32.00px in the same browser — it paints nothing, so a
// symbol carrying it reaches the eye as `ETH`. It is named on its own
// in the strip for exactly that reason.
test("U+007F paints nothing and is stripped", () => {
expect(isSpoofedSymbol(DEL + "ETH", FAKE_ETH_CONTRACT)).toBe(true);
});
// The other side of the boundary, which is not the class boundary but
// the visibility one: the remaining C0 and C1 controls render as a
// visible 48.00px box in the same browser, so a symbol carrying one
// does not look like `ETH` and must not be judged a spoof. Widening
// the strip to \p{Cc} — the obvious over-correction once U+007F is in
// it — fails this test.
test("visible control characters do not make a symbol a spoof", () => {
expect(isSpoofedSymbol(NEL + "ETH", FAKE_ETH_CONTRACT)).toBe(false);
expect(isSpoofedSymbol(cp(0x0001) + "ETH", FAKE_ETH_CONTRACT)).toBe(
false,
);
expect(isSpoofedSymbol(cp(0x0090) + "ETH", FAKE_ETH_CONTRACT)).toBe(
false,
);
});
test("compatibility forms fold onto the symbol they imitate", () => {
expect(isSpoofedSymbol(FULLWIDTH_ETH, FAKE_ETH_CONTRACT)).toBe(true);
expect(isSpoofedSymbol(FULLWIDTH_USDC, FAKE_ETH_CONTRACT)).toBe(true);
});
// The two knowingly open classes, asserted here so that the boundary is
// a fact in the suite and not a claim in a PR body. A Cyrillic capital
// Ie is a distinct letter rather than a compatibility variant, so NFKC
// leaves it alone; and a right-to-left override reverses the rendering
// of what follows it, which dropping the control character does not
// undo. Closing either needs a confusables table or a bidi resolver,
// and both are a separate change from this one.
test("a Cyrillic homoglyph is knowingly still not caught", () => {
expect(
isSpoofedSymbol(CYRILLIC_CAPITAL_IE + "TH", FAKE_ETH_CONTRACT),
).toBe(false);
});
test("a bidi-reordered symbol is knowingly still not caught", () => {
expect(isSpoofedSymbol(RLO + "HTE", FAKE_ETH_CONTRACT)).toBe(false);
});
// Normalization does not reach the native-asset exemption, which turns
// on the absence of a contract address and never on the symbol.
test("a padded symbol with no contract is still not a spoof", () => {
expect(isSpoofedSymbol(" ETH ", null)).toBe(false);
expect(isSpoofedSymbol(NBSP + "ETH", "")).toBe(false);
});
test("a genuine contract still bears its own padded symbol", () => {
expect(isSpoofedSymbol(" USDC ", USDC_CONTRACT)).toBe(false);
expect(isSpoofedSymbol(ZWSP + "WETH", WETH_CONTRACT)).toBe(false);
});
// Normalization must not invent a match. Interior ASCII whitespace is
// left alone: `E T H` renders as `E T H`, not as `ETH`, so folding it
// would filter a token no user could confuse with the native asset.
test("a symbol that renders differently is not judged a spoof", () => {
expect(isSpoofedSymbol("E T H", FAKE_ETH_CONTRACT)).toBe(false);
expect(isSpoofedSymbol("ETH2", FAKE_ETH_CONTRACT)).toBe(false);
expect(isSpoofedSymbol("MY ETH", FAKE_ETH_CONTRACT)).toBe(false);
});
// The false-positive question, answered against the shipped data rather
// than by assertion: no bundled symbol carries whitespace or a
// non-ASCII character, so the normalization cannot newly filter one.
// The character class starts at `!` rather than at the space so that it
// asserts the claim it stands for — `[ -~]` would admit an interior
// space and let a whitespace-bearing entry through the guard.
test("no bundled symbol is touched by the normalization", () => {
for (const [symbol, addresses] of KNOWN_SYMBOLS) {
expect(symbol).toBe(symbol.trim());
expect(symbol).toMatch(/^[!-~]+$/);
if (addresses === null) continue;
for (const address of addresses) {
expect(isSpoofedSymbol(symbol, address)).toBe(false);
}
}
});
});
// Issue #276: the guard that was missing. The suite walked KNOWN_SYMBOLS,
// which is built from TOKENS, so it could only ever assert that the table
// agrees with itself. Seven symbols appear twice in the bundled list at two
// different real contracts, and the table kept whichever came first, so the
// other seven contracts — tokens in our own shipped list, at their own
// addresses — were judged spoofs and hidden from the balance list, the
// history and the send selector. That is the over-filtering direction: it
// hides a holding the user cannot then spend.
//
// This walk is over TOKENS, the data the wallet actually ships, so it fails
// whenever a bundled token would be filtered at its own address no matter
// which side of the table the mistake is on.
describe("the shipped token list", () => {
test("no bundled token is filtered at its own address", () => {
const filtered = TOKENS.filter((t) =>
isSpoofedSymbol(t.symbol, t.address),
).map((t) => t.symbol + " @ " + t.address);
expect(filtered).toEqual([]);
});
// The third failure mode the issue asks about: a symbol whose table entry
// names an address that is in neither the table nor the list would be a
// contract we vouch for and do not ship. There is none, and the table is
// built from the list, so this asserts the derivation has not acquired a
// hand-written entry.
test("every address the table vouches for is a bundled token", () => {
const bundled = new Set(TOKENS.map((t) => t.address.toLowerCase()));
for (const [symbol, addresses] of KNOWN_SYMBOLS) {
if (addresses === null) continue;
expect(addresses.size).toBeGreaterThan(0);
for (const address of addresses) {
expect(address).toBe(address.toLowerCase());
expect(bundled.has(address)).toBe(true);
// And it is the token that actually reports that symbol.
const token = TOKENS.find(
(t) => t.address.toLowerCase() === address,
);
expect(token.symbol.toUpperCase()).toBe(symbol);
}
}
});
// Both contracts behind a shared ticker must pass, from either side: a
// rule that admits only the one the table happens to visit first is the
// bug, not the fix.
test("both contracts behind a shared ticker are admitted", () => {
const bySymbol = new Map();
for (const t of TOKENS) {
const upper = t.symbol.toUpperCase();
if (!bySymbol.has(upper)) bySymbol.set(upper, []);
bySymbol.get(upper).push(t);
}
const shared = [...bySymbol].filter(([, list]) => list.length > 1);
// The shared tickers are a fact about the shipped data; if a future
// list has none, this test would silently assert nothing.
expect(shared.length).toBeGreaterThan(0);
for (const [, list] of shared) {
for (const t of list) {
expect(isSpoofedSymbol(t.symbol, t.address)).toBe(false);
}
}
});
// The seven from issue #276, named so that the reconciliation is a fact
// in the suite: each is two real contracts from the same source fetch,
// and the table now holds both rather than the one that came first.
test("the seven shared tickers each name both bundled contracts", () => {
const expected = {
TON: [
"0x582d872a1b094fc48f5de31d3b73f2d9be47def1", // Toncoin
"0x2be5e8c109e2197d077d13a82daead6a9b3433c5", // Tokamak Network
],
FRAX: [
"0x853d955acef822db058eb8505911ed77f175b99e", // Legacy Frax Dollar
"0x3432b6a60d23ca0dfca7761b7ab56459d9c964d0", // Frax (prev. FXS)
],
REUSD: [
"0x5086bf358635b81d8c47c66d1c8b9e567db70c72", // Re Protocol reUSD
"0x57ab1e0003f623289cd798b1824be09a793e4bec", // Resupply USD
],
EURE: [
"0x39b8b6385416f4ca36a20319f70d28621895279d", // Monerium EUR emoney
"0x3231cb76718cdef2155fc47b5286d82e6eda273f", // Monerium EUR emoney [OLD]
],
MSUSD: [
"0x4ba01f22827018b4772cd326c7627fb4956a7c00", // Main Street USD
"0xab5eb14c09d416f0ac63661e57edb7aecdb9befa", // Metronome Synth USD
],
MUSD: [
"0xaca92e438df0b2401ff60da7e4337b687a2435da", // MetaMask USD
"0xdd468a1ddc392dcdbef6db6e34e89aa338f9f186", // Mezo USD
],
JPYC: [
"0x431d5dff03120afa4bdf332c61a6e1766ef37bdb", // JPY Coin
"0x2370f9d504c7a6e775bf6e14b3f12846b594cd53", // JPY Coin v1
],
};
for (const [symbol, addresses] of Object.entries(expected)) {
expect([...KNOWN_SYMBOLS.get(symbol)].sort()).toEqual(
[...addresses].sort(),
);
for (const address of addresses) {
expect(isSpoofedSymbol(symbol, address)).toBe(false);
}
}
});
// The other direction, on the same symbols: widening the table to hold
// every bundled address for a ticker must not turn it into a pass for
// any other contract.
test("a shared ticker from a third contract is still a spoof", () => {
const bySymbol = new Map();
for (const t of TOKENS) {
const upper = t.symbol.toUpperCase();
if (!bySymbol.has(upper)) bySymbol.set(upper, []);
bySymbol.get(upper).push(t);
}
for (const [symbol, list] of bySymbol) {
if (list.length < 2) continue;
expect(isSpoofedSymbol(symbol, FAKE_ETH_CONTRACT)).toBe(true);
}
});
});
describe("surface 1: the transaction history", () => { describe("surface 1: the transaction history", () => {
function fakeEthTransfer() { function fakeEthTransfer() {
return { return {
@@ -442,22 +147,6 @@ describe("surface 1: the transaction history", () => {
expect(result.transactions).toEqual([]); expect(result.transactions).toEqual([]);
}); });
// Issue #260 on this surface: the same transfer with a padded symbol.
test("a padded fake ETH token transfer is filtered too", () => {
const padded = { ...fakeEthTransfer(), symbol: " ETH " };
const result = filterTransactions([padded], {
hideSpoofedSymbols: true,
hideFraudContracts: true,
hideLowHolderTokens: true,
hideDustTransactions: true,
dustThresholdGwei: 100000,
});
expect(result.transactions).toEqual([]);
// The contract is learned as fraudulent, exactly as for the
// unpadded symbol: the padding must not cost the blocklist entry.
expect(result.newFraudContracts).toEqual([FAKE_ETH_CONTRACT]);
});
test("a real native ETH transfer survives", () => { test("a real native ETH transfer survives", () => {
const native = { const native = {
hash: "0x" + "2".repeat(64), hash: "0x" + "2".repeat(64),
@@ -512,36 +201,6 @@ describe("surface 2: the Send token selector", () => {
expect(select.children).toEqual([]); expect(select.children).toEqual([]);
}); });
// Issue #260 on this surface: the option text is rendered into HTML,
// which collapses the padding, so an unfiltered padded token would sit
// in the selector reading exactly `ETH`.
test("a padded fake ETH token is not selectable either", () => {
render([
{
address: FAKE_ETH_CONTRACT,
symbol: " ETH ",
decimals: 18,
balance: "0.005",
holders: 900000,
},
]);
expect(select.children).toEqual([]);
});
test("a genuine token with a padded symbol stays selectable", () => {
render([
{
address: USDC_CONTRACT,
symbol: " USDC ",
decimals: 6,
balance: "12.5",
holders: 900000,
},
]);
expect(select.children).toHaveLength(1);
expect(select.children[0].value).toBe(USDC_CONTRACT);
});
test("native ETH remains the always-present option", () => { test("native ETH remains the always-present option", () => {
render([]); render([]);
expect(select.innerHTML).toBe('<option value="ETH">ETH</option>'); expect(select.innerHTML).toBe('<option value="ETH">ETH</option>');
@@ -592,35 +251,6 @@ describe("surface 3: the balance list", () => {
expect(balances).toEqual([]); expect(balances).toEqual([]);
}); });
// Issue #260 on this surface: the balance list is where the user forms
// their belief about what they own, and it renders the symbol into HTML.
test("a padded fake ETH token is filtered too", async () => {
respondWith([fakeEthItem({ symbol: " ETH " })]);
expect(await fetchTokenBalances(HOLDER, BLOCKSCOUT, [])).toEqual([]);
});
test("a fake ETH token padded with a no-break space is filtered", async () => {
respondWith([fakeEthItem({ symbol: NBSP + "ETH" + NBSP })]);
expect(await fetchTokenBalances(HOLDER, BLOCKSCOUT, [])).toEqual([]);
});
// The false-positive direction on the surface that matters most: a real
// holding whose symbol happens to carry padding is still listed, and the
// list still shows the symbol the token actually reports.
test("a genuine token with a padded symbol is not newly filtered", async () => {
respondWith([
fakeEthItem({
address_hash: USDC_CONTRACT,
symbol: " USDC ",
name: "USD Coin",
decimals: "6",
}),
]);
const balances = await fetchTokenBalances(HOLDER, BLOCKSCOUT, []);
expect(balances).toHaveLength(1);
expect(balances[0].symbol).toBe(" USDC ");
});
test("a genuine token keeps its place in the list", async () => { test("a genuine token keeps its place in the list", async () => {
respondWith([ respondWith([
fakeEthItem({ fakeEthItem({
@@ -644,33 +274,6 @@ describe("surface 3: the balance list", () => {
expect(await fetchTokenBalances(HOLDER, BLOCKSCOUT, [])).toEqual([]); expect(await fetchTokenBalances(HOLDER, BLOCKSCOUT, [])).toEqual([]);
}); });
// The adjacent finding from the same review as issue #260: the type gate
// compared exactly, so an explorer that ever varied the casing would
// silently drop a real holding before any filter ran. The comparison is
// now case-insensitive, which changes nothing about which types are
// admitted.
test("a differently-cased ERC-20 type still lists a real holding", async () => {
respondWith([
fakeEthItem({
type: "erc-20",
address_hash: USDC_CONTRACT,
symbol: "USDC",
name: "USD Coin",
decimals: "6",
}),
]);
const balances = await fetchTokenBalances(HOLDER, BLOCKSCOUT, []);
expect(balances).toHaveLength(1);
expect(balances[0].symbol).toBe("USDC");
});
test("case insensitivity does not admit another token type", async () => {
respondWith([fakeEthItem({ type: "erc-721" })]);
expect(await fetchTokenBalances(HOLDER, BLOCKSCOUT, [])).toEqual([]);
respondWith([fakeEthItem({ type: "ERC-20-EXTRA" })]);
expect(await fetchTokenBalances(HOLDER, BLOCKSCOUT, [])).toEqual([]);
});
// The money test: the user holds real ETH and has been airdropped a fake // The money test: the user holds real ETH and has been airdropped a fake
// ETH ERC-20. The fake is gone from the list of tokens; the real balance // ETH ERC-20. The fake is gone from the list of tokens; the real balance
// is exactly what the node reported. // is exactly what the node reported.

View File

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

View File

@@ -1,4 +1,4 @@
const { AbiCoder, Interface, solidityPacked } = require("ethers"); const { AbiCoder, Interface, solidityPacked, getBytes } = require("ethers");
const uniswap = require("../src/shared/uniswap"); const uniswap = require("../src/shared/uniswap");
const ROUTER_ADDR = "0x66a9893cc07d91d95644aedd05d03f95e1dba8af"; const ROUTER_ADDR = "0x66a9893cc07d91d95644aedd05d03f95e1dba8af";

380
yarn.lock
View File

@@ -427,90 +427,6 @@
resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz#0eaf705c941a218a43dba8e09f1df1d6cd2f1f17" resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz#0eaf705c941a218a43dba8e09f1df1d6cd2f1f17"
integrity sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA== integrity sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==
"@eslint-community/eslint-utils@^4.8.0":
version "4.10.1"
resolved "https://registry.yarnpkg.com/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz#8911bd72b2c3640a543609e0400b8c4d2e7e7cb6"
integrity sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==
dependencies:
eslint-visitor-keys "^3.4.3"
"@eslint-community/regexpp@^4.12.2":
version "4.12.2"
resolved "https://registry.yarnpkg.com/@eslint-community/regexpp/-/regexpp-4.12.2.tgz#bccdf615bcf7b6e8db830ec0b8d21c9a25de597b"
integrity sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==
"@eslint/config-array@^0.23.5":
version "0.23.5"
resolved "https://registry.yarnpkg.com/@eslint/config-array/-/config-array-0.23.5.tgz#56e86d243049195d8acc0c06a1b3dfdc3fa3de95"
integrity sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==
dependencies:
"@eslint/object-schema" "^3.0.5"
debug "^4.3.1"
minimatch "^10.2.4"
"@eslint/config-helpers@^0.7.0":
version "0.7.0"
resolved "https://registry.yarnpkg.com/@eslint/config-helpers/-/config-helpers-0.7.0.tgz#09ee4aa07b73f059ec2d4c74bf4b2ff02b322377"
integrity sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==
dependencies:
"@eslint/core" "^1.2.1"
"@eslint/core@^1.2.1":
version "1.2.1"
resolved "https://registry.yarnpkg.com/@eslint/core/-/core-1.2.1.tgz#c1da7cd1b82fa8787f98b5629fb811848a1b63ce"
integrity sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==
dependencies:
"@types/json-schema" "^7.0.15"
"@eslint/js@10.0.1":
version "10.0.1"
resolved "https://registry.yarnpkg.com/@eslint/js/-/js-10.0.1.tgz#1e8a876f50117af8ab67e47d5ad94d38d6622583"
integrity sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==
"@eslint/object-schema@^3.0.5":
version "3.0.5"
resolved "https://registry.yarnpkg.com/@eslint/object-schema/-/object-schema-3.0.5.tgz#88e9bf4d11d2b19c082e78ebe7ce88724a5eb091"
integrity sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==
"@eslint/plugin-kit@^0.7.2":
version "0.7.2"
resolved "https://registry.yarnpkg.com/@eslint/plugin-kit/-/plugin-kit-0.7.2.tgz#4b0962f3f2c7ce8bc98b3ecfe34525c09d2cb729"
integrity sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==
dependencies:
"@eslint/core" "^1.2.1"
levn "^0.4.1"
"@humanfs/core@^0.19.2":
version "0.19.2"
resolved "https://registry.yarnpkg.com/@humanfs/core/-/core-0.19.2.tgz#a8272ca03b2acf492670222b2320b6c421bfde60"
integrity sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==
dependencies:
"@humanfs/types" "^0.15.0"
"@humanfs/node@^0.16.6":
version "0.16.8"
resolved "https://registry.yarnpkg.com/@humanfs/node/-/node-0.16.8.tgz#8f800cccc13f4f8cd3116e2d9c0a94939da3e3ed"
integrity sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==
dependencies:
"@humanfs/core" "^0.19.2"
"@humanfs/types" "^0.15.0"
"@humanwhocodes/retry" "^0.4.0"
"@humanfs/types@^0.15.0":
version "0.15.0"
resolved "https://registry.yarnpkg.com/@humanfs/types/-/types-0.15.0.tgz#f2a09f62012390b2bff3fc6fb248ddec8c09a090"
integrity sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==
"@humanwhocodes/module-importer@^1.0.1":
version "1.0.1"
resolved "https://registry.yarnpkg.com/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz#af5b2691a22b44be847b0ca81641c5fb6ad0172c"
integrity sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==
"@humanwhocodes/retry@^0.4.0", "@humanwhocodes/retry@^0.4.2":
version "0.4.3"
resolved "https://registry.yarnpkg.com/@humanwhocodes/retry/-/retry-0.4.3.tgz#c2b9d2e374ee62c586d3adbea87199b1d7a7a6ba"
integrity sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==
"@isaacs/cliui@^8.0.2": "@isaacs/cliui@^8.0.2":
version "8.0.2" version "8.0.2"
resolved "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz" resolved "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz"
@@ -1092,16 +1008,6 @@
dependencies: dependencies:
"@babel/types" "^7.28.2" "@babel/types" "^7.28.2"
"@types/esrecurse@^4.3.1":
version "4.3.1"
resolved "https://registry.yarnpkg.com/@types/esrecurse/-/esrecurse-4.3.1.tgz#6f636af962fbe6191b830bd676ba5986926bccec"
integrity sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==
"@types/estree@^1.0.6", "@types/estree@^1.0.8":
version "1.0.9"
resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.9.tgz#cf3f0e876d7bee15a93ab925b82bf570a3904a24"
integrity sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==
"@types/istanbul-lib-coverage@*", "@types/istanbul-lib-coverage@^2.0.1", "@types/istanbul-lib-coverage@^2.0.6": "@types/istanbul-lib-coverage@*", "@types/istanbul-lib-coverage@^2.0.1", "@types/istanbul-lib-coverage@^2.0.6":
version "2.0.6" version "2.0.6"
resolved "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz" resolved "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz"
@@ -1121,11 +1027,6 @@
dependencies: dependencies:
"@types/istanbul-lib-report" "*" "@types/istanbul-lib-report" "*"
"@types/json-schema@^7.0.15":
version "7.0.15"
resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.15.tgz#596a1747233694d50f6ad8a7869fcb6f56cf5841"
integrity sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==
"@types/node@*", "@types/node@22.7.5": "@types/node@*", "@types/node@22.7.5":
version "22.7.5" version "22.7.5"
resolved "https://registry.npmjs.org/@types/node/-/node-22.7.5.tgz" resolved "https://registry.npmjs.org/@types/node/-/node-22.7.5.tgz"
@@ -1252,31 +1153,11 @@
resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.11.1.tgz#538b1e103bf8d9864e7b85cc96fa8d6fb6c40777" resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.11.1.tgz#538b1e103bf8d9864e7b85cc96fa8d6fb6c40777"
integrity sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g== integrity sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g==
acorn-jsx@^5.3.2:
version "5.3.2"
resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz#7ed5bb55908b3b2f1bc55c6af1653bada7f07937"
integrity sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==
acorn@^8.16.0:
version "8.18.0"
resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.18.0.tgz#4faf01b2d6d326bfeed97aea1f52220b5f4c1940"
integrity sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==
aes-js@4.0.0-beta.5: aes-js@4.0.0-beta.5:
version "4.0.0-beta.5" version "4.0.0-beta.5"
resolved "https://registry.npmjs.org/aes-js/-/aes-js-4.0.0-beta.5.tgz" resolved "https://registry.npmjs.org/aes-js/-/aes-js-4.0.0-beta.5.tgz"
integrity sha512-G965FqalsNyrPqgEGON7nIx1e/OVENSgiEIzyC63haUMuvNnwIgIjMs52hlTCKhkBny7A2ORNlfY9Zu+jmGk1Q== integrity sha512-G965FqalsNyrPqgEGON7nIx1e/OVENSgiEIzyC63haUMuvNnwIgIjMs52hlTCKhkBny7A2ORNlfY9Zu+jmGk1Q==
ajv@^6.14.0:
version "6.15.0"
resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.15.0.tgz#07e982c74626167aa7a2495c53817892d7139492"
integrity sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==
dependencies:
fast-deep-equal "^3.1.1"
fast-json-stable-stringify "^2.0.0"
json-schema-traverse "^0.4.1"
uri-js "^4.2.2"
ansi-escapes@^4.3.2: ansi-escapes@^4.3.2:
version "4.3.2" version "4.3.2"
resolved "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz" resolved "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz"
@@ -1416,13 +1297,6 @@ brace-expansion@^5.0.2:
dependencies: dependencies:
balanced-match "^4.0.2" balanced-match "^4.0.2"
brace-expansion@^5.0.8:
version "5.0.9"
resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-5.0.9.tgz#7c72438809b5fa5babf54199a1f1c281a6984fcf"
integrity sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==
dependencies:
balanced-match "^4.0.2"
braces@^3.0.3: braces@^3.0.3:
version "3.0.3" version "3.0.3"
resolved "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz" resolved "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz"
@@ -1555,7 +1429,7 @@ cross-spawn@^7.0.3, cross-spawn@^7.0.6:
shebang-command "^2.0.0" shebang-command "^2.0.0"
which "^2.0.1" which "^2.0.1"
debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.3.2: debug@^4.1.0, debug@^4.1.1, debug@^4.3.1:
version "4.4.3" version "4.4.3"
resolved "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz" resolved "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz"
integrity sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA== integrity sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==
@@ -1572,11 +1446,6 @@ dedent@^1.6.0:
resolved "https://registry.npmjs.org/dedent/-/dedent-1.7.1.tgz" resolved "https://registry.npmjs.org/dedent/-/dedent-1.7.1.tgz"
integrity sha512-9JmrhGZpOlEgOLdQgSm0zxFaYoQon408V1v49aqTWuXENVlnCuY9JBZcXZiCsZQWDjTm5Qf/nIvAy77mXDAjEg== integrity sha512-9JmrhGZpOlEgOLdQgSm0zxFaYoQon408V1v49aqTWuXENVlnCuY9JBZcXZiCsZQWDjTm5Qf/nIvAy77mXDAjEg==
deep-is@^0.1.3:
version "0.1.4"
resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.4.tgz#a6f2dce612fadd2ef1f519b73551f17e85199831"
integrity sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==
deepmerge@^4.3.1: deepmerge@^4.3.1:
version "4.3.1" version "4.3.1"
resolved "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz" resolved "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz"
@@ -1679,105 +1548,11 @@ escape-string-regexp@^2.0.0:
resolved "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz" resolved "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz"
integrity sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w== integrity sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==
escape-string-regexp@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz#14ba83a5d373e3d311e5afca29cf5bfad965bf34"
integrity sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==
eslint-scope@^9.1.2:
version "9.1.2"
resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-9.1.2.tgz#b9de6ace2fab1cff24d2e58d85b74c8fcea39802"
integrity sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==
dependencies:
"@types/esrecurse" "^4.3.1"
"@types/estree" "^1.0.8"
esrecurse "^4.3.0"
estraverse "^5.2.0"
eslint-visitor-keys@^3.4.3:
version "3.4.3"
resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz#0cd72fe8550e3c2eae156a96a4dddcd1c8ac5800"
integrity sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==
eslint-visitor-keys@^5.0.1:
version "5.0.1"
resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz#9e3c9489697824d2d4ce3a8ad12628f91e9f59be"
integrity sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==
eslint@10.8.1:
version "10.8.1"
resolved "https://registry.yarnpkg.com/eslint/-/eslint-10.8.1.tgz#fb37d514c19b6dd5b2d6b70169fd26fddfa97967"
integrity sha512-wqA7W2jbsC/BnV9Iv1UZpKVFkO1AdNoSmYW8NWG4HNOBbkAMvIqDZ27pI2f07dqn583NcIC44ckjAcOXDL1QbQ==
dependencies:
"@eslint-community/eslint-utils" "^4.8.0"
"@eslint-community/regexpp" "^4.12.2"
"@eslint/config-array" "^0.23.5"
"@eslint/config-helpers" "^0.7.0"
"@eslint/core" "^1.2.1"
"@eslint/plugin-kit" "^0.7.2"
"@humanfs/node" "^0.16.6"
"@humanwhocodes/module-importer" "^1.0.1"
"@humanwhocodes/retry" "^0.4.2"
"@types/estree" "^1.0.6"
ajv "^6.14.0"
cross-spawn "^7.0.6"
debug "^4.3.2"
escape-string-regexp "^4.0.0"
eslint-scope "^9.1.2"
eslint-visitor-keys "^5.0.1"
espree "^11.2.0"
esquery "^1.7.0"
esutils "^2.0.2"
fast-deep-equal "^3.1.3"
file-entry-cache "^8.0.0"
find-up "^5.0.0"
glob-parent "^6.0.2"
ignore "^5.2.0"
imurmurhash "^0.1.4"
is-glob "^4.0.0"
json-stable-stringify-without-jsonify "^1.0.1"
minimatch "^10.2.5"
natural-compare "^1.4.0"
optionator "^0.9.3"
espree@^11.2.0:
version "11.2.0"
resolved "https://registry.yarnpkg.com/espree/-/espree-11.2.0.tgz#01d5e47dc332aaba3059008362454a8cc34ccaa5"
integrity sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==
dependencies:
acorn "^8.16.0"
acorn-jsx "^5.3.2"
eslint-visitor-keys "^5.0.1"
esprima@^4.0.0: esprima@^4.0.0:
version "4.0.1" version "4.0.1"
resolved "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz" resolved "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz"
integrity sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A== integrity sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==
esquery@^1.7.0:
version "1.7.0"
resolved "https://registry.yarnpkg.com/esquery/-/esquery-1.7.0.tgz#08d048f261f0ddedb5bae95f46809463d9c9496d"
integrity sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==
dependencies:
estraverse "^5.1.0"
esrecurse@^4.3.0:
version "4.3.0"
resolved "https://registry.yarnpkg.com/esrecurse/-/esrecurse-4.3.0.tgz#7ad7964d679abb28bee72cec63758b1c5d2c9921"
integrity sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==
dependencies:
estraverse "^5.2.0"
estraverse@^5.1.0, estraverse@^5.2.0:
version "5.3.0"
resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-5.3.0.tgz#2eea5290702f26ab8fe5370370ff86c965d21123"
integrity sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==
esutils@^2.0.2:
version "2.0.3"
resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.3.tgz#74d2eb4de0b8da1293711910d50775b9b710ef64"
integrity sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==
ethereum-blockies-base64@^1.0.2: ethereum-blockies-base64@^1.0.2:
version "1.0.2" version "1.0.2"
resolved "https://registry.npmjs.org/ethereum-blockies-base64/-/ethereum-blockies-base64-1.0.2.tgz" resolved "https://registry.npmjs.org/ethereum-blockies-base64/-/ethereum-blockies-base64-1.0.2.tgz"
@@ -1830,21 +1605,11 @@ expect@30.2.0:
jest-mock "30.2.0" jest-mock "30.2.0"
jest-util "30.2.0" jest-util "30.2.0"
fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3: fast-json-stable-stringify@^2.1.0:
version "3.1.3"
resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525"
integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==
fast-json-stable-stringify@^2.0.0, fast-json-stable-stringify@^2.1.0:
version "2.1.0" version "2.1.0"
resolved "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz" resolved "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz"
integrity sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw== integrity sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==
fast-levenshtein@^2.0.6:
version "2.0.6"
resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917"
integrity sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==
fb-watchman@^2.0.2: fb-watchman@^2.0.2:
version "2.0.2" version "2.0.2"
resolved "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz" resolved "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz"
@@ -1852,13 +1617,6 @@ fb-watchman@^2.0.2:
dependencies: dependencies:
bser "2.1.1" bser "2.1.1"
file-entry-cache@^8.0.0:
version "8.0.0"
resolved "https://registry.yarnpkg.com/file-entry-cache/-/file-entry-cache-8.0.0.tgz#7787bddcf1131bffb92636c69457bbc0edd6d81f"
integrity sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==
dependencies:
flat-cache "^4.0.0"
fill-range@^7.1.1: fill-range@^7.1.1:
version "7.1.1" version "7.1.1"
resolved "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz" resolved "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz"
@@ -1874,27 +1632,6 @@ find-up@^4.0.0, find-up@^4.1.0:
locate-path "^5.0.0" locate-path "^5.0.0"
path-exists "^4.0.0" path-exists "^4.0.0"
find-up@^5.0.0:
version "5.0.0"
resolved "https://registry.yarnpkg.com/find-up/-/find-up-5.0.0.tgz#4c92819ecb7083561e4f4a240a86be5198f536fc"
integrity sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==
dependencies:
locate-path "^6.0.0"
path-exists "^4.0.0"
flat-cache@^4.0.0:
version "4.0.1"
resolved "https://registry.yarnpkg.com/flat-cache/-/flat-cache-4.0.1.tgz#0ece39fcb14ee012f4b0410bd33dd9c1f011127c"
integrity sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==
dependencies:
flatted "^3.2.9"
keyv "^4.5.4"
flatted@^3.2.9:
version "3.4.4"
resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.4.4.tgz#aeeca2a506303f0cee61c59e6c9f2a88d2f29fc6"
integrity sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==
foreground-child@^3.1.0: foreground-child@^3.1.0:
version "3.3.1" version "3.3.1"
resolved "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz" resolved "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz"
@@ -1933,13 +1670,6 @@ get-stream@^6.0.0:
resolved "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz" resolved "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz"
integrity sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg== integrity sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==
glob-parent@^6.0.2:
version "6.0.2"
resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-6.0.2.tgz#6d237d99083950c79290f24c7642a3de9a28f9e3"
integrity sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==
dependencies:
is-glob "^4.0.3"
glob@^10.3.10: glob@^10.3.10:
version "10.5.0" version "10.5.0"
resolved "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz" resolved "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz"
@@ -1964,11 +1694,6 @@ glob@^7.1.4:
once "^1.3.0" once "^1.3.0"
path-is-absolute "^1.0.0" path-is-absolute "^1.0.0"
globals@17.11.0:
version "17.11.0"
resolved "https://registry.yarnpkg.com/globals/-/globals-17.11.0.tgz#d643485bb30220d7751e511cf4f68c73d3870d87"
integrity sha512-Z2I8hM+PbJDXQDq3Icgpzv+mPdwr68iZUU9d5WW4FuXfDUQfkZaZuvjMv42/5crNyw154+9+VWXbYrUgDXbxNw==
graceful-fs@^4.2.11, graceful-fs@^4.2.4: graceful-fs@^4.2.11, graceful-fs@^4.2.4:
version "4.2.11" version "4.2.11"
resolved "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz" resolved "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz"
@@ -1989,11 +1714,6 @@ human-signals@^2.1.0:
resolved "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz" resolved "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz"
integrity sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw== integrity sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==
ignore@^5.2.0:
version "5.3.2"
resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.3.2.tgz#3cd40e729f3643fd87cb04e50bf0eb722bc596f5"
integrity sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==
import-local@^3.2.0: import-local@^3.2.0:
version "3.2.0" version "3.2.0"
resolved "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz" resolved "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz"
@@ -2040,7 +1760,7 @@ is-generator-fn@^2.1.0:
resolved "https://registry.npmjs.org/is-generator-fn/-/is-generator-fn-2.1.0.tgz" resolved "https://registry.npmjs.org/is-generator-fn/-/is-generator-fn-2.1.0.tgz"
integrity sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ== integrity sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==
is-glob@^4.0.0, is-glob@^4.0.3: is-glob@^4.0.3:
version "4.0.3" version "4.0.3"
resolved "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz" resolved "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz"
integrity sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg== integrity sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==
@@ -2492,51 +2212,21 @@ jsesc@^3.0.2:
resolved "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz" resolved "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz"
integrity sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA== integrity sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==
json-buffer@3.0.1:
version "3.0.1"
resolved "https://registry.yarnpkg.com/json-buffer/-/json-buffer-3.0.1.tgz#9338802a30d3b6605fbe0613e094008ca8c05a13"
integrity sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==
json-parse-even-better-errors@^2.3.0: json-parse-even-better-errors@^2.3.0:
version "2.3.1" version "2.3.1"
resolved "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz" resolved "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz"
integrity sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w== integrity sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==
json-schema-traverse@^0.4.1:
version "0.4.1"
resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz#69f6a87d9513ab8bb8fe63bdb0979c448e684660"
integrity sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==
json-stable-stringify-without-jsonify@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz#9db7b59496ad3f3cfef30a75142d2d930ad72651"
integrity sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==
json5@^2.2.3: json5@^2.2.3:
version "2.2.3" version "2.2.3"
resolved "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz" resolved "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz"
integrity sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg== integrity sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==
keyv@^4.5.4:
version "4.5.4"
resolved "https://registry.yarnpkg.com/keyv/-/keyv-4.5.4.tgz#a879a99e29452f942439f2a405e3af8b31d4de93"
integrity sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==
dependencies:
json-buffer "3.0.1"
leven@^3.1.0: leven@^3.1.0:
version "3.1.0" version "3.1.0"
resolved "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz" resolved "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz"
integrity sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A== integrity sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==
levn@^0.4.1:
version "0.4.1"
resolved "https://registry.yarnpkg.com/levn/-/levn-0.4.1.tgz#ae4562c007473b932a6200d403268dd2fffc6ade"
integrity sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==
dependencies:
prelude-ls "^1.2.1"
type-check "~0.4.0"
libsodium-sumo@^0.8.0: libsodium-sumo@^0.8.0:
version "0.8.2" version "0.8.2"
resolved "https://registry.npmjs.org/libsodium-sumo/-/libsodium-sumo-0.8.2.tgz" resolved "https://registry.npmjs.org/libsodium-sumo/-/libsodium-sumo-0.8.2.tgz"
@@ -2635,13 +2325,6 @@ locate-path@^5.0.0:
dependencies: dependencies:
p-locate "^4.1.0" p-locate "^4.1.0"
locate-path@^6.0.0:
version "6.0.0"
resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-6.0.0.tgz#55321eb309febbc59c4801d931a72452a681d286"
integrity sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==
dependencies:
p-locate "^5.0.0"
lru-cache@^10.2.0: lru-cache@^10.2.0:
version "10.4.3" version "10.4.3"
resolved "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz" resolved "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz"
@@ -2693,13 +2376,6 @@ mimic-fn@^2.1.0:
resolved "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz" resolved "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz"
integrity sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg== integrity sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==
minimatch@^10.2.4, minimatch@^10.2.5:
version "10.2.6"
resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-10.2.6.tgz#fd956bbe0b77241e9f15ac5dccb1c638060968ef"
integrity sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==
dependencies:
brace-expansion "^5.0.8"
minimatch@^3.0.4, minimatch@^3.1.1: minimatch@^3.0.4, minimatch@^3.1.1:
version "3.1.3" version "3.1.3"
resolved "https://registry.npmjs.org/minimatch/-/minimatch-3.1.3.tgz" resolved "https://registry.npmjs.org/minimatch/-/minimatch-3.1.3.tgz"
@@ -2780,18 +2456,6 @@ onetime@^5.1.2:
dependencies: dependencies:
mimic-fn "^2.1.0" mimic-fn "^2.1.0"
optionator@^0.9.3:
version "0.9.4"
resolved "https://registry.yarnpkg.com/optionator/-/optionator-0.9.4.tgz#7ea1c1a5d91d764fb282139c88fe11e182a3a734"
integrity sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==
dependencies:
deep-is "^0.1.3"
fast-levenshtein "^2.0.6"
levn "^0.4.1"
prelude-ls "^1.2.1"
type-check "^0.4.0"
word-wrap "^1.2.5"
p-limit@^2.2.0: p-limit@^2.2.0:
version "2.3.0" version "2.3.0"
resolved "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz" resolved "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz"
@@ -2799,7 +2463,7 @@ p-limit@^2.2.0:
dependencies: dependencies:
p-try "^2.0.0" p-try "^2.0.0"
p-limit@^3.0.2, p-limit@^3.1.0: p-limit@^3.1.0:
version "3.1.0" version "3.1.0"
resolved "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz" resolved "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz"
integrity sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ== integrity sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==
@@ -2813,13 +2477,6 @@ p-locate@^4.1.0:
dependencies: dependencies:
p-limit "^2.2.0" p-limit "^2.2.0"
p-locate@^5.0.0:
version "5.0.0"
resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-5.0.0.tgz#83c8315c6785005e3bd021839411c9e110e6d834"
integrity sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==
dependencies:
p-limit "^3.0.2"
p-try@^2.0.0: p-try@^2.0.0:
version "2.2.0" version "2.2.0"
resolved "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz" resolved "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz"
@@ -2905,11 +2562,6 @@ pnglib@0.0.1:
resolved "https://registry.npmjs.org/pnglib/-/pnglib-0.0.1.tgz" resolved "https://registry.npmjs.org/pnglib/-/pnglib-0.0.1.tgz"
integrity sha512-95ChzOoYLOPIyVmL+Y6X+abKGXUJlvOVLkB1QQkyXl7Uczc6FElUy/x01NS7r2GX6GRezloO/ecCX9h4U9KadA== integrity sha512-95ChzOoYLOPIyVmL+Y6X+abKGXUJlvOVLkB1QQkyXl7Uczc6FElUy/x01NS7r2GX6GRezloO/ecCX9h4U9KadA==
prelude-ls@^1.2.1:
version "1.2.1"
resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.2.1.tgz#debc6489d7a6e6b0e7611888cec880337d316396"
integrity sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==
prettier@^3.8.1: prettier@^3.8.1:
version "3.8.1" version "3.8.1"
resolved "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz" resolved "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz"
@@ -2924,11 +2576,6 @@ pretty-format@30.2.0:
ansi-styles "^5.2.0" ansi-styles "^5.2.0"
react-is "^18.3.1" react-is "^18.3.1"
punycode@^2.1.0:
version "2.3.1"
resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5"
integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==
pure-rand@^7.0.0: pure-rand@^7.0.0:
version "7.0.1" version "7.0.1"
resolved "https://registry.npmjs.org/pure-rand/-/pure-rand-7.0.1.tgz" resolved "https://registry.npmjs.org/pure-rand/-/pure-rand-7.0.1.tgz"
@@ -3175,13 +2822,6 @@ tslib@^2.8.1:
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f" resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f"
integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w== integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==
type-check@^0.4.0, type-check@~0.4.0:
version "0.4.0"
resolved "https://registry.yarnpkg.com/type-check/-/type-check-0.4.0.tgz#07b8203bfa7056c0657050e3ccd2c37730bab8f1"
integrity sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==
dependencies:
prelude-ls "^1.2.1"
type-detect@4.0.8: type-detect@4.0.8:
version "4.0.8" version "4.0.8"
resolved "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz" resolved "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz"
@@ -3232,13 +2872,6 @@ update-browserslist-db@^1.2.0:
escalade "^3.2.0" escalade "^3.2.0"
picocolors "^1.1.1" picocolors "^1.1.1"
uri-js@^4.2.2:
version "4.4.1"
resolved "https://registry.yarnpkg.com/uri-js/-/uri-js-4.4.1.tgz#9b1a52595225859e55f669d928f88c6c57f2a77e"
integrity sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==
dependencies:
punycode "^2.1.0"
v8-to-istanbul@^9.0.1: v8-to-istanbul@^9.0.1:
version "9.3.0" version "9.3.0"
resolved "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz" resolved "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz"
@@ -3267,11 +2900,6 @@ which@^2.0.1:
dependencies: dependencies:
isexe "^2.0.0" isexe "^2.0.0"
word-wrap@^1.2.5:
version "1.2.5"
resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.5.tgz#d2c45c6dd4fbce621a66f136cbe328afd0410b34"
integrity sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==
"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": "wrap-ansi-cjs@npm:wrap-ansi@^7.0.0":
version "7.0.0" version "7.0.0"
resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz" resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz"