Compare commits
1 Commits
main
...
958e92d753
| Author | SHA1 | Date | |
|---|---|---|---|
| 958e92d753 |
158
README.md
158
README.md
@@ -902,27 +902,6 @@ 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
|
||||
@@ -1006,87 +985,6 @@ 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.
|
||||
@@ -1113,12 +1011,10 @@ 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. 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.
|
||||
repeated in the element lists below.
|
||||
|
||||
Closing and reopening the popup returns to the screen the user was last on only
|
||||
for the views listed in `RESTORABLE_VIEWS` (`src/shared/restorableViews.js`).
|
||||
for the views listed in `RESTORABLE_VIEWS` (`src/popup/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
|
||||
@@ -1133,14 +1029,6 @@ 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
|
||||
@@ -1796,48 +1684,6 @@ 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
|
||||
|
||||
221
TODO.md
221
TODO.md
@@ -45,227 +45,6 @@ but the review is broader than any of them.
|
||||
|
||||
# Completed Steps
|
||||
|
||||
- 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
|
||||
|
||||
264
build.js
264
build.js
@@ -4,11 +4,6 @@ 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");
|
||||
@@ -21,18 +16,6 @@ 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
|
||||
@@ -51,17 +34,6 @@ 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 });
|
||||
}
|
||||
@@ -93,164 +65,6 @@ 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
|
||||
@@ -268,42 +82,6 @@ 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")
|
||||
@@ -474,9 +252,6 @@ 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");
|
||||
@@ -518,15 +293,6 @@ 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));
|
||||
}
|
||||
@@ -571,8 +337,6 @@ async function build() {
|
||||
tailwindOutput,
|
||||
path.join(distDir, "src", "popup", "styles.css"),
|
||||
);
|
||||
|
||||
copyIcons(distDir);
|
||||
}
|
||||
|
||||
// copy manifests
|
||||
@@ -585,8 +349,6 @@ 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".
|
||||
@@ -597,27 +359,7 @@ async function build() {
|
||||
console.log("Build complete: dist/chrome/ and dist/firefox/");
|
||||
}
|
||||
|
||||
// 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}`,
|
||||
);
|
||||
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,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -8,10 +8,6 @@
|
||||
|
||||
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.
|
||||
@@ -82,36 +78,12 @@ 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: [`${BACKGROUND_ENTRY_PREFIX}**/*.js`],
|
||||
plugins: { background: backgroundState },
|
||||
files: ["src/background/**/*.js"],
|
||||
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:
|
||||
@@ -135,9 +107,9 @@ module.exports = [
|
||||
},
|
||||
},
|
||||
|
||||
// Unit tests, and the helpers they require: jest on node.
|
||||
// Unit tests: jest on node.
|
||||
{
|
||||
files: ["tests/**/*.test.js", "tests/support/**/*.js"],
|
||||
files: ["tests/**/*.test.js"],
|
||||
languageOptions: {
|
||||
...commonjs,
|
||||
globals: { ...globals.node, ...globals.jest },
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.8 KiB |
BIN
icons/icon16.png
BIN
icons/icon16.png
Binary file not shown.
|
Before Width: | Height: | Size: 292 B |
BIN
icons/icon32.png
BIN
icons/icon32.png
Binary file not shown.
|
Before Width: | Height: | Size: 534 B |
BIN
icons/icon48.png
BIN
icons/icon48.png
Binary file not shown.
|
Before Width: | Height: | Size: 725 B |
@@ -9,12 +9,6 @@
|
||||
"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"
|
||||
},
|
||||
|
||||
@@ -5,12 +5,6 @@
|
||||
"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"
|
||||
},
|
||||
|
||||
@@ -1,206 +0,0 @@
|
||||
// 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 },
|
||||
};
|
||||
@@ -1,123 +0,0 @@
|
||||
// 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,
|
||||
};
|
||||
@@ -2,21 +2,19 @@
|
||||
// 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 { 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 { onChainSwitch } = require("../shared/chainSwitch");
|
||||
const {
|
||||
state,
|
||||
loadState,
|
||||
saveState,
|
||||
currentNetwork,
|
||||
} = require("../shared/state");
|
||||
const { refreshBalances, getProvider } = require("../shared/balances");
|
||||
const { debugFetch, log } = require("../shared/log");
|
||||
const {
|
||||
@@ -44,6 +42,7 @@ const {
|
||||
const {
|
||||
actionApi,
|
||||
runtimeApi,
|
||||
storageGet,
|
||||
tabsQuery,
|
||||
tabsSendMessage,
|
||||
windowsApi,
|
||||
@@ -180,51 +179,21 @@ const INTERNAL_ERROR_CODE = -32603;
|
||||
const INTERNAL_ERROR_MESSAGE =
|
||||
"AutistMask could not complete this request because of an internal error.";
|
||||
|
||||
// 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 };
|
||||
async function getState() {
|
||||
const result = await storageGet("autistmask");
|
||||
return (
|
||||
result.autistmask || {
|
||||
wallets: [],
|
||||
rpcUrl: DEFAULT_RPC_URL,
|
||||
activeAddress: null,
|
||||
allowedSites: {},
|
||||
deniedSites: {},
|
||||
}
|
||||
return { code: INTERNAL_ERROR_CODE, message: INTERNAL_ERROR_MESSAGE };
|
||||
);
|
||||
}
|
||||
|
||||
// The active address of a profile snapshot. Pure, and taking the snapshot as
|
||||
// an argument rather than reading storage itself: a handler that has already
|
||||
// read state must not answer "which account is this" from a SECOND, later read
|
||||
// — the two can disagree, and the checks that compare them would then be
|
||||
// comparing two different moments.
|
||||
function activeAddressOf(s) {
|
||||
async function getActiveAddress() {
|
||||
const s = await getState();
|
||||
if (s.activeAddress) return s.activeAddress;
|
||||
// Fall back to first address
|
||||
if (s.wallets.length > 0 && s.wallets[0].addresses.length > 0) {
|
||||
@@ -233,11 +202,6 @@ function activeAddressOf(s) {
|
||||
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
|
||||
@@ -246,14 +210,9 @@ 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() {
|
||||
return (await getState()).rpcUrl;
|
||||
const s = await getState();
|
||||
return s.rpcUrl || DEFAULT_RPC_URL;
|
||||
}
|
||||
|
||||
function extractHostname(origin) {
|
||||
@@ -594,26 +553,10 @@ 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 = activeAddressOf(s);
|
||||
const activeAddress = await getActiveAddress();
|
||||
if (!activeAddress) {
|
||||
return { error: { message: "No accounts available" } };
|
||||
}
|
||||
@@ -645,14 +588,29 @@ async function handleConnectionRequest(origin) {
|
||||
|
||||
if (decision.approved) {
|
||||
if (decision.remember) {
|
||||
await rememberSiteChoice("allowedSites", activeAddress, hostname);
|
||||
// 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();
|
||||
} else {
|
||||
connectedSites[origin + ":" + activeAddress] = true;
|
||||
}
|
||||
return { result: [activeAddress] };
|
||||
} else {
|
||||
if (decision.remember) {
|
||||
await rememberSiteChoice("deniedSites", activeAddress, hostname);
|
||||
await loadState();
|
||||
if (!state.deniedSites[activeAddress]) {
|
||||
state.deniedSites[activeAddress] = [];
|
||||
}
|
||||
if (!state.deniedSites[activeAddress].includes(hostname)) {
|
||||
state.deniedSites[activeAddress].push(hostname);
|
||||
}
|
||||
await saveState();
|
||||
}
|
||||
return {
|
||||
error: {
|
||||
@@ -696,7 +654,7 @@ async function handleRpc(method, params, origin) {
|
||||
|
||||
if (method === "eth_accounts") {
|
||||
const s = await getState();
|
||||
const activeAddress = activeAddressOf(s);
|
||||
const activeAddress = await getActiveAddress();
|
||||
if (!activeAddress) return { result: [] };
|
||||
const hostname = extractHostname(origin);
|
||||
const allowed = s.allowedSites[activeAddress] || [];
|
||||
@@ -709,11 +667,22 @@ async function handleRpc(method, params, origin) {
|
||||
return { result: [] };
|
||||
}
|
||||
|
||||
// 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).
|
||||
// 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.
|
||||
if (method === "eth_chainId" || method === "net_version") {
|
||||
const s = await getState();
|
||||
const net = networkById(s.networkId);
|
||||
@@ -730,7 +699,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 = activeAddressOf(s);
|
||||
const activeAddress = await getActiveAddress();
|
||||
const hostname = extractHostname(origin);
|
||||
const allowed = s.allowedSites[activeAddress] || [];
|
||||
if (
|
||||
@@ -740,25 +709,24 @@ async function handleRpc(method, params, origin) {
|
||||
return { error: { code: 4100, message: "Unauthorized" } };
|
||||
}
|
||||
|
||||
// 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).
|
||||
// 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();
|
||||
|
||||
const chainId = params?.[0]?.chainId;
|
||||
if (chainId === networkById(s.networkId).chainId) {
|
||||
if (chainId === currentNetwork().chainId) {
|
||||
return { result: null };
|
||||
}
|
||||
if (SUPPORTED_CHAIN_IDS.has(chainId)) {
|
||||
const target = networkByChainId(chainId);
|
||||
// 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),
|
||||
);
|
||||
await onChainSwitch(target.id);
|
||||
broadcastChainChanged(target.chainId);
|
||||
return { result: null };
|
||||
}
|
||||
@@ -805,7 +773,7 @@ async function handleRpc(method, params, origin) {
|
||||
|
||||
if (method === "wallet_getPermissions") {
|
||||
const s = await getState();
|
||||
const activeAddress = activeAddressOf(s);
|
||||
const activeAddress = await getActiveAddress();
|
||||
const hostname = extractHostname(origin);
|
||||
const allowed = s.allowedSites[activeAddress] || [];
|
||||
const isConnected =
|
||||
@@ -831,7 +799,7 @@ async function handleRpc(method, params, origin) {
|
||||
|
||||
if (method === "personal_sign" || method === "eth_sign") {
|
||||
const s = await getState();
|
||||
const activeAddress = activeAddressOf(s);
|
||||
const activeAddress = await getActiveAddress();
|
||||
if (!activeAddress)
|
||||
return { error: { message: "No accounts available" } };
|
||||
|
||||
@@ -880,7 +848,7 @@ async function handleRpc(method, params, origin) {
|
||||
|
||||
if (method === "eth_signTypedData_v4" || method === "eth_signTypedData") {
|
||||
const s = await getState();
|
||||
const activeAddress = activeAddressOf(s);
|
||||
const activeAddress = await getActiveAddress();
|
||||
if (!activeAddress)
|
||||
return { error: { message: "No accounts available" } };
|
||||
|
||||
@@ -923,11 +891,6 @@ 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 } };
|
||||
}
|
||||
}
|
||||
@@ -941,7 +904,7 @@ async function handleRpc(method, params, origin) {
|
||||
// page has its answer.
|
||||
async function handleSendTransaction(params, origin) {
|
||||
const s = await getState();
|
||||
const activeAddress = activeAddressOf(s);
|
||||
const activeAddress = await getActiveAddress();
|
||||
if (!activeAddress) return { error: { message: "No accounts available" } };
|
||||
|
||||
const hostname = extractHostname(origin);
|
||||
@@ -985,19 +948,10 @@ 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(s.rpcUrl, s.networkId),
|
||||
getProvider(await getRpcUrl()),
|
||||
activeAddress,
|
||||
txParams,
|
||||
);
|
||||
@@ -1085,7 +1039,7 @@ async function broadcastAccountsChanged() {
|
||||
}
|
||||
resetPopupUrl();
|
||||
const s = await getState();
|
||||
const activeAddress = activeAddressOf(s);
|
||||
const activeAddress = await getActiveAddress();
|
||||
const allowed = activeAddress ? s.allowedSites[activeAddress] || [] : [];
|
||||
let tabs;
|
||||
try {
|
||||
@@ -1125,60 +1079,20 @@ 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() {
|
||||
const s = await getState();
|
||||
await loadState();
|
||||
const now = Date.now();
|
||||
if (now - (s.lastBalanceRefresh || 0) < RECENT_BALANCE_REFRESH_MS) return;
|
||||
if (s.wallets.length === 0) return;
|
||||
|
||||
const wallets = s.wallets;
|
||||
if (now - (state.lastBalanceRefresh || 0) < RECENT_BALANCE_REFRESH_MS)
|
||||
return;
|
||||
if (state.wallets.length === 0) return;
|
||||
await refreshBalances(
|
||||
wallets,
|
||||
s.rpcUrl,
|
||||
s.blockscoutUrl,
|
||||
s.trackedTokens,
|
||||
s.networkId,
|
||||
state.wallets,
|
||||
state.rpcUrl,
|
||||
state.blockscoutUrl,
|
||||
state.trackedTokens,
|
||||
);
|
||||
|
||||
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;
|
||||
});
|
||||
state.lastBalanceRefresh = now;
|
||||
await saveState();
|
||||
}
|
||||
|
||||
// The recurring job runs off an alarm, not a timer. On Chrome MV3 this file is
|
||||
@@ -1187,14 +1101,7 @@ async function backgroundRefresh() {
|
||||
// module-level state does not outlive it. Alarms are held by the browser and
|
||||
// wake the worker to deliver them.
|
||||
registerAlarmHandlers({
|
||||
// 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);
|
||||
}),
|
||||
[BALANCE_REFRESH_ALARM]: backgroundRefresh,
|
||||
});
|
||||
|
||||
// Everything the background context needs re-established on start. This runs
|
||||
@@ -1293,7 +1200,12 @@ 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: failureError(err) });
|
||||
sendResponse({
|
||||
error: {
|
||||
code: INTERNAL_ERROR_CODE,
|
||||
message: INTERNAL_ERROR_MESSAGE,
|
||||
},
|
||||
});
|
||||
});
|
||||
return true;
|
||||
}
|
||||
@@ -1395,29 +1307,16 @@ 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 — 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.
|
||||
// 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.
|
||||
let chainId;
|
||||
let rpcUrl;
|
||||
let networkId;
|
||||
try {
|
||||
const s = await getState();
|
||||
networkId = s.networkId;
|
||||
chainId = networkById(networkId).chainId;
|
||||
rpcUrl = s.rpcUrl;
|
||||
const activeAddress = activeAddressOf(s);
|
||||
await loadState();
|
||||
chainId = currentNetwork().chainId;
|
||||
const activeAddress = await getActiveAddress();
|
||||
// An address switch between approval and signing refuses. The
|
||||
// approval named one account; signing from whichever account
|
||||
// is active now would send funds from an account this screen
|
||||
@@ -1489,7 +1388,7 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
||||
}
|
||||
|
||||
try {
|
||||
const provider = getProvider(rpcUrl, networkId);
|
||||
const provider = getProvider(state.rpcUrl);
|
||||
lastResortStage = TX_STAGE_BROADCAST;
|
||||
const tx = await provider.broadcastTransaction(msg.rawSignedTx);
|
||||
if (nonce !== null) spent.add(nonce);
|
||||
@@ -1528,10 +1427,18 @@ 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);
|
||||
const failure = failureError(e);
|
||||
settleApproval(msg.id, { error: failure }, { holdsClaim: true });
|
||||
settleApproval(
|
||||
msg.id,
|
||||
{
|
||||
error: {
|
||||
code: INTERNAL_ERROR_CODE,
|
||||
message: INTERNAL_ERROR_MESSAGE,
|
||||
},
|
||||
},
|
||||
{ holdsClaim: true },
|
||||
);
|
||||
sendResponse({
|
||||
error: failure.message,
|
||||
error: INTERNAL_ERROR_MESSAGE,
|
||||
retryable: false,
|
||||
stage: lastResortStage,
|
||||
});
|
||||
@@ -1623,10 +1530,18 @@ 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);
|
||||
const failure = failureError(e);
|
||||
settleApproval(msg.id, { error: failure }, { holdsClaim: true });
|
||||
settleApproval(
|
||||
msg.id,
|
||||
{
|
||||
error: {
|
||||
code: INTERNAL_ERROR_CODE,
|
||||
message: INTERNAL_ERROR_MESSAGE,
|
||||
},
|
||||
},
|
||||
{ holdsClaim: true },
|
||||
);
|
||||
sendResponse({
|
||||
error: failure.message,
|
||||
error: INTERNAL_ERROR_MESSAGE,
|
||||
retryable: false,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
// 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 };
|
||||
@@ -1761,75 +1761,6 @@
|
||||
</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>
|
||||
|
||||
@@ -1,14 +1,8 @@
|
||||
// AutistMask popup entry point.
|
||||
// Loads state, initializes views, triggers first render.
|
||||
|
||||
const {
|
||||
state,
|
||||
saveState,
|
||||
onSaveFailure,
|
||||
loadState,
|
||||
} = require("../shared/state");
|
||||
const { StateUnusableError } = require("../shared/stateSchema");
|
||||
const { log, setRuntimeDebug } = require("../shared/log");
|
||||
const { state, saveState, loadState } = require("../shared/state");
|
||||
const { setRuntimeDebug } = require("../shared/log");
|
||||
const { refreshPrices } = require("../shared/prices");
|
||||
const { refreshBalances } = require("../shared/balances");
|
||||
const {
|
||||
@@ -16,14 +10,13 @@ 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/shared/restorableViews.js) go
|
||||
// from persisted state (RESTORABLE_VIEWS, src/popup/restorableViews.js) go
|
||||
// through it; anything else falls back to the nearest restorable parent.
|
||||
const { renderView, makeBackRenderer } = require("./viewRouter");
|
||||
|
||||
@@ -42,7 +35,6 @@ 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);
|
||||
@@ -61,20 +53,11 @@ 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;
|
||||
}
|
||||
@@ -150,28 +133,7 @@ function fallbackView() {
|
||||
}
|
||||
|
||||
async function init() {
|
||||
// First, before anything can save: showView() saves on every navigation
|
||||
// without awaiting, so a save that fails from here on has somewhere to be
|
||||
// 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
|
||||
|
||||
@@ -17,13 +17,7 @@
|
||||
//
|
||||
// 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.
|
||||
//
|
||||
// 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.
|
||||
// point, which cannot be required outside a browser.
|
||||
const RESTORABLE_VIEWS = new Set([
|
||||
"main",
|
||||
"address",
|
||||
@@ -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("../shared/restorableViews");
|
||||
const { RESTORABLE_VIEWS } = require("./restorableViews");
|
||||
|
||||
// The views this page load has rendered.
|
||||
//
|
||||
@@ -53,17 +53,12 @@ function resetRenderedViews() {
|
||||
const ALWAYS_RENDER_ON_BACK = new Set(["main"]);
|
||||
|
||||
// Views that render an address the user picked and cannot be rendered
|
||||
// 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.
|
||||
// without one.
|
||||
const ADDRESS_VIEWS = new Set([
|
||||
"address",
|
||||
"address-token",
|
||||
"receive",
|
||||
"transaction",
|
||||
"confirm-tx",
|
||||
]);
|
||||
|
||||
function needsAddress(view) {
|
||||
@@ -79,73 +74,6 @@ 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.
|
||||
//
|
||||
@@ -179,11 +107,11 @@ function renderView(view, state, views) {
|
||||
views.settingsAddToken.show();
|
||||
return true;
|
||||
case "confirm-tx":
|
||||
if (!isRenderablePendingTx(data.pendingTx)) return false;
|
||||
if (!data.pendingTx) return false;
|
||||
views.confirmTx.restore();
|
||||
return true;
|
||||
case "transaction":
|
||||
if (!isRenderableTx(data.tx)) return false;
|
||||
if (!data.tx) return false;
|
||||
views.transactionDetail.render();
|
||||
return true;
|
||||
case "wait-tx":
|
||||
@@ -192,13 +120,10 @@ 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:
|
||||
|
||||
@@ -49,11 +49,7 @@ function init(ctx) {
|
||||
infoEl.style.visibility = "visible";
|
||||
log.debugf("Looking up token contract", contractAddr);
|
||||
try {
|
||||
const info = await lookupTokenInfo(
|
||||
contractAddr,
|
||||
state.rpcUrl,
|
||||
state.networkId,
|
||||
);
|
||||
const info = await lookupTokenInfo(contractAddr, state.rpcUrl);
|
||||
log.infof("Adding token", info.symbol, contractAddr);
|
||||
state.trackedTokens.push({
|
||||
address: contractAddr,
|
||||
|
||||
@@ -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, state.networkId);
|
||||
const scan = await scanForAddresses(xpub, state.rpcUrl);
|
||||
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, state.networkId);
|
||||
const scan = await scanForAddresses(xpub, state.rpcUrl);
|
||||
if (scan.addresses.length > 1) {
|
||||
wallet.addresses = scan.addresses.map((a) => ({
|
||||
address: a.address,
|
||||
|
||||
@@ -188,7 +188,6 @@ async function loadTransactions(address) {
|
||||
ensNameMap = await resolveEnsNames(
|
||||
counterparties,
|
||||
state.rpcUrl,
|
||||
state.networkId,
|
||||
);
|
||||
} catch {
|
||||
ensNameMap = new Map();
|
||||
|
||||
@@ -12,7 +12,6 @@ const {
|
||||
displaySymbol,
|
||||
truncateMiddle,
|
||||
balanceLine,
|
||||
unknownableAmount,
|
||||
renderAddressHtml,
|
||||
attachCopyHandlers,
|
||||
goBack,
|
||||
@@ -119,9 +118,7 @@ function show() {
|
||||
addr.tokenBalances,
|
||||
state.trackedTokens,
|
||||
);
|
||||
// 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;
|
||||
amount = tb ? parseFloat(tb.balance || "0") : 0;
|
||||
price = getPrice(symbol);
|
||||
}
|
||||
|
||||
@@ -155,7 +152,7 @@ function show() {
|
||||
attachCopyHandlers($("address-token-line"));
|
||||
|
||||
// USD total for this token only
|
||||
const usdVal = price && amount !== null ? amount * price : null;
|
||||
const usdVal = price ? amount * price : null;
|
||||
const usdStr = formatUsd(usdVal);
|
||||
$("address-token-usd-total").innerHTML = usdStr || " ";
|
||||
|
||||
@@ -271,7 +268,6 @@ async function loadTransactions(address, tokenId) {
|
||||
ensNameMap = await resolveEnsNames(
|
||||
counterparties,
|
||||
state.rpcUrl,
|
||||
state.networkId,
|
||||
);
|
||||
} catch {
|
||||
ensNameMap = new Map();
|
||||
|
||||
@@ -139,17 +139,12 @@ function show(txInfo) {
|
||||
|
||||
// Balance (with inline USD)
|
||||
if (isErc20) {
|
||||
// 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);
|
||||
const bal = txInfo.tokenBalance || "0";
|
||||
const balUsd = tokenPrice ? parseFloat(bal) * tokenPrice : null;
|
||||
$("confirm-balance").textContent = valueWithUsd(
|
||||
bal + " " + symbol,
|
||||
balUsd,
|
||||
);
|
||||
} else {
|
||||
const bal = txInfo.balance || "0";
|
||||
const balUsd = ethPrice ? parseFloat(bal) * ethPrice : null;
|
||||
@@ -240,12 +235,7 @@ function renderValidation(txInfo) {
|
||||
}
|
||||
if (codes.includes(CODES.INSUFFICIENT_TOKEN)) {
|
||||
messages.push(
|
||||
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 " +
|
||||
"Insufficient " +
|
||||
symbol +
|
||||
" balance. You have " +
|
||||
txInfo.tokenBalance +
|
||||
@@ -314,7 +304,7 @@ function formatFeeEth(wei) {
|
||||
|
||||
async function estimateGas(txInfo) {
|
||||
try {
|
||||
const provider = getProvider(state.rpcUrl, state.networkId);
|
||||
const provider = getProvider(state.rpcUrl);
|
||||
const feeData = await provider.getFeeData();
|
||||
let gasLimit;
|
||||
|
||||
@@ -396,7 +386,7 @@ async function estimateGas(txInfo) {
|
||||
|
||||
async function checkRecipientHistory(txInfo) {
|
||||
try {
|
||||
const provider = getProvider(state.rpcUrl, state.networkId);
|
||||
const provider = getProvider(state.rpcUrl);
|
||||
const asyncWarnings = await getFullWarnings(txInfo.to, provider, {
|
||||
fromAddress: txInfo.from,
|
||||
});
|
||||
@@ -464,7 +454,7 @@ function init(_ctx) {
|
||||
state.selectedAddress,
|
||||
decryptedSecret,
|
||||
);
|
||||
const provider = getProvider(state.rpcUrl, state.networkId);
|
||||
const provider = getProvider(state.rpcUrl);
|
||||
const connectedSigner = signer.connect(provider);
|
||||
|
||||
if (pendingTx.token === "ETH") {
|
||||
|
||||
@@ -45,11 +45,6 @@ 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
|
||||
@@ -132,38 +127,6 @@ 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.
|
||||
@@ -229,15 +192,6 @@ 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,
|
||||
@@ -245,18 +199,9 @@ function unknownableAmount(balance) {
|
||||
// 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 === null ? "quantity unknown" : amount.toFixed(4);
|
||||
const usd =
|
||||
price && amount !== null
|
||||
? formatUsd(amount * price) || " "
|
||||
: " ";
|
||||
const qty = amount.toFixed(4);
|
||||
const usd = price ? formatUsd(amount * price) || " " : " ";
|
||||
// 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)}"` : "";
|
||||
@@ -283,12 +228,7 @@ function balanceLinesForAddress(addr, trackedTokens, showZero) {
|
||||
);
|
||||
const seen = new Set();
|
||||
for (const t of addr.tokenBalances || []) {
|
||||
// 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);
|
||||
const bal = parseFloat(t.balance || "0");
|
||||
if (bal === 0 && !showZero) continue;
|
||||
html += balanceLine(
|
||||
t.symbol,
|
||||
@@ -321,12 +261,7 @@ function addressHoldsFunds(addr) {
|
||||
if (!addr) return false;
|
||||
if (parseFloat(addr.balance || "0") > 0) return true;
|
||||
for (const t of addr.tokenBalances || []) {
|
||||
// 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;
|
||||
if (parseFloat(t.balance || "0") > 0) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -576,7 +511,6 @@ module.exports = {
|
||||
showView,
|
||||
onViewLeave,
|
||||
updateDebugBanner,
|
||||
showSaveFailureBanner,
|
||||
setBackRenderer,
|
||||
pushCurrentView,
|
||||
goBack,
|
||||
@@ -586,7 +520,6 @@ module.exports = {
|
||||
balanceLine,
|
||||
balanceLinesForAddress,
|
||||
addressHoldsFunds,
|
||||
unknownableAmount,
|
||||
addressColor,
|
||||
addressDotHtml,
|
||||
escapeHtml,
|
||||
|
||||
@@ -12,7 +12,6 @@ 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");
|
||||
@@ -160,14 +159,9 @@ function updateSendBalance() {
|
||||
addr.tokenBalances,
|
||||
state.trackedTokens,
|
||||
);
|
||||
// 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";
|
||||
const bal = tb ? tb.balance || "0" : "0";
|
||||
$("send-balance").textContent =
|
||||
bal == null
|
||||
? "Current balance: unknown (" + symbol + ")"
|
||||
: "Current balance: " + bal + " " + symbol;
|
||||
"Current balance: " + bal + " " + symbol;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -208,7 +202,7 @@ function init(_ctx) {
|
||||
let ensName = null;
|
||||
if (to.includes(".") && !to.startsWith("0x")) {
|
||||
try {
|
||||
const provider = getProvider(state.rpcUrl, state.networkId);
|
||||
const provider = getProvider(state.rpcUrl);
|
||||
const resolved = await provider.resolveName(to);
|
||||
if (!resolved) {
|
||||
showFlash("Could not resolve " + to);
|
||||
@@ -241,45 +235,8 @@ function init(_ctx) {
|
||||
addr.tokenBalances,
|
||||
state.trackedTokens,
|
||||
);
|
||||
// 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;
|
||||
tokenBalance = tb ? tb.balance || "0" : "0";
|
||||
tokenDecimals = tb ? tb.decimals : null;
|
||||
}
|
||||
|
||||
ctx.showConfirmTx({
|
||||
|
||||
@@ -133,11 +133,7 @@ function init(_ctx) {
|
||||
infoEl.style.visibility = "visible";
|
||||
log.debugf("Looking up token contract", addr);
|
||||
try {
|
||||
const info = await lookupTokenInfo(
|
||||
addr,
|
||||
state.rpcUrl,
|
||||
state.networkId,
|
||||
);
|
||||
const info = await lookupTokenInfo(addr, state.rpcUrl);
|
||||
log.infof("Adding token", info.symbol, addr);
|
||||
state.trackedTokens.push({
|
||||
address: addr,
|
||||
|
||||
@@ -1,197 +0,0 @@
|
||||
// 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 };
|
||||
@@ -113,7 +113,7 @@ function startWait(txInfo, txHash, broadcastTime, pollNow) {
|
||||
renderElapsed();
|
||||
}, 1000);
|
||||
|
||||
const provider = getProvider(state.rpcUrl, state.networkId);
|
||||
const provider = getProvider(state.rpcUrl);
|
||||
let consecutiveFailures = 0;
|
||||
|
||||
async function poll() {
|
||||
|
||||
@@ -23,14 +23,33 @@
|
||||
// disputed is refused rather than guessed at.
|
||||
|
||||
// Solidity's decimals() is a uint8, and every source here is ultimately
|
||||
// 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");
|
||||
// reporting that call's result.
|
||||
const { MAX_DECIMALS } = 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
|
||||
|
||||
@@ -9,49 +9,25 @@ 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).
|
||||
//
|
||||
// `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);
|
||||
// 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);
|
||||
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(".");
|
||||
@@ -68,28 +44,10 @@ 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, name, symbol, decimals, balance, holders }].
|
||||
// Returns [{ address, symbol, decimals, balance }].
|
||||
// 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(
|
||||
@@ -114,46 +72,11 @@ 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
|
||||
@@ -182,15 +105,7 @@ 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,
|
||||
});
|
||||
@@ -203,15 +118,9 @@ 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,
|
||||
networkId,
|
||||
) {
|
||||
async function refreshBalances(wallets, rpcUrl, blockscoutUrl, trackedTokens) {
|
||||
log.debugf("refreshBalances start, rpc:", rpcUrl);
|
||||
const provider = getProvider(rpcUrl, networkId);
|
||||
const provider = getProvider(rpcUrl);
|
||||
const updates = [];
|
||||
|
||||
for (const wallet of wallets) {
|
||||
@@ -284,9 +193,9 @@ async function refreshBalances(
|
||||
|
||||
// Look up token metadata from its contract.
|
||||
// Calls symbol() and decimals() to verify it implements ERC-20.
|
||||
async function lookupTokenInfo(contractAddress, rpcUrl, networkId) {
|
||||
async function lookupTokenInfo(contractAddress, rpcUrl) {
|
||||
log.debugf("lookupTokenInfo", contractAddress, "rpc:", rpcUrl);
|
||||
const provider = getProvider(rpcUrl, networkId);
|
||||
const provider = getProvider(rpcUrl);
|
||||
const contract = new Contract(contractAddress, ERC20_ABI, provider);
|
||||
|
||||
let name, symbol, decimals;
|
||||
@@ -326,9 +235,9 @@ async function lookupTokenInfo(contractAddress, rpcUrl, networkId) {
|
||||
// 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, networkId, gapLimit = 5) {
|
||||
async function scanForAddresses(xpub, rpcUrl, gapLimit = 5) {
|
||||
log.debugf("scanForAddresses start, gapLimit:", gapLimit);
|
||||
const provider = getProvider(rpcUrl, networkId);
|
||||
const provider = getProvider(rpcUrl);
|
||||
const used = [];
|
||||
let checked = 0;
|
||||
let checkUpTo = gapLimit;
|
||||
|
||||
@@ -194,23 +194,6 @@ 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.
|
||||
@@ -268,7 +251,6 @@ module.exports = {
|
||||
sendMessage,
|
||||
storageGet,
|
||||
storageLocal,
|
||||
storageRemove,
|
||||
storageSet,
|
||||
tabsApi,
|
||||
tabsQuery,
|
||||
|
||||
@@ -1,23 +1,14 @@
|
||||
// Consolidated chain-switch handler for the popup.
|
||||
// Consolidated chain-switch handler.
|
||||
//
|
||||
// Every state change required when the active network changes is
|
||||
// performed here so that callers (settings UI, future chain additions) all go
|
||||
// performed here so that callers (settings UI, background
|
||||
// wallet_switchEthereumChain, 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 { applyChainSwitchFields } = require("./chainSwitchFields");
|
||||
const { networkById } = require("./networks");
|
||||
const { clearPrices } = require("./prices");
|
||||
|
||||
// Switch the active chain and reset all chain-specific cached state.
|
||||
@@ -25,14 +16,56 @@ const { clearPrices } = require("./prices");
|
||||
async function onChainSwitch(newNetworkId) {
|
||||
const { state, saveState } = require("./state");
|
||||
|
||||
const net = applyChainSwitchFields(state, 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.
|
||||
//
|
||||
// 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;
|
||||
|
||||
// --- price cache ---
|
||||
// Prices are chain-specific (testnet tokens are worthless,
|
||||
// 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.
|
||||
// ETC has different pricing, etc.).
|
||||
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;
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
// 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 };
|
||||
@@ -32,11 +32,11 @@ function setCache(address, name) {
|
||||
localStorage.setItem(key, JSON.stringify({ name, ts: Date.now() }));
|
||||
}
|
||||
|
||||
async function resolveEnsName(address, rpcUrl, networkId) {
|
||||
async function resolveEnsName(address, rpcUrl) {
|
||||
const cached = getCached(address);
|
||||
if (cached !== undefined) return cached;
|
||||
|
||||
const provider = getProvider(rpcUrl, networkId);
|
||||
const provider = getProvider(rpcUrl);
|
||||
try {
|
||||
const name = (await provider.lookupAddress(address)) || null;
|
||||
setCache(address, name);
|
||||
@@ -48,11 +48,11 @@ async function resolveEnsName(address, rpcUrl, networkId) {
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveEnsNames(addresses, rpcUrl, networkId) {
|
||||
async function resolveEnsNames(addresses, rpcUrl) {
|
||||
const results = new Map();
|
||||
await Promise.all(
|
||||
addresses.map(async (addr) => {
|
||||
results.set(addr, await resolveEnsName(addr, rpcUrl, networkId));
|
||||
results.set(addr, await resolveEnsName(addr, rpcUrl));
|
||||
}),
|
||||
);
|
||||
return results;
|
||||
|
||||
@@ -31,42 +31,8 @@ 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) {
|
||||
if (!isKnownNetworkId(id)) throw new UnknownNetworkError(id);
|
||||
return NETWORKS[id];
|
||||
return NETWORKS[id] || NETWORKS.mainnet;
|
||||
}
|
||||
|
||||
function networkByChainId(chainId) {
|
||||
@@ -85,8 +51,6 @@ function explorerLink(network, type, value) {
|
||||
module.exports = {
|
||||
NETWORKS,
|
||||
SUPPORTED_CHAIN_IDS,
|
||||
UnknownNetworkError,
|
||||
isKnownNetworkId,
|
||||
networkById,
|
||||
networkByChainId,
|
||||
explorerLink,
|
||||
|
||||
@@ -1,425 +0,0 @@
|
||||
// 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,
|
||||
};
|
||||
@@ -78,18 +78,9 @@ function getAddressValue(addr) {
|
||||
let usd = parseFloat(addr.balance || "0") * prices.ETH;
|
||||
let partial = false;
|
||||
for (const token of addr.tokenBalances || []) {
|
||||
// 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);
|
||||
const tokenBal = parseFloat(token.balance || "0");
|
||||
// A balance of zero is not a holding: it can neither add to the total
|
||||
// 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.
|
||||
// nor make it incomplete.
|
||||
if (!(tokenBal > 0)) continue;
|
||||
if (prices[token.symbol]) {
|
||||
usd += tokenBal * prices[token.symbol];
|
||||
|
||||
@@ -1,48 +1,46 @@
|
||||
// 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");
|
||||
const {
|
||||
DEFAULT_STATE,
|
||||
PERSISTED_FIELDS,
|
||||
normalizePersisted,
|
||||
} = require("./persistedState");
|
||||
|
||||
const {
|
||||
STATE_SCHEMA_VERSION,
|
||||
assertStateUsable,
|
||||
migrationNeeded,
|
||||
} = require("./stateSchema");
|
||||
// Dependency-free constant module; safe to pull into a background bundle.
|
||||
const { RESTORABLE_VIEWS } = require("../popup/restorableViews");
|
||||
|
||||
const { storageGet, storageSet } = require("./browserApi");
|
||||
const { log } = require("./log");
|
||||
|
||||
// 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 = {
|
||||
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 = {
|
||||
...DEFAULT_STATE,
|
||||
// Its own object, not the one DEFAULT_STATE holds: applyChainSwitchFields()
|
||||
// Its own object, not the one DEFAULT_STATE holds: onChainSwitch()
|
||||
// mutates this map in place, and a spread copies the reference.
|
||||
networkEndpoints: {},
|
||||
currentView: null,
|
||||
@@ -53,75 +51,161 @@ const rawState = {
|
||||
viewStack: [],
|
||||
};
|
||||
|
||||
// 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.
|
||||
// Keep only the leading run of stored views the popup is willing to render.
|
||||
//
|
||||
// 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.
|
||||
// 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.
|
||||
//
|
||||
// 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";
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
// 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 (
|
||||
!loaded &&
|
||||
!adopted &&
|
||||
typeof prop === "string" &&
|
||||
GUARDED_FIELDS.has(prop)
|
||||
kept.length === 0 &&
|
||||
currentView !== "main" &&
|
||||
RESTORABLE_VIEWS.has(currentView)
|
||||
) {
|
||||
throw new StateNotLoadedError(prop);
|
||||
return ["main"];
|
||||
}
|
||||
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 kept;
|
||||
}
|
||||
|
||||
// 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.
|
||||
@@ -133,7 +217,7 @@ let baseline = null;
|
||||
|
||||
function snapshotPersisted() {
|
||||
const out = {};
|
||||
for (const key of PERSISTED_FIELDS) out[key] = rawState[key];
|
||||
for (const key of PERSISTED_FIELDS) out[key] = state[key];
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -290,10 +374,11 @@ function mergeWallet(base, ours, theirs) {
|
||||
}
|
||||
|
||||
// Merge one address's leaf fields (balance, ensName, tokenBalances, ...).
|
||||
// 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.
|
||||
// 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.
|
||||
function mergeAddress(base, ours, theirs) {
|
||||
if (!base) return ours;
|
||||
const merged = { ...theirs };
|
||||
@@ -310,26 +395,12 @@ 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);
|
||||
@@ -337,16 +408,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) {
|
||||
put(key, mergeLeaf(base[key], ours[key], theirs[key]));
|
||||
result[key] = mergeLeaf(base[key], ours[key], theirs[key]);
|
||||
} else {
|
||||
put(key, theirs[key]);
|
||||
result[key] = theirs[key];
|
||||
}
|
||||
}
|
||||
|
||||
for (const key of Object.keys(ours)) {
|
||||
if (seen.has(key)) continue;
|
||||
if (!Object.prototype.hasOwnProperty.call(base, key)) {
|
||||
put(key, ours[key]);
|
||||
result[key] = ours[key];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -354,12 +425,12 @@ function mergeMapByKey(base, ours, theirs, mergeLeaf) {
|
||||
}
|
||||
|
||||
// allowedSites/deniedSites: { [address]: [hostname, ...] }. The hostname
|
||||
// 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.
|
||||
// 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.
|
||||
function mergeHostnameList(base, ours, theirs) {
|
||||
return mergeListByIdentity(
|
||||
base,
|
||||
@@ -374,15 +445,14 @@ function mergeSiteMap(base, ours, theirs) {
|
||||
return mergeMapByKey(base, ours, theirs, mergeHostnameList);
|
||||
}
|
||||
|
||||
// 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).
|
||||
// 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).
|
||||
function mergeEndpointEntry(base, ours, theirs) {
|
||||
if (!base) return ours;
|
||||
const merged = { ...theirs };
|
||||
@@ -398,12 +468,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) 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, 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).
|
||||
//
|
||||
// Only the fields this page actually changed — those that differ from
|
||||
// `baseline`, captured at the last loadState()/saveState() on this page —
|
||||
@@ -412,27 +482,28 @@ 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: a balance refresh mutates wallets IN PLACE (addr.balance /
|
||||
// whole field: backgroundRefresh() 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 that page's own copy — loaded before its multi-second network
|
||||
// write back background'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 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.
|
||||
// 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.
|
||||
//
|
||||
// `allowedSites` and `deniedSites` get the same treatment (mergeSiteMap(),
|
||||
// by address key and then by hostname within each address's list), for the
|
||||
// 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.
|
||||
// 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.
|
||||
//
|
||||
// `networkEndpoints` gets the same treatment too (mergeNetworkEndpoints(),
|
||||
// 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
|
||||
// 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
|
||||
// comment at mergeEndpointEntry() for why the collision this closes is
|
||||
// milder than the other two.
|
||||
//
|
||||
@@ -440,8 +511,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 `{}` (applyChainSwitchFields()) — so there is
|
||||
// no in-place mutation for a whole-field diff to collide with; `viewData` is
|
||||
// only ever reset wholesale to `{}` (onChainSwitch()) — 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.
|
||||
//
|
||||
@@ -466,17 +537,9 @@ 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() in persistedState.js.
|
||||
// normalizePersisted() above.
|
||||
const fresh = normalizePersisted(result.autistmask);
|
||||
|
||||
const merged = { ...fresh };
|
||||
@@ -509,17 +572,13 @@ 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.
|
||||
rawState.hasWallet = rawState.wallets.length > 0;
|
||||
state.hasWallet = state.wallets.length > 0;
|
||||
|
||||
baseline = structuredClone(snapshotPersisted());
|
||||
}
|
||||
@@ -536,88 +595,25 @@ 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");
|
||||
// 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,
|
||||
);
|
||||
}
|
||||
if (result.autistmask) {
|
||||
Object.assign(rawState, normalizePersisted(result.autistmask));
|
||||
Object.assign(state, 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.
|
||||
// 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.
|
||||
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;
|
||||
@@ -628,9 +624,7 @@ function currentAddress() {
|
||||
module.exports = {
|
||||
state,
|
||||
saveState,
|
||||
onSaveFailure,
|
||||
loadState,
|
||||
currentAddress,
|
||||
currentNetwork,
|
||||
StateNotLoadedError,
|
||||
};
|
||||
|
||||
@@ -1,278 +0,0 @@
|
||||
// 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,
|
||||
};
|
||||
@@ -11,10 +11,6 @@ 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.
|
||||
@@ -96,37 +92,21 @@ function parseTx(tx, addrLower) {
|
||||
function parseTokenTransfer(tt, addrLower) {
|
||||
const from = tt.from?.hash || "";
|
||||
const to = tt.to?.hash || "";
|
||||
// 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 decimals = parseInt(tt.total?.decimals || "18", 10);
|
||||
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: formatted,
|
||||
exactValue: exact,
|
||||
value: formatTxValue(formatUnits(rawVal, decimals)),
|
||||
exactValue: formatUnits(rawVal, decimals),
|
||||
rawAmount: rawVal,
|
||||
rawUnit:
|
||||
decimals === null
|
||||
? sym + " base units (decimals unknown)"
|
||||
: sym + " base units (10^-" + decimals + ")",
|
||||
rawUnit: sym + " base units (10^-" + decimals + ")",
|
||||
valueGwei: null,
|
||||
symbol: sym,
|
||||
direction: direction,
|
||||
|
||||
@@ -52,7 +52,7 @@ function mismatchMessage(displayed, onChain) {
|
||||
);
|
||||
}
|
||||
|
||||
// A decimals value from any source as a number, or null if it is not one.
|
||||
// A decimals value from either 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,16 +60,7 @@ 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. 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.
|
||||
// against it.
|
||||
function toDecimals(value) {
|
||||
let n;
|
||||
if (typeof value === "number") {
|
||||
@@ -119,7 +110,6 @@ module.exports = {
|
||||
displayedDecimals,
|
||||
transferAmountUnits,
|
||||
mismatchMessage,
|
||||
toDecimals,
|
||||
MAX_DECIMALS,
|
||||
UNKNOWN_DISPLAYED_DECIMALS_MESSAGE,
|
||||
UNREADABLE_CONTRACT_DECIMALS_MESSAGE,
|
||||
|
||||
@@ -48,79 +48,13 @@ function formatAmount(raw, decimals) {
|
||||
return truncateAmountNeverZero(formatUnits(raw, decimals));
|
||||
}
|
||||
|
||||
// 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") {
|
||||
if (!address || address === "0x0000000000000000000000000000000000000000") {
|
||||
return { symbol: "ETH", decimals: 18, address: null };
|
||||
}
|
||||
const t = TOKEN_BY_ADDRESS.get(address.toLowerCase());
|
||||
@@ -271,14 +205,6 @@ 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,
|
||||
@@ -327,17 +253,13 @@ function decodeV4Swap(input) {
|
||||
],
|
||||
params[i],
|
||||
);
|
||||
if (!present(settleToken)) settleToken = s[0][0];
|
||||
if (!settleToken) settleToken = s[0][0];
|
||||
const path = s[0][1];
|
||||
if (path.length > 0 && !present(takeToken)) {
|
||||
if (path.length > 0 && !takeToken) {
|
||||
takeToken = path[path.length - 1][0];
|
||||
}
|
||||
if (!present(amountIn)) {
|
||||
amountIn = v4ExactInAmount(s[0][2]);
|
||||
}
|
||||
if (!present(amountOutMin)) {
|
||||
amountOutMin = s[0][3];
|
||||
}
|
||||
if (!amountIn) amountIn = s[0][2];
|
||||
if (!amountOutMin) amountOutMin = s[0][3];
|
||||
} catch {
|
||||
// Fall through — SETTLE/TAKE will provide tokens
|
||||
}
|
||||
@@ -353,20 +275,16 @@ function decodeV4Swap(input) {
|
||||
);
|
||||
const poolKey = s[0][0];
|
||||
const zeroForOne = s[0][1];
|
||||
if (!present(settleToken))
|
||||
if (!settleToken)
|
||||
settleToken = zeroForOne
|
||||
? poolKey[0]
|
||||
: poolKey[1];
|
||||
if (!present(takeToken))
|
||||
if (!takeToken)
|
||||
takeToken = zeroForOne
|
||||
? poolKey[1]
|
||||
: poolKey[0];
|
||||
if (!present(amountIn)) {
|
||||
amountIn = v4ExactInAmount(s[0][2]);
|
||||
}
|
||||
if (!present(amountOutMin)) {
|
||||
amountOutMin = s[0][3];
|
||||
}
|
||||
if (!amountIn) amountIn = s[0][2];
|
||||
if (!amountOutMin) amountOutMin = s[0][3];
|
||||
} catch {
|
||||
// Fall through
|
||||
}
|
||||
@@ -383,9 +301,9 @@ function decodeV4Swap(input) {
|
||||
],
|
||||
params[i],
|
||||
);
|
||||
if (!present(takeToken)) takeToken = s[0][0];
|
||||
if (!takeToken) takeToken = s[0][0];
|
||||
const path = s[0][1];
|
||||
if (path.length > 0 && !present(settleToken)) {
|
||||
if (path.length > 0 && !settleToken) {
|
||||
settleToken = path[path.length - 1][0];
|
||||
}
|
||||
} catch {
|
||||
@@ -401,11 +319,11 @@ function decodeV4Swap(input) {
|
||||
);
|
||||
const poolKey = s[0][0];
|
||||
const zeroForOne = s[0][1];
|
||||
if (!present(settleToken))
|
||||
if (!settleToken)
|
||||
settleToken = zeroForOne
|
||||
? poolKey[0]
|
||||
: poolKey[1];
|
||||
if (!present(takeToken))
|
||||
if (!takeToken)
|
||||
takeToken = zeroForOne
|
||||
? poolKey[1]
|
||||
: poolKey[0];
|
||||
@@ -444,44 +362,11 @@ function decode(data, toAddress, sources) {
|
||||
|
||||
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(
|
||||
@@ -492,61 +377,61 @@ function decode(data, toAddress, sources) {
|
||||
try {
|
||||
if (cmdId === 0x0a) {
|
||||
const p = decodePermit2(inputs[i]);
|
||||
// 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 (p) {
|
||||
inputToken = p.token;
|
||||
inputAmount = p.amount;
|
||||
}
|
||||
}
|
||||
|
||||
if (cmdId === 0x0e) {
|
||||
const b = decodeBalanceCheck(inputs[i]);
|
||||
if (b) setOutput(b.token, b.minBalance);
|
||||
if (b) {
|
||||
outputToken = b.token;
|
||||
minOutput = b.minBalance;
|
||||
}
|
||||
}
|
||||
|
||||
if (cmdId === 0x00) {
|
||||
const s = decodeV3SwapExactIn(inputs[i]);
|
||||
if (s) {
|
||||
setInputOnce(s.tokenIn, s.amountIn);
|
||||
if (!inputToken) inputToken = s.tokenIn;
|
||||
if (!inputAmount) inputAmount = s.amountIn;
|
||||
// Always update output: in multi-step swaps (V3 → V4),
|
||||
// the last swap step determines the final output token
|
||||
// and minimum received amount.
|
||||
setOutput(s.tokenOut, s.amountOutMin);
|
||||
outputToken = s.tokenOut;
|
||||
minOutput = s.amountOutMin;
|
||||
}
|
||||
}
|
||||
|
||||
if (cmdId === 0x08) {
|
||||
const s = decodeV2SwapExactIn(inputs[i]);
|
||||
if (s) {
|
||||
setInputOnce(s.tokenIn, s.amountIn);
|
||||
setOutput(s.tokenOut, s.amountOutMin);
|
||||
if (!inputToken) inputToken = s.tokenIn;
|
||||
if (!inputAmount) inputAmount = s.amountIn;
|
||||
outputToken = s.tokenOut;
|
||||
minOutput = s.amountOutMin;
|
||||
}
|
||||
}
|
||||
|
||||
if (cmdId === 0x0b) {
|
||||
const w = decodeWrapEth(inputs[i]);
|
||||
if (w) {
|
||||
setInputOnce(
|
||||
"0x0000000000000000000000000000000000000000",
|
||||
w.amount,
|
||||
);
|
||||
if (w && !inputToken) {
|
||||
inputToken =
|
||||
"0x0000000000000000000000000000000000000000";
|
||||
inputAmount = w.amount;
|
||||
}
|
||||
}
|
||||
|
||||
if (cmdId === 0x10) {
|
||||
const v4 = decodeV4Swap(inputs[i]);
|
||||
if (v4) {
|
||||
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);
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -558,10 +443,7 @@ function decode(data, toAddress, sources) {
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve token info. A null token on either side means the calldata
|
||||
// named no currency for it; tokenInfo() refuses rather than calling it
|
||||
// ETH. UNWRAP_WETH is the one output that is ETH without a currency to
|
||||
// decode, and it is answered here rather than left to that rule.
|
||||
// Resolve token info
|
||||
const inInfo = tokenInfo(inputToken, sources);
|
||||
const outInfo = hasUnwrapWeth
|
||||
? { symbol: "ETH", decimals: 18, address: null }
|
||||
@@ -583,7 +465,7 @@ function decode(data, toAddress, sources) {
|
||||
address: toAddress,
|
||||
});
|
||||
|
||||
if (present(inputToken) && present(inInfo.address)) {
|
||||
if (inputToken && inInfo.address) {
|
||||
const label = inSymbol
|
||||
? inSymbol + " (" + inputToken + ")"
|
||||
: inputToken;
|
||||
@@ -595,28 +477,18 @@ function decode(data, toAddress, sources) {
|
||||
});
|
||||
} 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 (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);
|
||||
}
|
||||
if (inputAmount !== null && inputAmount !== undefined) {
|
||||
const maxUint160 = BigInt(
|
||||
"0xffffffffffffffffffffffffffffffffffffffff",
|
||||
);
|
||||
const isUnlimited = inputAmount >= maxUint160;
|
||||
// An unbounded permit needs no scale to describe, so it is still
|
||||
// named rather than refused.
|
||||
const amount = isUnlimited
|
||||
? { raw: "Unlimited", display: "Unlimited" }
|
||||
: amountText(inputAmount, inInfo);
|
||||
details.push({
|
||||
label: "Amount",
|
||||
value: amount.display,
|
||||
@@ -624,44 +496,26 @@ function decode(data, toAddress, sources) {
|
||||
});
|
||||
}
|
||||
|
||||
// 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)) {
|
||||
if (outSymbol) {
|
||||
if (outInfo.address) {
|
||||
const label = outSymbol
|
||||
? outSymbol + " (" + outInfo.address + ")"
|
||||
: outInfo.address;
|
||||
? outSymbol + " (" + outputToken + ")"
|
||||
: outputToken;
|
||||
details.push({
|
||||
label: "Token Out",
|
||||
value: label,
|
||||
address: outInfo.address,
|
||||
address: outputToken,
|
||||
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 });
|
||||
details.push({ label: "Token Out", value: outSymbol });
|
||||
}
|
||||
}
|
||||
|
||||
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.
|
||||
if (minOutput !== null && minOutput !== undefined) {
|
||||
details.push({
|
||||
label: "Min. received",
|
||||
value:
|
||||
minOutput === 0n
|
||||
? NO_MINIMUM
|
||||
: amountText(minOutput, outInfo).display,
|
||||
value: amountText(minOutput, outInfo).display,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -8,8 +8,6 @@
|
||||
// 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 = [];
|
||||
|
||||
@@ -249,17 +247,32 @@ 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 storage = makeStorageStub(initialStore, mockStorageTick);
|
||||
const storageStore = initialStore;
|
||||
const alarmsStub = makeAlarmsStub();
|
||||
const listeners = { onInstalled: [], onStartup: [] };
|
||||
global.chrome = {
|
||||
alarms: alarmsStub,
|
||||
storage,
|
||||
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];
|
||||
},
|
||||
},
|
||||
},
|
||||
runtime: {
|
||||
onMessage: { addListener: jest.fn() },
|
||||
onConnect: { addListener: jest.fn() },
|
||||
@@ -288,7 +301,7 @@ function loadBackground(initialStore = {}) {
|
||||
}));
|
||||
jest.resetModules();
|
||||
require("../src/background/index");
|
||||
return { alarmsStub, listeners, storage };
|
||||
return { alarmsStub, listeners, store: storageStore };
|
||||
}
|
||||
|
||||
// Flush the promise chains the startup path and the alarm handlers run on.
|
||||
@@ -397,23 +410,8 @@ 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: [
|
||||
{
|
||||
name: "Wallet 1",
|
||||
type: "hd",
|
||||
addresses: [
|
||||
{
|
||||
address:
|
||||
"0x0000000000000000000000000000000000000001",
|
||||
balance: "0",
|
||||
tokenBalances: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
{ address: "0x0000000000000000000000000000000000000001" },
|
||||
],
|
||||
lastBalanceRefresh: 0,
|
||||
},
|
||||
@@ -478,16 +476,12 @@ 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 { alarmsStub, storage } = loadBackground(seededStore());
|
||||
const store = seededStore();
|
||||
const { alarmsStub } = loadBackground(store);
|
||||
await settle();
|
||||
|
||||
mockNow += PERIOD_MS;
|
||||
// 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,
|
||||
});
|
||||
store.autistmask.lastBalanceRefresh = mockNow - 10 * 1000;
|
||||
alarmsStub.fire(BALANCE_REFRESH_ALARM);
|
||||
await settle();
|
||||
|
||||
|
||||
@@ -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", from: ADDRESS, to: ADDRESS } },
|
||||
viewData: { tx: { hash: "0xdead" } },
|
||||
});
|
||||
goBack();
|
||||
expect(calls).toEqual(["transactionDetail"]);
|
||||
@@ -162,14 +162,7 @@ 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: {
|
||||
token: "ETH",
|
||||
from: ADDRESS,
|
||||
to: ADDRESS,
|
||||
amount: "1",
|
||||
},
|
||||
},
|
||||
viewData: { pendingTx: { to: ADDRESS, amount: "1" } },
|
||||
});
|
||||
goBack();
|
||||
expect(calls).toEqual(["confirmTx"]);
|
||||
@@ -178,7 +171,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", to: ADDRESS },
|
||||
viewData: { hash: "0xdead" },
|
||||
});
|
||||
goBack();
|
||||
expect(calls).toEqual(["successTx"]);
|
||||
@@ -187,7 +180,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", to: ADDRESS },
|
||||
viewData: { message: "execution reverted" },
|
||||
});
|
||||
goBack();
|
||||
expect(calls).toEqual(["errorTx"]);
|
||||
|
||||
@@ -24,7 +24,6 @@ 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";
|
||||
@@ -138,22 +137,22 @@ function loadBackground(options) {
|
||||
jest.resetModules();
|
||||
|
||||
const broadcastTransaction = jest.fn();
|
||||
const loadState = jest.fn(opts.loadState || (async () => {}));
|
||||
|
||||
// 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;
|
||||
// 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;
|
||||
|
||||
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: (rpcUrl, networkId) =>
|
||||
fakeProvider(
|
||||
broadcastTransaction,
|
||||
opts.provider,
|
||||
chainOf(networkId).num,
|
||||
),
|
||||
getProvider: () =>
|
||||
fakeProvider(broadcastTransaction, opts.provider, chain.num),
|
||||
refreshBalances: jest.fn(async () => {}),
|
||||
}));
|
||||
jest.doMock("../src/shared/phishingDomains", () => ({
|
||||
@@ -175,48 +174,15 @@ 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: [
|
||||
{
|
||||
address: signer.address,
|
||||
balance: "0",
|
||||
tokenBalances: [],
|
||||
},
|
||||
{ name: "Wallet 1", type: "hd", addresses: [signer.address] },
|
||||
],
|
||||
},
|
||||
],
|
||||
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;
|
||||
@@ -228,7 +194,15 @@ function loadBackground(options) {
|
||||
const actionPopups = [];
|
||||
|
||||
global.chrome = {
|
||||
storage,
|
||||
storage: {
|
||||
local: {
|
||||
get: jest.fn(
|
||||
opts.storageGet ||
|
||||
(async () => ({ autistmask: persisted })),
|
||||
),
|
||||
set: jest.fn(async () => {}),
|
||||
},
|
||||
},
|
||||
runtime: {
|
||||
getURL: (path) => EXT_URL + path,
|
||||
onMessage: {
|
||||
@@ -427,32 +401,18 @@ 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) => {
|
||||
storage.write("autistmask", {
|
||||
...storage.read("autistmask"),
|
||||
activeAddress: address,
|
||||
});
|
||||
persisted.activeAddress = address;
|
||||
},
|
||||
// The user switching network in the toolbar popup. It moves the stored
|
||||
// network and the endpoint together, as a real switch does.
|
||||
// The user switching network in the toolbar popup.
|
||||
setNetwork: (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;
|
||||
chain = network;
|
||||
},
|
||||
fromPopup: { url: EXT_URL + "src/popup/index.html" },
|
||||
};
|
||||
@@ -717,16 +677,15 @@ 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();
|
||||
const bg = loadBackground({
|
||||
loadState: async () => {
|
||||
await stalled.promise;
|
||||
throw new Error("The wallet data could not be read.");
|
||||
},
|
||||
});
|
||||
|
||||
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",
|
||||
@@ -750,7 +709,6 @@ 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();
|
||||
@@ -1268,18 +1226,19 @@ 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 () => {
|
||||
const bg = loadBackground();
|
||||
let failNext = true;
|
||||
const bg = loadBackground({
|
||||
loadState: async () => {
|
||||
if (failNext) {
|
||||
failNext = false;
|
||||
throw new Error("storage unavailable");
|
||||
}
|
||||
},
|
||||
});
|
||||
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",
|
||||
|
||||
@@ -1,398 +0,0 @@
|
||||
// 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);
|
||||
});
|
||||
});
|
||||
@@ -1,270 +0,0 @@
|
||||
// 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([]);
|
||||
});
|
||||
});
|
||||
@@ -1,366 +0,0 @@
|
||||
// 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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -8,12 +8,10 @@
|
||||
// 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,
|
||||
// which covers the popup's chain switch; this file covers the background's,
|
||||
// which goes through storage rather than the shared state singleton.
|
||||
// The endpoint half of that issue lives in tests/networkEndpoints.test.js;
|
||||
// this file mocks the state module, which that one exercises for real.
|
||||
|
||||
const { networkById } = require("../src/shared/networks");
|
||||
const { makeStorageStub } = require("./support/storageStub");
|
||||
|
||||
const ADDRESS = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
|
||||
|
||||
@@ -53,11 +51,29 @@ afterEach(() => {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Load the background worker against stubbed browser APIs, with the real
|
||||
// chain-switch and persistence modules behind it, and return the handles to
|
||||
// drive it.
|
||||
// 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.
|
||||
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 () => {}),
|
||||
@@ -72,24 +88,12 @@ 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
|
||||
@@ -98,7 +102,12 @@ function loadBackground() {
|
||||
const toTabs = [];
|
||||
|
||||
global.chrome = {
|
||||
storage,
|
||||
storage: {
|
||||
local: {
|
||||
get: jest.fn(async () => ({ autistmask: persisted })),
|
||||
set: jest.fn(async () => {}),
|
||||
},
|
||||
},
|
||||
runtime: {
|
||||
getURL: (path) => "chrome-extension://autistmask/" + path,
|
||||
onMessage: {
|
||||
@@ -148,7 +157,7 @@ function loadBackground() {
|
||||
|
||||
return {
|
||||
switchChain,
|
||||
walletState: () => storage.read("autistmask"),
|
||||
walletState,
|
||||
chainChangedEvents: () =>
|
||||
toTabs.filter((m) => m.eventName === "chainChanged"),
|
||||
};
|
||||
@@ -165,8 +174,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([]);
|
||||
});
|
||||
|
||||
@@ -192,7 +201,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",
|
||||
@@ -208,16 +217,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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,7 +16,6 @@
|
||||
// first, so neither can see this.
|
||||
|
||||
const { networkById } = require("../src/shared/networks");
|
||||
const { makeStorageStub } = require("./support/storageStub");
|
||||
|
||||
const ADDRESS = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
|
||||
|
||||
@@ -65,12 +64,9 @@ afterEach(() => {
|
||||
delete global.chrome;
|
||||
});
|
||||
|
||||
// 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.
|
||||
// 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.
|
||||
function loadColdWorker(networkId) {
|
||||
jest.resetModules();
|
||||
|
||||
@@ -88,13 +84,20 @@ function loadColdWorker(networkId) {
|
||||
registerAlarmHandlers: jest.fn(),
|
||||
}));
|
||||
|
||||
const storage = makeStorageStub({ autistmask: storedProfile(networkId) });
|
||||
const store = { autistmask: storedProfile(networkId) };
|
||||
|
||||
let messageListener = null;
|
||||
const toTabs = [];
|
||||
|
||||
global.chrome = {
|
||||
storage,
|
||||
storage: {
|
||||
local: {
|
||||
get: jest.fn(async () => ({ autistmask: store.autistmask })),
|
||||
set: jest.fn(async (items) => {
|
||||
store.autistmask = items.autistmask;
|
||||
}),
|
||||
},
|
||||
},
|
||||
runtime: {
|
||||
getURL: (path) => "chrome-extension://autistmask/" + path,
|
||||
onMessage: {
|
||||
@@ -144,7 +147,7 @@ function loadColdWorker(networkId) {
|
||||
|
||||
return {
|
||||
switchChain,
|
||||
persisted: () => storage.read("autistmask"),
|
||||
persisted: () => store.autistmask,
|
||||
chainChangedEvents: () =>
|
||||
toTabs.filter((m) => m.eventName === "chainChanged"),
|
||||
};
|
||||
|
||||
@@ -1,279 +0,0 @@
|
||||
// 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);
|
||||
});
|
||||
});
|
||||
@@ -19,14 +19,6 @@ 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";
|
||||
|
||||
|
||||
@@ -13,15 +13,13 @@
|
||||
// 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 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`.
|
||||
// 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`.
|
||||
//
|
||||
// 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
|
||||
@@ -35,8 +33,7 @@ jest.mock("../src/shared/vault", () => ({
|
||||
decryptWithPassword: jest.fn(),
|
||||
}));
|
||||
|
||||
const { RESTORABLE_VIEWS } = require("../src/shared/restorableViews");
|
||||
const { makeStorageStub } = require("./support/storageStub");
|
||||
const { RESTORABLE_VIEWS } = require("../src/popup/restorableViews");
|
||||
|
||||
const VIEW = "delete-wallet-lost-password";
|
||||
|
||||
@@ -97,6 +94,35 @@ 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) {
|
||||
@@ -118,10 +144,10 @@ function load() {
|
||||
jest.resetModules();
|
||||
mockSettingsShow.mockClear();
|
||||
|
||||
const storage = makeStorageStub();
|
||||
const storage = makeStorage();
|
||||
const sent = [];
|
||||
globalThis.chrome = {
|
||||
storage: { local: storage.local },
|
||||
storage: { local: storage },
|
||||
runtime: { sendMessage: (msg) => sent.push(msg) },
|
||||
};
|
||||
globalThis.document = makeDocument();
|
||||
@@ -179,7 +205,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 = makeStorageStub();
|
||||
const storage = makeStorage();
|
||||
const written = { wallets: [{ name: "Wallet 1" }] };
|
||||
|
||||
await storage.set({ autistmask: written });
|
||||
@@ -367,8 +393,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.read())).not.toContain("secret-two");
|
||||
expect(JSON.stringify(storage.read())).not.toContain("xpub-Wallet 2");
|
||||
expect(JSON.stringify(storage._raw())).not.toContain("secret-two");
|
||||
expect(JSON.stringify(storage._raw())).not.toContain("xpub-Wallet 2");
|
||||
});
|
||||
|
||||
test("only the deleted wallet's site permissions are dropped", async () => {
|
||||
@@ -436,7 +462,7 @@ describe("deleting without the password", () => {
|
||||
expect(saved.activeAddress).toBeNull();
|
||||
expect(saved.allowedSites).toEqual({});
|
||||
expect(state.currentView).toBe("welcome");
|
||||
expect(JSON.stringify(storage.read())).not.toContain("secret-one");
|
||||
expect(JSON.stringify(storage._raw())).not.toContain("secret-one");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ jest.mock("../src/shared/wallet", () => ({
|
||||
getSignerForAddress: jest.fn(() => ({ privateKey: mockPrivateKey })),
|
||||
}));
|
||||
|
||||
const { RESTORABLE_VIEWS } = require("../src/shared/restorableViews");
|
||||
const { RESTORABLE_VIEWS } = require("../src/popup/restorableViews");
|
||||
|
||||
const VIEW = "export-privkey";
|
||||
const PASSWORD = "correct horse battery";
|
||||
|
||||
@@ -1,282 +0,0 @@
|
||||
// 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();
|
||||
});
|
||||
@@ -47,12 +47,6 @@ 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'"],
|
||||
@@ -121,51 +115,6 @@ 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.
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
// happened.
|
||||
|
||||
const { networkById } = require("../src/shared/networks");
|
||||
const { makeStorageStub } = require("./support/storageStub");
|
||||
|
||||
const ADDRESS = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
|
||||
|
||||
@@ -35,19 +34,25 @@ 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();
|
||||
const storage = makeStorageStub(persisted ? { autistmask: persisted } : {});
|
||||
global.chrome = { storage };
|
||||
let written = null;
|
||||
global.chrome = {
|
||||
storage: {
|
||||
local: {
|
||||
get: jest.fn(async () =>
|
||||
persisted ? { autistmask: persisted } : {},
|
||||
),
|
||||
set: jest.fn(async (items) => {
|
||||
written = items.autistmask;
|
||||
}),
|
||||
},
|
||||
},
|
||||
};
|
||||
return {
|
||||
mod: require("../src/shared/state"),
|
||||
chainSwitch: require("../src/shared/chainSwitch"),
|
||||
written: () => storage.read("autistmask"),
|
||||
written: () => written,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -90,26 +90,6 @@ 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(
|
||||
|
||||
@@ -1,404 +0,0 @@
|
||||
// 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();
|
||||
});
|
||||
});
|
||||
@@ -1,864 +0,0 @@
|
||||
// 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -35,11 +35,6 @@ 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
|
||||
|
||||
@@ -9,8 +9,6 @@
|
||||
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",
|
||||
@@ -53,17 +51,19 @@ describe("the UTC Timestamps checkbox placement", () => {
|
||||
});
|
||||
|
||||
describe("the UTC Timestamps setting round-trips through storage", () => {
|
||||
let storage;
|
||||
let store;
|
||||
|
||||
// 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() {
|
||||
storage = makeStorageStub();
|
||||
global.chrome = { storage };
|
||||
store = {};
|
||||
global.chrome = {
|
||||
storage: {
|
||||
local: {
|
||||
get: async (key) =>
|
||||
key in store ? { [key]: store[key] } : {},
|
||||
set: async (obj) => Object.assign(store, obj),
|
||||
},
|
||||
},
|
||||
};
|
||||
jest.resetModules();
|
||||
return require("../src/shared/state");
|
||||
}
|
||||
@@ -86,18 +86,12 @@ 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(storage.read("autistmask").utcTimestamps).toBe(true);
|
||||
expect(store.autistmask.utcTimestamps).toBe(true);
|
||||
|
||||
// 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).
|
||||
// A fresh popup load sees it.
|
||||
jest.resetModules();
|
||||
const second = require("../src/shared/state");
|
||||
expect(() => second.state.utcTimestamps).toThrow(
|
||||
second.StateNotLoadedError,
|
||||
);
|
||||
expect(second.state.utcTimestamps).toBe(false);
|
||||
await second.loadState();
|
||||
expect(second.state.utcTimestamps).toBe(true);
|
||||
});
|
||||
|
||||
@@ -12,7 +12,7 @@ const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const { walletHasRecoveryPhrase } = require("../src/shared/wallet");
|
||||
const { RESTORABLE_VIEWS } = require("../src/shared/restorableViews");
|
||||
const { RESTORABLE_VIEWS } = require("../src/popup/restorableViews");
|
||||
|
||||
const SHOW_PHRASE_VIEW = "show-phrase";
|
||||
|
||||
|
||||
@@ -1,37 +1,26 @@
|
||||
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: ADDRESS, balance: "0", tokenBalances: [] }],
|
||||
},
|
||||
];
|
||||
return [{ name: "Wallet 1", type: "hd", addresses: [ADDRESS] }];
|
||||
}
|
||||
|
||||
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 storage = makeStorageStub(persisted ? { autistmask: persisted } : {});
|
||||
global.chrome = { storage };
|
||||
return {
|
||||
mod: require("../src/shared/state"),
|
||||
set: storage.set,
|
||||
stored: () => storage.read("autistmask"),
|
||||
const set = jest.fn(async () => {});
|
||||
global.chrome = {
|
||||
storage: {
|
||||
local: {
|
||||
get: jest.fn(async () =>
|
||||
persisted ? { autistmask: persisted } : {},
|
||||
),
|
||||
set,
|
||||
},
|
||||
},
|
||||
};
|
||||
return { mod: require("../src/shared/state"), set };
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
|
||||
@@ -10,12 +10,32 @@
|
||||
//
|
||||
// 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 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).
|
||||
// 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).
|
||||
|
||||
const { makeStorageStub } = require("./support/storageStub");
|
||||
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);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -23,7 +43,7 @@ const { makeStorageStub } = require("./support/storageStub");
|
||||
// singleton, so each page needs its own registry to hold its own copy.
|
||||
function loadPage(storage) {
|
||||
jest.resetModules();
|
||||
globalThis.chrome = { storage: { local: storage.local } };
|
||||
globalThis.chrome = { storage: { local: storage } };
|
||||
return {
|
||||
state: require("../src/shared/state"),
|
||||
helpers: require("../src/popup/views/helpers"),
|
||||
@@ -98,7 +118,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 = makeStorageStub();
|
||||
const storage = makeStorage();
|
||||
await storage.set({ autistmask: { wallets: [W1] } });
|
||||
|
||||
// Loaded while storage held only Wallet 1, and never reloads —
|
||||
@@ -151,7 +171,7 @@ describe("the approval-window reproduction", () => {
|
||||
test("the wallet added in the popup survives confirming the approval", async () => {
|
||||
globalThis.document = makeDocument();
|
||||
|
||||
const storage = makeStorageStub();
|
||||
const storage = makeStorage();
|
||||
await storage.set({ autistmask: { wallets: [W1] } });
|
||||
|
||||
// The background opens the approval window on the approve-tx
|
||||
@@ -203,7 +223,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 = makeStorageStub();
|
||||
const storage = makeStorage();
|
||||
await storage.set({ autistmask: { wallets: [W1] } });
|
||||
|
||||
// "background": loads first, and its save is the one that lands
|
||||
@@ -243,7 +263,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 = makeStorageStub();
|
||||
const storage = makeStorage();
|
||||
await storage.set({ autistmask: { wallets: [W1, W2] } });
|
||||
|
||||
const background = loadPage(storage);
|
||||
@@ -304,7 +324,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 = makeStorageStub();
|
||||
const storage = makeStorage();
|
||||
await storage.set({
|
||||
autistmask: {
|
||||
wallets: [W1],
|
||||
@@ -342,7 +362,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 = makeStorageStub();
|
||||
const storage = makeStorage();
|
||||
await storage.set({
|
||||
autistmask: {
|
||||
wallets: [W1],
|
||||
@@ -393,7 +413,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 = makeStorageStub();
|
||||
const storage = makeStorage();
|
||||
await storage.set({ autistmask: { wallets: [W1] } });
|
||||
|
||||
// Both pages load before either has created their malformed wallet,
|
||||
|
||||
@@ -1,367 +0,0 @@
|
||||
// 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" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,423 +0,0 @@
|
||||
// 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);
|
||||
});
|
||||
});
|
||||
@@ -1,203 +0,0 @@
|
||||
// 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);
|
||||
});
|
||||
});
|
||||
@@ -1,347 +0,0 @@
|
||||
// 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,
|
||||
};
|
||||
@@ -1,73 +0,0 @@
|
||||
// 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 };
|
||||
@@ -682,7 +682,6 @@ describe("surface 3: the balance list", () => {
|
||||
"https://rpc.example.invalid",
|
||||
BLOCKSCOUT,
|
||||
[],
|
||||
"mainnet",
|
||||
);
|
||||
expect(addr.balance).toBe("1.2345");
|
||||
expect(addr.tokenBalances).toEqual([]);
|
||||
|
||||
@@ -30,11 +30,8 @@ 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. Empty
|
||||
// storage, so a load produces exactly the defaults.
|
||||
const { makeStorageStub } = require("./support/storageStub");
|
||||
|
||||
global.chrome = { storage: makeStorageStub() };
|
||||
// default settings can be asserted against what the README promises.
|
||||
global.chrome = { storage: { local: {} } };
|
||||
|
||||
const {
|
||||
fetchRecentTransactions,
|
||||
@@ -748,16 +745,6 @@ 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);
|
||||
|
||||
@@ -96,18 +96,22 @@ global.document = {
|
||||
|
||||
global.window = { location: { search: "" } };
|
||||
|
||||
// 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 stored = {};
|
||||
global.chrome = {
|
||||
storage: {
|
||||
local: {
|
||||
set: (obj) => {
|
||||
Object.assign(stored, obj);
|
||||
return Promise.resolve();
|
||||
},
|
||||
get: () => Promise.resolve(stored),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const txStatus = require("../src/popup/views/txStatus");
|
||||
const { state } = require("../src/shared/state");
|
||||
const { RESTORABLE_VIEWS } = require("../src/shared/restorableViews");
|
||||
const { RESTORABLE_VIEWS } = require("../src/popup/restorableViews");
|
||||
|
||||
const TX_HASH =
|
||||
"0x85215772ed26ea8b39c2b3b18779030487efbe0b5fd7e882592b2f62b837be84";
|
||||
|
||||
@@ -94,69 +94,6 @@ 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();
|
||||
@@ -181,18 +118,6 @@ 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");
|
||||
@@ -256,7 +181,8 @@ describe("uniswap decoder", () => {
|
||||
expect(tokenIn.value).toBe("ETH (native)");
|
||||
|
||||
const amount = result.details.find((d) => d.label === "Amount");
|
||||
expect(amount.value).toBe("1.0000 ETH");
|
||||
expect(amount.value).toContain("1.0000");
|
||||
expect(amount.value).toContain("ETH");
|
||||
});
|
||||
|
||||
test("decodes UNWRAP_WETH as ETH output", () => {
|
||||
@@ -412,211 +338,6 @@ 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(
|
||||
|
||||
@@ -1,107 +0,0 @@
|
||||
// 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();
|
||||
});
|
||||
});
|
||||
@@ -1,182 +0,0 @@
|
||||
// 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");
|
||||
});
|
||||
});
|
||||
@@ -1,211 +0,0 @@
|
||||
// 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");
|
||||
});
|
||||
});
|
||||
@@ -1,185 +0,0 @@
|
||||
// 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"> </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);
|
||||
});
|
||||
});
|
||||
@@ -1,455 +0,0 @@
|
||||
// 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'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'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'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\//);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user