Compare commits

...

11 Commits

Author SHA1 Message Date
1197d2171b fix: give every address a row of its own, so none wraps or is shortened (closes #380) (#381)
All checks were successful
check / check (push) Successful in 56s
e2e / e2e-chrome (push) Successful in 1m51s
e2e / e2e-firefox (push) Successful in 40s
2026-08-30 05:25:00 +02:00
a098bb0c32 fix: floor malformed allowedSites, fraudContracts and selectedToken entries (closes #362)
All checks were successful
check / check (push) Successful in 42s
e2e / e2e-chrome (push) Successful in 1m45s
e2e / e2e-firefox (push) Successful in 31s
A stored allowedSites whose value was not a list rendered a working popup and then made every subsequent save fail silently, so the user operated a wallet that persisted nothing -- worse than a blank popup, which is at least visibly broken. fraudContracts and selectedToken had the same shape: a container floored by truthiness or not at all, while its entries were dereferenced. Entries are now floored as well as containers, following the idiom #311 established, and a failed save raises a persistent banner instead of vanishing into a swallowed rejection.

The per-field justifications that used to live in a hand-written header are replaced by a contract test that drives each field's hostile and falsy values through a real popup boot, so a claim about a field answers to the code rather than to prose. Its guarantee is stated narrowly and deliberately: no structural dereference on the code paths a wholly-corrupted profile takes, which is not every path a stored record takes. The paths it does not drive are named where the claim is made, and are tracked in #379.
2026-08-23 23:06:17 +02:00
45500e66cf fix: declare and ship toolbar icons, so neither browser renders a puzzle piece (closes #371)
All checks were successful
check / check (push) Successful in 33s
e2e / e2e-chrome (push) Successful in 1m45s
e2e / e2e-firefox (push) Successful in 32s
Neither manifest declared any icons, so both browsers showed a generic puzzle-piece -- the first thing seen on every browser start, and how a user tells a real extension from a look-alike. Both manifests now declare 16/32/48/128, and the PNGs ship inside each browser archive rather than being left at dist/ root, which is the trap that made a naive zip incomplete before.

build.js reads which icons to copy from each manifest's own icons block, so the manifest is the single source of truth and a declared-but-absent size fails the build rather than shipping a dangling reference; the packager's reference-resolver covers them independently. Manifest values are constrained before being joined into a path. The artwork is original, generated from geometry rather than traced or fetched.
2026-08-23 21:26:14 +02:00
1b52aa1723 fix: store an absent explorer decimals as unknown instead of fabricating 18 (closes #349)
All checks were successful
check / check (push) Successful in 34s
e2e / e2e-chrome (push) Successful in 1m45s
e2e / e2e-firefox (push) Successful in 30s
parseInt(decimals || "18") ran before writing stored tokenBalances[].decimals, so an explorer reporting no decimals produced a fabricated 18 indistinguishable from a real one at read time. That defeated the resolve-or-refuse guarantees of #306 and #340: their refusal paths were intact but never fired, because the guess was laundered upstream of them.

An absent scale is now stored as unknown, and a holding whose scale nothing knows carries a null balance -- unknown, never zero -- with six reader sites saying so rather than printing 0.0000. The Send screen resolves the display scale rather than reading the stored one, so a bundled token whose explorer row omits decimals still sends; when the scale cannot be resolved the stored quantity is withdrawn too, so the user is told the balance is unknown rather than only that the fee failed.

Existing fabricated 18s cannot be told apart retroactively and are replaced wholesale on the next balance refresh. An explorer-sourced scale stays trusted -- only fabrication is removed; the reasoning is recorded on the issue.
2026-08-23 21:19:04 +02:00
75a5fa9891 harden: gate swap amounts on presence, not truthiness, so a figure always matches its token (closes #359)
All checks were successful
check / check (push) Successful in 32s
e2e / e2e-chrome (push) Successful in 1m45s
e2e / e2e-firefox (push) Successful in 29s
Token and amount were gated on truthiness and gated independently. An address is never falsy once set but 0n is, so a hop supplying a zero amount fixed the token permanently while leaving the amount open, and the next hop's figure was rendered against the first hop's token at that token's scale -- 0.5 WETH shown as 500000000000.0000 USDT. Nine defective gates are now presence checks, and fourteen address gates were converted defensively so a sixth instance of this class cannot grow.

Zero is not one thing. Established from v4-periphery: OPEN_DELTA is 0 and V4Router substitutes the full credit unconditionally, so a zero V4 exact-in amount means "swap the whole balance" and now reads "All available (V4 open delta)" rather than 0.0000, which would assert the exact inverse. amountOutMinimum gets no such mapping and universal-router special-cases only CONTRACT_BALANCE, so a zero minimum and a zero V2/V3 amount stay literal -- a zero floor reads "None (no minimum guaranteed)".

closes #364
2026-08-23 20:45:58 +02:00
c9ebac822a harden: state an undetermined swap input token as undetermined, not as ETH (closes #357)
All checks were successful
check / check (push) Successful in 33s
e2e / e2e-chrome (push) Successful in 1m45s
e2e / e2e-firefox (push) Successful in 31s
tokenInfo(null) yielded "ETH (native)", so a swap whose input token the calldata never named was asserted to the user as ETH. The determination established for the output side in #353 applies unchanged: Currency is a value type over address, so native ETH arrives as the truthy zero-address string and WRAP_ETH sets it explicitly -- null can only mean undetermined.

The rule now lives in tokenInfo() itself rather than at each call site, so the redundant output-side guard added by #356 is removed; both sides read one UNNAMED_CURRENCY constant and cannot drift. Verified by execution that a genuine native-ETH input still renders as ETH, including via WRAP_ETH and a V4 zero-address PoolKey, and that the real mainnet output-side fixture is unchanged.
2026-08-23 20:23:05 +02:00
ad6aa7b20d fix: version stored state, validate its shape, and give a corrupt blob a way out (closes #311)
All checks were successful
check / check (push) Successful in 33s
e2e / e2e-chrome (push) Successful in 1m46s
e2e / e2e-firefox (push) Successful in 34s
Stored state had no version and no structural validation, so a corrupt blob produced a completely blank popup with no message and no recovery control, and made every dApp RPC call from every page answer a generic -32603. There was no reset or wipe control anywhere in the UI.

saveState() now stamps a schema version and loadState() validates the shape. A version it does not understand, or a wallets array it cannot parse, lands on a recovery screen that names the problem, offers the stored record verbatim for export, and offers a destructive reset behind a typed confirmation. Unversioned but valid state -- which every existing install has -- migrates in place and keeps working; it is never shown a wipe prompt. A dApp call against unusable state answers -32007, which EIP-1474 leaves unassigned, rather than -32603. networkById() refuses an unknown id loudly instead of returning mainnet, and networkId is validated so a corrupt value cannot be used as an object key.

Fields the gate does not refuse are floored by type, container and entries both: a malformed trackedTokens or tokenBalances entry is dropped rather than dereferenced. Verified by an independent sweep of 1152 corrupt blobs producing no blank popup, with the same harness showing 9 blanks against the previous revision.
2026-08-23 20:04:00 +02:00
28a527295a harden: state an undetermined V4 output token as undetermined, not as ETH (closes #353)
All checks were successful
check / check (push) Successful in 34s
e2e / e2e-chrome (push) Successful in 1m47s
e2e / e2e-firefox (push) Successful in 31s
A V4 swap could set minOutput while leaving outputToken null, and tokenInfo(null) returned ETH at 18 decimals, so the approval screen named an asset the calldata never stated. Established from the V4 encoding that this is wrong rather than protocol semantics: Currency is a user-defined value type over address, so native ETH arrives as the truthy zero-address string and is already mapped to ETH, and an UNWRAP_WETH output is caught earlier — both genuine native-ETH routes are covered without null, so null can only mean no sub-action yielded a currency. A genuine native-ETH V4 swap still renders as ETH, pinned by test against the real mainnet fixture.

The same gate also let a later step overwrite minOutput while an earlier hop's outputToken stayed on screen, showing a figure against the wrong token. Fixed together as the same two lines and the same defect.
2026-08-23 18:13:20 +02:00
bd0a626e7b harden: stop the background reading the shared state singleton, and enforce it at build time (closes #324)
All checks were successful
check / check (push) Successful in 33s
e2e / e2e-chrome (push) Successful in 1m45s
e2e / e2e-firefox (push) Successful in 31s
Five defects, one of which destroyed every wallet, came from src/background reading and writing the module-level state singleton the MV3 worker never populates, which silently served DEFAULT_STATE. Each point fix created the next defect. The background now has its own per-call getState() and a queued read-modify-write updateState(); the singleton is unreachable from it, and an unpopulated read throws instead of serving defaults.

The prohibition is enforced by the build, not by review: build.js asserts over esbuild's own metafile that no forbidden module is an input of a background bundle, so every specifier syntax esbuild resolves is covered, and both halves of the table are checked for rot -- a stale key, a stale module, an empty list, or an unlisted entry point under src/background/ all fail the build. The ESLint rule remains as fast local feedback and reads the same shared table. Known bounds are documented where the table lives.

Also closes #320: getProvider() now requires a validated network id, so a cold worker no longer prepares a non-mainnet dApp transaction for mainnet and gets refused by the wallet's own verifier. backgroundRefresh() no longer mutates address objects across a network round trip, the broadcast path takes its endpoint and chain id from one snapshot, and eight test storage stubs now structured-clone on get as the real chrome.storage.local does.

closes #320
2026-08-23 17:57:30 +02:00
36bc6bee0e fix: always name a swap's output token, by address when no symbol is known (closes #346)
All checks were successful
check / check (push) Successful in 30s
e2e / e2e-chrome (push) Successful in 1m45s
e2e / e2e-firefox (push) Successful in 29s
The Token Out line was pushed only when a symbol was known, so a swap to a token absent from the bundled list showed a Min. received figure with no statement of which token was being received. It now falls back to the address, mirroring the Token In precedent in the same file. Composes with the unknown-scale refusal from #340: the address names the token while the figure declares its scale unknown. Native ETH out, ETH out via UNWRAP_WETH and bundled tokens render exactly as before.
2026-08-23 16:31:02 +02:00
43784cab3f harden: resolve or refuse the swap token scale instead of guessing 18 (closes #340)
All checks were successful
check / check (push) Successful in 29s
e2e / e2e-chrome (push) Successful in 1m47s
e2e / e2e-firefox (push) Successful in 31s
tokenInfo() returned decimals 18 for any token absent from the bundled list, so the swap approval line rendered a real 1000.00 of a 6-decimal token as 0.000000000001. The scale is now resolved from what the wallet already holds (bundled list, tracked tokens, explorer-reported decimals) or refused outright, matching the rule set for the ERC-20 path in #306. A refusal reuses unknownDecimalsAmount(), so it reads as "base units (decimals unknown)" with no decimal point and no symbol, and the same string propagates to rawValue so no downstream screen can render a figure the approval screen refused. No new network call on the approval path. Verified green on all three CI contexts: check, e2e-chrome, e2e-firefox.
2026-08-23 16:20:22 +02:00
85 changed files with 10015 additions and 932 deletions

206
README.md
View File

@@ -800,7 +800,12 @@ discoverable.
addresses visually, as a security feature.
- **Tailwind CSS**: Utility-first CSS via Tailwind. No custom CSS classes for
styling. Tailwind is configured with a minimal monochrome palette. This keeps
the styling co-located with the markup and eliminates CSS file management.
the styling co-located with the markup and eliminates CSS file management. The
handful of classes in `styles/main.css` are not styling: `.copy-flash-*`
carries the copy feedback animation, and `.am-address` carries the rule that
an address never wraps. Both are invariants that hold in every place they
appear, and spelling either out as repeated utilities is how one of those
places drifts away from the rest.
- **Vanilla JS**: No framework (React, Vue, Svelte, etc.). The popup UI is small
enough that vanilla JS with simple view switching is sufficient. A framework
would add bundle size, build complexity, and attack surface for no benefit at
@@ -849,6 +854,12 @@ that the portions still displayed will be more than adequate for the user to
verify addresses even in the case of address spoofing attacks. Clicking an
address will always copy the full, untruncated value.
As of the address-row layout change, no view invokes that exception: every
address in the popup is rendered on a row of its own, wide enough for all 42
characters, and no screen truncates one to fit. The cap is still enforced in
`truncateMiddle()` and the 32-character floor in `renderAddressHtml()`, so the
guarantee holds for any future caller; there simply are none today.
**Specific Exception — Transaction Detail view:** The transaction detail screen
is the authoritative record of a specific transaction and shows the exact,
untruncated amount with all meaningful decimal places (e.g. "0.00498824598498216
@@ -883,6 +894,46 @@ transaction detail view is the authoritative record and already shows exact
precision. The 4-decimal rule is unchanged everywhere else, including for
amounts at or above the floor on the approval screens.
The floor applies only where the token's scale is known. Where it is not, the
approval screen states base units instead of a quantity — see Unknown token
scale below — and no truncation happens at all.
**Specific Exception — unknown token scale:** Calldata carries base units and no
scale, so every amount on the dApp approval screen needs the token's `decimals`.
It is resolved from the bundled token list, then from the tokens the user
tracks, then from what the block explorer reported for the contract
(`resolveTokenDecimals()` in `src/shared/approvalAmount.js`). Where none of them
answers, the amount is not formatted: the line reads
`5000000000 base units (decimals unknown)`. A guessed scale is not an
approximation but a different number — 1,000 units of a 6-decimal token
formatted at 18 decimals reads `0.000000001` — on the screen whose only job is
to state what is being authorized. Both amount paths of that screen take this
rule: the ERC-20 `transfer`/`approve` line (`src/popup/views/approval.js`) and
the swap's `Amount` and `Min. received` lines (`src/shared/uniswap.js`). An
unbounded allowance or permit needs no scale to describe and is still shown as
`Unlimited`.
The rule holds only if nothing invents a scale UPSTREAM of it. Those three
sources are read as authoritative, so a value written into one of them cannot be
recognized as a guess afterwards: a fabricated `18` reads exactly like a real
`18`, and the refusal above then never fires. So `fetchTokenBalances()` in
`src/shared/balances.js` stores what the explorer reported or `null`, never a
default, and the same holds for the history list's token transfers in
`src/shared/transactions.js`. A token whose `decimals()` reverts has no scale
anywhere, and a holding of it carries no quantity either: its balance is `null`
— read as unknown, never as zero — and the balance list says so rather than
printing `0.0000` for money that is really there. `0` is a real scale and is
never treated as absent.
`tokenBalances[].decimals` is therefore the explorer's own answer and nothing
else, which is not the same question as the scale a screen should render at.
Anything that needs the second one calls `resolveTokenDecimals()` — the balance
list, the approval and swap lines, and the Send screen, which carries the
resolved scale onto the pending transaction for `transferAmount.js` to encode
and compare against. Reading the stored field directly instead answers `null`
for a bundled or tracked token the explorer merely omitted, which is not a
refusal the wallet has any reason to make.
#### Partial USD totals
Prices are fetched for the top 25 tokens only, so an address can hold assets the
@@ -966,6 +1017,87 @@ tokens with fewer than 1,000 holders" setting governs the transaction history
and the send-screen token selector, not this list. Tracked tokens with a zero
balance are listed as well while "Show tracked tokens with zero balance" is on.
#### Stored state and its version
The whole profile lives under a single extension-storage key, `autistmask`, and
carries a `schemaVersion``STATE_SCHEMA_VERSION` in
`src/shared/stateSchema.js`, currently `1`. Every write stamps it: the popup's
`saveState()` and the background's `updateState()` both do, so whichever context
wrote last, the record says which build's shape it is in.
Version 1 is the shape that shipped before versions existed, so a stored record
with no `schemaVersion` is version 1 rather than a defect: it loads normally and
is migrated in place by being stamped on the first write. An upgrade never shows
an existing user a warning about a profile that is perfectly good. The version
is bumped only when the MEANING of a stored field changes — a new field with a
sensible absent value is handled by `normalizePersisted()` and is not a bump,
because bumping for one would send every older install to StateRecovery for
nothing.
Every read of the record goes through `assertStateUsable()` first, on the raw
bytes, before normalization: `loadState()` for the popup and `getState()` for
the background. It refuses a record that is not an object, a `schemaVersion`
this build does not understand (a newer one included), a `wallets` that is not a
list of wallet records with address records in them, and a `networkId` that is
not a network in `src/shared/networks.js`. Refusing is the whole point — a
record the wallet cannot vouch for is never normalized, never written back, and
never half-loaded. The popup shows StateRecovery; a dApp gets a specific error
(`-32007`, an EIP-1474 server-error code the spec leaves unassigned) saying the
saved data cannot be read and that nothing was signed or sent, rather than the
generic `-32603` every request used to answer.
Every other field of the record is floored in `normalizePersisted()` rather than
gated, and the floor is not the same for every field. Some are type-checked as a
container AND entry by entry, because `[1, 2]` is a list, `{"0x…": "notalist"}`
is an object, and the dereference is one level below the container check; a
malformed entry is dropped, except in `networkEndpoints`, where the entry is
coerced so an unknown network's endpoints are not lost, and in `viewStack`,
where the stack is truncated at the first entry the popup will not reopen onto.
Some are type-checked as a scalar. The rest take the stored value verbatim,
because nothing dereferences them structurally.
Which field is which is not written in prose anywhere, deliberately.
`tests/persistedFieldContract.test.js` is the list: one row per persisted field,
naming the property that field's floor is claimed to have and proving it by
driving the real code with hostile values — and, for every field whose only
defence is that nothing dereferences it, by booting the real popup entry point
over that value onto every view the popup can reopen onto. That last part is
what makes the claim falsifiable, because this defect class lives on the restore
path rather than on the home screen. Read the claim narrowly, as that file
states it: what those boots prove is no structural dereference on the code paths
a WHOLLY-CORRUPTED PROFILE takes, which is not every path a stored record takes.
Not driven: any pairing of values the four slots do not produce, a view only
forward navigation opens, anything behind a click, and everything a healthy
profile reaches. Within that boundary the verdict is unconditional — if one of
those boots leaves the popup unhealthy or off the view it stored, `make check`
fails, including when it takes two corrupted fields at once, because the verdict
is the combined boot and the per-field re-boot that names a culprit can only
decorate the message. So does a field that gains a floor while its row still
claims it has none, and so does a field added to `PERSISTED_FIELDS` with no row
at all. The per-field justification that used to live in the header of
`src/shared/stateSchema.js` shipped a false claim in three consecutive changes,
each caught only by a reviewer re-deriving thirty fields by hand.
The `allowedSites` case is why the entry check is not optional. A stored
`{"0x…": "notalist"}` is a well-formed object holding a malformed entry: it
passed the gate, rendered a completely healthy popup, and then threw inside
`saveState()`'s per-hostname merge, so every save from that moment on failed and
the user went on operating a wallet that was persisting nothing
([#362](https://git.eeqj.de/sneak/AutistMask/issues/362)). A save that fails is
now also reported rather than swallowed: `onSaveFailure()` in
`src/shared/state.js` is called for every failed save, awaited or not, and the
popup puts up a persistent "NOT SAVED" banner (`showSaveFailureBanner()` in
`src/popup/views/helpers.js`). Storage can still fail for reasons no floor
covers — a quota, a revoked permission, a record a newer build wrote — and the
wallet must never look healthy while that is true.
The `networkId` check is not cosmetic: that value is an object KEY into
`state.networkEndpoints`, so an unvalidated `"__proto__"` would set the map's
prototype instead of an own key and the user's endpoint would silently not be
recorded. Every key test in the gate is an own-property test for that reason,
and `networkById()` throws on an id it does not know rather than quietly
answering mainnet.
#### Navigation
The main view shows all addresses grouped by wallet, with ETH balances inline.
@@ -992,10 +1124,12 @@ Three elements sit outside the screens and are present on all of them: the title
bar ("AutistMask by @sneak" plus the Settings gear), the flash message line
under it, and the red banner at the very top that appears on a debug build, when
runtime debug mode is on, or when the active network is a testnet. They are not
repeated in the element lists below.
repeated in the element lists below. StateRecovery is the one screen they are
not all present on: it hides the Settings gear, because it is shown precisely
when there is no profile for the screens behind that gear to render from.
Closing and reopening the popup returns to the screen the user was last on only
for the views listed in `RESTORABLE_VIEWS` (`src/popup/restorableViews.js`).
for the views listed in `RESTORABLE_VIEWS` (`src/shared/restorableViews.js`).
Every other screen falls back to Home. The screens that display a secret —
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
@@ -1010,6 +1144,14 @@ 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.
Those data guards check the ENTRIES of the stored `viewData`, not just the one
field each branch gates on, and the same goes for `selectedWallet` and
`selectedAddress`. `restoreView()` is not inside a `try`, so a `TypeError` in a
renderer skips the rest of popup init and leaves the user with no view, no
message and no control — the same blank popup by a longer route. Anything the
restore path dereferences is therefore either floored in `normalizePersisted()`
or refused by the guard, and the screen falls back to Home instead.
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
@@ -1051,13 +1193,17 @@ view would leave a wallet one click from deletion.
- Send / Receive quick-action buttons, both acting on the active address
- ETH/USD price display
- Wallet list: each wallet shows its name (tap to rename inline) and a "+"
button for HD and xprv wallets, then one block per address with "Address
N" (bold when active), the ENS name if resolved, the full address, an
`[info]` button, an `[x]` button (only on HD and xprv wallets holding more
than one address), the address USD total, and a balance line for ETH and
for each token shown for that address
button for HD and xprv wallets, then one block per address. The block
opens with a row carrying the colour dot, "Address N" (bold when active),
an `[info]` button and an `[x]` button (only on HD and xprv wallets
holding more than one address); the ENS name, if resolved, is below it;
then the full address on a row of its own, followed by the address USD
total and a balance line for ETH and for each token shown for that address
- "Recent Transactions": up to 25 transactions merged across every address
of every wallet, deduplicated by hash and filtered
of every wallet, deduplicated by hash and filtered. Each row is three
lines: age and direction, then the counterparty's colour dot (with our own
name for it, where it is one of our addresses) and the amount, then the
counterparty's full address on a row of its own
- "Add additional wallet..." link at bottom
- **Transitions**:
- Tap address row → sets the active address and broadcasts
@@ -1665,6 +1811,48 @@ view would leave a wallet one click from deletion.
- Popup window closed without answering → the request is rejected with
EIP-1193 code 4001
#### StateRecovery (`state-recovery`)
- **When**: `loadState()` refused the stored profile, so the popup has no
profile at all. It is the only screen reached without one, and the only one
that never appears during ordinary use.
- **Why it exists**: a record the wallet cannot read used to render nothing — no
view, no message, no control — while every dApp call answered a generic
internal error, and no reset or wipe control existed anywhere in the product.
The only escape was clearing extension storage through browser internals
([#311](https://git.eeqj.de/sneak/AutistMask/issues/311)).
- **Elements**:
- "Saved Data Cannot Be Read" heading, and a statement that nothing has been
changed or erased and nothing can be signed or sent
- The problem, in one sentence naming what is wrong with the record
- "Export Saved Data" button, and the read-only text box it fills
- What erasing does and does not do, in bold: every wallet stored in this
browser is deleted; nothing on chain changes and no money is moved
- A text input asking for `ERASE MY WALLET` to be typed back
- Error line
- "Erase Saved Data" button
- **Transitions**:
- "Export Saved Data" → the raw stored record, verbatim, in the text box on
the screen, and a downloaded `autistmask-saved-data.json` where the
browser allows one. The box is filled first and never depends on the
download: an export that can fail is not an export.
- "Erase Saved Data" (phrase typed) → the stored record is removed and the
popup reloads into **Welcome**
- "Erase Saved Data" (phrase not typed) → "Type ERASE MY WALLET to confirm.
Nothing was erased." on the error line
- **No other control is reachable.** The Settings gear is hidden while this
screen is up, because every screen behind it renders from the profile that
could not be read, and `showView()` is not used to raise it for the same
reason — it reads and writes the state singleton.
- **Both controls are required.** An export with no reset leaves the user
looking at a broken profile with no way to use the wallet again; a reset with
no export destroys the only copy of a record that may hold recoverable key
material. The typed phrase is the same barrier DeleteWalletLostPassword uses,
and for the same reason: there is no password to gate this with, since there
is no profile to check one against.
- Not in `RESTORABLE_VIEWS`: it is never persisted as the current view, because
nothing on this path writes state at all.
### External Services
AutistMask is not a fully self-contained offline tool. It necessarily

251
TODO.md
View File

@@ -45,6 +45,257 @@ but the review is broader than any of them.
# Completed Steps
- 2026-08-30: An address no longer wraps, or is shortened to fit, in any of the
common views ([#380](https://git.eeqj.de/sneak/AutistMask/issues/380)). The
wallet list was the reported case: the address shared one row with the
`[info]` and `[x]` controls and folded onto a second line, which turns one
42-character string the user is meant to compare into two shorter ones — the
shape an address-poisoning attack wants. The fix is layout, not CSS: every
address in the popup now sits alone on a full-width row, with the colour dot,
the wallet title, the ENS name and the explorer link moved onto a strip above
it, and the transaction rows carry the counterparty's whole address instead of
a `truncateMiddle()`d one squeezed in beside the amount. `truncateMiddle()`
keeps its 10-character cap and its 32-character floor moved into
`renderAddressHtml()`, so the guarantee outlives having no callers. The e2e
suite measures every rendered address in a real Chromium — whole, one line
box, inside its row and inside the popup — across Home, the address, token,
receive, send and transaction detail screens, the confirmation screen and the
dApp transaction prompt.
- 2026-08-23: Both manifests declare toolbar icons, and real PNGs at
16/32/48/128 ship inside both archives
([#371](https://git.eeqj.de/sneak/AutistMask/issues/371)). Neither manifest
had an `icons` block, so both browsers drew a generic puzzle piece — the first
thing the owner sees on every launch, and how a user tells a real extension
from a look-alike. The sizes `build.js` copies into each browser directory are
read out of the manifest that ships next to them rather than from a second
list, so a declared size `icons/` does not hold fails `make build`;
`script/lib/package.js` already resolves `.png` references, so an icon that
reached a manifest but not the archive fails packaging. The artwork is
original: a flat dark-navy rounded field with a teal triangular "A", drawn
from geometry and rasterised into PNG, nothing traced or downloaded.
- 2026-08-23: A persisted container whose ENTRIES were dereferenced unchecked no
longer reaches a `.map()` or a `.toLowerCase()`
([#362](https://git.eeqj.de/sneak/AutistMask/issues/362)). `allowedSites` was
the worst shape available: a stored `{"0x…": "notalist"}` passed the gate,
rendered a completely healthy popup, and then threw inside `saveState()`'s
per-hostname merge, so every save from that moment on failed silently and the
user went on operating a wallet that was persisting nothing — measured as
`chrome.storage.local.set` never being called at all. `deniedSites` has the
same shape, `fraudContracts` the same class with a milder consequence, and the
sweep for the class turned up `selectedToken`, `rpcUrl` (handed whole to
`new JsonRpcProvider()`, which throws synchronously outside any `try`), the
entries of `viewData` (four restore branches gate on one truthy field and then
dereference an address), and `selectedWallet`/`selectedAddress` (a stored
`"map"` is TRUTHY against a real Array, so the restore guard does not
short-circuit). All of them are now floored in `src/shared/persistedState.js`
or refused by the per-branch guards in `src/popup/viewRouter.js`. Separately,
a save that fails is no longer swallowed: `onSaveFailure()` in
`src/shared/state.js` reports every failed save, awaited or not, and the popup
raises a persistent "NOT SAVED" banner. The hand-written per-field
justification in the header of `src/shared/stateSchema.js` — which had shipped
a false claim in three consecutive changes — is replaced by
`tests/persistedFieldContract.test.js`, one row per persisted field, each
proven by driving the real code with hostile values — and, for a field whose
only defence is that nothing dereferences it, by booting the real popup entry
point over that value onto every view the popup can reopen onto, since that is
the path this whole class of defect lives on. Each such field is driven at
both polarities — a value nothing writes is wrong-typed and so truthy, so a
falsy slot is driven too, or the field is proven unable to be falsy after the
floor. The claim is narrow and stated as such: no structural dereference on
the code paths a wholly-corrupted profile takes, which is not every path a
stored record takes — a pairing of values the four slots do not produce, a
view only forward navigation opens, anything behind a click, and everything a
healthy profile reaches are all undriven. Within that boundary the verdict is
unconditional, including a dereference that takes two corrupted fields at
once, since the assertion is on the combined boot and the per-field re-boot
can only decorate the message. A field with no row and a field that gains a
floor while its row still claims it has none also fail `make check`.
- 2026-08-23: A swap amount and the token it is counted in now always come from
the same hop, on both sides of the approval screen
([#359](https://git.eeqj.de/sneak/AutistMask/issues/359) and
[#364](https://git.eeqj.de/sneak/AutistMask/issues/364), the output and input
halves of one gate, fixed as one unit). `src/shared/uniswap.js` gated the
token and the amount on truthiness and independently; an address is never
falsy once set but an amount of `0n` is, so a hop supplying a zero amount
fixed the token and left the amount open, and the next hop's figure was then
rendered against the first hop's token at that token's scale — 0.5 WETH shown
as `500000000000.0000 USDT`, and an earlier hop's `Min. received` shown for a
final leg that guarantees nothing. Both sides are now set as a pair through
explicit presence, a zero slippage floor reads `None (no minimum guaranteed)`,
and V4's `OPEN_DELTA` (an `amountIn` of zero, which `V4Router` reads as "swap
the whole open credit") reads `All available (V4 open delta)` instead of
`0.0000`.
- 2026-08-23: A swap whose input token the calldata never named is said to be
unknown instead of being called ETH
([#357](https://git.eeqj.de/sneak/AutistMask/issues/357)), the twin on the
input side of [#353](https://git.eeqj.de/sneak/AutistMask/issues/353). A null
`inputToken` rendered as `Token In: ETH (native)` and titled the swap
`Swap ETH -> X`, asserting the user was paying native ETH when nothing in the
calldata said so. The null-means-ETH collapse is now gone from `tokenInfo()`
itself rather than guarded at each call site: null is refused, and native ETH
keeps arriving as the explicit zero address that `WRAP_ETH` and V4's
`Currency.wrap(address(0))` both use.
- 2026-08-23: A swap whose output token the calldata never named is said to be
unknown instead of being called ETH
([#353](https://git.eeqj.de/sneak/AutistMask/issues/353)). `tokenInfo(null)`
answers `{symbol: "ETH", decimals: 18}`, and a V4 step could take the
`Min. received` figure while naming no output currency, so the approval screen
stated the wrong asset at the wrong scale. Null is not how V4 spells native
ETH: v4-core's `type Currency is address` wraps `address(0)` for it, which
reaches the decoder as the truthy string
`0x0000000000000000000000000000000000000000` and is named ETH there already.
`Token Out` now reads `Unknown (not named in the calldata)` and
`Min. received` falls to the base-unit refusal from
[#340](https://git.eeqj.de/sneak/AutistMask/issues/340).
- 2026-08-23: The stored profile carries a schema version, and a record the
wallet cannot read produces a screen instead of a blank popup
([#311](https://git.eeqj.de/sneak/AutistMask/issues/311)). `saveState()` and
`updateState()` both stamp `STATE_SCHEMA_VERSION`
(`src/shared/stateSchema.js`), and every read goes through
`assertStateUsable()` on the raw bytes before normalization gets a chance to
paper over them. Version 1 is the shape that shipped unversioned, so the
profile every existing install holds loads normally and is migrated in place
by being stamped on the first write — an upgrade shows nobody a wipe prompt
for a wallet that is fine. A record this build cannot vouch for is refused
instead: not normalized, not written back, not half-loaded. The popup shows
the new StateRecovery screen, which names the problem, exports the raw record
verbatim into the page (and downloads it where the browser allows), and offers
an erase behind a typed `ERASE MY WALLET` — both controls, because an export
with no reset leaves the user stuck and a reset with no export destroys the
only copy of possibly recoverable key material. The background refuses the
same record and answers dApps `-32007` — a code EIP-1474 leaves unassigned,
unlike `-32000`..`-32006` — with a message saying the saved data cannot be
read and that nothing was signed or sent, rather than the generic `-32603`
that every request used to get. Fields the gate deliberately does not check
produced the same blank popup on their own: `trackedTokens` and
`activeAddress` were floored on truthiness rather than on type, and
`trackedTokens`' ENTRIES and each address's `tokenBalances` were not floored
at all — `[1, 2]` is a list, and the dereference is `t.address.toLowerCase()`
one level below the container. All of them are type-checked now, entries
included, and the header of `src/shared/stateSchema.js` lists which fields of
the record get a type check and which get a `saved.x || default` or a verbatim
passthrough, rather than asserting a rule the module does not follow.
`networkById()` now throws on an id it does not know instead of quietly
answering mainnet, and the gate's key tests are all own-property tests:
`networkId` is an object key into `networkEndpoints`, so an unvalidated
`"__proto__"` used to set that map's prototype and drop the user's endpoint
silently. The three corrupt blobs from the issue drive the real popup entry
point in `tests/stateRecovery.test.js` and the real worker in
`tests/stateUnusableRpc.test.js`; each rendered nothing at all and answered
`-32603` before this. `src/popup/restorableViews.js` moved to
`src/shared/restorableViews.js`, since `persistedState.js` requires it and
that module is in the background bundle.
- 2026-08-23: An explorer that reports no `decimals` for a token no longer has a
scale invented for it before storage
([#349](https://git.eeqj.de/sneak/AutistMask/issues/349)).
`fetchTokenBalances()` did `parseInt(item.token.decimals || "18", 10)` on the
way in, so a token whose `decimals()` reverts was written to
`tokenBalances[].decimals` as a fabricated `18` that no reader could tell from
a real one. That is upstream of the resolve-or-refuse rule
([#306](https://git.eeqj.de/sneak/AutistMask/issues/306),
[#340](https://git.eeqj.de/sneak/AutistMask/issues/340)): both approval paths
read this stored value as an authoritative source, so the guess walked past
refusals that were intact and simply never fired. The stored value is now the
explorer's own answer or `null`, and both the ERC-20 amount line and the swap
lines reach `unknownDecimalsAmount()` on it. The history list's token
transfers carried the same `|| "18"` and now state base units with the scale
unknown rather than a quantity. A holding whose scale nothing knows carries
`balance: null` — unknown, not zero — and the balance list, the USD total, the
Send screen and the confirmation screen each say so instead of printing
`0.0000` for money that is really there. The uint8 check is one shared
`toDecimals()` rather than three copies, and it answers `0` for a real scale
of zero: `|| "18"` collapsed that to eighteen, the trap of
[#246](https://git.eeqj.de/sneak/AutistMask/issues/246). Existing installs
hold `18`s that cannot be told apart retroactively; they display exactly as
they do today until the next balance refresh, which rewrites `tokenBalances`
wholesale and needs no user action. No `|| 18` or `?? 18` fallback remains
anywhere in `src/`; the literal `18`s that do remain are real data, not
defaults — 432 per-token `decimals: 18` entries in the bundled
`src/shared/tokenList.js`, and, outside that file, only native ETH's
protocol-defined scale in `src/shared/uniswap.js` and the fixed-point
comparison scale in `src/shared/txValidation.js`. `tokenBalances[].decimals`
is the explorer's answer alone and not the scale a screen renders at, so the
Send screen resolves through `resolveTokenDecimals()` like every other
consumer: reading the stored field raw carried a `null` into `estimateGas()`
for a bundled token such as WETH, which reported an unestimable network fee
and left Send disabled behind a message no retry could clear. Send resolves
with `wallets`, which adds the cross-address disagreement check the balance
list does not make, so the two can differ; where they do, the stored quantity
was computed at a scale Send has refused, and it is withdrawn with it. An
unknown scale is an unknown balance, and the user is told that rather than
that the fee could not be estimated.
- 2026-08-23: The background no longer reads or writes the shared `state`
singleton ([#324](https://git.eeqj.de/sneak/AutistMask/issues/324)), which
also closes the cold-worker wrong-chain send
([#320](https://git.eeqj.de/sneak/AutistMask/issues/320)). One in-memory copy
loaded once is the popup's lifetime, not the MV3 worker's: the worker is
killed when idle, nothing loaded state at module scope, and an unpopulated
read was answered out of `DEFAULT_STATE` in silence. Five defects traced to
that, and every point fix added a `loadState()` that created the next one — a
load detaches the objects an in-flight handler is holding. The background now
has its own storage layer (`src/background/state.js`): `getState()` for a
detached per-call read, `updateState()` for a queued read-modify-write.
`backgroundRefresh()` refreshes a private copy and applies the balances that
came back by address, so a wallet added, renamed or deleted during the round
trip survives. The transaction attempt takes its chain id and its endpoint
from one snapshot, so a committed chain switch can no longer move the endpoint
under an artifact already verified against the old chain. `getProvider()` now
REQUIRES the network id, which is what closes
[#320](https://git.eeqj.de/sneak/AutistMask/issues/320) at the shape rather
than at the call site. The prohibition is enforced by `build.js`, which fails
the build when esbuild's own metafile reports `src/shared/state.js` as an
input of either background bundle — the resolution the shipped bundle was
actually built from, so no specifier syntax and no resolution rule can slip
past it, and `make build` runs in CI. A bundled entry point under
`src/background/` with no line in the table fails the build too, so a second
worker entry point is protected by default rather than only if whoever adds it
knows the table exists. The assertion itself is pinned by
`tests/buildForbiddenInputs.test.js`, including every way its table can rot: a
key no bundled entry point matched, a forbidden module this build bundled
nowhere, and an entry that lists no modules (which would otherwise empty the
lint rule's forbidden set as well, and is refused at require time). Its bound
is that it is keyed by path, so a COPY of the singleton at another path is
outside it — loud for three of the five defects and silent for the other two;
the bounds are recorded in full where the table lives
(`script/lib/forbiddenBundleInputs.js`). An ESLint rule that walks the require
graph textually gives the same answer in the editor, before a full bundle; it
reads the same table, and it is fast feedback rather than the guarantee. The
shapes it catches are pinned by `tests/backgroundStateLintRule.test.js`, and
so are the two it misses — a computed specifier and a symlink — as asserted
non-reports, which the build fails on. Reading an unloaded singleton now
throws `StateNotLoadedError` instead of serving defaults. The
`chrome.storage.local` stubs in eight test files aliased instead of
structured-cloning, which could let an assertion pass on a build that never
wrote anything; every test that drives real persistence now goes through
`tests/support/storageStub.js`.
- 2026-08-23: A swap always names its output token
([#346](https://git.eeqj.de/sneak/AutistMask/issues/346)). The `Token Out`
detail line in `src/shared/uniswap.js` was pushed only when a symbol was
known, so a swap whose output token is absent from the bundled list — every
newly listed token — showed a `Min. received` figure with nothing saying what
was being received. The line is now keyed on the token's address and falls
back to it when there is no symbol, exactly as the `Token In` line already
did. It composes with the unknown-scale refusal from
[#340](https://git.eeqj.de/sneak/AutistMask/issues/340): the address says
which token, the base-unit figure says how much and states that the scale is
unknown.
- 2026-08-23: The swap approval screen no longer guesses 18 decimals for a token
outside the bundled list
([#340](https://git.eeqj.de/sneak/AutistMask/issues/340)). `tokenInfo()` in
`src/shared/uniswap.js` returned `decimals: 18` for any such token — the same
assumption [#306](https://git.eeqj.de/sneak/AutistMask/issues/306) removed
from the ERC-20 amount line — so a 1,000-unit swap of a 6-decimal token was
stated as `0.000000001`, and every newly listed token reached it. The swap's
`Amount` and `Min. received` lines now resolve the scale through
`resolveTokenDecimals()`, the same bundled-list-then-tracked-then-explorer
order the ERC-20 line uses, and where nothing knows it they render
`unknownDecimalsAmount()` — base units with the scale stated — instead of a
number. No new data source and no network call: the scale comes only from what
the wallet already holds. An unbounded permit is still shown as `Unlimited`.
`README.md` records the rule as a Display Consistency exception.
- 2026-08-23: A failed release build no longer leaves a loadable debug bundle in
`dist/` ([#333](https://git.eeqj.de/sneak/AutistMask/issues/333)). With
`AUTISTMASK_DEBUG=1` exported, `make build` compiled a debug bundle and failed

266
build.js
View File

@@ -4,6 +4,11 @@ const crypto = require("crypto");
const { execSync } = require("child_process");
const esbuild = require("esbuild");
const { resolveVersion } = require("./script/lib/version");
const {
BACKGROUND_ENTRY_PREFIX,
FORBIDDEN_INPUTS,
assertTableWellFormed,
} = require("./script/lib/forbiddenBundleInputs");
const DIST = path.join(__dirname, "dist");
const DIST_CHROME = path.join(DIST, "chrome");
@@ -16,6 +21,18 @@ const SRC = path.join(__dirname, "src");
// rotting with it.
const AUDITED_MODULE = "src/shared/constants.js";
// FORBIDDEN_INPUTS — what each entry point's bundle may not contain, and what
// that covers — lives in script/lib/forbiddenBundleInputs.js, because the
// ESLint rule reads the same table and two literal copies of a path drift.
//
// This is the authoritative check, and it is here rather than in the linter
// because it consults the resolution esbuild actually performed. Any specifier
// syntax, any hop, any resolution rule that puts the module in the bundle fails
// the build, whether or not a text matcher would have recognized it. A
// background entry point the table does not name fails as well, so a second
// worker is protected by default rather than by someone remembering this file.
// Dockerfile:42 runs `make build`, so it is enforced in CI.
// The build receipt: every file this build emits, with its sha256 and whether
// it is one of the audited bundles. script/verify-build is handed this and
// checks dist/ against it, so the file list comes from the build that just ran
@@ -34,6 +51,17 @@ const RECEIPT_ENV = "AUTISTMASK_BUILD_RECEIPT";
// rather than writing a receipt that cannot be checked.
const SAFE_EMITTED_PATH = /^dist\/[A-Za-z0-9._][A-Za-z0-9._/-]*$/;
// Where each browser directory's manifest comes from, and — through its
// "icons" — which image files ship inside that directory.
const MANIFEST_SOURCES = new Map([
[DIST_CHROME, path.join(__dirname, "manifest", "chrome.json")],
[DIST_FIREFOX, path.join(__dirname, "manifest", "firefox.json")],
]);
// What an "icons" entry may name: a plain file under icons/, so a manifest
// value is never joined into a path that leaves the repo.
const ICON_REF_RE = /^icons\/[A-Za-z0-9._-]+\.png$/;
function ensureDir(dir) {
fs.mkdirSync(dir, { recursive: true });
}
@@ -65,6 +93,164 @@ function outputsContainingAuditedModule(metafile) {
.map(([outFile]) => repoRelative(outFile));
}
// Shortest import chain from `entryInput` to `target` through the metafile's
// own input graph, or null when there is none. The message this feeds is the
// point of the check: "state.js is in the worker bundle" is not actionable on
// its own, "index.js -> chainSwitchFields.js -> state.js" is.
function importChain(metafile, entryInput, target) {
const graph = new Map(
Object.entries(metafile.inputs).map(([input, info]) => [
repoRelative(input),
(info.imports || []).map((i) => repoRelative(i.path)),
]),
);
const start = repoRelative(entryInput);
const seen = new Set([start]);
const queue = [[start]];
while (queue.length > 0) {
const chain = queue.shift();
for (const next of graph.get(chain[chain.length - 1]) || []) {
if (next === target) return chain.concat([next]);
if (seen.has(next)) continue;
seen.add(next);
queue.push(chain.concat([next]));
}
}
return null;
}
// What the forbidden-input checks accumulate over a whole build: which
// FORBIDDEN_INPUTS keys were actually bundled, and every input of every output
// this build emitted. Both are read by assertForbiddenTableCovered() at the
// end — a table entry naming something that is not there any more enforces
// nothing, and must fail rather than pass quietly.
function newForbiddenRecord() {
return { entriesChecked: new Set(), bundledInputs: new Set() };
}
// Note every input of every output of one esbuild run. Deliberately not
// restricted to the entry points named in FORBIDDEN_INPUTS: it is the POPUP
// that legitimately bundles src/shared/state.js, and that is what makes
// "the forbidden module still exists at this path" checkable at all.
function recordBundledInputs(metafile, record) {
for (const info of Object.values(metafile.outputs)) {
for (const input of Object.keys(info.inputs)) {
record.bundledInputs.add(repoRelative(input));
}
}
}
// Fail the build when an entry point's bundle contains a module it is
// prohibited from reaching. The inputs come from esbuild's metafile, so this is
// the resolution the shipped bundle was built from and not a guess at it.
//
// A background entry point with no line in the table fails here too. The five
// defects this exists to prevent were accidents, and so is adding a second
// worker entry point without knowing that a table somewhere needs a line: the
// protection has to be the default for that directory rather than something
// the next author must opt into.
function assertNoForbiddenInputs(
entryPoint,
outfile,
metafile,
record,
table = FORBIDDEN_INPUTS,
) {
const entry = repoRelative(entryPoint);
const forbidden = table[entry];
if (!forbidden) {
if (!entry.startsWith(BACKGROUND_ENTRY_PREFIX)) return;
throw new Error(
`${entry} is a background entry point with no line in ` +
`FORBIDDEN_INPUTS, so nothing stops its bundle from ` +
`containing the shared state singleton. Add it to ` +
`script/lib/forbiddenBundleInputs.js. The MV3 worker never ` +
`populates that singleton, so reading it serves ` +
`DEFAULT_STATE; use getState()/updateState() from ` +
`src/background/state.js instead.`,
);
}
const out = repoRelative(outfile);
const entryOutput = Object.entries(metafile.outputs).find(
([outFile]) => repoRelative(outFile) === out,
);
if (!entryOutput) {
throw new Error(`esbuild reported no metafile output for ${out}`);
}
const inputs = new Set(
Object.keys(entryOutput[1].inputs).map(repoRelative),
);
// Recorded only once the bundle's inputs are actually in hand. Marking the
// entry checked any earlier — as this did — means an early return above
// satisfies assertForbiddenTableCovered() with a bundle nobody examined,
// and the coverage half cannot tell that from a real check. The lookup
// above is the fragile step: repoRelative() resolves against process.cwd()
// while esbuild's output keys are cwd-relative, so a change to where the
// build runs from could miss.
record.entriesChecked.add(entry);
for (const module of forbidden) {
if (!inputs.has(module)) continue;
const chain = importChain(metafile, entryPoint, module);
throw new Error(
`${out} bundles ${module}, which ${entry} must not reach` +
`${chain ? `: ${chain.join(" -> ")}` : ""}. The MV3 worker ` +
`never populates the shared state singleton, so reading it ` +
`serves DEFAULT_STATE. Use getState()/updateState() from ` +
`src/background/state.js instead.`,
);
}
}
// Fail the build when the table has rotted away from the tree it describes.
// Both halves of an entry rot independently, and either one turns the whole
// prohibition into a pass that checks nothing:
//
// - the KEY, when no bundled entry point matches it: the entry point was
// renamed or is no longer built, and no bundle was ever tested against the
// list;
// - the MODULE, when this build bundled it nowhere: the module was renamed,
// moved or deleted, so "is it an input of the background bundle" is asked
// about a path nothing resolves to and is answered no forever. The popup
// legitimately bundles src/shared/state.js, which is what makes this
// checkable — and it is stronger than an existsSync(), because it also
// fails when the file is still there but has dropped out of every bundle.
//
// This matters concretely: https://git.eeqj.de/sneak/AutistMask/issues/311
// rewrites this persistence layer, and a rename that quietly disarmed the
// guarantee would put the singleton back within reach of the worker with every
// check in the repo still green.
//
// The third way — an entry that lists no modules at all — is refused where the
// table is defined, at require time, because that one also empties the ESLint
// rule's forbidden set and so has to fail before either layer runs. It is
// re-checked here so the build's own half does not depend on the table having
// been loaded from that file.
function assertForbiddenTableCovered(record, table = FORBIDDEN_INPUTS) {
assertTableWellFormed(table);
for (const [entry, modules] of Object.entries(table)) {
if (!record.entriesChecked.has(entry)) {
throw new Error(
`${entry} is listed in FORBIDDEN_INPUTS but was not bundled, ` +
`so nothing checked it`,
);
}
for (const module of modules) {
if (record.bundledInputs.has(module)) continue;
throw new Error(
`${module} is listed in FORBIDDEN_INPUTS for ${entry}, but ` +
`this build bundled it nowhere, so the prohibition names ` +
`a module that is not in this tree at that path and ` +
`nothing enforces it. If the module moved, move it in ` +
`script/lib/forbiddenBundleInputs.js too, which both ` +
`this check and the ESLint rule read.`,
);
}
}
}
// Every file this build writes under dist/, recorded as it is written. This is
// the build's own account of what it emitted; it is never recovered by
// listing dist/, because a file that is in dist/ without this build having put
@@ -82,6 +268,42 @@ function copyEmitted(src, dest) {
recordEmitted(dest);
}
// Copy the icons one browser directory ships. The sizes come from the manifest
// that will sit next to them, not from a second list here: a size the manifest
// declares and icons/ does not hold fails the build, rather than shipping a
// manifest whose reference resolves to nothing. Relative to the browser
// directory, so nothing points up and out of it the way dist/styles.css does.
function copyIcons(distDir) {
const manifestPath = MANIFEST_SOURCES.get(distDir);
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
const refs = Object.values(manifest.icons || {});
if (refs.length === 0) {
throw new Error(
`${repoRelative(manifestPath)} declares no icons, so the browser ` +
`renders a generic placeholder for this extension`,
);
}
for (const ref of refs) {
if (!ICON_REF_RE.test(ref)) {
throw new Error(
`${repoRelative(manifestPath)} declares icon ` +
`${JSON.stringify(ref)}, which is not a plain file under ` +
`icons/`,
);
}
const src = path.join(__dirname, ref);
if (!fs.existsSync(src)) {
throw new Error(
`${repoRelative(manifestPath)} declares ${ref}, which is not ` +
`in this tree`,
);
}
const dest = path.join(distDir, ref);
ensureDir(path.dirname(dest));
copyEmitted(src, dest);
}
}
function sha256File(absPath) {
return crypto
.createHash("sha256")
@@ -252,6 +474,9 @@ async function build() {
// esbuild run below and recorded in the receipt for script/verify-build.
const auditedBundles = [];
// What the forbidden-input checks accumulate across those same runs.
const forbiddenRecord = newForbiddenRecord();
// compile tailwind CSS
console.log("Compiling Tailwind CSS...");
const tailwindInput = path.join(SRC, "popup", "styles", "main.css");
@@ -293,6 +518,15 @@ async function build() {
metafile: true,
define,
});
// Before the output is recorded as emitted: a bundle that violates a
// prohibition must abort the build, not be written into a receipt.
recordBundledInputs(result.metafile, forbiddenRecord);
assertNoForbiddenInputs(
entryPoint,
outfile,
result.metafile,
forbiddenRecord,
);
recordEmitted(outfile);
auditedBundles.push(...outputsContainingAuditedModule(result.metafile));
}
@@ -337,6 +571,8 @@ async function build() {
tailwindOutput,
path.join(distDir, "src", "popup", "styles.css"),
);
copyIcons(distDir);
}
// copy manifests
@@ -349,6 +585,8 @@ async function build() {
path.join(DIST_FIREFOX, "manifest.json"),
);
assertForbiddenTableCovered(forbiddenRecord);
// Written last so a build that died partway through leaves no receipt at
// all, which script/verify-build treats as a hard failure rather than as
// "nothing to check".
@@ -359,7 +597,27 @@ async function build() {
console.log("Build complete: dist/chrome/ and dist/firefox/");
}
build().catch((err) => {
console.error(`Build failed: ${err && err.message ? err.message : err}`);
process.exit(1);
});
// Run only as a program. Required as a module — which is how
// tests/buildForbiddenInputs.test.js reaches the checks below — this file
// builds nothing and writes nothing.
if (require.main === module) {
build().catch((err) => {
console.error(
`Build failed: ${err && err.message ? err.message : err}`,
);
process.exit(1);
});
}
// Exported for tests/buildForbiddenInputs.test.js only. The prohibition these
// three functions enforce is the guarantee behind
// https://git.eeqj.de/sneak/AutistMask/issues/324, and `make check` does not
// run `make build` — so they are unit tested against synthetic metafiles
// rather than being exercised only by CI, where "it ran" is not "it works".
module.exports = {
importChain,
newForbiddenRecord,
recordBundledInputs,
assertNoForbiddenInputs,
assertForbiddenTableCovered,
};

View File

@@ -8,6 +8,10 @@
const js = require("@eslint/js");
const globals = require("globals");
const backgroundState = require("./script/lib/eslint/noStateSingletonInBackground");
const {
BACKGROUND_ENTRY_PREFIX,
} = require("./script/lib/forbiddenBundleInputs");
// The extension APIs. MV3 Chrome exposes `chrome`; Firefox exposes both, and
// the code feature-detects between them.
@@ -78,12 +82,36 @@ module.exports = [
},
// MV3 background: a service worker, with no window and no document.
//
// It also may not reach src/shared/state.js. That module's `state` export
// is a per-bundle singleton loaded once and mutated in place, which is the
// popup's lifetime and not the worker's: the worker is killed when idle,
// nothing loads state at module scope, and an unpopulated read used to be
// served DEFAULT_STATE silently. Five defects came from background code
// reading or writing it (https://git.eeqj.de/sneak/AutistMask/issues/324),
// and each point fix added a loadState() that created the next one. The
// rule below checks reachability through the whole require graph, not just
// the direct require, because a re-export from any shared module the
// background already pulls in would put the singleton back in the bundle
// with no background file naming it.
//
// It is not the guarantee: build.js asserts the same prohibition against
// esbuild's own metafile, from the shared table in
// script/lib/forbiddenBundleInputs.js. This is the early report.
//
// The glob comes from that same file, because build.js uses the prefix to
// decide which entry points must be listed in the table at all: the two
// layers must not disagree about which files are "the background".
{
files: ["src/background/**/*.js"],
files: [`${BACKGROUND_ENTRY_PREFIX}**/*.js`],
plugins: { background: backgroundState },
languageOptions: {
...commonjs,
globals: { ...globals.serviceworker, ...extensionGlobals },
},
rules: {
"background/no-state-singleton-in-background": "error",
},
},
// src/shared is bundled into both, so it may only use what both provide:
@@ -107,9 +135,9 @@ module.exports = [
},
},
// Unit tests: jest on node.
// Unit tests, and the helpers they require: jest on node.
{
files: ["tests/**/*.test.js"],
files: ["tests/**/*.test.js", "tests/support/**/*.js"],
languageOptions: {
...commonjs,
globals: { ...globals.node, ...globals.jest },

BIN
icons/icon128.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

BIN
icons/icon16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 292 B

BIN
icons/icon32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 534 B

BIN
icons/icon48.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 725 B

View File

@@ -9,6 +9,12 @@
"content_security_policy": {
"extension_pages": "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; object-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' https: http:; frame-src 'none'; form-action 'none'; base-uri 'none'"
},
"icons": {
"16": "icons/icon16.png",
"32": "icons/icon32.png",
"48": "icons/icon48.png",
"128": "icons/icon128.png"
},
"action": {
"default_popup": "src/popup/index.html"
},

View File

@@ -5,6 +5,12 @@
"description": "Minimal Ethereum wallet for Firefox",
"permissions": ["storage", "activeTab", "alarms", "<all_urls>"],
"content_security_policy": "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; object-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' https: http:; frame-src 'none'; form-action 'none'; base-uri 'none'",
"icons": {
"16": "icons/icon16.png",
"32": "icons/icon32.png",
"48": "icons/icon48.png",
"128": "icons/icon128.png"
},
"browser_action": {
"default_popup": "src/popup/index.html"
},

View File

@@ -0,0 +1,206 @@
// ESLint rule: the background bundle may not reach the shared state singleton.
//
// src/shared/state.js holds a module-level `state` object, loaded once by
// loadState() and mutated in place from then on. That is the popup's model. In
// the MV3 service worker there is no "once": the worker is terminated when
// idle and revived by the next message, nothing loads state at module scope,
// and an unpopulated read used to be served DEFAULT_STATE without complaint —
// five defects, one cause
// (https://git.eeqj.de/sneak/AutistMask/issues/324). The background has its
// own per-call storage layer in src/background/state.js instead.
//
// THIS RULE IS NOT THE GUARANTEE, and must not be described as one. The
// guarantee is in build.js: FORBIDDEN_INPUTS / assertNoForbiddenInputs() fails
// the build when esbuild's own metafile reports src/shared/state.js as an input
// of a background bundle. That consults the resolution esbuild actually
// performed, so no specifier syntax and no resolution rule can slip past it,
// and Dockerfile:42 runs `make build` in CI.
//
// What this rule is: fast local feedback, in the editor and in `make lint`,
// before a full bundle. It reads sources from disk and matches import
// specifiers TEXTUALLY, so it is a best-effort approximation of module
// resolution — a hand-rolled matcher will diverge from a real bundler, and two
// earlier revisions of this file proved it by shipping holes (a template
// literal, a dynamic `import()`, a comment inside the call, a directory
// resolved through `package.json` `main`). Those are all covered now, and the
// next divergence is caught by the build rather than by widening this again.
//
// It checks REACHABILITY, not just the direct require: the singleton is one
// `require()` away from any shared module the background pulls in, and a
// re-export would put it back in the bundle without any background file naming
// it. So each background file is the root of a walk over the CommonJS require
// graph, and the error names the whole chain that brought the singleton in.
//
// Matching textually over-approximates — a specifier inside a comment or a
// string counts — which is the safe direction here: the failure mode is a
// spurious error naming an exact file and line, not a silent hole.
//
// Two shapes this rule does NOT report, both of which the build does fail on
// (each measured with `make lint` and `make build` on the branch that added
// this note):
//
// - a computed specifier, `require("../shared/" + "state")` — esbuild
// constant-folds it, so it is in the bundle and `make build` is exit 2
// naming src/shared/state.js, while `make lint` is exit 0. Same for
// `import("../shared/" + variable)`, which esbuild resolves as a glob.
// - a symlink to the module — esbuild reports the real path and fails the
// build; this rule resolves the link's own path and sees a different file.
//
// Both are pinned as non-reports in tests/backgroundStateLintRule.test.js, so
// this list is a measured description of the rule rather than a claim about
// it. They are known divergences, not things that cannot happen. A
// matcher will keep diverging from a bundler; that is why the guarantee is the
// build's and this rule is not widened again to chase them.
const fs = require("fs");
const path = require("path");
const { FORBIDDEN_INPUTS } = require("../forbiddenBundleInputs");
// The modules to keep out, repo-relative, taken from the same table build.js
// asserts against so that the two layers cannot name different paths. A second
// literal copy here is how a rename disarms one of them while the other still
// looks enforced.
const FORBIDDEN = [...new Set(Object.values(FORBIDDEN_INPUTS).flat())];
// Whatever may sit between a keyword, a paren and a specifier: whitespace and
// comments. `import(/* webpackChunkName: "x" */ "./x")` is a standard bundler
// idiom, and an inline `/* eslint-… */` is just as ordinary, so a matcher that
// allows only \s there is not strict, it is broken. Each alternative starts
// with a distinct character, so this cannot backtrack quadratically.
const GAP = "(?:\\s|/\\*[^]*?\\*/|//[^\\n]*)";
const SPECIFIER = "[\"'`]([^\"'`]+)[\"'`]";
// Both alternatives capture the specifier: call form first
// (`require(...)`/`import(...)`), then clause form (`from "x"`, and the bare
// side-effect `import "x"`). Nothing after the specifier is matched, so a
// trailing comment or a trailing comma cannot break the match either.
const SPECIFIER_RE = new RegExp(
`\\b(?:require|import)${GAP}*\\(${GAP}*${SPECIFIER}` +
`|\\b(?:from|import)${GAP}+${SPECIFIER}`,
"g",
);
// The `main` of a directory's package.json, as a specifier relative to that
// directory, or null. esbuild resolves a directory through it, so a walk that
// stops at `<dir>/index.js` reports a specifier it matched perfectly well as
// unresolvable.
function packageMain(dir) {
try {
const pkg = JSON.parse(
fs.readFileSync(path.join(dir, "package.json"), "utf8"),
);
return typeof pkg.main === "string" && pkg.main ? pkg.main : null;
} catch {
return null;
}
}
// Resolve a relative require to a file path, trying what node and esbuild would
// in the order they would: the path itself, then extensions, then the directory
// (its package.json `main`, then its index.js).
function resolveRelative(fromFile, spec) {
if (!spec.startsWith(".")) return null; // a package, not our tree
const base = path.resolve(path.dirname(fromFile), spec);
const main = packageMain(base);
for (const candidate of [
base,
base + ".js",
base + ".json",
...(main
? [path.resolve(base, main), path.resolve(base, main) + ".js"]
: []),
path.join(base, "index.js"),
]) {
try {
if (fs.statSync(candidate).isFile()) return candidate;
} catch {
// Not this candidate.
}
}
return null;
}
function requiresOf(file) {
let source;
try {
source = fs.readFileSync(file, "utf8");
} catch {
return [];
}
const out = [];
for (const match of source.matchAll(SPECIFIER_RE)) {
const resolved = resolveRelative(file, match[1] ?? match[2]);
if (resolved) out.push(resolved);
}
return out;
}
// Breadth-first from `entry`, returning the shortest chain of files that ends
// at one of the forbidden modules, or null when none is reachable.
function chainToForbidden(entry, forbidden) {
const seen = new Set([entry]);
const queue = [[entry]];
while (queue.length > 0) {
const chain = queue.shift();
for (const next of requiresOf(chain[chain.length - 1])) {
if (forbidden.has(next)) return chain.concat([next]);
if (seen.has(next)) continue;
seen.add(next);
queue.push(chain.concat([next]));
}
}
return null;
}
const rule = {
meta: {
type: "problem",
docs: {
description:
"the background bundle must not be able to reach the" +
" module-level state singleton in src/shared/state.js",
},
schema: [],
messages: {
reachable:
"The background must not reach the shared state singleton:" +
" {{chain}}. The MV3 worker never populates it, so reading it" +
" serves DEFAULT_STATE. Use getState()/updateState() from" +
" src/background/state.js instead.",
},
},
create(context) {
return {
"Program:exit"(node) {
const filename = context.filename;
// ESLint lints from the repo root, which is also where the
// forbidden paths are anchored.
const forbidden = new Set(
FORBIDDEN.map((module) =>
path.resolve(context.cwd, module),
),
);
const chain = chainToForbidden(
path.resolve(filename),
forbidden,
);
if (!chain) return;
context.report({
node,
messageId: "reachable",
data: {
chain: chain
.map((file) => path.relative(context.cwd, file))
.join(" -> "),
},
});
},
};
},
};
module.exports = {
rules: { "no-state-singleton-in-background": rule },
};

View File

@@ -0,0 +1,123 @@
// The modules a given entry point's bundle may not contain, keyed by the
// repo-relative entry point.
//
// ONE table, read by both layers that act on it: build.js asserts it against
// esbuild's own metafile (the guarantee), and
// script/lib/eslint/noStateSingletonInBackground.js reports the same
// prohibition in the editor (fast feedback). It lives here because a second
// literal copy of the path is exactly how a rename disarms one layer while the
// other still looks enforced.
//
// src/shared/state.js holds a module-level `state` object, loaded once by
// loadState() and mutated in place from then on. That is the popup's model:
// one page, one load at boot, one lifetime. The MV3 service worker has no
// "once" — it is killed when idle and revived by the next message, nothing
// loads state at module scope, and an unpopulated read was answered out of
// DEFAULT_STATE in silence. Five defects came from that, one of which
// destroyed a wallet (https://git.eeqj.de/sneak/AutistMask/issues/324). The
// background has its own per-call storage layer in src/background/state.js
// instead.
//
// What build.js's assertion covers, measured rather than assumed:
//
// - Any import of a listed module, at any hop, in any specifier syntax,
// however esbuild resolved it. The check reads the input list esbuild
// reported for the emitted bundle, so it is the resolution the shipped
// file was built from and not a model of it. Measured on a computed
// specifier that esbuild constant-folds (`require("../shared/" +
// "state")`), on a computed specifier it resolves as a glob
// (`import("../shared/" + variable)`), and on a symlink to the module
// (esbuild reports the real path): each is `make build` exit 2.
//
// - Every background entry point, whether or not anyone remembered to list
// it. A bundled entry point under BACKGROUND_ENTRY_PREFIX with no line in
// this table fails the build (assertNoForbiddenInputs()), so adding a
// second worker entry point is protected by default rather than protected
// only if the person adding it knew about this file. Measured: bundling
// src/background/worker2.js with no line here is `make build` exit 2.
//
// - NOT covered: a COPY of a listed module at another path. The table is
// keyed by path, so `cp src/shared/state.js src/shared/stateCopy.js` plus
// a background require of the copy is `make build` exit 0 and `make lint`
// exit 0 (measured). The copy carries the singleton's own guard, so
// defects 1-3 of https://git.eeqj.de/sneak/AutistMask/issues/324 — a read
// of a field nothing loaded — become a loud StateNotLoadedError instead of
// a silent DEFAULT_STATE. Defects 4 and 5 do NOT: a copy also carries
// loadState(), and a stale read several awaits after a load, or a load
// detaching the objects an in-flight handler is mutating, are silent over
// a LOADED singleton whether it is the original or a copy. So the residual
// is wider than "it fails loudly". A newly WRITTEN singleton has no
// backstop at all.
//
// - NOT covered: a background-behaving entry point outside
// BACKGROUND_ENTRY_PREFIX. The default protection above is keyed on that
// directory, which is also what eslint.config.js scopes the rule to, so a
// worker entry point placed somewhere else is covered by neither layer and
// needs its own line here.
//
// The ESLint rule's bounds are its own and are narrower: it matches specifiers
// textually, so a computed specifier and a symlink to a listed module are
// reported by the build and not by the rule. Both are pinned as non-reports in
// tests/backgroundStateLintRule.test.js and are `make build` exit 2 (measured).
// A second background entry point reached by one of those two shapes is
// therefore caught by the build and not by the rule — which is the same
// division of labour as everywhere else here, not an extra hole.
//
// Every way the table itself can rot is a failure rather than a quiet pass:
//
// - a KEY no bundled entry point matched, and a listed MODULE this build
// bundled nowhere: assertForbiddenTableCovered(), at the end of a build;
// - an entry that lists NO modules, and a table with no entries at all:
// assertTableWellFormed() below, at require time — so it fails the build
// and the lint run alike, because the rule reads the same values and an
// empty list leaves it with nothing to look for.
//
// All of it is pinned by tests/buildForbiddenInputs.test.js.
// What counts as a background entry point, and therefore must be listed above.
// The build has no other notion of one: entry points are the paths handed to
// bundle(), and this prefix is the narrowest rule that names the worker's
// directory. eslint.config.js scopes the lint rule with the same prefix, from
// this constant, so the two layers cannot disagree about what "background"
// means.
const BACKGROUND_ENTRY_PREFIX = "src/background/";
const FORBIDDEN_INPUTS = {
"src/background/index.js": ["src/shared/state.js"],
};
// Refuse a table that cannot prohibit anything. An entry whose module list is
// empty passes every check in both layers while enforcing nothing: the build
// finds no module to look for and records the entry as checked, and the rule's
// forbidden set — Object.values(...).flat() — comes back empty, so a plain
// `require("../shared/state")` in the worker is green everywhere. That is a
// one-character edit, so it fails here, where the table is defined and both
// layers must load it, rather than in either layer's own checks.
function assertTableWellFormed(table) {
const entries = Object.entries(table);
if (entries.length === 0) {
throw new Error(
"FORBIDDEN_INPUTS is empty, so nothing is prohibited anywhere. " +
"Removing the last entry disables the guarantee behind " +
"https://git.eeqj.de/sneak/AutistMask/issues/324.",
);
}
for (const [entry, modules] of entries) {
if (!Array.isArray(modules) || modules.length === 0) {
throw new Error(
`FORBIDDEN_INPUTS["${entry}"] lists no modules, so it ` +
`prohibits nothing while still looking enforced. Give it ` +
`the modules that entry point may not reach, or remove ` +
`the entry.`,
);
}
}
}
assertTableWellFormed(FORBIDDEN_INPUTS);
module.exports = {
BACKGROUND_ENTRY_PREFIX,
FORBIDDEN_INPUTS,
assertTableWellFormed,
};

View File

@@ -2,19 +2,21 @@
// Handles EIP-1193 RPC requests from content scripts and proxies
// non-sensitive calls to the configured Ethereum JSON-RPC endpoint.
const { DEFAULT_RPC_URL } = require("../shared/constants");
const {
SUPPORTED_CHAIN_IDS,
networkById,
networkByChainId,
} = require("../shared/networks");
const { onChainSwitch } = require("../shared/chainSwitch");
const {
state,
loadState,
saveState,
currentNetwork,
} = require("../shared/state");
const { applyChainSwitchFields } = require("../shared/chainSwitchFields");
// The background's own storage layer. src/shared/state.js — the module-level
// `state` singleton, loadState() and saveState() — is deliberately NOT
// imported here and must never be: see the header of src/background/state.js.
// The build enforces it, not review: build.js fails when esbuild's metafile
// reports that module as an input of this bundle (FORBIDDEN_INPUTS in
// script/lib/forbiddenBundleInputs.js). The ESLint rule of the same name is
// the same prohibition reported early, not the guarantee.
const { getState, updateState } = require("./state");
const { StateUnusableError } = require("../shared/stateSchema");
const { refreshBalances, getProvider } = require("../shared/balances");
const { debugFetch, log } = require("../shared/log");
const {
@@ -42,7 +44,6 @@ const {
const {
actionApi,
runtimeApi,
storageGet,
tabsQuery,
tabsSendMessage,
windowsApi,
@@ -179,21 +180,51 @@ const INTERNAL_ERROR_CODE = -32603;
const INTERNAL_ERROR_MESSAGE =
"AutistMask could not complete this request because of an internal error.";
async function getState() {
const result = await storageGet("autistmask");
return (
result.autistmask || {
wallets: [],
rpcUrl: DEFAULT_RPC_URL,
activeAddress: null,
allowedSites: {},
deniedSites: {},
}
);
// What the page is told when the wallet's own stored profile cannot be read.
//
// This used to be the generic answer above: getActiveAddress() dereferenced
// the stored wallet list on nearly every method, so a corrupt or
// newer-than-this-build record turned EVERY request from EVERY page into
// "internal error", which is also what a failed signing attempt answers. The
// page cannot tell those apart, and the user is told nothing about the one
// thing that is actually wrong or where to fix it
// (https://git.eeqj.de/sneak/AutistMask/issues/311).
//
// Its own code rather than -32603, because the condition is specific,
// diagnosable and has a user action attached — none of which "internal error"
// conveys.
//
// -32007 specifically: EIP-1474 sets aside -32000..-32099 for
// implementation-defined server errors, but it ASSIGNS meanings to -32000
// through -32006 (Invalid input, Resource not found, Resource unavailable,
// Transaction rejected, Method not supported, Limit exceeded, JSON-RPC version
// not supported). -32007..-32099 are the unassigned ones, and this condition
// is not any of the seven. Nothing above it is free to be overloaded either:
// this wallet already answers EIP-1474's -32002 "Resource unavailable" for a
// pending approval, the conventional way, so a page is entitled to read these
// codes by that table.
const STATE_UNUSABLE_CODE = -32007;
const STATE_UNUSABLE_MESSAGE =
"AutistMask cannot read its saved data, so nothing was signed or sent." +
" Open the AutistMask extension to export or reset it.";
// The EIP-1193 error for a handler that threw, by cause. Everything that
// consults the profile goes through getState(), so this one mapping covers
// every method rather than each one having to know about the condition.
function failureError(err) {
if (err instanceof StateUnusableError) {
log.errorf("state is unusable:", err.problem);
return { code: STATE_UNUSABLE_CODE, message: STATE_UNUSABLE_MESSAGE };
}
return { code: INTERNAL_ERROR_CODE, message: INTERNAL_ERROR_MESSAGE };
}
async function getActiveAddress() {
const s = await getState();
// The active address of a profile snapshot. Pure, and taking the snapshot as
// an argument rather than reading storage itself: a handler that has already
// read state must not answer "which account is this" from a SECOND, later read
// — the two can disagree, and the checks that compare them would then be
// comparing two different moments.
function activeAddressOf(s) {
if (s.activeAddress) return s.activeAddress;
// Fall back to first address
if (s.wallets.length > 0 && s.wallets[0].addresses.length > 0) {
@@ -202,6 +233,11 @@ async function getActiveAddress() {
return null;
}
// For the few call sites that need only the address and hold no snapshot.
async function getActiveAddress() {
return activeAddressOf(await getState());
}
// 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
@@ -210,9 +246,14 @@ function namesAnotherAddress(requested, activeAddress) {
return !!requested && !sameAddress(requested, activeAddress);
}
// The endpoint alone, for the one caller that needs nothing else. Anything
// that also needs the network the endpoint belongs to must take both from ONE
// snapshot — see handleSendTransaction() — because a chain switch moves them
// together and a provider built from two different reads can end up pointed at
// one chain and told it is on another
// (https://git.eeqj.de/sneak/AutistMask/issues/320).
async function getRpcUrl() {
const s = await getState();
return s.rpcUrl || DEFAULT_RPC_URL;
return (await getState()).rpcUrl;
}
function extractHostname(origin) {
@@ -553,10 +594,26 @@ runtime.onConnect.addListener((port) => {
}
});
// Record a remembered site decision under one address.
//
// A read-modify-write against storage, not a load-mutate-save of a shared
// singleton: the user takes seconds to answer the prompt, and everything else
// in the worker — a balance refresh in flight, another site's approval — has
// gone on running the whole time. Loading here used to replace the very
// objects that work was holding.
async function rememberSiteChoice(field, address, hostname) {
await updateState((s) => {
if (!s[field][address]) s[field][address] = [];
if (!s[field][address].includes(hostname)) {
s[field][address].push(hostname);
}
});
}
// Handle connection requests (eth_requestAccounts, wallet_requestPermissions)
async function handleConnectionRequest(origin) {
const s = await getState();
const activeAddress = await getActiveAddress();
const activeAddress = activeAddressOf(s);
if (!activeAddress) {
return { error: { message: "No accounts available" } };
}
@@ -588,29 +645,14 @@ async function handleConnectionRequest(origin) {
if (decision.approved) {
if (decision.remember) {
// Reload state to get latest, add to allowed, persist
await loadState();
if (!state.allowedSites[activeAddress]) {
state.allowedSites[activeAddress] = [];
}
if (!state.allowedSites[activeAddress].includes(hostname)) {
state.allowedSites[activeAddress].push(hostname);
}
await saveState();
await rememberSiteChoice("allowedSites", activeAddress, hostname);
} else {
connectedSites[origin + ":" + activeAddress] = true;
}
return { result: [activeAddress] };
} else {
if (decision.remember) {
await loadState();
if (!state.deniedSites[activeAddress]) {
state.deniedSites[activeAddress] = [];
}
if (!state.deniedSites[activeAddress].includes(hostname)) {
state.deniedSites[activeAddress].push(hostname);
}
await saveState();
await rememberSiteChoice("deniedSites", activeAddress, hostname);
}
return {
error: {
@@ -654,7 +696,7 @@ async function handleRpc(method, params, origin) {
if (method === "eth_accounts") {
const s = await getState();
const activeAddress = await getActiveAddress();
const activeAddress = activeAddressOf(s);
if (!activeAddress) return { result: [] };
const hostname = extractHostname(origin);
const allowed = s.allowedSites[activeAddress] || [];
@@ -667,22 +709,11 @@ async function handleRpc(method, params, origin) {
return { result: [] };
}
// Both answered from currentNetwork(), which reads the module-level state
// singleton, and nothing populates that at module scope. A worker revived
// by the page's own message therefore held DEFAULT_STATE and told a page
// it was on mainnet while the user was on Sepolia
// (https://git.eeqj.de/sneak/AutistMask/issues/317).
//
// Answered from getState() rather than by loading the singleton. Any page
// reaches these two — neither is gated on a connection, and the injected
// provider sends eth_chainId on every page load — and loadState() replaces
// state.wallets wholesale, which would detach the address objects an
// in-flight backgroundRefresh() is mutating across its network round trip,
// so its saveState() would persist the pre-refresh balances while still
// stamping lastBalanceRefresh. getState() is the detached per-call storage
// read the other read handlers here already use.
// networkById(undefined) falls back to mainnet, matching the default for a
// profile with no stored networkId.
// Both used to be answered from currentNetwork(), which reads the
// module-level state singleton, and nothing populates that at module
// scope. A worker revived by the page's own message therefore held
// DEFAULT_STATE and told a page it was on mainnet while the user was on
// Sepolia (https://git.eeqj.de/sneak/AutistMask/issues/317).
if (method === "eth_chainId" || method === "net_version") {
const s = await getState();
const net = networkById(s.networkId);
@@ -699,7 +730,7 @@ async function handleRpc(method, params, origin) {
// not be able to do it. Ungated, any page could clear the
// [TESTNET] banner under a user who believed they were on Sepolia.
const s = await getState();
const activeAddress = await getActiveAddress();
const activeAddress = activeAddressOf(s);
const hostname = extractHostname(origin);
const allowed = s.allowedSites[activeAddress] || [];
if (
@@ -709,24 +740,25 @@ async function handleRpc(method, params, origin) {
return { error: { code: 4100, message: "Unauthorized" } };
}
// onChainSwitch() mutates the module-level state singleton and then
// saves every field of it, and currentNetwork() reads the same
// singleton. This worker may have been started by this very message:
// nothing loads state at module scope, so without this the singleton
// is DEFAULT_STATE, the same-chain check compares against the wrong
// network, and the save writes empty wallets, empty allowedSites and
// the default endpoints over the user's stored profile
// (https://git.eeqj.de/sneak/AutistMask/issues/316). Same precedent
// as the transaction path below.
await loadState();
// The chain in force is read from the snapshot above, not from the
// singleton: this worker may have been started by this very message,
// and the singleton would then be DEFAULT_STATE, so the same-chain
// check compared against mainnet whatever the user was on
// (https://git.eeqj.de/sneak/AutistMask/issues/316).
const chainId = params?.[0]?.chainId;
if (chainId === currentNetwork().chainId) {
if (chainId === networkById(s.networkId).chainId) {
return { result: null };
}
if (SUPPORTED_CHAIN_IDS.has(chainId)) {
const target = networkByChainId(chainId);
await onChainSwitch(target.id);
// Read-modify-write against storage. The old path went through
// onChainSwitch(), which mutates the singleton and then persists
// every field of it — on an unloaded worker that wrote empty
// wallets, empty allowedSites and the default endpoints over the
// user's stored profile, encrypted secrets included.
await updateState((fresh) =>
applyChainSwitchFields(fresh, target.id),
);
broadcastChainChanged(target.chainId);
return { result: null };
}
@@ -773,7 +805,7 @@ async function handleRpc(method, params, origin) {
if (method === "wallet_getPermissions") {
const s = await getState();
const activeAddress = await getActiveAddress();
const activeAddress = activeAddressOf(s);
const hostname = extractHostname(origin);
const allowed = s.allowedSites[activeAddress] || [];
const isConnected =
@@ -799,7 +831,7 @@ async function handleRpc(method, params, origin) {
if (method === "personal_sign" || method === "eth_sign") {
const s = await getState();
const activeAddress = await getActiveAddress();
const activeAddress = activeAddressOf(s);
if (!activeAddress)
return { error: { message: "No accounts available" } };
@@ -848,7 +880,7 @@ async function handleRpc(method, params, origin) {
if (method === "eth_signTypedData_v4" || method === "eth_signTypedData") {
const s = await getState();
const activeAddress = await getActiveAddress();
const activeAddress = activeAddressOf(s);
if (!activeAddress)
return { error: { message: "No accounts available" } };
@@ -891,6 +923,11 @@ async function handleRpc(method, params, origin) {
const result = await proxyRpc(method, params);
return { result };
} catch (e) {
// A node that answered with an error is reported as itself. The
// wallet being unable to read its own profile is not that, and it
// must not be flattened into a message with no code: it goes back
// to the dispatcher, which has the one answer for it.
if (e instanceof StateUnusableError) throw e;
return { error: { message: e.message } };
}
}
@@ -904,7 +941,7 @@ async function handleRpc(method, params, origin) {
// page has its answer.
async function handleSendTransaction(params, origin) {
const s = await getState();
const activeAddress = await getActiveAddress();
const activeAddress = activeAddressOf(s);
if (!activeAddress) return { error: { message: "No accounts available" } };
const hostname = extractHostname(origin);
@@ -948,10 +985,19 @@ async function handleSendTransaction(params, origin) {
// 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.
//
// The provider is built from ONE snapshot — the endpoint and the
// network name both come from `s`. It used to be
// getProvider(await getRpcUrl()) with no network name at all, so
// getProvider fell back to the unpopulated singleton's mainnet: the
// endpoint was the user's chain and the static hint was 0x1, ethers
// fixed chainId at 0x1, and the wallet's own verifySignedTx then
// refused every non-mainnet dApp send
// (https://git.eeqj.de/sneak/AutistMask/issues/320).
let approvedTx;
try {
approvedTx = await prepareApprovalTx(
getProvider(await getRpcUrl()),
getProvider(s.rpcUrl, s.networkId),
activeAddress,
txParams,
);
@@ -1039,7 +1085,7 @@ async function broadcastAccountsChanged() {
}
resetPopupUrl();
const s = await getState();
const activeAddress = await getActiveAddress();
const activeAddress = activeAddressOf(s);
const allowed = activeAddress ? s.allowedSites[activeAddress] || [] : [];
let tabs;
try {
@@ -1079,20 +1125,60 @@ async function broadcastAccountsChanged() {
const BALANCE_REFRESH_PERIOD_MS = BALANCE_REFRESH_PERIOD_MINUTES * 60 * 1000;
const RECENT_BALANCE_REFRESH_MS = Math.floor(BALANCE_REFRESH_PERIOD_MS / 2);
// The wallets this refresh works on are its OWN, and nothing else in the
// worker can reach them.
//
// refreshBalances() mutates address objects in place across a multi-second
// network round trip. It used to be handed the module-level singleton's
// wallets, which meant any concurrent handler that called loadState() replaced
// state.wallets underneath it: the refreshed balances landed on detached
// objects, and the save that followed persisted the PRE-refresh values while
// still stamping lastBalanceRefresh, suppressing the redo. Every point fix for
// the singleton added such a loadState(), so the next one would have done it
// again (https://git.eeqj.de/sneak/AutistMask/issues/324).
//
// So: read a snapshot, refresh a private copy of its wallets, then apply the
// balances that came back — by address, onto whatever storage holds NOW.
// Applying by address rather than writing the array back is what keeps a
// wallet or address added, renamed or deleted during the round trip.
async function backgroundRefresh() {
await loadState();
const s = await getState();
const now = Date.now();
if (now - (state.lastBalanceRefresh || 0) < RECENT_BALANCE_REFRESH_MS)
return;
if (state.wallets.length === 0) return;
if (now - (s.lastBalanceRefresh || 0) < RECENT_BALANCE_REFRESH_MS) return;
if (s.wallets.length === 0) return;
const wallets = s.wallets;
await refreshBalances(
state.wallets,
state.rpcUrl,
state.blockscoutUrl,
state.trackedTokens,
wallets,
s.rpcUrl,
s.blockscoutUrl,
s.trackedTokens,
s.networkId,
);
state.lastBalanceRefresh = now;
await saveState();
const refreshed = new Map();
for (const wallet of wallets) {
for (const addr of wallet.addresses || []) {
refreshed.set(String(addr.address).toLowerCase(), addr);
}
}
await updateState((fresh) => {
for (const wallet of fresh.wallets) {
for (const addr of wallet.addresses || []) {
const got = refreshed.get(String(addr.address).toLowerCase());
if (!got) continue;
// Only fields the refresh actually produced. refreshBalances()
// leaves a field untouched when its lookup failed, so an
// undefined here means "no answer", not "the answer is empty",
// and must not overwrite what is stored.
for (const key of ["balance", "ensName", "tokenBalances"]) {
if (got[key] !== undefined) addr[key] = got[key];
}
}
}
fresh.lastBalanceRefresh = now;
});
}
// The recurring job runs off an alarm, not a timer. On Chrome MV3 this file is
@@ -1101,7 +1187,14 @@ async function backgroundRefresh() {
// module-level state does not outlive it. Alarms are held by the browser and
// wake the worker to deliver them.
registerAlarmHandlers({
[BALANCE_REFRESH_ALARM]: backgroundRefresh,
// Caught here rather than left to the alarm dispatcher, which does not
// await what it calls: a profile this build cannot read makes every
// refresh throw, and an unhandled rejection per alarm tick says less than
// one logged line per tick does.
[BALANCE_REFRESH_ALARM]: () =>
backgroundRefresh().catch((e) => {
log.errorf("background balance refresh failed:", e);
}),
});
// Everything the background context needs re-established on start. This runs
@@ -1200,12 +1293,7 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
// "it does not throw today" is not a property anyone is
// maintaining.
log.errorf("RPC request failed:", msg.method, err);
sendResponse({
error: {
code: INTERNAL_ERROR_CODE,
message: INTERNAL_ERROR_MESSAGE,
},
});
sendResponse({ error: failureError(err) });
});
return true;
}
@@ -1307,16 +1395,29 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
// so an escape from there must not tell the user it might have.
let lastResortStage = TX_STAGE_VERIFY;
(async () => {
// The chain this attempt is on, read once. Verification below
// refuses an artifact signed for any other chain, and the nonce
// record is both consulted and written under this one, so a
// network switch part-way through cannot make the check and the
// record disagree about which chain the nonce was spent on.
// The chain this attempt is on, read once — and the endpoint it
// will be broadcast to comes from the SAME read.
//
// Verification below refuses an artifact signed for any other
// chain, and the nonce record is both consulted and written under
// this one, so a network switch part-way through cannot make the
// check and the record disagree about which chain the nonce was
// spent on. The endpoint used to be read separately, several
// awaits later (`state.rpcUrl` off the singleton), so a chain
// switch committed in that window moved the endpoint out from
// under a transaction already verified against the old chain: the
// artifact would be sent to the new chain's node, which is
// precisely the "signed for a different network" case the
// verification exists to prevent.
let chainId;
let rpcUrl;
let networkId;
try {
await loadState();
chainId = currentNetwork().chainId;
const activeAddress = await getActiveAddress();
const s = await getState();
networkId = s.networkId;
chainId = networkById(networkId).chainId;
rpcUrl = s.rpcUrl;
const activeAddress = activeAddressOf(s);
// 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
@@ -1388,7 +1489,7 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
}
try {
const provider = getProvider(state.rpcUrl);
const provider = getProvider(rpcUrl, networkId);
lastResortStage = TX_STAGE_BROADCAST;
const tx = await provider.broadcastTransaction(msg.rawSignedTx);
if (nonce !== null) spent.add(nonce);
@@ -1427,18 +1528,10 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
// the popup nor the page is ever answered. Settle both, through
// the same chokepoint as every other retirement.
log.errorf("transaction approval response failed:", e);
settleApproval(
msg.id,
{
error: {
code: INTERNAL_ERROR_CODE,
message: INTERNAL_ERROR_MESSAGE,
},
},
{ holdsClaim: true },
);
const failure = failureError(e);
settleApproval(msg.id, { error: failure }, { holdsClaim: true });
sendResponse({
error: INTERNAL_ERROR_MESSAGE,
error: failure.message,
retryable: false,
stage: lastResortStage,
});
@@ -1530,18 +1623,10 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
// Same shape as the transaction path: a throw out of the catch
// block above would leave the popup and the page both waiting.
log.errorf("sign approval response failed:", e);
settleApproval(
msg.id,
{
error: {
code: INTERNAL_ERROR_CODE,
message: INTERNAL_ERROR_MESSAGE,
},
},
{ holdsClaim: true },
);
const failure = failureError(e);
settleApproval(msg.id, { error: failure }, { holdsClaim: true });
sendResponse({
error: INTERNAL_ERROR_MESSAGE,
error: failure.message,
retryable: false,
});
});

96
src/background/state.js Normal file
View File

@@ -0,0 +1,96 @@
// The background's access to the persisted profile.
//
// There is no in-memory copy here, and that is the whole design. The MV3
// service worker is terminated when idle and revived by the next message, so
// anything held at module scope is either absent or arbitrarily stale, and
// src/shared/state.js's module-level `state` singleton — which nothing in the
// worker ever populates — silently served DEFAULT_STATE to whoever read it.
// Five defects came out of that (https://git.eeqj.de/sneak/AutistMask/issues/324),
// and every point fix for one of them added a loadState() that created the
// next: loading detaches the objects an in-flight handler is holding.
//
// So the background reads per call and writes read-modify-write:
//
// getState() one storage read, normalized, detached. Nothing else
// holds the object it returns, so a handler may keep it
// across any number of awaits and no concurrent work can
// move it.
// updateState(fn) read fresh, apply fn to that fresh record, write it
// back — all inside a queue, so two background writes
// never interleave, and the read is one storage round trip
// ahead of the write rather than a page lifetime ahead of
// it (which is what made the popup's saveState() need a
// per-field merge against a baseline at all).
//
// A handler that must both read and write therefore does its network work
// against a snapshot it owns, and applies the RESULT inside updateState().
// It never publishes an object other in-flight work is holding.
const { storageGet, storageSet } = require("../shared/browserApi");
const { normalizePersisted } = require("../shared/persistedState");
const {
STATE_SCHEMA_VERSION,
assertStateUsable,
} = require("../shared/stateSchema");
// A fresh, fully-normalized, detached copy of the persisted profile.
//
// Normalized rather than raw: a legacy or malformed record is self-healed the
// same way loadState() heals it for the popup, so the background is never the
// one context reasoning about a shape the rest of the extension repairs.
//
// Throws StateUnusableError for a record this build cannot make sense of,
// before normalization gets a chance to paper over it — the same gate, in the
// same place, as the popup's loadState(). Every handler that consults the
// profile comes through here, so a dApp call against such a record is answered
// with the specific error the dispatcher maps that to (src/background/index.js)
// rather than dereferencing its way into a generic -32603.
async function getState() {
const result = await storageGet("autistmask");
assertStateUsable(result.autistmask);
return normalizePersisted(result.autistmask);
}
// Serializes the read-modify-write turns below. Two of them interleaved would
// each read before the other wrote, and the second write would carry the first
// one's fields back to their pre-turn values.
let updateQueue = Promise.resolve();
async function updateStateOnce(mutate) {
const s = await getState();
await mutate(s);
s.hasWallet = Boolean(s.wallets && s.wallets.length > 0);
// Stamped on every write, exactly as the popup's saveState() stamps it:
// whichever context writes last, the record in storage is in this build's
// shape and says so.
s.schemaVersion = STATE_SCHEMA_VERSION;
await storageSet({ autistmask: s });
return s;
}
// Apply `mutate` to a record read fresh from storage and write the result
// back. `mutate` receives a detached, normalized profile and mutates it in
// place; it may be async, but it must not do anything slow — the window
// between the read and the write is the window in which another context's
// write is lost, and keeping it to one storage round trip is what makes a
// whole-record write safe here. Concretely: the write is the WHOLE record, so
// a popup write that lands inside that window is reverted, in every field, by
// the record this turn read before it. That is accepted because the window is
// one round trip long and the popup is not writing while the worker is;
// widening it is what would make it a real hazard.
//
// `mutate` must also not call updateState() itself, directly or through
// anything it awaits: the queue is strictly serial, so the inner turn waits on
// the outer one, which is waiting on the inner one. That deadlocks the whole
// background, not just the caller. Mutate the record you were handed.
//
// Resolves with the record that was written.
function updateState(mutate) {
const turn = updateQueue.then(() => updateStateOnce(mutate));
// The queue must advance even when a turn rejects, or every update after
// it queues behind a promise that never settles.
updateQueue = turn.catch(() => {});
return turn;
}
module.exports = { getState, updateState };

View File

@@ -213,10 +213,7 @@
</div>
<!-- active address display -->
<div
id="active-address-display"
class="text-xs break-all mb-3"
></div>
<div id="active-address-display" class="text-xs mb-3"></div>
<!-- quick actions for active address -->
<div class="flex gap-2 mb-2">
@@ -292,7 +289,7 @@
class="font-bold mb-1 hidden flex items-center"
></div>
<div
class="text-xs mb-1 cursor-pointer break-all"
class="text-xs mb-1 cursor-pointer"
title="Click to copy"
id="address-line"
>
@@ -380,14 +377,14 @@
></div>
<h2 class="font-bold mb-1">Export Private Key</h2>
<p class="text-xs mb-1" id="export-privkey-title"></p>
<p class="text-xs mb-3">
<div class="text-xs mb-3">
<span id="export-privkey-dot"></span>
<span
id="export-privkey-address"
class="cursor-pointer"
title="Click to copy"
></span>
</p>
</div>
<p class="text-xs mb-3 text-muted">
Warning: anyone with this private key can access and
transfer all funds from this address. Never share it.
@@ -440,7 +437,7 @@
</div>
<div
class="text-xs mb-1 cursor-pointer break-all"
class="text-xs mb-1 cursor-pointer"
title="Click to copy"
id="address-token-line"
>
@@ -573,19 +570,16 @@
<!-- ERC-20 token contract (hidden for ETH) -->
<div id="confirm-token-section" class="mb-3 hidden">
<div class="text-xs text-muted mb-1">Token contract</div>
<div
id="confirm-token-contract"
class="text-xs break-all"
></div>
<div id="confirm-token-contract" class="text-xs"></div>
</div>
<div class="mb-3">
<div class="text-xs text-muted mb-1">From</div>
<div id="confirm-from" class="text-xs break-all"></div>
<div id="confirm-from" class="text-xs"></div>
</div>
<div class="mb-3">
<div class="text-xs text-muted mb-1">To</div>
<div id="confirm-to" class="text-xs break-all"></div>
<div id="confirm-to" class="text-xs"></div>
<div
id="confirm-to-ens"
class="text-xs text-muted hidden"
@@ -728,7 +722,7 @@
</div>
<div class="mb-3">
<div class="text-xs text-muted mb-1">To</div>
<div id="wait-tx-to" class="text-xs break-all"></div>
<div id="wait-tx-to" class="text-xs"></div>
</div>
<div class="mb-3">
<div class="text-xs text-muted mb-1">Transaction hash</div>
@@ -747,7 +741,7 @@
</div>
<div class="mb-3">
<div class="text-xs text-muted mb-1">To</div>
<div id="success-tx-to" class="text-xs break-all"></div>
<div id="success-tx-to" class="text-xs"></div>
</div>
<div class="mb-3">
<div class="text-xs text-muted mb-1">Block</div>
@@ -774,7 +768,7 @@
</div>
<div class="mb-3">
<div class="text-xs text-muted mb-1">To</div>
<div id="error-tx-to" class="text-xs break-all"></div>
<div id="error-tx-to" class="text-xs"></div>
</div>
<div class="mb-3">
<div
@@ -811,9 +805,9 @@
<canvas id="receive-qr"></canvas>
</div>
<div
class="border border-border p-2 break-all mb-3 text-xs cursor-pointer"
class="border border-border p-2 mb-3 text-xs cursor-pointer"
>
<span id="receive-address-block" class="select-all"></span>
<div id="receive-address-block" class="select-all"></div>
<span id="receive-etherscan-link"></span>
</div>
<button
@@ -1239,7 +1233,7 @@
</p>
<div
id="delete-address-value"
class="text-xs mb-2 break-all min-h-[1rem]"
class="text-xs mb-2 min-h-[1rem]"
></div>
<div
class="text-xs mb-2 border border-border border-dashed p-2"
@@ -1429,14 +1423,11 @@
</div>
<div class="mb-2">
<div class="text-xs text-muted mb-1">From</div>
<div
id="tx-detail-from"
class="text-xs break-all"
></div>
<div id="tx-detail-from" class="text-xs"></div>
</div>
<div class="mb-2">
<div class="text-xs text-muted mb-1">To</div>
<div id="tx-detail-to" class="text-xs break-all"></div>
<div id="tx-detail-to" class="text-xs"></div>
</div>
</div>
@@ -1473,7 +1464,7 @@
</div>
<div
id="tx-detail-token-contract"
class="text-xs break-all"
class="text-xs"
></div>
</div>
</div>
@@ -1567,11 +1558,11 @@
<div class="mb-3">
<div class="text-xs text-muted mb-1">From</div>
<div id="approve-tx-from" class="text-xs break-all"></div>
<div id="approve-tx-from" class="text-xs"></div>
</div>
<div class="mb-3">
<div class="text-xs text-muted mb-1">Contract</div>
<div id="approve-tx-to" class="text-xs break-all"></div>
<div id="approve-tx-to" class="text-xs"></div>
</div>
<div class="mb-3">
<div class="text-xs text-muted mb-1">Value</div>
@@ -1673,7 +1664,7 @@
<div class="mb-3">
<div class="text-xs text-muted mb-1">From</div>
<div id="approve-sign-from" class="text-xs break-all"></div>
<div id="approve-sign-from" class="text-xs"></div>
</div>
<div class="mb-3">
@@ -1761,6 +1752,75 @@
</button>
</div>
</div>
<!-- ============ STATE RECOVERY ============ -->
<!--
Shown when the stored profile cannot be read at all. Every
other screen renders from that profile, so this one is reached
without one and is the only way out of a wallet that would
otherwise be a blank popup.
-->
<div id="view-state-recovery" class="view hidden">
<h2 class="font-bold mb-2">Saved Data Cannot Be Read</h2>
<p class="text-xs mb-2">
AutistMask stopped rather than guessing. Nothing has been
changed or erased, and nothing can be signed or sent until
this is resolved.
</p>
<div
id="state-recovery-problem"
class="text-xs font-bold mb-3 break-words"
></div>
<p class="text-xs mb-2">
Export the saved data first and keep it. It may hold the
encrypted keys for your wallets, and it is the only copy.
</p>
<button
id="btn-state-recovery-export"
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer"
>
Export Saved Data
</button>
<textarea
id="state-recovery-blob"
readonly
class="hidden border border-border p-1 w-full h-32 font-mono text-xs bg-bg text-fg mt-2"
></textarea>
<p class="text-xs mt-3 mb-2">
<strong
>Erasing the saved data deletes every wallet stored in
this browser.</strong
>
Nothing on the blockchain changes and no money is moved, but
without the exported copy above, or the recovery phrase for
each wallet written down, everything they hold is gone
forever.
</p>
<p class="text-xs mb-1">
To confirm, type
<strong>ERASE MY WALLET</strong>
below.
</p>
<div class="mb-2">
<input
type="text"
id="state-recovery-reset-input"
class="border border-border p-1 w-full font-mono text-sm bg-bg text-fg"
placeholder="Type ERASE MY WALLET"
/>
</div>
<div
id="state-recovery-flash"
class="text-xs text-red-500 mb-2 min-h-[1.25rem]"
style="visibility: hidden"
></div>
<button
id="btn-state-recovery-reset"
class="border border-border text-red-500 px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer"
>
Erase Saved Data
</button>
</div>
</div>
<script src="index.js"></script>

View File

@@ -1,8 +1,14 @@
// AutistMask popup entry point.
// Loads state, initializes views, triggers first render.
const { state, saveState, loadState } = require("../shared/state");
const { setRuntimeDebug } = require("../shared/log");
const {
state,
saveState,
onSaveFailure,
loadState,
} = require("../shared/state");
const { StateUnusableError } = require("../shared/stateSchema");
const { log, setRuntimeDebug } = require("../shared/log");
const { refreshPrices } = require("../shared/prices");
const { refreshBalances } = require("../shared/balances");
const {
@@ -10,13 +16,14 @@ const {
showView,
updateDebugBanner,
setBackRenderer,
showSaveFailureBanner,
pushCurrentView,
goBack,
} = require("./views/helpers");
const { applyTheme } = require("./theme");
// Renders a view the popup lands on without having navigated to it forward:
// on restore here, and on Back. Only the views that can be fully re-rendered
// from persisted state (RESTORABLE_VIEWS, src/popup/restorableViews.js) go
// from persisted state (RESTORABLE_VIEWS, src/shared/restorableViews.js) go
// through it; anything else falls back to the nearest restorable parent.
const { renderView, makeBackRenderer } = require("./viewRouter");
@@ -35,6 +42,7 @@ const settings = require("./views/settings");
const settingsAddToken = require("./views/settingsAddToken");
const deleteAddress = require("./views/deleteAddress");
const approval = require("./views/approval");
const stateRecovery = require("./views/stateRecovery");
function renderWalletList() {
home.render(ctx);
@@ -53,11 +61,20 @@ async function doRefreshAndRender() {
state.rpcUrl,
state.blockscoutUrl,
state.trackedTokens,
state.networkId,
),
]);
state.lastBalanceRefresh = Date.now();
await saveState();
renderWalletList();
} catch (e) {
// Every call site fires this and walks away — the boot below, the ten
// second interval, and eight views through ctx — so it must never
// reject: an unhandled rejection is not a report of anything. The save
// inside it reports its own failure through onSaveFailure() (see
// src/shared/state.js); what is left here is a failed network round
// trip, which the next tick retries.
log.errorf("popup: background refresh failed:", e);
} finally {
refreshInFlight = false;
}
@@ -133,7 +150,28 @@ function fallbackView() {
}
async function init() {
await loadState();
// First, before anything can save: showView() saves on every navigation
// without awaiting, so a save that fails from here on has somewhere to be
// reported rather than being swallowed by the save queue
// (https://git.eeqj.de/sneak/AutistMask/issues/362). Registered ahead of
// the approval-window branch below too, since that window saves as well.
onSaveFailure(showSaveFailureBanner);
try {
await loadState();
} catch (e) {
// A profile this build cannot read is the one failure that must not
// fall through to the rest of init(). It used to: the load "succeeded"
// on a record nothing had validated, and the first dereference below
// threw, leaving a popup with no view, no message and no control on
// it, and no way out of the wallet from inside the product
// (https://git.eeqj.de/sneak/AutistMask/issues/311). Now the load
// refuses, and this is the screen that says so.
if (e instanceof StateUnusableError) {
stateRecovery.show(e);
return;
}
throw e;
}
applyTheme(state.theme);
// Sync runtime debug flag from persisted state before first render

View File

@@ -44,3 +44,23 @@ body {
background-color 225ms ease-out,
color 225ms ease-out;
}
/* An address is one atomic string, so it gets a row of its own and never
* breaks across lines. A wrapped address reads as two shorter strings, and
* two shorter strings are exactly what an address-poisoning attack needs
* the user to compare instead of the whole thing. Every view that shows an
* address puts it in one of these, alone: the colour dot, the wallet title,
* the ENS name and the explorer link all live on their own line above, so
* nothing competes with the 42 characters for width.
*
* overflow-x is the escape hatch, not the mechanism. The row is wide enough
* for a full address at every nesting depth the popup uses; if that ever
* stops being true — a font with wider glyphs, a browser zoom — the row
* scrolls and the user can still reach the last character, rather than the
* tail being clipped away by #app's overflow-x-hidden with nothing to say
* it happened. tests/e2e asserts the scroll is never actually needed. */
.am-address {
display: block;
white-space: nowrap;
overflow-x: auto;
}

View File

@@ -12,7 +12,7 @@
// dispatch and its data guards can be tested directly; src/popup/index.js
// cannot be required outside a browser.
const { RESTORABLE_VIEWS } = require("./restorableViews");
const { RESTORABLE_VIEWS } = require("../shared/restorableViews");
// The views this page load has rendered.
//
@@ -53,12 +53,17 @@ function resetRenderedViews() {
const ALWAYS_RENDER_ON_BACK = new Set(["main"]);
// Views that render an address the user picked and cannot be rendered
// without one.
// without one. "confirm-tx" is here because its Sign button dereferences
// `state.wallets[state.selectedWallet].encryptedSecret`
// (src/popup/views/confirmTx.js) behind no guard of its own — a screen that
// can only throw when the user presses its one button must not be restored
// onto.
const ADDRESS_VIEWS = new Set([
"address",
"address-token",
"receive",
"transaction",
"confirm-tx",
]);
function needsAddress(view) {
@@ -74,6 +79,73 @@ function hasValidAddress(state) {
);
}
// The stored viewData ENTRIES each branch below dereferences, as opposed to
// the one field it gates on.
//
// A gate on a single truthy field checks the container, not the entries, and
// the dereference is one level below it: a stored `{"currentView":
// "success-tx","viewData":{"hash":"0x1"}}` passes `data.hash` and then throws
// on `address.toLowerCase()` inside addressTitle() (src/popup/views/
// helpers.js), out of restoreView(), which src/popup/index.js does not guard —
// so the rest of popup init never runs. txStatus.restoreWait() has checked its
// own branch's fields since it was written; these are the other four.
//
// Only what actually throws is required. Fields that are compared,
// concatenated or escaped coerce (escapeHtml() and displaySymbol() both
// String() their argument), so requiring them would refuse a restorable screen
// over a cosmetic value.
function isText(value) {
return typeof value === "string";
}
function isRecord(value) {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
// An address handed to renderAddressHtml()/addressTitle(): both reach
// `address.slice()` and `address.toLowerCase()` with no guard.
function isAddressText(value) {
return isText(value);
}
// Decoded calldata, as decodedDetailsHtml() (src/popup/views/txStatus.js)
// walks it: `for (const d of decoded.details)` needs an iterable, and each
// entry's `address` reaches toAddressHtml(). Absent or falsy is the ordinary
// case and short-circuits before either.
function isRenderableDecoded(value) {
if (!value) return true;
if (!isRecord(value)) return false;
if (!value.details) return true;
if (!Array.isArray(value.details)) return false;
return value.details.every(
(entry) =>
isRecord(entry) && (!entry.address || isAddressText(entry.address)),
);
}
// The pending transaction confirmTx.show() renders: `token` reaches
// renderAddressHtml() when it is not "ETH", and `from`/`to` reach
// addressTitle(), makeBlockie() and getLocalWarnings().
function isRenderablePendingTx(value) {
return (
isRecord(value) &&
isText(value.token) &&
isAddressText(value.from) &&
isAddressText(value.to)
);
}
// The stored transaction transactionDetail.render() shows. contractAddress is
// optional on an ETH transfer, and reaches addressDotHtml() when it is there.
function isRenderableTx(value) {
return (
isRecord(value) &&
isAddressText(value.from) &&
isAddressText(value.to) &&
(!value.contractAddress || isAddressText(value.contractAddress))
);
}
// Render `view` from persisted state. Each view module shows itself, so a
// true return means the view is both rendered and on screen.
//
@@ -107,11 +179,11 @@ function renderView(view, state, views) {
views.settingsAddToken.show();
return true;
case "confirm-tx":
if (!data.pendingTx) return false;
if (!isRenderablePendingTx(data.pendingTx)) return false;
views.confirmTx.restore();
return true;
case "transaction":
if (!data.tx) return false;
if (!isRenderableTx(data.tx)) return false;
views.transactionDetail.render();
return true;
case "wait-tx":
@@ -120,10 +192,13 @@ function renderView(view, state, views) {
return Boolean(views.txStatus.restoreWait());
case "success-tx":
if (!data.hash) return false;
if (!isAddressText(data.to)) return false;
if (!isRenderableDecoded(data.decoded)) return false;
views.txStatus.renderSuccess();
return true;
case "error-tx":
if (!data.message) return false;
if (!isAddressText(data.to)) return false;
views.txStatus.renderError();
return true;
default:

View File

@@ -49,7 +49,11 @@ function init(ctx) {
infoEl.style.visibility = "visible";
log.debugf("Looking up token contract", contractAddr);
try {
const info = await lookupTokenInfo(contractAddr, state.rpcUrl);
const info = await lookupTokenInfo(
contractAddr,
state.rpcUrl,
state.networkId,
);
log.infof("Adding token", info.symbol, contractAddr);
state.trackedTokens.push({
address: contractAddr,

View File

@@ -179,7 +179,7 @@ async function importMnemonic(ctx) {
// Scan for used HD addresses beyond index 0.
showFlash("Scanning for addresses...", 30000);
const scan = await scanForAddresses(xpub, state.rpcUrl);
const scan = await scanForAddresses(xpub, state.rpcUrl, state.networkId);
if (scan.addresses.length > 1) {
wallet.addresses = scan.addresses.map((a) => ({
address: a.address,
@@ -298,7 +298,7 @@ async function importXprvKey(ctx) {
// Scan for used HD addresses beyond index 0.
showFlash("Scanning for addresses...", 30000);
const scan = await scanForAddresses(xpub, state.rpcUrl);
const scan = await scanForAddresses(xpub, state.rpcUrl, state.networkId);
if (scan.addresses.length > 1) {
wallet.addresses = scan.addresses.map((a) => ({
address: a.address,

View File

@@ -7,7 +7,6 @@ const {
addressTitle,
escapeHtml,
displaySymbol,
truncateMiddle,
renderAddressHtml,
attachCopyHandlers,
goBack,
@@ -188,6 +187,7 @@ async function loadTransactions(address) {
ensNameMap = await resolveEnsNames(
counterparties,
state.rpcUrl,
state.networkId,
);
} catch {
ensNameMap = new Map();
@@ -228,10 +228,12 @@ function renderTransactions(txs) {
const amountStr = tx.value
? escapeHtml(tx.value + " " + sym)
: escapeHtml(sym);
const maxAddr = Math.max(32, 36 - Math.max(0, amountStr.length - 10));
const displayAddr =
title || ensName || truncateMiddle(counterparty, maxAddr);
const addrStr = escapeHtml(displayAddr);
// The counterparty used to be squeezed in beside the amount and
// truncated to whatever was left over. It gets its own row now and
// is shown whole; the title or ENS name, where there is one, names
// it on the line above rather than replacing it.
const nameStr = escapeHtml(title || ensName || "");
const addrStr = escapeHtml(counterparty);
const dot = addressDotHtml(counterparty);
const err = tx.isError ? " (failed)" : "";
const opacity = tx.isError ? " opacity:0.5;" : "";
@@ -239,7 +241,8 @@ function renderTransactions(txs) {
const iso = escapeHtml(isoDate(tx.timestamp));
html += `<div class="tx-row py-2 border-b border-border-light text-xs cursor-pointer hover:bg-hover" data-tx="${i}" style="${opacity}">`;
html += `<div class="flex justify-between"><span class="text-muted" title="${iso}">${ago}</span><span>${dirLabel}${err}</span></div>`;
html += `<div class="flex justify-between"><span class="flex items-center">${dot}${addrStr}</span><span>${amountStr}</span></div>`;
html += `<div class="flex justify-between"><span class="flex items-center">${dot}${nameStr}</span><span>${amountStr}</span></div>`;
html += `<div class="am-address">${addrStr}</div>`;
html += `</div>`;
i++;
}

View File

@@ -10,8 +10,8 @@ const {
addressTitle,
escapeHtml,
displaySymbol,
truncateMiddle,
balanceLine,
unknownableAmount,
renderAddressHtml,
attachCopyHandlers,
goBack,
@@ -118,7 +118,9 @@ function show() {
addr.tokenBalances,
state.trackedTokens,
);
amount = tb ? parseFloat(tb.balance || "0") : 0;
// null when the scale is unknown: no quantity to show, and none to
// price. balanceLine() states that rather than printing 0.0000.
amount = tb ? unknownableAmount(tb.balance) : 0;
price = getPrice(symbol);
}
@@ -152,7 +154,7 @@ function show() {
attachCopyHandlers($("address-token-line"));
// USD total for this token only
const usdVal = price ? amount * price : null;
const usdVal = price && amount !== null ? amount * price : null;
const usdStr = formatUsd(usdVal);
$("address-token-usd-total").innerHTML = usdStr || "&nbsp;";
@@ -268,6 +270,7 @@ async function loadTransactions(address, tokenId) {
ensNameMap = await resolveEnsNames(
counterparties,
state.rpcUrl,
state.networkId,
);
} catch {
ensNameMap = new Map();
@@ -301,10 +304,12 @@ function renderTransactions(txs) {
const amountStr = tx.value
? escapeHtml(tx.value + " " + sym)
: escapeHtml(sym);
const maxAddr = Math.max(32, 36 - Math.max(0, amountStr.length - 10));
const displayAddr =
title || ensName || truncateMiddle(counterparty, maxAddr);
const addrStr = escapeHtml(displayAddr);
// The counterparty used to be squeezed in beside the amount and
// truncated to whatever was left over. It gets its own row now and
// is shown whole; the title or ENS name, where there is one, names
// it on the line above rather than replacing it.
const nameStr = escapeHtml(title || ensName || "");
const addrStr = escapeHtml(counterparty);
const dot = addressDotHtml(counterparty);
const err = tx.isError ? " (failed)" : "";
const opacity = tx.isError ? " opacity:0.5;" : "";
@@ -312,7 +317,8 @@ function renderTransactions(txs) {
const iso = escapeHtml(isoDate(tx.timestamp));
html += `<div class="tx-row py-2 border-b border-border-light text-xs cursor-pointer hover:bg-hover" data-tx="${i}" style="${opacity}">`;
html += `<div class="flex justify-between"><span class="text-muted" title="${iso}">${ago}</span><span>${dirLabel}${err}</span></div>`;
html += `<div class="flex justify-between"><span class="flex items-center">${dot}${addrStr}</span><span>${amountStr}</span></div>`;
html += `<div class="flex justify-between"><span class="flex items-center">${dot}${nameStr}</span><span>${amountStr}</span></div>`;
html += `<div class="am-address">${addrStr}</div>`;
html += `</div>`;
i++;
}

View File

@@ -73,6 +73,14 @@ function tokenLabel(address) {
function decodeCalldata(data, toAddress) {
if (!data || data === "0x" || data.length < 10) return null;
// Where a token's scale is looked for, for every decoder below: the ERC-20
// amount line and the swap's Amount and Min. received lines resolve it the
// same way, and refuse to format the same way when it is nowhere.
const decimalsSources = {
trackedTokens: state.trackedTokens,
wallets: state.wallets,
};
// Try ERC-20 (approve / transfer)
try {
const parsed = erc20Iface.parseTransaction({ data });
@@ -84,10 +92,10 @@ function decodeCalldata(data, toAddress) {
// the wrong number, and for a token with fewer decimals than the
// guess it is the wrong number in the direction that reads as
// zero. See tokenAmountText().
const tokenDecimals = resolveTokenDecimals(toAddress, {
trackedTokens: state.trackedTokens,
wallets: state.wallets,
});
const tokenDecimals = resolveTokenDecimals(
toAddress,
decimalsSources,
);
const contractLabel = tokenSymbol
? tokenSymbol + " (" + toAddress + ")"
: toAddress;
@@ -167,7 +175,7 @@ function decodeCalldata(data, toAddress) {
}
// Try Uniswap Universal Router
const routerResult = uniswap.decode(data, toAddress);
const routerResult = uniswap.decode(data, toAddress, decimalsSources);
if (routerResult) return routerResult;
return null;

View File

@@ -139,12 +139,17 @@ function show(txInfo) {
// Balance (with inline USD)
if (isErc20) {
const bal = txInfo.tokenBalance || "0";
const balUsd = tokenPrice ? parseFloat(bal) * tokenPrice : null;
$("confirm-balance").textContent = valueWithUsd(
bal + " " + symbol,
balUsd,
);
// null is a balance whose scale nothing knows, not a balance of zero
// (https://git.eeqj.de/sneak/AutistMask/issues/349). The send is
// refused at encode time for the same missing scale; what this line
// must not do is state a quantity nobody established.
const bal = txInfo.tokenBalance;
const balUsd =
tokenPrice && bal != null ? parseFloat(bal) * tokenPrice : null;
$("confirm-balance").textContent =
bal == null
? "unknown (" + symbol + ")"
: valueWithUsd(bal + " " + symbol, balUsd);
} else {
const bal = txInfo.balance || "0";
const balUsd = ethPrice ? parseFloat(bal) * ethPrice : null;
@@ -235,17 +240,22 @@ function renderValidation(txInfo) {
}
if (codes.includes(CODES.INSUFFICIENT_TOKEN)) {
messages.push(
"Insufficient " +
symbol +
" balance. You have " +
txInfo.tokenBalance +
" " +
symbol +
" but are trying to send " +
txInfo.amount +
" " +
symbol +
".",
txInfo.tokenBalance == null
? "This token's balance is unknown, because nothing this" +
" wallet can consult reports how many decimal places it" +
" uses, so the amount you are trying to send cannot be" +
" checked against it."
: "Insufficient " +
symbol +
" balance. You have " +
txInfo.tokenBalance +
" " +
symbol +
" but are trying to send " +
txInfo.amount +
" " +
symbol +
".",
);
}
if (codes.includes(CODES.INSUFFICIENT_ETH)) {
@@ -304,7 +314,7 @@ function formatFeeEth(wei) {
async function estimateGas(txInfo) {
try {
const provider = getProvider(state.rpcUrl);
const provider = getProvider(state.rpcUrl, state.networkId);
const feeData = await provider.getFeeData();
let gasLimit;
@@ -386,7 +396,7 @@ async function estimateGas(txInfo) {
async function checkRecipientHistory(txInfo) {
try {
const provider = getProvider(state.rpcUrl);
const provider = getProvider(state.rpcUrl, state.networkId);
const asyncWarnings = await getFullWarnings(txInfo.to, provider, {
fromAddress: txInfo.from,
});
@@ -454,7 +464,7 @@ function init(_ctx) {
state.selectedAddress,
decryptedSecret,
);
const provider = getProvider(state.rpcUrl);
const provider = getProvider(state.rpcUrl, state.networkId);
const connectedSigner = signer.connect(provider);
if (pendingTx.token === "ETH") {

View File

@@ -45,6 +45,11 @@ const VIEWS = [
"approve-sign",
"export-privkey",
"show-phrase",
// Shown by src/popup/views/stateRecovery.js when the stored profile
// cannot be read. It is never reached through showView() — by then the
// state singleton this file writes on every navigation refuses to be read
// — but it is listed so that every view-hiding loop covers it.
"state-recovery",
];
// Cleanup callbacks for views that hold a secret in the DOM. The view
@@ -127,6 +132,38 @@ function updateDebugBanner(viewName) {
}
}
// The banner shown when a save has failed, registered as the save-failure
// reporter by src/popup/index.js.
//
// Persistent and not dismissable, unlike showFlash(): what it says is true
// until the popup is closed, and a message that clears itself after two seconds
// is how the user goes on operating a wallet that is persisting nothing
// (https://git.eeqj.de/sneak/AutistMask/issues/362). It survives navigation
// because it hangs off document.body rather than off a view.
//
// Created on demand rather than authored in index.html, the same way
// updateDebugBanner() creates its own: it is absent from a popup where nothing
// has failed, which is the state that must not need markup to be in.
//
// textContent, never innerHTML: `detail` carries an error message, which may
// come from the browser's storage layer.
function showSaveFailureBanner(detail) {
let banner = document.getElementById("save-failure-banner");
if (!banner) {
banner = document.createElement("div");
banner.id = "save-failure-banner";
banner.style.cssText =
"background:#c00;color:#fff;text-align:center;font-size:10px;padding:2px 4px;font-family:monospace;position:sticky;top:0;z-index:10000;";
document.body.prepend(banner);
}
const message = (detail && (detail.message || detail.problem)) || detail;
banner.textContent =
"NOT SAVED — AutistMask could not write to storage, so recent" +
" changes are not stored. Close and reopen the popup; if this keeps" +
" happening, do not rely on anything you change now." +
(message ? " (" + String(message) + ")" : "");
}
// Callback that renders a view being navigated BACK onto. Set once by
// index.js via setBackRenderer(), which routes the view through the same
// per-view render and data guards restoreView() uses.
@@ -192,6 +229,15 @@ function showFlash(msg, duration = 2000) {
}, duration);
}
// A stored token balance as a number, or null when there is no number in it.
// balances.js writes null for a holding whose scale nothing knows, and this
// keeps that null from becoming a zero one dereference later.
function unknownableAmount(balance) {
if (balance == null) return null;
const n = parseFloat(balance);
return Number.isFinite(n) ? n : null;
}
// One row of the balance list: symbol, quantity, fiat value.
//
// `symbol` is the ERC-20's own symbol() as the block explorer reported it,
@@ -199,9 +245,18 @@ function showFlash(msg, duration = 2000) {
// attacker-chosen length until it has been through displaySymbol. This is
// the row that issue #307 was reported against: every screen that lists a
// holding renders through here.
//
// `amount` is null for a holding whose scale nothing knows
// (https://git.eeqj.de/sneak/AutistMask/issues/349). There is no quantity to
// print for it and no fiat value to derive from one, and printing 0.0000 for
// a real holding is the failure this whole rule exists to prevent, so the row
// says so instead.
function balanceLine(symbol, amount, price, tokenId) {
const qty = amount.toFixed(4);
const usd = price ? formatUsd(amount * price) || "&nbsp;" : "&nbsp;";
const qty = amount === null ? "quantity unknown" : amount.toFixed(4);
const usd =
price && amount !== null
? formatUsd(amount * price) || "&nbsp;"
: "&nbsp;";
// tokenId is a contract address out of the same explorer JSON, and it
// lands inside a quoted attribute.
const tokenAttr = tokenId ? ` data-token="${escapeHtml(tokenId)}"` : "";
@@ -228,7 +283,12 @@ function balanceLinesForAddress(addr, trackedTokens, showZero) {
);
const seen = new Set();
for (const t of addr.tokenBalances || []) {
const bal = parseFloat(t.balance || "0");
// A null balance is a holding of an unstatable amount, not a holding
// of zero, so the show-zero setting has no say over it: hiding it
// would be asserting the zero nobody established. Anything that does
// not parse to a finite number is unknown for the same reason — the
// `|| "0"` this replaced turned both into a confident zero.
const bal = unknownableAmount(t.balance);
if (bal === 0 && !showZero) continue;
html += balanceLine(
t.symbol,
@@ -261,11 +321,22 @@ function addressHoldsFunds(addr) {
if (!addr) return false;
if (parseFloat(addr.balance || "0") > 0) return true;
for (const t of addr.tokenBalances || []) {
if (parseFloat(t.balance || "0") > 0) return true;
// A null balance is a holding whose amount could not be stated —
// balances.js drops a row of zero base units before the scale is
// consulted, so a row that survived with no quantity is holding
// something. Warning about funds must err towards warning.
const bal = unknownableAmount(t.balance);
if (bal === null || bal > 0) return true;
}
return false;
}
// The fewest characters of an address any caller may ask to display. The
// 10-character cap inside truncateMiddle() is the other half of the same
// guarantee; this is the half that used to be spelled out at each call
// site, and is now enforced once in renderAddressHtml().
const ADDRESS_MIN_DISPLAY_LEN = 32;
// Truncate the middle of a string, replacing removed characters with "…".
// Safety: refuses to truncate more than 10 characters, which is the maximum
// that still prevents address spoofing attacks (see Display Consistency in
@@ -453,17 +524,29 @@ function attachCopyHandlers(container) {
// Unified address rendering.
//
// Produces consistent HTML for any Ethereum address:
// • Color dot
// • Optional title (e.g. "Wallet 1 — Address 2") shown bold above address
// • Optional ENS name shown bold above address
// • Full address (or truncated via maxLen) with dashed-underline click-to-copy
// • Etherscan external link icon
// Two stacked rows, in this order:
// 1. Identity strip — colour dot, optional title (e.g. "Wallet 1 —
// Address 2") and the explorer link icon. Optional ENS name below it.
// 2. The address itself, alone on a full-width row that never wraps
// (see .am-address in styles/main.css).
//
// The split is the point. Everything used to sit on one line: dot, address
// and link together, with `break-all` to let the address fold when the line
// ran out. In the wallet list, where the row also carried [info] and [x],
// it ran out every time — the bug in #380 — and a folded address is a
// spoofing hazard, not a cosmetic one. Nothing shares the address's row
// now, so all 42 characters fit at every nesting depth the popup uses and
// nothing has to be dropped or folded to make room.
//
// Options object:
// title — wallet title string (from addressTitle)
// ensName — ENS name string
// maxLen — if set, truncate address display (min 32 chars enforced)
// maxLen — if set, truncate address display. Floored at 32 characters
// here rather than by the caller: no view passes it any more
// (every address row is wide enough for all 42 characters),
// so a floor that lived in the callers would have gone away
// with them, and the "at least 32 characters" guarantee has
// to survive having no current callers to be a guarantee.
// noLink — if true, omit etherscan link
//
// After inserting the returned HTML into the DOM, call
@@ -471,22 +554,22 @@ function attachCopyHandlers(container) {
function renderAddressHtml(address, opts) {
const { title, ensName, maxLen, noLink } = opts || {};
const dot = addressDotHtml(address);
const displayAddr = maxLen ? truncateMiddle(address, maxLen) : address;
const displayAddr = maxLen
? truncateMiddle(address, Math.max(ADDRESS_MIN_DISPLAY_LEN, maxLen))
: address;
const link = etherscanAddressUrl(address);
const extLink = noLink ? "" : etherscanLinkHtml(link);
let html = "";
html += `<div class="flex items-center">${dot}`;
if (title) {
html += `<div class="flex items-center font-bold">${dot}${escapeHtml(title)}</div>`;
html += `<span class="font-bold">${escapeHtml(title)}</span>`;
}
html += `${extLink}</div>`;
if (ensName) {
html += `<div class="flex items-center font-bold">${title ? "" : dot}${escapeHtml(ensName)}</div>`;
}
if (title || ensName) {
html += `<div class="flex items-center">${copyableHtml(displayAddr, "break-all")}${extLink}</div>`;
} else {
html += `<div class="flex items-center">${dot}${copyableHtml(displayAddr, "break-all")}${extLink}</div>`;
html += `<div class="font-bold">${escapeHtml(ensName)}</div>`;
}
html += `<div class="am-address">${copyableHtml(displayAddr)}</div>`;
return html;
}
@@ -511,6 +594,7 @@ module.exports = {
showView,
onViewLeave,
updateDebugBanner,
showSaveFailureBanner,
setBackRenderer,
pushCurrentView,
goBack,
@@ -520,6 +604,7 @@ module.exports = {
balanceLine,
balanceLinesForAddress,
addressHoldsFunds,
unknownableAmount,
addressColor,
addressDotHtml,
escapeHtml,

View File

@@ -9,7 +9,6 @@ const {
addressTitle,
escapeHtml,
displaySymbol,
truncateMiddle,
renderAddressHtml,
attachCopyHandlers,
pushCurrentView,
@@ -117,10 +116,13 @@ function renderHomeTxList(ctx) {
const amountStr = tx.value
? escapeHtml(tx.value + " " + sym)
: escapeHtml(sym);
// The counterparty used to be squeezed in beside the amount and
// truncated to whatever was left over. It gets its own row now and
// is shown whole; the title, when it is one of our own addresses,
// names it on the line above rather than replacing it.
const title = addressTitle(counterparty, state.wallets);
const maxAddr = Math.max(32, 36 - Math.max(0, amountStr.length - 10));
const displayAddr = title || truncateMiddle(counterparty, maxAddr);
const addrStr = escapeHtml(displayAddr);
const titleStr = title ? escapeHtml(title) : "";
const addrStr = escapeHtml(counterparty);
const dot = addressDotHtml(counterparty);
const err = tx.isError ? " (failed)" : "";
const opacity = tx.isError ? " opacity:0.5;" : "";
@@ -128,7 +130,8 @@ function renderHomeTxList(ctx) {
const iso = escapeHtml(isoDate(tx.timestamp));
html += `<div class="home-tx-row py-2 border-b border-border-light text-xs cursor-pointer hover:bg-hover" data-tx="${i}" style="${opacity}">`;
html += `<div class="flex justify-between"><span class="text-muted" title="${iso}">${ago}</span><span>${dirLabel}${err}</span></div>`;
html += `<div class="flex justify-between"><span class="flex items-center">${dot}${addrStr}</span><span>${amountStr}</span></div>`;
html += `<div class="flex justify-between"><span class="flex items-center">${dot}${titleStr}</span><span>${amountStr}</span></div>`;
html += `<div class="am-address">${addrStr}</div>`;
html += `</div>`;
i++;
}
@@ -252,17 +255,22 @@ function walletListHtml() {
: "";
const dot = addressDotHtml(addr.address);
const titleBold = isActive ? "font-bold" : "";
html += `<div class="text-xs ${titleBold}">Address ${ai + 1}</div>`;
// [info] and [x] ride on the "Address N" line, which was empty
// to its right, so the address below gets the row to itself.
// They used to sit beside the address and take about a third of
// the width off it, which is what made a 42-character address
// fold onto a second line here and nowhere else (#380).
html += `<div class="flex text-xs items-center justify-between">`;
html += `<span class="flex items-center ${titleBold}">${dot}Address ${ai + 1}</span>`;
html += `<span class="flex-shrink-0 ml-1">${infoBtn}${removeBtn}</span>`;
html += `</div>`;
if (addr.ensName) {
// An ENS reverse record is whatever the name owner set it
// to; renderAddressHtml() escapes its own copy of this and
// this list was the one that did not.
html += `<div class="text-xs font-bold flex items-center">${dot}${escapeHtml(addr.ensName)}</div>`;
html += `<div class="text-xs font-bold">${escapeHtml(addr.ensName)}</div>`;
}
html += `<div class="flex text-xs items-center justify-between">`;
html += `<span class="flex items-center break-all">${addr.ensName ? "" : dot}${escapeHtml(addr.address)}</span>`;
html += `<span class="flex-shrink-0 ml-1">${infoBtn}${removeBtn}</span>`;
html += `</div>`;
html += `<div class="am-address text-xs">${escapeHtml(addr.address)}</div>`;
const addrTotal = formatAddressTotal(getAddressValue(addr));
html += `<div class="text-xs text-muted text-right min-h-[1rem]">${addrTotal || "&nbsp;"}</div>`;
html += balanceLinesForAddress(

View File

@@ -12,6 +12,7 @@ const {
const { state, currentAddress } = require("../../shared/state");
let ctx;
const { getProvider } = require("../../shared/balances");
const { resolveTokenDecimals } = require("../../shared/approvalAmount");
const { resolveSymbol } = require("../../shared/tokenList");
const { isLowHolderCount } = require("../../shared/holders");
const { isSpoofedSymbol } = require("../../shared/symbolSpoof");
@@ -159,9 +160,14 @@ function updateSendBalance() {
addr.tokenBalances,
state.trackedTokens,
);
const bal = tb ? tb.balance || "0" : "0";
// A null balance is a holding whose scale nothing knows. Saying "0"
// for it would be a claim about the amount; the send itself is
// refused later by transferAmountUnits() for the same missing scale.
const bal = tb ? tb.balance : "0";
$("send-balance").textContent =
"Current balance: " + bal + " " + symbol;
bal == null
? "Current balance: unknown (" + symbol + ")"
: "Current balance: " + bal + " " + symbol;
}
}
@@ -202,7 +208,7 @@ function init(_ctx) {
let ensName = null;
if (to.includes(".") && !to.startsWith("0x")) {
try {
const provider = getProvider(state.rpcUrl);
const provider = getProvider(state.rpcUrl, state.networkId);
const resolved = await provider.resolveName(to);
if (!resolved) {
showFlash("Could not resolve " + to);
@@ -235,8 +241,45 @@ function init(_ctx) {
addr.tokenBalances,
state.trackedTokens,
);
tokenBalance = tb ? tb.balance || "0" : "0";
tokenDecimals = tb ? tb.decimals : null;
// null carried through rather than flattened to "0": the confirm
// screen states an unknown balance as unknown, and
// validateTransfer() treats it as no balance to spend from, which
// is the fail-closed side of an amount nobody can check.
tokenBalance = tb ? (tb.balance ?? null) : "0";
// Resolved the same way balances.js resolved the scale it
// DISPLAYED this token's balance at: bundled list, then the user's
// tracked tokens, then the explorer. The stored
// tokenBalances[].decimals is the explorer's own answer alone, so
// reading it raw carries a null forward for a token the wallet
// does know the scale of — and displayedDecimals() then throws
// inside estimateGas(), which the confirmation screen reports as
// an unestimable fee. Unsendable, over a scale that was never in
// doubt (https://git.eeqj.de/sneak/AutistMask/issues/349).
// Still null when nothing knows: no fallback.
//
// Resolved WITH `wallets`, which balances.js does not pass: that
// adds explorerDecimals()'s cross-address check, so a contract two
// addresses report different scales for answers null rather than
// picking one. That check has to apply here, because this value
// encodes a transfer; balances.js is formatting one explorer row
// at fetch time and cannot consult a state it is in the middle of
// replacing.
tokenDecimals = resolveTokenDecimals(token, {
trackedTokens: state.trackedTokens,
wallets: state.wallets,
});
// The two resolutions can therefore differ, and where they do, the
// stored `balance` is a quantity computed at a scale this screen
// has just declined to stand behind. Stating it would leave
// validateTransfer() checking the amount against a number the
// wallet does not vouch for, and — since the unknown-balance path
// is gated on the balance, not on the scale — would leave the
// fee-estimate failure as the only thing on the confirmation
// screen, which says nothing about decimals. Unknown scale means
// unknown balance. Only a stored quantity is withdrawn: the "0"
// for a token that has no row at all is an absence of holdings,
// which is true at every scale.
if (tb && tokenDecimals === null) tokenBalance = null;
}
ctx.showConfirmTx({

View File

@@ -133,7 +133,11 @@ function init(_ctx) {
infoEl.style.visibility = "visible";
log.debugf("Looking up token contract", addr);
try {
const info = await lookupTokenInfo(addr, state.rpcUrl);
const info = await lookupTokenInfo(
addr,
state.rpcUrl,
state.networkId,
);
log.infof("Adding token", info.symbol, addr);
state.trackedTokens.push({
address: addr,

View File

@@ -0,0 +1,197 @@
// The screen the popup shows when it cannot read the stored profile.
//
// Everything else in the popup assumes a loaded profile: showView() reads and
// writes the state singleton, every view renders from it, and the Settings
// gear leads to a screen that does both. None of that is available here — by
// the time this runs, loadState() has REFUSED, deliberately, and reading the
// singleton throws (https://git.eeqj.de/sneak/AutistMask/issues/311).
//
// So this module talks to the DOM directly and touches no state at all. It is
// the one screen that must work when nothing else can, which is also why it
// takes no ctx and needs no init(): whatever the rest of the popup did or did
// not manage to wire up, this shows.
//
// Two controls, and both are required. An export with no reset leaves the user
// looking at their broken profile with no way to use the wallet again; a reset
// with no export destroys the only copy of a record that may hold key material
// a later build could read. So the export is offered first, in the page where
// it cannot fail, and the reset is behind a typed confirmation.
// $ and VIEWS only: nothing else in helpers is safe here, since showView() and
// everything under it read the state singleton. $ is taken from there rather
// than written again locally so that tests/popupElementIds.test.js sees these
// lookups and holds every id below against the markup.
const { $, VIEWS } = require("./helpers");
const { storageGet, storageRemove } = require("../../shared/browserApi");
const { log } = require("../../shared/log");
// Typed in full before anything is erased, in the same spirit as the wallet
// name on DeleteWalletLostPassword: this button destroys key material and
// there is no password in front of it, because there is no profile to check a
// password against. Compared case-insensitively — the phrase is the barrier,
// not the shift key.
const RESET_PHRASE = "ERASE MY WALLET";
let wired = false;
function setFlash(message) {
const node = $("state-recovery-flash");
node.textContent = message;
node.style.visibility = message ? "visible" : "hidden";
}
// The stored record exactly as storage hands it back, however malformed, with
// no normalization, no defaulting and no repair on it: this is evidence, and
// the point of the export is that a later build (or a human) sees what is
// actually there. It is not the raw bytes — storage deserializes, and
// exportRecord() re-serializes with JSON.stringify — so a value JSON cannot
// represent is the one thing that does not survive the trip. See there.
async function rawRecord() {
const result = await storageGet("autistmask");
return result.autistmask;
}
// Best effort, and never the only route. A download from an extension popup
// depends on the browser, the popup staying open long enough, and the
// extension's content security policy; the textarea below depends on none of
// those, and is filled first.
function offerDownload(text) {
try {
if (
typeof Blob !== "function" ||
typeof URL === "undefined" ||
typeof URL.createObjectURL !== "function"
) {
return false;
}
const url = URL.createObjectURL(
new Blob([text], { type: "application/json" }),
);
const link = document.createElement("a");
link.href = url;
link.download = "autistmask-saved-data.json";
link.click();
// Revoked in a later task, not in this one: the download is started
// from the click and revoking the URL in the same turn can cancel it
// before it has been read. If the popup closes first the URL dies with
// the document anyway.
if (typeof URL.revokeObjectURL === "function") {
setTimeout(() => URL.revokeObjectURL(url), 0);
}
return true;
} catch (e) {
log.errorf("state recovery: download failed:", e);
return false;
}
}
// Residual, stated rather than left to be discovered: structured-clone storage
// holds values JSON does not have, and no build here writes one, but the export
// is a funds-recovery path and what it cannot carry has to be written down.
//
// Loud: JSON.stringify THROWS on a reference cycle or a BigInt. That lands in
// the catch below, so the export fails entirely and erase is the only control
// left on the screen.
//
// Silent, and the worse of the two, because the box then looks complete:
// a Date becomes its ISO string, a Map or a Set becomes {}, a property whose
// value is undefined is dropped from the output entirely, and NaN and
// ±Infinity become null. Nothing here can serialize any of it faithfully;
// recovering such a record needs the browser's own storage inspector.
async function exportRecord() {
let text;
try {
const record = await rawRecord();
text = JSON.stringify(record === undefined ? null : record, null, 2);
} catch (e) {
log.errorf("state recovery: export failed:", e);
setFlash(
"The saved data could not be read out of storage. Nothing has" +
" been changed.",
);
return;
}
// JSON.stringify answers undefined for a value it cannot represent, and
// an empty box would read as "there was nothing there".
if (typeof text !== "string") text = String(text);
const box = $("state-recovery-blob");
box.value = text;
box.classList.remove("hidden");
const downloaded = offerDownload(text);
setFlash(
downloaded
? "Saved data downloaded, and shown below. Keep a copy before" +
" erasing anything."
: "Saved data shown below. Copy it and keep it before erasing" +
" anything.",
);
}
async function resetProfile() {
const typed = $("state-recovery-reset-input").value || "";
if (typed.trim().toUpperCase() !== RESET_PHRASE) {
setFlash("Type " + RESET_PHRASE + " to confirm. Nothing was erased.");
return;
}
try {
await storageRemove("autistmask");
} catch (e) {
log.errorf("state recovery: reset failed:", e);
setFlash("The saved data could not be erased. Nothing was changed.");
return;
}
setFlash("Saved data erased. AutistMask is starting fresh.");
// Back to a first run, which is what the wallet now is. A popup that
// cannot reload says so rather than sitting on a screen describing a
// profile that no longer exists.
if (
typeof window !== "undefined" &&
window.location &&
typeof window.location.reload === "function"
) {
window.location.reload();
return;
}
setFlash("Saved data erased. Close and reopen AutistMask.");
}
function wire() {
if (wired) return;
wired = true;
$("btn-state-recovery-export").addEventListener("click", exportRecord);
$("btn-state-recovery-reset").addEventListener("click", resetProfile);
}
/**
* Show the recovery screen, naming `problem`.
*
* @param {Error|string} problem the StateUnusableError from the read that
* refused, or its sentence.
*/
function show(problem) {
const sentence =
(problem && (problem.problem || problem.message)) || String(problem);
// Not showView(): that reads and writes the singleton this screen exists
// because nothing could load.
for (const view of VIEWS) {
const node = document.getElementById("view-" + view);
if (node) node.classList.add("hidden");
}
// The one global control, and it leads to a screen that renders from the
// profile. There is nowhere to go from here but out.
const gear = $("btn-settings");
if (gear) gear.classList.add("hidden");
$("state-recovery-problem").textContent = sentence;
$("state-recovery-blob").value = "";
$("state-recovery-blob").classList.add("hidden");
$("state-recovery-reset-input").value = "";
setFlash("");
wire();
$("view-state-recovery").classList.remove("hidden");
log.errorf("state is unusable, showing the recovery screen:", sentence);
}
module.exports = { show, RESET_PHRASE };

View File

@@ -137,10 +137,16 @@ function render() {
if (tx.contractAddress) {
const dot = addressDotHtml(tx.contractAddress);
const link = explorerUrl("token", tx.contractAddress);
// Hand-rolled rather than renderAddressHtml() because the
// link goes to the explorer's /token/ page, not /address/.
// Same two-row shape though: dot and link on the strip, the
// contract address alone on the row below it.
tokenContractEl.innerHTML =
`<div class="flex items-center">${dot}` +
copyableHtml(tx.contractAddress, "break-all") +
etherscanLinkHtml(link) +
`</div>` +
`<div class="am-address">` +
copyableHtml(tx.contractAddress) +
`</div>`;
tokenContractSection.classList.remove("hidden");
} else {

View File

@@ -113,7 +113,7 @@ function startWait(txInfo, txHash, broadcastTime, pollNow) {
renderElapsed();
}, 1000);
const provider = getProvider(state.rpcUrl);
const provider = getProvider(state.rpcUrl, state.networkId);
let consecutiveFailures = 0;
async function poll() {

View File

@@ -14,38 +14,23 @@
// them answers, unknownDecimalsAmount() renders the base-unit integer with the
// unknown scale stated, and no formatUnits() call is reached at all.
//
// The Uniswap decoder's Amount and Min. received lines land on this same
// screen and use these same two functions, so there is one way of resolving a
// scale and one way of saying there is none.
//
// This is the display counterpart to transferAmount.js, which takes the same
// stance on the wallet's own send path: an amount whose scale is unknown or
// disputed is refused rather than guessed at.
// Solidity's decimals() is a uint8, and every source here is ultimately
// reporting that call's result.
const { MAX_DECIMALS } = require("./transferAmount");
// reporting that call's result. toDecimals() is that check, shared with the
// send path rather than copied: the bundled list stores numbers, the
// explorer's copy arrives as a string, and a token the user added by hand
// carries whatever lookupTokenInfo() got back, so the accepted types are
// enumerated rather than coerced.
const { toDecimals } = require("./transferAmount");
const { TOKEN_BY_ADDRESS } = require("./tokenList");
// A decimals value as a number, or null if it is not one. The bundled list
// stores numbers, the explorer's copy arrives as a string, and a token the
// user added by hand can carry whatever lookupTokenInfo() got back, so the
// accepted types are enumerated rather than coerced: Number([]) is 0 and
// Number(true) is 1, so a coercing check would read an empty array as a scale
// of zero and format the amount as whole tokens.
function toDecimals(value) {
let n;
if (typeof value === "number") {
n = value;
} else if (typeof value === "bigint") {
if (value < 0n || value > BigInt(MAX_DECIMALS)) return null;
n = Number(value);
} else if (typeof value === "string") {
if (!/^[0-9]+$/.test(value)) return null;
n = Number(value);
} else {
return null;
}
if (!Number.isInteger(n) || n < 0 || n > MAX_DECIMALS) return null;
return n;
}
// Every decimals the explorer reported for this contract, across all the
// addresses whose balances have been fetched. They describe one contract, so
// they should agree; a set that does not agree is a scale in dispute, and this

View File

@@ -9,25 +9,49 @@ const {
formatUnits,
} = require("ethers");
const { ERC20_ABI } = require("./constants");
const { NETWORKS } = require("./networks");
const { log, debugFetch } = require("./log");
const { deriveAddressFromXpub } = require("./wallet");
const { TOKEN_BY_ADDRESS } = require("./tokenList");
const { LOW_HOLDER_THRESHOLD, parseHoldersCount } = require("./holders");
const { isSpoofedSymbol } = require("./symbolSpoof");
const { toDecimals } = require("./transferAmount");
const { resolveTokenDecimals } = require("./approvalAmount");
// Use a static network to skip auto-detection (which can fail and cause
// "could not coalesce error" on some RPC endpoints like Cloudflare).
// Accepts an optional networkName ("mainnet" or "sepolia") for the static
// network hint so ethers picks the right chain parameters. When omitted,
// reads the currently selected network from extension state.
function getProvider(rpcUrl, networkName) {
// Lazy require to avoid circular dependency issues at module scope.
const { currentNetwork } = require("./state");
const name = networkName || currentNetwork().id;
const net = Network.from(name);
//
// `networkId` is REQUIRED, and is one of the ids in networks.js. It used to be
// optional, falling back to currentNetwork() — the module-level `state`
// singleton, which the MV3 service worker never populates. The endpoint then
// came out right and the static hint came out mainnet, so ethers fixed
// `chainId` at 0x1 and every non-mainnet dApp send was prepared for the wrong
// chain and then refused by the wallet's own verifier
// (https://git.eeqj.de/sneak/AutistMask/issues/320). Requiring it is what
// stops that from coming back: a caller that has no network to name has no
// business constructing a provider, and there is no longer a default for it
// to get silently wrong.
//
// Validated against NETWORKS rather than passed straight to Network.from():
// ethers knows chains this wallet does not, so an id that is not one of ours
// is a caller bug and must not resolve to a working provider for some other
// chain.
function getProvider(rpcUrl, networkId) {
const net = Network.from(requireNetworkId(networkId).id);
return new JsonRpcProvider(rpcUrl, net, { staticNetwork: net });
}
function requireNetworkId(networkId) {
const net = NETWORKS[networkId];
if (!net) {
throw new Error(
"getProvider requires the id of a supported network; got " +
JSON.stringify(networkId),
);
}
return net;
}
function formatBalance(wei) {
const eth = formatEther(wei);
const parts = eth.split(".");
@@ -44,10 +68,28 @@ function formatTokenBalance(raw, decimals) {
return parts[0] + "." + dec;
}
// The explorer's reported holding as an exact base-unit integer, or null when
// it reported nothing usable. Base units carry no scale, so this value is
// meaningful before the scale is known — which is what lets a holding of zero
// be recognised as zero without guessing a scale to divide it by.
function rawUnits(value) {
if (typeof value === "bigint") return value >= 0n ? value : null;
if (typeof value === "number") {
return Number.isSafeInteger(value) && value >= 0 ? BigInt(value) : null;
}
if (typeof value !== "string" || !/^[0-9]+$/.test(value)) return null;
return BigInt(value);
}
// Fetch token balances for a single address from Blockscout.
// Returns [{ address, symbol, decimals, balance }].
// Returns [{ address, name, symbol, decimals, balance, holders }].
// Filters out spam: only shows tokens that are in the known token list,
// explicitly tracked by the user, or have >= 1000 holders.
//
// `decimals` and `balance` are each null when the answer is unknown, the same
// way `holders` already is. Absence is never filled in here: this is the
// upstream of every screen that displays a token amount, so a value invented
// at this point is indistinguishable from a real one everywhere below it.
async function fetchTokenBalances(address, blockscoutUrl, trackedTokens) {
try {
const resp = await debugFetch(
@@ -72,11 +114,46 @@ async function fetchTokenBalances(address, blockscoutUrl, trackedTokens) {
// is unchanged.
const type = String(item.token?.type || "").toUpperCase();
if (type !== "ERC-20") continue;
const decimals = parseInt(item.token.decimals || "18", 10);
const bal = formatTokenBalance(item.value || "0", decimals);
if (bal === "0.0") continue;
const tokenAddr = (item.token.address_hash || "").toLowerCase();
// What the explorer reported, or null. NEVER a default: this
// value is written to state and every later reader — the approval
// screen's amount line, the swap lines, the Send screen — takes it
// as the token's resolved scale. A fabricated 18 reads exactly
// like a real 18 at that point, so it does not merely display the
// wrong quantity, it walks straight past the refusal those screens
// already have for a scale nobody knows
// (https://git.eeqj.de/sneak/AutistMask/issues/349).
const decimals = toDecimals(item.token.decimals);
const raw = rawUnits(item.value);
// No usable amount at all is nothing to list, exactly as a
// formatted "0.0" was before. Checked on the base-unit integer so
// it does not depend on knowing the scale: zero base units is zero
// tokens at every scale, and a value the explorer did not report
// as an integer is not a holding.
if (raw === null || raw === 0n) continue;
// The scale this row's balance is DISPLAYED at, which is not the
// same question as what the explorer said. The bundled list and
// the tokens the user tracks both outrank the explorer already
// (resolveTokenDecimals), so a token they know keeps showing its
// real quantity even when the explorer's entry omits decimals.
// Only what neither of them nor the explorer knows is unknown.
// The stored `decimals` above stays the explorer's own answer
// either way: copying another source into it would make
// explorerDecimals()'s disagreement check compare something other
// than explorer values.
const known = resolveTokenDecimals(tokenAddr, { trackedTokens });
const scale = known !== null ? known : decimals;
// null is a holding of an amount that cannot be stated, which is
// not the same as a holding of zero, and must never render as one.
// With a scale, the display filter proper applies: a balance that
// rounds to zero at six places is dust and is not listed. Without
// one there is no such judgement to make, and the row is kept.
const bal = scale === null ? null : formatTokenBalance(raw, scale);
if (bal === "0.0") continue;
// null means the explorer reported no count, which is not the
// same as a count of zero. This gate is not the low-holder
// display filter: it has no user-facing off switch and governs
@@ -105,7 +182,15 @@ async function fetchTokenBalances(address, blockscoutUrl, trackedTokens) {
address: item.token.address_hash,
name: item.token.name || "",
symbol: item.token.symbol || "???",
// null means the explorer reported no usable scale — unknown,
// not 18. Distinguishable from a real 18 at read time is the
// entire point: resolveTokenDecimals() falls through a null to
// its refusal, and takes an 18 as the answer.
decimals: decimals,
// null means nothing anywhere knows the scale, so there is no
// token quantity to state. Not "0.0": a nonzero holding shown
// as zero is the same lie in the balance list that the
// approval screens refuse to tell.
balance: bal,
holders: holders,
});
@@ -118,9 +203,15 @@ async function fetchTokenBalances(address, blockscoutUrl, trackedTokens) {
}
// Fetch ETH balances, ENS names, and ERC-20 token balances for all addresses.
async function refreshBalances(wallets, rpcUrl, blockscoutUrl, trackedTokens) {
async function refreshBalances(
wallets,
rpcUrl,
blockscoutUrl,
trackedTokens,
networkId,
) {
log.debugf("refreshBalances start, rpc:", rpcUrl);
const provider = getProvider(rpcUrl);
const provider = getProvider(rpcUrl, networkId);
const updates = [];
for (const wallet of wallets) {
@@ -193,9 +284,9 @@ async function refreshBalances(wallets, rpcUrl, blockscoutUrl, trackedTokens) {
// Look up token metadata from its contract.
// Calls symbol() and decimals() to verify it implements ERC-20.
async function lookupTokenInfo(contractAddress, rpcUrl) {
async function lookupTokenInfo(contractAddress, rpcUrl, networkId) {
log.debugf("lookupTokenInfo", contractAddress, "rpc:", rpcUrl);
const provider = getProvider(rpcUrl);
const provider = getProvider(rpcUrl, networkId);
const contract = new Contract(contractAddress, ERC20_ABI, provider);
let name, symbol, decimals;
@@ -235,9 +326,9 @@ async function lookupTokenInfo(contractAddress, rpcUrl) {
// Checks gapLimit addresses in parallel per batch. Stops when an entire
// batch has no used addresses (i.e. gapLimit consecutive empty addresses).
// Returns { addresses: [{ address, index }], nextIndex }.
async function scanForAddresses(xpub, rpcUrl, gapLimit = 5) {
async function scanForAddresses(xpub, rpcUrl, networkId, gapLimit = 5) {
log.debugf("scanForAddresses start, gapLimit:", gapLimit);
const provider = getProvider(rpcUrl);
const provider = getProvider(rpcUrl, networkId);
const used = [];
let checked = 0;
let checkUpTo = gapLimit;

View File

@@ -194,6 +194,23 @@ function storageSet(items) {
return Promise.resolve(storage.set(items));
}
/**
* Erase stored keys. The one caller is the destructive reset on the recovery
* screen (src/popup/views/stateRecovery.js), which is the only way out of a
* profile no build can read; it rejects rather than defaulting for the same
* reason the two above do — a reset that silently did nothing would leave the
* user in the dead end they were promised an exit from.
*
* @param {string|string[]} keys
* @returns {Promise<void>}
* @throws rejects where `storage.local` is absent.
*/
function storageRemove(keys) {
const storage = storageLocal();
if (!storage) return storageUnavailable("remove");
return Promise.resolve(storage.remove(keys));
}
/**
* @param {Object} queryInfo
* @returns {Promise<Array>} the matching tabs.
@@ -251,6 +268,7 @@ module.exports = {
sendMessage,
storageGet,
storageLocal,
storageRemove,
storageSet,
tabsApi,
tabsQuery,

View File

@@ -1,14 +1,23 @@
// Consolidated chain-switch handler.
// Consolidated chain-switch handler for the popup.
//
// Every state change required when the active network changes is
// performed here so that callers (settings UI, background
// wallet_switchEthereumChain, future chain additions) all go
// performed here so that callers (settings UI, future chain additions) all go
// through a single code path.
//
// Adding a new chain (e.g. ETC) requires only a new entry in
// networks.js — no per-caller wiring is needed.
//
// The background does NOT come through here: this function mutates the
// module-level `state` singleton, which the MV3 service worker never
// populates, and a background switch performed on it wrote DEFAULT_STATE over
// the user's whole profile
// (https://git.eeqj.de/sneak/AutistMask/issues/316). The field mutations
// themselves live in chainSwitchFields.js, which takes the record to mutate as
// an argument; src/background/state.js applies them inside a read-modify-write
// against storage, and the singleton is not reachable from the background
// bundle at all (enforced by the ESLint rule in eslint.config.js).
const { networkById } = require("./networks");
const { applyChainSwitchFields } = require("./chainSwitchFields");
const { clearPrices } = require("./prices");
// Switch the active chain and reset all chain-specific cached state.
@@ -16,56 +25,14 @@ const { clearPrices } = require("./prices");
async function onChainSwitch(newNetworkId) {
const { state, saveState } = require("./state");
const net = networkById(newNetworkId);
// --- core identity ---
// Endpoints are remembered per network rather than reset to the
// defaults, because a user who points the wallet at their own node has
// no way to get that URL back once it is gone: overwriting it moved
// every address and every transaction onto a third-party endpoint
// silently and permanently.
//
// state.rpcUrl / state.blockscoutUrl stay the live endpoints of the
// active network, so nothing that reads them changes. The invariant is
// that for the ACTIVE network those two fields are authoritative and
// the map entry may be stale (Settings writes the fields directly);
// for every other network the map is authoritative. Snapshotting the
// outgoing network here, before the switch, is what reconciles them.
state.networkEndpoints[state.networkId] = {
rpcUrl: state.rpcUrl,
blockscoutUrl: state.blockscoutUrl,
};
const remembered = state.networkEndpoints[net.id] || {};
state.networkId = net.id;
state.rpcUrl = remembered.rpcUrl || net.defaultRpcUrl;
state.blockscoutUrl = remembered.blockscoutUrl || net.defaultBlockscoutUrl;
const net = applyChainSwitchFields(state, newNetworkId);
// --- price cache ---
// Prices are chain-specific (testnet tokens are worthless,
// ETC has different pricing, etc.).
// ETC has different pricing, etc.). In-memory and per bundle, so this is
// the popup's own cache — the only context that ever fills it.
clearPrices();
// --- balance / refresh state ---
// Reset last-refresh timestamp so the next polling cycle
// triggers an immediate balance refresh on the new chain.
state.lastBalanceRefresh = 0;
// Clear per-address balances and token balances so stale data
// from the previous chain is never displayed while the first
// refresh on the new chain is in flight.
for (const wallet of state.wallets) {
for (const addr of wallet.addresses) {
addr.balance = "0";
addr.tokenBalances = [];
}
}
// --- chain-specific caches ---
// Token holder counts and fraud contract lists are
// chain-specific and must not carry over.
state.tokenHolderCache = {};
state.fraudContracts = [];
await saveState();
return net;

View File

@@ -0,0 +1,69 @@
// The field mutations a chain switch performs, applied to a state record
// handed in rather than to the module-level `state` singleton.
//
// Split out of chainSwitch.js so the background can perform a chain switch
// without the singleton being reachable from its bundle at all. The popup
// still goes through onChainSwitch() (chainSwitch.js), which applies this to
// the singleton and saves; the background applies it to the detached record of
// its own read-modify-write (src/background/state.js).
//
// Everything here is synchronous and touches nothing but the object it is
// given: no storage, no caches, no imports beyond the network table. That is
// what makes it usable on a record that has been read fresh from storage
// microseconds earlier and is about to be written back.
const { networkById } = require("./networks");
// Switch `s` to `newNetworkId` and reset every piece of chain-specific state
// it carries. Returns the network configuration object for the new chain.
function applyChainSwitchFields(s, newNetworkId) {
const net = networkById(newNetworkId);
// --- core identity ---
// Endpoints are remembered per network rather than reset to the
// defaults, because a user who points the wallet at their own node has
// no way to get that URL back once it is gone: overwriting it moved
// every address and every transaction onto a third-party endpoint
// silently and permanently.
//
// s.rpcUrl / s.blockscoutUrl stay the live endpoints of the active
// network, so nothing that reads them changes. The invariant is that for
// the ACTIVE network those two fields are authoritative and the map entry
// may be stale (Settings writes the fields directly); for every other
// network the map is authoritative. Snapshotting the outgoing network
// here, before the switch, is what reconciles them.
if (!s.networkEndpoints) s.networkEndpoints = {};
s.networkEndpoints[s.networkId] = {
rpcUrl: s.rpcUrl,
blockscoutUrl: s.blockscoutUrl,
};
const remembered = s.networkEndpoints[net.id] || {};
s.networkId = net.id;
s.rpcUrl = remembered.rpcUrl || net.defaultRpcUrl;
s.blockscoutUrl = remembered.blockscoutUrl || net.defaultBlockscoutUrl;
// --- balance / refresh state ---
// Reset last-refresh timestamp so the next polling cycle
// triggers an immediate balance refresh on the new chain.
s.lastBalanceRefresh = 0;
// Clear per-address balances and token balances so stale data
// from the previous chain is never displayed while the first
// refresh on the new chain is in flight.
for (const wallet of s.wallets || []) {
for (const addr of wallet.addresses || []) {
addr.balance = "0";
addr.tokenBalances = [];
}
}
// --- chain-specific caches ---
// Token holder counts and fraud contract lists are
// chain-specific and must not carry over.
s.tokenHolderCache = {};
s.fraudContracts = [];
return net;
}
module.exports = { applyChainSwitchFields };

View File

@@ -32,11 +32,11 @@ function setCache(address, name) {
localStorage.setItem(key, JSON.stringify({ name, ts: Date.now() }));
}
async function resolveEnsName(address, rpcUrl) {
async function resolveEnsName(address, rpcUrl, networkId) {
const cached = getCached(address);
if (cached !== undefined) return cached;
const provider = getProvider(rpcUrl);
const provider = getProvider(rpcUrl, networkId);
try {
const name = (await provider.lookupAddress(address)) || null;
setCache(address, name);
@@ -48,11 +48,11 @@ async function resolveEnsName(address, rpcUrl) {
}
}
async function resolveEnsNames(addresses, rpcUrl) {
async function resolveEnsNames(addresses, rpcUrl, networkId) {
const results = new Map();
await Promise.all(
addresses.map(async (addr) => {
results.set(addr, await resolveEnsName(addr, rpcUrl));
results.set(addr, await resolveEnsName(addr, rpcUrl, networkId));
}),
);
return results;

View File

@@ -31,8 +31,42 @@ const SUPPORTED_CHAIN_IDS = new Set(
Object.values(NETWORKS).map((n) => n.chainId),
);
// Thrown rather than defaulted. An id this build does not know used to answer
// with MAINNET, so a stored `{networkId:"base"}` rendered the selector as
// Ethereum Mainnet with no banner and answered eth_chainId 0x1, while rpcUrl
// still pointed at Base — the wallet telling the user and the page one chain
// while transacting on another. Nothing in this codebase has an unknown id to
// offer: stored state is validated against this table before it is loaded
// (src/shared/stateSchema.js), and every other caller passes an id it took
// from here. So an unknown id is a defect, and it says so, the same way
// getProvider() (src/shared/balances.js) already refuses one.
class UnknownNetworkError extends Error {
constructor(id) {
super(
"AutistMask does not know the network " +
JSON.stringify(id) +
"; it supports " +
Object.keys(NETWORKS).join(", "),
);
this.name = "UnknownNetworkError";
this.networkId = id;
}
}
// Own properties only: NETWORKS inherits from Object.prototype, so
// NETWORKS["constructor"] and NETWORKS["__proto__"] both answer with something
// truthy that is not a network. A stored id is untrusted input, and this is
// the test the validator uses to decide whether it may be adopted at all.
function isKnownNetworkId(id) {
return (
typeof id === "string" &&
Object.prototype.hasOwnProperty.call(NETWORKS, id)
);
}
function networkById(id) {
return NETWORKS[id] || NETWORKS.mainnet;
if (!isKnownNetworkId(id)) throw new UnknownNetworkError(id);
return NETWORKS[id];
}
function networkByChainId(chainId) {
@@ -51,6 +85,8 @@ function explorerLink(network, type, value) {
module.exports = {
NETWORKS,
SUPPORTED_CHAIN_IDS,
UnknownNetworkError,
isKnownNetworkId,
networkById,
networkByChainId,
explorerLink,

View File

@@ -0,0 +1,425 @@
// The shape of the persisted profile, and the normalization every read of it
// goes through. No singleton, no storage access, no browser API: just the
// record definition and pure functions over it.
//
// Split out of state.js so that a context which must never touch the
// module-level `state` singleton can still speak the same record format.
// src/background/state.js is that context — the MV3 service worker never
// populates the singleton, and every defect in
// https://git.eeqj.de/sneak/AutistMask/issues/324 came from background code
// reaching it anyway and being served DEFAULT_STATE.
const { DEFAULT_RPC_URL, DEFAULT_BLOCKSCOUT_URL } = require("./constants");
const { isKnownNetworkId } = require("./networks");
const { STATE_SCHEMA_VERSION } = require("./stateSchema");
// Dependency-free constant module. It lives under src/shared/ rather than
// src/popup/ precisely because this module is in the background bundle: a
// popup-path module reached from the worker is the shape the prohibition in
// script/lib/forbiddenBundleInputs.js exists to keep out, even when the
// particular module is harmless.
const { RESTORABLE_VIEWS } = require("./restorableViews");
const DEFAULT_STATE = {
hasWallet: false,
wallets: [],
trackedTokens: [],
networkId: "mainnet",
rpcUrl: DEFAULT_RPC_URL,
blockscoutUrl: DEFAULT_BLOCKSCOUT_URL,
// Endpoints remembered per network: { [networkId]: { rpcUrl,
// blockscoutUrl } }. rpcUrl/blockscoutUrl above are the live endpoints
// of the active network; this is what the others are restored from
// when the active network changes. See applyChainSwitchFields().
networkEndpoints: {},
lastBalanceRefresh: 0,
activeAddress: null,
allowedSites: {},
deniedSites: {},
rememberSiteChoice: true,
showZeroBalanceTokens: true,
hideSpoofedSymbols: true,
hideLowHolderTokens: true,
hideFraudContracts: true,
hideDustTransactions: true,
dustThresholdGwei: 100000,
utcTimestamps: false,
fraudContracts: [],
tokenHolderCache: {},
theme: "system",
debugMode: false,
};
// Every field written to and read from the single "autistmask" storage key.
// hasWallet is deliberately excluded from the diffing/merge logic in
// state.js — like loadState() does, it is always derived from `wallets`,
// never carried as an independent value. schemaVersion is excluded for the
// same reason and is absent from DEFAULT_STATE for it: it describes the
// record rather than being part of it, and every write stamps the current
// value rather than diffing whatever was read.
const PERSISTED_FIELDS = Object.keys(DEFAULT_STATE)
.filter((key) => key !== "hasWallet")
.concat([
"currentView",
"selectedWallet",
"selectedAddress",
"selectedToken",
"viewData",
"viewStack",
]);
function isRecord(value) {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
// A list of token references, as everything downstream dereferences them:
// `t.address.toLowerCase()`, with no guard of its own (src/shared/balances.js,
// src/popup/views/helpers.js, and every view that shows a balance line).
//
// Both the container AND the entries, because they are separate defects. A
// container check alone leaves a well-formed list of malformed entries walking
// through to a dereference one level below the check, which is the same blank
// popup: `[1, 2]` and `[{}]` are lists.
//
// A malformed entry is DROPPED rather than repaired: a token reference with no
// address identifies nothing, so there is no value to repair it to, and the
// alternative — refusing the whole record — sends a user whose wallets are
// perfectly readable to an export-or-erase screen over a token list. An entry
// that is a record with a text address is kept verbatim, extra fields and all.
//
// Verbatim is load-bearing for the fields BESIDE the address. A tokenBalances
// entry carries `decimals: null` and `balance: null` when nothing knows the
// token's scale (src/shared/balances.js,
// https://git.eeqj.de/sneak/AutistMask/issues/349), and those nulls are the
// record that the value is unknown. Only `address` decides whether an entry
// survives, so an unknown-scale holding is kept — flooring a null here to some
// default would put the guess back one layer down from where it was removed.
function tokenRefs(value) {
if (!Array.isArray(value)) return [];
return value.filter(
(entry) => isRecord(entry) && typeof entry.address === "string",
);
}
// A list of strings, for the fields whose entries are dereferenced as text:
// fraudContracts (`a.toLowerCase()` in src/popup/views/send.js and
// src/shared/transactions.js) and each address's hostname list in the site maps
// below (`h !== host` filters, `list.includes(hostname)` in the background).
//
// Same rule as tokenRefs(), for the same reason: the container AND the entries,
// with a malformed entry DROPPED rather than repaired. A number in a hostname
// list names no site and a number in fraudContracts names no contract, so there
// is nothing to repair either to, and the empty list is a legitimate value that
// survives. The result is a fresh array of primitives, so it shares no
// structure with `saved`.
function textList(value) {
if (!Array.isArray(value)) return [];
return value.filter((entry) => typeof entry === "string");
}
// allowedSites / deniedSites: { [address]: [hostname, ...] }.
//
// The container check these had (truthy and not an array) is not the floor:
// `{"0xabc…": "notalist"}` IS a non-array object, and the dereference is one
// level below it. saveState() merges these maps per key and then per hostname
// WITHIN each key, so a stored value that is not a list reaches `base.map()` in
// mergeListByIdentity() (src/shared/state.js) and throws — after the popup has
// rendered, which is why every save from then on failed while the UI looked
// healthy (https://git.eeqj.de/sneak/AutistMask/issues/362). The Settings
// revoke button (`list.filter()`), and the background's
// `allowed.includes(hostname)` gate, dereference it the same way; on that last
// one a stored string would also answer a SUBSTRING match, so a corrupt map
// could widen a site permission rather than merely throw.
//
// An address key whose value is not a list of hostnames is dropped entirely: it
// grants and denies nothing, and dropping it fails closed. A stored own
// "__proto__" key — which JSON can carry — is dropped for the same reason: it
// can never be a wallet address, so it grants nothing either, and keeping it
// only keeps a value that saveState()'s merge would hand to the prototype
// setter on the next write. Keys are written with defineProperty so that no key
// reaching this function can consult a setter at all, whatever the rule above
// it becomes; mergeMapByKey() in src/shared/state.js writes the same way.
function siteMap(value) {
const out = {};
if (!isRecord(value)) return out;
for (const address of Object.keys(value)) {
if (address === "__proto__") continue;
const hostnames = textList(value[address]);
if (hostnames.length === 0) continue;
defineOwn(out, address, hostnames);
}
return out;
}
// An endpoint URL: non-empty text, or the fallback.
function url(value, fallback) {
return typeof value === "string" && value !== "" ? value : fallback;
}
// One remembered endpoint pair out of networkEndpoints, floored on the two
// fields applyChainSwitchFields() (src/shared/chainSwitchFields.js) assigns
// STRAIGHT ONTO s.rpcUrl / s.blockscoutUrl on the next chain switch: flooring
// the live fields alone would leave a non-string sitting one switch away from
// them. A field that is not text is deleted rather than replaced, so the
// switch falls through its own `|| net.defaultRpcUrl`. Anything else the pair
// carries is kept: a profile that has been on a build storing more per-network
// fields must not lose them by passing through this one.
function endpointPair(value) {
const pair = { ...(isRecord(value) ? value : {}) };
for (const field of ["rpcUrl", "blockscoutUrl"]) {
if (typeof pair[field] !== "string" || pair[field] === "") {
delete pair[field];
}
}
return pair;
}
// A list index into wallets / a wallet's addresses: a non-negative integer, or
// null for "nothing selected".
//
// hasValidAddress() (src/popup/viewRouter.js) guards the restore path with
// `state.wallets[state.selectedWallet] && …addresses[state.selectedAddress]`,
// which is safe for a stale INTEGER — out of range is undefined, and the `&&`
// short-circuits — and NOT safe for a string naming an Array.prototype member.
// `wallets["map"]` is truthy, so the guard does not short-circuit and
// `.addresses[…]` throws out of restoreView(): the dead popup. "length",
// "constructor" and "__proto__" answer the same way, and
// src/popup/views/confirmTx.js dereferences selectedWallet behind no guard at
// all.
function listIndex(value) {
return Number.isInteger(value) && value >= 0 ? value : null;
}
// Write `key` as an own data property, never through a setter. Plain
// assignment of "__proto__" replaces the object's prototype and records no
// entry; every map built from stored keys goes through this.
function defineOwn(obj, key, value) {
Object.defineProperty(obj, key, {
value: value,
writable: true,
enumerable: true,
configurable: true,
});
}
// 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;
}
// Turn a raw stored (or missing) record into the full, defaulted shape
// loadState() used to assign directly onto `state`. A pure function so that
// saveState() can apply it too: the fields THIS page did not change still have
// to come from storage in their loaded-and-normalized form, not as the raw
// bytes another page (or an old release) left there — otherwise a legacy shape
// a load has always self-healed in memory (a missing networkEndpoints map, an
// out-of-range flag) is dropped right back into storage unfixed every time the
// page that DID normalize it saves something unrelated, because that field's
// value never "changed" for that page to notice.
//
// The result never shares structure with `saved`, so a caller may mutate it
// freely: it is the detached record every per-call read in the background is
// built on.
function normalizePersisted(saved) {
saved = saved || {};
const out = {};
// Every write goes out at the current version. That IS the migration for
// the unversioned records every install in the field holds: version 1 is
// the shape that shipped unversioned, so a record that validated is
// carried forward simply by being stamped. A record this build does NOT
// understand never reaches here — assertStateUsable() refuses it on the
// read path first (src/shared/stateSchema.js).
out.schemaVersion = STATE_SCHEMA_VERSION;
out.wallets = structuredClone(saved.wallets || []);
// Derived, never trusted verbatim off storage — see loadState().
out.hasWallet = out.wallets.length > 0;
// Each address's token holdings, floored to a list of token records on the
// detached copy above. Every reader iterates it behind a `|| []` that only
// covers an ABSENT value, and then dereferences `t.address.toLowerCase()`
// and `t.balance` — so a stored string iterates as characters, a number
// throws on the iterator, and a null entry throws on the field.
//
// This field specifically, because refreshBalances() writes it WHOLESALE
// rather than merging into it: a write that only partly lands is the live
// cause https://git.eeqj.de/sneak/AutistMask/issues/311 names, and this is
// where it lands. The wallet list itself is the gate's (stateSchema.js);
// what is below an address record is not, and gets floored here.
if (Array.isArray(out.wallets)) {
for (const wallet of out.wallets) {
if (!isRecord(wallet) || !Array.isArray(wallet.addresses)) continue;
for (const addr of wallet.addresses) {
if (!isRecord(addr)) continue;
addr.tokenBalances = tokenRefs(addr.tokenBalances);
}
}
}
// An actual list of token records is required, not merely a truthy value
// and not merely a list: everything downstream iterates this and
// dereferences `token.address`, so a stored string or object walks through
// a `|| []`, and a list of numbers walks through an Array.isArray(), and
// both throw on the first read — the blank popup from the issue, for a
// profile whose wallets are perfectly fine. An empty list is a legitimate
// value and survives.
out.trackedTokens = structuredClone(tokenRefs(saved.trackedTokens));
// The loud refusal for an unknown id is assertStateUsable(); this is the
// floor under it. networkId is an object KEY into networkEndpoints below,
// so a value that is not a network in networks.js must never get that far
// — "__proto__" would set the map's prototype instead of an own key, and
// the user's endpoint would silently not be recorded.
out.networkId = isKnownNetworkId(saved.networkId)
? saved.networkId
: DEFAULT_STATE.networkId;
// Non-empty text or the default, never anything else. getProvider()
// (src/shared/balances.js) hands rpcUrl straight to `new
// JsonRpcProvider()`, which throws SYNCHRONOUSLY for a value that is not a
// string — out of src/popup/views/txStatus.js and src/popup/views/
// addWallet.js, neither of which is inside a try, and the first of which a
// stored `currentView: "wait-tx"` reaches through restoreView(). It is a
// scalar, so the type check is the whole fix.
out.rpcUrl = url(saved.rpcUrl, DEFAULT_STATE.rpcUrl);
out.blockscoutUrl = url(saved.blockscoutUrl, DEFAULT_STATE.blockscoutUrl);
// An actual object is required, not merely a truthy non-array: the code
// below and applyChainSwitchFields() index and ASSIGN INTO this value, and
// assigning a property to a string or a number is a silent no-op in
// sloppy mode. Copied rather than referenced, nested pairs included, so
// normalizing never mutates the object a caller handed in.
const rawEndpoints =
typeof saved.networkEndpoints === "object" &&
saved.networkEndpoints !== null &&
!Array.isArray(saved.networkEndpoints)
? saved.networkEndpoints
: {};
out.networkEndpoints = {};
for (const netId of Object.keys(rawEndpoints)) {
// Keys other than the known network ids are kept rather than dropped,
// so a profile that has been on a build with more networks does not
// lose their endpoints by passing through this one. That is why an own
// "__proto__" key survives here where siteMap() drops it, and why the
// write has to go through defineOwn().
defineOwn(
out.networkEndpoints,
netId,
endpointPair(rawEndpoints[netId]),
);
}
// A profile written before this map existed carries exactly one pair of
// endpoints, belonging to whatever network it was last on. Adopt it as
// that network's remembered pair, so a custom endpoint set on the old
// build is not lost by the first switch away and back.
if (!out.networkEndpoints[out.networkId]) {
out.networkEndpoints[out.networkId] = {
rpcUrl: out.rpcUrl,
blockscoutUrl: out.blockscoutUrl,
};
}
out.lastBalanceRefresh = saved.lastBalanceRefresh || 0;
// A non-empty address, or null, never anything else: this is passed to
// address.slice() and compared against stored addresses, so a stored
// number or object walks through a `|| null` and throws on the first
// render. The empty string is text but it is not an address, and it must
// become null rather than survive: init() auto-selects the first address
// only on a STRICT null, so a stored "" would leave the popup with no
// address ever selected. Nothing in src/ writes one, and this keeps the
// behaviour the `|| null` this check replaced already had.
out.activeAddress =
typeof saved.activeAddress === "string" && saved.activeAddress !== ""
? saved.activeAddress
: null;
out.allowedSites = siteMap(saved.allowedSites);
out.deniedSites = siteMap(saved.deniedSites);
out.rememberSiteChoice =
saved.rememberSiteChoice !== undefined
? saved.rememberSiteChoice
: true;
out.showZeroBalanceTokens =
saved.showZeroBalanceTokens !== undefined
? saved.showZeroBalanceTokens
: true;
// A profile written before this setting existed has no key for it. It
// is a safety filter, so absent must load as on, not as undefined.
out.hideSpoofedSymbols =
saved.hideSpoofedSymbols !== undefined
? saved.hideSpoofedSymbols
: true;
out.hideLowHolderTokens =
saved.hideLowHolderTokens !== undefined
? saved.hideLowHolderTokens
: true;
out.hideFraudContracts =
saved.hideFraudContracts !== undefined
? saved.hideFraudContracts
: true;
out.hideDustTransactions =
saved.hideDustTransactions !== undefined
? saved.hideDustTransactions
: true;
out.dustThresholdGwei =
saved.dustThresholdGwei !== undefined
? saved.dustThresholdGwei
: 100000;
out.utcTimestamps =
saved.utcTimestamps !== undefined ? saved.utcTimestamps : false;
// A list of contract addresses, floored the same way: send.js builds its
// fraud set as `(state.fraudContracts || []).map((a) => a.toLowerCase())`
// and filterTransactions() maps the same list through normalizeAddress(),
// so a stored string walks through the `|| []` and a stored number walks
// through an Array.isArray().
out.fraudContracts = textList(saved.fraudContracts);
out.tokenHolderCache = structuredClone(saved.tokenHolderCache || {});
out.theme = saved.theme || "system";
out.debugMode = saved.debugMode !== undefined ? saved.debugMode : false;
out.currentView = saved.currentView || null;
out.selectedWallet = listIndex(saved.selectedWallet);
out.selectedAddress = listIndex(saved.selectedAddress);
// "ETH", or a contract address, or null — never anything else. The popup
// restores onto "address-token" behind a truthiness check on this field and
// then dereferences it as text (`tokenId.toLowerCase()` in
// src/popup/views/addressToken.js, `state.selectedToken.toLowerCase()` in
// src/popup/views/receive.js), so a stored number is truthy, passes the
// restore gate, and throws on the screen it restores onto. Found by the
// sweep for this same defect class in
// https://git.eeqj.de/sneak/AutistMask/issues/362; floored to null, which
// is what the restore gate already treats as "nothing selected". The empty
// string was already falsy here and stays null.
out.selectedToken =
typeof saved.selectedToken === "string" && saved.selectedToken !== ""
? saved.selectedToken
: null;
out.viewData = structuredClone(saved.viewData || {});
out.viewStack = restorableStack(saved.viewStack, out.currentView);
return out;
}
module.exports = {
DEFAULT_STATE,
PERSISTED_FIELDS,
normalizePersisted,
restorableStack,
};

View File

@@ -78,9 +78,18 @@ function getAddressValue(addr) {
let usd = parseFloat(addr.balance || "0") * prices.ETH;
let partial = false;
for (const token of addr.tokenBalances || []) {
const tokenBal = parseFloat(token.balance || "0");
// A null balance is a holding whose scale nothing knows, so it has no
// quantity to price — but it is still a holding, and a total that
// silently omits it would read as complete. That is exactly what
// `partial` is for (https://git.eeqj.de/sneak/AutistMask/issues/349).
if (token.balance == null) {
partial = true;
continue;
}
const tokenBal = parseFloat(token.balance);
// A balance of zero is not a holding: it can neither add to the total
// nor make it incomplete.
// nor make it incomplete. Anything that is not a number at all is not
// a holding this can price either, and is left to the same rule.
if (!(tokenBal > 0)) continue;
if (prices[token.symbol]) {
usd += tokenBal * prices[token.symbol];

View File

@@ -17,7 +17,13 @@
//
// Kept in its own module, with no dependencies, so tests can assert the
// exclusion directly rather than trusting a reading of the popup entry
// point, which cannot be required outside a browser.
// point.
//
// It sits under src/shared/ rather than src/popup/ because
// src/shared/persistedState.js needs it and that module is in the BACKGROUND
// bundle: a popup-path module reached from the worker is the shape
// script/lib/forbiddenBundleInputs.js exists to keep out, whether or not the
// particular module is harmless.
const RESTORABLE_VIEWS = new Set([
"main",
"address",

View File

@@ -1,46 +1,48 @@
// State management and extension storage persistence.
//
// The `state` export is a module-level singleton: ONE in-memory copy of the
// profile per bundle, loaded once by loadState() and mutated in place from
// then on. That is the popup's model — one page, one load at boot, one
// lifetime.
//
// It is NOT the background's model, and the background must not reach it. The
// MV3 service worker is torn down when idle and revived by the next message,
// nothing loads state at module scope, and an unpopulated read used to hand
// back DEFAULT_STATE with no complaint — five defects came out of that one
// fact (https://git.eeqj.de/sneak/AutistMask/issues/324). Two things close it:
// this module is unreachable from the background bundle, and reading a
// persisted field of the singleton before a load now THROWS instead of quietly
// serving a default.
//
// The unreachability is enforced by the BUILD. build.js fails when esbuild's
// own metafile reports this module as an input of a background bundle — the
// resolution the shipped file was built from, so no specifier syntax gets past
// it — from the table in script/lib/forbiddenBundleInputs.js, which also
// records what that does and does not cover. The ESLint rule that reports the
// same thing in the editor is fast feedback in front of the build, not the
// guarantee.
const { DEFAULT_RPC_URL, DEFAULT_BLOCKSCOUT_URL } = require("./constants");
const { networkById } = require("./networks");
// Dependency-free constant module; safe to pull into a background bundle.
const { RESTORABLE_VIEWS } = require("../popup/restorableViews");
const {
DEFAULT_STATE,
PERSISTED_FIELDS,
normalizePersisted,
} = require("./persistedState");
const {
STATE_SCHEMA_VERSION,
assertStateUsable,
migrationNeeded,
} = require("./stateSchema");
const { storageGet, storageSet } = require("./browserApi");
const { log } = require("./log");
const DEFAULT_STATE = {
hasWallet: false,
wallets: [],
trackedTokens: [],
networkId: "mainnet",
rpcUrl: DEFAULT_RPC_URL,
blockscoutUrl: DEFAULT_BLOCKSCOUT_URL,
// Endpoints remembered per network: { [networkId]: { rpcUrl,
// blockscoutUrl } }. rpcUrl/blockscoutUrl above are the live endpoints
// of the active network; this is what the others are restored from
// when the active network changes. See onChainSwitch().
networkEndpoints: {},
lastBalanceRefresh: 0,
activeAddress: null,
allowedSites: {},
deniedSites: {},
rememberSiteChoice: true,
showZeroBalanceTokens: true,
hideSpoofedSymbols: true,
hideLowHolderTokens: true,
hideFraudContracts: true,
hideDustTransactions: true,
dustThresholdGwei: 100000,
utcTimestamps: false,
fraudContracts: [],
tokenHolderCache: {},
theme: "system",
debugMode: false,
};
const state = {
// The live record the proxy below guards. Everything inside this module reads
// and writes THIS object, never the proxy: the guard is for callers.
const rawState = {
...DEFAULT_STATE,
// Its own object, not the one DEFAULT_STATE holds: onChainSwitch()
// Its own object, not the one DEFAULT_STATE holds: applyChainSwitchFields()
// mutates this map in place, and a spread copies the reference.
networkEndpoints: {},
currentView: null,
@@ -51,161 +53,75 @@ const state = {
viewStack: [],
};
// Keep only the leading run of stored views the popup is willing to render.
// False until loadState() has completed in this bundle. Until then, a
// persisted field that has not been assigned in this context cannot be READ:
// see StateNotLoadedError.
let loaded = false;
// True once this context has assigned anything into the singleton.
//
// 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.
// What the guard is for is a context that READS a profile nobody put there —
// every one of the five defects was a pure read of an untouched singleton,
// answered out of DEFAULT_STATE. A context that has written into it is
// managing it deliberately (the popup does, via loadState() at boot and by
// hand thereafter), and reading back what you yourself put there is not the
// mistake being caught.
//
// 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"];
// The cost of that is honest and worth naming: a context that writes one field
// and then reads a different, untouched one is still served that field's
// default. Nothing closes that here — what closes it for the background is
// that the background cannot reach this module at all, which build.js asserts
// against esbuild's metafile on every build (FORBIDDEN_INPUTS in
// script/lib/forbiddenBundleInputs.js, pinned by
// tests/buildForbiddenInputs.test.js).
let adopted = false;
// Every field whose pre-load value would be a plausible-looking default rather
// than the user's data. The view scratch fields are guarded too: currentView
// and viewStack are persisted, and a save that carried their pre-load values
// would overwrite a real stored stack with an empty one.
const GUARDED_FIELDS = new Set(PERSISTED_FIELDS.concat(["hasWallet"]));
class StateNotLoadedError extends Error {
constructor(field) {
super(
"state." +
field +
" was read before loadState(); this context has no profile" +
" loaded and must not be served DEFAULT_STATE",
);
this.name = "StateNotLoadedError";
}
return kept;
}
// Loud, not defaulted. The whole defect class this guard closes looks exactly
// like working code at the call site: the read succeeds, the value is
// well-formed, and it describes a wallet that is not the user's.
const state = new Proxy(rawState, {
get(target, prop, receiver) {
if (
!loaded &&
!adopted &&
typeof prop === "string" &&
GUARDED_FIELDS.has(prop)
) {
throw new StateNotLoadedError(prop);
}
return Reflect.get(target, prop, receiver);
},
set(target, prop, value, receiver) {
if (typeof prop === "string" && GUARDED_FIELDS.has(prop)) {
adopted = true;
}
return Reflect.set(target, prop, value, receiver);
},
});
// Return the network configuration for the currently selected network.
function currentNetwork() {
return networkById(state.networkId);
}
// Every field written to and read from the single "autistmask" storage key.
// hasWallet is deliberately excluded from the diffing/merge logic below —
// like loadState() does, it is always derived from `wallets`, never carried
// as an independent value.
const PERSISTED_FIELDS = Object.keys(DEFAULT_STATE)
.filter((key) => key !== "hasWallet")
.concat([
"currentView",
"selectedWallet",
"selectedAddress",
"selectedToken",
"viewData",
"viewStack",
]);
// Turn a raw stored (or missing) record into the full, defaulted shape
// loadState() used to assign directly onto `state`. Pulled out as a pure
// function so saveState() can apply it too: the fields THIS page did not
// change still have to come from storage in their loaded-and-normalized
// form, not as the raw bytes another page (or an old release) left there —
// otherwise a legacy shape a load has always self-healed in memory (a
// missing networkEndpoints map, an out-of-range flag) is dropped right back
// into storage unfixed every time the page that DID normalize it saves
// something unrelated, because that field's value never "changed" for that
// page to notice.
function normalizePersisted(saved) {
saved = saved || {};
const out = {};
out.wallets = saved.wallets || [];
// Derived, never trusted verbatim off storage — see loadState().
out.hasWallet = out.wallets.length > 0;
out.trackedTokens = saved.trackedTokens || [];
out.networkId = saved.networkId || DEFAULT_STATE.networkId;
out.rpcUrl = saved.rpcUrl || DEFAULT_STATE.rpcUrl;
out.blockscoutUrl = saved.blockscoutUrl || DEFAULT_STATE.blockscoutUrl;
// An actual object is required, not merely a truthy non-array: the code
// below and onChainSwitch() index and ASSIGN INTO this value, and
// assigning a property to a string or a number is a silent no-op in
// sloppy mode. Copied rather than referenced, nested pairs included, so
// normalizing never mutates the object a caller handed in.
const rawEndpoints =
typeof saved.networkEndpoints === "object" &&
saved.networkEndpoints !== null &&
!Array.isArray(saved.networkEndpoints)
? saved.networkEndpoints
: {};
out.networkEndpoints = {};
for (const netId of Object.keys(rawEndpoints)) {
out.networkEndpoints[netId] = { ...rawEndpoints[netId] };
}
// A profile written before this map existed carries exactly one pair of
// endpoints, belonging to whatever network it was last on. Adopt it as
// that network's remembered pair, so a custom endpoint set on the old
// build is not lost by the first switch away and back.
if (!out.networkEndpoints[out.networkId]) {
out.networkEndpoints[out.networkId] = {
rpcUrl: out.rpcUrl,
blockscoutUrl: out.blockscoutUrl,
};
}
out.lastBalanceRefresh = saved.lastBalanceRefresh || 0;
out.activeAddress = saved.activeAddress || null;
out.allowedSites =
saved.allowedSites && !Array.isArray(saved.allowedSites)
? saved.allowedSites
: {};
out.deniedSites =
saved.deniedSites && !Array.isArray(saved.deniedSites)
? saved.deniedSites
: {};
out.rememberSiteChoice =
saved.rememberSiteChoice !== undefined
? saved.rememberSiteChoice
: true;
out.showZeroBalanceTokens =
saved.showZeroBalanceTokens !== undefined
? saved.showZeroBalanceTokens
: true;
// A profile written before this setting existed has no key for it. It
// is a safety filter, so absent must load as on, not as undefined.
out.hideSpoofedSymbols =
saved.hideSpoofedSymbols !== undefined
? saved.hideSpoofedSymbols
: true;
out.hideLowHolderTokens =
saved.hideLowHolderTokens !== undefined
? saved.hideLowHolderTokens
: true;
out.hideFraudContracts =
saved.hideFraudContracts !== undefined
? saved.hideFraudContracts
: true;
out.hideDustTransactions =
saved.hideDustTransactions !== undefined
? saved.hideDustTransactions
: true;
out.dustThresholdGwei =
saved.dustThresholdGwei !== undefined
? saved.dustThresholdGwei
: 100000;
out.utcTimestamps =
saved.utcTimestamps !== undefined ? saved.utcTimestamps : false;
out.fraudContracts = saved.fraudContracts || [];
out.tokenHolderCache = saved.tokenHolderCache || {};
out.theme = saved.theme || "system";
out.debugMode = saved.debugMode !== undefined ? saved.debugMode : false;
out.currentView = saved.currentView || null;
out.selectedWallet =
saved.selectedWallet !== undefined ? saved.selectedWallet : null;
out.selectedAddress =
saved.selectedAddress !== undefined ? saved.selectedAddress : null;
out.selectedToken = saved.selectedToken || null;
out.viewData = saved.viewData || {};
out.viewStack = restorableStack(saved.viewStack, out.currentView);
return out;
}
// The persisted fields as they stood at the end of this page's last
// loadState() or saveState(). saveState() diffs the live state against this
// to find only the fields THIS page actually changed.
@@ -217,7 +133,7 @@ let baseline = null;
function snapshotPersisted() {
const out = {};
for (const key of PERSISTED_FIELDS) out[key] = state[key];
for (const key of PERSISTED_FIELDS) out[key] = rawState[key];
return out;
}
@@ -374,11 +290,10 @@ function mergeWallet(base, ours, theirs) {
}
// Merge one address's leaf fields (balance, ensName, tokenBalances, ...).
// tokenBalances is itself an array, but only backgroundRefresh() ever
// writes it and always wholesale (refreshBalances() in
// src/shared/balances.js), so there is no membership to reconcile within
// it — it is a leaf like balance or ensName, not a list with its own
// identity.
// tokenBalances is itself an array, but only a balance refresh ever writes
// it and always wholesale (refreshBalances() in src/shared/balances.js), so
// there is no membership to reconcile within it — it is a leaf like balance
// or ensName, not a list with its own identity.
function mergeAddress(base, ours, theirs) {
if (!base) return ours;
const merged = { ...theirs };
@@ -395,12 +310,26 @@ function mergeAddress(base, ours, theirs) {
// a key another page edited. Unlike an array's identity function, an object
// key can't collide with a different logical entry (Object.keys() is
// already deduplicated), so this needs no collision floor of its own.
//
// Every write goes through defineProperty rather than assignment. The keys are
// whatever the stored record carries, and plain assignment of "__proto__" —
// which JSON can carry and normalizePersisted() keeps for networkEndpoints —
// replaces this object's prototype and records no entry. That would undo one
// layer downstream exactly what defineOwn() does in
// src/shared/persistedState.js.
function mergeMapByKey(base, ours, theirs, mergeLeaf) {
base = base || {};
ours = ours || {};
theirs = theirs || {};
const result = {};
const seen = new Set();
const put = (key, value) =>
Object.defineProperty(result, key, {
value: value,
writable: true,
enumerable: true,
configurable: true,
});
for (const key of Object.keys(theirs)) {
seen.add(key);
@@ -408,16 +337,16 @@ function mergeMapByKey(base, ours, theirs, mergeLeaf) {
const inOurs = Object.prototype.hasOwnProperty.call(ours, key);
if (inBase && !inOurs) continue; // this page deleted the whole entry
if (inOurs) {
result[key] = mergeLeaf(base[key], ours[key], theirs[key]);
put(key, mergeLeaf(base[key], ours[key], theirs[key]));
} else {
result[key] = theirs[key];
put(key, theirs[key]);
}
}
for (const key of Object.keys(ours)) {
if (seen.has(key)) continue;
if (!Object.prototype.hasOwnProperty.call(base, key)) {
result[key] = ours[key];
put(key, ours[key]);
}
}
@@ -425,12 +354,12 @@ function mergeMapByKey(base, ours, theirs, mergeLeaf) {
}
// allowedSites/deniedSites: { [address]: [hostname, ...] }. The hostname
// list is itself membership, not a leaf — src/background/index.js pushes a
// newly approved/denied hostname onto it in place, and the Settings "revoke"
// button (src/popup/views/settings.js) filters a hostname out of it in
// place, from a different page. Merge it the same way wallets are merged:
// identity is the hostname itself, so a merged pair is always equal and
// mergeItem is a no-op pick.
// list is itself membership, not a leaf — the background appends a newly
// approved/denied hostname to it, and the Settings "revoke" button
// (src/popup/views/settings.js) filters a hostname out of it in place, from a
// different page. Merge it the same way wallets are merged: identity is the
// hostname itself, so a merged pair is always equal and mergeItem is a no-op
// pick.
function mergeHostnameList(base, ours, theirs) {
return mergeListByIdentity(
base,
@@ -445,14 +374,15 @@ function mergeSiteMap(base, ours, theirs) {
return mergeMapByKey(base, ours, theirs, mergeHostnameList);
}
// networkEndpoints: { [networkId]: {rpcUrl, blockscoutUrl} }. onChainSwitch()
// (src/shared/chainSwitch.js) writes state.networkEndpoints[networkId] in
// place before saving. No code path ever removes a key from this map, so the
// membership collision that matters for allowedSites/wallets (an add on one
// page racing a delete on another) can't happen here — but two pages
// switching to two different networks concurrently still race a whole-field
// diff the same way, so it gets the same per-key merge for the leaf edit
// case (e.g. Settings saving a custom RPC URL for the active network).
// networkEndpoints: { [networkId]: {rpcUrl, blockscoutUrl} }.
// applyChainSwitchFields() (src/shared/chainSwitchFields.js) writes
// networkEndpoints[networkId] in place before saving. No code path ever
// removes a key from this map, so the membership collision that matters for
// allowedSites/wallets (an add on one page racing a delete on another) can't
// happen here — but two pages switching to two different networks
// concurrently still race a whole-field diff the same way, so it gets the same
// per-key merge for the leaf edit case (e.g. Settings saving a custom RPC URL
// for the active network).
function mergeEndpointEntry(base, ours, theirs) {
if (!base) return ours;
const merged = { ...theirs };
@@ -468,12 +398,12 @@ function mergeNetworkEndpoints(base, ours, theirs) {
// Read-modify-write, merged per field, rather than one full-blob write.
//
// Every extension page (the toolbar popup, a dApp approval window, the
// background's backgroundRefresh()) holds its own in-memory `state`, loaded
// once, and showView() saves on every navigation. A full-blob write here
// clobbers whatever a second page had written since — including, in the
// worst case, an entire wallet and its encrypted secret with no attacker
// and no unusual input (see the issue this fixes).
// Every extension page (the toolbar popup, a dApp approval window) holds its
// own in-memory `state`, loaded once, and showView() saves on every
// navigation. A full-blob write here clobbers whatever a second page had
// written since — including, in the worst case, an entire wallet and its
// encrypted secret with no attacker and no unusual input (see the issue this
// fixes).
//
// Only the fields this page actually changed — those that differ from
// `baseline`, captured at the last loadState()/saveState() on this page —
@@ -482,28 +412,27 @@ function mergeNetworkEndpoints(base, ours, theirs) {
//
// `wallets` is merged structurally (mergeListByIdentity(), by wallet
// identity and then by address identity within each wallet), not as one
// whole field: backgroundRefresh() mutates wallets IN PLACE (addr.balance /
// whole field: a balance refresh mutates wallets IN PLACE (addr.balance /
// ensName / tokenBalances, via refreshBalances()), so a whole-field diff
// would mark all of `wallets` "changed" the moment any balance moved and
// write back background's own copy — loaded before its multi-second network
// write back that page's own copy — loaded before its multi-second network
// round trip — clobbering a wallet another page added, or resurrecting one
// another page deleted, in that window. Merging by identity lets
// background's leaf changes and another page's membership changes
// (add/delete a wallet or an address) apply independently instead of
// colliding as the same field.
// another page deleted, in that window. Merging by identity lets the leaf
// changes and another page's membership changes (add/delete a wallet or an
// address) apply independently instead of colliding as the same field.
//
// `allowedSites` and `deniedSites` get the same treatment (mergeSiteMap(),
// by address key and then by hostname within each address's list), for the
// identical reason: src/background/index.js pushes a newly
// approved/denied hostname onto them in place, and the Settings "revoke"
// button (src/popup/views/settings.js) filters one out in place, from a
// different page. A whole-field diff here doesn't just lose data, it is a
// security defect — a stale page's save can resurrect a just-revoked site
// permission, or silently wipe a permission just granted elsewhere.
// identical reason: the background appends a newly approved/denied hostname
// to them, and the Settings "revoke" button (src/popup/views/settings.js)
// filters one out in place, from a different page. A whole-field diff here
// doesn't just lose data, it is a security defect — a stale page's save can
// resurrect a just-revoked site permission, or silently wipe a permission just
// granted elsewhere.
//
// `networkEndpoints` gets the same treatment too (mergeNetworkEndpoints(),
// by network id), since onChainSwitch() writes into it in place; the value
// per key is a small leaf object with no membership of its own; see the
// by network id), since applyChainSwitchFields() writes into it in place; the
// value per key is a small leaf object with no membership of its own; see the
// comment at mergeEndpointEntry() for why the collision this closes is
// milder than the other two.
//
@@ -511,8 +440,8 @@ function mergeNetworkEndpoints(base, ours, theirs) {
// `trackedTokens`/`fraudContracts`/`viewStack` are arrays of scalars with no
// per-element identity to merge by; `tokenHolderCache` is a map shaped like
// the ones above, but nothing in src/ ever writes an entry into it — it is
// only ever reset wholesale to `{}` (onChainSwitch()) — so there is no
// in-place mutation for a whole-field diff to collide with; `viewData` is
// only ever reset wholesale to `{}` (applyChainSwitchFields()) — so there is
// no in-place mutation for a whole-field diff to collide with; `viewData` is
// this page's own UI scratch space, not data another page has any reason to
// share membership of.
//
@@ -537,9 +466,17 @@ function mergeNetworkEndpoints(base, ours, theirs) {
async function saveStateOnce() {
const current = snapshotPersisted();
const result = await storageGet("autistmask");
// The record in storage right now is about to be merged into and written
// back, so it is validated exactly like a load validates it. Without this,
// a page whose own load succeeded would normalize a record it does not
// understand — one a NEWER build wrote in the meantime, say — and write
// the result back over it, destroying the only copy of whatever that
// record held. Refusing is louder than that and loses nothing: the live
// state is untouched and the next save retries.
assertStateUsable(result.autistmask);
// Normalized, not raw: a field this page did not change still has to
// come from storage in its loaded (self-healed) shape. See
// normalizePersisted() above.
// normalizePersisted() in persistedState.js.
const fresh = normalizePersisted(result.autistmask);
const merged = { ...fresh };
@@ -572,13 +509,17 @@ async function saveStateOnce() {
}
}
merged.hasWallet = Boolean(merged.wallets && merged.wallets.length > 0);
// Stamped on every write, never merged or diffed: the record that goes to
// storage is in THIS build's shape whatever shape it was read in, which is
// what migrates the unversioned records every install in the field holds.
merged.schemaVersion = STATE_SCHEMA_VERSION;
await storageSet({ autistmask: merged });
// Derived from this page's own wallets, never adopted off the wire —
// see loadState(). Everything else this page did not change is left
// exactly as it stood; see the note above.
state.hasWallet = state.wallets.length > 0;
rawState.hasWallet = rawState.wallets.length > 0;
baseline = structuredClone(snapshotPersisted());
}
@@ -595,25 +536,88 @@ async function saveStateOnce() {
// begins, so each one only ever sees the true live state at its turn.
let saveQueue = Promise.resolve();
// Where a failed save is REPORTED, set once by the context that has a screen
// to say it on (src/popup/index.js).
//
// A save that fails must not fail silently. showView() fires saveState() on
// every navigation without awaiting it, and the queue below has to attach a
// rejection handler to keep advancing — so a failing save was swallowed
// entirely: no throw, no message, nothing on screen. The wallet kept running
// against storage that was rejecting every write, which is the data-loss half
// of https://git.eeqj.de/sneak/AutistMask/issues/362. The awaited callers were
// no better off: `await saveState()` inside an unguarded event handler surfaces
// in the console and nowhere the user looks.
//
// This is the "tell the user" half; the other half is the floor in
// normalizePersisted(), which stops the malformed-record cause from arising in
// the first place. Both, because a floor only covers the causes it knows about
// and storage can still fail for reasons of its own (quota, a revoked
// permission, a record a newer build wrote).
let saveFailureHandler = null;
function onSaveFailure(fn) {
saveFailureHandler = fn;
}
function reportSaveFailure(err) {
log.errorf("state: saving failed, changes were NOT persisted:", err);
if (!saveFailureHandler) return;
try {
saveFailureHandler(err);
} catch (e) {
// The reporter is the last thing standing between a failed save and
// silence; a reporter that throws must not become an unhandled
// rejection of its own on top of it.
log.errorf("state: the save-failure reporter itself failed:", e);
}
}
function saveState() {
const turn = saveQueue.then(saveStateOnce);
// The queue must advance even when a save rejects, or every save after
// it queues behind a promise that never settles.
saveQueue = turn.catch(() => {});
// Every failed save is reported, whether or not the caller awaited this
// one. The returned promise still rejects, so a caller that DOES await
// keeps its own error handling.
turn.catch(reportSaveFailure);
return turn;
}
// Rejects with StateUnusableError for a stored record this build cannot make
// sense of. Nothing is assigned and `loaded` stays false in that case, so a
// caller that ignores the rejection gets StateNotLoadedError on the first
// read rather than a half-populated profile. The caller that does NOT ignore
// it is the popup entry point, which shows the recovery screen
// (src/popup/views/stateRecovery.js) instead of proceeding.
async function loadState() {
const result = await storageGet("autistmask");
if (result.autistmask) {
Object.assign(state, normalizePersisted(result.autistmask));
// Before normalization, on the raw bytes: normalizing first would paper
// over the very shapes this refuses, which is how a corrupt record used to
// reach the popup and blank it (issue #311).
assertStateUsable(result.autistmask);
if (migrationNeeded(result.autistmask)) {
log.infof(
"state: migrating an unversioned profile to schema version",
STATE_SCHEMA_VERSION,
);
}
// The point of comparison every saveState() on this page diffs against,
// whether storage had a profile or was empty. See PERSISTED_FIELDS above
// saveState() for why a reference here would be wrong.
if (result.autistmask) {
Object.assign(rawState, normalizePersisted(result.autistmask));
}
// Whether storage had a profile or was empty, this context has now read
// it, and the defaults standing in for an empty profile are the right
// answer rather than a stand-in for one nobody looked for.
loaded = true;
// The point of comparison every saveState() on this page diffs against.
// See PERSISTED_FIELDS in persistedState.js for why a reference here
// would be wrong.
baseline = structuredClone(snapshotPersisted());
}
// Through the guarded proxy, not rawState: a caller asking which address is
// selected before anything was loaded gets the same loud failure it would get
// reading the fields itself.
function currentAddress() {
if (state.selectedWallet === null || state.selectedAddress === null) {
return null;
@@ -624,7 +628,9 @@ function currentAddress() {
module.exports = {
state,
saveState,
onSaveFailure,
loadState,
currentAddress,
currentNetwork,
StateNotLoadedError,
};

278
src/shared/stateSchema.js Normal file
View File

@@ -0,0 +1,278 @@
// The version stamped on the stored profile, and the shape check every read
// of one goes through.
//
// Storage is the one input to this extension that nobody validated. A profile
// carried no version at all, so there was no way to tell a record this build
// understands from one a later build wrote, and loadState() coerced scalars
// while trusting the structure — so a `wallets` that was a string, or an array
// of nulls, or a later schema's wallet records, reached the popup and threw on
// the first dereference. The popup rendered NOTHING: no view, no message, no
// control, and no way out from inside the product
// (https://git.eeqj.de/sneak/AutistMask/issues/311).
//
// Two separate jobs, deliberately not merged:
//
// stateProblem() / assertStateUsable() refuse a record this build cannot
// safely reason about, loudly, naming the problem in a
// sentence that goes on screen. This is the gate.
// normalizePersisted() (persistedState.js) self-heal a record that IS
// usable: absent fields, legacy shapes, out-of-range flags.
//
// The gate runs FIRST, on the raw stored bytes, before normalization has a
// chance to paper over a record whose meaning nobody can vouch for. A blob
// that fails it is left in storage untouched — it is the user's only copy of
// whatever it holds, and the recovery screen exports it before offering to
// erase it.
//
// What is checked HERE is what nothing downstream can floor: the wallet list,
// the version, and the network id that keys an object. Every other field is
// normalizePersisted()'s to make safe, and what that function does is NOT
// uniform across the record.
//
// WHICH FLOOR A GIVEN FIELD HAS IS NOT WRITTEN HERE. It is
// tests/persistedFieldContract.test.js: one row per persisted field, naming
// the property that field's floor is claimed to have, and PROVING it by
// driving the real code with hostile values — the gate for a field the gate
// refuses, normalizePersisted() for a field it floors, and, for a field whose
// only defence is that nothing dereferences it structurally, a boot of the
// real popup entry point onto EVERY view the popup can reopen onto.
//
// That last part is the whole point, because this defect class lives on the
// RESTORE path and not on Home. Take the claim NARROWLY, exactly as that file
// states it: what those boots prove is no structural dereference on the code
// paths a WHOLLY-CORRUPTED PROFILE takes — which is not every path a stored
// record takes. Not driven: any pairing of values the four slots do not
// produce, a view only forward navigation opens, anything behind a click, and
// everything a healthy profile reaches. Within that boundary the verdict is
// unconditional, including a dereference that takes two corrupted fields at
// once. That suite also goes red on a field that gains a floor while its row
// still claims it has none, and on a field added to PERSISTED_FIELDS with no
// row at all.
//
// That test exists because this comment did not work. It carried a
// hand-written justification per field, and it shipped a false one in three
// consecutive changes — a different field each time, each caught only by a
// reviewer re-deriving thirty fields by hand. A claim nobody can execute is
// worse than no claim, because it is believed.
//
// The trap is worth stating here, since it is what all three got wrong: a
// check on a CONTAINER is not a check on its ENTRIES, and the dereference is
// one level below the container. `[1, 2]` is a list, `{"0x…": "notalist"}` is
// a record, and `{"currentView":"success-tx","viewData":{"hash":"0x1"}}`
// passes the restore gate and throws on the address the renderer below it
// reads. A field added to the record needs a decision about its entries as
// well as its shape — and then a row in that test.
const { isKnownNetworkId } = require("./networks");
// Bump this when the MEANING of a stored field changes, and add the migration
// that carries the older version forward. Adding a field with a defaulted
// absent value is not a bump: normalizePersisted() already handles that, and
// bumping for it would send every older install to the recovery screen for no
// reason.
//
// Version 1 is the shape that shipped unversioned. An unversioned record is
// therefore version 1, not a defect — see migrationNeeded() below.
const STATE_SCHEMA_VERSION = 1;
// Thrown by every read path that finds a record it cannot use. `problem` is
// the sentence shown to the user; `message` carries the same text so a log
// line or a rethrow is not empty.
class StateUnusableError extends Error {
constructor(problem) {
super(problem);
this.name = "StateUnusableError";
this.problem = problem;
}
}
function isPlainObject(value) {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
// Own properties only, everywhere in this file. `saved` comes from storage as
// parsed JSON, so `saved.constructor` and `saved.__proto__` answer from the
// prototype chain for a record that carries neither — a check written as a
// plain truthiness test can be satisfied by Object.prototype rather than by
// anything the user's profile actually contains.
function has(obj, key) {
return Object.prototype.hasOwnProperty.call(obj, key);
}
function ordinal(index) {
return String(index + 1);
}
function describeType(value) {
if (value === null) return "null";
if (Array.isArray(value)) return "a list";
return "a " + typeof value;
}
// One address record, as every screen dereferences it.
function addressProblem(addr, walletIndex, addrIndex) {
const where =
"address " +
ordinal(addrIndex) +
" of wallet " +
ordinal(walletIndex) +
" in the saved data";
if (!isPlainObject(addr)) {
return "The " + where + " is " + describeType(addr) + ", not a record.";
}
if (typeof addr.address !== "string" || addr.address === "") {
return "The " + where + " has no address.";
}
return null;
}
function walletProblem(wallet, index) {
const where = "Wallet " + ordinal(index) + " in the saved data";
if (!isPlainObject(wallet)) {
return where + " is " + describeType(wallet) + ", not a wallet record.";
}
if (!Array.isArray(wallet.addresses)) {
return where + " has no list of addresses.";
}
if (has(wallet, "name") && typeof wallet.name !== "string") {
return where + " has a name that is not text.";
}
for (let i = 0; i < wallet.addresses.length; i++) {
const problem = addressProblem(wallet.addresses[i], index, i);
if (problem) return problem;
}
return null;
}
function versionProblem(saved) {
// No version field at all is the shape every install in the field has:
// no build ever wrote one. It is version 1, and it is migrated in place.
if (!has(saved, "schemaVersion")) return null;
const version = saved.schemaVersion;
if (
typeof version !== "number" ||
!Number.isInteger(version) ||
version < 1
) {
return (
"The saved data carries a schema version AutistMask does not" +
" recognize (" +
JSON.stringify(version) +
")."
);
}
if (version > STATE_SCHEMA_VERSION) {
return (
"The saved data was written by a newer version of AutistMask" +
" (schema version " +
version +
"; this build understands version " +
STATE_SCHEMA_VERSION +
")."
);
}
return null;
}
/**
* The reason this build cannot use `saved`, as a sentence for the user, or
* null when it can.
*
* @param {*} saved the raw record from storage, or undefined for a fresh
* install.
* @returns {string|null}
*/
function stateProblem(saved) {
// Nothing stored is a first run, not a defect.
if (saved === undefined || saved === null) return null;
if (!isPlainObject(saved)) {
return (
"The saved data is " +
describeType(saved) +
", not the record AutistMask stores."
);
}
const version = versionProblem(saved);
if (version) return version;
// Read once, from an OWN property or not at all, so that a polluted
// prototype cannot decide whether a profile is refused. Note that
// normalizePersisted() reads the same field plainly, and so WOULD consult
// the prototype chain: the two halves agree only because a record arriving
// from storage has been through structuredClone and always carries
// Object.prototype. Nothing reachable from storage can put them at odds,
// but a caller that hands either one a hand-built object with an unusual
// prototype is not covered by that.
const wallets =
has(saved, "wallets") && saved.wallets !== undefined
? saved.wallets
: [];
if (!Array.isArray(wallets)) {
return (
"The list of wallets in the saved data is " +
describeType(wallets) +
", not a list."
);
}
for (let i = 0; i < wallets.length; i++) {
const problem = walletProblem(wallets[i], i);
if (problem) return problem;
}
// networkId is not merely displayed: it is an object KEY into
// state.networkEndpoints. A corrupt "__proto__" would set that map's
// prototype instead of an own key, so the user's endpoint would silently
// not be recorded and a switch away and back would return the public
// default. isKnownNetworkId() is an own-property test against the network
// table for exactly that reason.
if (
has(saved, "networkId") &&
saved.networkId !== undefined &&
!isKnownNetworkId(saved.networkId)
) {
return (
"The saved data selects a network AutistMask does not know (" +
JSON.stringify(saved.networkId) +
")."
);
}
return null;
}
/**
* Refuse a record this build cannot use.
*
* @param {*} saved the raw record from storage.
* @throws {StateUnusableError}
*/
function assertStateUsable(saved) {
const problem = stateProblem(saved);
if (problem) throw new StateUnusableError(problem);
}
/**
* Whether `saved` is a usable record written before versions existed, and so
* gets the current version stamped on it the next time anything writes. Purely
* informational — the migration itself is that stamp, since version 1 IS the
* unversioned shape.
*
* @param {*} saved
* @returns {boolean}
*/
function migrationNeeded(saved) {
return (
isPlainObject(saved) &&
!has(saved, "schemaVersion") &&
stateProblem(saved) === null
);
}
module.exports = {
STATE_SCHEMA_VERSION,
StateUnusableError,
assertStateUsable,
migrationNeeded,
stateProblem,
};

View File

@@ -11,6 +11,10 @@ const { log, debugFetch } = require("./log");
const { TOKEN_BY_ADDRESS } = require("./tokenList");
const { parseHoldersCount, isLowHolderCount } = require("./holders");
const { isSpoofedSymbol } = require("./symbolSpoof");
// The uint8 test every scale in this wallet goes through. Shared, not copied:
// a scale is either reported or it is unknown, and "unknown" must mean the
// same thing here as it does on the screens that refuse to format one.
const { toDecimals } = require("./transferAmount");
// The plain 4-decimal rule. The history and balance lists deliberately keep
// truncation without the approval screens' nonzero floor: the transaction
// detail view is the authoritative record and already shows exact precision.
@@ -92,21 +96,37 @@ function parseTx(tx, addrLower) {
function parseTokenTransfer(tt, addrLower) {
const from = tt.from?.hash || "";
const to = tt.to?.hash || "";
const decimals = parseInt(tt.total?.decimals || "18", 10);
// The explorer's own answer, or null. Never a default: a transfer of
// 5000000000 units formatted at a guessed 18 reads as 0.000000005, and
// nothing downstream can tell that from a real 18-decimal transfer of
// that size. `parseInt(x || "18", 10)` also collapsed a genuine scale of
// ZERO into 18 (https://git.eeqj.de/sneak/AutistMask/issues/246).
const decimals = toDecimals(tt.total?.decimals);
const rawVal = tt.total?.value || "0";
const direction =
normalizeAddress(from) === addrLower ? "sent" : "received";
const sym = tt.token?.symbol || "?";
// Without a scale there is no token quantity, so none is stated: the list
// row falls back to the symbol alone and the detail screen to its
// direction label, exactly as the contract-call rows above already do.
// The exact figure is not lost — it is the base-unit line below, which is
// the one number that needs no scale to be true.
const formatted =
decimals === null ? "" : formatTxValue(formatUnits(rawVal, decimals));
const exact = decimals === null ? "" : formatUnits(rawVal, decimals);
return {
hash: tt.transaction_hash,
blockNumber: tt.block_number,
timestamp: Math.floor(new Date(tt.timestamp).getTime() / 1000),
from: from,
to: to,
value: formatTxValue(formatUnits(rawVal, decimals)),
exactValue: formatUnits(rawVal, decimals),
value: formatted,
exactValue: exact,
rawAmount: rawVal,
rawUnit: sym + " base units (10^-" + decimals + ")",
rawUnit:
decimals === null
? sym + " base units (decimals unknown)"
: sym + " base units (10^-" + decimals + ")",
valueGwei: null,
symbol: sym,
direction: direction,

View File

@@ -52,7 +52,7 @@ function mismatchMessage(displayed, onChain) {
);
}
// A decimals value from either source as a number, or null if it is not one.
// A decimals value from any source as a number, or null if it is not one.
// decimals() comes back from ethers as a bigint and the explorer's copy arrives
// as a string, so both of those are accepted alongside a plain number; anything
// fractional, negative, out of uint8 range, or of any other type at all is not.
@@ -60,7 +60,16 @@ function mismatchMessage(displayed, onChain) {
// The types are enumerated rather than coerced because Number() is far too
// willing: Number([]) is 0 and Number(true) is 1, so a coercing check would
// admit an empty array as a scale of zero and encode a whole-token transfer
// against it.
// against it. Absence answers null and never a default, and a real scale of
// ZERO answers 0 — the two are different answers, which is the whole point:
// a falsy-collapsing `value || 18` cannot tell them apart, and neither can a
// reader of what it wrote (https://git.eeqj.de/sneak/AutistMask/issues/246).
//
// Exported because every module that has to decide whether it knows a token's
// scale needs exactly this test, and three separate copies of it is three
// places for the answer to drift: approvalAmount.js resolves the scale the
// approval screens display at, and balances.js decides what the explorer
// actually reported before it is stored.
function toDecimals(value) {
let n;
if (typeof value === "number") {
@@ -110,6 +119,7 @@ module.exports = {
displayedDecimals,
transferAmountUnits,
mismatchMessage,
toDecimals,
MAX_DECIMALS,
UNKNOWN_DISPLAYED_DECIMALS_MESSAGE,
UNREADABLE_CONTRACT_DECIMALS_MESSAGE,

View File

@@ -4,6 +4,10 @@
const { Interface, AbiCoder, getBytes, formatUnits } = require("ethers");
const { TOKEN_BY_ADDRESS } = require("./tokenList");
const { truncateAmountNeverZero } = require("./amountDisplay");
const {
resolveTokenDecimals,
unknownDecimalsAmount,
} = require("./approvalAmount");
const coder = AbiCoder.defaultAbiCoder();
@@ -44,13 +48,103 @@ function formatAmount(raw, decimals) {
return truncateAmountNeverZero(formatUnits(raw, decimals));
}
function tokenInfo(address) {
if (!address || address === "0x0000000000000000000000000000000000000000") {
// One wording for either side of the screen: a currency the calldata never
// named. It reads as a refusal, the same stance unknownDecimalsAmount() takes
// on a scale — not as a token name, and not as a quantity.
const UNNAMED_CURRENCY = "Unknown (not named in the calldata)";
// Explicit presence, never truthiness. Every gate in this file that guards a
// decoded value goes through here: an address is never falsy once set, but an
// amount of 0n is, and a gate that cannot tell a genuine zero from an absent
// value is the trap this decoder has now been bitten by five times.
function present(value) {
return value !== null && value !== undefined;
}
// Uniswap V4 spells "use the whole open delta" as an amount of zero:
// v4-periphery `src/libraries/ActionConstants.sol` declares
// `uint128 internal constant OPEN_DELTA = 0` ("used to signal that an action
// should use the input value of the open delta on the pool manager or of the
// balance that the contract holds"), and `src/V4Router.sol` substitutes the
// full open credit whenever an exact-in swap action's `amountIn` equals it:
//
// uint128 amountIn = params.amountIn;
// if (amountIn == ActionConstants.OPEN_DELTA) {
// amountIn = _getFullCredit(...).toUint128();
// }
//
// in both `_swapExactInputSingle` and `_swapExactInput`. Sentinel and literal
// zero are the same uint128 word, so the encoding CANNOT distinguish them —
// and the router does not try: it reads every zero as the sentinel, so in V4
// there is no such thing as an exact-in swap of literally zero. The amount is
// therefore not stated by the calldata at all; it is whatever credit is open
// at execution time. It is carried as this sentinel rather than as 0n because
// printing "0.0000" would state the exact inverse of what will happen —
// "nothing is being swapped" for a step that swaps the entire balance.
//
// `amountOutMinimum` gets no such mapping: V4Router compares it directly
// (`if (amountOut < params.amountOutMinimum) revert V4TooLittleReceived`), so
// a zero minimum is a literal zero slippage floor and is stated as one. Nor do
// the V2/V3 paths have it — universal-router's `V3SwapRouter.v3SwapExactInput`
// special-cases only `ActionConstants.CONTRACT_BALANCE` (1<<255), never zero —
// so a zero `amountIn` there is a literal zero and is displayed as one.
const OPEN_DELTA = Symbol("v4-open-delta");
// The two amount lines that state a fact instead of a quantity. Same register
// as UNNAMED_CURRENCY — a sentence in the value slot, so it cannot be misread
// as a number — and deliberately not a third phrasing of "not named": these
// say different things.
const OPEN_DELTA_AMOUNT = "All available (V4 open delta)";
const NO_MINIMUM = "None (no minimum guaranteed)";
// Permit2 amounts are uint160; the maximum is Permit2's "unbounded".
const MAX_UINT160 = BigInt("0xffffffffffffffffffffffffffffffffffffffff");
// `decimals` is null when nothing knows this token's scale. It is not
// defaulted to 18: the swap lines land on the same approval screen as the
// ERC-20 line, and a scale guessed there is what showed a 1,000 USDT swap as
// 0.000000000001. `sources` is { trackedTokens, wallets }, shaped as they are
// on `state`; resolveTokenDecimals() reads the bundled list, then those.
//
// A null `address` means UNDETERMINED — the calldata named no currency for
// that side — and is refused rather than named. It is not native ETH: Uniswap
// V4 spells native ETH as `Currency.wrap(address(0))` (v4-core
// `type Currency is address`), and a Currency is ABI-encoded as a plain
// address word, so every decode site here gets back the truthy string
// "0x0000000000000000000000000000000000000000" for it — never null. WRAP_ETH
// sets that same explicit zero address, and an UNWRAP_WETH output is caught by
// its caller before this is consulted, so nothing that genuinely is ETH
// arrives null. Naming a null ETH states the wrong asset and formats its
// amount at the wrong scale.
function tokenInfo(address, sources) {
if (!address) {
return { symbol: null, decimals: null, address: null };
}
if (address === "0x0000000000000000000000000000000000000000") {
return { symbol: "ETH", decimals: 18, address: null };
}
const t = TOKEN_BY_ADDRESS.get(address.toLowerCase());
if (t) return { symbol: t.symbol, decimals: t.decimals, address };
return { symbol: null, decimals: 18, address };
return {
symbol: t ? t.symbol : null,
decimals: resolveTokenDecimals(address, sources),
address,
};
}
// A swap amount line. With a scale it is the token quantity; with none it is
// the base-unit integer with the unknown scale stated, the same refusal the
// ERC-20 amount line makes, so the screen has one way of saying it. `display`
// is the line on the screen, `raw` is what the status screens carry.
function amountText(raw, info) {
if (info.decimals === null) {
const unknown = unknownDecimalsAmount(raw);
return { raw: unknown, display: unknown };
}
const formatted = formatAmount(raw, info.decimals);
return {
raw: formatted,
display: formatted + (info.symbol ? " " + info.symbol : ""),
};
}
// Decode PERMIT2_PERMIT (command 0x0a) input bytes.
@@ -177,6 +271,14 @@ const V4_SWAP_EXACT_OUT = 0x09;
const V4_SETTLE = 0x0b;
const V4_TAKE = 0x0e;
// A V4 exact-in `amountIn`, read the way V4Router reads it: zero is
// ActionConstants.OPEN_DELTA, not a quantity of zero. See the OPEN_DELTA
// comment above. The exact-OUT actions below decode no amounts at all, so
// their own OPEN_DELTA mapping on `amountOut` never reaches the screen.
function v4ExactInAmount(raw) {
return raw === 0n ? OPEN_DELTA : raw;
}
// Decode V4_SWAP (command 0x10) input bytes.
// The input is ABI-encoded as (bytes actions, bytes[] params).
// We extract token addresses from SETTLE (input) and TAKE (output) sub-actions,
@@ -225,13 +327,17 @@ function decodeV4Swap(input) {
],
params[i],
);
if (!settleToken) settleToken = s[0][0];
if (!present(settleToken)) settleToken = s[0][0];
const path = s[0][1];
if (path.length > 0 && !takeToken) {
if (path.length > 0 && !present(takeToken)) {
takeToken = path[path.length - 1][0];
}
if (!amountIn) amountIn = s[0][2];
if (!amountOutMin) amountOutMin = s[0][3];
if (!present(amountIn)) {
amountIn = v4ExactInAmount(s[0][2]);
}
if (!present(amountOutMin)) {
amountOutMin = s[0][3];
}
} catch {
// Fall through — SETTLE/TAKE will provide tokens
}
@@ -247,16 +353,20 @@ function decodeV4Swap(input) {
);
const poolKey = s[0][0];
const zeroForOne = s[0][1];
if (!settleToken)
if (!present(settleToken))
settleToken = zeroForOne
? poolKey[0]
: poolKey[1];
if (!takeToken)
if (!present(takeToken))
takeToken = zeroForOne
? poolKey[1]
: poolKey[0];
if (!amountIn) amountIn = s[0][2];
if (!amountOutMin) amountOutMin = s[0][3];
if (!present(amountIn)) {
amountIn = v4ExactInAmount(s[0][2]);
}
if (!present(amountOutMin)) {
amountOutMin = s[0][3];
}
} catch {
// Fall through
}
@@ -273,9 +383,9 @@ function decodeV4Swap(input) {
],
params[i],
);
if (!takeToken) takeToken = s[0][0];
if (!present(takeToken)) takeToken = s[0][0];
const path = s[0][1];
if (path.length > 0 && !settleToken) {
if (path.length > 0 && !present(settleToken)) {
settleToken = path[path.length - 1][0];
}
} catch {
@@ -291,11 +401,11 @@ function decodeV4Swap(input) {
);
const poolKey = s[0][0];
const zeroForOne = s[0][1];
if (!settleToken)
if (!present(settleToken))
settleToken = zeroForOne
? poolKey[0]
: poolKey[1];
if (!takeToken)
if (!present(takeToken))
takeToken = zeroForOne
? poolKey[1]
: poolKey[0];
@@ -323,7 +433,7 @@ function decodeV4Swap(input) {
// Try to decode a Universal Router execute() call.
// Returns { name, description, details } matching the format used by
// the approval UI, or null if the calldata is not a recognised execute().
function decode(data, toAddress) {
function decode(data, toAddress, sources) {
try {
const parsed = ROUTER_IFACE.parseTransaction({ data });
if (!parsed) return null;
@@ -334,11 +444,44 @@ function decode(data, toAddress) {
let inputToken = null;
let inputAmount = null;
let inputEstablished = false;
let outputToken = null;
let minOutput = null;
let hasUnwrapWeth = false;
const commandNames = [];
// THE INVARIANT: an amount and the token it is counted in always come
// from the same hop. A figure is never rendered against a token that
// did not supply it.
//
// Both sides are therefore set as a PAIR — never field by field, and
// never on truthiness. An address is never falsy once set but an
// amount of 0n is, so gating the two halves independently let a hop
// with a zero amount fix the token and leave the amount open; the next
// hop's figure was then displayed against the first hop's token, at the
// first hop's scale. A V3 USDT->WETH hop with amountIn 0 followed by a
// V2 WETH->USDC hop of 0.5e18 rendered "500000000000.0000 USDT".
//
// The input side is fixed by the first hop that states either half, the
// output side by the last, because the final leg is what the user
// receives. A half the establishing hop did not state stays null and
// the line says so, rather than being filled in from a different hop.
const setInput = (token, amount) => {
inputToken = present(token) ? token : null;
inputAmount = present(amount) ? amount : null;
inputEstablished = true;
};
const setInputOnce = (token, amount) => {
if (inputEstablished) return;
if (!present(token) && !present(amount)) return;
setInput(token, amount);
};
const setOutput = (token, amount) => {
if (!present(token) && !present(amount)) return;
outputToken = present(token) ? token : null;
minOutput = present(amount) ? amount : null;
};
for (let i = 0; i < commandsBytes.length; i++) {
const cmdId = commandsBytes[i] & 0x1f;
commandNames.push(
@@ -349,61 +492,61 @@ function decode(data, toAddress) {
try {
if (cmdId === 0x0a) {
const p = decodePermit2(inputs[i]);
if (p) {
inputToken = p.token;
inputAmount = p.amount;
}
// A permit states both halves itself, so it may replace an
// input side an earlier hop established without breaking
// the invariant.
if (p) setInput(p.token, p.amount);
}
if (cmdId === 0x0e) {
const b = decodeBalanceCheck(inputs[i]);
if (b) {
outputToken = b.token;
minOutput = b.minBalance;
}
if (b) setOutput(b.token, b.minBalance);
}
if (cmdId === 0x00) {
const s = decodeV3SwapExactIn(inputs[i]);
if (s) {
if (!inputToken) inputToken = s.tokenIn;
if (!inputAmount) inputAmount = s.amountIn;
setInputOnce(s.tokenIn, s.amountIn);
// Always update output: in multi-step swaps (V3 → V4),
// the last swap step determines the final output token
// and minimum received amount.
outputToken = s.tokenOut;
minOutput = s.amountOutMin;
setOutput(s.tokenOut, s.amountOutMin);
}
}
if (cmdId === 0x08) {
const s = decodeV2SwapExactIn(inputs[i]);
if (s) {
if (!inputToken) inputToken = s.tokenIn;
if (!inputAmount) inputAmount = s.amountIn;
outputToken = s.tokenOut;
minOutput = s.amountOutMin;
setInputOnce(s.tokenIn, s.amountIn);
setOutput(s.tokenOut, s.amountOutMin);
}
}
if (cmdId === 0x0b) {
const w = decodeWrapEth(inputs[i]);
if (w && !inputToken) {
inputToken =
"0x0000000000000000000000000000000000000000";
inputAmount = w.amount;
if (w) {
setInputOnce(
"0x0000000000000000000000000000000000000000",
w.amount,
);
}
}
if (cmdId === 0x10) {
const v4 = decodeV4Swap(inputs[i]);
if (v4) {
if (!inputToken && v4.tokenIn) inputToken = v4.tokenIn;
if (!inputAmount && v4.amountIn)
inputAmount = v4.amountIn;
// Always update output: last swap step wins
if (v4.tokenOut) outputToken = v4.tokenOut;
if (v4.amountOutMin) minOutput = v4.amountOutMin;
setInputOnce(v4.tokenIn, v4.amountIn);
// Always update output: last swap step wins. A step
// that carries the Min. received figure but decoded no
// output currency makes the output *undetermined* — it
// is neither ETH nor whatever an earlier step named,
// and that figure is no longer counted in that token.
// Equally, a step that names an output currency but no
// minimum leaves Min. received unstated rather than
// keeping an earlier step's figure beside the new
// token. setOutput() is both rules; a step that states
// neither half leaves the output alone.
setOutput(v4.tokenOut, v4.amountOutMin);
}
}
@@ -415,11 +558,14 @@ function decode(data, toAddress) {
}
}
// Resolve token info
const inInfo = tokenInfo(inputToken);
// Resolve token info. A null token on either side means the calldata
// named no currency for it; tokenInfo() refuses rather than calling it
// ETH. UNWRAP_WETH is the one output that is ETH without a currency to
// decode, and it is answered here rather than left to that rule.
const inInfo = tokenInfo(inputToken, sources);
const outInfo = hasUnwrapWeth
? { symbol: "ETH", decimals: 18, address: null }
: tokenInfo(outputToken);
: tokenInfo(outputToken, sources);
const inSymbol = inInfo.symbol;
const outSymbol = outInfo.symbol;
@@ -437,7 +583,7 @@ function decode(data, toAddress) {
address: toAddress,
});
if (inputToken && inInfo.address) {
if (present(inputToken) && present(inInfo.address)) {
const label = inSymbol
? inSymbol + " (" + inputToken + ")"
: inputToken;
@@ -449,47 +595,74 @@ function decode(data, toAddress) {
});
} else if (inSymbol === "ETH") {
details.push({ label: "Token In", value: "ETH (native)" });
} else {
// Nothing established the input token, so the line says that
// rather than going missing or naming a token by default. Same
// wording as the Token Out refusal below: the two sides of this
// screen must not describe the same condition in two ways.
details.push({ label: "Token In", value: UNNAMED_CURRENCY });
}
if (inputAmount !== null && inputAmount !== undefined) {
const maxUint160 = BigInt(
"0xffffffffffffffffffffffffffffffffffffffff",
);
const isUnlimited = inputAmount >= maxUint160;
const amountRaw = isUnlimited
? "Unlimited"
: formatAmount(inputAmount, inInfo.decimals);
const amountStr = isUnlimited
? "Unlimited"
: amountRaw + (inSymbol ? " " + inSymbol : "");
if (present(inputAmount)) {
// Two amounts need no scale to describe and are named rather than
// formatted: V4's open delta, which is not a quantity at all (see
// OPEN_DELTA), and an unbounded permit. The open-delta test comes
// first — the sentinel is not a bigint and cannot be compared with
// one.
let amount;
if (inputAmount === OPEN_DELTA) {
amount = { raw: OPEN_DELTA_AMOUNT, display: OPEN_DELTA_AMOUNT };
} else if (inputAmount >= MAX_UINT160) {
amount = { raw: "Unlimited", display: "Unlimited" };
} else {
amount = amountText(inputAmount, inInfo);
}
details.push({
label: "Amount",
value: amountStr,
rawValue: amountRaw,
value: amount.display,
rawValue: amount.raw,
});
}
if (outSymbol) {
if (outInfo.address) {
const label = outSymbol
? outSymbol + " (" + outputToken + ")"
: outputToken;
details.push({
label: "Token Out",
value: label,
address: outputToken,
isToken: true,
});
} else {
details.push({ label: "Token Out", value: outSymbol });
}
// Keyed on the address, not the symbol: a token absent from the
// bundled list has no symbol, and gating the line on one dropped it
// entirely, leaving a Min. received figure with nothing saying what is
// being received. The Token In line above already falls back to the
// address; this does the same.
if (present(outInfo.address)) {
const label = outSymbol
? outSymbol + " (" + outInfo.address + ")"
: outInfo.address;
details.push({
label: "Token Out",
value: label,
address: outInfo.address,
isToken: true,
});
} else if (outSymbol) {
details.push({ label: "Token Out", value: outSymbol });
} else {
// Nothing established the output token, so the line says that
// rather than going missing or naming a token by default, and a
// Min. received figure below it is never attached to a token the
// calldata did not state.
details.push({ label: "Token Out", value: UNNAMED_CURRENCY });
}
if (minOutput !== null && minOutput !== undefined) {
const minStr =
formatAmount(minOutput, outInfo.decimals) +
(outSymbol ? " " + outSymbol : "");
details.push({ label: "Min. received", value: minStr });
if (present(minOutput)) {
// A zero floor is the one case the user most needs stated: the
// swap guarantees nothing back. It is said in words, in the same
// register as UNNAMED_CURRENCY, because "0.0000 WETH" reads as an
// artifact of the four-decimal rule rather than as "this may
// return nothing" — and because it is true at every scale, so it
// holds even when the output token's decimals are unknown.
details.push({
label: "Min. received",
value:
minOutput === 0n
? NO_MINIMUM
: amountText(minOutput, outInfo).display,
});
}
details.push({ label: "Steps", value: commandNames.join(" \u2192 ") });

View File

@@ -8,6 +8,8 @@
// A controllable clock plus a stubbed balance refresh, so a cadence test can
// measure the interval between refreshes that actually happened rather than
// asserting the interval someone intended.
const { makeStorageStub } = require("./support/storageStub");
let mockNow = 0;
const mockBalanceRefreshAt = [];
@@ -247,32 +249,17 @@ describe("alarms module", () => {
// Loads the background worker against stubbed browser APIs. The returned
// store is the extension storage the worker sees, so a test can seed wallet
// state and read back what the worker persisted.
// The stub clones in both directions, as the real chrome.storage.local does,
// and carries the latency simulation above on every operation. It used to
// alias, which for this file meant the worker's in-memory wallets and the
// "stored" ones were one object — see tests/support/storageStub.js.
function loadBackground(initialStore = {}) {
const storageStore = initialStore;
const storage = makeStorageStub(initialStore, mockStorageTick);
const alarmsStub = makeAlarmsStub();
const listeners = { onInstalled: [], onStartup: [] };
global.chrome = {
alarms: alarmsStub,
storage: {
local: {
get: async (key) => {
mockStorageTick();
return Object.prototype.hasOwnProperty.call(
storageStore,
key,
)
? { [key]: storageStore[key] }
: {};
},
set: async (items) => {
mockStorageTick();
Object.assign(storageStore, items);
},
remove: async (key) => {
delete storageStore[key];
},
},
},
storage,
runtime: {
onMessage: { addListener: jest.fn() },
onConnect: { addListener: jest.fn() },
@@ -301,7 +288,7 @@ function loadBackground(initialStore = {}) {
}));
jest.resetModules();
require("../src/background/index");
return { alarmsStub, listeners, store: storageStore };
return { alarmsStub, listeners, storage };
}
// Flush the promise chains the startup path and the alarm handlers run on.
@@ -410,8 +397,23 @@ describe("balance refresh steady-state cadence", () => {
return {
autistmask: {
hasWallet: true,
// A whole wallet record, not a bare address: a stored profile
// is validated against the schema on every read now
// (src/shared/stateSchema.js), and a wallet with no address
// list is one of the shapes that refuses to load.
wallets: [
{ address: "0x0000000000000000000000000000000000000001" },
{
name: "Wallet 1",
type: "hd",
addresses: [
{
address:
"0x0000000000000000000000000000000000000001",
balance: "0",
tokenBalances: [],
},
],
},
],
lastBalanceRefresh: 0,
},
@@ -476,12 +478,16 @@ describe("balance refresh steady-state cadence", () => {
// The guard's actual job, and the reason it is shortened rather than
// removed: while the popup is open it refreshes every 10 seconds and
// stamps the same field, and the background job has nothing to add.
const store = seededStore();
const { alarmsStub } = loadBackground(store);
const { alarmsStub, storage } = loadBackground(seededStore());
await settle();
mockNow += PERIOD_MS;
store.autistmask.lastBalanceRefresh = mockNow - 10 * 1000;
// As the open popup's own refresh would leave it: written to storage,
// not poked into an object the worker happens to share.
storage.write("autistmask", {
...storage.read("autistmask"),
lastBalanceRefresh: mockNow - 10 * 1000,
});
alarmsStub.fire(BALANCE_REFRESH_ALARM);
await settle();

View File

@@ -153,7 +153,7 @@ describe("Back onto a view the reopened popup never rendered", () => {
test("Back onto the transaction detail renders it", () => {
reopenedOn("settings", ["main", "transaction"], {
viewData: { tx: { hash: "0xdead" } },
viewData: { tx: { hash: "0xdead", from: ADDRESS, to: ADDRESS } },
});
goBack();
expect(calls).toEqual(["transactionDetail"]);
@@ -162,7 +162,14 @@ describe("Back onto a view the reopened popup never rendered", () => {
test("Back onto the transaction confirmation restores it", () => {
reopenedOn("settings", ["main", "confirm-tx"], {
viewData: { pendingTx: { to: ADDRESS, amount: "1" } },
viewData: {
pendingTx: {
token: "ETH",
from: ADDRESS,
to: ADDRESS,
amount: "1",
},
},
});
goBack();
expect(calls).toEqual(["confirmTx"]);
@@ -171,7 +178,7 @@ describe("Back onto a view the reopened popup never rendered", () => {
test("Back onto the success screen renders it", () => {
reopenedOn("settings", ["main", "success-tx"], {
viewData: { hash: "0xdead" },
viewData: { hash: "0xdead", to: ADDRESS },
});
goBack();
expect(calls).toEqual(["successTx"]);
@@ -180,7 +187,7 @@ describe("Back onto a view the reopened popup never rendered", () => {
test("Back onto the failure screen renders it", () => {
reopenedOn("settings", ["main", "error-tx"], {
viewData: { message: "execution reverted" },
viewData: { message: "execution reverted", to: ADDRESS },
});
goBack();
expect(calls).toEqual(["errorTx"]);

View File

@@ -24,6 +24,7 @@ const { Network, Wallet } = require("ethers");
// before any jest.doMock() of the module, so the copy assertions below check
// what the user is actually shown.
const { describeSigningFailure } = require("../src/shared/approvalVerify");
const { makeStorageStub } = require("./support/storageStub");
const SIGNER_KEY =
"0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d";
@@ -137,22 +138,22 @@ function loadBackground(options) {
jest.resetModules();
const broadcastTransaction = jest.fn();
const loadState = jest.fn(opts.loadState || (async () => {}));
// The network the wallet is on, which the tests switch under a pending
// approval. The node the transaction is populated against is on the same
// one, as it would be: switching networks switches the RPC endpoint too.
let chain = MAINNET;
// The node the transaction is populated against is on whatever chain the
// stored profile says, as it would be: switching networks switches the RPC
// endpoint too. The background takes the network from storage per call —
// it holds no in-memory copy — so this reads the record rather than a
// variable the test keeps alongside it.
const chainOf = (networkId) =>
networkId === "sepolia" ? SEPOLIA : MAINNET;
jest.doMock("../src/shared/state", () => ({
state: { rpcUrl: "https://rpc.invalid", wallets: [] },
loadState,
saveState: jest.fn(async () => {}),
currentNetwork: () => ({ chainId: chain.hex }),
}));
jest.doMock("../src/shared/balances", () => ({
getProvider: () =>
fakeProvider(broadcastTransaction, opts.provider, chain.num),
getProvider: (rpcUrl, networkId) =>
fakeProvider(
broadcastTransaction,
opts.provider,
chainOf(networkId).num,
),
refreshBalances: jest.fn(async () => {}),
}));
jest.doMock("../src/shared/phishingDomains", () => ({
@@ -174,15 +175,48 @@ function loadBackground(options) {
}
const persisted = {
// Address RECORDS, not bare address strings: a stored profile is
// validated against the schema on every read now
// (src/shared/stateSchema.js), and a bare string where a record
// belongs is one of the shapes that refuses to load.
wallets: [
{ name: "Wallet 1", type: "hd", addresses: [signer.address] },
{
name: "Wallet 1",
type: "hd",
addresses: [
{
address: signer.address,
balance: "0",
tokenBalances: [],
},
],
},
],
networkId: "mainnet",
rpcUrl: "https://rpc.invalid",
activeAddress: signer.address,
allowedSites: { [signer.address]: [HOSTNAME] },
deniedSites: {},
};
// The one wallet state there is. The background reads it per call and
// writes it read-modify-write; it holds no in-memory copy and cannot reach
// the shared singleton. Clones in both directions, as the real API does —
// the stub here used to hand back the live record and drop every write on
// the floor, so a test could neither see what was persisted nor be sure
// what it read had crossed the boundary
// (https://git.eeqj.de/sneak/AutistMask/issues/324).
const storage = makeStorageStub({ autistmask: persisted });
// A test that needs the state read itself to misbehave installs a hook —
// a stall, a throw — in place of the next reads. Armed after setup so
// that raising the approval is not what fails.
let storageGetHook = opts.storageGet || null;
const realGet = storage.local.get;
storage.local.get = jest.fn(async (key) =>
storageGetHook ? storageGetHook(key) : realGet(key),
);
let messageListener = null;
let windowRemovedListener = null;
let connectListener = null;
@@ -194,15 +228,7 @@ function loadBackground(options) {
const actionPopups = [];
global.chrome = {
storage: {
local: {
get: jest.fn(
opts.storageGet ||
(async () => ({ autistmask: persisted })),
),
set: jest.fn(async () => {}),
},
},
storage,
runtime: {
getURL: (path) => EXT_URL + path,
onMessage: {
@@ -401,18 +427,32 @@ function loadBackground(options) {
connectApproval,
closeWindow,
broadcastTransaction,
loadState,
created,
removed,
storage,
// 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;
storage.write("autistmask", {
...storage.read("autistmask"),
activeAddress: address,
});
},
// The user switching network in the toolbar popup.
// The user switching network in the toolbar popup. It moves the stored
// network and the endpoint together, as a real switch does.
setNetwork: (network) => {
chain = network;
const networkId = network === SEPOLIA ? "sepolia" : "mainnet";
storage.write("autistmask", {
...storage.read("autistmask"),
networkId,
rpcUrl: "https://rpc-" + networkId + ".invalid",
});
},
// Make the next state reads misbehave — stall, throw — without
// touching the reads that raised the approval. Pass null to restore.
setStateReadHook: (hook) => {
storageGetHook = hook;
},
fromPopup: { url: EXT_URL + "src/popup/index.html" },
};
@@ -677,15 +717,16 @@ describe("one transaction approval at a time", () => {
// page never — and holds the slot for the life of the worker with it.
test("an approval whose window closed under a failed attempt is answered, and frees the next request", async () => {
const stalled = deferred();
const bg = loadBackground({
loadState: async () => {
await stalled.promise;
throw new Error("The wallet data could not be read.");
},
});
const bg = loadBackground();
const first = bg.requestTx();
await settle();
// Armed only now: the approval was raised against a working state
// read, and it is the ATTEMPT's read that hangs and then fails.
bg.setStateReadHook(async () => {
await stalled.promise;
throw new Error("The wallet data could not be read.");
});
bg.send(
{
type: "AUTISTMASK_TX_RESPONSE",
@@ -709,6 +750,7 @@ describe("one transaction approval at a time", () => {
error: { code: 4001, message: "User rejected the request." },
});
bg.setStateReadHook(null);
const second = bg.requestTx();
await settle();
expect(second.result()).toBeNull();
@@ -1226,19 +1268,18 @@ describe("what the approval is verified against", () => {
// The interlock must not cost the retry the approval exists to allow.
describe("the interlock releases a failed attempt", () => {
test("a retryable failure before the broadcast leaves the approval usable", async () => {
let failNext = true;
const bg = loadBackground({
loadState: async () => {
if (failNext) {
failNext = false;
throw new Error("storage unavailable");
}
},
});
const bg = loadBackground();
const pending = bg.requestTx();
await settle();
const id = pending.id();
// The attempt's state read fails once, then works: nothing was
// broadcast, so the approval must survive for the retry.
bg.setStateReadHook(() => {
bg.setStateReadHook(null);
throw new Error("storage unavailable");
});
const first = bg.send(
{
type: "AUTISTMASK_TX_RESPONSE",

View File

@@ -0,0 +1,398 @@
// What one background handler's state can do to another's while both are in
// flight.
//
// The background used to read and write the module-level `state` singleton in
// src/shared/state.js — one object, shared by every handler in the worker,
// replaced wholesale by any loadState(). Two consequences, both covered here
// and both from https://git.eeqj.de/sneak/AutistMask/issues/324:
//
// - A transaction attempt captured the chain id at its loadState() and then
// read the ENDPOINT off the singleton several awaits later. A chain switch
// committed in that window moved the endpoint under an artifact already
// verified against the old chain, so it would have gone to the new chain's
// node — the very thing the verification exists to prevent.
//
// - backgroundRefresh() handed the singleton's wallets to refreshBalances(),
// which mutates address objects in place across a multi-second network
// round trip. Any concurrent handler that loaded state replaced those
// objects, so the refreshed balances landed on detached ones and the save
// that followed persisted the pre-refresh values — while still stamping
// lastBalanceRefresh, suppressing the redo.
//
// Both use the real persistence path over a cloning storage stub. Nothing here
// asserts the absence of a loadState() call; each asserts the OUTCOME, so it
// holds against any implementation that gets the outcome right.
const { Wallet } = require("ethers");
const { networkById } = require("../src/shared/networks");
const { makeStorageStub } = require("./support/storageStub");
const SIGNER_KEY =
"0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d";
const signer = new Wallet(SIGNER_KEY);
const RECIPIENT = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
const CONNECTED_ORIGIN = "https://dapp.example";
const CONNECTED_HOSTNAME = "dapp.example";
const EXT_URL = "chrome-extension://autistmask/";
const MAINNET = networkById("mainnet");
const SEPOLIA = networkById("sepolia");
const NONCE = 7;
const REFRESHED_BALANCE = "1.5";
// The transaction the background populates, and the artifact signed from it.
// Its chain is a parameter because the whole subject here is a chain moving
// under work already committed to one.
function populated(chainId) {
return {
type: 2,
chainId,
nonce: NONCE,
gasLimit: 100000n,
maxFeePerGas: 2000000000n,
maxPriorityFeePerGas: 1000000000n,
to: RECIPIENT,
value: 10000000000000000n,
data: "0x",
};
}
function storedProfile(networkId) {
const net = networkById(networkId);
return {
hasWallet: true,
wallets: [
{
name: "Wallet 1",
type: "hd",
xpub: "xpub-1",
addresses: [
{
address: signer.address,
balance: "0.0",
tokenBalances: [],
},
],
},
],
activeAddress: signer.address,
networkId,
rpcUrl: net.defaultRpcUrl,
blockscoutUrl: net.defaultBlockscoutUrl,
allowedSites: { [signer.address]: [CONNECTED_HOSTNAME] },
deniedSites: {},
trackedTokens: [],
lastBalanceRefresh: 0,
};
}
async function settle() {
for (let i = 0; i < 60; i++) await Promise.resolve();
}
function deferred() {
let resolve;
const promise = new Promise((res) => {
resolve = res;
});
return { promise, resolve };
}
afterEach(() => {
delete global.chrome;
});
// The background worker over a cloning storage stub, with the network and the
// clock stubbed out. `opts.refreshBalances` replaces the balance refresh so a
// test can hold one open across another handler's whole turn.
function loadWorker(networkId, opts) {
const options = opts || {};
jest.resetModules();
// Every provider this worker constructs, in order, with the endpoint and
// the network id it was given. The subject of the first test is which pair
// reaches the broadcast.
const providers = [];
const broadcastTransaction = jest.fn(async () => ({ hash: "0xfeed" }));
jest.doMock("../src/shared/balances", () => ({
getProvider: (rpcUrl, networkId2) => {
const provider = {
rpcUrl,
networkId: networkId2,
broadcastTransaction,
getNetwork: async () => ({
chainId: BigInt(networkById(networkId2).networkVersion),
}),
getTransactionCount: async () => NONCE,
estimateGas: async () => 100000n,
getFeeData: async () => ({
gasPrice: 2000000000n,
maxFeePerGas: 2000000000n,
maxPriorityFeePerGas: 1000000000n,
}),
};
providers.push(provider);
return provider;
},
refreshBalances:
options.refreshBalances || jest.fn(async () => undefined),
}));
jest.doMock("../src/shared/phishingDomains", () => ({
isPhishingDomain: () => false,
}));
let alarmHandlers = {};
jest.doMock("../src/shared/alarms", () => ({
BALANCE_REFRESH_ALARM: "balance",
BALANCE_REFRESH_PERIOD_MINUTES: 1,
ensureRecurringAlarms: jest.fn(async () => {}),
registerAlarmHandlers: jest.fn((handlers) => {
alarmHandlers = handlers;
}),
}));
const storage = makeStorageStub({ autistmask: storedProfile(networkId) });
// A hook the tests use to suspend one handler mid-flight, so the other one
// runs entirely inside its window.
let getHook = null;
const realGet = storage.local.get;
storage.local.get = jest.fn(async (key) => {
if (getHook) await getHook();
return realGet(key);
});
let messageListener = null;
// Every popup URL the background opened. The approval id is in it, and
// that is how the popup learns which approval it is answering.
const createdUrls = [];
global.chrome = {
storage,
runtime: {
getURL: (path) => EXT_URL + path,
onMessage: {
addListener: (fn) => {
messageListener = fn;
},
},
onConnect: { addListener: () => {} },
lastError: null,
},
windows: {
getLastFocused: (cb) => cb(null),
create: (createOpts, cb) => {
createdUrls.push(createOpts.url);
cb({ id: createdUrls.length });
},
remove: (id, cb) => {
if (cb) cb();
},
onRemoved: { addListener: () => {} },
},
tabs: {
query: (queryInfo, cb) => cb([{ id: 1 }]),
sendMessage: (tabId, message, cb) => {
if (cb) cb();
},
},
action: { setPopup: () => {} },
};
require("../src/background/index");
function send(msg, sender) {
let result = null;
const kept = messageListener(msg, sender, (r) => {
result = r;
});
return { kept, result: () => result };
}
function rpc(method, params, origin) {
return send(
{ type: "AUTISTMASK_RPC", method, params },
{ origin: origin || CONNECTED_ORIGIN },
);
}
return {
rpc,
send,
providers,
broadcastTransaction,
persisted: () => storage.read("autistmask"),
setGetHook: (hook) => {
getHook = hook;
},
fromPopup: { url: EXT_URL + "src/popup/index.html" },
fireBalanceAlarm: () => alarmHandlers.balance(),
lastApprovalId: () => {
const url = createdUrls[createdUrls.length - 1];
if (!url) return null;
return new URL(url, EXT_URL).searchParams.get("approval");
},
};
}
describe("a chain switch under a transaction already committed to a chain", () => {
// Item 4 of https://git.eeqj.de/sneak/AutistMask/issues/324.
//
// The artifact is verified against the chain read at the top of the
// attempt. Whatever endpoint it is then broadcast to has to be that same
// chain's — otherwise the wallet checks a transaction against Sepolia and
// sends it to a mainnet node. A connected site can switch the chain at any
// moment, including this one.
test("the artifact is broadcast to the endpoint of the chain it was verified against", async () => {
const bg = loadWorker("sepolia");
// Raise the approval, then find its id from the popup's own fetch.
bg.rpc("eth_sendTransaction", [
{
from: signer.address,
to: RECIPIENT,
value: "0x2386f26fc10000",
data: "0x",
},
]);
await settle();
const id = bg.lastApprovalId();
expect(id).toBeTruthy();
// The popup signs what it was shown: Sepolia.
const rawSignedTx = await signer.signTransaction(
populated(Number(SEPOLIA.networkVersion)),
);
// A connected site switches the chain while the attempt is running,
// and the switch is committed to storage in full before the attempt
// goes any further.
//
// It is fired from inside the attempt's SECOND state read, because
// that is where the window used to be: the chain id was captured at
// the first read and the endpoint was taken off the singleton several
// awaits later, so a switch landing between them moved the endpoint
// under an artifact already verified against the old chain. An
// implementation that takes both from one read has no second read for
// this to fire on, and the switch below runs after the attempt is
// done instead — which is the point.
let reads = 0;
let switched = null;
const doSwitch = async () => {
switched = bg.rpc("wallet_switchEthereumChain", [
{ chainId: MAINNET.chainId },
]);
await settle();
};
bg.setGetHook(async () => {
reads++;
if (reads !== 2) return;
bg.setGetHook(null);
await doSwitch();
});
const attempt = bg.send(
{
type: "AUTISTMASK_TX_RESPONSE",
id,
approved: true,
rawSignedTx,
},
{ url: bg.fromPopup.url },
);
await settle();
bg.setGetHook(null);
if (!switched) await doSwitch();
expect(switched.result()).toEqual({ result: null });
expect(bg.persisted().networkId).toBe("mainnet");
await settle();
// It went out, and it went out to Sepolia's node — the chain the
// artifact was verified against. Reading the endpoint separately from
// the chain id put mainnet's here.
expect(attempt.result()).toEqual({ txHash: "0xfeed" });
expect(bg.broadcastTransaction).toHaveBeenCalledTimes(1);
const used = bg.providers[bg.providers.length - 1];
expect(used.rpcUrl).toBe(SEPOLIA.defaultRpcUrl);
expect(used.networkId).toBe("sepolia");
});
});
describe("a balance refresh under another handler's state read", () => {
// Item 5 of https://git.eeqj.de/sneak/AutistMask/issues/324.
//
// The trigger is a same-chain wallet_switchEthereumChain from a connected
// site: it answers { result: null } and changes nothing, so the ONLY thing
// it can do to the refresh is what its state read does. On the singleton
// that read replaced state.wallets, detaching the objects the refresh was
// mutating.
test("a chain read arriving mid-refresh does not discard the refresh", async () => {
const roundTrip = deferred();
const reachedNetwork = deferred();
const bg = loadWorker("sepolia", {
refreshBalances: async (wallets) => {
reachedNetwork.resolve();
await roundTrip.promise;
// In place, on the objects handed in — as balances.js does.
wallets[0].addresses[0].balance = REFRESHED_BALANCE;
},
});
const refresh = bg.fireBalanceAlarm();
await reachedNetwork.promise;
const answered = bg.rpc("wallet_switchEthereumChain", [
{ chainId: SEPOLIA.chainId },
]);
await settle();
expect(answered.result()).toEqual({ result: null });
roundTrip.resolve();
await refresh;
expect(bg.persisted().wallets[0].addresses[0].balance).toBe(
REFRESHED_BALANCE,
);
expect(bg.persisted().lastBalanceRefresh).toBeGreaterThan(0);
});
// The other half of "does not publish a shared object": a wallet added
// while the refresh was in flight must survive the refresh's own write.
test("a wallet added mid-refresh survives the refresh's write", async () => {
const roundTrip = deferred();
const reachedNetwork = deferred();
const bg = loadWorker("sepolia", {
refreshBalances: async (wallets) => {
reachedNetwork.resolve();
await roundTrip.promise;
wallets[0].addresses[0].balance = REFRESHED_BALANCE;
},
});
const refresh = bg.fireBalanceAlarm();
await reachedNetwork.promise;
// Another extension page adds a wallet while the round trip is out.
const during = bg.persisted();
during.wallets.push({
name: "Wallet 2",
type: "hd",
xpub: "xpub-2",
addresses: [
{ address: RECIPIENT, balance: "0.0", tokenBalances: [] },
],
});
global.chrome.storage.write("autistmask", during);
roundTrip.resolve();
await refresh;
const after = bg.persisted();
expect(after.wallets).toHaveLength(2);
expect(after.wallets[0].addresses[0].balance).toBe(REFRESHED_BALANCE);
});
});

View File

@@ -0,0 +1,270 @@
// The lint rule that keeps src/shared/state.js out of the background bundle
// (script/lib/eslint/noStateSingletonInBackground.js).
//
// Five defects, one of which destroyed a wallet, came from background code
// reaching that singleton, and each point fix created the next site
// (https://git.eeqj.de/sneak/AutistMask/issues/324).
//
// What this file does NOT do is establish that the singleton cannot reach the
// background bundle. That is build.js's FORBIDDEN_INPUTS assertion, which reads
// esbuild's metafile and so cannot be evaded by a syntax a matcher does not
// know; it is pinned by tests/buildForbiddenInputs.test.js. The rule under test
// here is fast local feedback in front of that, and these cases pin the shapes
// it is known to catch, so a regression in the matcher is a failing test rather
// than a quietly narrower rule.
//
// Every shape below was measured against a real `make build`: each one puts
// state.js in the shipped background bundles, and each one was invisible to
// some earlier revision of the matcher — the quoted-only regex missed the
// backtick, the dynamic import and the `from` clause; its successor missed a
// comment inside the call and a directory resolved through package.json `main`.
//
// Two shapes the rule does NOT report are pinned below as non-reports, in
// "the divergences from the build's answer": a computed specifier such as
// `require("../shared/" + "state")`, which esbuild constant-folds, and a
// symlink to the module, whose real path esbuild reports. Both put state.js in
// the shipped background bundle and both are `make build` exit 2 with
// `make lint` exit 0 (measured). Pinning them as non-reports is what makes the
// rule's stated bounds a measured description rather than a claim: if either
// starts being reported, or the matcher is widened until one is, a test says
// so. Their catch is the build's, and is pinned in
// tests/buildForbiddenInputs.test.js against the metafile that catches it.
const fs = require("fs");
const os = require("os");
const path = require("path");
const { Linter } = require("eslint");
const plugin = require("../script/lib/eslint/noStateSingletonInBackground");
const RULE = "background/no-state-singleton-in-background";
// The three files a fixture tree always has. `src/background/index.js` is
// supplied per case; the other two stand in for the real modules.
const SHARED_STATE = "const state = {};\nmodule.exports = { state };\n";
const SHARED_HOP =
"// A shared module the background legitimately imports.\n" +
"module.exports = { applyChainSwitchFields() {} };\n";
let roots = [];
function fixture(files) {
const root = fs.realpathSync(
fs.mkdtempSync(path.join(os.tmpdir(), "autistmask-state-rule-")),
);
roots.push(root);
const tree = {
"src/shared/state.js": SHARED_STATE,
"src/shared/chainSwitchFields.js": SHARED_HOP,
...files,
};
for (const [rel, source] of Object.entries(tree)) {
const abs = path.join(root, rel);
fs.mkdirSync(path.dirname(abs), { recursive: true });
fs.writeFileSync(abs, source);
}
return root;
}
// Run the rule exactly as eslint.config.js runs it, over a real tree: the walk
// reads its sources from disk, so a virtual RuleTester would not exercise it.
// `sourceType` is the fixture's own, not the rule's business: the walk is
// textual and never parses the files it follows. The two ESM cases below pass
// "module" only so espree can parse the fixture at all — in this repo those
// shapes are also a parse error under the commonjs config, but the rule must
// not be left depending on that.
function lintBackground(root, { sourceType = "commonjs" } = {}) {
const file = path.join(root, "src/background/index.js");
const linter = new Linter({ cwd: root });
return linter.verify(
fs.readFileSync(file, "utf8"),
{
plugins: { background: plugin },
languageOptions: { ecmaVersion: 2024, sourceType },
rules: { [RULE]: "error" },
},
file,
);
}
function chainOf(messages) {
expect(messages).toHaveLength(1);
expect(messages[0].ruleId).toBe(RULE);
// "...singleton: <chain>. The MV3 worker..." — the chain is what the
// message exists to hand the reader, so assert on it rather than on the
// fact that something was reported.
return messages[0].message.split("singleton: ")[1].split(". The MV3")[0];
}
afterEach(() => {
for (const root of roots) fs.rmSync(root, { recursive: true, force: true });
roots = [];
});
describe("the specifier syntaxes the matcher is known to catch", () => {
test("a quoted require", () => {
const root = fixture({
"src/background/index.js":
'const { state } = require("../shared/state");\n' +
"module.exports = { state };\n",
});
expect(chainOf(lintBackground(root))).toBe(
"src/background/index.js -> src/shared/state.js",
);
});
test("a backtick require", () => {
const root = fixture({
"src/background/index.js":
"const { state } = require(`../shared/state`);\n" +
"module.exports = { state };\n",
});
expect(chainOf(lintBackground(root))).toBe(
"src/background/index.js -> src/shared/state.js",
);
});
test("a dynamic import inside an async function", () => {
const root = fixture({
"src/background/index.js":
"async function readState() {\n" +
' const m = await import("../shared/state");\n' +
" return m.state;\n" +
"}\n" +
"module.exports = { readState };\n",
});
expect(chainOf(lintBackground(root))).toBe(
"src/background/index.js -> src/shared/state.js",
);
});
test("a static import from-clause", () => {
const root = fixture({
"src/background/index.js":
'import { state } from "../shared/state";\n' +
"export { state };\n",
});
expect(chainOf(lintBackground(root, { sourceType: "module" }))).toBe(
"src/background/index.js -> src/shared/state.js",
);
});
// `import(/* webpackChunkName: "x" */ "./x")` is the standard bundler
// annotation idiom, and prettier leaves both of these exactly as written,
// so nothing else in the repo would object to them either.
test("a comment between the paren and the specifier", () => {
const root = fixture({
"src/background/index.js":
'globalThis.__probe = require(/* probe */ "../shared/state").state;\n',
});
expect(chainOf(lintBackground(root))).toBe(
"src/background/index.js -> src/shared/state.js",
);
});
test("a comment between the specifier and the closing paren", () => {
const root = fixture({
"src/background/index.js":
'globalThis.__probe = require("../shared/state" /* probe */).state;\n',
});
expect(chainOf(lintBackground(root))).toBe(
"src/background/index.js -> src/shared/state.js",
);
});
test("a bare side-effect import", () => {
const root = fixture({
"src/background/index.js": 'import "../shared/state";\n',
});
expect(chainOf(lintBackground(root, { sourceType: "module" }))).toBe(
"src/background/index.js -> src/shared/state.js",
);
});
});
describe("reachability, not just the direct specifier", () => {
// The shape a no-restricted-imports could never see: no background file
// names state.js, and the singleton is in the bundle anyway. In a backtick
// require, so this fails on the specifier widening as well as on the walk.
test("a two-hop re-export through a shared module", () => {
const root = fixture({
"src/background/index.js":
'const { applyChainSwitchFields } = require("../shared/chainSwitchFields");\n' +
"module.exports = { applyChainSwitchFields };\n",
"src/shared/chainSwitchFields.js":
SHARED_HOP +
"module.exports.state = require(`./state`).state;\n",
});
expect(chainOf(lintBackground(root))).toBe(
"src/background/index.js -> src/shared/chainSwitchFields.js" +
" -> src/shared/state.js",
);
});
// Resolution, not syntax: the specifier names a directory, and the file it
// resolves to is chosen by that directory's package.json `main`. A walk
// that only tries `<dir>/index.js` stops on a specifier it matched.
test("a directory resolved through its package.json main", () => {
const root = fixture({
"src/background/index.js":
'globalThis.__probe = require("../shared/probepkg").state;\n',
"src/shared/probepkg/package.json": '{"main": "./bridge.js"}\n',
"src/shared/probepkg/bridge.js":
'const { state } = require("../state");\n' +
"module.exports = { state };\n",
});
expect(chainOf(lintBackground(root))).toBe(
"src/background/index.js -> src/shared/probepkg/bridge.js" +
" -> src/shared/state.js",
);
});
});
// These two are holes in the rule, and they are pinned as holes on purpose:
// the build catches both, the rule is fast feedback in front of it, and a
// written-down bound that nothing measures is how the previous three rounds of
// this change ended up with claims that were false.
describe("the divergences from the build's answer", () => {
test("a computed specifier is not reported (esbuild folds it; the build fails)", () => {
const root = fixture({
"src/background/index.js":
'globalThis.__probe = require("../shared/" + "state").state;\n',
});
expect(lintBackground(root)).toEqual([]);
});
test("a symlink to the module is not reported (esbuild reports the real path)", () => {
const root = fixture({
"src/background/index.js":
'const { state } = require("../shared/stateLink");\n' +
"module.exports = { state };\n",
});
fs.symlinkSync(
path.join(root, "src/shared/state.js"),
path.join(root, "src/shared/stateLink.js"),
);
expect(lintBackground(root)).toEqual([]);
});
});
describe("what the rule must not report", () => {
test("a background file that reaches only its own state layer", () => {
const root = fixture({
"src/background/index.js":
'const { getState } = require("./state");\n' +
'const { applyChainSwitchFields } = require("../shared/chainSwitchFields");\n' +
"module.exports = { getState, applyChainSwitchFields };\n",
"src/background/state.js":
"async function getState() {}\nmodule.exports = { getState };\n",
});
expect(lintBackground(root)).toEqual([]);
});
// The tree as it actually stands. This is the assertion that would catch a
// widened matcher that resolves something it should not: it runs the rule
// over the real background entrypoint, from the real repo root.
test("the repository's own background entrypoint", () => {
const root = path.resolve(__dirname, "..");
expect(lintBackground(root)).toEqual([]);
});
});

View File

@@ -0,0 +1,366 @@
// build.js's FORBIDDEN_INPUTS assertion — the mechanical guarantee that the
// MV3 background bundle cannot contain src/shared/state.js
// (https://git.eeqj.de/sneak/AutistMask/issues/324).
//
// Why this file exists: `make check` does not run `make build`. CI executes
// the assertion (Dockerfile runs `make build`), but executing is not testing —
// invert its condition, or make the table lookup always come back undefined,
// and every check in this repo stays green while the singleton walks back into
// the worker. Five defects, one destroyed wallet, and the whole argument for
// the scoped loud-read guard rest on this assertion, so it is pinned here.
//
// The subject is build.js's exported helpers plus the table's own
// well-formedness check, driven against SYNTHETIC metafiles in esbuild's
// shape. Nothing here shells out to a build or writes dist/: the assertion's
// job is to read a metafile correctly, and a metafile is data. That the real
// shapes reach it is the build's own business and is measured in the PR that
// introduced it.
//
// Every vacuous pass this guarantee has been found to have is pinned below,
// because each one was a way for `make check`, `make lint` and `make build` to
// be green over a background bundle containing the singleton: a stale key, a
// stale module, an entry that lists no modules, an entry recorded as checked
// before its bundle was in hand, and a background entry point nobody added to
// the table.
//
// Paths are absolute on the way in, because the helpers normalize whatever
// esbuild gave them to repo-relative and this file should not depend on the
// working directory jest was started from.
const path = require("path");
const {
importChain,
newForbiddenRecord,
recordBundledInputs,
assertNoForbiddenInputs,
assertForbiddenTableCovered,
} = require("../build");
const {
BACKGROUND_ENTRY_PREFIX,
FORBIDDEN_INPUTS,
assertTableWellFormed,
} = require("../script/lib/forbiddenBundleInputs");
const ROOT = path.resolve(__dirname, "..");
const abs = (p) => path.join(ROOT, p);
const ENTRY = "src/background/index.js";
const OUT = "dist/chrome/src/background/index.js";
const STATE = "src/shared/state.js";
const HOP = "src/shared/chainSwitchFields.js";
const SECOND = "src/background/worker2.js";
const SECOND_OUT = "dist/chrome/src/background/worker2.js";
const POPUP = "src/popup/index.js";
const POPUP_OUT = "dist/chrome/src/popup/index.js";
// The real table's shape: entry point -> modules its bundle may not contain.
const TABLE = { [ENTRY]: [STATE] };
// A metafile as esbuild emits one: `outputs[out].inputs` is the flat list of
// every input that contributed to that output, and `inputs[file].imports` is
// the edge list, which is what the chain walk follows.
function metafile({ outputs = {}, imports = {} } = {}) {
return {
outputs: Object.fromEntries(
Object.entries(outputs).map(([out, inputs]) => [
abs(out),
{
inputs: Object.fromEntries(
inputs.map((input) => [
abs(input),
{ bytesInOutput: 1 },
]),
),
},
]),
),
inputs: Object.fromEntries(
Object.entries(imports).map(([file, targets]) => [
abs(file),
{ imports: targets.map((target) => ({ path: abs(target) })) },
]),
),
};
}
function check(mf, table = TABLE, record = newForbiddenRecord()) {
recordBundledInputs(mf, record);
assertNoForbiddenInputs(abs(ENTRY), abs(OUT), mf, record, table);
return record;
}
describe("assertNoForbiddenInputs()", () => {
test("a forbidden module in the bundle fails, naming the import chain", () => {
const mf = metafile({
outputs: { [OUT]: [ENTRY, HOP, STATE] },
imports: {
[ENTRY]: [HOP],
[HOP]: [STATE],
},
});
expect(() => check(mf)).toThrow(
`${OUT} bundles ${STATE}, which ${ENTRY} must not reach: ` +
`${ENTRY} -> ${HOP} -> ${STATE}.`,
);
});
test("a bundle without the forbidden module passes, and is recorded as checked", () => {
const mf = metafile({
outputs: { [OUT]: [ENTRY, HOP, "src/background/state.js"] },
imports: { [ENTRY]: [HOP, "src/background/state.js"] },
});
const record = check(mf);
expect([...record.entriesChecked]).toEqual([ENTRY]);
expect(record.bundledInputs.has(STATE)).toBe(false);
});
test("the failure still names the bundle when no import chain can be shown", () => {
// esbuild resolves `import("../shared/" + variable)` as a glob: the
// module is an input of the output, but no single edge leads to it.
// The message must degrade to no chain rather than crash.
const mf = metafile({
outputs: { [OUT]: [ENTRY, STATE] },
imports: { [ENTRY]: [] },
});
expect(() => check(mf)).toThrow(
`${OUT} bundles ${STATE}, which ${ENTRY} must not reach.`,
);
});
// The lookup this covers is the fragile step in the whole assertion:
// repoRelative() resolves against process.cwd() and esbuild's output keys
// are cwd-relative, so a change to where the build runs from, or to
// outfile vs outdir, makes it miss. It must be loud, and the entry must
// NOT already be marked checked when it does — otherwise a later edit
// turning this throw into an early return leaves both halves of the
// guarantee satisfied by a bundle nothing looked at.
test("an output esbuild did not report fails, and records nothing as checked", () => {
const mf = metafile({
outputs: { "dist/chrome/src/background/renamed.js": [ENTRY] },
imports: { [ENTRY]: [] },
});
const record = newForbiddenRecord();
recordBundledInputs(mf, record);
expect(() =>
assertNoForbiddenInputs(abs(ENTRY), abs(OUT), mf, record, TABLE),
).toThrow(`esbuild reported no metafile output for ${OUT}`);
expect([...record.entriesChecked]).toEqual([]);
// So even if that throw became `return`, the coverage half catches it.
record.bundledInputs.add(STATE);
expect(() => assertForbiddenTableCovered(record, TABLE)).toThrow(
`${ENTRY} is listed in FORBIDDEN_INPUTS but was not bundled`,
);
});
});
// Finding from the fifth review of this change: adding a second worker entry
// point is exactly the accident this guarantee exists for, and the person
// adding one has no reason to know a table elsewhere needs a line. So the
// default for the background directory is protected, not unprotected.
describe("background entry points are protected by default", () => {
test("a bundled background entry point with no line in the table fails", () => {
const mf = metafile({
outputs: { [SECOND_OUT]: [SECOND, STATE] },
imports: { [SECOND]: [STATE] },
});
const record = newForbiddenRecord();
recordBundledInputs(mf, record);
expect(() =>
assertNoForbiddenInputs(
abs(SECOND),
abs(SECOND_OUT),
mf,
record,
TABLE,
),
).toThrow(
`${SECOND} is a background entry point with no line in ` +
`FORBIDDEN_INPUTS`,
);
});
test("an entry point outside the background directory needs no line", () => {
// The popup legitimately bundles the singleton; that is its model.
const mf = metafile({
outputs: { [POPUP_OUT]: [POPUP, STATE] },
imports: { [POPUP]: [STATE] },
});
const record = newForbiddenRecord();
recordBundledInputs(mf, record);
expect(() =>
assertNoForbiddenInputs(
abs(POPUP),
abs(POPUP_OUT),
mf,
record,
TABLE,
),
).not.toThrow();
expect([...record.entriesChecked]).toEqual([]);
});
test("the prefix is the one the lint rule is scoped to", () => {
expect(BACKGROUND_ENTRY_PREFIX).toBe("src/background/");
expect(SECOND.startsWith(BACKGROUND_ENTRY_PREFIX)).toBe(true);
expect(POPUP.startsWith(BACKGROUND_ENTRY_PREFIX)).toBe(false);
});
});
describe("recordBundledInputs()", () => {
// The sole data source for the module half of the anti-rot check, and
// every other case here hand-seeds what it produces. Driven for real,
// across two outputs, and then handed straight to the check that reads it.
test("records every input of every output, satisfying the module half", () => {
const mf = metafile({
outputs: {
[OUT]: [ENTRY, HOP],
[POPUP_OUT]: [POPUP, STATE],
},
imports: { [ENTRY]: [HOP], [POPUP]: [STATE] },
});
const record = newForbiddenRecord();
recordBundledInputs(mf, record);
expect([...record.bundledInputs].sort()).toEqual(
[ENTRY, HOP, POPUP, STATE].sort(),
);
// Nothing hand-seeded: the covered check passes on what the recorder
// actually collected, so a recorder that collects nothing fails here.
assertNoForbiddenInputs(abs(ENTRY), abs(OUT), mf, record, TABLE);
expect(() => assertForbiddenTableCovered(record, TABLE)).not.toThrow();
});
});
describe("importChain()", () => {
test("terminates on a cyclic input graph, and still finds the module", () => {
const mf = metafile({
imports: {
[ENTRY]: [HOP],
[HOP]: ["src/shared/log.js"],
// The cycle: log <-> hop, with the target one hop past it.
"src/shared/log.js": [HOP, STATE],
},
});
expect(importChain(mf, abs(ENTRY), STATE)).toEqual([
ENTRY,
HOP,
"src/shared/log.js",
STATE,
]);
});
test("terminates and returns null when a cycle cannot reach the module", () => {
const mf = metafile({
imports: {
[ENTRY]: [HOP],
[HOP]: ["src/shared/log.js"],
"src/shared/log.js": [HOP, ENTRY],
},
});
expect(importChain(mf, abs(ENTRY), STATE)).toBeNull();
});
});
describe("assertForbiddenTableCovered()", () => {
test("the shipped table is satisfied by a build that checked it", () => {
const record = newForbiddenRecord();
record.entriesChecked.add(ENTRY);
// The popup bundle is what legitimately contains the singleton.
record.bundledInputs.add(STATE);
expect(() => assertForbiddenTableCovered(record, TABLE)).not.toThrow();
});
// The build this drives bundles the popup and no background entry point,
// because a stale key AND a bundled background entry point is now the
// stronger failure above — the entry point that is there but unlisted is
// reported by name, before the end of the build. This case is the rot that
// is left after that one: a key naming something this build never bundled.
test("a key no bundled entry point matched fails", () => {
const mf = metafile({
outputs: { [POPUP_OUT]: [POPUP] },
imports: { [POPUP]: [] },
});
const stale = { "src/background/renamed.js": [STATE] };
const record = newForbiddenRecord();
recordBundledInputs(mf, record);
assertNoForbiddenInputs(abs(POPUP), abs(POPUP_OUT), mf, record, stale);
record.bundledInputs.add(STATE);
expect(() => assertForbiddenTableCovered(record, stale)).toThrow(
"src/background/renamed.js is listed in FORBIDDEN_INPUTS but was" +
" not bundled, so nothing checked it",
);
});
test("a forbidden module this build bundled nowhere fails", () => {
// The other half of the same rot: renaming or moving the singleton
// leaves a table that names a path nothing resolves to any more, and
// every bundle then passes it vacuously.
const mf = metafile({
outputs: { [OUT]: [ENTRY] },
imports: { [ENTRY]: [] },
});
const stale = { [ENTRY]: ["src/shared/stateRenamed.js"] };
const record = check(mf, stale);
record.bundledInputs.add(STATE);
expect(() => assertForbiddenTableCovered(record, stale)).toThrow(
"src/shared/stateRenamed.js is listed in FORBIDDEN_INPUTS for" +
` ${ENTRY}, but this build bundled it nowhere`,
);
});
// The third rot, and the worst of the three: an entry with an empty list
// is checked, is bundled, names no module that could be missing, and
// prohibits nothing — in BOTH layers at once, since the rule's forbidden
// set is Object.values(table).flat().
test("an entry that lists no modules fails", () => {
const mf = metafile({
outputs: { [OUT]: [ENTRY, STATE] },
imports: { [ENTRY]: [STATE] },
});
const empty = { [ENTRY]: [] };
const record = newForbiddenRecord();
recordBundledInputs(mf, record);
assertNoForbiddenInputs(abs(ENTRY), abs(OUT), mf, record, empty);
expect(() => assertForbiddenTableCovered(record, empty)).toThrow(
`FORBIDDEN_INPUTS["${ENTRY}"] lists no modules`,
);
});
});
describe("assertTableWellFormed()", () => {
// Runs at require time on the shipped table, in script/lib/
// forbiddenBundleInputs.js, so an empty list fails the lint run as well as
// the build — the build's own re-check cannot help a layer that never
// reaches the build.
test("the shipped table is well formed", () => {
expect(() => assertTableWellFormed(FORBIDDEN_INPUTS)).not.toThrow();
expect(Object.entries(FORBIDDEN_INPUTS).length).toBeGreaterThan(0);
});
test("an entry that lists no modules fails, naming the entry", () => {
expect(() => assertTableWellFormed({ [ENTRY]: [] })).toThrow(
`FORBIDDEN_INPUTS["${ENTRY}"] lists no modules`,
);
});
test("a table with no entries at all fails", () => {
expect(() => assertTableWellFormed({})).toThrow(
"FORBIDDEN_INPUTS is empty",
);
});
});

View File

@@ -8,10 +8,12 @@
// broadcast — because an error code alone would not distinguish a gate from
// a switch that happened and then reported a failure.
//
// The endpoint half of that issue lives in tests/networkEndpoints.test.js;
// this file mocks the state module, which that one exercises for real.
// The endpoint half of that issue lives in tests/networkEndpoints.test.js,
// which covers the popup's chain switch; this file covers the background's,
// which goes through storage rather than the shared state singleton.
const { networkById } = require("../src/shared/networks");
const { makeStorageStub } = require("./support/storageStub");
const ADDRESS = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
@@ -51,29 +53,11 @@ afterEach(() => {
// ---------------------------------------------------------------------------
// Load the background worker against stubbed browser APIs, with the real
// chain-switch module behind it, and return the handles to drive it. The
// wallet state is a plain object so that a switch that DID happen is visible
// as a mutation of it, and one that did not is visible as its absence.
// chain-switch and persistence modules behind it, and return the handles to
// drive it.
function loadBackground() {
jest.resetModules();
const walletState = {
networkId: "mainnet",
rpcUrl: CUSTOM_RPC,
blockscoutUrl: MAINNET.defaultBlockscoutUrl,
networkEndpoints: {},
wallets: walletFixture(),
lastBalanceRefresh: 1,
tokenHolderCache: {},
fraudContracts: [],
};
jest.doMock("../src/shared/state", () => ({
state: walletState,
loadState: jest.fn(async () => {}),
saveState: jest.fn(async () => {}),
currentNetwork: () => networkById(walletState.networkId),
}));
jest.doMock("../src/shared/balances", () => ({
getProvider: () => ({}),
refreshBalances: jest.fn(async () => {}),
@@ -88,12 +72,24 @@ function loadBackground() {
registerAlarmHandlers: jest.fn(),
}));
// Storage is the only wallet state there is. The background reads and
// writes it per call — it holds no in-memory copy and cannot reach the
// shared singleton — so a switch that happened is visible here as a
// written record, and one that did not is visible as its absence.
const persisted = {
networkId: "mainnet",
rpcUrl: CUSTOM_RPC,
blockscoutUrl: MAINNET.defaultBlockscoutUrl,
networkEndpoints: {},
wallets: walletFixture(),
lastBalanceRefresh: 1,
tokenHolderCache: {},
fraudContracts: [],
activeAddress: ADDRESS,
allowedSites: { [ADDRESS]: [CONNECTED_HOSTNAME] },
deniedSites: {},
};
const storage = makeStorageStub({ autistmask: persisted });
let messageListener = null;
// Every message the background pushed at a content script. chainChanged
@@ -102,12 +98,7 @@ function loadBackground() {
const toTabs = [];
global.chrome = {
storage: {
local: {
get: jest.fn(async () => ({ autistmask: persisted })),
set: jest.fn(async () => {}),
},
},
storage,
runtime: {
getURL: (path) => "chrome-extension://autistmask/" + path,
onMessage: {
@@ -157,7 +148,7 @@ function loadBackground() {
return {
switchChain,
walletState,
walletState: () => storage.read("autistmask"),
chainChangedEvents: () =>
toTabs.filter((m) => m.eventName === "chainChanged"),
};
@@ -174,8 +165,8 @@ describe("wallet_switchEthereumChain is gated on the connection", () => {
// The refusal has to be a refusal to ACT, not just an error string:
// the wallet is still on mainnet, still on the user's own node, and
// no page was told the chain moved.
expect(bg.walletState.networkId).toBe("mainnet");
expect(bg.walletState.rpcUrl).toBe(CUSTOM_RPC);
expect(bg.walletState().networkId).toBe("mainnet");
expect(bg.walletState().rpcUrl).toBe(CUSTOM_RPC);
expect(bg.chainChangedEvents()).toEqual([]);
});
@@ -201,7 +192,7 @@ describe("wallet_switchEthereumChain is gated on the connection", () => {
const result = await bg.switchChain(SEPOLIA.chainId, CONNECTED_ORIGIN);
expect(result).toEqual({ result: null });
expect(bg.walletState.networkId).toBe("sepolia");
expect(bg.walletState().networkId).toBe("sepolia");
expect(bg.chainChangedEvents()).toEqual([
{
type: "AUTISTMASK_EVENT",
@@ -217,16 +208,16 @@ describe("wallet_switchEthereumChain is gated on the connection", () => {
const result = await bg.switchChain("0x89", CONNECTED_ORIGIN);
expect(result.error.code).toBe(4902);
expect(bg.walletState.networkId).toBe("mainnet");
expect(bg.walletState().networkId).toBe("mainnet");
});
test("a switch by a connected origin keeps the user's endpoint", async () => {
const bg = loadBackground();
await bg.switchChain(SEPOLIA.chainId, CONNECTED_ORIGIN);
expect(bg.walletState.rpcUrl).toBe(SEPOLIA.defaultRpcUrl);
expect(bg.walletState().rpcUrl).toBe(SEPOLIA.defaultRpcUrl);
await bg.switchChain(MAINNET.chainId, CONNECTED_ORIGIN);
expect(bg.walletState.rpcUrl).toBe(CUSTOM_RPC);
expect(bg.walletState().rpcUrl).toBe(CUSTOM_RPC);
});
});

View File

@@ -16,6 +16,7 @@
// first, so neither can see this.
const { networkById } = require("../src/shared/networks");
const { makeStorageStub } = require("./support/storageStub");
const ADDRESS = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
@@ -64,9 +65,12 @@ afterEach(() => {
delete global.chrome;
});
// Load the background worker with the real state and chain-switch modules
// behind it, over a storage stub that actually keeps what is written — a
// wipe is only observable against storage that remembers.
// Load the background worker with the real chain-switch and persistence
// modules behind it, over a storage stub that actually keeps what is written —
// a wipe is only observable against storage that remembers — and that clones
// in both directions, as the real API does. It used to alias, so the record
// the worker held and the "stored" one were a single object; see
// tests/support/storageStub.js.
function loadColdWorker(networkId) {
jest.resetModules();
@@ -84,20 +88,13 @@ function loadColdWorker(networkId) {
registerAlarmHandlers: jest.fn(),
}));
const store = { autistmask: storedProfile(networkId) };
const storage = makeStorageStub({ autistmask: storedProfile(networkId) });
let messageListener = null;
const toTabs = [];
global.chrome = {
storage: {
local: {
get: jest.fn(async () => ({ autistmask: store.autistmask })),
set: jest.fn(async (items) => {
store.autistmask = items.autistmask;
}),
},
},
storage,
runtime: {
getURL: (path) => "chrome-extension://autistmask/" + path,
onMessage: {
@@ -147,7 +144,7 @@ function loadColdWorker(networkId) {
return {
switchChain,
persisted: () => store.autistmask,
persisted: () => storage.read("autistmask"),
chainChangedEvents: () =>
toTabs.filter((m) => m.eventName === "chainChanged"),
};

View File

@@ -0,0 +1,279 @@
// Which chain a dApp transaction is PREPARED for on a worker that has not
// loaded state.
//
// The MV3 service worker is terminated when idle — roughly 30 seconds, which
// is its normal condition — and revived by the page's own message. Nothing
// loads state at module scope, so handleSendTransaction() used to build its
// provider with `getProvider(await getRpcUrl())`: the endpoint came from
// storage and was right, and the static network hint was omitted, so
// src/shared/balances.js fell back to currentNetwork() — the unpopulated
// singleton — and answered mainnet. ethers then fixed `chainId` at 0x1.
//
// The transaction was not sent on the wrong chain: verifySignedTx() compares
// the artifact against the selected chain and refused it. So the guard held
// and the feature did not — a user on any non-mainnet network could not send
// from a dApp at all, and the error described the symptom
// (https://git.eeqj.de/sneak/AutistMask/issues/320).
//
// This drives the real balances module and the real approval preparation and
// verification. Only ethers' JsonRpcProvider is replaced, so the static
// network hint getProvider() computes is the hint the population sees.
const { Network, Wallet, Transaction } = require("ethers");
const { networkById } = require("../src/shared/networks");
const { makeStorageStub } = require("./support/storageStub");
const SIGNER_KEY =
"0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d";
const signer = new Wallet(SIGNER_KEY);
const RECIPIENT = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
const CONNECTED_ORIGIN = "https://dapp.example";
const CONNECTED_HOSTNAME = "dapp.example";
const EXT_URL = "chrome-extension://autistmask/";
const SEPOLIA = networkById("sepolia");
const MAINNET = networkById("mainnet");
const NONCE = 7;
const TX_HASH = "0xfeed";
const TX_PARAMS = {
from: signer.address,
to: RECIPIENT,
value: "0x2386f26fc10000",
data: "0x",
};
function storedProfile(networkId) {
const net = networkById(networkId);
return {
hasWallet: true,
wallets: [
{
name: "Wallet 1",
type: "hd",
xpub: "xpub-1",
addresses: [
{
address: signer.address,
balance: "0.0",
tokenBalances: [],
},
],
},
],
activeAddress: signer.address,
networkId,
rpcUrl: net.defaultRpcUrl,
blockscoutUrl: net.defaultBlockscoutUrl,
allowedSites: { [signer.address]: [CONNECTED_HOSTNAME] },
deniedSites: {},
trackedTokens: [],
};
}
async function settle() {
for (let i = 0; i < 60; i++) await Promise.resolve();
}
afterEach(() => {
delete global.chrome;
});
// A worker whose only wallet state is what is in storage, with ethers'
// JsonRpcProvider replaced by a stub that answers out of the static network it
// was constructed with — which is exactly what a real staticNetwork provider
// does, and what makes the chain id on the approval screen observable here.
function loadColdWorker(networkId) {
jest.resetModules();
const constructed = [];
const broadcast = [];
jest.doMock("ethers", () => {
const actual = jest.requireActual("ethers");
class StubJsonRpcProvider {
constructor(url, network) {
this._network = network;
constructed.push({ url, network });
}
async getNetwork() {
return this._network;
}
async getTransactionCount() {
return NONCE;
}
async estimateGas() {
return 100000n;
}
async getFeeData() {
return {
gasPrice: 2000000000n,
maxFeePerGas: 2000000000n,
maxPriorityFeePerGas: 1000000000n,
};
}
async broadcastTransaction(raw) {
broadcast.push(raw);
return { hash: TX_HASH };
}
}
return { ...actual, JsonRpcProvider: StubJsonRpcProvider };
});
jest.doMock("../src/shared/phishingDomains", () => ({
isPhishingDomain: () => false,
}));
jest.doMock("../src/shared/alarms", () => ({
BALANCE_REFRESH_ALARM: "balance",
BALANCE_REFRESH_PERIOD_MINUTES: 1,
ensureRecurringAlarms: jest.fn(async () => {}),
registerAlarmHandlers: jest.fn(),
}));
const storage = makeStorageStub({ autistmask: storedProfile(networkId) });
let messageListener = null;
const createdUrls = [];
global.chrome = {
storage,
runtime: {
getURL: (path) => EXT_URL + path,
onMessage: {
addListener: (fn) => {
messageListener = fn;
},
},
onConnect: { addListener: () => {} },
lastError: null,
},
windows: {
getLastFocused: (cb) => cb(null),
create: (opts, cb) => {
createdUrls.push(opts.url);
cb({ id: createdUrls.length });
},
remove: (id, cb) => {
if (cb) cb();
},
onRemoved: { addListener: () => {} },
},
tabs: {
query: (queryInfo, cb) => cb([{ id: 1 }]),
sendMessage: (tabId, message, cb) => {
if (cb) cb();
},
},
action: { setPopup: () => {} },
};
require("../src/background/index");
function send(msg, sender) {
let result = null;
messageListener(msg, sender, (r) => {
result = r;
});
return () => result;
}
return {
send,
constructed,
broadcast,
fromPopup: { url: EXT_URL + "src/popup/index.html" },
// The first message this worker ever sees, as the injected provider
// sends it.
sendTransaction: () =>
send(
{
type: "AUTISTMASK_RPC",
method: "eth_sendTransaction",
params: [TX_PARAMS],
},
{ origin: CONNECTED_ORIGIN },
),
approvalId: () => {
const url = createdUrls[createdUrls.length - 1];
return url
? new URL(url, EXT_URL).searchParams.get("approval")
: null;
},
};
}
// What the approval window does: fetch the approval and sign the transaction
// it was handed, exactly as given.
function signApproved(approvedTx) {
const tx = {};
for (const [key, value] of Object.entries(approvedTx)) {
if (key === "from") continue;
tx[key] = value;
}
return signer.signTransaction(tx);
}
describe("a dApp transaction prepared by a worker that never loaded state", () => {
test("a cold send on Sepolia reaches the approval screen and goes out", async () => {
const bg = loadColdWorker("sepolia");
const answer = bg.sendTransaction();
await settle();
// The provider was built for Sepolia, endpoint and static hint
// together. Omitting the hint made this mainnet.
expect(bg.constructed).toHaveLength(1);
expect(bg.constructed[0].url).toBe(SEPOLIA.defaultRpcUrl);
expect(bg.constructed[0].network.chainId).toBe(
Network.from("sepolia").chainId,
);
// So the approval the user is shown is a Sepolia transaction.
const id = bg.approvalId();
expect(id).toBeTruthy();
const approval = bg.send(
{ type: "AUTISTMASK_GET_APPROVAL", id },
{ url: bg.fromPopup.url },
)();
expect(approval.type).toBe("tx");
expect(approval.approvedTx.chainId).toBe(SEPOLIA.chainId);
// And it survives the wallet's own verification, which is where a
// 0x1-stamped artifact was refused as "for a different network".
const rawSignedTx = await signApproved(approval.approvedTx);
const response = bg.send(
{
type: "AUTISTMASK_TX_RESPONSE",
id,
approved: true,
rawSignedTx,
},
{ url: bg.fromPopup.url },
);
await settle();
expect(response()).toEqual({ txHash: TX_HASH });
expect(bg.broadcast).toEqual([rawSignedTx]);
expect(Number(Transaction.from(rawSignedTx).chainId)).toBe(
Number(SEPOLIA.networkVersion),
);
expect(answer()).toEqual({ result: TX_HASH });
});
test("a cold send on mainnet is prepared for mainnet", async () => {
// The stored value and the old fallback agree here, so this case
// cannot catch the defect; it is what keeps the fix from being a swap.
const bg = loadColdWorker("mainnet");
bg.sendTransaction();
await settle();
expect(bg.constructed[0].url).toBe(MAINNET.defaultRpcUrl);
const approval = bg.send(
{ type: "AUTISTMASK_GET_APPROVAL", id: bg.approvalId() },
{ url: bg.fromPopup.url },
)();
expect(approval.approvedTx.chainId).toBe(MAINNET.chainId);
});
});

View File

@@ -19,6 +19,14 @@ const {
balanceWarningHtml,
} = require("../src/popup/views/deleteAddress");
const { prices, clearPrices } = require("../src/shared/prices");
const { state } = require("../src/shared/state");
// The screen prices holdings, and pricing asks which chain it is on. Reading
// the singleton's network before anything loaded it now throws rather than
// answering mainnet by default
// (https://git.eeqj.de/sneak/AutistMask/issues/324), so the network this
// fixture is on is stated instead of assumed.
state.networkId = "mainnet";
const USDC = "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48";

View File

@@ -13,13 +13,15 @@
// never persisting it looks identical from `state`, and a build that never
// wrote at all would pass a check that only reads `state` back.
//
// That makes the storage stub load-bearing, so it is a real store that
// structured-clones on both `set` and `get`. A stub whose `get` hands back
// the same object its `set` was given aliases the caller's own array: the
// test then reads its own in-memory mutation and calls it persistence, and
// passes against a build that persists nothing (see issue #324). The
// aliasing is closed off explicitly by the first test below rather than
// left as an assumption about `structuredClone`.
// That makes the storage stub load-bearing, so it is the shared one from
// tests/support/storageStub.js, a real store that structured-clones on both
// `set` and `get`. A stub whose `get` hands back the same object its `set`
// was given aliases the caller's own array: the test then reads its own
// in-memory mutation and calls it persistence, and passes against a build
// that persists nothing
// (https://git.eeqj.de/sneak/AutistMask/issues/324). The aliasing is closed
// off explicitly by the first test below rather than left as an assumption
// about `structuredClone`.
//
// The view is driven against a minimal DOM stub, in the same shape as
// tests/exportPrivkey.test.js: the module reads and writes named nodes and
@@ -33,7 +35,8 @@ jest.mock("../src/shared/vault", () => ({
decryptWithPassword: jest.fn(),
}));
const { RESTORABLE_VIEWS } = require("../src/popup/restorableViews");
const { RESTORABLE_VIEWS } = require("../src/shared/restorableViews");
const { makeStorageStub } = require("./support/storageStub");
const VIEW = "delete-wallet-lost-password";
@@ -94,35 +97,6 @@ function makeDocument() {
};
}
// --------------------------------------------------------- storage stub
// A store that behaves the way `chrome.storage.local` does: what goes in is
// serialized, so the caller keeps no handle on what came to rest there, and
// what comes out is a fresh object the caller may mutate freely.
function makeStorage() {
let store = {};
return {
get: async (keys) => {
const wanted =
keys === undefined || keys === null
? Object.keys(store)
: [].concat(keys);
const out = {};
for (const key of wanted) {
if (key in store) out[key] = structuredClone(store[key]);
}
return out;
},
set: async (items) => {
for (const [key, value] of Object.entries(items)) {
store[key] = structuredClone(value);
}
},
// Test-only: what the extension would find on a cold start.
_raw: () => structuredClone(store),
};
}
// ------------------------------------------------------------ harness
function wallet(name, secret, addresses) {
@@ -144,10 +118,10 @@ function load() {
jest.resetModules();
mockSettingsShow.mockClear();
const storage = makeStorage();
const storage = makeStorageStub();
const sent = [];
globalThis.chrome = {
storage: { local: storage },
storage: { local: storage.local },
runtime: { sendMessage: (msg) => sent.push(msg) },
};
globalThis.document = makeDocument();
@@ -205,7 +179,7 @@ async function openLostPassword(deleteWallet, walletIdx) {
// every other test in this file against a build that never writes.
describe("the storage stub", () => {
test("does not hand back the object it was given", async () => {
const storage = makeStorage();
const storage = makeStorageStub();
const written = { wallets: [{ name: "Wallet 1" }] };
await storage.set({ autistmask: written });
@@ -393,8 +367,8 @@ describe("deleting without the password", () => {
// The deleted wallet's secret is gone from storage entirely, not
// merely unreferenced by the wallet list.
expect(JSON.stringify(storage._raw())).not.toContain("secret-two");
expect(JSON.stringify(storage._raw())).not.toContain("xpub-Wallet 2");
expect(JSON.stringify(storage.read())).not.toContain("secret-two");
expect(JSON.stringify(storage.read())).not.toContain("xpub-Wallet 2");
});
test("only the deleted wallet's site permissions are dropped", async () => {
@@ -462,7 +436,7 @@ describe("deleting without the password", () => {
expect(saved.activeAddress).toBeNull();
expect(saved.allowedSites).toEqual({});
expect(state.currentView).toBe("welcome");
expect(JSON.stringify(storage._raw())).not.toContain("secret-one");
expect(JSON.stringify(storage.read())).not.toContain("secret-one");
});
});

View File

@@ -1525,6 +1525,7 @@ async function goToConfirm(page, { token, balance, amount }) {
await page.fill("#send-amount", amount);
await page.click("#btn-send-review");
await visible(page, "#view-confirm-tx");
await assertAddressesFit(page, "the confirmation screen");
}
// A balance as the main view renders it: balanceLinesForAddress() writes
@@ -3262,6 +3263,8 @@ test("eth_sendTransaction signs the approved transaction and broadcasts it (#183
JSON.stringify(screen.data),
);
await assertAddressesFit(popup, "the dApp transaction prompt");
const broadcastBefore = env.routeOpts.broadcastTransactions.length;
await popup.fill("#approve-tx-password", PASSWORD);
await popup.click("#btn-approve-tx");
@@ -3425,6 +3428,206 @@ test("the password never crossed either boundary in this section (#183)", async
await env.dapp.close();
});
// ------------------------------------------- address layout (#380)
//
// "addresses should never wrap in the common views. this doesn't mean to
// just change the css, but update the layout itself so the untruncated
// addresses are shown in full and don't mess up the layout."
//
// Every one of these questions is about glyph advances and the width of
// the box an address landed in, and nothing in the markup answers any of
// them: a row can hold `white-space: nowrap` and still be too narrow, and
// the popup's own `overflow-x-hidden` would then hide the evidence by
// clipping the tail. So they are measured in a real Chromium, on the real
// rendered views, one assertion per property #380 names:
//
// - the whole address is there (42 characters, no ellipsis)
// - it occupies exactly one line box
// - it fits its row, so the overflow-x escape hatch never engages
// - its row ends inside the popup's content box
// - and the document itself does not scroll sideways
//
// The narrowest containers the popup has are covered here — the
// transaction detail wells (`bg-well p-3 mx-1`) and the token contract
// well — so the wider ones cannot fail while these pass.
// Everything on screen that carries an address, measured in one pass.
// Views other than the current one are display:none and measure zero, so
// filtering on width leaves exactly what a user can see right now.
function addressRowReport(page) {
return page.evaluate(() => {
const app = document.getElementById("app");
const appRight = app.getBoundingClientRect().right;
const rows = [];
for (const el of document.querySelectorAll(".am-address")) {
const box = el.getBoundingClientRect();
if (box.width === 0) continue;
// Line boxes are counted off the inline content, because the
// element's own rect is one box whether the text inside it
// wrapped or not. A Range yields a rect per contained node as
// well as per line, so it is the distinct tops that count:
// a copyable span and the text inside it share one.
const range = document.createRange();
range.selectNodeContents(el);
const tops = new Set(
Array.from(range.getClientRects()).map((r) =>
Math.round(r.top),
),
);
rows.push({
text: el.innerText.trim(),
lineBoxes: tops.size,
overflow: el.scrollWidth - el.clientWidth,
overhang: Math.round(box.right - appRight),
});
}
return {
rows,
pageOverflow:
document.documentElement.scrollWidth -
document.documentElement.clientWidth,
};
});
}
async function assertAddressesFit(page, where) {
const report = await addressRowReport(page);
assert(
report.rows.length > 0,
where + ": no address rows were rendered, so nothing was measured",
);
for (const row of report.rows) {
assert(
/^0x[0-9a-fA-F]{40}$/.test(row.text),
where +
": the address is not shown whole: " +
JSON.stringify(row.text),
);
assert(
row.lineBoxes === 1,
where +
": " +
row.text +
" wrapped onto " +
row.lineBoxes +
" lines",
);
assert(
row.overflow <= 1,
where +
": " +
row.text +
" is " +
row.overflow +
"px wider than the row holding it",
);
assert(
row.overhang <= 1,
where +
": " +
row.text +
" reaches " +
row.overhang +
"px past the popup's content box",
);
}
assert(
report.pageOverflow <= 0,
where + ": the popup scrolls sideways by " + report.pageOverflow + "px",
);
return report.rows.length;
}
// Back to Home from wherever the suite above finished, without assuming
// which screen that was. Every screen the popup can rest on has a Back
// button, and Home has none, so unwinding until Home shows is the one
// route that does not depend on the order of the tests before this point.
async function unwindToHome(page) {
for (let i = 0; i < 12; i++) {
if (await page.isVisible("#view-main")) return;
const back = page
.locator(".view:not(.hidden) button", { hasText: "Back" })
.first();
if ((await back.count()) === 0) break;
await back.click();
await page.waitForTimeout(150);
}
await visible(page, "#view-main");
}
// The reproduction from the issue: a wallet holding more than one address.
// Every address in the list is a full 42 characters competing with the
// [info] and [x] controls for one row's width, which is the state the
// wallet view was reported wrapping in.
test("a wallet with two addresses lists both in full, unwrapped (#380)", async (env) => {
await unwindToHome(env.page);
const before = await env.page
.locator("#wallet-list .btn-addr-info")
.count();
await env.page.locator("#wallet-list .btn-add-address").first().click();
await env.page.waitForFunction(
(n) =>
document.querySelectorAll("#wallet-list .btn-addr-info").length > n,
before,
{ timeout: 60000 },
);
const shown = await assertAddressesFit(env.page, "the wallet list");
assert(
shown >= before + 1,
"the wallet list measured " +
shown +
" addresses, fewer than the " +
(before + 1) +
" it now holds",
);
// The [x] control only exists on a wallet holding more than one
// address, so its presence is also the proof the second one landed.
const removable = await env.page
.locator("#wallet-list .btn-remove-address")
.count();
assert(removable > 0, "the second address did not reach the wallet list");
});
test("every common view shows its addresses in full on one line (#380)", async (env) => {
await unwindToHome(env.page);
await assertAddressesFit(env.page, "Home");
await env.page.locator("#wallet-list .btn-addr-info").first().click();
await visible(env.page, "#view-address");
await visible(env.page, "#tx-list .tx-row");
await assertAddressesFit(env.page, "the address screen");
await env.page.click("#btn-receive");
await visible(env.page, "#view-receive");
await assertAddressesFit(env.page, "the receive screen");
await env.page.click("#btn-receive-back");
await visible(env.page, "#view-address");
await env.page.click("#btn-send");
await visible(env.page, "#view-send");
await assertAddressesFit(env.page, "the send screen");
await env.page.click("#btn-send-back");
await visible(env.page, "#view-address");
// The transaction detail screen carries the narrowest address rows in
// the popup: its fields sit inside a well that takes another 24px of
// padding and 8px of margin off the content width, and the token
// contract row there is narrower still.
await env.page.locator("#address-balances .balance-row").first().click();
await visible(env.page, "#view-address-token");
await assertAddressesFit(env.page, "the token screen");
await env.page.click("#btn-address-token-back");
await visible(env.page, "#view-address");
await env.page.locator("#tx-list .tx-row").first().click();
await visible(env.page, "#view-transaction");
await visible(env.page, "#tx-detail-token-contract-section");
await assertAddressesFit(env.page, "the transaction detail screen");
});
// ---------------------------------------------------------------- runner
async function main() {

View File

@@ -21,7 +21,7 @@ jest.mock("../src/shared/wallet", () => ({
getSignerForAddress: jest.fn(() => ({ privateKey: mockPrivateKey })),
}));
const { RESTORABLE_VIEWS } = require("../src/popup/restorableViews");
const { RESTORABLE_VIEWS } = require("../src/shared/restorableViews");
const VIEW = "export-privkey";
const PASSWORD = "correct horse battery";

View File

@@ -0,0 +1,282 @@
// What the balance fetcher stores when the block explorer reports no decimals
// for a token, and what the approval screens then display.
//
// https://git.eeqj.de/sneak/AutistMask/issues/349: `fetchTokenBalances()` did
// `parseInt(item.token.decimals || "18", 10)` BEFORE writing the row, so a
// token whose `decimals()` reverts — and which the explorer therefore reports
// no scale for — was stored with a fabricated 18. Nothing downstream could
// tell that from a real 18.
//
// That matters because it is upstream of two refusals that were already built
// and already merged. https://git.eeqj.de/sneak/AutistMask/issues/306 made the
// ERC-20 amount line resolve the real scale or refuse to format, and
// https://git.eeqj.de/sneak/AutistMask/issues/340 did the same for the swap
// lines. Both read this stored value as an authoritative source, so the guess
// walked straight past them: the refusal was intact and simply never fired.
//
// So these tests run a real explorer response through the real fetcher and
// assert on the real approval screens. A test that hand-writes `decimals: null`
// onto state would pass on the broken build, because the fabrication is in the
// writer, not the readers.
jest.mock("../src/shared/log", () => ({
log: {
debugf: () => {},
infof: () => {},
warnf: () => {},
errorf: () => {},
},
debugFetch: jest.fn(),
setRuntimeDebug: () => {},
isDebug: () => false,
}));
global.fetch = jest.fn(() => {
throw new Error("tests must not perform network requests");
});
const { makeStorageStub } = require("./support/storageStub");
global.chrome = { storage: makeStorageStub() };
const { AbiCoder, Interface } = require("ethers");
const { ERC20_ABI } = require("../src/shared/constants");
const { fetchTokenBalances } = require("../src/shared/balances");
const { debugFetch } = require("../src/shared/log");
const { state } = require("../src/shared/state");
const { unknownDecimalsAmount } = require("../src/shared/approvalAmount");
const { decodeCalldata } = require("../src/popup/views/approval");
const { TOKEN_BY_ADDRESS } = require("../src/shared/tokenList");
const HOLDER = "0x" + "a".repeat(40);
const BLOCKSCOUT = "https://blockscout.example/api/v2";
const ROUTER = "0x66a9893cc07d91d95644aedd05d03f95e1dba8af";
const RECIPIENT = "0xC0FfEE0000000000000000000000000000c0fFEe";
const SPENDER = "0x1111111111111111111111111111111111111111";
// Outside the bundled list and untracked, so the explorer is the only source
// of a scale for it — which is the case the fabrication was hiding.
const NOVEL = "0xE2E0000000000000000000000000000000000E2e";
// In the bundled list, at 18 decimals, for the other side of a swap.
const WETH = "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2";
// The holding the explorer reports, in base units. Large enough that it does
// not round to zero even when divided by 10^18, which is what makes it the
// case the laundering actually REACHED: a smaller holding formatted at the
// fabricated 18 comes out "0.0", the balance list drops the row as dust, and
// the approval screens then find no source for the scale and refuse anyway —
// for the wrong reason, and only by luck.
const HOLDING = 5000000000000000000n;
// The amount in the dApp's calldata, which is a separate number from the
// holding. 1,000.00 of a 6-decimal token; formatted at the fabricated 18 it
// reads 0.000000001, and at a real scale of 0 it reads 1000000000.
const THOUSAND_AT_SIX = 1000000000n;
const HALF_WETH = 500000000000000000n;
const erc20Iface = new Interface(ERC20_ABI);
const coder = AbiCoder.defaultAbiCoder();
const routerIface = new Interface([
"function execute(bytes commands, bytes[] inputs, uint256 deadline)",
]);
// One Blockscout token-balances row. `token` is spread last so a test can
// override or blank a field; the base row carries no `decimals` at all, which
// is exactly what a token whose decimals() reverts produces.
function row(token = {}, value = HOLDING) {
return {
value: String(value),
token: {
type: "ERC-20",
address_hash: NOVEL,
symbol: "NOVEL",
name: "Novel Token",
// Well clear of the balance list's own spam floor, so the row is
// admitted on its holder count alone: neither the bundled list nor
// a tracked entry can supply a scale for it.
holders_count: "50000",
...token,
},
};
}
function respondWith(items) {
debugFetch.mockImplementation(async () => ({
ok: true,
status: 200,
statusText: "OK",
json: async () => items,
}));
}
// Fetch and place the result exactly where refreshBalances() places it, so the
// approval screens read what a real refresh would have left on state.
async function fetchOnto(items, trackedTokens = []) {
respondWith(items);
const balances = await fetchTokenBalances(
HOLDER,
BLOCKSCOUT,
trackedTokens,
);
state.trackedTokens = trackedTokens;
state.wallets = [
{
name: "Wallet 1",
addresses: [
{ address: HOLDER, balance: "1.0", tokenBalances: balances },
],
},
];
return balances;
}
// The ERC-20 approval screen's Amount line, and the swap decoder's.
function erc20AmountLine(data, tokenAddress) {
return decodeCalldata(data, tokenAddress).details.find(
(d) => d.label === "Amount",
).value;
}
function swapAmountLine(data) {
return decodeCalldata(data, ROUTER).details.find(
(d) => d.label === "Amount",
).value;
}
function transferData(amount) {
return erc20Iface.encodeFunctionData("transfer", [RECIPIENT, amount]);
}
function approveData(amount) {
return erc20Iface.encodeFunctionData("approve", [SPENDER, amount]);
}
function swapData(tokenIn, amountIn, tokenOut, amountOutMin) {
const input = coder.encode(
["address", "uint256", "uint256", "address[]", "bool"],
[RECIPIENT, amountIn, amountOutMin, [tokenIn, tokenOut], true],
);
return routerIface.encodeFunctionData("execute", [
"0x08",
[input],
9999999999n,
]);
}
beforeEach(() => {
debugFetch.mockReset();
state.trackedTokens = [];
state.wallets = [];
});
describe("what fetchTokenBalances stores for an absent scale", () => {
test("the token is not in the bundled list, so the explorer is the only source", () => {
expect(TOKEN_BY_ADDRESS.has(NOVEL.toLowerCase())).toBe(false);
});
test("an absent decimals is stored as null, not as 18", async () => {
const balances = await fetchOnto([row()]);
expect(balances).toHaveLength(1);
expect(balances[0].decimals).toBeNull();
});
test("an explicit null decimals is stored as null too", async () => {
const balances = await fetchOnto([row({ decimals: null })]);
expect(balances[0].decimals).toBeNull();
});
// The same explorer row twice, differing only in whether it reports a
// scale of 18. Before the fix both stored 18 and no reader could tell
// which one had actually been reported.
test("a real 18 is stored as 18, and so is distinguishable from absent", async () => {
const real = await fetchOnto([row({ decimals: "18" })]);
expect(real[0].decimals).toBe(18);
expect(real[0].balance).toBe("5.0");
const absent = await fetchOnto([row()]);
expect(absent[0].decimals).toBeNull();
expect(real[0].decimals).not.toBe(absent[0].decimals);
});
// The falsy-collapse trap of
// https://git.eeqj.de/sneak/AutistMask/issues/246. `decimals || "18"` reads
// a real scale of zero as absent and then as eighteen, which is eighteen
// orders of magnitude of error in the direction that displays as nothing.
test("a real scale of zero is stored as zero, not collapsed", async () => {
for (const reported of ["0", 0]) {
const balances = await fetchOnto([row({ decimals: reported })]);
expect(balances[0].decimals).toBe(0);
expect(balances[0].balance).toBe("5000000000000000000.0");
}
});
test("no quantity is stated for a holding whose scale is unknown", async () => {
const balances = await fetchOnto([row()]);
// Not "0.0": the holding is real and nonzero, and a zero here is the
// same lie the approval screens refuse to tell.
expect(balances[0].balance).toBeNull();
});
// Zero base units is zero tokens at every scale, so this filter never
// needed a scale in the first place and does not acquire one now.
test("a holding of zero base units is still dropped without a scale", async () => {
expect(await fetchOnto([row({}, 0n)])).toEqual([]);
});
test("the bundled list still supplies a quantity the explorer omitted", async () => {
const balances = await fetchOnto([
row({ address_hash: WETH, symbol: "WETH" }),
]);
// The stored decimals stay the explorer's own answer — absent. Copying
// another source in here would make explorerDecimals()'s disagreement
// check compare something other than explorer values.
expect(balances[0].decimals).toBeNull();
// The displayed quantity still comes out right, because the bundled
// list knows this token's scale and outranks the explorer anyway.
expect(balances[0].balance).toBe("5.0");
});
});
describe("the ERC-20 approval line reaches its refusal", () => {
test("a transfer of a token the explorer gave no scale for is not formatted", async () => {
await fetchOnto([row()]);
const line = erc20AmountLine(transferData(THOUSAND_AT_SIX), NOVEL);
expect(line).toBe(unknownDecimalsAmount(THOUSAND_AT_SIX));
// The defect: a fabricated 18 renders this as 0.000000001, a quantity,
// and a wrong one.
expect(line).not.toMatch(/^0\./);
});
test("an approve of the same token is not formatted either", async () => {
await fetchOnto([row()]);
const line = erc20AmountLine(approveData(THOUSAND_AT_SIX), NOVEL);
expect(line).toBe(unknownDecimalsAmount(THOUSAND_AT_SIX));
expect(line).not.toMatch(/^0\./);
});
test("a scale the explorer did report still formats", async () => {
await fetchOnto([row({ decimals: "6" })]);
expect(erc20AmountLine(transferData(THOUSAND_AT_SIX), NOVEL)).toBe(
"1000.0000",
);
});
});
describe("the swap approval line reaches its refusal", () => {
test("a swap of a token the explorer gave no scale for is not formatted", async () => {
await fetchOnto([row()]);
const line = swapAmountLine(
swapData(NOVEL, THOUSAND_AT_SIX, WETH, HALF_WETH),
);
expect(line).toBe(unknownDecimalsAmount(THOUSAND_AT_SIX));
expect(line).not.toMatch(/^0\./);
});
test("a scale the explorer did report still formats", async () => {
await fetchOnto([row({ decimals: "6" })]);
expect(
swapAmountLine(swapData(NOVEL, THOUSAND_AT_SIX, WETH, HALF_WETH)),
).toBe("1000.0000");
});
});
test("no test in this file performed a network request", () => {
expect(global.fetch).not.toHaveBeenCalled();
});

View File

@@ -47,6 +47,12 @@ const fs = require("fs");
const path = require("path");
const MANIFEST_DIR = path.join(__dirname, "..", "manifest");
const ROOT = path.join(__dirname, "..");
// The sizes both stores and both toolbars ask for.
const EXPECTED_ICON_SIZES = ["16", "32", "48", "128"];
const PNG_SIGNATURE = Buffer.from("89504e470d0a1a0a", "hex");
const EXPECTED_DIRECTIVES = {
"default-src": ["'self'"],
@@ -115,6 +121,51 @@ function assertPolicy(policy) {
}
}
// The declared icons, in both manifests.
//
// Without an "icons" block a browser draws a generic puzzle piece in the
// toolbar for this extension, which is both the first thing the user sees and
// how a real extension is told apart from a look-alike. Declaring one is not
// enough on its own: an entry naming a file that is not in the tree ships a
// reference to nothing, so the referenced bytes are read here and required to
// be a PNG of the size the entry claims. build.js copies these into each
// browser directory, relative to it, and script/lib/package.js then refuses to
// build an archive that does not contain everything the manifest names.
function assertIcons(target) {
const icons = readManifest(target).icons;
expect(Object.keys(icons).sort()).toEqual(EXPECTED_ICON_SIZES.sort());
for (const size of EXPECTED_ICON_SIZES) {
const ref = icons[size];
expect([size, ref]).toEqual([size, `icons/icon${size}.png`]);
const bytes = fs.readFileSync(path.join(ROOT, ref));
expect(bytes.subarray(0, 8)).toEqual(PNG_SIGNATURE);
// IHDR width and height, at fixed offsets right after the signature
// and the chunk header.
expect([ref, bytes.readUInt32BE(16), bytes.readUInt32BE(20)]).toEqual([
ref,
Number(size),
Number(size),
]);
}
}
describe("declared icons", () => {
test("chrome declares real icons at every size", () => {
assertIcons("chrome");
});
test("firefox declares real icons at every size", () => {
assertIcons("firefox");
});
test("both targets declare the same icons", () => {
expect(readManifest("firefox").icons).toEqual(
readManifest("chrome").icons,
);
});
});
describe("shipped Content Security Policy", () => {
// MV3 takes an object and applies extension_pages to the popup and the
// background service worker, which is where libsodium runs.

View File

@@ -10,6 +10,7 @@
// happened.
const { networkById } = require("../src/shared/networks");
const { makeStorageStub } = require("./support/storageStub");
const ADDRESS = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
@@ -34,25 +35,19 @@ function walletFixture() {
// saveState() wrote — so a case can reload a fresh module from the bytes an
// earlier one persisted, which is what an extension restart does. `state` is
// a module-level singleton, so the registry has to be reset per load.
// The stub clones in both directions, as the real chrome.storage.local does.
// It used to alias, and written() then handed the NEXT module load the live
// in-memory object of the previous one as its "persisted bytes" — an extension
// restart that never crossed a serialization boundary. See
// tests/support/storageStub.js.
function loadModuleWith(persisted) {
jest.resetModules();
let written = null;
global.chrome = {
storage: {
local: {
get: jest.fn(async () =>
persisted ? { autistmask: persisted } : {},
),
set: jest.fn(async (items) => {
written = items.autistmask;
}),
},
},
};
const storage = makeStorageStub(persisted ? { autistmask: persisted } : {});
global.chrome = { storage };
return {
mod: require("../src/shared/state"),
chainSwitch: require("../src/shared/chainSwitch"),
written: () => written,
written: () => storage.read("autistmask"),
};
}

View File

@@ -90,6 +90,26 @@ describe("archive self-containment", () => {
);
});
// Toolbar icons are the other file the manifest names and no bundler
// emits, so an archive built without them would carry a manifest whose
// "icons" resolve to nothing and a browser would fall back to a generic
// placeholder without saying so.
test("a manifest naming an icon that is not in the archive fails", () => {
const { members, read } = archiveOf({
"manifest.json": JSON.stringify({
...MINIMAL_MANIFEST,
icons: { 16: "icons/icon16.png", 128: "icons/icon128.png" },
}),
"src/popup/index.html": "<html></html>",
"src/popup/index.js": "//",
"src/background/index.js": "//",
"icons/icon16.png": "PNG",
});
expect(() => checkSelfContained("chrome", members, read)).toThrow(
/would not be self-contained.*icons\/icon128\.png/s,
);
});
test("an archive with no manifest.json at its root fails", () => {
const { members, read } = archiveOf({ "src/popup/index.js": "//" });
expect(() => checkSelfContained("chrome", members, read)).toThrow(

View File

@@ -0,0 +1,404 @@
// A persisted container that is checked while its ENTRIES are dereferenced
// unchecked (https://git.eeqj.de/sneak/AutistMask/issues/362).
//
// https://git.eeqj.de/sneak/AutistMask/issues/311 settled the idiom — floor the
// container AND its entries, dropping anything that cannot be safely
// dereferenced — and applied it to trackedTokens and tokenBalances. These are
// the fields it did not reach.
//
// allowedSites is the worst shape in the codebase, and it is what the boot
// tests below measure: a stored `{"0x…": "notalist"}` passes the gate, renders
// a WORKING popup, and then throws `base.map is not a function` inside
// saveState()'s merge — so every save from then on fails while the UI looks
// entirely healthy and the user goes on operating a wallet that is persisting
// nothing. A blank popup is at least visibly broken; this is not. So the
// assertion here is never merely "the popup rendered": it is "the popup
// rendered AND the write actually landed in storage".
//
// Observed at ad6aa7b, with the floors below removed:
// allowedSites: {"0x…": "notalist"} -> views=["main"], errors=[], and
// storage.set NEVER called: the stored record kept no schemaVersion, so
// nothing the user did was persisted.
// fraudContracts: "0x…" -> renderSendTokenSelect() threw
// "(state.fraudContracts || []).map is not a function"
// fraudContracts: [42] -> threw "a.toLowerCase is not a
// function"
// selectedToken: 42 (restoring onto address-token) -> views=[], errors=
// ["tokenId.toLowerCase is not a function"] — a blank popup.
const { normalizePersisted } = require("../src/shared/persistedState");
const { makeStorageStub } = require("./support/storageStub");
const {
bootPopup,
cleanupPopup,
unversionedValidProfile,
ADDRESS,
TOKEN_ADDRESS,
} = require("./support/popupBoot");
// One extension page: a fresh module registry over the given storage. state.js
// resolves the storage API at require time, so the stub has to be installed
// before the module is loaded.
function loadStateModule(storage) {
jest.resetModules();
globalThis.chrome = { storage: { local: storage.local } };
return require("../src/shared/state");
}
afterEach(() => {
cleanupPopup();
});
// ------------------------------------------------------- the floor itself
describe("the floor under allowedSites and deniedSites", () => {
for (const field of ["allowedSites", "deniedSites"]) {
test(`${field} that is not a record becomes an empty record`, () => {
for (const bad of ["nope", 42, true, [ADDRESS], null]) {
expect(normalizePersisted({ [field]: bad })[field]).toEqual({});
}
});
test(`an ${field} entry whose value is not a hostname list is dropped`, () => {
for (const bad of ["dapp.example", 42, null, { a: 1 }, true]) {
expect(
normalizePersisted({ [field]: { [ADDRESS]: bad } })[field],
).toEqual({});
}
});
test(`a hostname that is not text is dropped from an ${field} entry`, () => {
expect(
normalizePersisted({
[field]: { [ADDRESS]: [42, null, "dapp.example", {}] },
})[field],
).toEqual({ [ADDRESS]: ["dapp.example"] });
});
test(`a real ${field} map survives, copied not shared`, () => {
const saved = { [field]: { [ADDRESS]: ["dapp.example"] } };
const out = normalizePersisted(saved);
expect(out[field]).toEqual(saved[field]);
expect(out[field]).not.toBe(saved[field]);
expect(out[field][ADDRESS]).not.toBe(saved[field][ADDRESS]);
});
test(`a good ${field} entry beside a malformed one survives`, () => {
const out = normalizePersisted({
[field]: { [ADDRESS]: ["dapp.example"], [TOKEN_ADDRESS]: 42 },
});
expect(out[field]).toEqual({ [ADDRESS]: ["dapp.example"] });
});
test(`a stored own "__proto__" key in ${field} is dropped`, () => {
// JSON can carry the key. It can never be a wallet address, so it
// grants and denies nothing and goes the way of every other key
// whose value is unusable; keeping it would only keep a value that
// saveState()'s merge hands to the prototype setter on the next
// write.
const saved = JSON.parse(
'{"' + field + '":{"__proto__":["evil.invalid"]}}',
);
const out = normalizePersisted(saved);
expect(Object.getPrototypeOf(out[field])).toBe(Object.prototype);
expect(Object.keys(out[field])).toEqual([]);
});
}
});
describe('a stored own "__proto__" key surviving a save', () => {
// The floor writes map keys with defineProperty; saveState()'s merge is one
// layer downstream of it and used to write them with plain assignment,
// which hands "__proto__" to the prototype setter and records no entry.
// networkEndpoints is where a key that is not a known id is deliberately
// KEPT, so it is where that undoing shows.
test("does not move a prototype or vanish from networkEndpoints", async () => {
const profile = unversionedValidProfile();
profile.networkEndpoints = JSON.parse(
'{"__proto__":{"rpcUrl":"https://kept.invalid"}}',
);
const storage = makeStorageStub({ autistmask: profile });
const { state, loadState, saveState } = loadStateModule(storage);
await loadState();
state.theme = "dark";
await saveState();
// Not compared against Object.prototype by identity: the storage stub
// clones through structuredClone, which builds the result in the host
// realm, so the two Object.prototypes are different objects.
const stored = storage.read("autistmask").networkEndpoints;
expect(Object.getPrototypeOf(stored).rpcUrl).toBeUndefined();
expect(Object.keys(stored)).toContain("__proto__");
});
});
describe("the floor under fraudContracts", () => {
test("fraudContracts that is not a list becomes an empty list", () => {
for (const bad of ["nope", 42, true, { a: 1 }]) {
expect(
normalizePersisted({ fraudContracts: bad }).fraudContracts,
).toEqual([]);
}
});
test("a fraudContracts entry that is not text is dropped", () => {
expect(
normalizePersisted({
fraudContracts: [42, null, TOKEN_ADDRESS, {}, []],
}).fraudContracts,
).toEqual([TOKEN_ADDRESS]);
});
test("a real fraudContracts list survives, copied not shared", () => {
const saved = { fraudContracts: [TOKEN_ADDRESS] };
const out = normalizePersisted(saved);
expect(out.fraudContracts).toEqual(saved.fraudContracts);
expect(out.fraudContracts).not.toBe(saved.fraudContracts);
});
});
describe("the floor under selectedToken", () => {
// Found by the sweep for this defect class, not named in the issue: the
// restore gate in src/popup/viewRouter.js checks truthiness only, and both
// src/popup/views/addressToken.js and src/popup/views/receive.js then
// dereference it as text.
test("a selectedToken that is not text becomes null", () => {
for (const bad of [42, true, { a: 1 }, [TOKEN_ADDRESS]]) {
expect(
normalizePersisted({ selectedToken: bad }).selectedToken,
).toBeNull();
}
});
test("a real selectedToken survives; the empty string becomes null", () => {
expect(
normalizePersisted({ selectedToken: TOKEN_ADDRESS }).selectedToken,
).toBe(TOKEN_ADDRESS);
expect(normalizePersisted({ selectedToken: "ETH" }).selectedToken).toBe(
"ETH",
);
expect(
normalizePersisted({ selectedToken: "" }).selectedToken,
).toBeNull();
});
});
// ----------------------------------------- what the user actually gets
describe("a malformed allowedSites entry", () => {
const MALFORMED = [
{ name: "a string", value: "notalist" },
{ name: "a number", value: 42 },
{ name: "a record", value: { hostnames: ["dapp.example"] } },
];
for (const { name, value } of MALFORMED) {
test(`whose value is ${name}: a working popup whose writes persist`, async () => {
const env = await bootPopup(
unversionedValidProfile({
allowedSites: { [ADDRESS]: value },
}),
);
expect({
visibleViews: env.visibleViews(),
errors: env.pageErrors,
}).toEqual({ visibleViews: ["main"], errors: [] });
// The half that matters. A popup that renders and never persists
// again is worse than one that renders nothing, because nothing
// tells the user. The version stamp is proof a write landed: it
// is absent from the stored record until saveState() writes one.
expect(env.storage.set).toHaveBeenCalled();
const stored = env.storage.read("autistmask");
expect(stored.schemaVersion).toBe(1);
expect(stored.wallets[0].encryptedSecret).toBe(
"encrypted-secret-1",
);
expect(stored.allowedSites).toEqual({});
});
}
test("the well-formed entries beside it keep working", async () => {
const env = await bootPopup(
unversionedValidProfile({
allowedSites: {
[ADDRESS]: ["dapp.example"],
[TOKEN_ADDRESS]: "notalist",
},
}),
);
expect(env.pageErrors).toEqual([]);
expect(env.storage.read("autistmask").allowedSites).toEqual({
[ADDRESS]: ["dapp.example"],
});
});
test("a later save still lands, not just the first", async () => {
// The failure this closes was in the MERGE, which runs on every save
// against whatever is in storage at the time. One write landing is not
// enough: the field has to stay mergeable.
const storage = makeStorageStub({
autistmask: unversionedValidProfile({
allowedSites: { [ADDRESS]: "notalist" },
}),
});
const { state, loadState, saveState } = loadStateModule(storage);
await loadState();
state.theme = "dark";
await saveState();
state.utcTimestamps = true;
await saveState();
const stored = storage.read("autistmask");
expect(stored.theme).toBe("dark");
expect(stored.utcTimestamps).toBe(true);
expect(stored.allowedSites).toEqual({});
expect(stored.wallets[0].encryptedSecret).toBe("encrypted-secret-1");
});
});
describe("a malformed fraudContracts", () => {
// The send screen, which is where this one lands: the boot path only
// reaches fraudContracts through loadHomeTxs(), which catches, so the
// consequence is an unusable send screen rather than silent data loss.
function stubSendDocument() {
const select = { innerHTML: "", children: [] };
select.appendChild = (child) => select.children.push(child);
globalThis.document = {
getElementById: (id) => (id === "send-token" ? select : null),
createElement: () => ({ value: "", textContent: "" }),
};
return select;
}
const HELD = {
address: TOKEN_ADDRESS,
symbol: "AAA",
decimals: 18,
balance: "12.5",
holders: 50000,
};
async function sendScreenTokens(fraudContracts) {
const storage = makeStorageStub({
autistmask: unversionedValidProfile({ fraudContracts }),
});
const { loadState } = loadStateModule(storage);
await loadState();
const select = stubSendDocument();
const { renderSendTokenSelect } = require("../src/popup/views/send");
renderSendTokenSelect({ address: ADDRESS, tokenBalances: [HELD] });
return select.children.map((opt) => opt.value);
}
for (const bad of ["notalist", 42, { a: 1 }, [42], [null], [{}]]) {
test(`${JSON.stringify(bad)}: a usable send screen`, async () => {
await expect(sendScreenTokens(bad)).resolves.toEqual([
TOKEN_ADDRESS,
]);
});
}
test("a real fraud entry beside a malformed one still hides its token", async () => {
await expect(
sendScreenTokens([42, TOKEN_ADDRESS.toLowerCase()]),
).resolves.toEqual([]);
});
});
describe("a malformed selectedToken", () => {
test("does not blank the popup on restore", async () => {
const env = await bootPopup(
unversionedValidProfile({
currentView: "address-token",
selectedWallet: 0,
selectedAddress: 0,
selectedToken: 42,
viewStack: ["main", "address"],
}),
);
expect({
visibleViews: env.visibleViews(),
errors: env.pageErrors,
}).toEqual({ visibleViews: ["main"], errors: [] });
});
});
// --------------------------------------------- a save that fails is told
describe("a save that fails", () => {
function failingStorage(profile) {
const storage = makeStorageStub({ autistmask: profile });
const realSet = storage.local.set;
storage.local.set = jest.fn(async () => {
throw new Error("QUOTA_BYTES quota exceeded");
});
storage.restoreWrites = () => {
storage.local.set = realSet;
};
return storage;
}
test("is reported, not swallowed by the save queue", async () => {
const storage = failingStorage(unversionedValidProfile());
const { state, loadState, saveState, onSaveFailure } =
loadStateModule(storage);
const failures = [];
onSaveFailure((e) => failures.push(String(e && e.message)));
await loadState();
state.theme = "dark";
// Not awaited, which is how showView() saves on every navigation and
// how the failure used to disappear entirely.
saveState();
for (let i = 0; i < 50; i++) await Promise.resolve();
expect(failures).toEqual(["QUOTA_BYTES quota exceeded"]);
});
test("still rejects for a caller that awaits it", async () => {
const storage = failingStorage(unversionedValidProfile());
const { state, loadState, saveState, onSaveFailure } =
loadStateModule(storage);
onSaveFailure(() => {});
await loadState();
state.theme = "dark";
await expect(saveState()).rejects.toThrow("QUOTA_BYTES");
});
test("puts a banner on the popup saying nothing is being saved", async () => {
const env = await bootPopup(undefined, {
storage: failingStorage(unversionedValidProfile()),
});
// The popup is still usable — the point is that it no longer looks
// healthy while silently persisting nothing.
expect(env.visibleViews()).toEqual(["main"]);
const banner = env.node("save-failure-banner");
expect(banner).not.toBeNull();
expect(banner.textContent).toContain("NOT SAVED");
expect(banner.textContent).toContain("QUOTA_BYTES quota exceeded");
});
test("no banner appears on a popup whose saves work", async () => {
const env = await bootPopup(unversionedValidProfile());
expect(env.node("save-failure-banner")).toBeNull();
});
});

View File

@@ -0,0 +1,864 @@
// What the floor under each persisted field actually guarantees — as a table
// that RUNS, one row per field.
//
// This file replaces a hand-written per-field justification in the header of
// src/shared/stateSchema.js. That comment shipped a false claim in three
// consecutive changes: every author wrote plausible prose about thirty fields,
// every reviewer re-derived it by hand, and it kept being wrong in a different
// place each time. The artifact was the problem. A claim nobody can execute is
// worse than no claim, because it is believed.
//
// So the claim is a row here instead:
//
// KIND.REFUSED assertStateUsable() refuses the record outright. Proven by
// stateProblem() naming a problem for every hostile value.
// KIND.ENTRIES normalizePersisted() floors the container AND its entries.
// Proven by holds() over the normalized value.
// KIND.SCALAR normalizePersisted() floors it to one scalar type, or to a
// fixed fallback. Proven the same way.
// KIND.LOOSE `saved.x || default`, no type check at all. The claim is
// that no structural dereference of it is reachable from a
// stored record — which cannot be argued, only driven, so the
// proof is a boot of the REAL popup entry point over a stored
// record carrying the hostile value, ONTO EVERY RESTORABLE
// VIEW. Home is not where this class of defect lives.
//
// Every row is driven through a boot regardless of kind, but only a LOOSE row
// (or a row that sets `alsoSweep`) is swept across the restore path: that is
// what declaring LOOSE costs. ENTRIES and SCALAR rows are proven by their
// holds() instead, because a floored value is not hostile by the time a
// renderer sees it. A LOOSE row must additionally prove it is loose: if
// someone floors the field — even partially — and leaves the row saying LOOSE,
// the "survives verbatim" assertion fails. A field added to PERSISTED_FIELDS
// with no row fails the first test in the file.
//
// The sweep is what makes a LOOSE row falsifiable, so read how it is driven
// before trusting it. A row the ROUTER reads (`routes`) gets its own boot per
// view, because a hostile value in it legitimately changes which view renders.
// Every other swept field is corrupted on the SAME boot, one boot per view per
// slot, and that boot has to land on the view it stored — so a field that does
// move the routing cannot hide in the crowd. Every swept field is driven at
// BOTH POLARITIES: a value nothing in src/ writes is a wrong-typed one and so
// always truthy, which leaves `if (!state.x) { state.y.deref() }` unentered on
// the very boot that corrupts x. The last slot is the falsy one for that
// reason, and a field that cannot be falsy after the floor says so in its row
// and is proven so.
//
// READ THE CLAIM NARROWLY. What this file proves is: NO STRUCTURAL
// DEREFERENCE ON THE CODE PATHS A WHOLLY-CORRUPTED PROFILE TAKES. That is not
// every path a stored record takes, and the difference is the whole of what
// this file does not cover:
//
// - Only the values in the table, in the SLOT arrangement below: four value
// combinations per view, not the product of twelve fields. A dereference
// reached only under a pairing no slot produces is not driven at all.
// - Only what a stored record reaches by ITSELF. A view only forward
// navigation opens, and anything behind a click, is not driven.
// - Nothing about the paths a HEALTHY profile takes, which is most of the
// popup. This file is a floor under one defect class, not a proof about
// the renderers.
//
// Within that boundary it is unconditional: if one of these boots leaves the
// popup unhealthy or off the view it stored, this file goes red — including
// when it takes two corrupted fields at once, because the verdict is the
// combined boot itself and the per-field re-boot below can only decorate the
// message. That last part is the one thing an earlier version got wrong: it
// asserted on the per-field list, so an observed dead popup that no single
// field reproduced was reported green.
//
// Booting every field separately at every value would be several hundred boots
// and most of the suite's budget; this is forty-four. Widening it further is
// out of scope — proving no field is dereferenced on any reachable render path
// is exhaustive verification of the popup, not a floor under a stored record.
//
// The three claims this replaced, all false, all caught here by construction:
// rpcUrl reaching `new JsonRpcProvider()` (a synchronous throw, not a caught
// request); viewData's ENTRIES being dereferenced by four restore branches
// that gate on one truthy field each; and selectedWallet, where a stale
// integer index is the SAFE case and `wallets["map"]` is the throwing one.
const {
PERSISTED_FIELDS,
normalizePersisted,
} = require("../src/shared/persistedState");
const { stateProblem } = require("../src/shared/stateSchema");
const { RESTORABLE_VIEWS } = require("../src/shared/restorableViews");
const {
bootPopup,
cleanupPopup,
unversionedValidProfile,
ADDRESS,
TOKEN_ADDRESS,
} = require("./support/popupBoot");
const KIND = {
REFUSED: "refused by the gate",
ENTRIES: "container and entries type-checked",
SCALAR: "scalar type-checked",
LOOSE: "loosely floored; safety proven by driving the popup",
};
const isText = (v) => typeof v === "string";
const isRecord = (v) =>
typeof v === "object" && v !== null && !Array.isArray(v);
const isIndexOrNull = (v) => v === null || (Number.isInteger(v) && v >= 0);
const isTextOrNull = (v) => v === null || (isText(v) && v !== "");
const everyEntry = (v, fn) => Array.isArray(v) && v.every(fn);
// A row is SWEPT — driven onto every restorable view rather than only onto
// Home — when its claim is that no restore path dereferences the field. That
// is what LOOSE means. The two index rows opt in with `alsoSweep` although
// they are floored, because the restore path is precisely why they gained a
// floor and the sweep is the regression guard on it.
const swept = (row) => row.kind === KIND.LOOSE || Boolean(row.alsoSweep);
// Every value a swept row drives through a boot: the hostile set, plus the
// falsy slot that gives the field its other polarity. `hostile` values are all
// TRUTHY by nature — a value nothing in src/ writes is a wrong-typed one, and
// wrong-typed values are objects, non-empty strings and non-zero numbers. A
// field that is only ever truthy on the boot that corrupts it cannot falsify
// `if (!state.x) { state.y.deref() }`, so the falsy slot is not optional.
const sweptValues = (row) => [...row.hostile, ...(row.falsy || [])];
// ------------------------------------------------------------------ the table
//
// `hostile` is values a stored record can carry that nothing in src/ ever
// writes. Each one is driven through the floor AND through a real popup boot —
// and, for a swept row, through one boot per restorable view — so keep the
// list short and pointed. `floorOnly` is extra values checked against the
// floor alone, which is pure and free. `hostileRestore` is extra values driven
// through the restore path only, for a value that means nothing until a
// particular branch's gate has let it past.
//
// `falsy` is the other POLARITY of a swept field, driven for the same reason.
// It is not a value src/ never writes — for three of these fields it is the
// DEFAULT_STATE default, which is the branch every ordinary install takes —
// and that is the point: without it, a dereference behind `if (!state.x)` is
// unreachable on the one boot that corrupts x. A swept row that cannot supply
// one says `neverFalsy` instead, which is proven rather than asserted: every
// falsy value stored under that field comes back TRUTHY from the floor, so no
// `!state.x` branch is reachable from a stored record at all.
const CONTRACT = [
{
field: "wallets",
kind: KIND.REFUSED,
hostile: [42, "notastructure", { a: 1 }, [null], [{ addresses: 1 }]],
},
{
field: "networkId",
kind: KIND.REFUSED,
hostile: [42, "notanetwork", { a: 1 }, "__proto__"],
},
{
field: "trackedTokens",
kind: KIND.ENTRIES,
hostile: [42, "notalist", { a: 1 }],
floorOnly: [[1, 2], [null], [{}], [[TOKEN_ADDRESS]]],
holds: (v) => everyEntry(v, (t) => isRecord(t) && isText(t.address)),
},
{
field: "allowedSites",
kind: KIND.ENTRIES,
hostile: [42, "notarecord", { [ADDRESS]: "notalist" }],
floorOnly: [
[ADDRESS],
{ [ADDRESS]: 42 },
{ [ADDRESS]: [42, null, {}] },
JSON.parse('{"__proto__":["evil.invalid"]}'),
],
holds: siteMapHolds,
},
{
field: "deniedSites",
kind: KIND.ENTRIES,
hostile: [42, "notarecord", { [ADDRESS]: "notalist" }],
floorOnly: [
[ADDRESS],
{ [ADDRESS]: 42 },
{ [ADDRESS]: [42, null, {}] },
JSON.parse('{"__proto__":["evil.invalid"]}'),
],
holds: siteMapHolds,
},
{
field: "fraudContracts",
kind: KIND.ENTRIES,
hostile: [42, "notalist", { a: 1 }],
floorOnly: [[42], [null], [{}], [[TOKEN_ADDRESS]]],
holds: (v) => everyEntry(v, isText),
},
{
field: "viewStack",
kind: KIND.ENTRIES,
hostile: [42, "notalist", ["main", "show-phrase", "settings"]],
floorOnly: [[1, 2], [null], [{}], ["export-privkey"]],
// Truncated at the first entry the popup will not reopen onto, rather
// than filtered: every surviving entry's Back target has to stay the
// one it had. restorableStack() may also substitute ["main"] under a
// view restored below the root, so this is the one ENTRIES field whose
// result is not always a subset of what was stored.
holds: (v) => everyEntry(v, (e) => RESTORABLE_VIEWS.has(e)),
},
{
field: "networkEndpoints",
kind: KIND.ENTRIES,
hostile: [42, "notarecord", { mainnet: "notapair" }],
floorOnly: [
[1, 2],
{ mainnet: { rpcUrl: 42, blockscoutUrl: {} } },
{ mainnet: { rpcUrl: "", blockscoutUrl: [] } },
{ sepolia: 42 },
],
// Entries are coerced rather than dropped: an unknown network id is
// KEPT, so a profile that has been on a build with more networks does
// not lose their endpoints here. What is floored is the two URL fields
// inside the pair, which applyChainSwitchFields() assigns straight onto
// s.rpcUrl / s.blockscoutUrl on the next switch.
holds: (v) =>
isRecord(v) &&
Object.keys(v).every((id) => {
const pair = v[id];
return (
isRecord(pair) &&
(pair.rpcUrl === undefined ||
(isText(pair.rpcUrl) && pair.rpcUrl !== "")) &&
(pair.blockscoutUrl === undefined ||
(isText(pair.blockscoutUrl) &&
pair.blockscoutUrl !== ""))
);
}),
},
{
field: "rpcUrl",
kind: KIND.SCALAR,
hostile: [42, true, { a: 1 }],
floorOnly: [[], "", null],
holds: (v) => isText(v) && v !== "",
// The claim this row replaced said a bad value "fails the request on a
// path that already catches". It does not: getProvider() hands rpcUrl
// to `new JsonRpcProvider()`, which throws SYNCHRONOUSLY, from two call
// sites outside any try — and a stored `currentView: "wait-tx"` reaches
// one of them through restoreView(). So the row proves the claim
// against the real constructor rather than describing it.
alsoProven: (normalized, hostile) => {
// requireActual: bootPopup() mocks this module out for the boots
// above, and a mocked getProvider() would prove nothing at all
// about the constructor this row is a claim about.
const { getProvider } = jest.requireActual(
"../src/shared/balances",
);
expect(() => getProvider(hostile, "mainnet")).toThrow();
const provider = getProvider(normalized, "mainnet");
expect(provider).toBeTruthy();
provider.destroy();
},
},
{
field: "blockscoutUrl",
kind: KIND.SCALAR,
hostile: [42, true, { a: 1 }],
floorOnly: [[], "", null],
holds: (v) => isText(v) && v !== "",
},
{
field: "activeAddress",
kind: KIND.SCALAR,
hostile: [42, true, { a: 1 }],
floorOnly: [[ADDRESS], ""],
holds: isTextOrNull,
},
{
field: "selectedToken",
kind: KIND.SCALAR,
hostile: [42, true, { a: 1 }],
floorOnly: [[TOKEN_ADDRESS], ""],
holds: isTextOrNull,
},
{
field: "selectedWallet",
kind: KIND.SCALAR,
// The prototype members are the whole point: `wallets["map"]` is
// TRUTHY, so hasValidAddress()'s `&&` does not short-circuit and
// `.addresses[…]` throws. A stale INTEGER is the safe case.
hostile: ["map", "__proto__", { a: 1 }],
floorOnly: ["length", "constructor", "toString", "0", -1, 1.5, true],
holds: isIndexOrNull,
// SCALAR, and swept anyway: the restore path is precisely why this
// field gained a floor, so the sweep is the regression guard on it.
alsoSweep: true,
routes: true,
// A stale INTEGER index, which reaches the restore path by a different
// route from the prototype members above — falsy or out of range
// rather than truthy — and has to keep being the safe case.
hostileRestore: [{ value: "length" }, { value: 5 }],
},
{
field: "selectedAddress",
kind: KIND.SCALAR,
hostile: ["map", "__proto__", { a: 1 }],
floorOnly: ["length", "constructor", "toString", "0", -1, 1.5, true],
holds: isIndexOrNull,
alsoSweep: true,
routes: true,
hostileRestore: [{ value: 5 }],
},
{
field: "currentView",
kind: KIND.LOOSE,
routes: true,
// Compared, and concatenated into the debug banner's textContent
// (src/popup/views/helpers.js) with no gate in front of it, which
// coerces. Nothing renders FROM it without RESTORABLE_VIEWS.has()
// first, and Set.has() answers false for any value.
hostile: [42, "no-such-view", { a: 1 }],
// `saved.currentView || null`: the falsy polarity is the popup landing
// on Home, which every boot in "booting onto Home" below also drives.
falsy: [""],
},
{
field: "viewData",
kind: KIND.LOOSE,
routes: true,
// The container is taken verbatim; what makes its ENTRIES safe is the
// per-branch guard in src/popup/viewRouter.js. The sweep drives the
// container shapes below onto every restorable view; hostileRestore
// adds the records that PASS a branch's gate and then hand its
// renderer something it dereferences, which is where the entries are
// actually decided.
hostile: [42, "notarecord", { a: 1 }, [1, 2]],
// `structuredClone(saved.viewData || {})`: the container is never falsy
// in state whatever was stored, so no `!state.viewData` branch exists to
// drive.
neverFalsy: true,
hostileRestore: [
// success-tx passes on `data.hash`, and renderSuccess() then calls
// toAddressHtml(d.to) -> addressTitle() -> address.toLowerCase().
{ value: { hash: "0x1" }, views: ["success-tx"] },
{ value: { hash: "0x1", to: 42 }, views: ["success-tx"] },
{
value: { hash: "0x1", to: ADDRESS, decoded: { details: 7 } },
views: ["success-tx"],
},
{
value: {
hash: "0x1",
to: ADDRESS,
decoded: { details: [{ address: 42 }] },
},
views: ["success-tx"],
},
// error-tx passes on `data.message`, same dereference.
{ value: { message: "boom" }, views: ["error-tx"] },
{ value: { message: "boom", to: 42 }, views: ["error-tx"] },
// transaction passes on `data.tx`.
{ value: { tx: { hash: "0x1" } }, views: ["transaction"] },
{
value: {
tx: {
hash: "0x1",
from: ADDRESS,
to: ADDRESS,
contractAddress: 42,
},
},
views: ["transaction"],
},
// confirm-tx passes on `data.pendingTx`.
{ value: { pendingTx: { amount: "1" } }, views: ["confirm-tx"] },
{
value: {
pendingTx: {
token: 42,
from: ADDRESS,
to: ADDRESS,
amount: "1",
},
},
views: ["confirm-tx"],
},
// wait-tx passes on `pendingWait.hash`; restoreWait() has checked
// the fields below it since it was written, and this is the
// regression guard.
{
value: {
pendingWait: {
hash: "0x1",
txInfo: { to: 42, amount: "1" },
},
},
views: ["wait-tx"],
},
// A record that passes EVERY branch's gate at once, driven onto
// every restorable view: a branch a view does not read must stay
// one it does not read, and each renderer must survive the fields
// another branch left behind.
{
value: {
hash: "0x1",
message: "boom",
tx: { hash: "0x1" },
pendingTx: { amount: "1" },
pendingWait: { hash: "0x1" },
},
},
],
},
{
field: "lastBalanceRefresh",
kind: KIND.LOOSE,
// Arithmetic only: `now - (s.lastBalanceRefresh || 0)` compares false
// for a non-number and forces a refresh.
hostile: [true, "notatime", { a: 1 }],
// `|| 0` collapses every falsy stored value to 0, so 0 IS the whole
// falsy polarity of this field — and it is the DEFAULT_STATE default,
// the value a profile carries until its first refresh lands.
falsy: [0],
},
{
field: "tokenHolderCache",
kind: KIND.LOOSE,
// Nothing DEREFERENCES it structurally. It is read by the
// field-agnostic snapshotPersisted()/deepEqual() in
// src/shared/state.js, which are safe for any value, and otherwise
// only reset wholesale in src/shared/chainSwitchFields.js.
hostile: [42, "notarecord", [1, 2]],
// `structuredClone(saved.tokenHolderCache || {})`.
neverFalsy: true,
},
{
field: "theme",
kind: KIND.LOOSE,
// Compared against "dark"/"light" in applyTheme() and otherwise falls
// to the system branch; assigned into an input .value, which coerces.
hostile: [42, "chartreuse", { a: 1 }],
// `saved.theme || "system"`.
neverFalsy: true,
},
{
field: "dustThresholdGwei",
kind: KIND.LOOSE,
hostile: ["notanumber", true, { a: 1 }],
// Survives verbatim, so the falsy slot is also wrong-typed: "" reaches
// filterTransactions() as a comparand and a settings input .value.
falsy: [""],
},
...[
"rememberSiteChoice",
"showZeroBalanceTokens",
"hideSpoofedSymbols",
"hideLowHolderTokens",
"hideFraudContracts",
"hideDustTransactions",
"utcTimestamps",
"debugMode",
].map((field) => ({
field,
kind: KIND.LOOSE,
// A flag: only ever tested for truthiness, and written back verbatim.
hostile: [42, "notabool", { a: 1 }],
// Both answers to that truthiness test have to be driven, and 0 is a
// value src/ never writes for a flag. For utcTimestamps and debugMode
// the falsy answer is also the DEFAULT_STATE default.
falsy: [0],
})),
];
function siteMapHolds(v) {
return (
isRecord(v) &&
Object.getPrototypeOf(v) === Object.prototype &&
!Object.prototype.hasOwnProperty.call(v, "__proto__") &&
Object.keys(v).every((key) => everyEntry(v[key], isText))
);
}
afterEach(() => {
cleanupPopup();
});
// -------------------------------------------------------------- exhaustive
describe("the contract covers the record", () => {
test("every persisted field has exactly one row, and no row invents one", () => {
const rows = CONTRACT.map((row) => row.field);
expect([...rows].sort()).toEqual([...PERSISTED_FIELDS].sort());
});
test("every row declares a kind this file knows how to prove", () => {
const kinds = Object.values(KIND);
for (const row of CONTRACT) {
expect(kinds).toContain(row.kind);
expect(row.hostile.length).toBeGreaterThan(0);
}
});
});
// ------------------------------------------------------------- the floors
function profileWith(field, value) {
return unversionedValidProfile({ [field]: value });
}
describe("the floor each row claims", () => {
// The falsy slot is deliberately NOT in here. `saved.x || default` is a
// floor on falsy values and on nothing else, so a falsy value is the one
// thing a LOOSE field need not carry through verbatim; what it has to carry
// through is being falsy, which "both polarities" below asserts.
for (const row of CONTRACT) {
const values = [...row.hostile, ...(row.floorOnly || [])];
if (row.kind === KIND.REFUSED) {
test(`${row.field}: the gate refuses it`, () => {
for (const value of values) {
expect(
typeof stateProblem(profileWith(row.field, value)),
).toBe("string");
}
});
continue;
}
test(`${row.field}: ${row.kind}`, () => {
for (const value of values) {
const out = normalizePersisted(profileWith(row.field, value));
if (row.kind === KIND.LOOSE) {
// The claim IS that there is no floor. A field that grows
// one has to move to another kind rather than keep a row
// saying its readers are what make it safe.
continue;
}
expect({
value: value,
holds: row.holds(out[row.field]),
}).toEqual({ value: value, holds: true });
}
});
if (row.kind === KIND.LOOSE) {
test(`${row.field}: is genuinely unfloored`, () => {
// EVERY value, not some: a PARTIAL floor is still a floor, and
// a row that keeps saying LOOSE because one hostile value out
// of three still survives is exactly the stale claim this file
// exists to stop.
for (const value of values) {
const out = normalizePersisted(
profileWith(row.field, value),
);
expect({
value: value,
survived: JSON.stringify(out[row.field]),
}).toEqual({
value: value,
survived: JSON.stringify(value),
});
}
});
}
}
});
// --------------------------------------------- driving the real popup boot
// A booted popup is healthy when nothing threw out of init() and something is
// on screen. A throw out of restoreView() is neither: init() does not guard it,
// so the rest of popup init never runs and the user gets a popup with no view,
// no message and no control on it.
async function bootHealth(profile) {
const env = await bootPopup(profile);
return {
errors: env.pageErrors,
blank: env.visibleViews().length === 0,
};
}
const HEALTHY = { errors: [], blank: false };
// unversionedValidProfile() stores no currentView, so every boot in here lands
// on Home. That is the cheap half of the proof; the restore path below is the
// half that matters.
// Both polarities of every swept field are driven, or the field is proven
// unable to take one of them. This is the guard on the sweep itself: a hostile
// set is all-truthy by construction, so without a falsy slot a dereference
// behind `if (!state.x)` is never reached on the boot that corrupts x — the
// same falsy-collapse blind spot the fields below were floored for.
describe("both polarities of every swept field are driven", () => {
const FALSY_STORED = [0, "", false, null];
const floored = (field, value) =>
normalizePersisted(profileWith(field, value))[field];
for (const row of CONTRACT) {
if (!swept(row)) continue;
if (row.neverFalsy) {
test(`${row.field}: cannot be falsy in state at all`, () => {
for (const value of FALSY_STORED) {
expect({
stored: value,
truthy: Boolean(floored(row.field, value)),
}).toEqual({ stored: value, truthy: true });
}
});
continue;
}
test(`${row.field}: truthy and falsy`, () => {
// What the boots below actually drive, floored the way a renderer
// sees it — not what the row says it drives.
const driven = [
...sweptValues(row),
...(row.hostileRestore || []).map((entry) => entry.value),
].map((value) => floored(row.field, value));
expect({
truthy: driven.some((value) => Boolean(value)),
falsy: driven.some((value) => !value),
}).toEqual({ truthy: true, falsy: true });
});
}
});
describe("a hostile value for one field, booting onto Home", () => {
for (const row of CONTRACT) {
for (const value of sweptValues(row)) {
test(`${row.field} = ${JSON.stringify(value)}`, async () => {
await expect(
bootHealth(profileWith(row.field, value)),
).resolves.toEqual(HEALTHY);
});
}
}
});
describe("a row's extra proof against the real reader", () => {
for (const row of CONTRACT) {
if (!row.alsoProven) continue;
test(row.field, () => {
for (const value of row.hostile) {
const out = normalizePersisted(profileWith(row.field, value));
row.alsoProven(out[row.field], value);
}
});
}
});
// ------------------------------------------------ driving the restore path
// Everything above lands on Home. Home is not where this class of defect
// lives: all three of the false claims this file replaced were falsified by a
// RESTORE, through the unguarded restoreView() in src/popup/index.js. So a
// swept row's hostile values are driven onto EVERY restorable view, one boot
// each.
//
// This is what makes a LOOSE row falsifiable. A field that gains a structural
// dereference on any restorable view — `state.theme.toLowerCase()` in a view's
// show(), say — turns the row red here, instead of waiting for a reviewer to
// re-derive the claim by hand.
// restoreWait() resumes from this, so it has to be a finite number and recent
// enough that the resumed deadline has not already passed — a wait that has
// outlived its deadline resolves on the first poll instead of staying on
// screen. Read once at module load, so every boot in one run shares it.
const BROADCAST_TIME = Date.now();
// A viewData well formed for every restorable branch at once, so the only
// thing a swept boot can fail on is the field the row corrupts. "the base
// profile the sweep corrupts" below proves this really does render each view
// rather than falling back — without that, a sweep could pass by never
// reaching a renderer at all.
const WELL_FORMED_DATA = {
hash: "0x1",
message: "boom",
to: ADDRESS,
decoded: { details: [{ address: TOKEN_ADDRESS }] },
tx: { hash: "0x1", from: ADDRESS, to: ADDRESS, contractAddress: null },
pendingTx: {
token: "ETH",
from: ADDRESS,
to: ADDRESS,
amount: "1",
balance: "2",
},
pendingWait: {
hash: "0x1",
txInfo: { to: ADDRESS, amount: "1" },
broadcastTime: BROADCAST_TIME,
},
};
function restoringOnto(view, extra) {
return unversionedValidProfile({
currentView: view,
selectedWallet: 0,
selectedAddress: 0,
selectedToken: TOKEN_ADDRESS,
viewStack: ["main"],
viewData: WELL_FORMED_DATA,
...extra,
});
}
// A boot that RESTORED is healthy and landed on the view it stored, rather
// than falling back to Home — which a healthy boot also does, and which would
// let a sweep pass by never running the renderer it is aimed at.
async function restoredHealth(profile, view) {
const env = await bootPopup(profile);
return {
errors: env.pageErrors,
restored: env.visibleViews().includes(view),
};
}
const RESTORED = { errors: [], restored: true };
describe("the base profile the sweep corrupts", () => {
for (const view of RESTORABLE_VIEWS) {
test(`renders ${view} rather than falling back`, async () => {
await expect(
restoredHealth(restoringOnto(view), view),
).resolves.toEqual(RESTORED);
});
}
});
// A field the ROUTER itself reads — the two it gates on and the two
// hasValidAddress() indexes with. A hostile value in one of these legitimately
// changes which view renders, so each gets its own boot per view and is held
// only to "healthy", not to "restored onto the view it stored".
const routes = (row) => Boolean(row.routes);
// Every routing row × every hostile value × every restorable view. Profiles
// are deduplicated because a hostile `currentView` REPLACES the view being
// restored onto, which would otherwise be the same boot eleven times.
describe("a hostile routing value restoring onto", () => {
for (const row of CONTRACT) {
if (!swept(row) || !routes(row)) continue;
const seen = new Set();
for (const value of sweptValues(row)) {
for (const view of RESTORABLE_VIEWS) {
const profile = restoringOnto(view, { [row.field]: value });
const key = JSON.stringify(profile);
if (seen.has(key)) continue;
seen.add(key);
test(`${view}: ${row.field} = ${JSON.stringify(
value,
)}`, async () => {
await expect(bootHealth(profile)).resolves.toEqual(HEALTHY);
});
}
}
}
});
// Every OTHER swept field, corrupted at once, one boot per view per hostile
// slot: twelve fields on one boot rather than twelve boots. A field is only in
// here because it is not one the router reads — and that is ASSERTED, not
// argued, because the boot has to land on `view`. A field that does move the
// routing turns this red and has to declare `routes` and take the individual
// sweep above.
//
// Combining hides one thing, and the last slot is what stops it. A hostile
// value is wrong-typed and therefore TRUTHY, so on a boot where every swept
// field is hostile, no `if (!state.x)` branch is entered — and a dereference
// inside such a branch would go unseen however loudly it throws. The last slot
// is the falsy one: every swept field that CAN be falsy is falsy on it, which
// is also the state an ordinary install boots in for three of them, while the
// fields that cannot be falsy stay hostile-truthy. That makes it a MIX, and a
// deliberate one — the interaction between a falsy flag and a still-hostile
// theme is a shape a stored record really produces.
//
// The verdict is the combined boot, always. When it goes red the same view is
// re-booted one field at a time, so the failure NAMES a culprit instead of
// leaving a reader to bisect twelve fields — but that loop only decorates the
// message. It cannot clear the failure. A dereference that needs two corrupted
// fields at once is reproduced by neither field alone, and a version of this
// file that asserted on the named list reported exactly that case green while
// watching the popup die.
const UNROUTED = CONTRACT.filter((row) => swept(row) && !routes(row));
const HOSTILE_SLOTS = Math.max(
...UNROUTED.map((row) => sweptValues(row).length),
);
function unroutedValues(slot) {
const fields = {};
for (const row of UNROUTED) {
const values = sweptValues(row);
fields[row.field] = values[slot % values.length];
}
return fields;
}
describe("every field the router does not read, corrupted at once, onto", () => {
for (const view of RESTORABLE_VIEWS) {
for (let slot = 0; slot < HOSTILE_SLOTS; slot++) {
test(`${view}: hostile value ${slot + 1} in all ${
UNROUTED.length
} of them`, async () => {
const fields = unroutedValues(slot);
const together = await restoredHealth(
restoringOnto(view, fields),
view,
);
// The per-field re-boot only DECORATES the message. The
// verdict is `together`, unconditionally: a dereference that
// needs two corrupted fields at once is reproduced by NEITHER
// field alone, so an assertion on the named list would report
// an observed dead popup as green.
const named = [];
if (together.errors.length > 0 || !together.restored) {
for (const row of UNROUTED) {
const one = await restoredHealth(
restoringOnto(view, {
[row.field]: fields[row.field],
}),
view,
);
if (one.errors.length === 0 && one.restored) continue;
named.push(
`${row.field}=${JSON.stringify(
fields[row.field],
)}: ` +
(one.errors.join("; ") || `fell off ${view}`),
);
}
if (named.length === 0) {
named.push(
"no single field reproduces it; it takes two or " +
`more of ${JSON.stringify(fields)}`,
);
}
}
expect({
view: view,
together: together,
fields: named,
}).toEqual({ view: view, together: RESTORED, fields: [] });
});
}
}
});
// The values that only mean something on the restore path: a viewData that
// PASSES a branch's gate and then hands its renderer something dereferenced,
// and the index values whose route through hasValidAddress() differs from the
// row's own hostile set.
describe("a restore-only hostile value onto", () => {
for (const row of CONTRACT) {
for (const entry of row.hostileRestore || []) {
for (const view of entry.views || RESTORABLE_VIEWS) {
test(`${view}: ${row.field} = ${JSON.stringify(
entry.value,
)}`, async () => {
await expect(
bootHealth(
restoringOnto(view, { [row.field]: entry.value }),
),
).resolves.toEqual(HEALTHY);
});
}
}
}
});

View File

@@ -35,6 +35,11 @@ const POPUP_HTML_PATH = path.join(POPUP_DIR, "index.html");
const RUNTIME_CREATED_IDS = new Set([
// Created by updateDebugBanner() in src/popup/views/helpers.js.
"debug-banner",
// Created by showSaveFailureBanner() in the same file, on the first save
// that fails. Absent from the markup on purpose: a popup where nothing has
// failed must not have to carry an empty banner
// (https://git.eeqj.de/sneak/AutistMask/issues/362).
"save-failure-banner",
]);
// Every id lookup the popup performs with a literal argument, as

View File

@@ -9,6 +9,8 @@
const fs = require("fs");
const path = require("path");
const { makeStorageStub } = require("./support/storageStub");
const POPUP_HTML = fs.readFileSync(
path.join(__dirname, "..", "src", "popup", "index.html"),
"utf8",
@@ -51,19 +53,17 @@ describe("the UTC Timestamps checkbox placement", () => {
});
describe("the UTC Timestamps setting round-trips through storage", () => {
let store;
let storage;
// The stub clones in both directions, as the real chrome.storage.local
// does. It used to alias, which is fatal to a round-trip test in
// particular: the object the module holds and the object "storage" holds
// are then the same object, so the setting appears to have been persisted
// and read back on a build where neither happened. See
// tests/support/storageStub.js.
function loadStateModule() {
store = {};
global.chrome = {
storage: {
local: {
get: async (key) =>
key in store ? { [key]: store[key] } : {},
set: async (obj) => Object.assign(store, obj),
},
},
};
storage = makeStorageStub();
global.chrome = { storage };
jest.resetModules();
return require("../src/shared/state");
}
@@ -86,12 +86,18 @@ describe("the UTC Timestamps setting round-trips through storage", () => {
// What the change handler in views/settings.js does.
first.state.utcTimestamps = true;
await first.saveState();
expect(store.autistmask.utcTimestamps).toBe(true);
expect(storage.read("autistmask").utcTimestamps).toBe(true);
// A fresh popup load sees it.
// A fresh popup load sees it — and, before that load, refuses to
// answer at all rather than reporting the default. That refusal is
// what makes the assertion below evidence of a read from storage
// instead of a value that was already sitting in memory
// (https://git.eeqj.de/sneak/AutistMask/issues/324).
jest.resetModules();
const second = require("../src/shared/state");
expect(second.state.utcTimestamps).toBe(false);
expect(() => second.state.utcTimestamps).toThrow(
second.StateNotLoadedError,
);
await second.loadState();
expect(second.state.utcTimestamps).toBe(true);
});

View File

@@ -12,7 +12,7 @@ const fs = require("fs");
const path = require("path");
const { walletHasRecoveryPhrase } = require("../src/shared/wallet");
const { RESTORABLE_VIEWS } = require("../src/popup/restorableViews");
const { RESTORABLE_VIEWS } = require("../src/shared/restorableViews");
const SHOW_PHRASE_VIEW = "show-phrase";

View File

@@ -1,26 +1,37 @@
const ADDRESS = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
// Address RECORDS, not bare address strings. A stored profile is validated
// against the schema on every read now (src/shared/stateSchema.js), and a bare
// string where an address record belongs is one of the shapes that refuses to
// load — as it should, since every screen dereferences addr.address.
function oneWallet() {
return [{ name: "Wallet 1", type: "hd", addresses: [ADDRESS] }];
return [
{
name: "Wallet 1",
type: "hd",
addresses: [{ address: ADDRESS, balance: "0", tokenBalances: [] }],
},
];
}
const { makeStorageStub } = require("./support/storageStub");
// state.js resolves the storage API at require time, so the stub has to exist
// before the module is loaded, and the module registry has to be reset between
// cases because `state` is a module-level singleton.
//
// The stub clones in both directions, as the real chrome.storage.local does —
// see tests/support/storageStub.js for why an aliasing one made this file
// assert less than it appears to.
function loadModuleWith(persisted) {
jest.resetModules();
const set = jest.fn(async () => {});
global.chrome = {
storage: {
local: {
get: jest.fn(async () =>
persisted ? { autistmask: persisted } : {},
),
set,
},
},
const storage = makeStorageStub(persisted ? { autistmask: persisted } : {});
global.chrome = { storage };
return {
mod: require("../src/shared/state"),
set: storage.set,
stored: () => storage.read("autistmask"),
};
return { mod: require("../src/shared/state"), set };
}
afterEach(() => {

View File

@@ -10,32 +10,12 @@
//
// Both cases below drive the real state.js module through two independent
// module registries sharing one storage backend, the way two real extension
// pages share one chrome.storage.local. The storage stub structured-clones
// on both get and set — a stub that hands back the object it was given
// aliases the caller's own mutation and would make this entire defect class
// invisible (see https://git.eeqj.de/sneak/AutistMask/issues/324).
// pages share one chrome.storage.local. The shared stub structured-clones on
// both get and set — a stub that hands back the object it was given aliases
// the caller's own mutation and would make this entire defect class invisible
// (see https://git.eeqj.de/sneak/AutistMask/issues/324).
function makeStorage() {
let store = {};
return {
get: async (keys) => {
const wanted =
keys === undefined || keys === null
? Object.keys(store)
: [].concat(keys);
const out = {};
for (const key of wanted) {
if (key in store) out[key] = structuredClone(store[key]);
}
return out;
},
set: async (items) => {
for (const [key, value] of Object.entries(items)) {
store[key] = structuredClone(value);
}
},
};
}
const { makeStorageStub } = require("./support/storageStub");
// One extension page: a fresh module registry over the shared storage.
// state.js resolves the storage API at require time, so the stub has to be
@@ -43,7 +23,7 @@ function makeStorage() {
// singleton, so each page needs its own registry to hold its own copy.
function loadPage(storage) {
jest.resetModules();
globalThis.chrome = { storage: { local: storage } };
globalThis.chrome = { storage: { local: storage.local } };
return {
state: require("../src/shared/state"),
helpers: require("../src/popup/views/helpers"),
@@ -118,7 +98,7 @@ describe("a save from a page that never saw a wallet another page added", () =>
// a save from a second page loaded before that wallet existed. Both
// wallets must survive.
test("both wallets are in storage afterwards", async () => {
const storage = makeStorage();
const storage = makeStorageStub();
await storage.set({ autistmask: { wallets: [W1] } });
// Loaded while storage held only Wallet 1, and never reloads —
@@ -171,7 +151,7 @@ describe("the approval-window reproduction", () => {
test("the wallet added in the popup survives confirming the approval", async () => {
globalThis.document = makeDocument();
const storage = makeStorage();
const storage = makeStorageStub();
await storage.set({ autistmask: { wallets: [W1] } });
// The background opens the approval window on the approve-tx
@@ -223,7 +203,7 @@ describe("the approval-window reproduction", () => {
// membership" collided as the same field.
describe("background refresh racing a wallet added on another page", () => {
test("the wallet added elsewhere survives background's stale balance save", async () => {
const storage = makeStorage();
const storage = makeStorageStub();
await storage.set({ autistmask: { wallets: [W1] } });
// "background": loads first, and its save is the one that lands
@@ -263,7 +243,7 @@ describe("background refresh racing a wallet added on another page", () => {
describe("background refresh racing a wallet deleted on another page", () => {
test("the wallet deleted elsewhere stays deleted after background's stale balance save", async () => {
const storage = makeStorage();
const storage = makeStorageStub();
await storage.set({ autistmask: { wallets: [W1, W2] } });
const background = loadPage(storage);
@@ -324,7 +304,7 @@ function revokeSite(pageState, hostname) {
describe("a dApp approval racing a stale Settings page's later save", () => {
test("the fresh approval survives Settings revoking an unrelated site", async () => {
const storage = makeStorage();
const storage = makeStorageStub();
await storage.set({
autistmask: {
wallets: [W1],
@@ -362,7 +342,7 @@ describe("a dApp approval racing a stale Settings page's later save", () => {
describe("a revoked site permission against a stale page's later save", () => {
test("the revocation holds even when the stale page approves something else", async () => {
const storage = makeStorage();
const storage = makeStorageStub();
await storage.set({
autistmask: {
wallets: [W1],
@@ -413,7 +393,7 @@ function legacyWallet(name, secret) {
describe("two wallets independently created with a colliding identity", () => {
test("both survive, encryptedSecret included, instead of one silently replacing the other", async () => {
const storage = makeStorage();
const storage = makeStorageStub();
await storage.set({ autistmask: { wallets: [W1] } });
// Both pages load before either has created their malformed wallet,

367
tests/stateRecovery.test.js Normal file
View File

@@ -0,0 +1,367 @@
// A stored profile the popup cannot read must produce a SCREEN, not a blank
// popup (https://git.eeqj.de/sneak/AutistMask/issues/311).
//
// The three corrupt blobs below are the ones the pre-1.0 audit wrote into
// storage. Against the build this file was added to, each one rendered nothing
// at all — no view, no message, no control — because init() dereferenced
// `state.wallets[0].addresses` on a record nothing had validated and threw
// before the first showView().
//
// So the assertions here are deliberately made through the REAL popup entry
// point rather than against the recovery view module directly. A recovery
// screen that renders perfectly when something calls it, and that nothing
// calls, is exactly the defect: what has to be true is that BOOTING the popup
// on a bad blob lands on it.
//
// The boot harness and its DOM stub — built FROM src/popup/index.html, so
// "which views are visible" is answered against the real element set — live in
// tests/support/popupBoot.js, since tests/persistedEntryFloors.test.js needs
// the same boot.
//
// The fourth case is the upgrade one, and it is the case that must NOT reach
// the recovery screen: every install in the field has a valid profile with no
// version field, and showing those users a wipe prompt would be a worse defect
// than the one being fixed. It is migrated in place and keeps working.
const {
bootPopup,
cleanupPopup,
unversionedValidProfile,
ADDRESS,
TOKEN_ADDRESS,
} = require("./support/popupBoot");
// ------------------------------------------------------------- fixtures
// The three blobs from the issue, each with the error it produced.
const CORRUPT_BLOBS = [
{
name: "wallets is a string",
blob: { hasWallet: true, wallets: ADDRESS, activeAddress: ADDRESS },
},
{
name: "wallets is an array of garbage",
blob: {
hasWallet: true,
wallets: [null, 42, "wallet"],
activeAddress: ADDRESS,
},
},
{
name: "future-schema blob (unknown fields, no version)",
blob: {
hasWallet: true,
// A later schema that renamed the field and moved the key
// material, written by a build this one knows nothing about, and
// stamped with no version because this build never wrote one.
wallets: [
{
id: "wallet-1",
label: "Wallet 1",
accounts: [{ addr: ADDRESS, wei: "0x0" }],
keyring: { kind: "hd", vault: "…" },
},
],
profileFormat: "am-2",
activeAccount: ADDRESS,
},
},
];
afterEach(cleanupPopup);
// --------------------------------------------------------------- tests
describe("a stored profile the popup cannot read", () => {
for (const { name, blob } of CORRUPT_BLOBS) {
test(`${name}: the recovery screen, not a blank popup`, async () => {
const env = await bootPopup(blob);
// Asserted together, and in the audit's own shape: a failure here
// prints both what was on screen and what the console said, which
// is the pair that identifies this defect.
expect({
visibleViews: env.visibleViews(),
errors: env.pageErrors,
}).toEqual({ visibleViews: ["state-recovery"], errors: [] });
// The screen has to NAME the problem. A blank recovery screen is
// the same dead end with a border around it.
expect(env.text("state-recovery-problem").length).toBeGreaterThan(
10,
);
});
}
test("the Settings gear is hidden, since every screen behind it reads the profile", async () => {
const env = await bootPopup(CORRUPT_BLOBS[0].blob);
expect(env.hidden("btn-settings")).toBe(true);
});
test("it does not write over the record it could not read", async () => {
// The blob is evidence, and possibly the only copy of key material in
// a shape a later build could recover. A boot that normalized it back
// into storage would destroy exactly that.
const env = await bootPopup(CORRUPT_BLOBS[2].blob);
expect(env.storage.read("autistmask")).toEqual(CORRUPT_BLOBS[2].blob);
expect(env.storage.set).not.toHaveBeenCalled();
});
});
describe("the export on the recovery screen", () => {
test("hands back the raw stored record verbatim", async () => {
const env = await bootPopup(CORRUPT_BLOBS[2].blob);
await env.click("btn-state-recovery-export");
// Shown in the page, which always works, whatever the browser does
// with a download from an extension popup.
expect(env.hidden("state-recovery-blob")).toBe(false);
expect(JSON.parse(env.value("state-recovery-blob"))).toEqual(
CORRUPT_BLOBS[2].blob,
);
});
});
describe("the destructive reset on the recovery screen", () => {
test("erases nothing without the typed confirmation", async () => {
const env = await bootPopup(CORRUPT_BLOBS[0].blob);
env.node("state-recovery-reset-input").value = "yes";
await env.click("btn-state-recovery-reset");
expect(env.storage.read("autistmask")).toEqual(CORRUPT_BLOBS[0].blob);
expect(env.text("state-recovery-flash").length).toBeGreaterThan(10);
expect(env.reloaded()).toBe(0);
});
test("erases the stored profile once the phrase is typed", async () => {
const env = await bootPopup(CORRUPT_BLOBS[0].blob);
env.node("state-recovery-reset-input").value = "erase my wallet";
await env.click("btn-state-recovery-reset");
expect(env.storage.read("autistmask")).toBeUndefined();
expect(env.reloaded()).toBe(1);
});
});
describe("an unversioned profile that is perfectly valid", () => {
// The upgrade case. Every install in the field is in this state, and the
// popup must load it, not offer to wipe it.
test("boots to the wallet list, not the recovery screen", async () => {
const env = await bootPopup(unversionedValidProfile());
expect(env.visibleViews()).toEqual(["main"]);
expect(env.pageErrors).toEqual([]);
});
test("is migrated in place: the version is stamped, the wallet survives", async () => {
const env = await bootPopup(unversionedValidProfile());
const stored = env.storage.read("autistmask");
expect(stored.schemaVersion).toBe(1);
expect(stored.wallets).toHaveLength(1);
expect(stored.wallets[0].encryptedSecret).toBe("encrypted-secret-1");
expect(stored.wallets[0].addresses[0].address).toBe(ADDRESS);
expect(stored.activeAddress).toBe(ADDRESS);
expect(stored.allowedSites).toEqual({ [ADDRESS]: ["dapp.example"] });
});
});
describe("a first run with nothing in storage", () => {
test("boots to Welcome", async () => {
const env = await bootPopup(undefined);
expect(env.visibleViews()).toEqual(["welcome"]);
expect(env.pageErrors).toEqual([]);
});
});
describe("a garbage value in a field the gate does not check", () => {
// The gate refuses only what nothing can floor: the wallet list, the
// version, the network key. Everything else is normalizePersisted()'s job,
// and where that job was written as `saved.x || default` rather than a
// type check, a TRUTHY value of the wrong type walked straight through and
// threw on the first dereference — the same blank popup this issue is
// about, measured the same way. Every row below did, at the head named
// against it; none has ever been removed from this list.
//
// These belong on the floor rather than in the gate: none of these values
// carries key material, all have a sane default, and sending a user whose
// wallets are perfectly readable to an export-or-erase screen over a
// broken token list would destroy more than it saves.
//
// The CONTAINER and its ELEMENTS are separate defects. Round 2 floored the
// containers with Array.isArray(), which left every row whose container is
// a well-formed list of malformed entries still blanking the popup: the
// dereference is `t.address.toLowerCase()`, one level below the check.
const CORRUPT_FIELDS = [
// Container shapes. Observed at 2e2ecf9, before the round-2 floor:
// trackedTokens: "nope" -> views=[] "Cannot read properties of
// undefined (reading 'toLowerCase')"
// trackedTokens: 42 -> views=[] "trackedTokens is not iterable"
// trackedTokens: {a:1} -> views=[] "trackedTokens is not iterable"
// activeAddress: 42 -> views=[] "address.slice is not a
// function"
// activeAddress: {a:1} -> views=[] "address.slice is not a
// function"
{ name: "trackedTokens is a string", patch: { trackedTokens: "nope" } },
{ name: "trackedTokens is a number", patch: { trackedTokens: 42 } },
{
name: "trackedTokens is an object",
patch: { trackedTokens: { a: 1 } },
},
{ name: "activeAddress is a number", patch: { activeAddress: 42 } },
{
name: "activeAddress is an object",
patch: { activeAddress: { a: 1 } },
},
// Element shapes: a list, holding entries that are not token records.
// Observed at a10a984, AFTER the container floor:
// [1,2] -> views=[] "Cannot read properties of undefined
// (reading 'toLowerCase')"
// [null] -> views=[] "Cannot read properties of null
// (reading 'address')"
// [{}] -> views=[] "Cannot read properties of undefined
// (reading 'toLowerCase')"
// [{address:42}] -> views=[] "t.address.toLowerCase is not a
// function"
// ["0xAA…"] -> views=[] "Cannot read properties of undefined
// (reading 'toLowerCase')"
{
name: "trackedTokens holds numbers",
patch: { trackedTokens: [1, 2] },
},
{
name: "trackedTokens holds null",
patch: { trackedTokens: [null] },
},
{
name: "trackedTokens holds a record with no address",
patch: { trackedTokens: [{}] },
},
{
name: "trackedTokens holds a record whose address is a number",
patch: { trackedTokens: [{ address: 42 }] },
},
{
name: "trackedTokens holds bare address strings",
patch: { trackedTokens: [TOKEN_ADDRESS] },
},
];
// The same defect one level deeper, inside a wallet the gate accepted.
// tokenBalances is written WHOLESALE by refreshBalances(), so the partial
// write https://git.eeqj.de/sneak/AutistMask/issues/311 names as the live
// cause of a corrupt record lands exactly here. Observed at a10a984:
// "x" -> views=[] "Cannot read properties of undefined (reading
// 'toLowerCase')" (a string iterates as characters)
// [null] -> views=[] "Cannot read properties of null (reading
// 'balance')"
// [42] -> views=[] "Cannot read properties of undefined (reading
// 'toLowerCase')"
// 42 -> views=[] "number 42 is not iterable"
const CORRUPT_TOKEN_BALANCES = [
{ name: "a string", value: "x" },
{ name: "a list holding null", value: [null] },
{ name: "a list of numbers", value: [42] },
{ name: "a number", value: 42 },
];
function profileWithTokenBalances(value) {
const profile = unversionedValidProfile();
profile.wallets[0].addresses[0].tokenBalances = value;
return profile;
}
for (const { name, patch } of CORRUPT_FIELDS) {
test(`${name}: a working popup, not a blank one`, async () => {
const env = await bootPopup(
Object.assign(unversionedValidProfile(), patch),
);
expect({
visibleViews: env.visibleViews(),
errors: env.pageErrors,
}).toEqual({ visibleViews: ["main"], errors: [] });
});
}
for (const { name, value } of CORRUPT_TOKEN_BALANCES) {
test(`an address whose tokenBalances is ${name}: a working popup, not a blank one`, async () => {
const env = await bootPopup(profileWithTokenBalances(value));
expect({
visibleViews: env.visibleViews(),
errors: env.pageErrors,
}).toEqual({ visibleViews: ["main"], errors: [] });
});
}
test("the wallet is intact afterwards, and the bad value is gone", async () => {
const env = await bootPopup(
Object.assign(unversionedValidProfile(), {
trackedTokens: "nope",
activeAddress: 42,
}),
);
const stored = env.storage.read("autistmask");
expect(stored.wallets[0].encryptedSecret).toBe("encrypted-secret-1");
expect(stored.trackedTokens).toEqual([]);
// Floored to null, then filled in by init()'s auto-default.
expect(stored.activeAddress).toBe(ADDRESS);
});
test("an empty activeAddress does not leave the popup with none selected", async () => {
// "" is text, so a type check alone lets it through — and init()
// auto-selects the first address only on a STRICT null, so it has to
// be floored to null rather than kept.
const env = await bootPopup(
Object.assign(unversionedValidProfile(), { activeAddress: "" }),
);
expect(env.visibleViews()).toEqual(["main"]);
expect(env.storage.read("autistmask").activeAddress).toBe(ADDRESS);
});
test("a malformed token entry is dropped, and the well-formed ones beside it survive", async () => {
const env = await bootPopup(
Object.assign(unversionedValidProfile(), {
trackedTokens: [
1,
null,
{},
{ address: 42 },
TOKEN_ADDRESS,
{ address: TOKEN_ADDRESS, symbol: "AAA", decimals: 18 },
],
}),
);
const stored = env.storage.read("autistmask");
expect(stored.trackedTokens).toEqual([
{ address: TOKEN_ADDRESS, symbol: "AAA", decimals: 18 },
]);
});
test("a malformed tokenBalances entry is dropped, and the wallet and its address survive", async () => {
const env = await bootPopup(
profileWithTokenBalances([
null,
42,
{ address: TOKEN_ADDRESS, symbol: "AAA", balance: "2.0" },
]),
);
const stored = env.storage.read("autistmask");
expect(stored.wallets[0].encryptedSecret).toBe("encrypted-secret-1");
expect(stored.wallets[0].addresses[0].address).toBe(ADDRESS);
expect(stored.wallets[0].addresses[0].tokenBalances).toEqual([
{ address: TOKEN_ADDRESS, symbol: "AAA", balance: "2.0" },
]);
});
});

423
tests/stateSchema.test.js Normal file
View File

@@ -0,0 +1,423 @@
// The stored-profile version stamp and the shape gate in front of it
// (https://git.eeqj.de/sneak/AutistMask/issues/311).
//
// tests/stateRecovery.test.js covers what the USER sees when a record is
// refused. This file covers what is refused and what is not, which is the
// half that decides whether an upgrade brick or a false alarm ever happens:
// a gate that refuses too much sends a perfectly good wallet to a wipe prompt,
// and one that refuses too little is the blank popup again.
const fs = require("fs");
const path = require("path");
const {
STATE_SCHEMA_VERSION,
StateUnusableError,
assertStateUsable,
migrationNeeded,
stateProblem,
} = require("../src/shared/stateSchema");
const {
NETWORKS,
UnknownNetworkError,
isKnownNetworkId,
networkById,
} = require("../src/shared/networks");
const { normalizePersisted } = require("../src/shared/persistedState");
const { RESET_PHRASE } = require("../src/popup/views/stateRecovery");
const { makeStorageStub } = require("./support/storageStub");
const ADDRESS = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
function validProfile(extra) {
return {
hasWallet: true,
wallets: [
{
type: "hd",
name: "Wallet 1",
xpub: "xpub-wallet-1",
encryptedSecret: "encrypted-secret-1",
nextIndex: 1,
addresses: [
{ address: ADDRESS, balance: "1.5", tokenBalances: [] },
],
},
],
activeAddress: ADDRESS,
networkId: "mainnet",
...(extra || {}),
};
}
afterEach(() => {
delete global.chrome;
});
describe("what the gate accepts", () => {
test("nothing stored at all is a first run, not a defect", () => {
expect(stateProblem(undefined)).toBeNull();
expect(stateProblem(null)).toBeNull();
expect(stateProblem({})).toBeNull();
});
test("a valid profile with no version field is accepted and migrated", () => {
const saved = validProfile();
expect(stateProblem(saved)).toBeNull();
expect(migrationNeeded(saved)).toBe(true);
// The migration IS the stamp: version 1 is the shape that shipped
// unversioned, so nothing about the record has to change.
expect(normalizePersisted(saved).schemaVersion).toBe(
STATE_SCHEMA_VERSION,
);
expect(normalizePersisted(saved).wallets).toEqual(saved.wallets);
});
test("a profile already at the current version needs no migration", () => {
const saved = validProfile({ schemaVersion: STATE_SCHEMA_VERSION });
expect(stateProblem(saved)).toBeNull();
expect(migrationNeeded(saved)).toBe(false);
});
test("an empty wallet list is fine", () => {
expect(stateProblem({ hasWallet: false, wallets: [] })).toBeNull();
});
test("unknown extra fields alone are not a defect", () => {
// Only a change in the MEANING of a stored field is a version bump, so
// a field this build does not know must not be a refusal on its own —
// otherwise a downgrade would wipe a working wallet.
expect(stateProblem(validProfile({ somethingNew: 42 }))).toBeNull();
});
});
describe("what the gate refuses", () => {
test("a record that is not the record AutistMask stores", () => {
expect(stateProblem("wallet")).toMatch(/not the record/);
expect(stateProblem([1, 2])).toMatch(/not the record/);
});
test("a version from a newer build, naming both versions", () => {
const problem = stateProblem(
validProfile({ schemaVersion: STATE_SCHEMA_VERSION + 1 }),
);
expect(problem).toMatch(/newer version/);
expect(problem).toContain(String(STATE_SCHEMA_VERSION + 1));
expect(problem).toContain(String(STATE_SCHEMA_VERSION));
});
test("a version that is not a version at all", () => {
for (const version of ["1", 1.5, 0, -1, null, {}]) {
expect(
stateProblem(validProfile({ schemaVersion: version })),
).toMatch(/schema version/);
}
});
test("wallets that is not a list", () => {
expect(stateProblem({ wallets: ADDRESS })).toMatch(/not a list/);
expect(stateProblem({ wallets: { 0: {} } })).toMatch(/not a list/);
});
test("a wallet that is not a wallet record", () => {
expect(stateProblem({ wallets: [null] })).toMatch(/wallet record/);
expect(stateProblem({ wallets: [42] })).toMatch(/wallet record/);
});
test("a wallet whose addresses are missing or not records", () => {
expect(stateProblem({ wallets: [{ name: "Wallet 1" }] })).toMatch(
/no list of addresses/,
);
expect(stateProblem({ wallets: [{ addresses: [ADDRESS] }] })).toMatch(
/not a record/,
);
expect(stateProblem({ wallets: [{ addresses: [{}] }] })).toMatch(
/no address/,
);
});
test("the problem names WHICH wallet, counting from one", () => {
const problem = stateProblem({
wallets: [validProfile().wallets[0], { name: "Wallet 2" }],
});
expect(problem).toContain("Wallet 2");
});
test("assertStateUsable throws the sentence, not a generic error", () => {
let thrown = null;
try {
assertStateUsable({ wallets: ADDRESS });
} catch (e) {
thrown = e;
}
expect(thrown).toBeInstanceOf(StateUnusableError);
expect(thrown.problem).toMatch(/not a list/);
expect(thrown.message).toBe(thrown.problem);
});
});
describe("networkId, which is used as an object key", () => {
// https://git.eeqj.de/sneak/AutistMask/issues/311#issuecomment-67478:
// state.networkId keys state.networkEndpoints, so a corrupt "__proto__"
// sets that map's prototype instead of an own key and the user's endpoint
// is silently not recorded.
test("a network this build does not know is refused", () => {
expect(stateProblem(validProfile({ networkId: "base" }))).toMatch(
/does not know/,
);
});
test('"__proto__" and "constructor" are refused, not resolved', () => {
for (const id of ["__proto__", "constructor", "toString"]) {
expect(stateProblem(validProfile({ networkId: id }))).toMatch(
/does not know/,
);
expect(isKnownNetworkId(id)).toBe(false);
}
});
test("the gate reads own properties only", () => {
// A record whose PROTOTYPE carries the fields must not be read as
// though it carried them itself: that is how a polluted prototype
// would decide whether a profile is refused.
const inherited = Object.create({
schemaVersion: STATE_SCHEMA_VERSION + 99,
networkId: "base",
wallets: "not a list",
});
expect(stateProblem(inherited)).toBeNull();
});
test("normalizing never turns a stored key into a prototype", () => {
// JSON can carry an own "__proto__" key, and plain assignment would
// treat it as the prototype setter rather than storing an entry.
const saved = JSON.parse(
'{"networkId":"mainnet","networkEndpoints":' +
'{"__proto__":{"rpcUrl":"https://evil.invalid"}}}',
);
const out = normalizePersisted(saved);
expect(Object.prototype.hasOwnProperty.call(out, "rpcUrl")).toBe(true);
expect(out.rpcUrl).not.toBe("https://evil.invalid");
expect(Object.getPrototypeOf(out.networkEndpoints)).toBe(
Object.prototype,
);
expect({}.rpcUrl).toBeUndefined();
});
test("an unknown stored networkId never reaches the endpoint map", () => {
// The floor under the gate: normalization alone must not adopt it.
const out = normalizePersisted({ networkId: "__proto__" });
expect(out.networkId).toBe("mainnet");
expect(Object.keys(out.networkEndpoints)).toEqual(["mainnet"]);
});
});
describe("the floors under the gate, for fields the gate does not check", () => {
// The gate's scope is what nothing can floor. Everything it lets through
// is normalizePersisted()'s to make safe, and a floor written as
// `saved.x || default` is not one: a truthy value of the wrong type walks
// through it and throws on the first dereference, which produced the blank
// popup from the issue. Nor is a container check on its own: [1, 2] is a
// list, and the dereference is `t.address.toLowerCase()` one level below
// it. Container AND entries, therefore — an empty list still survives.
const TOKEN = "0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
test("trackedTokens that is not a list becomes an empty list", () => {
for (const bad of ["nope", 42, true, { a: 1 }]) {
expect(
normalizePersisted({ trackedTokens: bad }).trackedTokens,
).toEqual([]);
}
});
test("a trackedTokens entry that is not a token record is dropped", () => {
for (const bad of [1, null, {}, { address: 42 }, TOKEN, [], true]) {
expect(
normalizePersisted({ trackedTokens: [bad] }).trackedTokens,
).toEqual([]);
}
});
test("a real trackedTokens list survives, copied not shared", () => {
const saved = { trackedTokens: [{ address: ADDRESS, symbol: "AM" }] };
const out = normalizePersisted(saved);
expect(out.trackedTokens).toEqual(saved.trackedTokens);
expect(out.trackedTokens).not.toBe(saved.trackedTokens);
expect(normalizePersisted({ trackedTokens: [] }).trackedTokens).toEqual(
[],
);
});
test("a good trackedTokens entry beside a malformed one survives", () => {
const good = { address: TOKEN, symbol: "AM", decimals: 18 };
expect(
normalizePersisted({ trackedTokens: [1, null, good, {}] })
.trackedTokens,
).toEqual([good]);
});
// Below an address record, which the gate walks but does not descend into.
// refreshBalances() writes tokenBalances WHOLESALE, so a write that only
// partly lands leaves exactly this field malformed.
function walletWith(tokenBalances) {
return {
wallets: [
{
name: "Wallet 1",
addresses: [{ address: ADDRESS, tokenBalances }],
},
],
};
}
function balancesOf(out) {
return out.wallets[0].addresses[0].tokenBalances;
}
test("an address's tokenBalances that is not a list becomes an empty list", () => {
for (const bad of ["x", 42, true, { a: 1 }, undefined]) {
expect(balancesOf(normalizePersisted(walletWith(bad)))).toEqual([]);
}
});
test("a tokenBalances entry that is not a token record is dropped", () => {
for (const bad of [null, 42, "x", {}, { address: 42 }]) {
expect(balancesOf(normalizePersisted(walletWith([bad])))).toEqual(
[],
);
}
});
test("a real tokenBalances entry survives, copied not shared", () => {
const held = { address: TOKEN, symbol: "AM", balance: "2.0" };
const saved = walletWith([held]);
const out = normalizePersisted(saved);
expect(balancesOf(out)).toEqual([held]);
expect(balancesOf(out)[0]).not.toBe(held);
});
test("activeAddress that is not text becomes null", () => {
for (const bad of [42, true, { a: 1 }, [ADDRESS]]) {
expect(
normalizePersisted({ activeAddress: bad }).activeAddress,
).toBeNull();
}
});
test("a real activeAddress survives; the empty string becomes null", () => {
expect(
normalizePersisted({ activeAddress: ADDRESS }).activeAddress,
).toBe(ADDRESS);
// "" is text but it is not an address, and src/popup/index.js
// auto-selects the first address only on a STRICT null — so keeping
// the empty string would leave the popup with none ever selected.
expect(
normalizePersisted({ activeAddress: "" }).activeAddress,
).toBeNull();
});
});
describe("networkById on an unknown id", () => {
test("throws instead of quietly answering mainnet", () => {
expect(() => networkById("base")).toThrow(UnknownNetworkError);
expect(() => networkById(undefined)).toThrow(UnknownNetworkError);
// Both of these used to answer with something truthy off the
// prototype chain rather than with a network.
expect(() => networkById("constructor")).toThrow(UnknownNetworkError);
expect(() => networkById("__proto__")).toThrow(UnknownNetworkError);
});
test("still answers every network it does know", () => {
for (const id of Object.keys(NETWORKS)) {
expect(networkById(id).id).toBe(id);
}
});
});
describe("the version stamp on the way out", () => {
function loadStateModule(persisted) {
jest.resetModules();
const storage = makeStorageStub(
persisted ? { autistmask: persisted } : {},
);
global.chrome = { storage };
return { storage, mod: require("../src/shared/state") };
}
test("the popup stamps it on a profile that had none", async () => {
const { storage, mod } = loadStateModule(validProfile());
await mod.loadState();
await mod.saveState();
expect(storage.read("autistmask").schemaVersion).toBe(
STATE_SCHEMA_VERSION,
);
});
test("the background stamps it on a profile that had none", async () => {
jest.resetModules();
const storage = makeStorageStub({ autistmask: validProfile() });
global.chrome = { storage };
const { updateState } = require("../src/background/state");
await updateState((s) => {
s.lastBalanceRefresh = 1;
});
expect(storage.read("autistmask").schemaVersion).toBe(
STATE_SCHEMA_VERSION,
);
});
test("a save refuses to write over a record it cannot read", async () => {
// Another context — a newer build, or something else entirely — wrote
// a record this one does not understand while this page was open. That
// record is the only copy of whatever it holds, and normalizing it
// back into storage would destroy it.
const { storage, mod } = loadStateModule(validProfile());
await mod.loadState();
const hostile = { schemaVersion: STATE_SCHEMA_VERSION + 1 };
storage.write("autistmask", hostile);
// By name rather than by constructor: jest.resetModules() above gives
// the module under test its own copy of the error class, so instanceof
// across that boundary would be comparing two identical classes.
const thrown = await mod.saveState().then(
() => null,
(e) => e,
);
expect(thrown && thrown.name).toBe("StateUnusableError");
expect(thrown.problem).toMatch(/newer version/);
expect(storage.read("autistmask")).toEqual(hostile);
});
});
describe("the typed confirmation phrase", () => {
test("the markup asks for the phrase the code checks", () => {
// The button is behind a phrase typed by hand. A screen that asks for
// one phrase while the code compares another is an exit that cannot be
// taken, on the one screen that exists to be an exit.
const html = fs.readFileSync(
path.join(__dirname, "..", "src", "popup", "index.html"),
"utf8",
);
expect(html).toContain(RESET_PHRASE);
});
});

View File

@@ -0,0 +1,203 @@
// What a dApp is told when the wallet's stored profile cannot be read
// (https://git.eeqj.de/sneak/AutistMask/issues/311).
//
// The popup is not the only casualty of a bad blob. getActiveAddress()
// dereferences the stored wallet list on nearly every method, so against the
// build this file was added to, EVERY request from EVERY page came back as
// -32603 "AutistMask could not complete this request because of an internal
// error" — the code the wallet also answers when a signing attempt blows up,
// with nothing in it to tell the page or the user what is actually wrong or
// what to do about it.
//
// So what is pinned here is that the answer is SPECIFIC: its own code, and a
// message that says the saved data cannot be read, that nothing was signed or
// sent, and where to go to fix it.
//
// Same cold-worker shape as tests/coldWorkerChainId.test.js: the real state
// modules, over a storage stub, with no loadState() of the test's own — the
// handler has to reach storage by itself, as a worker revived by the page's
// own message does.
const CONNECTED_ORIGIN = "https://dapp.example";
const ADDRESS = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
// The generic answer, quoted rather than imported: this file's whole point is
// that the state-unusable path stopped using it.
const GENERIC_INTERNAL_ERROR_CODE = -32603;
// EIP-1474's assigned non-standard codes, verbatim. The spec sets aside
// -32000..-32099 for implementation-defined server errors but hands out
// meanings for the first seven, so those are exactly the codes this condition
// may NOT take: a page reading -32001 is entitled to read "Resource not
// found". -32002 is in this table AND in use here, for a pending approval.
const EIP_1474_ASSIGNED = {
"-32000": "Invalid input",
"-32001": "Resource not found",
"-32002": "Resource unavailable",
"-32003": "Transaction rejected",
"-32004": "Method not supported",
"-32005": "Limit exceeded",
"-32006": "JSON-RPC version not supported",
};
// The three blobs from the issue.
const CORRUPT_BLOBS = [
{
name: "wallets is a string",
blob: { hasWallet: true, wallets: ADDRESS },
},
{
name: "wallets is an array of garbage",
blob: { hasWallet: true, wallets: [null, 42, "wallet"] },
},
{
name: "future-schema blob (unknown fields, no version)",
blob: {
hasWallet: true,
wallets: [{ id: "wallet-1", accounts: [{ addr: ADDRESS }] }],
profileFormat: "am-2",
},
},
];
async function settle() {
for (let i = 0; i < 50; i++) await Promise.resolve();
}
afterEach(() => {
delete global.chrome;
});
function loadColdWorker(stored) {
jest.resetModules();
jest.doMock("../src/shared/balances", () => ({
getProvider: () => ({}),
refreshBalances: jest.fn(async () => {}),
}));
jest.doMock("../src/shared/phishingDomains", () => ({
isPhishingDomain: () => false,
}));
jest.doMock("../src/shared/alarms", () => ({
BALANCE_REFRESH_ALARM: "balance",
BALANCE_REFRESH_PERIOD_MINUTES: 1,
ensureRecurringAlarms: jest.fn(async () => {}),
registerAlarmHandlers: jest.fn(),
}));
const store = { autistmask: structuredClone(stored) };
let messageListener = null;
const set = jest.fn(async (items) => {
store.autistmask = structuredClone(items.autistmask);
});
global.chrome = {
storage: {
local: {
get: jest.fn(async () => structuredClone(store)),
set,
},
},
runtime: {
getURL: (p) => "chrome-extension://autistmask/" + p,
onMessage: {
addListener: (fn) => {
messageListener = fn;
},
},
onConnect: { addListener: () => {} },
lastError: null,
},
windows: {
getLastFocused: (cb) => cb(null),
create: (options, cb) => cb({ id: 1 }),
remove: (id, cb) => {
if (cb) cb();
},
onRemoved: { addListener: () => {} },
},
tabs: {
query: (queryInfo, cb) => cb([{ id: 1 }]),
sendMessage: (tabId, message, cb) => {
if (cb) cb();
},
},
action: { setPopup: () => {} },
};
require("../src/background/index");
async function rpc(method, params) {
let result = null;
messageListener(
{ type: "AUTISTMASK_RPC", method, params: params || [] },
{ origin: CONNECTED_ORIGIN },
(r) => {
result = r;
},
);
await settle();
return result;
}
return { rpc, persisted: () => store.autistmask, storageSet: set };
}
// Every method a page can reach that has to consult the profile.
const METHODS = [
"eth_accounts",
"eth_requestAccounts",
"eth_chainId",
"personal_sign",
"eth_sendTransaction",
];
describe("a dApp call against a profile the wallet cannot read", () => {
for (const { name, blob } of CORRUPT_BLOBS) {
test(`${name}: a specific error, not the generic internal one`, async () => {
const bg = loadColdWorker(blob);
const answer = await bg.rpc("eth_accounts");
expect(answer.error).toBeDefined();
expect(answer.error.code).not.toBe(GENERIC_INTERNAL_ERROR_CODE);
// The message has to say what is wrong, that nothing was sent,
// and where to go. "Internal error" says none of the three.
expect(answer.error.message).toMatch(/saved data/i);
expect(answer.error.message).toMatch(/nothing was/i);
expect(answer.error.message).toMatch(/AutistMask/);
});
}
test("every method that consults the profile answers the same way", async () => {
const bg = loadColdWorker(CORRUPT_BLOBS[0].blob);
const codes = new Set();
for (const method of METHODS) {
const answer = await bg.rpc(method, ["0x00", ADDRESS]);
expect(answer.error).toBeDefined();
codes.add(answer.error.code);
}
// One code for the condition, whatever the method was.
expect(codes.size).toBe(1);
expect(codes.has(GENERIC_INTERNAL_ERROR_CODE)).toBe(false);
// And it is a code EIP-1474 has not already given a meaning to, so a
// page reading it by the spec's table is not told something false.
const code = [...codes][0];
expect(EIP_1474_ASSIGNED[String(code)]).toBeUndefined();
expect(code).toBeLessThanOrEqual(-32007);
expect(code).toBeGreaterThanOrEqual(-32099);
});
test("it does not write over the record it could not read", async () => {
const bg = loadColdWorker(CORRUPT_BLOBS[1].blob);
await bg.rpc("eth_accounts");
await bg.rpc("eth_chainId");
expect(bg.storageSet).not.toHaveBeenCalled();
expect(bg.persisted()).toEqual(CORRUPT_BLOBS[1].blob);
});
});

347
tests/support/popupBoot.js Normal file
View File

@@ -0,0 +1,347 @@
// Boot the REAL popup entry point over a stored record, exactly as the browser
// does: storage already holds the record, the page loads, DOMContentLoaded
// fires.
//
// Written for https://git.eeqj.de/sneak/AutistMask/issues/311 inside
// tests/stateRecovery.test.js and lifted here unchanged in substance when
// https://git.eeqj.de/sneak/AutistMask/issues/362 needed the same boot for a
// second field. Assertions about a corrupt stored profile have to be made
// through the entry point rather than against a view module: a screen that
// renders perfectly when something calls it, and that nothing calls, IS the
// defect.
//
// The DOM stub is built FROM src/popup/index.html — every id in the markup,
// with the classes the markup gives it — so "which views are visible" is
// answered against the real element set, and a screen with no markup behind it
// cannot pass.
const fs = require("fs");
const path = require("path");
const { makeStorageStub } = require("./storageStub");
const POPUP_HTML = fs.readFileSync(
path.join(__dirname, "..", "..", "src", "popup", "index.html"),
"utf8",
);
// Fixed addresses, never used for anything but these tests.
const ADDRESS = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
const TOKEN_ADDRESS = "0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
// A profile in the shape every install in the field has it: complete, valid,
// and carrying no version field, because no build ever wrote one. The starting
// point for "and now corrupt exactly one field of it".
function unversionedValidProfile(extra) {
return {
hasWallet: true,
wallets: [
{
type: "hd",
name: "Wallet 1",
xpub: "xpub-wallet-1",
encryptedSecret: "encrypted-secret-1",
nextIndex: 1,
addresses: [
{ address: ADDRESS, balance: "1.5", tokenBalances: [] },
],
},
],
activeAddress: ADDRESS,
networkId: "mainnet",
rpcUrl: "https://ethereum-rpc.publicnode.com",
blockscoutUrl: "https://eth.blockscout.com/api/v2",
allowedSites: { [ADDRESS]: ["dapp.example"] },
deniedSites: {},
trackedTokens: [],
theme: "system",
...(extra || {}),
};
}
function makeElement(id, className) {
const classes = new Set(
(className || "").split(/\s+/).filter((name) => name !== ""),
);
const el = {
id,
tagName: "DIV",
textContent: "",
value: "",
innerHTML: "",
href: "",
download: "",
disabled: false,
style: {},
dataset: {},
listeners: {},
clicked: 0,
classList: {
add: (...names) => names.forEach((n) => classes.add(n)),
remove: (...names) => names.forEach((n) => classes.delete(n)),
contains: (n) => classes.has(n),
toggle: (n, force) => {
const on = force === undefined ? !classes.has(n) : force;
if (on) classes.add(n);
else classes.delete(n);
return on;
},
},
addEventListener: (name, fn) => {
el.listeners[name] = el.listeners[name] || [];
el.listeners[name].push(fn);
},
removeEventListener: () => {},
appendChild: () => {},
remove: () => {},
focus: () => {},
select: () => {},
setAttribute: (name, value) => {
el[name] = value;
},
querySelector: () => null,
querySelectorAll: () => [],
// The Receive view draws its QR onto #receive-qr through the qrcode
// package, which calls getContext("2d") and then createImageData/
// putImageData on the result. Without this the render throws from
// inside a promise the view does not await, which takes the whole node
// process down rather than failing a test — so a suite that boots onto
// Receive could not report anything.
getContext: () => ({
createImageData: (w, h) => ({
width: w,
height: h,
data: new Uint8ClampedArray(w * h * 4),
}),
putImageData: () => {},
clearRect: () => {},
}),
click: () => {
el.clicked += 1;
},
};
// src/ reaches parentElement only to hide or unhide the wrapper a field
// sits in (txStatus.js renderSuccess(), transactionDetail.js render()).
// The stub is flat — it is built from the ids in the markup, not from its
// tree — so each element gets a wrapper of its own, made on demand so this
// does not recurse. It is never registered by id, so nothing can mistake
// it for a view. Without it, success-tx and transaction throw on the first
// line that touches a wrapper and cannot be booted onto at all.
let parent = null;
Object.defineProperty(el, "parentElement", {
get() {
if (!parent) parent = makeElement(id + "-parent", "");
return parent;
},
});
return el;
}
// Every id in the markup, with the classes the markup gives it. A view the
// popup is supposed to reveal has to exist here, which means it has to exist
// in src/popup/index.html.
function idsFromHtml(html) {
const out = new Map();
const tags = html.match(/<[a-zA-Z][^>]*>/g) || [];
for (const tag of tags) {
const id = /\bid="([^"]+)"/.exec(tag);
if (!id) continue;
const cls = /\bclass="([^"]*)"/.exec(tag);
out.set(id[1], cls ? cls[1] : "");
}
return out;
}
// Ids the popup creates at runtime rather than authoring in the markup, and
// that must therefore read as ABSENT until something creates them. Answering
// with a fresh element instead would make "is the banner up?" always true.
const RUNTIME_IDS = new Set(["debug-banner", "save-failure-banner"]);
function makeDocument(html) {
const authored = idsFromHtml(html);
const els = new Map();
for (const [id, className] of authored) {
els.set(id, makeElement(id, className));
}
const created = [];
const prepended = [];
const doc = {
listeners: {},
getElementById(id) {
if (RUNTIME_IDS.has(id) && !els.has(id)) return null;
if (!els.has(id)) els.set(id, makeElement(id, ""));
return els.get(id);
},
createElement(tag) {
const el = makeElement("created-" + tag, "");
el.tagName = String(tag).toUpperCase();
created.push(el);
return el;
},
addEventListener(name, fn) {
doc.listeners[name] = doc.listeners[name] || [];
doc.listeners[name].push(fn);
},
querySelectorAll: () => [],
documentElement: makeElement("html", ""),
body: {
// Recorded, and registered under its id: a banner the popup
// prepends is on the page from then on, and a test asking for it
// by id has to find it.
prepend: (el) => {
prepended.push(el);
if (el && el.id) els.set(el.id, el);
},
appendChild: () => {},
removeChild: () => {},
},
elements: els,
authoredIds: authored,
created,
prepended,
};
return doc;
}
async function settle() {
for (let i = 0; i < 50; i++) await Promise.resolve();
}
/**
* Boot the popup over `stored`.
*
* @param {*} stored the record storage holds, or undefined for a first run.
* @param {object} [options]
* @param {object} [options.storage] a storage stub from makeStorageStub(), for
* a test that needs to make writes fail or to watch the round trips.
* @returns {Promise<object>} handles onto the booted page.
*/
async function bootPopup(stored, options) {
jest.resetModules();
// The three modules that reach the network. None is on the path under
// test; all would make the suite hit the internet.
jest.doMock("../../src/shared/prices", () => ({
prices: {},
refreshPrices: jest.fn(async () => {}),
clearPrices: jest.fn(),
getPrice: () => null,
formatUsd: () => "",
formatAddressTotal: () => "",
getAddressValue: () => ({ usd: null, partial: false }),
getWalletValue: () => ({ usd: null, partial: false }),
getTotalValue: () => ({ usd: null, partial: false }),
}));
jest.doMock("../../src/shared/balances", () => ({
fetchTokenBalances: jest.fn(async () => []),
refreshBalances: jest.fn(async () => {}),
lookupTokenInfo: jest.fn(async () => null),
getProvider: () => ({}),
scanForAddresses: jest.fn(async () => []),
}));
jest.doMock("../../src/shared/transactions", () => ({
fetchRecentTransactions: jest.fn(async () => []),
filterTransactions: () => [],
}));
const storage =
(options && options.storage) ||
makeStorageStub(stored === undefined ? {} : { autistmask: stored });
const document = makeDocument(POPUP_HTML);
const reloads = [];
globalThis.chrome = {
storage: { local: storage.local },
runtime: {
sendMessage: jest.fn(async () => ({})),
getURL: (p) => "chrome-extension://autistmask/" + p,
onMessage: { addListener: () => {} },
},
};
globalThis.document = document;
globalThis.window = {
location: {
search: "",
href: "chrome-extension://autistmask/src/popup/index.html",
reload: () => reloads.push(Date.now()),
},
matchMedia: () => ({
matches: false,
addEventListener: () => {},
removeEventListener: () => {},
}),
addEventListener: () => {},
};
// The 10s refresh loop init() starts would outlive the test.
const realSetInterval = globalThis.setInterval;
globalThis.setInterval = () => 0;
require("../../src/popup/index");
const booted = [];
for (const fn of document.listeners.DOMContentLoaded || []) {
booted.push(fn());
}
// What the browser console would have shown. A throw out of init() is the
// blank popup issue 311 is about, so it is captured rather than thrown:
// the assertion that matters is what ended up on screen.
const pageErrors = [];
for (const p of booted) {
try {
await p;
} catch (e) {
pageErrors.push(String((e && e.message) || e));
}
}
await settle();
globalThis.setInterval = realSetInterval;
return {
storage,
document,
pageErrors,
reloaded: () => reloads.length,
node: (id) => document.getElementById(id),
text: (id) => {
const el = document.getElementById(id);
return el ? el.textContent : null;
},
value: (id) => document.getElementById(id).value,
hidden: (id) =>
document.getElementById(id).classList.contains("hidden"),
click: async (id) => {
const el = document.getElementById(id);
const fns = el.listeners.click || [];
for (const fn of fns) await fn();
await settle();
},
settle,
// The view ids whose section is not hidden, as the audit measured them.
visibleViews: () => {
const out = [];
for (const [id, el] of document.elements) {
if (!id.startsWith("view-")) continue;
if (!el.classList.contains("hidden")) out.push(id.slice(5));
}
return out;
},
};
}
function cleanupPopup() {
delete globalThis.chrome;
delete globalThis.document;
delete globalThis.window;
}
module.exports = {
bootPopup,
cleanupPopup,
settle,
unversionedValidProfile,
ADDRESS,
TOKEN_ADDRESS,
POPUP_HTML,
};

View File

@@ -0,0 +1,73 @@
// A chrome.storage.local stub that behaves like the real one.
//
// The real extension storage API is a serialization boundary: `set` writes a
// structured clone of what it is given, and `get` hands back a structured
// clone of what is stored. Nothing an extension page holds is ever the object
// storage holds.
//
// A stub that skips the clone aliases them together, and that hides an entire
// class of defect rather than merely being imprecise. loadState() assigns
// nested references straight out of the get result, so over an aliasing stub a
// test can assert "the endpoint was persisted" and pass on a build that never
// called saveState() at all: the in-memory mutation IS the stored record.
// Measured, not theorised — with an aliasing `get` restored over the handler
// fixed in https://git.eeqj.de/sneak/AutistMask/pulls/319, the whole suite
// passed 794/794 (https://git.eeqj.de/sneak/AutistMask/issues/324).
//
// So every test that drives real persistence uses this, and nothing rebuilds
// a storage stub by hand.
// `initial` is the starting contents, keyed as storage is: { autistmask: {...} }.
// `onOp` runs before each operation, for a test that needs to advance a clock
// or count round trips.
function makeStorageStub(initial, onOp) {
const store = initial ? structuredClone(initial) : {};
const tick = onOp || (() => {});
const get = jest.fn(async (key) => {
tick();
if (key === undefined || key === null) return structuredClone(store);
const keys = Array.isArray(key) ? key : [key];
const out = {};
for (const k of keys) {
if (Object.prototype.hasOwnProperty.call(store, k)) {
out[k] = structuredClone(store[k]);
}
}
return out;
});
const set = jest.fn(async (items) => {
tick();
for (const k of Object.keys(items)) {
store[k] = structuredClone(items[k]);
}
});
const remove = jest.fn(async (key) => {
tick();
for (const k of Array.isArray(key) ? key : [key]) delete store[k];
});
return {
// Drop this straight in as chrome.storage.
local: { get, set, remove },
get,
set,
remove,
// What is stored, cloned on the way out: a test can neither observe a
// later write through an object it read nor reach into the store by
// mutating one.
read: (key) =>
key === undefined
? structuredClone(store)
: structuredClone(store[key]),
// Seed or replace a record without going through the module under
// test — for standing in as "another page wrote this".
write: (key, value) => {
store[key] = structuredClone(value);
},
};
}
module.exports = { makeStorageStub };

View File

@@ -682,6 +682,7 @@ describe("surface 3: the balance list", () => {
"https://rpc.example.invalid",
BLOCKSCOUT,
[],
"mainnet",
);
expect(addr.balance).toBe("1.2345");
expect(addr.tokenBalances).toEqual([]);

View File

@@ -30,8 +30,11 @@ global.fetch = jest.fn(() => {
});
// state.js reads chrome.storage.local at module load; stub it so the
// default settings can be asserted against what the README promises.
global.chrome = { storage: { local: {} } };
// default settings can be asserted against what the README promises. Empty
// storage, so a load produces exactly the defaults.
const { makeStorageStub } = require("./support/storageStub");
global.chrome = { storage: makeStorageStub() };
const {
fetchRecentTransactions,
@@ -745,6 +748,16 @@ describe("dust threshold filtering", () => {
});
describe("filter defaults promised by the README and Settings", () => {
// The defaults are what a load of empty storage produces, so the load is
// part of the assertion rather than an incantation before it: reading the
// singleton before any load now throws (StateNotLoadedError), because a
// context served DEFAULT_STATE without asking for it is the whole subject
// of https://git.eeqj.de/sneak/AutistMask/issues/324. The stub at the top
// of this file has storage empty.
beforeAll(async () => {
await require("../src/shared/state").loadState();
});
test("all four toggles default to on and the threshold to 100,000 gwei", () => {
expect(state.hideSpoofedSymbols).toBe(true);
expect(state.hideLowHolderTokens).toBe(true);

View File

@@ -96,22 +96,18 @@ global.document = {
global.window = { location: { search: "" } };
const stored = {};
global.chrome = {
storage: {
local: {
set: (obj) => {
Object.assign(stored, obj);
return Promise.resolve();
},
get: () => Promise.resolve(stored),
},
},
};
// Clones in both directions, as the real chrome.storage.local does; the stub
// here used to hand back the live stored object, so an in-memory mutation
// looked like a write that had reached storage. See
// tests/support/storageStub.js.
const { makeStorageStub } = require("./support/storageStub");
const storage = makeStorageStub();
global.chrome = { storage };
const txStatus = require("../src/popup/views/txStatus");
const { state } = require("../src/shared/state");
const { RESTORABLE_VIEWS } = require("../src/popup/restorableViews");
const { RESTORABLE_VIEWS } = require("../src/shared/restorableViews");
const TX_HASH =
"0x85215772ed26ea8b39c2b3b18779030487efbe0b5fd7e882592b2f62b837be84";

View File

@@ -94,6 +94,69 @@ function encodeV4Swap(actions, params) {
return coder.encode(["bytes", "bytes[]"], [actions, params]);
}
// V4 inner action IDs, as src/shared/uniswap.js names them.
const V4_SWAP_EXACT_IN = 0x07;
const V4_SWAP_EXACT_IN_SINGLE_ID = 0x06;
const V4_SETTLE_ID = 0x0b;
const V4_TAKE_ID = 0x0e;
const ZERO_ADDR = "0x0000000000000000000000000000000000000000";
// Helper: V4 SETTLE params — (address currency, uint256 maxAmount, bool payerIsUser)
function encodeV4Settle(currency) {
return coder.encode(["address", "uint256", "bool"], [currency, 0n, true]);
}
// Helper: V4 TAKE params — (address currency, address recipient, uint256 amount)
function encodeV4Take(currency) {
return coder.encode(
["address", "address", "uint256"],
[currency, USER_ADDR, 0n],
);
}
// Helper: V4 ExactInputParams — (address currencyIn,
// tuple(address,uint24,int24,address,bytes)[] path,
// uint128 amountIn, uint128 amountOutMin)
function encodeV4ExactIn(currencyIn, pathTokens, amountIn, amountOutMin) {
return coder.encode(
[
"tuple(address,tuple(address,uint24,int24,address,bytes)[],uint128,uint128)",
],
[
[
currencyIn,
pathTokens.map((t) => [t, 3000, 60, ZERO_ADDR, "0x"]),
amountIn,
amountOutMin,
],
],
);
}
// Helper: V4 ExactInputSingleParams —
// (tuple(address,address,uint24,int24,address) poolKey, bool zeroForOne,
// uint128 amountIn, uint128 amountOutMin, bytes hookData)
function encodeV4ExactInSingle(currency0, currency1, amountIn, amountOutMin) {
return coder.encode(
[
"tuple(tuple(address,address,uint24,int24,address),bool,uint128,uint128,bytes)",
],
[
[
[currency0, currency1, 100, 1, ZERO_ADDR],
true, // zeroForOne: in = currency0, out = currency1
amountIn,
amountOutMin,
"0x",
],
],
);
}
function detail(result, label) {
return result.details.find((d) => d.label === label);
}
describe("uniswap decoder", () => {
test("returns null for non-execute calldata", () => {
expect(uniswap.decode("0x", ROUTER_ADDR)).toBeNull();
@@ -118,6 +181,18 @@ describe("uniswap decoder", () => {
expect(tokenIn.value).toContain("USDT");
expect(tokenIn.address.toLowerCase()).toBe(USDT_ADDR.toLowerCase());
// Genuine native ETH on the output side, on a real mainnet fixture:
// V4's TAKE names it as Currency.wrap(address(0)), which reaches the
// decoder as the explicit zero address and must still read as ETH.
const tokenOut = result.details.find((d) => d.label === "Token Out");
expect(tokenOut.value).toBe("ETH");
expect(result.details.find((d) => d.label === "Amount").value).toBe(
"Unlimited",
);
expect(
result.details.find((d) => d.label === "Min. received").value,
).toBe("0.0002 ETH");
const steps = result.details.find((d) => d.label === "Steps");
expect(steps.value).toContain("Permit2 Permit");
expect(steps.value).toContain("V4 Swap");
@@ -181,8 +256,7 @@ describe("uniswap decoder", () => {
expect(tokenIn.value).toBe("ETH (native)");
const amount = result.details.find((d) => d.label === "Amount");
expect(amount.value).toContain("1.0000");
expect(amount.value).toContain("ETH");
expect(amount.value).toBe("1.0000 ETH");
});
test("decodes UNWRAP_WETH as ETH output", () => {
@@ -338,6 +412,211 @@ describe("uniswap decoder", () => {
expect(steps.value).toContain("V4 Swap");
});
// https://git.eeqj.de/sneak/AutistMask/issues/364 — the input half.
//
// Fails against c9ebac8: `if (!inputAmount) inputAmount = s.amountIn`
// cannot tell the V3 hop's genuine 0n from "not yet set", so the V2 hop's
// 0.5 WETH overwrote it while Token In stayed pinned to the V3 hop's USDT.
// Observed there: Amount = "500000000000.0000 USDT".
test("a hop's zero amountIn is a real amount, not an opening for the next hop's figure", () => {
const data = buildExecute(
solidityPacked(["uint8", "uint8"], [0x00, 0x08]),
[
encodeV3SwapExactIn(USER_ADDR, 0n, 0n, [USDT_ADDR, WETH_ADDR]),
encodeV2SwapExactIn(
USER_ADDR,
500000000000000000n, // 0.5 WETH
1000000n,
[WETH_ADDR, USDC_ADDR],
),
],
9999999999n,
);
const result = uniswap.decode(data, ROUTER_ADDR);
expect(result).not.toBeNull();
// The amount and the token it is counted in come from the same hop.
expect(detail(result, "Token In").address.toLowerCase()).toBe(
USDT_ADDR.toLowerCase(),
);
expect(detail(result, "Amount").value).toBe("0.0000 USDT");
expect(detail(result, "Amount").value).not.toContain("500000000000");
});
// https://git.eeqj.de/sneak/AutistMask/issues/359 — the output half, in
// the shape the issue measured: a V4 step that states a minimum of zero
// and names no output currency.
//
// Fails against c9ebac8: `if (v4.amountOutMin) minOutput = ...` and the
// `else if` beside it both read 0n as absent, so neither the figure nor
// the token moved. Observed there: Token Out = "WETH (0xC02aaA39...)" and
// Min. received = "0.5000 WETH" — the V3 hop's guarantee shown for a
// transaction whose final leg guarantees nothing.
test("a V4 step with a zero amountOutMin states no minimum instead of keeping an earlier hop's", () => {
const data = buildExecute(
solidityPacked(["uint8", "uint8"], [0x00, 0x10]),
[
encodeV3SwapExactIn(USER_ADDR, 2000000n, 500000000000000000n, [
USDT_ADDR,
WETH_ADDR,
]),
encodeV4Swap(new Uint8Array([V4_SWAP_EXACT_IN]), [
encodeV4ExactIn(
WETH_ADDR,
[], // no path: this step names no output currency
1000000000000000000n,
0n, // no slippage floor at all
),
]),
],
9999999999n,
);
const result = uniswap.decode(data, ROUTER_ADDR);
expect(result).not.toBeNull();
expect(detail(result, "Token Out").value).toBe(
"Unknown (not named in the calldata)",
);
expect(detail(result, "Min. received").value).toBe(
"None (no minimum guaranteed)",
);
expect(detail(result, "Min. received").value).not.toContain("0.5000");
});
// The same zero floor, but with the final leg's output currency named:
// the figure must belong to the token beside it. Against c9ebac8 this
// rendered Token Out = USDC with Min. received = "500000000000.0000 USDC",
// the V3 hop's 0.5e18 WETH figure re-scaled to USDC's six decimals.
test("a zero minimum is stated against the token that supplied it", () => {
const data = buildExecute(
solidityPacked(["uint8", "uint8"], [0x00, 0x10]),
[
encodeV3SwapExactIn(USER_ADDR, 2000000n, 500000000000000000n, [
USDT_ADDR,
WETH_ADDR,
]),
encodeV4Swap(
new Uint8Array([
V4_SETTLE_ID,
V4_SWAP_EXACT_IN_SINGLE_ID,
V4_TAKE_ID,
]),
[
encodeV4Settle(WETH_ADDR),
encodeV4ExactInSingle(
WETH_ADDR,
USDC_ADDR,
1000000000000000000n,
0n,
),
encodeV4Take(USDC_ADDR),
],
),
],
9999999999n,
);
const result = uniswap.decode(data, ROUTER_ADDR);
expect(result).not.toBeNull();
expect(detail(result, "Token Out").value).toContain("USDC");
expect(detail(result, "Min. received").value).toBe(
"None (no minimum guaranteed)",
);
});
// The other half of the same invariant: a final leg that names an output
// currency but no minimum leaves Min. received unstated. Against c9ebac8
// the V3 hop's figure stayed on screen beside the new token, rendering
// "500000000000.0000 USDC".
test("a final leg with no minimum drops the line rather than keeping an earlier hop's figure", () => {
const data = buildExecute(
solidityPacked(["uint8", "uint8"], [0x00, 0x10]),
[
encodeV3SwapExactIn(USER_ADDR, 2000000n, 500000000000000000n, [
USDT_ADDR,
WETH_ADDR,
]),
encodeV4Swap(new Uint8Array([V4_SETTLE_ID, V4_TAKE_ID]), [
encodeV4Settle(WETH_ADDR),
encodeV4Take(USDC_ADDR),
]),
],
9999999999n,
);
const result = uniswap.decode(data, ROUTER_ADDR);
expect(result).not.toBeNull();
expect(detail(result, "Token Out").value).toContain("USDC");
expect(detail(result, "Min. received")).toBeUndefined();
});
// V4 spells "swap the whole open delta" as an amountIn of zero
// (v4-periphery ActionConstants.OPEN_DELTA = 0, applied by V4Router's
// _swapExactInputSingle / _swapExactInput). It is not a quantity, and
// printing "0.0000 WETH" for it would state the exact inverse of what the
// step does. Against c9ebac8 the Amount line was omitted entirely.
test("a V4 open-delta amountIn is named, not printed as zero", () => {
const data = buildExecute(
"0x10",
[
encodeV4Swap(
new Uint8Array([
V4_SETTLE_ID,
V4_SWAP_EXACT_IN_SINGLE_ID,
V4_TAKE_ID,
]),
[
encodeV4Settle(WETH_ADDR),
encodeV4ExactInSingle(
WETH_ADDR,
USDC_ADDR,
0n, // ActionConstants.OPEN_DELTA
990000n,
),
encodeV4Take(USDC_ADDR),
],
),
],
9999999999n,
);
const result = uniswap.decode(data, ROUTER_ADDR);
expect(result).not.toBeNull();
expect(detail(result, "Token In").value).toContain("WETH");
expect(detail(result, "Amount").value).toBe(
"All available (V4 open delta)",
);
expect(detail(result, "Min. received").value).toBe("0.9900 USDC");
});
// Pins what https://git.eeqj.de/sneak/AutistMask/pulls/356 changed without
// testing: a non-swap execute() carrying only PERMIT2_PERMIT names no
// output currency, so it says so and titles itself "Uniswap Swap" rather
// than inventing "Token Out: ETH".
test("a PERMIT2_PERMIT-only execute() invents no output token", () => {
const data = buildExecute(
"0x0a",
[encodePermit2(USDT_ADDR, 5000000n, ROUTER_ADDR)],
9999999999n,
);
const result = uniswap.decode(data, ROUTER_ADDR);
expect(result).not.toBeNull();
expect(result.name).toBe("Uniswap Swap");
expect(detail(result, "Token In").value).toContain("USDT");
expect(detail(result, "Token Out").value).toBe(
"Unknown (not named in the calldata)",
);
expect(detail(result, "Token Out").address).toBeUndefined();
expect(detail(result, "Min. received")).toBeUndefined();
});
test("handles unknown tokens gracefully", () => {
const fakeToken = "0x1111111111111111111111111111111111111111";
const data = buildExecute(

View File

@@ -0,0 +1,107 @@
// The output token of a swap, as the dApp approval screen names it.
//
// Issue #346: the `Token Out` detail line in `src/shared/uniswap.js` was
// pushed only `if (outSymbol)`, and a token absent from the bundled list has
// no symbol. The line was therefore dropped entirely for exactly that
// population — which is every newly listed token — leaving a `Min. received`
// figure with nothing on the screen saying what is being received. The
// `Token In` line already falls back to the address; the rule asserted here is
// that the output side does too, always.
const { AbiCoder, Interface, getAddress } = require("ethers");
const uniswap = require("../src/shared/uniswap");
const { unknownDecimalsAmount } = require("../src/shared/approvalAmount");
const ROUTER = "0x66a9893cc07d91d95644aedd05d03f95e1dba8af";
const RECIPIENT = "0xC0FfEE0000000000000000000000000000c0fFEe";
// In the bundled list, at 18 decimals.
const WETH = "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2";
// Outside it, as every newly listed token is. Checksummed, because that is
// the form the decoder gets back from the ABI decode and puts on the line.
const NOVEL_OUT = getAddress("0xd0d0000000000000000000000000000000000d0d");
const HALF_WETH = 500000000000000000n;
// 1,000.00 of a 6-decimal token.
const THOUSAND_AT_SIX = 1000000000n;
const coder = AbiCoder.defaultAbiCoder();
const routerIface = new Interface([
"function execute(bytes commands, bytes[] inputs, uint256 deadline)",
]);
// A V2_SWAP_EXACT_IN (command 0x08) execute() call.
function swapData(tokenIn, amountIn, tokenOut, amountOutMin) {
const input = coder.encode(
["address", "uint256", "uint256", "address[]", "bool"],
[RECIPIENT, amountIn, amountOutMin, [tokenIn, tokenOut], true],
);
return routerIface.encodeFunctionData("execute", [
"0x08",
[input],
9999999999n,
]);
}
// An UNWRAP_WETH (command 0x0c) execute() call: the output side is native ETH,
// which has no contract address to name.
function unwrapData() {
return routerIface.encodeFunctionData("execute", [
"0x0c",
[coder.encode(["address", "uint256"], [RECIPIENT, HALF_WETH])],
9999999999n,
]);
}
function detail(data, label, sources) {
const decoded = uniswap.decode(data, ROUTER, sources || {});
return decoded.details.find((d) => d.label === label);
}
describe("a swap to a token absent from the bundled list", () => {
const data = () => swapData(WETH, HALF_WETH, NOVEL_OUT, THOUSAND_AT_SIX);
test("still renders a Token Out line, naming the address", () => {
const out = detail(data(), "Token Out");
expect(out).toBeDefined();
expect(out.value).toBe(NOVEL_OUT);
expect(out.address).toBe(NOVEL_OUT);
expect(out.isToken).toBe(true);
});
test("names the address alongside the unknown-scale refusal", () => {
// The same population hits both: no symbol, and no scale either. The
// address says which token, the base units say how much and admit the
// scale is unknown — neither line silently means something else.
expect(detail(data(), "Token Out").value).toBe(NOVEL_OUT);
expect(detail(data(), "Min. received").value).toBe(
unknownDecimalsAmount(THOUSAND_AT_SIX),
);
});
test("names the address when the scale is known but the symbol is not", () => {
const sources = {
trackedTokens: [
{ address: NOVEL_OUT, symbol: "NOVEL", decimals: 6 },
],
};
expect(detail(data(), "Token Out", sources).value).toBe(NOVEL_OUT);
expect(detail(data(), "Min. received", sources).value).toBe(
"1000.0000",
);
});
});
describe("the output tokens that already had a line keep it unchanged", () => {
test("a bundled token is named by symbol and address", () => {
const data = swapData(NOVEL_OUT, THOUSAND_AT_SIX, WETH, HALF_WETH);
const out = detail(data, "Token Out");
expect(out.value).toBe("WETH (" + WETH + ")");
expect(out.address).toBe(WETH);
});
test("an unwrap to native ETH is named ETH, with no address", () => {
const out = detail(unwrapData(), "Token Out");
expect(out.value).toBe("ETH");
expect(out.address).toBeUndefined();
});
});

View File

@@ -0,0 +1,182 @@
// What the dApp approval screen says the input token of a swap is when the
// calldata did not name one.
//
// Issue #357, the twin on the input side of
// https://git.eeqj.de/sneak/AutistMask/issues/353: `tokenInfo(null)` answered
// `{symbol: "ETH", decimals: 18}`, so a null `inputToken` rendered as
// `Token In: ETH (native)` and titled the swap `Swap ETH -> X`. An
// undetermined input was therefore asserted to the user as native ETH — the
// same class as https://git.eeqj.de/sneak/AutistMask/issues/340 and
// https://git.eeqj.de/sneak/AutistMask/issues/306, naming the wrong asset
// rather than merely mis-scaling it.
//
// The determination this file pins is the one #353 established, checked here
// on the input side: null is NOT how native ETH arrives. v4-core declares
// `type Currency is address` and wraps `address(0)` for native ETH, and a
// user-defined value type over `address` carries the plain `address` ABI
// encoding, so a native-ETH currency reaches the decoder as the truthy string
// "0x0000000000000000000000000000000000000000". WRAP_ETH sets that same
// explicit zero address. Both halves are asserted below: the zero address
// stays ETH, and null refuses.
const { AbiCoder, Interface, solidityPacked } = require("ethers");
const uniswap = require("../src/shared/uniswap");
const ROUTER = "0x66a9893cc07d91d95644aedd05d03f95e1dba8af";
const USER = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
const ZERO = "0x0000000000000000000000000000000000000000";
// Both in the bundled list: USDT at 6 decimals, USDC at 6.
const USDT = "0xdAC17F958D2ee523a2206206994597C13D831ec7";
const USDC = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48";
// The same wording the output side refuses with — one screen, one vocabulary.
const REFUSAL = "Unknown (not named in the calldata)";
const ONE_ETH = 1000000000000000000n;
const V4_SWAP_EXACT_IN = 0x07;
const V4_SETTLE = 0x0b;
const V4_TAKE = 0x0e;
const coder = AbiCoder.defaultAbiCoder();
const routerIface = new Interface([
"function execute(bytes commands, bytes[] inputs, uint256 deadline)",
]);
function execute(commands, inputs) {
return routerIface.encodeFunctionData("execute", [
commands,
inputs,
9999999999n,
]);
}
function v4Input(actions, params) {
return coder.encode(
["bytes", "bytes[]"],
[new Uint8Array(actions), params],
);
}
// IV4Router.ExactInputParams as the decoder reads it:
// (Currency currencyIn, PathKey[] path, uint128 amountIn, uint128 minOut).
function exactInParams(currencyIn, path, amountIn, amountOutMin) {
return coder.encode(
[
"tuple(address,tuple(address,uint24,int24,address,bytes)[],uint128,uint128)",
],
[[currencyIn, path, amountIn, amountOutMin]],
);
}
// BALANCE_CHECK_ERC20 (command 0x0e): (address owner, address token,
// uint256 minBalance). It names the output token and nothing about the input.
function balanceCheck(token, minBalance) {
return coder.encode(
["address", "address", "uint256"],
[USER, token, minBalance],
);
}
function detail(data, label) {
const decoded = uniswap.decode(data, ROUTER, {});
expect(decoded).not.toBeNull();
return decoded.details.find((d) => d.label === label);
}
describe("an execute() whose input currency never decoded", () => {
// A V4_SWAP whose sub-action params do not decode — an action encoding
// this decoder does not know — leaves every V4 token null. The
// BALANCE_CHECK still names the output, so the screen has a Min. received
// figure and a Token Out, and previously claimed the user was paying ETH
// for them.
const data = () =>
execute(solidityPacked(["uint8", "uint8"], [0x10, 0x0e]), [
coder.encode(["bytes", "bytes[]"], ["0x07", ["0x"]]),
balanceCheck(USDC, 2000000n),
]);
test("says the input token is unknown instead of naming ETH", () => {
expect(detail(data(), "Token In").value).toBe(REFUSAL);
});
test("keeps a Token In line, so the screen never omits what is paid", () => {
const tokenIn = detail(data(), "Token In");
expect(tokenIn).toBeDefined();
// A refusal is not a token: nothing to link to an explorer.
expect(tokenIn.address).toBeUndefined();
expect(tokenIn.isToken).toBeUndefined();
});
test("does not name ETH in the swap title either", () => {
expect(uniswap.decode(data(), ROUTER, {}).name).toBe("Uniswap Swap");
});
});
describe("an execute() that names only an output token", () => {
// A lone BALANCE_CHECK_ERC20: nothing in it says what is being paid.
const data = () => execute("0x0e", [balanceCheck(USDT, 2000000n)]);
test("refuses the input rather than defaulting it to ETH", () => {
expect(detail(data(), "Token In").value).toBe(REFUSAL);
expect(uniswap.decode(data(), ROUTER, {}).name).toBe("Uniswap Swap");
});
test("still states the output it did establish", () => {
expect(detail(data(), "Token Out").value).toContain("USDT");
expect(detail(data(), "Min. received").value).toBe("2.0000 USDT");
});
});
describe("native ETH in, which V4 spells as the zero address", () => {
test("a Currency of address(0) decodes to a truthy address string", () => {
// The fact the whole determination rests on: an absent input currency
// and a native-ETH one are distinguishable here, because the ABI
// decoder never yields null for an address word.
const [currency] = coder.decode(
["address", "uint256", "bool"],
coder.encode(["address", "uint256", "bool"], [ZERO, ONE_ETH, true]),
);
expect(currency).toBe(ZERO);
expect(Boolean(currency)).toBe(true);
});
test("a V4 SETTLE of the zero address is still ETH at 18 decimals", () => {
const data = execute("0x10", [
v4Input(
[V4_SETTLE, V4_SWAP_EXACT_IN, V4_TAKE],
[
coder.encode(
["address", "uint256", "bool"],
[ZERO, ONE_ETH, true],
),
exactInParams(
ZERO,
[[USDT, 500, 10, ZERO, "0x"]],
ONE_ETH,
2000000n,
),
coder.encode(
["address", "address", "uint256"],
[USDT, USER, 0n],
),
],
),
]);
expect(detail(data, "Token In").value).toBe("ETH (native)");
expect(detail(data, "Amount").value).toBe("1.0000 ETH");
expect(uniswap.decode(data, ROUTER, {}).name).toBe("Swap ETH → USDT");
});
test("WRAP_ETH is still ETH at 18 decimals", () => {
// WRAP_ETH names no currency of its own; the decoder supplies the
// explicit zero address for it, which is why it survives this change.
const data = execute("0x0b", [
coder.encode(["address", "uint256"], [ROUTER, ONE_ETH]),
]);
expect(detail(data, "Token In").value).toBe("ETH (native)");
expect(detail(data, "Amount").value).toBe("1.0000 ETH");
});
});

View File

@@ -0,0 +1,211 @@
// What the dApp approval screen says the output token of a V4 swap is when the
// calldata did not name one.
//
// Issue #353: `src/shared/uniswap.js` took a V4 step's `amountOutMin` while
// leaving `outputToken` null, and `tokenInfo(null)` answers
// `{symbol: "ETH", decimals: 18}`. An undetermined output was therefore stated
// to the user as ETH, with `Min. received` formatted at 18 decimals — the same
// class as https://git.eeqj.de/sneak/AutistMask/issues/340 and
// https://git.eeqj.de/sneak/AutistMask/issues/306, but naming the wrong asset
// rather than the wrong scale.
//
// The determination this file pins: null is NOT how V4 spells native ETH.
// v4-core declares `type Currency is address` and wraps `address(0)` for
// native ETH, and a Currency is ABI-encoded as a plain address word, so a
// native-ETH currency reaches the decoder as the string
// "0x0000000000000000000000000000000000000000" — truthy, and already named
// ETH by tokenInfo(). Both cases are asserted below: the zero address stays
// ETH, and null refuses.
const { AbiCoder, Interface, solidityPacked } = require("ethers");
const uniswap = require("../src/shared/uniswap");
const { unknownDecimalsAmount } = require("../src/shared/approvalAmount");
const ROUTER = "0x66a9893cc07d91d95644aedd05d03f95e1dba8af";
const USER = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
const ZERO = "0x0000000000000000000000000000000000000000";
// Both in the bundled list: USDT at 6 decimals, WETH at 18.
const USDT = "0xdAC17F958D2ee523a2206206994597C13D831ec7";
const WETH = "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2";
const REFUSAL = "Unknown (not named in the calldata)";
// The figure whose asset is in question. At 18 decimals it renders as
// 0.000000000001; in base units it renders as itself.
const MIN_OUT = 1234567n;
const HALF_ETH = 500000000000000000n;
const V4_SWAP_EXACT_IN_SINGLE = 0x06;
const V4_SWAP_EXACT_IN = 0x07;
const V4_SETTLE = 0x0b;
const V4_TAKE = 0x0e;
const coder = AbiCoder.defaultAbiCoder();
const routerIface = new Interface([
"function execute(bytes commands, bytes[] inputs, uint256 deadline)",
]);
function execute(commands, inputs) {
return routerIface.encodeFunctionData("execute", [
commands,
inputs,
9999999999n,
]);
}
function v4Input(actions, params) {
return coder.encode(
["bytes", "bytes[]"],
[new Uint8Array(actions), params],
);
}
// IV4Router.ExactInputParams as the decoder reads it:
// (Currency currencyIn, PathKey[] path, uint128 amountIn, uint128 minOut).
// An empty path names no output currency at all.
function exactInParams(currencyIn, path, amountIn, amountOutMin) {
return coder.encode(
[
"tuple(address,tuple(address,uint24,int24,address,bytes)[],uint128,uint128)",
],
[[currencyIn, path, amountIn, amountOutMin]],
);
}
// IV4Router.ExactInputSingleParams: (PoolKey, bool zeroForOne, uint128
// amountIn, uint128 minOut, bytes hookData).
function exactInSingleParams(currency0, currency1, zeroForOne, min) {
return coder.encode(
[
"tuple(tuple(address,address,uint24,int24,address),bool,uint128,uint128,bytes)",
],
[
[
[currency0, currency1, 500, 10, ZERO],
zeroForOne,
1000000n,
min,
"0x",
],
],
);
}
// V3_SWAP_EXACT_IN (command 0x00) input: path is token(20) fee(3) token(20).
function v3ExactIn(tokenIn, tokenOut, amountIn, amountOutMin) {
const path =
"0x" +
tokenIn.slice(2).toLowerCase() +
"000bb8" +
tokenOut.slice(2).toLowerCase();
return coder.encode(
["address", "uint256", "uint256", "bytes", "bool"],
[USER, amountIn, amountOutMin, path, true],
);
}
function detail(data, label) {
const decoded = uniswap.decode(data, ROUTER, {});
expect(decoded).not.toBeNull();
return decoded.details.find((d) => d.label === label);
}
describe("a V4 step that states a Min. received but no output currency", () => {
// SWAP_EXACT_IN with an empty path: amountOutMin decodes, no currency out
// does, and there is no TAKE to supply one.
const data = () =>
execute("0x10", [
v4Input(
[V4_SWAP_EXACT_IN],
[exactInParams(USDT, [], 5000000n, MIN_OUT)],
),
]);
test("says the output token is unknown instead of naming ETH", () => {
expect(detail(data(), "Token Out").value).toBe(REFUSAL);
});
test("keeps a Token Out line, so the figure is never unattributed", () => {
const out = detail(data(), "Token Out");
expect(out).toBeDefined();
// A refusal is not a token: nothing to link to an explorer.
expect(out.address).toBeUndefined();
expect(out.isToken).toBeUndefined();
});
test("refuses to scale Min. received rather than assuming 18", () => {
expect(detail(data(), "Min. received").value).toBe(
unknownDecimalsAmount(MIN_OUT),
);
});
test("does not name ETH in the swap title either", () => {
const decoded = uniswap.decode(data(), ROUTER, {});
expect(decoded.name).toBe("Uniswap Swap");
});
});
describe("a V4 step whose Min. received supersedes an earlier step's", () => {
// V3 USDT -> WETH, then a V4 step that carries the final Min. received but
// names no currency. The figure belongs to the V4 step, so it must not be
// read against the token the V3 step named.
const data = () =>
execute(solidityPacked(["uint8", "uint8"], [0x00, 0x10]), [
v3ExactIn(USDT, WETH, 5000000n, HALF_ETH),
v4Input(
[V4_SWAP_EXACT_IN],
[exactInParams(WETH, [], HALF_ETH, MIN_OUT)],
),
]);
test("does not keep naming the earlier step's output token", () => {
expect(detail(data(), "Token Out").value).toBe(REFUSAL);
});
test("refuses to scale the superseding figure", () => {
expect(detail(data(), "Min. received").value).toBe(
unknownDecimalsAmount(MIN_OUT),
);
});
});
describe("native ETH out, which V4 spells as the zero address", () => {
// The pin on the other half of the determination. PoolKey currencies are
// ordered, so native ETH is currency0; zeroForOne false swaps USDT in for
// ETH out. TAKE names the same zero-address currency.
const data = () =>
execute("0x10", [
v4Input(
[V4_SETTLE, V4_SWAP_EXACT_IN_SINGLE, V4_TAKE],
[
coder.encode(
["address", "uint256", "bool"],
[USDT, 5000000n, true],
),
exactInSingleParams(ZERO, USDT, false, HALF_ETH),
coder.encode(
["address", "address", "uint256"],
[ZERO, USER, 0n],
),
],
),
]);
test("a Currency of address(0) decodes to a truthy address string", () => {
// The fact the whole determination rests on: an absent output currency
// and a native-ETH one are distinguishable here, because the ABI
// decoder never yields null for an address word.
const [currency] = coder.decode(
["address", "address", "uint256"],
coder.encode(["address", "address", "uint256"], [ZERO, USER, 0n]),
);
expect(currency).toBe(ZERO);
expect(Boolean(currency)).toBe(true);
});
test("is still named ETH and still scaled at 18 decimals", () => {
expect(detail(data(), "Token Out").value).toBe("ETH");
expect(detail(data(), "Min. received").value).toBe("0.5000 ETH");
expect(uniswap.decode(data(), ROUTER, {}).name).toBe("Swap USDT → ETH");
});
});

View File

@@ -0,0 +1,176 @@
// The scale the swap lines of the dApp approval screen are displayed with.
//
// Issue #340: `tokenInfo()` in `src/shared/uniswap.js` returned `decimals: 18`
// for any token absent from the bundled list — the same guess
// https://git.eeqj.de/sneak/AutistMask/issues/306 removed from the ERC-20
// amount line, still live on the swap path. A 1,000-token swap of a 6-decimal
// token then rendered as `0.000000001` on the one screen whose job is to state
// what is being authorized, and every newly listed token reaches it.
//
// What is asserted here is the rule #306 established: resolve the real scale
// wherever the wallet already has it, and where nothing has it refuse to
// format — base units with the scale stated, never a quantity.
globalThis.chrome = {
storage: { local: { get: async () => ({}), set: async () => {} } },
};
const { AbiCoder, Interface } = require("ethers");
const { state } = require("../src/shared/state");
const { unknownDecimalsAmount } = require("../src/shared/approvalAmount");
const { decodeCalldata } = require("../src/popup/views/approval");
const ROUTER = "0x66a9893cc07d91d95644aedd05d03f95e1dba8af";
const RECIPIENT = "0xC0FfEE0000000000000000000000000000c0fFEe";
// Outside the bundled list, as every newly listed token is.
const NOVEL = "0xE2E0000000000000000000000000000000000E2e";
// Also outside it, standing in for the swap's output side.
const NOVEL_OUT = "0xd0d0000000000000000000000000000000000d0d";
// In the bundled list, at 18 decimals.
const WETH = "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2";
// 1,000.00 of a 6-decimal token — the amount from the issue, which the 18
// guess rendered as 0.000000001.
const THOUSAND_AT_SIX = 1000000000n;
// 0.5 WETH out, so the Min. received line has a real number of its own.
const HALF_WETH = 500000000000000000n;
const coder = AbiCoder.defaultAbiCoder();
const routerIface = new Interface([
"function execute(bytes commands, bytes[] inputs, uint256 deadline)",
]);
// A V2_SWAP_EXACT_IN (command 0x08) execute() call: `amountIn` of `tokenIn`
// for at least `amountOutMin` of `tokenOut`.
function swapData(tokenIn, amountIn, tokenOut, amountOutMin) {
const input = coder.encode(
["address", "uint256", "uint256", "address[]", "bool"],
[RECIPIENT, amountIn, amountOutMin, [tokenIn, tokenOut], true],
);
return routerIface.encodeFunctionData("execute", [
"0x08",
[input],
9999999999n,
]);
}
// A wallet holding `token` with the decimals the block explorer reported,
// shaped as balances.js writes it onto state.
function walletsHolding(token, decimals) {
return [
{
name: "Wallet 1",
addresses: [
{
address: "0x" + "a".repeat(40),
balance: "1.0",
tokenBalances: [
{
address: token,
symbol: "NOVEL",
decimals,
balance: "1000.0",
},
],
},
],
},
];
}
// The swap detail line as the approval screen renders it. It goes through
// decodeCalldata() rather than uniswap.decode() directly, because the scale
// sources the screen supplies are part of what is under test.
function swapDetail(data, label) {
const decoded = decodeCalldata(data, ROUTER);
return decoded.details.find((d) => d.label === label);
}
beforeEach(() => {
state.trackedTokens = [];
state.wallets = [];
});
describe("a swap of a token outside the bundled list", () => {
const data = () => swapData(NOVEL, THOUSAND_AT_SIX, WETH, HALF_WETH);
test("shows the true quantity when the user tracks the token", () => {
state.trackedTokens = [
{ address: NOVEL, symbol: "NOVEL", decimals: 6 },
];
expect(swapDetail(data(), "Amount").value).toBe("1000.0000");
});
test("shows the true quantity from the explorer's decimals", () => {
state.wallets = walletsHolding(NOVEL, "6");
expect(swapDetail(data(), "Amount").value).toBe("1000.0000");
});
test("refuses to format when nothing knows the scale", () => {
const detail = swapDetail(data(), "Amount");
expect(detail.value).toBe("1000000000 base units (decimals unknown)");
expect(detail.value).toBe(unknownDecimalsAmount(THOUSAND_AT_SIX));
// The defect: an 18-decimal guess renders this swap as 0.000000001, a
// quantity, and a wrong one.
expect(detail.value).not.toMatch(/^0\./);
expect(detail.value).not.toMatch(/[0-9]\.[0-9]/);
});
test("the amount carried to the status screens is the same refusal", () => {
expect(swapDetail(data(), "Amount").rawValue).toBe(
"1000000000 base units (decimals unknown)",
);
});
test("a bundled token on the other side still formats", () => {
expect(swapDetail(data(), "Min. received").value).toBe("0.5000 WETH");
});
});
describe("the Min. received line takes the same rule", () => {
test("refuses to format an output token of unknown scale", () => {
const data = swapData(WETH, HALF_WETH, NOVEL_OUT, THOUSAND_AT_SIX);
const detail = swapDetail(data, "Min. received");
expect(detail.value).toBe("1000000000 base units (decimals unknown)");
expect(detail.value).not.toMatch(/[0-9]\.[0-9]/);
});
test("shows the true quantity when the user tracks the output token", () => {
state.trackedTokens = [
{ address: NOVEL_OUT, symbol: "NOVEL", decimals: 6 },
];
const data = swapData(WETH, HALF_WETH, NOVEL_OUT, THOUSAND_AT_SIX);
expect(swapDetail(data, "Min. received").value).toBe("1000.0000");
});
});
describe("the permit amount takes the same rule", () => {
// PERMIT2_PERMIT (command 0x0a): the input token and amount come from the
// permit rather than from a swap step.
function permitData(token, amount) {
const input = coder.encode(
[
"tuple(tuple(address,uint160,uint48,uint48),address,uint256)",
"bytes",
],
[[[token, amount, 0, 0], ROUTER, 9999999999], "0x1234"],
);
return routerIface.encodeFunctionData("execute", [
"0x0a",
[input],
9999999999n,
]);
}
test("refuses to format a permit on a token of unknown scale", () => {
const detail = swapDetail(permitData(NOVEL, THOUSAND_AT_SIX), "Amount");
expect(detail.value).toBe("1000000000 base units (decimals unknown)");
});
test("an unbounded permit is still named, with or without a scale", () => {
const maxUint160 = (1n << 160n) - 1n;
expect(swapDetail(permitData(NOVEL, maxUint160), "Amount").value).toBe(
"Unlimited",
);
});
});

View File

@@ -0,0 +1,185 @@
// What the screens that READ a stored token balance do with a holding whose
// scale nothing knows.
//
// https://git.eeqj.de/sneak/AutistMask/issues/349 stopped `fetchTokenBalances()`
// fabricating a scale of 18, so a row it cannot state a quantity for is now
// stored with `balance: null`. Every reader of that field therefore has two
// distinct inputs where it used to have one, and the property that has to hold
// at each of them is the same one this codebase keeps losing:
//
// null (unknown) and 0 (genuinely zero) must produce DIFFERENT output.
//
// Losing it is what https://git.eeqj.de/sneak/AutistMask/issues/246,
// https://git.eeqj.de/sneak/AutistMask/issues/306,
// https://git.eeqj.de/sneak/AutistMask/issues/322,
// https://git.eeqj.de/sneak/AutistMask/issues/359 and
// https://git.eeqj.de/sneak/AutistMask/issues/364 each were. So every case
// below asserts the pair, not just that the null branch does something
// reasonable: an assertion on the null alone still passes on a build that
// renders both as zero, which is precisely the build being guarded against.
//
// The writer half — that the fetcher stores null rather than 18 — is in
// tests/fabricatedDecimals.test.js, and the Send and confirmation screens are
// in tests/unknownScaleSend.test.js.
"use strict";
// helpers.js reaches for both at module scope through the modules it pulls in.
globalThis.chrome = {
storage: {
local: {
get: () => Promise.resolve({}),
set: () => Promise.resolve(),
},
},
runtime: { sendMessage: () => {} },
};
globalThis.document = {
getElementById: () => null,
createElement: () => ({ style: {}, classList: { toggle() {} } }),
body: { prepend: () => {} },
addEventListener: () => {},
};
const {
balanceLine,
balanceLinesForAddress,
addressHoldsFunds,
} = require("../src/popup/views/helpers");
const {
prices,
clearPrices,
getAddressValue,
} = require("../src/shared/prices");
const { state } = require("../src/shared/state");
const NOVEL = "0x1111111111111111111111111111111111111111";
// One stored tokenBalances row. `balance: null` is what balances.js writes for
// a holding whose scale nothing knows; "0.0" is a quantity that was actually
// established and is zero.
function holding(balance) {
return {
address: NOVEL,
symbol: "NOVEL",
decimals: balance === null ? null : 18,
balance,
holders: 50000,
};
}
function address(balance) {
return {
address: "0x" + "a".repeat(40),
balance: "0",
tokenBalances: [holding(balance)],
};
}
// The quantity cell of a rendered row, which is the second of the two spans
// inside the fixed-width span.
function quantities(html) {
return [...html.matchAll(/<span>([^<]*)<\/span>/g)].map((m) => m[1]);
}
beforeEach(() => {
clearPrices();
state.wallets = [];
state.trackedTokens = [];
state.activeAddress = null;
});
afterEach(() => {
clearPrices();
});
describe("balanceLine", () => {
test("an unknown quantity and a zero one render differently", () => {
const unknown = balanceLine("NOVEL", null, null, NOVEL);
const zero = balanceLine("NOVEL", 0, null, NOVEL);
expect(unknown).not.toBe(zero);
expect(quantities(unknown)).toEqual(["NOVEL", "quantity unknown"]);
expect(quantities(zero)).toEqual(["NOVEL", "0.0000"]);
});
test("an unknown quantity produces no fiat figure, a zero one does", () => {
prices.NOVEL = 3;
const unknown = balanceLine("NOVEL", null, 3, NOVEL);
const zero = balanceLine("NOVEL", 0, 3, NOVEL);
// A price times an unknown quantity is not $0.00: that is the same
// claim of "nothing here" the quantity cell just refused to make.
expect(unknown).toContain(
'<span class="text-right text-muted flex-1">&nbsp;</span>',
);
expect(zero).toContain(
'<span class="text-right text-muted flex-1">$0.00</span>',
);
});
});
describe("balanceLinesForAddress", () => {
// The show-zero setting is a statement about zeroes. An unknown quantity
// is not one, so hiding the row would assert the zero nobody established
// and the holding would vanish from the list entirely.
test("hiding zero balances hides the zero row and keeps the unknown one", () => {
const unknown = balanceLinesForAddress(address(null), [], false);
const zero = balanceLinesForAddress(address("0.0"), [], false);
expect(unknown).not.toBe(zero);
expect(unknown).toContain("quantity unknown");
expect(unknown).toContain("NOVEL");
expect(zero).not.toContain("NOVEL");
});
test("showing zero balances still tells the two apart", () => {
const unknown = balanceLinesForAddress(address(null), [], true);
const zero = balanceLinesForAddress(address("0.0"), [], true);
expect(unknown).not.toBe(zero);
expect(quantities(unknown)).toEqual([
"ETH",
"0.0000",
"NOVEL",
"quantity unknown",
]);
expect(quantities(zero)).toEqual(["ETH", "0.0000", "NOVEL", "0.0000"]);
});
});
describe("addressHoldsFunds", () => {
// Read by deleteAddress.js to decide whether removing the address is
// warned about. balances.js drops a row of zero base units before any
// scale is consulted, so a row that survived with no quantity is holding
// something, and the warning must err towards warning.
test("an unknown balance holds funds, a zero balance does not", () => {
expect(addressHoldsFunds(address(null))).toBe(true);
expect(addressHoldsFunds(address("0.0"))).toBe(false);
});
});
describe("getAddressValue", () => {
// `usd` is the value of what could be priced and `partial` says it is a
// floor rather than the total. An unpriceable holding is exactly what
// `partial` exists for; a holding of zero can neither add to the total nor
// make it incomplete.
test("an unknown balance makes the total partial, a zero balance does not", () => {
prices.ETH = 2000;
prices.NOVEL = 3;
const unknown = getAddressValue(address(null));
const zero = getAddressValue(address("0.0"));
expect(unknown).not.toEqual(zero);
expect(unknown).toEqual({ usd: 0, partial: true });
expect(zero).toEqual({ usd: 0, partial: false });
});
test("an unknown balance is not priced as zero of the token", () => {
prices.ETH = 2000;
prices.NOVEL = 3;
// The same row with a real quantity of 10 is worth $30. Neither that
// figure nor a confident $0.00 may be stated for the unknown one.
expect(getAddressValue(address("10.0"))).toEqual({
usd: 30,
partial: false,
});
expect(getAddressValue(address(null)).usd).toBe(0);
expect(getAddressValue(address(null)).partial).toBe(true);
});
});

View File

@@ -0,0 +1,455 @@
// The Send and confirmation screens for a token whose explorer row carries no
// decimals.
//
// https://git.eeqj.de/sneak/AutistMask/issues/349 made `fetchTokenBalances()`
// store the explorer's own answer — `null` when it reported none — while the
// scale a balance is DISPLAYED at is resolved separately: bundled list, then
// the user's tracked tokens, then the explorer. The two are different
// questions, and `tokenBalances[].decimals` only answers the second one.
//
// A reader that takes the stored field for the display scale therefore gets
// `null` for a token the wallet does know the scale of. On the Send path that
// null reaches `displayedDecimals()` inside `estimateGas()`, which throws, is
// caught as an unavailable fee, and disables Send behind "The network fee could
// not be estimated" — untrue, unactionable, and for a bundled token like WETH
// or DAI whose scale was never in doubt. So the Send screen resolves the scale
// the same way the balance list did, and only carries a null forward when that
// resolution genuinely answers null.
//
// Driven through the real `fetchTokenBalances()`, the real Send review handler
// and the real confirmation screen: a test that hand-wrote `decimals: null`
// onto state would not show which of the two questions each screen is asking.
//
// The reader sites that are pure display are in tests/unknownScaleDisplay.test.js,
// and what the fetcher stores is in tests/fabricatedDecimals.test.js.
"use strict";
jest.mock("../src/shared/log", () => ({
log: {
debugf: () => {},
infof: () => {},
warnf: () => {},
errorf: () => {},
},
debugFetch: jest.fn(),
setRuntimeDebug: () => {},
isDebug: () => false,
}));
// Everything the confirmation screen would reach the network for. The gas
// estimate is the point: with a usable scale it must succeed, so that a failure
// in these tests is a failure of the scale and not of the stub.
const mockProvider = {
getFeeData: async () => ({
maxFeePerGas: 2000000000n,
gasPrice: 1000000000n,
}),
estimateGas: async () => 21000n,
getCode: async () => "0x",
getTransactionCount: async () => 1,
getBalance: async () => 0n,
};
jest.mock("../src/shared/balances", () => {
const actual = jest.requireActual("../src/shared/balances");
return { ...actual, getProvider: () => mockProvider };
});
// The confirmation screen's best-effort Etherscan label lookup is the one
// thing here that reaches for fetch(). It is stubbed to fail, which is the
// path it already takes offline; the assertion at the bottom of this file
// pins that it is the ONLY fetch these screens make.
global.fetch = jest.fn(() => {
throw new Error("tests must not perform network requests");
});
const { makeStorageStub } = require("./support/storageStub");
global.chrome = { storage: makeStorageStub(), runtime: { sendMessage() {} } };
// A stub DOM. Every id in index.html that these two views touch resolves to a
// fresh recording element; nothing here depends on layout, only on what the
// views write into the elements and which handlers they register.
const elements = new Map();
function makeEl(id) {
const handlers = new Map();
return {
id,
textContent: "",
innerHTML: "",
value: "",
disabled: false,
onclick: null,
style: {},
dataset: {},
classList: {
add() {},
remove() {},
toggle() {},
contains: () => false,
},
handlers,
addEventListener(name, fn) {
handlers.set(name, fn);
},
appendChild(child) {
return child;
},
querySelectorAll: () => [],
querySelector: () => null,
remove() {},
focus() {},
};
}
global.document = {
getElementById(id) {
if (!elements.has(id)) elements.set(id, makeEl(id));
return elements.get(id);
},
createElement: (tag) => makeEl(tag),
body: { prepend() {}, appendChild() {} },
addEventListener() {},
};
global.navigator = { clipboard: { writeText() {} } };
const { parseUnits } = require("ethers");
const { fetchTokenBalances } = require("../src/shared/balances");
const { debugFetch } = require("../src/shared/log");
const { state } = require("../src/shared/state");
const {
displayedDecimals,
transferAmountUnits,
} = require("../src/shared/transferAmount");
const send = require("../src/popup/views/send");
const confirmTx = require("../src/popup/views/confirmTx");
const { TOKEN_BY_ADDRESS } = require("../src/shared/tokenList");
const HOLDER = "0x" + "a".repeat(40);
const SECOND_HOLDER = "0x" + "b".repeat(40);
const RECIPIENT = "0xC0FfEE0000000000000000000000000000c0fFEe";
const BLOCKSCOUT = "https://blockscout.example/api/v2";
// Bundled, 18 decimals. The wallet knows this token's scale without asking
// anyone, which is what makes an unsendable WETH a regression rather than a
// refusal.
const WETH = "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2";
// Neither bundled nor tracked, so the explorer is the only possible source and
// an omission there really is an unknown scale.
const NOVEL = "0xE2E0000000000000000000000000000000000E2e";
const FIVE_WETH = 5000000000000000000n;
function row(token = {}, value = FIVE_WETH) {
return {
value: String(value),
token: {
type: "ERC-20",
address_hash: WETH,
symbol: "WETH",
name: "Wrapped Ether",
holders_count: "50000",
...token,
},
};
}
// Fetch the explorer's rows through the real fetcher and put them exactly where
// refreshBalances() puts them.
async function fetchOnto(items) {
debugFetch.mockImplementation(async () => ({
ok: true,
status: 200,
statusText: "OK",
json: async () => items,
}));
const balances = await fetchTokenBalances(HOLDER, BLOCKSCOUT, []);
state.wallets = [
{
name: "Wallet 1",
addresses: [
{ address: HOLDER, balance: "1.0", tokenBalances: balances },
],
},
];
state.selectedWallet = 0;
state.selectedAddress = 0;
return balances;
}
// The same, for two addresses of one wallet holding the same contract. Sending
// is from the first. Two addresses is what it takes to reach
// explorerDecimals()'s disagreement check, which is only reachable across rows.
async function fetchOntoBoth(itemsA, itemsB) {
debugFetch.mockImplementation(async () => ({
ok: true,
status: 200,
statusText: "OK",
json: async () => itemsA,
}));
const a = await fetchTokenBalances(HOLDER, BLOCKSCOUT, []);
debugFetch.mockImplementation(async () => ({
ok: true,
status: 200,
statusText: "OK",
json: async () => itemsB,
}));
const b = await fetchTokenBalances(SECOND_HOLDER, BLOCKSCOUT, []);
state.wallets = [
{
name: "Wallet 1",
addresses: [
{ address: HOLDER, balance: "1.0", tokenBalances: a },
{ address: SECOND_HOLDER, balance: "1.0", tokenBalances: b },
],
},
];
state.selectedWallet = 0;
state.selectedAddress = 0;
return { a, b };
}
function el(id) {
return global.document.getElementById(id);
}
// Press Review on the Send screen and return the txInfo it hands the
// confirmation screen.
async function reviewSend(tokenAddress, amount) {
let handed = null;
send.init({ showConfirmTx: (info) => (handed = info) });
state.selectedToken = tokenAddress;
el("send-token").value = tokenAddress;
el("send-to").value = RECIPIENT;
el("send-amount").value = amount;
await el("btn-send-review").handlers.get("click")();
return handed;
}
// show() kicks off the gas estimate without awaiting it; this lets it settle.
async function settle() {
for (let i = 0; i < 10; i++) await new Promise((r) => setTimeout(r, 0));
}
function text(id) {
return el(id).textContent;
}
function errors() {
return el("confirm-errors").innerHTML;
}
function sendDisabled() {
return el("btn-confirm-send").disabled;
}
beforeEach(() => {
elements.clear();
debugFetch.mockReset();
state.wallets = [];
state.trackedTokens = [];
state.selectedToken = null;
state.fraudContracts = [];
state.hideLowHolderTokens = false;
state.currentView = null;
});
describe("the Send screen resolves the scale rather than reading the stored one", () => {
test("the bundled list knows WETH, and the explorer row does not report a scale", async () => {
expect(TOKEN_BY_ADDRESS.get(WETH.toLowerCase()).decimals).toBe(18);
const balances = await fetchOnto([row()]);
// Stored: the explorer's own answer, which is nothing. Reading THIS is
// what carried a null into the fee estimate.
expect(balances[0].decimals).toBeNull();
// Displayed: the bundled scale, so the quantity on screen is real.
expect(balances[0].balance).toBe("5.0");
});
test("the review hands the confirmation screen the resolved scale, not the stored null", async () => {
const balances = await fetchOnto([row()]);
const txInfo = await reviewSend(WETH, "1.5");
expect(txInfo.tokenDecimals).toBe(18);
expect(txInfo.tokenDecimals).not.toBe(balances[0].decimals);
expect(txInfo.tokenBalance).toBe("5.0");
});
test("that scale estimates a fee and leaves Send enabled", async () => {
await fetchOnto([row()]);
const txInfo = await reviewSend(WETH, "1.5");
confirmTx.show(txInfo);
await settle();
// The regression: displayedDecimals(null) threw in estimateGas(), the
// catch reported the fee as unknown, and Send stayed disabled behind a
// message about the network fee that no retry could clear.
expect(text("confirm-fee-amount")).not.toBe("Unable to estimate");
expect(text("confirm-fee-amount")).toContain("ETH");
expect(errors()).toBe("");
expect(sendDisabled()).toBe(false);
});
test("and the transfer encodes at the scale that was displayed", async () => {
await fetchOnto([row()]);
const txInfo = await reviewSend(WETH, "1.5");
// The two calls confirmTx makes with this field: the gas estimate's
// scale, and the encode, which compares it against the contract's own
// decimals() before parsing.
expect(displayedDecimals(txInfo.tokenDecimals)).toBe(18);
expect(
transferAmountUnits(txInfo.amount, txInfo.tokenDecimals, 18n),
).toBe(parseUnits("1.5", 18));
});
test("a token nothing knows the scale of is still refused, and says why", async () => {
await fetchOnto([
row({ address_hash: NOVEL, symbol: "NOVEL", name: "Novel Token" }),
]);
const txInfo = await reviewSend(NOVEL, "1.5");
// No fallback was introduced: resolution answers null here, and the
// null is what goes forward.
expect(txInfo.tokenDecimals).toBeNull();
expect(txInfo.tokenBalance).toBeNull();
confirmTx.show(txInfo);
await settle();
expect(text("confirm-balance")).toBe("unknown (NOVEL)");
expect(errors()).toContain("This token&#39;s balance is unknown");
expect(sendDisabled()).toBe(true);
});
});
// balances.js resolves the display scale WITHOUT `wallets`, so its explorer leg
// is the row it is formatting. send.js resolves WITH `wallets`, so its explorer
// leg is explorerDecimals(), which answers null when two addresses report
// different scales for one contract — the check that must apply before a scale
// encodes a transfer. The two therefore disagree exactly here, and a stored
// balance formatted at a scale the Send screen just refused is not a balance it
// may state: it would leave validateTransfer() satisfied, the unknown-balance
// sentence unfired, and the fee-estimate failure as the only thing on screen.
describe("a scale the explorer's own rows disagree about", () => {
// 5000000 units at the "6" address A reports, 5e18 at the "18" address B
// reports: both format to "5.0", so the disagreement is in the scale alone
// and not in the quantity.
function novel(decimals, value) {
return row(
{
address_hash: NOVEL,
symbol: "NOVEL",
name: "Novel Token",
decimals,
},
value,
);
}
test("is stored per row, because storage holds the explorer's own answer", async () => {
const { a, b } = await fetchOntoBoth(
[novel("6", 5000000n)],
[novel("18", FIVE_WETH)],
);
expect(a[0].decimals).toBe(6);
expect(a[0].balance).toBe("5.0");
expect(b[0].decimals).toBe(18);
});
test("resolves to null on the Send screen, and takes the balance with it", async () => {
await fetchOntoBoth([novel("6", 5000000n)], [novel("18", FIVE_WETH)]);
const txInfo = await reviewSend(NOVEL, "1.5");
expect(txInfo.tokenDecimals).toBeNull();
// The regression this closes: null scale alongside a non-null balance.
expect(txInfo.tokenBalance).toBeNull();
});
test("so the user is told the balance is unknown, not only that the fee failed", async () => {
await fetchOntoBoth([novel("6", 5000000n)], [novel("18", FIVE_WETH)]);
const txInfo = await reviewSend(NOVEL, "1.5");
confirmTx.show(txInfo);
await settle();
// Before the fix: "5.0 NOVEL", an empty confirm-errors, and
// confirm-fee-unknown-error — "the network fee could not be
// estimated... please go back and try again" — as the only explanation
// for a screen that can never proceed.
expect(errors()).not.toBe("");
expect(errors()).toContain("This token&#39;s balance is unknown");
expect(text("confirm-balance")).toBe("unknown (NOVEL)");
expect(sendDisabled()).toBe(true);
// The fee line still reports the estimate as unavailable, because it
// genuinely is — displayedDecimals() refuses the same missing scale.
// What changed is that it is no longer the ONLY thing on the screen,
// and no longer the only offered explanation. This is exactly how the
// token nothing knows the scale of already behaved.
expect(text("confirm-fee-amount")).toBe("Unable to estimate");
expect(el("confirm-fee-unknown-error").style.visibility).toBe(
"visible",
);
});
test("while agreeing rows leave the scale usable", async () => {
await fetchOntoBoth([novel("6", 5000000n)], [novel("6", 5000000n)]);
const txInfo = await reviewSend(NOVEL, "1.5");
expect(txInfo.tokenDecimals).toBe(6);
expect(txInfo.tokenBalance).toBe("5.0");
confirmTx.show(txInfo);
await settle();
expect(text("confirm-balance")).toBe("5.0 NOVEL");
expect(errors()).toBe("");
expect(sendDisabled()).toBe(false);
});
});
describe("the confirmation screen tells an unknown balance from a zero one", () => {
function txInfo(tokenBalance) {
return {
from: HOLDER,
to: RECIPIENT,
ensName: null,
amount: "1.5",
token: NOVEL,
balance: "1.0",
tokenSymbol: "NOVEL",
tokenBalance,
tokenDecimals: tokenBalance === null ? null : 18,
};
}
async function render(tokenBalance) {
state.wallets = [
{
name: "Wallet 1",
addresses: [
{ address: HOLDER, balance: "1.0", tokenBalances: [] },
],
},
];
state.selectedWallet = 0;
state.selectedAddress = 0;
confirmTx.show(txInfo(tokenBalance));
await settle();
return { balance: text("confirm-balance"), errors: errors() };
}
test("the balance line states unknown rather than a quantity of zero", async () => {
const unknown = await render(null);
const zero = await render("0.0");
expect(unknown.balance).not.toBe(zero.balance);
expect(unknown.balance).toBe("unknown (NOVEL)");
expect(zero.balance).toBe("0.0 NOVEL");
});
// Both hit INSUFFICIENT_TOKEN — an unknown balance is treated as nothing to
// spend from, which is the fail-closed side — but "you have 0.0" is a claim
// about the holding, and this one has no established quantity to claim.
test("the insufficient-balance message names the reason, not a figure", async () => {
const unknown = await render(null);
const zero = await render("0.0");
expect(unknown.errors).not.toBe(zero.errors);
expect(unknown.errors).toContain("This token&#39;s balance is unknown");
expect(unknown.errors).not.toContain("You have");
expect(zero.errors).toContain("You have 0.0 NOVEL");
expect(zero.errors).not.toContain("balance is unknown");
});
});
test("the only network these screens reached for is the Etherscan label lookup", () => {
for (const [url] of global.fetch.mock.calls) {
expect(String(url)).toMatch(/^https:\/\/etherscan\.io\/address\//);
}
});