Compare commits

...

6 Commits

Author SHA1 Message Date
277ec8c8f8 harden: make the background physically unable to read the shared state singleton (closes #324)
All checks were successful
check / check (push) Successful in 50s
e2e / e2e-chrome (push) Successful in 1m25s
e2e / e2e-firefox (push) Successful in 38s
Five defects traced to one fact: src/background/index.js read and wrote the
module-level `state` singleton in src/shared/state.js, which the MV3 service
worker never populates and which answered an unpopulated read out of
DEFAULT_STATE in silence. Every previous fix added a loadState() before the
access, and that is what produced the fifth: a load detaches the objects an
in-flight handler is holding.

So the reachability goes rather than a sixth call site.

The background now has its own storage layer, src/background/state.js:
getState() is a detached, normalized per-call read, and updateState() is a
queued read-modify-write whose read is one storage round trip ahead of its
write. Nothing in the background holds an in-memory copy of the profile.

- Every handler takes one snapshot and answers from it, including the address
  it names: activeAddressOf(s) replaced a second, later storage read that
  could disagree with the first.
- wallet_switchEthereumChain applies applyChainSwitchFields() (split out of
  chainSwitch.js, which keeps the singleton path for the popup) inside
  updateState() instead of calling onChainSwitch() on the singleton.
- The remembered site decision is a read-modify-write, not a load-mutate-save
  around a prompt the user takes seconds to answer.
- backgroundRefresh() refreshes a private copy of the wallets and applies the
  balances that came back by address, so it never publishes an object other
  in-flight work holds, and a wallet added or deleted during the round trip
  survives its write.
- The transaction attempt takes its chain id and its endpoint from the same
  snapshot. They used to come from different moments, so a chain switch
  committed in between moved the endpoint under an artifact already verified
  against the old chain.

getProvider(rpcUrl, networkId) now REQUIRES the network id and validates it
against networks.js. That closes the cold-worker wrong-chain send at its shape
rather than at one call site: the hint used to default to currentNetwork() off
the unpopulated singleton, so the endpoint was the user's chain and ethers
fixed chainId at 0x1, and the wallet's own verifySignedTx then refused every
non-mainnet dApp send. refreshBalances(), lookupTokenInfo(), scanForAddresses()
and resolveEnsName() carry the id through; balances.js no longer requires
state.js at all.

The prohibition is enforced mechanically, not by review: a custom ESLint rule
walks the CommonJS require graph from every src/background/ file and fails the
lint when src/shared/state.js is reachable, naming the chain. A re-export from
any shared module cannot put the singleton back in the bundle unnoticed.

The rule's matcher covers every specifier syntax esbuild resolves statically —
quoted require, backtick require, dynamic import(), and a static import/export
`from` clause — because a narrower match is not a matter of tidiness but a sixth
site the build cannot see: each of those shapes was measured to put state.js in
dist/chrome/src/background/index.js while the lint stayed clean.
tests/backgroundStateLintRule.test.js pins all of them, plus the two-hop
re-export, against a real fixture tree. A computed specifier
(require("../shared/" + "state")) is deliberately not matched: esbuild cannot
resolve it either, so it never reaches the bundle.

Reading a persisted field of the singleton before any load now throws
StateNotLoadedError instead of serving DEFAULT_STATE.

Test stubs: chrome.storage.local is a serialization boundary, and eight files
stubbed it with an aliasing get, so the object a module held and the object
"storage" held were one object — an assertion could pass on a build that never
wrote anything. Every test that drives real persistence now goes through
tests/support/storageStub.js, which structured-clones in both directions.

closes #320
2026-08-23 14:08:14 +00:00
669c443bf9 fix: never render a nonzero approval amount as zero (closes #322)
All checks were successful
check / check (push) Successful in 31s
e2e / e2e-chrome (push) Successful in 1m12s
e2e / e2e-firefox (push) Successful in 22s
An amount below the 4-decimal display floor now extends to its first significant digit on the approval and confirmation screens, instead of stating a real transfer, allowance or swap Min. received as 0.0000. The rule had been implemented three times; all three now share src/shared/amountDisplay.js, which holds the plain truncation and the floored variant side by side. History and balance lists keep the unfloored rule, pinned by test.
2026-08-23 15:43:04 +02:00
12b0c4d1c6 build: remove dist/ when a release build fails (closes #333)
Some checks failed
check / check (push) Successful in 30s
e2e / e2e-chrome (push) Has been cancelled
e2e / e2e-firefox (push) Has been cancelled
A failed release build no longer leaves a complete, loadable debug bundle in dist/ whose every wallet uses the publicly committed test recovery phrase. Each step of the release build runs through script/discard-dist-on-failure, which removes dist/ on failure, says on stderr that it did and why, and returns the step's own status. build-debug is deliberately unwrapped. script/verify-build is untouched.
2026-08-23 15:39:04 +02:00
c36d8b6ddf docs: state the enforced dist/ verification scope precisely (closes #331)
All checks were successful
check / check (push) Successful in 29s
e2e / e2e-chrome (push) Successful in 1m11s
e2e / e2e-firefox (push) Successful in 22s
README, the script synopsis, its header paragraph and the check_dist_tree comment now all say the same thing: regular files and symlinks under dist/ are covered; fifos, sockets, device nodes and empty directories are not, and why. No behaviour change — the walk is untouched.
2026-08-23 15:33:58 +02:00
cef6aaab11 fix: merge state per field instead of overwriting the whole blob (closes #304)
All checks were successful
check / check (push) Successful in 33s
e2e / e2e-chrome (push) Successful in 1m13s
e2e / e2e-firefox (push) Successful in 24s
saveState() is now a read-modify-write that merges only the fields this page
changed, diffed against a deep-cloned per-page baseline. wallets, allowedSites,
deniedSites and networkEndpoints merge structurally by identity, so membership
comes from fresh storage except for this page's own adds and deletes.

Fixes a second extension page silently deleting a wallet, the background balance
refresh clobbering a concurrent add or resurrecting a delete, and a stale page
resurrecting a revoked site permission.

Colliding wallet identities keep both records and log rather than silently
dropping an encryptedSecret. Concurrent writers of the same leaf remain
last-writer-wins by design.
2026-08-20 16:41:19 +02:00
20e911059a fix: give a wallet whose password is lost a way out, and say the password cannot be reset (closes #312)
All checks were successful
check / check (push) Successful in 31s
e2e / e2e-chrome (push) Successful in 1m10s
e2e / e2e-firefox (push) Successful in 23s
A user who forgot their password but held their recovery phrase was permanently
locked out: deletion was password-gated and re-importing the phrase was refused
as a duplicate. Their only escape was destroying extension storage through
browser internals, taking every other wallet with it.

DeleteWallet gains an "I have lost my password" route that destroys the stored
secret after the wallet's name is typed back. No password gate was added:
requiring one to discard a secret protects nothing, since an attacker who wants
destruction can uninstall the extension, and the only person it stops is the
legitimate user who lost it. The screen is excluded from RESTORABLE_VIEWS and
registers an onViewLeave cleanup.

Deletion was chosen over re-import because a key wallet is duplicate-checked by
address rather than xpub, so an xpub-only relaxation would leave that user
still wedged; because re-import makes the user retype their recovery phrase
into a live popup merely to change a password; and because it reaches no end
state that delete-then-import plus scanForAddresses() does not. The attacker
argument did not decide it — re-import clears the "no worse than the phrase
alone" bar.

All three AddWallet password hints now state the password cannot be recovered
or reset and name that mode's only backup, the xprv mode correctly claiming no
recovery phrase. deleteAddress.js no longer tells the user that deleting a
wallet asks for a password, which this change made false.

The typed confirmation collapses internal whitespace on both sides: a wallet
renamed with two spaces displays with one, so the string a user could see and
type could never match, making the confirmation untypable on the one screen
whose purpose is un-wedging a stuck user.

Measured, not reasoned, after review found the first reserve twice too large
and pushing the Import button below the fold: #btn-add-wallet-confirm bottom
628.13 -> 580.13 at 360x600, scrollHeight 636 -> 600, hint box 48px identical
across all three tabs and on re-entry. make check 40 suites / 828 tests,
test-e2e 55/55, test-e2e-firefox 8/8.
2026-08-20 15:12:28 +02:00
52 changed files with 4347 additions and 659 deletions

View File

@@ -60,19 +60,31 @@ hooks:
# scrubbed from the build itself: with AUTISTMASK_DEBUG=1 exported, this target # scrubbed from the build itself: with AUTISTMASK_DEBUG=1 exported, this target
# compiles a debug bundle and then fails on it, loudly, rather than quietly # compiles a debug bundle and then fails on it, loudly, rather than quietly
# handing back something other than the release build that was asked for. # handing back something other than the release build that was asked for.
#
# Every step of this target is wrapped in script/discard-dist-on-failure, so a
# release build that fails removes dist/ instead of leaving a complete, loadable
# debug bundle there for whoever runs the build, sees it fail, and loads
# dist/chrome/ anyway. A step that succeeds removes nothing, and build-debug is
# deliberately not wrapped.
build: build:
@echo "Building extension..." @echo "Building extension..."
@set -eu; \ @set -eu; \
receipt="$$(mktemp "$${TMPDIR:-/tmp}/autistmask-build-receipt.XXXXXX")"; \ receipt="$$(mktemp "$${TMPDIR:-/tmp}/autistmask-build-receipt.XXXXXX")"; \
trap 'rm -f "$$receipt"' EXIT INT TERM; \ trap 'rm -f "$$receipt"' EXIT INT TERM; \
AUTISTMASK_BUILD_RECEIPT="$$receipt" yarn run build 2>&1; \ script/discard-dist-on-failure \
env -u AUTISTMASK_DEBUG script/verify-build --expect release \ env AUTISTMASK_BUILD_RECEIPT="$$receipt" yarn run build 2>&1; \
script/discard-dist-on-failure \
env -u AUTISTMASK_DEBUG script/verify-build --expect release \
--receipt "$$receipt" --receipt "$$receipt"
@script/check-censored --require-dist @script/discard-dist-on-failure script/check-censored --require-dist
# Development-only build: enables the red DEBUG / INSECURE banner and makes # Development-only build: enables the red DEBUG / INSECURE banner and makes
# the hardcoded test recovery phrase the output of wallet creation. Never # the hardcoded test recovery phrase the output of wallet creation. Never
# distribute the artifacts this produces. # distribute the artifacts this produces.
#
# No discard-dist-on-failure here, on purpose: a debug build that fails is not
# producing an artifact anyone could mistake for a release one, and its dist/ is
# the evidence of what went wrong.
build-debug: build-debug:
@echo "Building extension (DEBUG)..." @echo "Building extension (DEBUG)..."
@set -eu; \ @set -eu; \

175
README.md
View File

@@ -63,8 +63,12 @@ that runs `make build`, that target compiles a debug bundle and then **fails**,
because it tells `script/verify-build` in so many words that it was supposed to because it tells `script/verify-build` in so many words that it was supposed to
produce a release build. It used to be that the verifier read the same variable produce a release build. It used to be that the verifier read the same variable
out of its own environment, agreed with itself, and reported a debug artifact as out of its own environment, agreed with itself, and reported a debug artifact as
verified. The build prints which mode it used. See the verified. The build prints which mode it used. A release build that fails also
[DEBUG Mode Policy](#debug-mode-policy) for what the flag changes. **Never **removes `dist/`**, and says so: the bundle it had already written is loadable,
and a loud failure is no protection against someone loading `dist/chrome/`
anyway. `make build-debug` keeps its `dist/` on failure — that output is not
mistakable for a release build, and it is the evidence of what went wrong. See
the [DEBUG Mode Policy](#debug-mode-policy) for what the flag changes. **Never
distribute a debug build** — every wallet it creates gets the same publicly distribute a debug build** — every wallet it creates gets the same publicly
known test recovery phrase. known test recovery phrase.
@@ -81,17 +85,21 @@ lives.
one of the bundles containing `src/shared/constants.js` — into a build receipt, one of the bundles containing `src/shared/constants.js` — into a build receipt,
and `script/verify-build` checks `dist/` against that receipt: every recorded and `script/verify-build` checks `dist/` against that receipt: every recorded
file present with exactly the recorded bytes, every audited bundle carrying the file present with exactly the recorded bytes, every audited bundle carrying the
requested `DEBUG` marker, and nothing under `dist/` that the build did not requested `DEBUG` marker, and no regular file or symlink under `dist/` that the
write. The `Makefile` creates the receipt path with `mktemp` per invocation, build did not write. The `Makefile` creates the receipt path with `mktemp` per
outside the repo, and deletes it afterwards. invocation, outside the repo, and deletes it afterwards.
That is what ties the check to a build rather than to a directory. What it That is what ties the check to a build rather than to a directory. What it
establishes is narrow and worth stating exactly: `dist/` is byte for byte the establishes is narrow and worth stating exactly: `dist/` is byte for byte the
output of the `build.js` run that just finished, with nothing added, removed or output of the `build.js` run that just finished, with no regular file or symlink
altered in between. It establishes nothing about whether the source tree or added, removed or altered in between. Regular files and symlinks are the whole
`build.js` were honest, and it offers nothing to someone handed a `dist/` from of what the tree walk covers; fifos, sockets, device nodes and empty directories
elsewhere — without the receipt from its own build there is no input to the under `dist/` are not checked, because a build emits none of them, none can
check. Verifiable provenance for a third party is signing, which this is not. carry a shippable payload, and `grep` on a fifo would hang rather than fail. It
establishes nothing about whether the source tree or `build.js` were honest, and
it offers nothing to someone handed a `dist/` from elsewhere — without the
receipt from its own build there is no input to the check. Verifiable provenance
for a third party is signing, which this is not.
There is deliberately no target that re-verifies an existing `dist/` on its own. There is deliberately no target that re-verifies an existing `dist/` on its own.
The list of files to check has to come from the build that produced them; read The list of files to check has to come from the build that produced them; read
@@ -143,26 +151,35 @@ provide:
serves. Run deliberately, never as part of a build: the output is committed serves. Run deliberately, never as part of a build: the output is committed
and there is no runtime fetch, so the shipped list is as fresh as the last and there is no runtime fetch, so the shipped list is as fresh as the last
vendoring run that was released vendoring run that was released
- `script/verify-build --expect release|debug --receipt PATH` — assert that - `script/verify-build --expect release|debug --receipt PATH` — assert that the
`dist/` is exactly what the build that just ran emitted, and that the compiled regular files and symlinks under `dist/` are exactly what the build that just
`DEBUG` state of the bundles in it is the one that was asked for. Both ran emitted (other file types are out of scope), and that the compiled `DEBUG`
arguments are required and neither has a default: the expected mode is stated state of the bundles in it is the one that was asked for. Both arguments are
by the caller rather than read from `AUTISTMASK_DEBUG`, and the file list required and neither has a default: the expected mode is stated by the caller
comes from the build's receipt rather than from `dist/` (see rather than read from `AUTISTMASK_DEBUG`, and the file list comes from the
build's receipt rather than from `dist/` (see
[Build Receipts](#build-receipts)). Run automatically at the end of [Build Receipts](#build-receipts)). Run automatically at the end of
`make build` and `make build-debug`; fails loudly rather than passing whenever `make build` and `make build-debug`; fails loudly rather than passing whenever
it cannot determine something. Not part of `make check`, which does not depend it cannot determine something. Not part of `make check`, which does not depend
on build artifacts existing. on build artifacts existing.
- `script/discard-dist-on-failure COMMAND [ARG...]` — run one step of the
**release** build and, if it fails, remove `dist/` before returning that
step's exit status, saying on stderr that it did and why. Every step of
`make build` runs through it; `make build-debug` runs none of them through it.
A step that succeeds removes nothing, and a removal that cannot be completed
is reported as loudly as one that was
- `script/test-verify-build` — exercise every failure mode of - `script/test-verify-build` — exercise every failure mode of
`script/verify-build` against a fixture tree in a temp dir, asserting the exit `script/verify-build` against a fixture tree in a temp dir, asserting the exit
status and the message of each, and read the `make build` and status and the message of each, assert the state of `dist/` on disk after a
failing and a succeeding release build step, and read the `make build` and
`make build-debug` recipes back out of `make -n` to check that they pass the `make build-debug` recipes back out of `make -n` to check that they pass the
mode as an argument on a scrubbed environment. Part of `make check`; it reads mode as an argument on a scrubbed environment and wrap only the release path.
no build artifacts and writes nothing under `dist/`. The cases that depend on Part of `make check`; it reads no build artifacts and writes nothing under
file permissions cannot mean anything for a process that is not subject to `dist/`. The cases that depend on file permissions cannot mean anything for a
them, so the harness proves its runner against a mode-000 file before counting process that is not subject to them, so the harness proves its runner against
them, dropping to an unprivileged user when run as root; if it cannot, it a mode-000 file before counting them, dropping to an unprivileged user when
skips those cases and says so in a banner rather than passing them. run as root; if it cannot, it skips those cases and says so in a banner rather
than passing them.
- `script/docker` — build the Docker image tagged via `script/projectname` - `script/docker` — build the Docker image tagged via `script/projectname`
- `script/cibuild` — CI entrypoint: plain `docker build .` - `script/cibuild` — CI entrypoint: plain `docker build .`
- `script/precommit` — run by the git pre-commit hook; runs `script/check` - `script/precommit` — run by the git pre-commit hook; runs `script/check`
@@ -176,9 +193,11 @@ The Makefile shims to those. It also carries a few targets that have no
silently rewritten. Use `make setup` for a fresh clone. silently rewritten. Use `make setup` for a fresh clone.
- `make hooks` — shims to `script/install-precommit` - `make hooks` — shims to `script/install-precommit`
- `make build` — build the extension into `dist/chrome/` and `dist/firefox/`, - `make build` — build the extension into `dist/chrome/` and `dist/firefox/`,
then verify the result against the build's receipt as a release build then verify the result against the build's receipt as a release build. A
failure at any step removes `dist/`
- `make build-debug` — the same build with `AUTISTMASK_DEBUG=1`, verified as a - `make build-debug` — the same build with `AUTISTMASK_DEBUG=1`, verified as a
debug build (see [Debug Builds](#debug-builds)) debug build, and keeping its `dist/` on failure (see
[Debug Builds](#debug-builds))
- `make clean` — remove `dist/` - `make clean` — remove `dist/`
- `make dev` — build in watch mode - `make dev` — build in watch mode
@@ -689,6 +708,32 @@ Both are click-copyable. Truncating to 4 decimals in summary views is acceptable
for scannability, but the detail view must never discard precision — it is the for scannability, but the detail view must never discard precision — it is the
one place the user can always use to verify exact details. one place the user can always use to verify exact details.
**Specific Exception — nonzero floor on the approval screens:** A nonzero amount
must never render as zero. Truncating to 4 decimals does exactly that to an
amount below 0.0001 — 1 base unit of an 18-decimal token, 500 base units of an
8-decimal one — and on the dApp approval screen and the wait/success/error
screens that carry its amount forward, a real transfer or allowance then reads
as "nothing is being moved". A swap's `Min. received` is the sharper case: a
slippage floor shown as `0.0000` states that the swap may return nothing.
On those screens, when the truncated string would contain no digit from 1 to 9
and the value does, the amount is extended to its first significant digit
instead: `0.000000000000000001 DAI`, not `0.0000 DAI`. The test is on the whole
truncated string, integer part included, so `1.00005` still shows as `1.0000`
the exception only fires where the entire displayed figure would read as zero. A
genuine zero still renders `0.0000`, and truncation stays truncation: `0.99999`
shows as `0.9999`, never rounded up.
The rule and its exception live in `src/shared/amountDisplay.js` as
`truncateAmount()` and `truncateAmountNeverZero()`. Everything the approval and
confirmation screens display goes through the floored one — the ERC-20 amount,
the ETH value and max fee (`src/popup/views/approval.js`), and the swap's
`Amount` and `Min. received` lines (`src/shared/uniswap.js`). The history and
balance lists (`src/shared/transactions.js`) use the unfloored one: the
transaction detail view is the authoritative record and already shows exact
precision. The 4-decimal rule is unchanged everywhere else, including for
amounts at or above the floor on the approval screens.
#### Partial USD totals #### Partial USD totals
Prices are fetched for the top 25 tokens only, so an address can hold assets the Prices are fetched for the top 25 tokens only, so an address can hold assets the
@@ -805,7 +850,9 @@ for the views listed in `RESTORABLE_VIEWS` (`src/popup/restorableViews.js`).
Every other screen falls back to Home. The screens that display a secret — Every other screen falls back to Home. The screens that display a secret —
ExportPrivKey and ShowRecoveryPhrase — are deliberately absent from that list, ExportPrivKey and ShowRecoveryPhrase — are deliberately absent from that list,
so the popup can never reopen onto one of them with no password prompt in front so the popup can never reopen onto one of them with no password prompt in front
of it. of it. So are the two that destroy one, DeleteWallet and
DeleteWalletLostPassword: a popup reopened by accident must not land on a screen
whose button erases key material.
A reopened popup renders the wallet list and the one screen it restores onto, A reopened popup renders the wallet list and the one screen it restores onto,
and nothing else, so every screen on the stack behind that one is still the and nothing else, so every screen on the stack behind that one is still the
@@ -828,7 +875,10 @@ exit from that screen rather than only on its "Back" button, so nothing secret
survives in a hidden view once the user has navigated away by any route. That survives in a hidden view once the user has navigated away by any route. That
covers the revealed private key and recovery phrase, the recovery phrase, covers the revealed private key and recovery phrase, the recovery phrase,
private key or extended private key entered on AddWallet, and the password typed private key or extended private key entered on AddWallet, and the password typed
on ConfirmTx, DeleteWallet, ApproveTx and ApproveSign. on ConfirmTx, DeleteWallet, ApproveTx and ApproveSign. DeleteWalletLostPassword
registers one as well, for the neighbouring reason rather than that one: a
wallet name is not a secret, but a typed confirmation left standing in a hidden
view would leave a wallet one click from deletion.
#### Welcome (`welcome`) #### Welcome (`welcome`)
@@ -889,7 +939,13 @@ on ConfirmTx, DeleteWallet, ApproveTx and ApproveSign.
- **From xprv**: instruction text and a masked extended private key - **From xprv**: instruction text and a masked extended private key
input input
- Password + confirm password inputs, with a hint line whose wording depends - Password + confirm password inputs, with a hint line whose wording depends
on the selected tab on the selected tab. Every wording says that the password cannot be
recovered or reset and names what the only backup of the wallet is — the
recovery phrase, the private key or the extended private key, according to
the tab. This is the only warning the user gets before the wallet exists;
without it, the lost-password route on DeleteWallet is the first they
would hear of it. The hint line reserves its height, so switching tabs
cannot move the password fields under the pointer.
- "Import" button - "Import" button
- **Transitions**: - **Transitions**:
- "Import" with a valid entry and a matching password of at least 12 - "Import" with a valid entry and a matching password of at least 12
@@ -1244,6 +1300,7 @@ on ConfirmTx, DeleteWallet, ApproveTx and ApproveSign.
- Error line - Error line
- Password input - Password input
- "Confirm Delete" button - "Confirm Delete" button
- An underlined "I have lost my password" control
- **Transitions**: - **Transitions**:
- "Confirm Delete" (correct password, other wallets remain) → deletes the - "Confirm Delete" (correct password, other wallets remain) → deletes the
wallet and its site permissions, then → **Settings** with a "Wallet wallet and its site permissions, then → **Settings** with a "Wallet
@@ -1253,10 +1310,54 @@ on ConfirmTx, DeleteWallet, ApproveTx and ApproveSign.
- Either way, the active address moves only if it belonged to the deleted - Either way, the active address moves only if it belonged to the deleted
wallet, and `AUTISTMASK_ACTIVE_CHANGED` is broadcast when it does wallet, and `AUTISTMASK_ACTIVE_CHANGED` is broadcast when it does
(`src/shared/walletDelete.js`) (`src/shared/walletDelete.js`)
- "Confirm Delete" (wrong password) → "Wrong password." on the error line, - "Confirm Delete" (wrong password) → "That password is incorrect. Please
nothing deleted try again." on the error line, nothing deleted
- "I have lost my password" → **DeleteWalletLostPassword**
- "Back" → previous screen (Settings) - "Back" → previous screen (Settings)
#### DeleteWalletLostPassword (`delete-wallet-lost-password`)
- **When**: User tapped "I have lost my password" on DeleteWallet.
- **Why it exists**: without it, a user who has forgotten the password but still
holds the recovery phrase has no route back into the product at all. Deletion
was password-gated, and importing the phrase again is refused as a duplicate
xpub by `findWalletByXpub()` while the wallet is still stored, so the only
escape was clearing extension storage through browser internals — which takes
every other wallet with it.
- **Elements**:
- "Back" button, "Delete Wallet Without a Password" heading
- A statement that the password cannot be recovered or reset, so the wallet
cannot be unlocked again, and that no password is needed to delete it
- What deletion does and does not do: it erases the copy of the key stored
on this device; nothing on chain changes and no money is moved
- The route back — adding the wallet again with the recovery phrase and a
new password — and, in bold, that without that phrase written down the
deletion loses everything the wallet holds, forever
- That the other wallets are not touched
- The wallet's name, and a text input asking for it to be typed back
- Error line
- "Delete This Wallet Forever" button
- **Transitions**:
- "Delete This Wallet Forever" (name typed correctly) → the same two
outcomes as "Confirm Delete" above, through the same `finishDelete()`, so
the selection repair, permission cleanup and `AUTISTMASK_ACTIVE_CHANGED`
broadcast are identical on both routes
- "Delete This Wallet Forever" (name does not match) → "That is not the name
of this wallet. Type <name> to confirm." on the error line, nothing
deleted
- "Back" → **DeleteWallet**, re-entered through its `show()` so the wallet
selection comes back with it. The two delete screens are siblings rather
than parent and child: nothing is pushed on the way here, so both have
Settings as their Back target.
- **Deliberately not password-gated.** A password in front of _discarding_ a
secret protects nobody: an attacker at the popup who wants the wallet gone can
uninstall the extension, so the only person such a gate stops is the owner who
forgot it. The typed name is a check that the user knows which wallet they are
on, not a secret, so it is matched with surrounding spaces and letter case
ignored.
- Not in `RESTORABLE_VIEWS`, alongside `delete-wallet-confirm`: a popup reopened
by accident must not land on a screen whose button erases key material.
#### DeleteAddress (`delete-address-confirm`) #### DeleteAddress (`delete-address-confirm`)
- **When**: User tapped the `[x]` next to an address on Home. Offered only on HD - **When**: User tapped the `[x]` next to an address on Home. Offered only on HD
@@ -1273,13 +1374,13 @@ on ConfirmTx, DeleteWallet, ApproveTx and ApproveSign.
refused: "+" derives the next unused index (`nextIndex` is a high-water refused: "+" derives the next unused index (`nextIndex` is a high-water
mark), and re-importing the wallet's key material is rejected as a mark), and re-importing the wallet's key material is rejected as a
duplicate by `findWalletByXpub` while the wallet is still present. What duplicate by `findWalletByXpub` while the wallet is still present. What
works is deleting the whole wallet in Settings — password-gated, and it works is deleting the whole wallet in Settings — which destroys the stored
destroys the stored secret — then importing again, whereupon secret — then importing again, whereupon `scanForAddresses()` rediscovers
`scanForAddresses()` rediscovers the address **only if it has on-chain the address **only if it has on-chain activity**. An address that was
activity**. An address that was never used is not found by that scan. The never used is not found by that scan. The text is written by
text is written by `recoveryPathText()` rather than sitting in `recoveryPathText()` rather than sitting in `index.html`, so it can name
`index.html`, so it can name the wallet's own kind of key material: an the wallet's own kind of key material: an xprv wallet has no recovery
xprv wallet has no recovery phrase to re-import. phrase to re-import.
- A warning when the address holds anything, ETH or any tracked ERC-20, - A warning when the address holds anything, ETH or any tracked ERC-20,
followed by the holdings themselves via `balanceLinesForAddress()` and the followed by the holdings themselves via `balanceLinesForAddress()` and the
USD total via `formatAddressTotal()` (see USD total via `formatAddressTotal()` (see

123
TODO.md
View File

@@ -26,7 +26,8 @@ milestone is in flight on `next`; its `next` -> `main` PR is
[#190](https://git.eeqj.de/sneak/AutistMask/pulls/190). `make check` verified [#190](https://git.eeqj.de/sneak/AutistMask/pulls/190). `make check` verified
green on `next` at `e9fa8be` on 2026-08-10, and `make build` produces green on `next` at `e9fa8be` on 2026-08-10, and `make build` produces
`dist/chrome/` and `dist/firefox/`, verified against the build's own receipt to `dist/chrome/` and `dist/firefox/`, verified against the build's own receipt to
be exactly what that build emitted with `DEBUG` compiled off. hold exactly the regular files and symlinks that build emitted, with `DEBUG`
compiled off.
The backlog lives on the The backlog lives on the
[Gitea tracker](https://git.eeqj.de/sneak/AutistMask/issues), which is [Gitea tracker](https://git.eeqj.de/sneak/AutistMask/issues), which is
@@ -44,6 +45,126 @@ but the review is broader than any of them.
# Completed Steps # Completed Steps
- 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 an ESLint rule that
walks the background's require graph and matches every specifier syntax
esbuild resolves — quoted, backtick, dynamic `import()` and `from` clause — so
neither a re-export nor an unusual specifier can put the singleton back in the
bundle; the rule's own coverage is pinned by
`tests/backgroundStateLintRule.test.js`. 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 failed release build no longer leaves a loadable debug bundle in
`dist/` ([#333](https://git.eeqj.de/sneak/AutistMask/issues/333)). With
`AUTISTMASK_DEBUG=1` exported, `make build` compiled a debug bundle and failed
on it in `script/verify-build` — but the bundle stayed on disk, loadable, with
every wallet it creates using the publicly committed test recovery phrase.
Every step of `make build` now runs through `script/discard-dist-on-failure`,
which removes `dist/` when a step fails and says on stderr that it did and
why; a removal it cannot complete is reported just as loudly.
`make build-debug` is deliberately not wrapped: its output is not mistakable
for a release build and is the evidence of the failure.
`script/test-verify-build` asserts the state of `dist/` on disk after a
failing and a succeeding step, not just the exit status, and reads `make -n`
to check the wrapper is on the release path and only there.
- 2026-08-23: `README.md` and `script/verify-build`'s own comments now state the
emitted-tree guarantee at the width the code actually enforces
([#331](https://git.eeqj.de/sneak/AutistMask/issues/331)). The tree walk is
`-type f -o -type l`, so the guarantee covers regular files and symlinks under
`dist/`; fifos, sockets, device nodes and empty directories are not checked,
because a build emits none of them, none can carry a shippable payload, and
`grep` on a fifo would hang rather than fail. The exclusion is deliberate and
unchanged — the README said "nothing under `dist/` that the build did not
write", which was broader than that. Documentation only; no executable line
changed.
- 2026-08-23: An amount below the 4-decimal display floor no longer reads as
zero on the approval screens
([#322](https://git.eeqj.de/sneak/AutistMask/issues/322)). With the token's
true scale resolved, the 4-decimal truncation still printed a small amount as
`0.0000` — 1 base unit of an 18-decimal token, 500 of an 8-decimal one — so a
real transfer, allowance or swap was stated as nothing on the one screen whose
job is to say what is being authorized, and a swap's `Min. received` claimed
the user might receive nothing. Three copies of that truncation existed; they
now share `src/shared/amountDisplay.js`. Everything the approval and
confirmation screens render (`src/popup/views/approval.js`,
`src/shared/uniswap.js`) extends to the first significant digit when the
truncated figure would otherwise read as zero, keeping the amount in token
units rather than switching to base units mid-line. The history and balance
lists (`src/shared/transactions.js`) keep the unfloored rule, which is out of
scope by the issue's definition of done. `README.md`'s Display Consistency
section records the exception.
- 2026-08-20: A second extension page can no longer silently delete a wallet
([#304](https://git.eeqj.de/sneak/AutistMask/issues/304)). `saveState()` wrote
the entire state blob, and every extension page — the toolbar popup, a dApp
approval window, `backgroundRefresh()` — holds its own in-memory `state`,
loaded once, with `showView()` saving on every navigation; a second page that
saved after a first had written something new overwrote it, no attacker or
unusual input required. `saveState()` is now a read-modify-write: it re-reads
storage, diffs the persisted fields against a deep-cloned `baseline` snapshot
taken at the last `loadState()`/`saveState()` on that page, and writes only
the fields that actually changed — everything else is carried forward from
storage in its loaded-and-normalized shape (`normalizePersisted()`, shared
with `loadState()`), so a legacy or malformed record a load has always
self-healed in memory keeps getting written back even on a save that touched
something else entirely. `showView()` fires `saveState()` on every navigation
without awaiting it, so two saves from the same page can be in flight at once;
a FIFO queue serializes them rather than letting a slow one finish after a
later one and re-derive a stale answer. Deliberately not done: the live
`state` of a field this page does not own is not rehydrated from what another
page wrote, only the persisted record is — adopting a concurrently-written
value into `state` reintroduced the same clobber one page later, caught by
`tests/txStatus.test.js` red. Two writers of the same field still resolve
last-writer-wins, documented at the merge point. `tests/stateMerge.test.js`
covers the two-page save and the approval-window reproduction from the issue —
add a wallet in one page, force a save from a second page loaded before it,
both wallets survive — each demonstrated failing against the unfixed full-blob
write.
- 2026-08-20: A forgotten password no longer wedges the wallet
([#312](https://git.eeqj.de/sneak/AutistMask/issues/312)). Deleting a wallet
was password-gated and importing its recovery phrase again was refused as a
duplicate xpub, so a user who had the phrase but not the password could
neither leave nor come back: the only way out was clearing extension storage
through browser internals, which takes every other wallet with it.
DeleteWallet now offers "I have lost my password", a screen that destroys the
wallet after the user types its name back — no password, because requiring one
to _discard_ a secret protects nobody. An attacker at the popup who wants the
wallet gone can uninstall the extension; the only person such a gate stopped
was the owner who forgot it. That was chosen over allowing a duplicate xpub to
re-encrypt in place: re-import would have had to be built three times over
(`hd` and `xprv` by xpub, `key` by address), would make the user retype the
recovery phrase into a live popup to change a password, and reaches no state
that delete-then-import does not already reach through `scanForAddresses()`.
Both routes share one `finishDelete()`, so the selection repair, the
site-permission cleanup and the `AUTISTMASK_ACTIVE_CHANGED` broadcast cannot
diverge between them, and the new screen is excluded from `RESTORABLE_VIEWS`
a popup reopened by accident must not land on a button that erases key
material. AddWallet's password hint now says, per import mode, that the
password cannot be recovered or reset and what the only backup is; the hint
line reserves its height so switching tabs cannot move the password fields.
The test drives the real view against a `chrome.storage.local` stub that
structured-clones on both `set` and `get` and asserts against the read-back,
so it fails on the deletion of `saveState()` and not only on an in-memory
splice.
- 2026-08-20: `make build` can no longer hand back a debug build, and - 2026-08-20: `make build` can no longer hand back a debug build, and
`script/verify-build` can no longer be satisfied by bytes the build did not `script/verify-build` can no longer be satisfied by bytes the build did not
produce ([#309](https://git.eeqj.de/sneak/AutistMask/issues/309)). The produce ([#309](https://git.eeqj.de/sneak/AutistMask/issues/309)). The

View File

@@ -8,6 +8,7 @@
const js = require("@eslint/js"); const js = require("@eslint/js");
const globals = require("globals"); const globals = require("globals");
const backgroundState = require("./script/lib/eslint/noStateSingletonInBackground");
// The extension APIs. MV3 Chrome exposes `chrome`; Firefox exposes both, and // The extension APIs. MV3 Chrome exposes `chrome`; Firefox exposes both, and
// the code feature-detects between them. // the code feature-detects between them.
@@ -78,12 +79,28 @@ module.exports = [
}, },
// MV3 background: a service worker, with no window and no document. // 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.
{ {
files: ["src/background/**/*.js"], files: ["src/background/**/*.js"],
plugins: { background: backgroundState },
languageOptions: { languageOptions: {
...commonjs, ...commonjs,
globals: { ...globals.serviceworker, ...extensionGlobals }, 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: // src/shared is bundled into both, so it may only use what both provide:
@@ -107,9 +124,9 @@ module.exports = [
}, },
}, },
// Unit tests: jest on node. // Unit tests, and the helpers they require: jest on node.
{ {
files: ["tests/**/*.test.js"], files: ["tests/**/*.test.js", "tests/support/**/*.js"],
languageOptions: { languageOptions: {
...commonjs, ...commonjs,
globals: { ...globals.node, ...globals.jest }, globals: { ...globals.node, ...globals.jest },

78
script/discard-dist-on-failure Executable file
View File

@@ -0,0 +1,78 @@
#!/bin/sh
# script/discard-dist-on-failure: run one step of the RELEASE build, and if that
# step fails, remove dist/ before returning its exit status. Our own extension
# to scripts-to-rule-them-all, wrapped around every step of make build.
#
# Why: with AUTISTMASK_DEBUG=1 exported in the calling shell, make build
# compiles a debug bundle and then fails on it in script/verify-build — but the
# bundle is already written. It is loadable, and every wallet it creates gets
# the publicly committed test recovery phrase from src/shared/constants.js. A
# failed release build that leaves that behind is a smaller version of the trap
# the verifier exists to close, and "the failure was loud" only works on an
# operator who does not load dist/chrome/ anyway. Removing the artifact does not
# depend on that.
#
# Two things this deliberately does not do. It does not wrap make build-debug: a
# debug build that failed is not a mistakable artifact, and its output is the
# evidence of what went wrong. And it never removes anything on a step that
# SUCCEEDS, including the final check-censored --require-dist pass.
#
# The removal is never silent: it says dist/ is gone and why, on stderr, above
# the build's own failure.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
DIST="$ROOT/dist"
usage() {
echo "usage: discard-dist-on-failure COMMAND [ARG...]" >&2
}
# Remove dist/, and say so. A removal that could not be completed is reported as
# loudly as one that was: the artifact is still on disk, and reporting nothing
# would leave the operator believing it is not.
discard_dist() {
if [ ! -e "$DIST" ] && [ ! -h "$DIST" ]; then
echo "discard-dist-on-failure: the release build failed. There was no" \
"dist/ to remove." >&2
return 0
fi
rm -rf "$DIST" || true
if [ -e "$DIST" ] || [ -h "$DIST" ]; then
echo "discard-dist-on-failure: the release build failed and dist/" \
"COULD NOT BE REMOVED, so it is still on disk. Do not load it:" \
"a release build that failed may hold a complete debug bundle," \
"whose wallets all use the publicly committed test recovery" \
"phrase. Remove it by hand (make clean)." >&2
return 0
fi
echo "discard-dist-on-failure: the release build failed, so dist/ WAS" \
"REMOVED and no longer exists. A release build that fails has often" \
"already emitted a complete, loadable debug bundle — every wallet it" \
"creates gets the publicly committed test recovery phrase — so the" \
"failed build is not left behind to be loaded. Fix the failure and" \
"re-run make build, or run make build-debug if a debug build is what" \
"was wanted; that target keeps its output." >&2
}
main() {
[ "$#" -ge 1 ] || {
usage
echo "discard-dist-on-failure: no command given, so no build step ran" \
"and nothing was removed." >&2
exit 1
}
_status=0
"$@" || _status=$?
[ "$_status" -ne 0 ] || return 0
discard_dist
exit "$_status"
}
main "$@"

View File

@@ -0,0 +1,147 @@
// 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.
//
// A convention nobody can violate beats a convention everyone remembers, so
// this is a lint error rather than a review item. 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.
//
// The walk reads sources from disk and matches import specifiers textually.
// That over-approximates — a specifier inside a comment or a string counts —
// and over-approximating is the safe direction for a prohibition: the failure
// mode is a spurious error naming an exact file and line, not a silent hole.
//
// It has to match EVERY specifier syntax esbuild resolves statically, because
// the hole a narrower match leaves is not "the rule is less tidy", it is a
// sixth site the build cannot see. Matching only `require("x")` and `require('x')`
// let four shapes through, each of which was confirmed to put the singleton in
// the shipped worker bundle: a backtick `require(`x`)`, a dynamic `import("x")`,
// a static `import ... from "x"` / `export ... from "x"`, and any of those one
// hop away in a shared module the background already pulls in.
//
// Known and deliberate gap: a computed specifier, `require("../shared/" +
// "state")`. It is not matched here, and it is not a hole — esbuild cannot
// resolve it statically either, so it never reaches the bundle. Contorting the
// rule to chase it would buy nothing.
const fs = require("fs");
const path = require("path");
// The module this rule exists to keep out, relative to the repo root.
const FORBIDDEN = path.join("src", "shared", "state.js");
// Both alternatives capture the specifier: call form first
// (`require(...)`/`import(...)`), then clause form (`from "x"`, and the bare
// side-effect `import "x"`).
const SPECIFIER_RE =
/\b(?:require|import)\(\s*["'`]([^"'`]+)["'`]\s*\)|\b(?:from|import)\s+["'`]([^"'`]+)["'`]/g;
// Resolve a relative require to a file path, trying the extensions node would.
function resolveRelative(fromFile, spec) {
if (!spec.startsWith(".")) return null; // a package, not our tree
const base = path.resolve(path.dirname(fromFile), spec);
for (const candidate of [
base,
base + ".js",
base + ".json",
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 the forbidden module, or null when it is not 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 (next === forbidden) 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 path is anchored.
const forbidden = path.resolve(context.cwd, FORBIDDEN);
const chain = chainToForbidden(
path.resolve(filename),
forbidden,
);
if (!chain) return;
context.report({
node,
messageId: "reachable",
data: {
chain: chain
.map((file) => path.relative(context.cwd, file))
.join(" -> "),
},
});
},
};
},
};
module.exports = {
rules: { "no-state-singleton-in-background": rule },
};

View File

@@ -1,7 +1,8 @@
#!/bin/sh #!/bin/sh
# script/test-verify-build: exercise every failure mode of # script/test-verify-build: exercise every failure mode of
# script/verify-build. Our own extension to scripts-to-rule-them-all, run # script/verify-build, and what make build does with dist/ after one of them
# from script/check so make check covers it. # (script/discard-dist-on-failure). Our own extension to
# scripts-to-rule-them-all, run from script/check so make check covers it.
# #
# Why this exists: verify-build is the build-integrity guard, and four separate # Why this exists: verify-build is the build-integrity guard, and four separate
# reviews of it each found a fresh vacuous pass — the grep exit-2 conflation, # reviews of it each found a fresh vacuous pass — the grep exit-2 conflation,
@@ -31,6 +32,7 @@ set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
VERIFY_BUILD="$ROOT/script/verify-build" VERIFY_BUILD="$ROOT/script/verify-build"
DISCARD_DIST="$ROOT/script/discard-dist-on-failure"
MARKER_ON="autistmask-build-debug=on" MARKER_ON="autistmask-build-debug=on"
MARKER_OFF="autistmask-build-debug=off" MARKER_OFF="autistmask-build-debug=off"
@@ -150,6 +152,7 @@ build_fixture() {
mkdir -p "$FIXTURE/script" mkdir -p "$FIXTURE/script"
ln -s "$VERIFY_BUILD" "$FIXTURE/script/verify-build" ln -s "$VERIFY_BUILD" "$FIXTURE/script/verify-build"
ln -s "$DISCARD_DIST" "$FIXTURE/script/discard-dist-on-failure"
mkdir -p "$FIXTURE/dist/chrome/src/popup" \ mkdir -p "$FIXTURE/dist/chrome/src/popup" \
"$FIXTURE/dist/chrome/src/content" \ "$FIXTURE/dist/chrome/src/content" \
@@ -513,12 +516,125 @@ c_debug_build() {
write_receipt write_receipt
} }
c_no_dist() { rm -rf dist; }
# --- dist discard -----------------------------------------------------------
#
# make build wraps every step of the release path in
# script/discard-dist-on-failure, so a release build that fails removes dist/:
# with AUTISTMASK_DEBUG=1 exported it has already emitted a complete, loadable
# debug bundle whose every wallet uses the publicly committed test recovery
# phrase, and a loud failure alone does not stop someone loading dist/chrome/
# anyway. make build-debug is deliberately not wrapped.
#
# Both directions are asserted against the state of dist/ ON DISK after the run,
# not against the exit status: a case reading only the status would keep passing
# if the removal quietly stopped happening, which is the flip this exists to
# catch. The wrapper runs against the fixture — its ROOT is the fixture, via the
# symlink in the fixture's script/ — with trivial commands standing in for the
# build steps, because what is under test is what happens after a step says no,
# not the step.
# discard_case <name> <setup> <status> <gone|kept> <want> <unwanted> [cmd...]
discard_case() {
_dc_name="$1"
_dc_setup="$2"
_dc_want_status="$3"
_dc_want_dist="$4"
_dc_want="$5"
_dc_unwanted="$6"
shift 6
build_fixture
if ! (cd "$FIXTURE" && "$_dc_setup") >/dev/null 2>&1; then
FAILED=$((FAILED + 1))
echo " FAIL: $_dc_name"
echo " the case's own setup failed, so nothing was tested."
return 0
fi
_dc_status=0
_dc_out="$(cd "$FIXTURE" &&
"$FIXTURE/script/discard-dist-on-failure" "$@" 2>&1)" || _dc_status=$?
_ok=yes
_why=""
if [ "$_dc_status" -ne "$_dc_want_status" ]; then
_ok=no
_why="exit status $_dc_status, wanted $_dc_want_status"
fi
# The assertion this case exists for: what is on disk now.
if [ -e "$FIXTURE/dist" ] || [ -h "$FIXTURE/dist" ]; then
_dc_dist=kept
else
_dc_dist=gone
fi
if [ "$_dc_dist" != "$_dc_want_dist" ]; then
_ok=no
_why="${_why:+$_why; }dist/ is $_dc_dist after the run, wanted"
_why="$_why $_dc_want_dist"
elif [ "$_dc_want_dist" = kept ] &&
[ ! -f "$FIXTURE/dist/chrome/src/popup/index.js" ]; then
# Kept has to mean intact: a dist/ emptied out is not one left alone.
_ok=no
_why="${_why:+$_why; }dist/ survived but its emitted bundle did not"
fi
_dc_check_message "$_dc_want" want
_dc_check_message "$_dc_unwanted" unwanted
if [ "$_ok" = yes ]; then
PASSED=$((PASSED + 1))
echo " ok: $_dc_name"
return 0
fi
FAILED=$((FAILED + 1))
echo " FAIL: $_dc_name"
echo " $_why"
echo " --- discard-dist-on-failure output ---"
printf '%s\n' "$_dc_out" | sed 's/^/ /'
echo " --- end output ---"
}
# Require ($2 = want) or forbid ($2 = unwanted) a substring in the wrapper's
# output, updating _ok and _why. An empty substring asserts nothing. Same grep
# discipline as everywhere else here: 0 and 1 are answers, anything else means
# the message was never checked.
_dc_check_message() {
[ -n "$1" ] || return 0
_dcm_g=0
printf '%s\n' "$_dc_out" | grep -q -F -e "$1" || _dcm_g=$?
case "$_dcm_g" in
0)
[ "$2" = unwanted ] || return 0
_ok=no
_why="${_why:+$_why; }message contained: $1"
;;
1)
[ "$2" = want ] || return 0
_ok=no
_why="${_why:+$_why; }message did not contain: $1"
;;
*)
_ok=no
_why="${_why:+$_why; }grep exited $_dcm_g matching the message, so the
message was never checked"
;;
esac
}
# --- Makefile wiring -------------------------------------------------------- # --- Makefile wiring --------------------------------------------------------
# The verifier cases above prove what verify-build does when it is told what to # The verifier cases above prove what verify-build does when it is told what to
# expect. This proves the Makefile tells it — with the mode as an argument, on # expect, and the discard cases prove what the wrapper does with dist/. This
# a scrubbed environment, and identically whether or not AUTISTMASK_DEBUG is # proves the Makefile wires both up — the mode as an argument, on a scrubbed
# exported in the shell that ran make. Read off `make -n`, so no build runs. # environment, identically whether or not AUTISTMASK_DEBUG is exported in the
# shell that ran make, and the wrapper on the release path only. Read off
# `make -n`, so no build runs.
check_makefile_wiring() { check_makefile_wiring() {
if ! command -v make >/dev/null 2>&1; then if ! command -v make >/dev/null 2>&1; then
SKIPPED=$((SKIPPED + 1)) SKIPPED=$((SKIPPED + 1))
@@ -537,6 +653,34 @@ check_makefile_wiring() {
build-debug "verify-build --expect debug" build-debug "verify-build --expect debug"
_wiring_case "make build-debug scrubs AUTISTMASK_DEBUG for the verifier" \ _wiring_case "make build-debug scrubs AUTISTMASK_DEBUG for the verifier" \
build-debug "env -u AUTISTMASK_DEBUG" build-debug "env -u AUTISTMASK_DEBUG"
# The release path runs its steps through the wrapper, including the final
# check-censored pass; the debug path runs none of them through it, which is
# what keeps a failed debug build's dist/ on disk.
_wiring_case "make build wraps its steps in discard-dist-on-failure" \
build "script/discard-dist-on-failure"
_wiring_case "make build wraps check-censored --require-dist too" \
build "script/discard-dist-on-failure script/check-censored"
_wiring_case_absent "make build-debug never discards its dist/" \
build-debug "discard-dist-on-failure"
}
# Run `make -n TARGET` with AUTISTMASK_DEBUG=1 exported, into _wc_out. Returns
# non-zero, having already reported the failure, when make itself failed: a
# recipe that could not be printed was never checked.
_wiring_make_n() {
AUTISTMASK_DEBUG=1
export AUTISTMASK_DEBUG
_wc_status=0
_wc_out="$(cd "$ROOT" && make -n "$_wc_target" 2>&1)" || _wc_status=$?
unset AUTISTMASK_DEBUG
[ "$_wc_status" -ne 0 ] || return 0
FAILED=$((FAILED + 1))
echo " FAIL: $_wc_name"
echo " make -n $_wc_target exited $_wc_status"
return 1
} }
_wiring_case() { _wiring_case() {
@@ -544,18 +688,7 @@ _wiring_case() {
_wc_target="$2" _wc_target="$2"
_wc_want="$3" _wc_want="$3"
AUTISTMASK_DEBUG=1 _wiring_make_n || return 0
export AUTISTMASK_DEBUG
_wc_status=0
_wc_out="$(cd "$ROOT" && make -n "$_wc_target" 2>&1)" || _wc_status=$?
unset AUTISTMASK_DEBUG
if [ "$_wc_status" -ne 0 ]; then
FAILED=$((FAILED + 1))
echo " FAIL: $_wc_name"
echo " make -n $_wc_target exited $_wc_status"
return 0
fi
_wc_g=0 _wc_g=0
printf '%s\n' "$_wc_out" | grep -q -F -e "$_wc_want" || _wc_g=$? printf '%s\n' "$_wc_out" | grep -q -F -e "$_wc_want" || _wc_g=$?
@@ -577,6 +710,34 @@ _wiring_case() {
esac esac
} }
# The inverse: the recipe must NOT run something.
_wiring_case_absent() {
_wc_name="$1"
_wc_target="$2"
_wc_want="$3"
_wiring_make_n || return 0
_wc_g=0
printf '%s\n' "$_wc_out" | grep -q -F -e "$_wc_want" || _wc_g=$?
case "$_wc_g" in
1)
PASSED=$((PASSED + 1))
echo " ok: $_wc_name"
;;
0)
FAILED=$((FAILED + 1))
echo " FAIL: $_wc_name"
echo " make -n $_wc_target runs: $_wc_want"
;;
*)
FAILED=$((FAILED + 1))
echo " FAIL: $_wc_name"
echo " grep exited $_wc_g, so the recipe was never checked"
;;
esac
}
run_cases() { run_cases() {
check_case "control: untouched dist passes" \ check_case "control: untouched dist passes" \
no release 0 "2 bundle(s) $MARKER_OFF" c_control no release 0 "2 bundle(s) $MARKER_OFF" c_control
@@ -712,6 +873,18 @@ run_cases() {
no release 1 "carries a debug marker but the build did not" \ no release 1 "carries a debug marker but the build did not" \
c_marker_on_plain_file c_marker_on_plain_file
discard_case "a failed release build step removes dist/" \
c_control 3 gone "dist/ WAS REMOVED" "" sh -c 'exit 3'
discard_case "a successful release build step leaves dist/ alone" \
c_control 0 kept "" "REMOVED" true
discard_case "a failed release build step with no dist/ says there was none" \
c_no_dist 3 gone "There was no dist/ to remove" "" sh -c 'exit 3'
discard_case "the wrapper given no command removes nothing" \
c_control 1 kept "no command given" ""
check_makefile_wiring check_makefile_wiring
} }
@@ -740,6 +913,10 @@ main() {
echo "test-verify-build: $VERIFY_BUILD is missing or not executable" >&2 echo "test-verify-build: $VERIFY_BUILD is missing or not executable" >&2
exit 1 exit 1
} }
[ -x "$DISCARD_DIST" ] || {
echo "test-verify-build: $DISCARD_DIST is missing or not executable" >&2
exit 1
}
echo "Testing script/verify-build failure modes..." echo "Testing script/verify-build failure modes..."
pick_sha256_tool pick_sha256_tool

View File

@@ -1,8 +1,10 @@
#!/bin/sh #!/bin/sh
# script/verify-build: assert that dist/ holds exactly what the build that just # script/verify-build: assert that the regular files and symlinks under dist/
# ran emitted, and that the compiled DEBUG state of that output is the one the # are exactly what the build that just ran emitted (other file types are out of
# caller asked for. Our own extension to scripts-to-rule-them-all, run at the # scope; see "What that does and does not establish" below), and that the
# end of make build / make build-debug. # compiled DEBUG state of that output is the one the caller asked for. Our own
# extension to scripts-to-rule-them-all, run at the end of make build /
# make build-debug.
# #
# Why the DEBUG half exists: DEBUG makes the publicly committed test recovery # Why the DEBUG half exists: DEBUG makes the publicly committed test recovery
# phrase the output of wallet creation, so a release artifact built with it live # phrase the output of wallet creation, so a release artifact built with it live
@@ -29,12 +31,16 @@
# path fresh per invocation, outside the repo, and deletes it afterwards. # path fresh per invocation, outside the repo, and deletes it afterwards.
# #
# What that does and does not establish. It establishes that dist/ is byte for # What that does and does not establish. It establishes that dist/ is byte for
# byte the output of the build.js run that just finished, with nothing added, # byte the output of the build.js run that just finished, with no regular file
# nothing missing and nothing altered in between, and that the audited bundles # or symlink added, missing or altered in between, and that the audited bundles
# in it compiled to the requested mode. It does NOT establish that the source # in it compiled to the requested mode. Regular files and symlinks are the whole
# tree or build.js were honest, and it says nothing at all to someone handed a # of what the tree walk covers; fifos, sockets, device nodes and empty
# dist/ from elsewhere: without the receipt from its own build they have no # directories under dist/ are not checked, because a build emits none of them,
# input to this check. That is signing, and it is not this control. # none can carry a shippable payload, and grep on a fifo would hang rather than
# fail. It does NOT establish that the source tree or build.js were honest, and
# it says nothing at all to someone handed a dist/ from elsewhere: without the
# receipt from its own build they have no input to this check. That is signing,
# and it is not this control.
# #
# It fails rather than passes whenever it cannot determine something. Minified # It fails rather than passes whenever it cannot determine something. Minified
# output is not a stable contract, so "matched neither marker" is not evidence # output is not a stable contract, so "matched neither marker" is not evidence
@@ -392,8 +398,10 @@ check_receipt_entries() {
# its own command line, so a linked dist/ collapses this walk to one entry # its own command line, so a linked dist/ collapses this walk to one entry
# and cross-checks nothing. # and cross-checks nothing.
# #
# Types other than regular files and symlinks are left out on purpose: a build # Types other than regular files and symlinks — fifos, sockets, device nodes and
# emits none of them, and grep on a fifo would hang rather than fail. # empty directories — are left out on purpose, and the guarantee is bounded to
# what is walked: a build emits none of them, none can carry a shippable
# payload, and grep on a fifo would hang rather than fail.
check_dist_tree() { check_dist_tree() {
LISTING="$(mktemp "${TMPDIR:-/tmp}/verify-build-dist.XXXXXX")" || LISTING="$(mktemp "${TMPDIR:-/tmp}/verify-build-dist.XXXXXX")" ||
fail "could not create a temporary file for the dist/ listing, so the fail "could not create a temporary file for the dist/ listing, so the

View File

@@ -2,19 +2,17 @@
// Handles EIP-1193 RPC requests from content scripts and proxies // Handles EIP-1193 RPC requests from content scripts and proxies
// non-sensitive calls to the configured Ethereum JSON-RPC endpoint. // non-sensitive calls to the configured Ethereum JSON-RPC endpoint.
const { DEFAULT_RPC_URL } = require("../shared/constants");
const { const {
SUPPORTED_CHAIN_IDS, SUPPORTED_CHAIN_IDS,
networkById, networkById,
networkByChainId, networkByChainId,
} = require("../shared/networks"); } = require("../shared/networks");
const { onChainSwitch } = require("../shared/chainSwitch"); const { applyChainSwitchFields } = require("../shared/chainSwitchFields");
const { // The background's own storage layer. src/shared/state.js — the module-level
state, // `state` singleton, loadState() and saveState() — is deliberately NOT
loadState, // imported here and must never be: see the header of src/background/state.js,
saveState, // and the lint rule that enforces it in eslint.config.js.
currentNetwork, const { getState, updateState } = require("./state");
} = require("../shared/state");
const { refreshBalances, getProvider } = require("../shared/balances"); const { refreshBalances, getProvider } = require("../shared/balances");
const { debugFetch, log } = require("../shared/log"); const { debugFetch, log } = require("../shared/log");
const { const {
@@ -42,7 +40,6 @@ const {
const { const {
actionApi, actionApi,
runtimeApi, runtimeApi,
storageGet,
tabsQuery, tabsQuery,
tabsSendMessage, tabsSendMessage,
windowsApi, windowsApi,
@@ -179,21 +176,12 @@ const INTERNAL_ERROR_CODE = -32603;
const INTERNAL_ERROR_MESSAGE = const INTERNAL_ERROR_MESSAGE =
"AutistMask could not complete this request because of an internal error."; "AutistMask could not complete this request because of an internal error.";
async function getState() { // The active address of a profile snapshot. Pure, and taking the snapshot as
const result = await storageGet("autistmask"); // an argument rather than reading storage itself: a handler that has already
return ( // read state must not answer "which account is this" from a SECOND, later read
result.autistmask || { // — the two can disagree, and the checks that compare them would then be
wallets: [], // comparing two different moments.
rpcUrl: DEFAULT_RPC_URL, function activeAddressOf(s) {
activeAddress: null,
allowedSites: {},
deniedSites: {},
}
);
}
async function getActiveAddress() {
const s = await getState();
if (s.activeAddress) return s.activeAddress; if (s.activeAddress) return s.activeAddress;
// Fall back to first address // Fall back to first address
if (s.wallets.length > 0 && s.wallets[0].addresses.length > 0) { if (s.wallets.length > 0 && s.wallets[0].addresses.length > 0) {
@@ -202,6 +190,11 @@ async function getActiveAddress() {
return null; 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 // 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 // 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 // to be active: the page asked for account A and would otherwise be handed
@@ -210,9 +203,14 @@ function namesAnotherAddress(requested, activeAddress) {
return !!requested && !sameAddress(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() { async function getRpcUrl() {
const s = await getState(); return (await getState()).rpcUrl;
return s.rpcUrl || DEFAULT_RPC_URL;
} }
function extractHostname(origin) { function extractHostname(origin) {
@@ -553,10 +551,26 @@ runtime.onConnect.addListener((port) => {
} }
}); });
// Record a remembered site decision under one address.
//
// A read-modify-write against storage, not a load-mutate-save of a shared
// singleton: the user takes seconds to answer the prompt, and everything else
// in the worker — a balance refresh in flight, another site's approval — has
// gone on running the whole time. Loading here used to replace the very
// objects that work was holding.
async function rememberSiteChoice(field, address, hostname) {
await updateState((s) => {
if (!s[field][address]) s[field][address] = [];
if (!s[field][address].includes(hostname)) {
s[field][address].push(hostname);
}
});
}
// Handle connection requests (eth_requestAccounts, wallet_requestPermissions) // Handle connection requests (eth_requestAccounts, wallet_requestPermissions)
async function handleConnectionRequest(origin) { async function handleConnectionRequest(origin) {
const s = await getState(); const s = await getState();
const activeAddress = await getActiveAddress(); const activeAddress = activeAddressOf(s);
if (!activeAddress) { if (!activeAddress) {
return { error: { message: "No accounts available" } }; return { error: { message: "No accounts available" } };
} }
@@ -588,29 +602,14 @@ async function handleConnectionRequest(origin) {
if (decision.approved) { if (decision.approved) {
if (decision.remember) { if (decision.remember) {
// Reload state to get latest, add to allowed, persist await rememberSiteChoice("allowedSites", activeAddress, hostname);
await loadState();
if (!state.allowedSites[activeAddress]) {
state.allowedSites[activeAddress] = [];
}
if (!state.allowedSites[activeAddress].includes(hostname)) {
state.allowedSites[activeAddress].push(hostname);
}
await saveState();
} else { } else {
connectedSites[origin + ":" + activeAddress] = true; connectedSites[origin + ":" + activeAddress] = true;
} }
return { result: [activeAddress] }; return { result: [activeAddress] };
} else { } else {
if (decision.remember) { if (decision.remember) {
await loadState(); await rememberSiteChoice("deniedSites", activeAddress, hostname);
if (!state.deniedSites[activeAddress]) {
state.deniedSites[activeAddress] = [];
}
if (!state.deniedSites[activeAddress].includes(hostname)) {
state.deniedSites[activeAddress].push(hostname);
}
await saveState();
} }
return { return {
error: { error: {
@@ -654,7 +653,7 @@ async function handleRpc(method, params, origin) {
if (method === "eth_accounts") { if (method === "eth_accounts") {
const s = await getState(); const s = await getState();
const activeAddress = await getActiveAddress(); const activeAddress = activeAddressOf(s);
if (!activeAddress) return { result: [] }; if (!activeAddress) return { result: [] };
const hostname = extractHostname(origin); const hostname = extractHostname(origin);
const allowed = s.allowedSites[activeAddress] || []; const allowed = s.allowedSites[activeAddress] || [];
@@ -667,22 +666,11 @@ async function handleRpc(method, params, origin) {
return { result: [] }; return { result: [] };
} }
// Both answered from currentNetwork(), which reads the module-level state // Both used to be answered from currentNetwork(), which reads the
// singleton, and nothing populates that at module scope. A worker revived // module-level state singleton, and nothing populates that at module
// by the page's own message therefore held DEFAULT_STATE and told a page // scope. A worker revived by the page's own message therefore held
// it was on mainnet while the user was on Sepolia // DEFAULT_STATE and told a page it was on mainnet while the user was on
// (https://git.eeqj.de/sneak/AutistMask/issues/317). // 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") { if (method === "eth_chainId" || method === "net_version") {
const s = await getState(); const s = await getState();
const net = networkById(s.networkId); const net = networkById(s.networkId);
@@ -699,7 +687,7 @@ async function handleRpc(method, params, origin) {
// not be able to do it. Ungated, any page could clear the // not be able to do it. Ungated, any page could clear the
// [TESTNET] banner under a user who believed they were on Sepolia. // [TESTNET] banner under a user who believed they were on Sepolia.
const s = await getState(); const s = await getState();
const activeAddress = await getActiveAddress(); const activeAddress = activeAddressOf(s);
const hostname = extractHostname(origin); const hostname = extractHostname(origin);
const allowed = s.allowedSites[activeAddress] || []; const allowed = s.allowedSites[activeAddress] || [];
if ( if (
@@ -709,24 +697,25 @@ async function handleRpc(method, params, origin) {
return { error: { code: 4100, message: "Unauthorized" } }; return { error: { code: 4100, message: "Unauthorized" } };
} }
// onChainSwitch() mutates the module-level state singleton and then // The chain in force is read from the snapshot above, not from the
// saves every field of it, and currentNetwork() reads the same // singleton: this worker may have been started by this very message,
// singleton. This worker may have been started by this very message: // and the singleton would then be DEFAULT_STATE, so the same-chain
// nothing loads state at module scope, so without this the singleton // check compared against mainnet whatever the user was on
// is DEFAULT_STATE, the same-chain check compares against the wrong // (https://git.eeqj.de/sneak/AutistMask/issues/316).
// 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; const chainId = params?.[0]?.chainId;
if (chainId === currentNetwork().chainId) { if (chainId === networkById(s.networkId).chainId) {
return { result: null }; return { result: null };
} }
if (SUPPORTED_CHAIN_IDS.has(chainId)) { if (SUPPORTED_CHAIN_IDS.has(chainId)) {
const target = networkByChainId(chainId); const target = networkByChainId(chainId);
await onChainSwitch(target.id); // Read-modify-write against storage. The old path went through
// onChainSwitch(), which mutates the singleton and then persists
// every field of it — on an unloaded worker that wrote empty
// wallets, empty allowedSites and the default endpoints over the
// user's stored profile, encrypted secrets included.
await updateState((fresh) =>
applyChainSwitchFields(fresh, target.id),
);
broadcastChainChanged(target.chainId); broadcastChainChanged(target.chainId);
return { result: null }; return { result: null };
} }
@@ -773,7 +762,7 @@ async function handleRpc(method, params, origin) {
if (method === "wallet_getPermissions") { if (method === "wallet_getPermissions") {
const s = await getState(); const s = await getState();
const activeAddress = await getActiveAddress(); const activeAddress = activeAddressOf(s);
const hostname = extractHostname(origin); const hostname = extractHostname(origin);
const allowed = s.allowedSites[activeAddress] || []; const allowed = s.allowedSites[activeAddress] || [];
const isConnected = const isConnected =
@@ -799,7 +788,7 @@ async function handleRpc(method, params, origin) {
if (method === "personal_sign" || method === "eth_sign") { if (method === "personal_sign" || method === "eth_sign") {
const s = await getState(); const s = await getState();
const activeAddress = await getActiveAddress(); const activeAddress = activeAddressOf(s);
if (!activeAddress) if (!activeAddress)
return { error: { message: "No accounts available" } }; return { error: { message: "No accounts available" } };
@@ -848,7 +837,7 @@ async function handleRpc(method, params, origin) {
if (method === "eth_signTypedData_v4" || method === "eth_signTypedData") { if (method === "eth_signTypedData_v4" || method === "eth_signTypedData") {
const s = await getState(); const s = await getState();
const activeAddress = await getActiveAddress(); const activeAddress = activeAddressOf(s);
if (!activeAddress) if (!activeAddress)
return { error: { message: "No accounts available" } }; return { error: { message: "No accounts available" } };
@@ -904,7 +893,7 @@ async function handleRpc(method, params, origin) {
// page has its answer. // page has its answer.
async function handleSendTransaction(params, origin) { async function handleSendTransaction(params, origin) {
const s = await getState(); const s = await getState();
const activeAddress = await getActiveAddress(); const activeAddress = activeAddressOf(s);
if (!activeAddress) return { error: { message: "No accounts available" } }; if (!activeAddress) return { error: { message: "No accounts available" } };
const hostname = extractHostname(origin); const hostname = extractHostname(origin);
@@ -948,10 +937,19 @@ async function handleSendTransaction(params, origin) {
// user is shown is a complete one and is the same object the signed // 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 // artifact is checked against. A failure raises no approval at all and
// is reported to the requesting page; see approvalTx.js. // 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; let approvedTx;
try { try {
approvedTx = await prepareApprovalTx( approvedTx = await prepareApprovalTx(
getProvider(await getRpcUrl()), getProvider(s.rpcUrl, s.networkId),
activeAddress, activeAddress,
txParams, txParams,
); );
@@ -1039,7 +1037,7 @@ async function broadcastAccountsChanged() {
} }
resetPopupUrl(); resetPopupUrl();
const s = await getState(); const s = await getState();
const activeAddress = await getActiveAddress(); const activeAddress = activeAddressOf(s);
const allowed = activeAddress ? s.allowedSites[activeAddress] || [] : []; const allowed = activeAddress ? s.allowedSites[activeAddress] || [] : [];
let tabs; let tabs;
try { try {
@@ -1079,20 +1077,60 @@ async function broadcastAccountsChanged() {
const BALANCE_REFRESH_PERIOD_MS = BALANCE_REFRESH_PERIOD_MINUTES * 60 * 1000; const BALANCE_REFRESH_PERIOD_MS = BALANCE_REFRESH_PERIOD_MINUTES * 60 * 1000;
const RECENT_BALANCE_REFRESH_MS = Math.floor(BALANCE_REFRESH_PERIOD_MS / 2); 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() { async function backgroundRefresh() {
await loadState(); const s = await getState();
const now = Date.now(); const now = Date.now();
if (now - (state.lastBalanceRefresh || 0) < RECENT_BALANCE_REFRESH_MS) if (now - (s.lastBalanceRefresh || 0) < RECENT_BALANCE_REFRESH_MS) return;
return; if (s.wallets.length === 0) return;
if (state.wallets.length === 0) return;
const wallets = s.wallets;
await refreshBalances( await refreshBalances(
state.wallets, wallets,
state.rpcUrl, s.rpcUrl,
state.blockscoutUrl, s.blockscoutUrl,
state.trackedTokens, s.trackedTokens,
s.networkId,
); );
state.lastBalanceRefresh = now;
await saveState(); const refreshed = new Map();
for (const wallet of wallets) {
for (const addr of wallet.addresses || []) {
refreshed.set(String(addr.address).toLowerCase(), addr);
}
}
await updateState((fresh) => {
for (const wallet of fresh.wallets) {
for (const addr of wallet.addresses || []) {
const got = refreshed.get(String(addr.address).toLowerCase());
if (!got) continue;
// Only fields the refresh actually produced. refreshBalances()
// leaves a field untouched when its lookup failed, so an
// undefined here means "no answer", not "the answer is empty",
// and must not overwrite what is stored.
for (const key of ["balance", "ensName", "tokenBalances"]) {
if (got[key] !== undefined) addr[key] = got[key];
}
}
}
fresh.lastBalanceRefresh = now;
});
} }
// The recurring job runs off an alarm, not a timer. On Chrome MV3 this file is // The recurring job runs off an alarm, not a timer. On Chrome MV3 this file is
@@ -1307,16 +1345,29 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
// so an escape from there must not tell the user it might have. // so an escape from there must not tell the user it might have.
let lastResortStage = TX_STAGE_VERIFY; let lastResortStage = TX_STAGE_VERIFY;
(async () => { (async () => {
// The chain this attempt is on, read once. Verification below // The chain this attempt is on, read once — and the endpoint it
// refuses an artifact signed for any other chain, and the nonce // will be broadcast to comes from the SAME read.
// record is both consulted and written under this one, so a //
// network switch part-way through cannot make the check and the // Verification below refuses an artifact signed for any other
// record disagree about which chain the nonce was spent on. // chain, and the nonce record is both consulted and written under
// this one, so a network switch part-way through cannot make the
// check and the record disagree about which chain the nonce was
// spent on. The endpoint used to be read separately, several
// awaits later (`state.rpcUrl` off the singleton), so a chain
// switch committed in that window moved the endpoint out from
// under a transaction already verified against the old chain: the
// artifact would be sent to the new chain's node, which is
// precisely the "signed for a different network" case the
// verification exists to prevent.
let chainId; let chainId;
let rpcUrl;
let networkId;
try { try {
await loadState(); const s = await getState();
chainId = currentNetwork().chainId; networkId = s.networkId;
const activeAddress = await getActiveAddress(); chainId = networkById(networkId).chainId;
rpcUrl = s.rpcUrl;
const activeAddress = activeAddressOf(s);
// An address switch between approval and signing refuses. The // An address switch between approval and signing refuses. The
// approval named one account; signing from whichever account // approval named one account; signing from whichever account
// is active now would send funds from an account this screen // is active now would send funds from an account this screen
@@ -1388,7 +1439,7 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
} }
try { try {
const provider = getProvider(state.rpcUrl); const provider = getProvider(rpcUrl, networkId);
lastResortStage = TX_STAGE_BROADCAST; lastResortStage = TX_STAGE_BROADCAST;
const tx = await provider.broadcastTransaction(msg.rawSignedTx); const tx = await provider.broadcastTransaction(msg.rawSignedTx);
if (nonce !== null) spent.add(nonce); if (nonce !== null) spent.add(nonce);

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

@@ -0,0 +1,76 @@
// 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");
// 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.
async function getState() {
const result = await storageGet("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);
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.
//
// `mutate` must also not call updateState() itself, directly or through
// anything it awaits: the queue is strictly serial, so the inner turn waits on
// the outer one, which is waiting on the inner one. That deadlocks the whole
// background, not just the caller. Mutate the record you were handed.
//
// Resolves with the record that was written.
function updateState(mutate) {
const turn = updateQueue.then(() => updateStateOnce(mutate));
// The queue must advance even when a turn rejects, or every update after
// it queues behind a promise that never settles.
updateQueue = turn.catch(() => {});
return turn;
}
module.exports = { getState, updateState };

View File

@@ -153,12 +153,28 @@
<!-- Shared password fields --> <!-- Shared password fields -->
<div class="mb-2" id="add-wallet-password-section"> <div class="mb-2" id="add-wallet-password-section">
<label class="block mb-1">Choose a password</label> <label class="block mb-1">Choose a password</label>
<!-- The hint is swapped in place when the import tab
changes, and it sits directly above the password
fields, so a wording that wraps to a different
number of lines would move them under the pointer.
Two things stop that: the three wordings in
PASSWORD_HINTS are kept within a couple of
characters of each other in length, and this floor
matches what each of them needs. All three measure
48px -- 3 lines at the 16px line height, at the
368px width this box has in the 396px popup body.
Do not raise it: the reserve is unused height on
every tab, and at 6rem it pushed
#btn-add-wallet-confirm to bottom=628px in a 600px
viewport, below the fold. -->
<p <p
class="text-xs text-muted mb-1" class="text-xs text-muted mb-1 min-h-[3rem]"
id="add-wallet-password-hint" id="add-wallet-password-hint"
> >
This password encrypts your recovery phrase on this This password encrypts your recovery phrase on this
device. You will need it to send funds. device. You will need it to send funds. It cannot be
recovered or reset, so keep your recovery phrase written
down: it is the only backup of this wallet.
</p> </p>
<input <input
type="password" type="password"
@@ -1140,6 +1156,71 @@
> >
Confirm Delete Confirm Delete
</button> </button>
<p class="text-xs mt-3">
<span
id="btn-delete-wallet-lost-password"
class="underline decoration-dashed cursor-pointer"
>I have lost my password</span
>
</p>
</div>
<!-- ============ DELETE WALLET WITHOUT THE PASSWORD ============ -->
<div id="view-delete-wallet-lost-password" class="view hidden">
<button
id="btn-delete-wallet-lost-back"
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer mb-2"
>
&lt; Back
</button>
<h2 class="font-bold mb-3">Delete Wallet Without a Password</h2>
<p class="text-xs mb-2">
Your password cannot be recovered or reset, so there is no
way to unlock
<strong id="delete-wallet-lost-name"></strong> again. You
can still delete it, and no password is needed to do that.
</p>
<p class="text-xs mb-2">
Deleting it erases the copy of its key that is stored on
this device. Nothing on the blockchain changes, and the
money at its addresses is not moved or destroyed.
</p>
<p class="text-xs mb-2">
If you have the recovery phrase for this wallet written
down, add the wallet again afterwards with a new password
and you will have it back.
<strong
>If you do not have it written down, deleting this
wallet means losing everything it holds,
forever.</strong
>
</p>
<p class="text-xs mb-3">Your other wallets are not touched.</p>
<p class="text-xs mb-1">
To confirm, type the name of the wallet (<strong
id="delete-wallet-lost-name-echo"
></strong
>) below.
</p>
<div class="mb-2">
<input
type="text"
id="delete-wallet-lost-name-input"
class="border border-border p-1 w-full font-mono text-sm bg-bg text-fg"
placeholder="Type the wallet name"
/>
</div>
<div
id="delete-wallet-lost-flash"
class="text-xs text-red-500 mb-2 min-h-[1.25rem]"
style="visibility: hidden"
></div>
<button
id="btn-delete-wallet-lost-confirm"
class="border border-border text-red-500 px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer"
>
Delete This Wallet Forever
</button>
</div> </div>
<!-- ============ DELETE ADDRESS CONFIRM ============ --> <!-- ============ DELETE ADDRESS CONFIRM ============ -->

View File

@@ -53,6 +53,7 @@ async function doRefreshAndRender() {
state.rpcUrl, state.rpcUrl,
state.blockscoutUrl, state.blockscoutUrl,
state.trackedTokens, state.trackedTokens,
state.networkId,
), ),
]); ]);
state.lastBalanceRefresh = Date.now(); state.lastBalanceRefresh = Date.now();

View File

@@ -10,6 +10,11 @@
// prompt in front of it, on a popup the user may have reopened by accident. // prompt in front of it, on a popup the user may have reopened by accident.
// That is why "export-privkey" and "show-phrase" are absent. // That is why "export-privkey" and "show-phrase" are absent.
// //
// Nor may a view whose button destroys a wallet be listed, for the mirror
// reason: a popup reopened by accident must not land on the screen that
// erases key material. That is why "delete-wallet-confirm" and
// "delete-wallet-lost-password" are absent.
//
// Kept in its own module, with no dependencies, so tests can assert the // Kept in its own module, with no dependencies, so tests can assert the
// exclusion directly rather than trusting a reading of the popup entry // exclusion directly rather than trusting a reading of the popup entry
// point, which cannot be required outside a browser. // point, which cannot be required outside a browser.

View File

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

View File

@@ -42,12 +42,24 @@ let currentMode = "mnemonic";
const MODES = ["mnemonic", "privkey", "xprv"]; const MODES = ["mnemonic", "privkey", "xprv"];
// Each hint names what this import mode's own backup is, because a key
// wallet and an xprv wallet have no recovery phrase to point the user at.
// All three say the same thing about the password: it is gone for good if
// it is forgotten. That sentence is the only warning the user gets before
// the wallet exists, and without it the lost-password route in
// views/deleteWallet.js is the first they hear of it.
//
// Keep the three within a couple of characters of each other in length.
// The hint sits directly above the password fields and the tabs swap it in
// place, so a wording that wraps to a different number of lines would move
// those fields under the pointer; the reserved height on
// #add-wallet-password-hint is the other half of that guarantee.
const PASSWORD_HINTS = { const PASSWORD_HINTS = {
mnemonic: mnemonic:
"This password encrypts your recovery phrase on this device. You will need it to send funds.", "This password encrypts your recovery phrase on this device. You will need it to send funds. It cannot be recovered or reset, so keep your recovery phrase written down: it is the only backup of this wallet.",
privkey: privkey:
"This password encrypts your private key on this device. You will need it to send funds.", "This password encrypts your private key on this device. You will need it to send funds. It cannot be recovered or reset, so keep your private key saved somewhere safe: it is the only backup of this wallet.",
xprv: "This password encrypts your key on this device. You will need it to send funds.", xprv: "This password encrypts your key on this device. You will need it to send funds. It cannot be recovered or reset, so keep your extended private key saved somewhere safe: it is the only backup of this wallet.",
}; };
function switchMode(mode) { function switchMode(mode) {
@@ -167,7 +179,7 @@ async function importMnemonic(ctx) {
// Scan for used HD addresses beyond index 0. // Scan for used HD addresses beyond index 0.
showFlash("Scanning for addresses...", 30000); showFlash("Scanning for addresses...", 30000);
const scan = await scanForAddresses(xpub, state.rpcUrl); const scan = await scanForAddresses(xpub, state.rpcUrl, state.networkId);
if (scan.addresses.length > 1) { if (scan.addresses.length > 1) {
wallet.addresses = scan.addresses.map((a) => ({ wallet.addresses = scan.addresses.map((a) => ({
address: a.address, address: a.address,
@@ -286,7 +298,7 @@ async function importXprvKey(ctx) {
// Scan for used HD addresses beyond index 0. // Scan for used HD addresses beyond index 0.
showFlash("Scanning for addresses...", 30000); showFlash("Scanning for addresses...", 30000);
const scan = await scanForAddresses(xpub, state.rpcUrl); const scan = await scanForAddresses(xpub, state.rpcUrl, state.networkId);
if (scan.addresses.length > 1) { if (scan.addresses.length > 1) {
wallet.addresses = scan.addresses.map((a) => ({ wallet.addresses = scan.addresses.map((a) => ({
address: a.address, address: a.address,

View File

@@ -188,6 +188,7 @@ async function loadTransactions(address) {
ensNameMap = await resolveEnsNames( ensNameMap = await resolveEnsNames(
counterparties, counterparties,
state.rpcUrl, state.rpcUrl,
state.networkId,
); );
} catch { } catch {
ensNameMap = new Map(); ensNameMap = new Map();

View File

@@ -268,6 +268,7 @@ async function loadTransactions(address, tokenId) {
ensNameMap = await resolveEnsNames( ensNameMap = await resolveEnsNames(
counterparties, counterparties,
state.rpcUrl, state.rpcUrl,
state.networkId,
); );
} catch { } catch {
ensNameMap = new Map(); ensNameMap = new Map();

View File

@@ -25,6 +25,12 @@ const {
resolveTokenDecimals, resolveTokenDecimals,
unknownDecimalsAmount, unknownDecimalsAmount,
} = require("../../shared/approvalAmount"); } = require("../../shared/approvalAmount");
// Four decimals, with the nonzero floor these screens hold: every amount this
// view renders — the ERC-20 line, the ETH value, the max fee — and every one
// it carries forward to the wait/success/error screens goes through it.
const {
truncateAmountNeverZero: formatTxValue,
} = require("../../shared/amountDisplay");
const { decryptWithPassword } = require("../../shared/vault"); const { decryptWithPassword } = require("../../shared/vault");
const { getSignerForAddress } = require("../../shared/wallet"); const { getSignerForAddress } = require("../../shared/wallet");
const { walletDefect } = require("../../shared/walletDefects"); const { walletDefect } = require("../../shared/walletDefects");
@@ -40,13 +46,6 @@ function approvalAddressHtml(address) {
return renderAddressHtml(address, { title }); return renderAddressHtml(address, { title });
} }
function formatTxValue(val) {
const parts = val.split(".");
if (parts.length === 1) return val + ".0000";
const dec = (parts[1] + "0000").slice(0, 4);
return parts[0] + "." + dec;
}
// The amount line for a decoded ERC-20 call. With a known scale it is the // The amount line for a decoded ERC-20 call. With a known scale it is the
// token quantity; with `decimals` null it is the base-unit integer with the // token quantity; with `decimals` null it is the base-unit integer with the
// unknown scale stated, because formatting it with an assumed scale is what // unknown scale stated, because formatting it with an assumed scale is what

View File

@@ -304,7 +304,7 @@ function formatFeeEth(wei) {
async function estimateGas(txInfo) { async function estimateGas(txInfo) {
try { try {
const provider = getProvider(state.rpcUrl); const provider = getProvider(state.rpcUrl, state.networkId);
const feeData = await provider.getFeeData(); const feeData = await provider.getFeeData();
let gasLimit; let gasLimit;
@@ -386,7 +386,7 @@ async function estimateGas(txInfo) {
async function checkRecipientHistory(txInfo) { async function checkRecipientHistory(txInfo) {
try { try {
const provider = getProvider(state.rpcUrl); const provider = getProvider(state.rpcUrl, state.networkId);
const asyncWarnings = await getFullWarnings(txInfo.to, provider, { const asyncWarnings = await getFullWarnings(txInfo.to, provider, {
fromAddress: txInfo.from, fromAddress: txInfo.from,
}); });
@@ -454,7 +454,7 @@ function init(_ctx) {
state.selectedAddress, state.selectedAddress,
decryptedSecret, decryptedSecret,
); );
const provider = getProvider(state.rpcUrl); const provider = getProvider(state.rpcUrl, state.networkId);
const connectedSigner = signer.connect(provider); const connectedSigner = signer.connect(provider);
if (pendingTx.token === "ETH") { if (pendingTx.token === "ETH") {

View File

@@ -45,8 +45,8 @@ function setFlash(msg) {
// wallet.nextIndex is a high-water mark and is deliberately not rewound; and // wallet.nextIndex is a high-water mark and is deliberately not rewound; and
// re-importing this wallet's key material is refused as a duplicate by // re-importing this wallet's key material is refused as a duplicate by
// findWalletByXpub() for as long as the wallet is here. What remains is to // findWalletByXpub() for as long as the wallet is here. What remains is to
// delete the whole wallet in Settings — which asks for the password and // delete the whole wallet in Settings — which destroys the stored secret,
// destroys the stored secret — and import again, after which // with or without the password — and import again, after which
// scanForAddresses() rediscovers the address only if it has on-chain // scanForAddresses() rediscovers the address only if it has on-chain
// activity. An address that was never used is not found by that scan, and // activity. An address that was never used is not found by that scan, and
// the copy must not imply otherwise. // the copy must not imply otherwise.
@@ -63,8 +63,7 @@ function recoveryPathText(wallet) {
"importing this " + "importing this " +
secret + secret +
" again is refused while this wallet is still here. The way back is " + " again is refused while this wallet is still here. The way back is " +
"to delete the whole wallet in Settings, which asks for your " + "to delete the whole wallet in Settings, which destroys the stored " +
"password and destroys the stored " +
secret + secret +
", and then import that " + ", and then import that " +
secret + secret +

View File

@@ -14,8 +14,29 @@ const {
} = require("../../shared/walletDelete"); } = require("../../shared/walletDelete");
let deleteWalletIndex = null; let deleteWalletIndex = null;
let lostPasswordIndex = null;
let ctx = null; let ctx = null;
// The name shown for a wallet, and on the lost-password screen the string
// the user has to type back. One function so the two cannot disagree: a
// confirmation that asks for a name other than the one on screen is
// unusable.
function displayName(walletIdx) {
const wallet = state.wallets[walletIdx];
return (wallet && wallet.name) || "Wallet " + (walletIdx + 1);
}
// What the typed confirmation and the wallet name are compared as. HTML
// collapses runs of whitespace when it renders the name, so a wallet named
// "My Wallet" with two spaces DISPLAYS as "My Wallet": the user cannot
// see the second space and cannot type a string that matches the stored
// name. Comparing collapsed on both sides is what keeps the confirmation
// satisfiable, on the one screen whose whole purpose is unwedging a user
// who is already stuck. Case and surrounding space go the same way.
function confirmKey(name) {
return name.trim().replace(/\s+/g, " ").toLowerCase();
}
// Drop the password from the DOM and the wallet selection from the // Drop the password from the DOM and the wallet selection from the
// closure. Registered as the view-leave handler as well as run on entry, // closure. Registered as the view-leave handler as well as run on entry,
// so the typed password does not sit in the hidden view after the user // so the typed password does not sit in the hidden view after the user
@@ -27,19 +48,89 @@ function clear() {
$("delete-wallet-flash").style.visibility = "hidden"; $("delete-wallet-flash").style.visibility = "hidden";
} }
// The lost-password screen holds no secret — a wallet name is not one —
// but it is wiped on leave for the neighbouring reason: a typed
// confirmation left standing in a hidden view is one click away from
// destroying a wallet the user has since navigated off. The button is
// re-enabled here too, so a screen left mid-delete is usable on re-entry.
function clearLostPassword() {
lostPasswordIndex = null;
$("delete-wallet-lost-name-input").value = "";
$("delete-wallet-lost-flash").textContent = "";
$("delete-wallet-lost-flash").style.visibility = "hidden";
const btn = $("btn-delete-wallet-lost-confirm");
btn.disabled = false;
btn.classList.remove("text-muted");
}
function show(walletIdx) { function show(walletIdx) {
clear(); clear();
deleteWalletIndex = walletIdx; deleteWalletIndex = walletIdx;
const wallet = state.wallets[walletIdx]; $("delete-wallet-name").textContent = displayName(walletIdx);
$("delete-wallet-name").textContent =
wallet.name || "Wallet " + (walletIdx + 1);
showView("delete-wallet-confirm"); showView("delete-wallet-confirm");
} }
// The two delete screens are siblings, not parent and child: nothing is
// pushed on the way here, and Back goes to show() rather than goBack().
// Both then have the same Back target — Settings, the screen that pushed
// delete-wallet-confirm — and re-entering through show() hands the confirm
// screen its wallet selection back, which a bare goBack() onto a view
// whose leave hook has already nulled that selection would not.
function showLostPassword() {
const walletIdx = deleteWalletIndex;
if (walletIdx === null) {
goBack();
return;
}
const name = displayName(walletIdx);
clearLostPassword();
$("delete-wallet-lost-name").textContent = name;
$("delete-wallet-lost-name-echo").textContent = name;
// showView() runs the leave hook of delete-wallet-confirm, which nulls
// deleteWalletIndex, so this screen's own selection is recorded after
// it and not before.
showView("delete-wallet-lost-password");
lostPasswordIndex = walletIdx;
}
// Remove the wallet and put the user somewhere sensible. Shared by both
// routes onto this screen, so the selection repair, the site-permission
// cleanup and the accountsChanged broadcast cannot drift apart between
// them.
async function finishDelete(walletIdx) {
const { activeAddressChanged } = removeWalletFromState(state, walletIdx);
deleteWalletIndex = null;
lostPasswordIndex = null;
if (!state.hasWallet) {
clearViewStack();
await saveState();
// Save before broadcasting: the background reads the active
// address back out of storage to build accountsChanged.
if (activeAddressChanged) broadcastActiveChanged();
showView("welcome");
return;
}
await saveState();
if (activeAddressChanged) broadcastActiveChanged();
// Reset stack to [main] so Settings back goes home.
// Use require() lazily to avoid circular dependency
// (settings.js requires deleteWallet.js).
clearViewStack();
state.viewStack.push("main");
ctx.renderWalletList();
const settings = require("./settings");
settings.show();
showFlash("Wallet deleted.");
}
function init(_ctx) { function init(_ctx) {
ctx = _ctx; ctx = _ctx;
onViewLeave("delete-wallet-confirm", clear); onViewLeave("delete-wallet-confirm", clear);
onViewLeave("delete-wallet-lost-password", clearLostPassword);
// No wipe here: goBack() routes through showView(), which runs the // No wipe here: goBack() routes through showView(), which runs the
// leave hook. // leave hook.
@@ -47,6 +138,60 @@ function init(_ctx) {
goBack(); goBack();
}); });
// The escape hatch, and deliberately not gated on anything a user who
// has lost the password cannot produce. A password in front of
// DISCARDING a secret protects nobody: an attacker at the popup who
// wants the wallet gone can uninstall the extension, so the only
// person such a gate stops is the owner who forgot it — and before
// this route existed that owner could neither delete the wallet nor
// import its recovery phrase again, because AddWallet refuses the xpub
// as a duplicate while the wallet is still stored.
$("btn-delete-wallet-lost-password").addEventListener("click", () => {
showLostPassword();
});
$("btn-delete-wallet-lost-back").addEventListener("click", () => {
const walletIdx = lostPasswordIndex;
if (walletIdx === null) {
goBack();
return;
}
show(walletIdx);
});
$("btn-delete-wallet-lost-confirm").addEventListener("click", async () => {
if (lostPasswordIndex === null) {
$("delete-wallet-lost-flash").textContent =
"No wallet selected for deletion.";
$("delete-wallet-lost-flash").style.visibility = "visible";
return;
}
// Case, surrounding spaces and repeated inner spaces are not part
// of the confirmation; see confirmKey(). This asks whether the
// user knows which wallet they are on; it is not a secret, and
// refusing "wallet 2" for "Wallet 2" would only teach the user to
// distrust the control.
const typed = $("delete-wallet-lost-name-input").value;
const expected = displayName(lostPasswordIndex);
if (confirmKey(typed) !== confirmKey(expected)) {
$("delete-wallet-lost-flash").textContent =
"That is not the name of this wallet. Type " +
expected +
" to confirm.";
$("delete-wallet-lost-flash").style.visibility = "visible";
return;
}
const btn = $("btn-delete-wallet-lost-confirm");
btn.disabled = true;
btn.classList.add("text-muted");
// finishDelete() navigates, and the leave hook re-enables the
// button and wipes the typed name on the way out.
await finishDelete(lostPasswordIndex);
});
$("btn-delete-wallet-confirm").addEventListener("click", async () => { $("btn-delete-wallet-confirm").addEventListener("click", async () => {
const pw = $("delete-wallet-password").value; const pw = $("delete-wallet-password").value;
if (!pw) { if (!pw) {
@@ -82,34 +227,7 @@ function init(_ctx) {
return; return;
} }
// Remove the wallet and repair selection, permissions and hasWallet await finishDelete(walletIdx);
const { activeAddressChanged } = removeWalletFromState(
state,
walletIdx,
);
deleteWalletIndex = null;
if (!state.hasWallet) {
clearViewStack();
await saveState();
// Save before broadcasting: the background reads the active
// address back out of storage to build accountsChanged.
if (activeAddressChanged) broadcastActiveChanged();
showView("welcome");
} else {
await saveState();
if (activeAddressChanged) broadcastActiveChanged();
// Reset stack to [main] so Settings back goes home.
// Use require() lazily to avoid circular dependency
// (settings.js requires deleteWallet.js).
clearViewStack();
state.viewStack.push("main");
ctx.renderWalletList();
const settings = require("./settings");
settings.show();
showFlash("Wallet deleted.");
}
}); });
} }

View File

@@ -36,6 +36,7 @@ const VIEWS = [
"add-token", "add-token",
"settings", "settings",
"delete-wallet-confirm", "delete-wallet-confirm",
"delete-wallet-lost-password",
"delete-address-confirm", "delete-address-confirm",
"settings-addtoken", "settings-addtoken",
"transaction", "transaction",

View File

@@ -202,7 +202,7 @@ function init(_ctx) {
let ensName = null; let ensName = null;
if (to.includes(".") && !to.startsWith("0x")) { if (to.includes(".") && !to.startsWith("0x")) {
try { try {
const provider = getProvider(state.rpcUrl); const provider = getProvider(state.rpcUrl, state.networkId);
const resolved = await provider.resolveName(to); const resolved = await provider.resolveName(to);
if (!resolved) { if (!resolved) {
showFlash("Could not resolve " + to); showFlash("Could not resolve " + to);

View File

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

View File

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

View File

@@ -0,0 +1,46 @@
// The 4-decimal amount rule from README.md's Display Consistency section, and
// the one exception to it, in one place. Three call sites had grown their own
// copy of the truncation — the history and balance lists
// (`src/shared/transactions.js`), the approval screen's ERC-20 amount line
// (`src/popup/views/approval.js`) and its Uniswap swap detail lines
// (`src/shared/uniswap.js`) — and a fix applied to one of them left the other
// two showing a different number for the same value.
//
// The two functions below are the two policies, not two implementations of
// one: summary lists truncate, and the screens that state what is being
// authorized truncate with a floor. Keeping them adjacent is the point, so a
// change to the rule cannot reach one screen and miss another.
// Truncate to exactly four decimal places. Truncation, never rounding: an
// amount must never be displayed as larger than it is, so 0.99999 stays
// 0.9999.
function truncateAmount(val) {
const parts = val.split(".");
if (parts.length === 1) return val + ".0000";
return parts[0] + "." + (parts[1] + "0000").slice(0, 4);
}
// The same rule, plus the invariant the approval and confirmation screens
// hold: a nonzero amount never renders as zero. Truncating to four decimals
// does exactly that to an amount below 0.0001 — one base unit of an 18-decimal
// token, 500 of an 8-decimal one — and a real transfer or allowance then reads
// as "nothing is being moved" on the screen whose whole job is to say what is
// being authorized.
//
// When the truncated string carries no significant digit and the value does,
// the amount is extended to its first significant digit instead. It stays in
// token units, the same unit as the symbol printed beside it. A genuine zero
// still renders 0.0000, and anything at or above the floor is untouched.
function truncateAmountNeverZero(val) {
const truncated = truncateAmount(val);
// Tests the whole truncated string, integer part included: 1.00005 has a
// significant digit already and stays 1.0000.
if (/[1-9]/.test(truncated)) return truncated;
const parts = val.split(".");
if (parts.length === 1) return truncated;
const sig = parts[1].search(/[1-9]/);
if (sig === -1) return truncated;
return parts[0] + "." + parts[1].slice(0, sig + 1);
}
module.exports = { truncateAmount, truncateAmountNeverZero };

View File

@@ -9,6 +9,7 @@ const {
formatUnits, formatUnits,
} = require("ethers"); } = require("ethers");
const { ERC20_ABI } = require("./constants"); const { ERC20_ABI } = require("./constants");
const { NETWORKS } = require("./networks");
const { log, debugFetch } = require("./log"); const { log, debugFetch } = require("./log");
const { deriveAddressFromXpub } = require("./wallet"); const { deriveAddressFromXpub } = require("./wallet");
const { TOKEN_BY_ADDRESS } = require("./tokenList"); const { TOKEN_BY_ADDRESS } = require("./tokenList");
@@ -17,17 +18,38 @@ const { isSpoofedSymbol } = require("./symbolSpoof");
// Use a static network to skip auto-detection (which can fail and cause // Use a static network to skip auto-detection (which can fail and cause
// "could not coalesce error" on some RPC endpoints like Cloudflare). // "could not coalesce error" on some RPC endpoints like Cloudflare).
// Accepts an optional networkName ("mainnet" or "sepolia") for the static //
// network hint so ethers picks the right chain parameters. When omitted, // `networkId` is REQUIRED, and is one of the ids in networks.js. It used to be
// reads the currently selected network from extension state. // optional, falling back to currentNetwork() — the module-level `state`
function getProvider(rpcUrl, networkName) { // singleton, which the MV3 service worker never populates. The endpoint then
// Lazy require to avoid circular dependency issues at module scope. // came out right and the static hint came out mainnet, so ethers fixed
const { currentNetwork } = require("./state"); // `chainId` at 0x1 and every non-mainnet dApp send was prepared for the wrong
const name = networkName || currentNetwork().id; // chain and then refused by the wallet's own verifier
const net = Network.from(name); // (https://git.eeqj.de/sneak/AutistMask/issues/320). Requiring it is what
// stops that from coming back: a caller that has no network to name has no
// business constructing a provider, and there is no longer a default for it
// to get silently wrong.
//
// Validated against NETWORKS rather than passed straight to Network.from():
// ethers knows chains this wallet does not, so an id that is not one of ours
// is a caller bug and must not resolve to a working provider for some other
// chain.
function getProvider(rpcUrl, networkId) {
const net = Network.from(requireNetworkId(networkId).id);
return new JsonRpcProvider(rpcUrl, net, { staticNetwork: net }); 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) { function formatBalance(wei) {
const eth = formatEther(wei); const eth = formatEther(wei);
const parts = eth.split("."); const parts = eth.split(".");
@@ -118,9 +140,15 @@ async function fetchTokenBalances(address, blockscoutUrl, trackedTokens) {
} }
// Fetch ETH balances, ENS names, and ERC-20 token balances for all addresses. // Fetch ETH balances, ENS names, and ERC-20 token balances for all addresses.
async function refreshBalances(wallets, rpcUrl, blockscoutUrl, trackedTokens) { async function refreshBalances(
wallets,
rpcUrl,
blockscoutUrl,
trackedTokens,
networkId,
) {
log.debugf("refreshBalances start, rpc:", rpcUrl); log.debugf("refreshBalances start, rpc:", rpcUrl);
const provider = getProvider(rpcUrl); const provider = getProvider(rpcUrl, networkId);
const updates = []; const updates = [];
for (const wallet of wallets) { for (const wallet of wallets) {
@@ -193,9 +221,9 @@ async function refreshBalances(wallets, rpcUrl, blockscoutUrl, trackedTokens) {
// Look up token metadata from its contract. // Look up token metadata from its contract.
// Calls symbol() and decimals() to verify it implements ERC-20. // Calls symbol() and decimals() to verify it implements ERC-20.
async function lookupTokenInfo(contractAddress, rpcUrl) { async function lookupTokenInfo(contractAddress, rpcUrl, networkId) {
log.debugf("lookupTokenInfo", contractAddress, "rpc:", rpcUrl); log.debugf("lookupTokenInfo", contractAddress, "rpc:", rpcUrl);
const provider = getProvider(rpcUrl); const provider = getProvider(rpcUrl, networkId);
const contract = new Contract(contractAddress, ERC20_ABI, provider); const contract = new Contract(contractAddress, ERC20_ABI, provider);
let name, symbol, decimals; let name, symbol, decimals;
@@ -235,9 +263,9 @@ async function lookupTokenInfo(contractAddress, rpcUrl) {
// Checks gapLimit addresses in parallel per batch. Stops when an entire // Checks gapLimit addresses in parallel per batch. Stops when an entire
// batch has no used addresses (i.e. gapLimit consecutive empty addresses). // batch has no used addresses (i.e. gapLimit consecutive empty addresses).
// Returns { addresses: [{ address, index }], nextIndex }. // Returns { addresses: [{ address, index }], nextIndex }.
async function scanForAddresses(xpub, rpcUrl, gapLimit = 5) { async function scanForAddresses(xpub, rpcUrl, networkId, gapLimit = 5) {
log.debugf("scanForAddresses start, gapLimit:", gapLimit); log.debugf("scanForAddresses start, gapLimit:", gapLimit);
const provider = getProvider(rpcUrl); const provider = getProvider(rpcUrl, networkId);
const used = []; const used = [];
let checked = 0; let checked = 0;
let checkUpTo = gapLimit; let checkUpTo = gapLimit;

View File

@@ -1,14 +1,23 @@
// Consolidated chain-switch handler. // Consolidated chain-switch handler for the popup.
// //
// Every state change required when the active network changes is // Every state change required when the active network changes is
// performed here so that callers (settings UI, background // performed here so that callers (settings UI, future chain additions) all go
// wallet_switchEthereumChain, future chain additions) all go
// through a single code path. // through a single code path.
// //
// Adding a new chain (e.g. ETC) requires only a new entry in // Adding a new chain (e.g. ETC) requires only a new entry in
// networks.js — no per-caller wiring is needed. // networks.js — no per-caller wiring is needed.
//
// The background does NOT come through here: this function mutates the
// module-level `state` singleton, which the MV3 service worker never
// populates, and a background switch performed on it wrote DEFAULT_STATE over
// the user's whole profile
// (https://git.eeqj.de/sneak/AutistMask/issues/316). The field mutations
// themselves live in chainSwitchFields.js, which takes the record to mutate as
// an argument; src/background/state.js applies them inside a read-modify-write
// against storage, and the singleton is not reachable from the background
// bundle at all (enforced by the ESLint rule in eslint.config.js).
const { networkById } = require("./networks"); const { applyChainSwitchFields } = require("./chainSwitchFields");
const { clearPrices } = require("./prices"); const { clearPrices } = require("./prices");
// Switch the active chain and reset all chain-specific cached state. // Switch the active chain and reset all chain-specific cached state.
@@ -16,56 +25,14 @@ const { clearPrices } = require("./prices");
async function onChainSwitch(newNetworkId) { async function onChainSwitch(newNetworkId) {
const { state, saveState } = require("./state"); const { state, saveState } = require("./state");
const net = networkById(newNetworkId); const net = applyChainSwitchFields(state, 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 --- // --- price cache ---
// Prices are chain-specific (testnet tokens are worthless, // Prices are chain-specific (testnet tokens are worthless,
// ETC has different pricing, etc.). // ETC has different pricing, etc.). In-memory and per bundle, so this is
// the popup's own cache — the only context that ever fills it.
clearPrices(); 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(); await saveState();
return net; return net;

View File

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

View File

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

View File

@@ -0,0 +1,204 @@
// 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");
// Dependency-free constant module; safe to pull into a background bundle.
const { RESTORABLE_VIEWS } = require("../popup/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.
const PERSISTED_FIELDS = Object.keys(DEFAULT_STATE)
.filter((key) => key !== "hasWallet")
.concat([
"currentView",
"selectedWallet",
"selectedAddress",
"selectedToken",
"viewData",
"viewStack",
]);
// Keep only the leading run of stored views the popup is willing to render.
//
// restoreView() refuses to reopen ONTO a non-restorable view, but the stack
// behind it used to be restored verbatim, so Back could walk onto a screen
// whose content is deliberately never re-rendered — and "show-phrase" has no
// Back control to leave by. Truncating at the first such entry instead of
// splicing it out keeps the result a prefix of the stored stack, so every
// surviving entry's Back target is exactly the one it had; splicing would
// silently re-point the entry above the hole at a different screen.
//
// Filtering happens here on load rather than in saveState(): the live
// in-session stack is legitimate (the screen really is rendered while the
// popup is open), and only a load-side filter also repairs the stacks
// already in storage, including ones written before a view left the set.
function restorableStack(stored, currentView) {
// A stored stack that is missing or not an array keeps nothing, but it
// still goes through the never-empty rule below rather than returning
// early: otherwise a corrupt stack would depend on exactly the goBack()
// fallback that the explicit ["main"] exists in order not to depend on.
const source = Array.isArray(stored) ? stored : [];
const cut = source.findIndex((view) => !RESTORABLE_VIEWS.has(view));
const kept = cut === -1 ? source.slice() : source.slice(0, cut);
// A view restored below the root still needs somewhere for Back to go.
if (
kept.length === 0 &&
currentView !== "main" &&
RESTORABLE_VIEWS.has(currentView)
) {
return ["main"];
}
return kept;
}
// 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 = {};
out.wallets = structuredClone(saved.wallets || []);
// Derived, never trusted verbatim off storage — see loadState().
out.hasWallet = out.wallets.length > 0;
out.trackedTokens = structuredClone(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 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)) {
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)
? structuredClone(saved.allowedSites)
: {};
out.deniedSites =
saved.deniedSites && !Array.isArray(saved.deniedSites)
? structuredClone(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 = structuredClone(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 =
saved.selectedWallet !== undefined ? saved.selectedWallet : null;
out.selectedAddress =
saved.selectedAddress !== undefined ? saved.selectedAddress : null;
out.selectedToken = saved.selectedToken || null;
out.viewData = structuredClone(saved.viewData || {});
out.viewStack = restorableStack(saved.viewStack, out.currentView);
return out;
}
module.exports = {
DEFAULT_STATE,
PERSISTED_FIELDS,
normalizePersisted,
restorableStack,
};

View File

@@ -1,45 +1,36 @@
// State management and extension storage persistence. // 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 (enforced by the
// ESLint rule in eslint.config.js, and by the background having its own
// per-call storage layer in src/background/state.js), and reading a persisted
// field of the singleton before a load now THROWS instead of quietly serving
// a default.
const { DEFAULT_RPC_URL, DEFAULT_BLOCKSCOUT_URL } = require("./constants");
const { networkById } = require("./networks"); const { networkById } = require("./networks");
// Dependency-free constant module; safe to pull into a background bundle. const {
const { RESTORABLE_VIEWS } = require("../popup/restorableViews"); DEFAULT_STATE,
PERSISTED_FIELDS,
normalizePersisted,
} = require("./persistedState");
const { storageGet, storageSet } = require("./browserApi"); const { storageGet, storageSet } = require("./browserApi");
const { log } = require("./log");
const DEFAULT_STATE = { // The live record the proxy below guards. Everything inside this module reads
hasWallet: false, // and writes THIS object, never the proxy: the guard is for callers.
wallets: [], const rawState = {
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, ...DEFAULT_STATE,
// Its own object, not the one DEFAULT_STATE holds: onChainSwitch() // Its own object, not the one DEFAULT_STATE holds: applyChainSwitchFields()
// mutates this map in place, and a spread copies the reference. // mutates this map in place, and a spread copies the reference.
networkEndpoints: {}, networkEndpoints: {},
currentView: null, currentView: null,
@@ -50,175 +41,486 @@ const state = {
viewStack: [], viewStack: [],
}; };
// Keep only the leading run of stored views the popup is willing to render. // False until loadState() has completed in this bundle. Until then, a
// persisted field that has not been assigned in this context cannot be READ:
// see StateNotLoadedError.
let loaded = false;
// True once this context has assigned anything into the singleton.
// //
// restoreView() refuses to reopen ONTO a non-restorable view, but the stack // What the guard is for is a context that READS a profile nobody put there —
// behind it used to be restored verbatim, so Back could walk onto a screen // every one of the five defects was a pure read of an untouched singleton,
// whose content is deliberately never re-rendered — and "show-phrase" has no // answered out of DEFAULT_STATE. A context that has written into it is
// Back control to leave by. Truncating at the first such entry instead of // managing it deliberately (the popup does, via loadState() at boot and by
// splicing it out keeps the result a prefix of the stored stack, so every // hand thereafter), and reading back what you yourself put there is not the
// surviving entry's Back target is exactly the one it had; splicing would // mistake being caught.
// silently re-point the entry above the hole at a different screen.
// //
// Filtering happens here on load rather than in saveState(): the live // The cost of that is honest and worth naming: a context that writes one field
// in-session stack is legitimate (the screen really is rendered while the // and then reads a different, untouched one is still served that field's
// popup is open), and only a load-side filter also repairs the stacks // default. Nothing closes that here — what closes it for the background is
// already in storage, including ones written before a view left the set. // that the background cannot reach this module at all (eslint.config.js).
function restorableStack(stored, currentView) { let adopted = false;
// 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 // Every field whose pre-load value would be a plausible-looking default rather
// early: otherwise a corrupt stack would depend on exactly the goBack() // than the user's data. The view scratch fields are guarded too: currentView
// fallback that the explicit ["main"] exists in order not to depend on. // and viewStack are persisted, and a save that carried their pre-load values
const source = Array.isArray(stored) ? stored : []; // would overwrite a real stored stack with an empty one.
const cut = source.findIndex((view) => !RESTORABLE_VIEWS.has(view)); const GUARDED_FIELDS = new Set(PERSISTED_FIELDS.concat(["hasWallet"]));
const kept = cut === -1 ? source.slice() : source.slice(0, cut);
// A view restored below the root still needs somewhere for Back to go. class StateNotLoadedError extends Error {
if ( constructor(field) {
kept.length === 0 && super(
currentView !== "main" && "state." +
RESTORABLE_VIEWS.has(currentView) field +
) { " was read before loadState(); this context has no profile" +
return ["main"]; " loaded and must not be served DEFAULT_STATE",
);
this.name = "StateNotLoadedError";
} }
return kept;
} }
// Loud, not defaulted. The whole defect class this guard closes looks exactly
// like working code at the call site: the read succeeds, the value is
// well-formed, and it describes a wallet that is not the user's.
const state = new Proxy(rawState, {
get(target, prop, receiver) {
if (
!loaded &&
!adopted &&
typeof prop === "string" &&
GUARDED_FIELDS.has(prop)
) {
throw new StateNotLoadedError(prop);
}
return Reflect.get(target, prop, receiver);
},
set(target, prop, value, receiver) {
if (typeof prop === "string" && GUARDED_FIELDS.has(prop)) {
adopted = true;
}
return Reflect.set(target, prop, value, receiver);
},
});
// Return the network configuration for the currently selected network. // Return the network configuration for the currently selected network.
function currentNetwork() { function currentNetwork() {
return networkById(state.networkId); return networkById(state.networkId);
} }
async function saveState() { // The persisted fields as they stood at the end of this page's last
const persisted = { // loadState() or saveState(). saveState() diffs the live state against this
hasWallet: state.hasWallet, // to find only the fields THIS page actually changed.
wallets: state.wallets, //
trackedTokens: state.trackedTokens, // Deep-cloned, not a reference: callers mutate persisted objects and arrays
networkId: state.networkId, // in place (state.wallets.push(...)), and a reference baseline would mutate
rpcUrl: state.rpcUrl, // right along with `state`, so the diff would always come out empty.
blockscoutUrl: state.blockscoutUrl, let baseline = null;
networkEndpoints: state.networkEndpoints,
lastBalanceRefresh: state.lastBalanceRefresh, function snapshotPersisted() {
activeAddress: state.activeAddress, const out = {};
allowedSites: state.allowedSites, for (const key of PERSISTED_FIELDS) out[key] = rawState[key];
deniedSites: state.deniedSites, return out;
rememberSiteChoice: state.rememberSiteChoice, }
showZeroBalanceTokens: state.showZeroBalanceTokens,
hideSpoofedSymbols: state.hideSpoofedSymbols, function deepEqual(a, b) {
hideLowHolderTokens: state.hideLowHolderTokens, if (a === b) return true;
hideFraudContracts: state.hideFraudContracts, if (typeof a !== "object" || typeof b !== "object") return false;
hideDustTransactions: state.hideDustTransactions, if (a === null || b === null) return false;
dustThresholdGwei: state.dustThresholdGwei, if (Array.isArray(a) !== Array.isArray(b)) return false;
utcTimestamps: state.utcTimestamps, const aKeys = Object.keys(a);
fraudContracts: state.fraudContracts, const bKeys = Object.keys(b);
tokenHolderCache: state.tokenHolderCache, if (aKeys.length !== bKeys.length) return false;
theme: state.theme, for (const key of aKeys) {
debugMode: state.debugMode, if (!Object.prototype.hasOwnProperty.call(b, key)) return false;
currentView: state.currentView, if (!deepEqual(a[key], b[key])) return false;
selectedWallet: state.selectedWallet, }
selectedAddress: state.selectedAddress, return true;
selectedToken: state.selectedToken, }
viewData: state.viewData,
viewStack: state.viewStack, // Stable identity for a wallet, independent of its position in the array
}; // (which shifts under a concurrent add/delete elsewhere) and independent of
await storageSet({ autistmask: persisted }); // its mutable fields (name is user-editable; addresses gains/loses entries
// via scanning and deleteAddress.js). An "hd"/"xprv" wallet's xpub never
// changes for its lifetime and is already enforced unique
// (findWalletByXpub() in addWallet.js). A "key" wallet has no xpub, exactly
// one address for its whole lifetime (nothing ever adds to or removes from
// a key wallet's address list), and that address is already enforced
// unique (findWalletByAddress()) — so it stands in for identity there.
// Neither invariant is enforced by this function or by
// mergeListByIdentity() below — they hold only because every wallet-
// creation path in addWallet.js happens to populate one or the other before
// the wallet ever reaches state.wallets, and because canRemoveAddress() in
// walletDelete.js never lets a wallet's address list go to zero. A wallet
// with neither (an empty/legacy/corrupt record) falls back to the same
// "addr:" identity as every other such record, which is a genuine
// collision, not a proxy for one — see the collision handling in
// mergeListByIdentity().
function walletIdentity(wallet) {
if (wallet.xpub) return "xpub:" + wallet.xpub;
const first = wallet.addresses && wallet.addresses[0];
return "addr:" + (first ? String(first.address).toLowerCase() : "");
}
// Stable identity for an address within one wallet's address list. An
// address is unique within its wallet and, once derived or imported, never
// changes — only whether it is present.
function addressIdentity(addr) {
return String(addr.address).toLowerCase();
}
// Merge one array of identity-bearing objects (wallets, or the addresses
// inside one wallet) by identity rather than by array index — an index
// shifts under a concurrent insert/delete elsewhere, which would merge the
// wrong pair of objects entirely.
//
// `theirs` (fresh storage) sets the membership baseline and the order:
// - An item this page never had baseline knowledge of, but that is in
// `theirs`, was added by someone else — kept as-is.
// - An item `base` had and `ours` no longer has was deleted by THIS page
// — dropped even though `theirs` still has it (this page's own delete
// must win over a background save that only touched leaf fields).
// - An item present in both `ours` and `theirs` is merged leaf-by-leaf via
// `mergeItem`, so a leaf this page changed (e.g. a renamed wallet) lands
// on top of `theirs`' otherwise-current copy (e.g. a refreshed balance).
// Anything left in `ours` that `base` never had and `theirs` does not have
// yet is this page's own new addition — appended.
//
// `identityOf` is not guaranteed collision-free (walletIdentity() falls
// back to one shared "addr:" value for any wallet with neither an xpub nor
// a populated first address). Two records that collide under it must never
// silently collapse into one — that is exactly how this function used to
// drop a wallet, encryptedSecret included, with no error and no log. Two
// defenses:
// - `ours` is indexed into GROUPS, not a single item per identity, so two
// colliding live items on this page can't overwrite each other in the
// index before the merge below even runs.
// - A matched pair with no shared `base` entry (neither page ever agreed
// on this identity) is only merged leaf-by-leaf when the two sides are
// already equal. If they differ, that is not "the same record edited
// twice", it is two different records that happen to share an identity
// — both are kept, unmerged, rather than guessing which one is real.
function mergeListByIdentity(base, ours, theirs, identityOf, mergeItem) {
base = base || [];
ours = ours || [];
theirs = theirs || [];
const baseIndex = new Map(base.map((item) => [identityOf(item), item]));
const oursIndex = new Map();
for (const item of ours) {
const id = identityOf(item);
if (!oursIndex.has(id)) oursIndex.set(id, []);
oursIndex.get(id).push(item);
}
const result = [];
const seen = new Set();
for (const theirItem of theirs) {
const id = identityOf(theirItem);
seen.add(id);
const oursGroup = oursIndex.get(id);
if (baseIndex.has(id) && !oursGroup) continue;
if (oursGroup) {
const baseItem = baseIndex.get(id);
if (!baseItem && !deepEqual(oursGroup[0], theirItem)) {
log.errorf(
"state: identity collision merging",
JSON.stringify(id),
"- keeping both records instead of dropping one",
);
result.push(theirItem, ...oursGroup);
} else {
result.push(mergeItem(baseItem, oursGroup[0], theirItem));
for (let i = 1; i < oursGroup.length; i++) {
result.push(oursGroup[i]);
}
}
} else {
result.push(theirItem);
}
}
for (const item of ours) {
const id = identityOf(item);
if (seen.has(id)) continue;
if (!baseIndex.has(id)) result.push(item);
}
return result;
}
// Merge one wallet's scalar/leaf fields (name, encryptedSecret, nextIndex,
// ...) against base, then recurse into its address list by identity. `base`
// is null when this page created the wallet itself and no other page has
// (yet) produced a same-identity record — nothing to merge in that case,
// this page's own copy wins outright. mergeListByIdentity() only ever calls
// this with `!base` when `ours` and `theirs` are already equal (a genuine
// collision between two DIFFERENT same-identity records is caught and kept
// as two separate entries before this function is reached), so returning
// `ours` here can't discard a different wallet's data.
function mergeWallet(base, ours, theirs) {
if (!base) return ours;
const merged = { ...theirs };
for (const key of Object.keys(ours)) {
if (key === "addresses") continue;
if (!deepEqual(ours[key], base[key])) merged[key] = ours[key];
}
merged.addresses = mergeListByIdentity(
base.addresses,
ours.addresses,
theirs.addresses,
addressIdentity,
mergeAddress,
);
return merged;
}
// 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.
function mergeAddress(base, ours, theirs) {
if (!base) return ours;
const merged = { ...theirs };
for (const key of Object.keys(ours)) {
if (!deepEqual(ours[key], base[key])) merged[key] = ours[key];
}
return merged;
}
// Merge a plain object keyed by string (allowedSites/deniedSites: address ->
// hostname list; networkEndpoints: networkId -> {rpcUrl, blockscoutUrl}) the
// same way mergeListByIdentity() merges an array — by key, not by whole-
// object diff — so a key one page added or removed applies independently of
// 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.
function mergeMapByKey(base, ours, theirs, mergeLeaf) {
base = base || {};
ours = ours || {};
theirs = theirs || {};
const result = {};
const seen = new Set();
for (const key of Object.keys(theirs)) {
seen.add(key);
const inBase = Object.prototype.hasOwnProperty.call(base, key);
const inOurs = Object.prototype.hasOwnProperty.call(ours, key);
if (inBase && !inOurs) continue; // this page deleted the whole entry
if (inOurs) {
result[key] = mergeLeaf(base[key], ours[key], theirs[key]);
} else {
result[key] = theirs[key];
}
}
for (const key of Object.keys(ours)) {
if (seen.has(key)) continue;
if (!Object.prototype.hasOwnProperty.call(base, key)) {
result[key] = ours[key];
}
}
return result;
}
// 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.
function mergeHostnameList(base, ours, theirs) {
return mergeListByIdentity(
base,
ours,
theirs,
(hostname) => hostname,
(b, o, t) => t,
);
}
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).
function mergeEndpointEntry(base, ours, theirs) {
if (!base) return ours;
const merged = { ...theirs };
for (const key of Object.keys(ours)) {
if (!deepEqual(ours[key], base[key])) merged[key] = ours[key];
}
return merged;
}
function mergeNetworkEndpoints(base, ours, theirs) {
return mergeMapByKey(base, ours, theirs, mergeEndpointEntry);
}
// 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).
//
// Only the fields this page actually changed — those that differ from
// `baseline`, captured at the last loadState()/saveState() on this page —
// are written; every other field is carried forward from whatever is in
// storage right now, which may already be a value another page wrote.
//
// `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 /
// 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
// 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.
//
// `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.
//
// `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
// comment at mergeEndpointEntry() for why the collision this closes is
// milder than the other two.
//
// Every other persisted field stays a whole-field diff:
// `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
// this page's own UI scratch space, not data another page has any reason to
// share membership of.
//
// This does not make two pages that both change the SAME leaf concurrently
// safe: last write wins on that one leaf, same as before. What it removes
// is the cross-field (and now cross-membership-vs-leaf) clobber — a page
// that only navigated, or only refreshed a balance, overwriting a wallet or
// address list it never touched the membership of.
//
// This page's own live `state` is deliberately NOT rehydrated from a field
// another page changed — only the record written to storage is merged.
// showView() fires saveState() on every navigation without awaiting it,
// which is what makes the queue above necessary in the first place, and a
// save that is slow to come back has no way to tell whether the field it
// is about to hand back is still the current answer or has since been
// overtaken by something this very page did in the meantime; writing it
// into `state` regardless reintroduced exactly the clobber this function
// exists to remove, just delayed and confined to one page instead of two
// (caught by tests/txStatus.test.js). A page's live picture of a field it
// does not own goes on being whatever its last loadState() saw, same as
// before this fix; only the persisted record is guaranteed current.
async function saveStateOnce() {
const current = snapshotPersisted();
const result = await storageGet("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.
const fresh = normalizePersisted(result.autistmask);
const merged = { ...fresh };
for (const key of PERSISTED_FIELDS) {
if (key === "wallets") {
merged.wallets = mergeListByIdentity(
baseline ? baseline.wallets : [],
current.wallets,
fresh.wallets,
walletIdentity,
mergeWallet,
);
} else if (key === "allowedSites" || key === "deniedSites") {
merged[key] = mergeSiteMap(
baseline ? baseline[key] : {},
current[key],
fresh[key],
);
} else if (key === "networkEndpoints") {
merged.networkEndpoints = mergeNetworkEndpoints(
baseline ? baseline.networkEndpoints : {},
current.networkEndpoints,
fresh.networkEndpoints,
);
} else if (
baseline === null ||
!deepEqual(current[key], baseline[key])
) {
merged[key] = current[key];
}
}
merged.hasWallet = Boolean(merged.wallets && merged.wallets.length > 0);
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;
baseline = structuredClone(snapshotPersisted());
}
// showView() calls saveState() on every navigation without awaiting it, so
// two saves from the SAME page can be in flight at once — e.g. a screen
// shown, then immediately replaced before the first save's storageGet()
// round trip has come back. Left concurrent, the first save's turn would
// finish after the second's live-state mutation and then re-hydrate `state`
// from what IT read, stomping the second, later change back to a stale
// value — the same clobber this function exists to prevent, just between
// two saves on one page instead of two pages. Queuing makes every save's
// snapshot-diff-write-rehydrate run start to finish before the next one
// begins, so each one only ever sees the true live state at its turn.
let saveQueue = Promise.resolve();
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(() => {});
return turn;
} }
async function loadState() { async function loadState() {
const result = await storageGet("autistmask"); const result = await storageGet("autistmask");
if (result.autistmask) { if (result.autistmask) {
const saved = result.autistmask; Object.assign(rawState, normalizePersisted(result.autistmask));
state.wallets = saved.wallets || [];
// Derived, never read from storage: a profile persisted with the flag
// out of step with the wallet list would otherwise stay broken on
// every load. Nothing depends on the two disagreeing.
state.hasWallet = state.wallets.length > 0;
state.trackedTokens = saved.trackedTokens || [];
state.networkId = saved.networkId || DEFAULT_STATE.networkId;
state.rpcUrl = saved.rpcUrl || DEFAULT_STATE.rpcUrl;
state.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. A stored primitive would therefore be re-persisted
// unchanged forever, and every switch would fall back to the network
// default — the endpoint loss this map exists to prevent, with no
// self-healing. The allowedSites/deniedSites guards below are only
// read from, which is why they can be looser.
state.networkEndpoints =
typeof saved.networkEndpoints === "object" &&
saved.networkEndpoints !== null &&
!Array.isArray(saved.networkEndpoints)
? saved.networkEndpoints
: {};
// 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 (!state.networkEndpoints[state.networkId]) {
state.networkEndpoints[state.networkId] = {
rpcUrl: state.rpcUrl,
blockscoutUrl: state.blockscoutUrl,
};
}
state.lastBalanceRefresh = saved.lastBalanceRefresh || 0;
state.activeAddress = saved.activeAddress || null;
state.allowedSites =
saved.allowedSites && !Array.isArray(saved.allowedSites)
? saved.allowedSites
: {};
state.deniedSites =
saved.deniedSites && !Array.isArray(saved.deniedSites)
? saved.deniedSites
: {};
state.rememberSiteChoice =
saved.rememberSiteChoice !== undefined
? saved.rememberSiteChoice
: true;
state.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.
state.hideSpoofedSymbols =
saved.hideSpoofedSymbols !== undefined
? saved.hideSpoofedSymbols
: true;
state.hideLowHolderTokens =
saved.hideLowHolderTokens !== undefined
? saved.hideLowHolderTokens
: true;
state.hideFraudContracts =
saved.hideFraudContracts !== undefined
? saved.hideFraudContracts
: true;
state.hideDustTransactions =
saved.hideDustTransactions !== undefined
? saved.hideDustTransactions
: true;
state.dustThresholdGwei =
saved.dustThresholdGwei !== undefined
? saved.dustThresholdGwei
: 100000;
state.utcTimestamps =
saved.utcTimestamps !== undefined ? saved.utcTimestamps : false;
state.fraudContracts = saved.fraudContracts || [];
state.tokenHolderCache = saved.tokenHolderCache || {};
state.theme = saved.theme || "system";
state.debugMode =
saved.debugMode !== undefined ? saved.debugMode : false;
state.currentView = saved.currentView || null;
state.selectedWallet =
saved.selectedWallet !== undefined ? saved.selectedWallet : null;
state.selectedAddress =
saved.selectedAddress !== undefined ? saved.selectedAddress : null;
state.selectedToken = saved.selectedToken || null;
state.viewData = saved.viewData || {};
state.viewStack = restorableStack(saved.viewStack, state.currentView);
} }
// Whether storage had a profile or was empty, this context has now read
// it, and the defaults standing in for an empty profile are the right
// answer rather than a stand-in for one nobody looked for.
loaded = true;
// The point of comparison every saveState() on this page diffs against.
// See PERSISTED_FIELDS in persistedState.js for why a reference here
// would be wrong.
baseline = structuredClone(snapshotPersisted());
} }
// Through the guarded proxy, not rawState: a caller asking which address is
// selected before anything was loaded gets the same loud failure it would get
// reading the fields itself.
function currentAddress() { function currentAddress() {
if (state.selectedWallet === null || state.selectedAddress === null) { if (state.selectedWallet === null || state.selectedAddress === null) {
return null; return null;
@@ -232,4 +534,5 @@ module.exports = {
loadState, loadState,
currentAddress, currentAddress,
currentNetwork, currentNetwork,
StateNotLoadedError,
}; };

View File

@@ -11,6 +11,10 @@ const { log, debugFetch } = require("./log");
const { TOKEN_BY_ADDRESS } = require("./tokenList"); const { TOKEN_BY_ADDRESS } = require("./tokenList");
const { parseHoldersCount, isLowHolderCount } = require("./holders"); const { parseHoldersCount, isLowHolderCount } = require("./holders");
const { isSpoofedSymbol } = require("./symbolSpoof"); const { isSpoofedSymbol } = require("./symbolSpoof");
// 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.
const { truncateAmount: formatTxValue } = require("./amountDisplay");
// Ethereum addresses are case-insensitive: EIP-55 mixed case is a checksum // Ethereum addresses are case-insensitive: EIP-55 mixed case is a checksum
// over the address, not part of its identity. Every address comparison in // over the address, not part of its identity. Every address comparison in
@@ -20,13 +24,6 @@ function normalizeAddress(addr) {
return (addr || "").toLowerCase(); return (addr || "").toLowerCase();
} }
function formatTxValue(val) {
const parts = val.split(".");
if (parts.length === 1) return val + ".0000";
const dec = (parts[1] + "0000").slice(0, 4);
return parts[0] + "." + dec;
}
function parseTx(tx, addrLower) { function parseTx(tx, addrLower) {
const from = tx.from?.hash || ""; const from = tx.from?.hash || "";
const to = tx.to?.hash || ""; const to = tx.to?.hash || "";

View File

@@ -3,6 +3,7 @@
const { Interface, AbiCoder, getBytes, formatUnits } = require("ethers"); const { Interface, AbiCoder, getBytes, formatUnits } = require("ethers");
const { TOKEN_BY_ADDRESS } = require("./tokenList"); const { TOKEN_BY_ADDRESS } = require("./tokenList");
const { truncateAmountNeverZero } = require("./amountDisplay");
const coder = AbiCoder.defaultAbiCoder(); const coder = AbiCoder.defaultAbiCoder();
@@ -34,11 +35,13 @@ const COMMAND_NAMES = {
0x21: "Execute Sub-Plan", 0x21: "Execute Sub-Plan",
}; };
// The swap's Amount and Min. received lines land on the same approval screen,
// and Amount is carried to the wait/success/error screens as the ERC-20 line
// is, so they take the same nonzero floor: a swap of an amount below 0.0001 is
// not "0.0000", and a slippage floor of one base unit does not read as "you may
// receive nothing".
function formatAmount(raw, decimals) { function formatAmount(raw, decimals) {
const parts = formatUnits(raw, decimals).split("."); return truncateAmountNeverZero(formatUnits(raw, decimals));
if (parts.length === 1) return parts[0] + ".0000";
const dec = (parts[1] + "0000").slice(0, 4);
return parts[0] + "." + dec;
} }
function tokenInfo(address) { function tokenInfo(address) {

View File

@@ -8,6 +8,8 @@
// A controllable clock plus a stubbed balance refresh, so a cadence test can // A controllable clock plus a stubbed balance refresh, so a cadence test can
// measure the interval between refreshes that actually happened rather than // measure the interval between refreshes that actually happened rather than
// asserting the interval someone intended. // asserting the interval someone intended.
const { makeStorageStub } = require("./support/storageStub");
let mockNow = 0; let mockNow = 0;
const mockBalanceRefreshAt = []; const mockBalanceRefreshAt = [];
@@ -247,32 +249,17 @@ describe("alarms module", () => {
// Loads the background worker against stubbed browser APIs. The returned // Loads the background worker against stubbed browser APIs. The returned
// store is the extension storage the worker sees, so a test can seed wallet // store is the extension storage the worker sees, so a test can seed wallet
// state and read back what the worker persisted. // 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 = {}) { function loadBackground(initialStore = {}) {
const storageStore = initialStore; const storage = makeStorageStub(initialStore, mockStorageTick);
const alarmsStub = makeAlarmsStub(); const alarmsStub = makeAlarmsStub();
const listeners = { onInstalled: [], onStartup: [] }; const listeners = { onInstalled: [], onStartup: [] };
global.chrome = { global.chrome = {
alarms: alarmsStub, 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: { runtime: {
onMessage: { addListener: jest.fn() }, onMessage: { addListener: jest.fn() },
onConnect: { addListener: jest.fn() }, onConnect: { addListener: jest.fn() },
@@ -301,7 +288,7 @@ function loadBackground(initialStore = {}) {
})); }));
jest.resetModules(); jest.resetModules();
require("../src/background/index"); require("../src/background/index");
return { alarmsStub, listeners, store: storageStore }; return { alarmsStub, listeners, storage };
} }
// Flush the promise chains the startup path and the alarm handlers run on. // Flush the promise chains the startup path and the alarm handlers run on.
@@ -476,12 +463,16 @@ describe("balance refresh steady-state cadence", () => {
// The guard's actual job, and the reason it is shortened rather than // 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 // removed: while the popup is open it refreshes every 10 seconds and
// stamps the same field, and the background job has nothing to add. // stamps the same field, and the background job has nothing to add.
const store = seededStore(); const { alarmsStub, storage } = loadBackground(seededStore());
const { alarmsStub } = loadBackground(store);
await settle(); await settle();
mockNow += PERIOD_MS; mockNow += PERIOD_MS;
store.autistmask.lastBalanceRefresh = mockNow - 10 * 1000; // As the open popup's own refresh would leave it: written to storage,
// not poked into an object the worker happens to share.
storage.write("autistmask", {
...storage.read("autistmask"),
lastBalanceRefresh: mockNow - 10 * 1000,
});
alarmsStub.fire(BALANCE_REFRESH_ALARM); alarmsStub.fire(BALANCE_REFRESH_ALARM);
await settle(); await settle();

View File

@@ -0,0 +1,190 @@
// The floor of the approval screen's amount line.
//
// Amounts are truncated to four decimal places for scannability (README.md,
// Display Consistency). With the token's true scale resolved, that truncation
// can still take a real amount below the floor and print it as `0.0000`: one
// base unit of an 18-decimal token, or a few hundred of an 8-decimal one. On
// the one screen whose job is to state what is being authorized, a nonzero
// transfer or allowance then reads as nothing.
//
// The invariant asserted here is narrow: a nonzero amount never renders as
// zero. The four-decimal rule itself is unchanged, and the string the
// confirmation screens carry as `txInfo.amount` is the same one, so it is
// asserted on `rawValue` alongside the displayed line.
//
// Both amount paths of that screen are covered: the ERC-20 line decoded by
// `src/popup/views/approval.js`, and the swap's `Amount` and `Min. received`
// lines decoded by `src/shared/uniswap.js`.
globalThis.chrome = {
storage: { local: { get: async () => ({}), set: async () => {} } },
};
const { AbiCoder, Interface } = require("ethers");
const { ERC20_ABI } = require("../src/shared/constants");
const { state } = require("../src/shared/state");
const { decodeCalldata } = require("../src/popup/views/approval");
const uniswap = require("../src/shared/uniswap");
const {
truncateAmount,
truncateAmountNeverZero,
} = require("../src/shared/amountDisplay");
const iface = new Interface(ERC20_ABI);
// Bundled tokens, so the scale and the symbol both come from the list.
const USDC = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"; // 6 decimals
const WBT = "0x925206b8a707096Ed26ae47C84747fE0bb734F59"; // 8 decimals
const DAI = "0x6B175474E89094C44Da98b954EedeAC495271d0F"; // 18 decimals
// Outside the list, so the scale comes from what the user tracks and the line
// carries no symbol.
const NOVEL = "0xE2E0000000000000000000000000000000000E2e";
const RECIPIENT = "0xC0FfEE0000000000000000000000000000c0fFEe";
const SPENDER = "0x1111111111111111111111111111111111111111";
// The Uniswap swap lines land on this same approval screen.
const ROUTER = "0x66a9893cc07d91d95644aedd05d03f95e1dba8af";
const USDT = "0xdAC17F958D2ee523a2206206994597C13D831ec7"; // 6 decimals
const WETH = "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2"; // 18 decimals
const coder = AbiCoder.defaultAbiCoder();
const routerIface = new Interface([
"function execute(bytes commands, bytes[] inputs, uint256 deadline)",
]);
// A V2_SWAP_EXACT_IN (command 0x08) execute() call: `amountIn` of USDT for at
// least `amountOutMin` of WETH.
function swapData(amountIn, amountOutMin) {
const input = coder.encode(
["address", "uint256", "uint256", "address[]", "bool"],
[RECIPIENT, amountIn, amountOutMin, [USDT, WETH], true],
);
return routerIface.encodeFunctionData("execute", [
"0x08",
[input],
9999999999n,
]);
}
function swapDetail(amountIn, amountOutMin, label) {
const decoded = uniswap.decode(swapData(amountIn, amountOutMin), ROUTER);
return decoded.details.find((d) => d.label === label);
}
function transferData(amount) {
return iface.encodeFunctionData("transfer", [RECIPIENT, amount]);
}
function approveData(amount) {
return iface.encodeFunctionData("approve", [SPENDER, amount]);
}
// The Amount detail as the approval screen renders it: `value` is the line on
// the screen, `rawValue` is what is carried to the wait/success/error screens.
function amount(data, token) {
const decoded = decodeCalldata(data, token);
return decoded.details.find((d) => d.label === "Amount");
}
beforeEach(() => {
state.trackedTokens = [];
state.wallets = [];
});
describe("a nonzero amount never renders as zero", () => {
test("500 base units of a 6-decimal token", () => {
const detail = amount(transferData(500n), USDC);
expect(detail.value).toBe("0.0005 USDC");
expect(detail.rawValue).toBe("0.0005");
});
test("1 base unit of an 18-decimal token", () => {
const detail = amount(transferData(1n), DAI);
expect(detail.value).toBe("0.000000000000000001 DAI");
expect(detail.rawValue).toBe("0.000000000000000001");
});
test("500 base units of an 8-decimal token", () => {
expect(amount(transferData(500n), WBT).rawValue).toBe("0.000005");
});
test("an allowance below the floor is not rendered as zero either", () => {
expect(amount(approveData(1n), DAI).value).toBe(
"0.000000000000000001 DAI",
);
});
// The floor holds at any scale, not only the three above: for every
// decimals a token can declare, one base unit has to show a digit.
test("one base unit shows a significant digit at every scale", () => {
for (let decimals = 0; decimals <= 30; decimals++) {
state.trackedTokens = [{ address: NOVEL, decimals }];
expect(amount(transferData(1n), NOVEL).rawValue).toMatch(/[1-9]/);
}
});
});
// The swap decoder formats its own amounts, so the same floor has to hold on
// the swap lines of the same screen. `Min. received` is the sharper of the
// two: the slippage floor rendered as `0.0000` states that the swap may return
// nothing.
describe("a swap's amounts never render as zero either", () => {
test("a swap input below the floor keeps a significant digit", () => {
// 50 base units of a 6-decimal token is 0.00005.
const detail = swapDetail(50n, 10n ** 15n, "Amount");
expect(detail.value).toBe("0.00005 USDT");
expect(detail.rawValue).toBe("0.00005");
});
test("a min-received below the floor keeps a significant digit", () => {
// 1 wei of an 18-decimal token.
expect(swapDetail(10n ** 6n, 1n, "Min. received").value).toBe(
"0.000000000000000001 WETH",
);
});
test("swap amounts at or above the floor are still truncated", () => {
expect(swapDetail(1000000n, 10n ** 15n, "Amount").rawValue).toBe(
"1.0000",
);
expect(
swapDetail(1000000n, 999999999999999999n, "Min. received").value,
).toBe("0.9999 WETH");
});
});
describe("the four-decimal rule is otherwise unchanged", () => {
test("a whole amount keeps exactly four decimals", () => {
expect(amount(transferData(5000000000n), USDC).rawValue).toBe(
"5000.0000",
);
});
test("precision beyond four decimals is still truncated", () => {
expect(amount(transferData(1234567890123456789n), DAI).rawValue).toBe(
"1.2345",
);
});
test("an amount at the floor is not extended", () => {
expect(amount(transferData(100000000000000n), DAI).rawValue).toBe(
"0.0001",
);
});
test("a genuine zero still renders as zero", () => {
expect(amount(transferData(0n), DAI).rawValue).toBe("0.0000");
});
// The three truncators now share one module. The floor is a policy of the
// approval and confirmation screens only: the history and balance lists
// keep plain truncation, because the transaction detail view is the
// authoritative record and already shows exact precision.
test("the list rule stays unfloored", () => {
expect(truncateAmount("0.000000000000000001")).toBe("0.0000");
expect(truncateAmountNeverZero("0.000000000000000001")).toBe(
"0.000000000000000001",
);
});
});

View File

@@ -24,6 +24,7 @@ const { Network, Wallet } = require("ethers");
// before any jest.doMock() of the module, so the copy assertions below check // before any jest.doMock() of the module, so the copy assertions below check
// what the user is actually shown. // what the user is actually shown.
const { describeSigningFailure } = require("../src/shared/approvalVerify"); const { describeSigningFailure } = require("../src/shared/approvalVerify");
const { makeStorageStub } = require("./support/storageStub");
const SIGNER_KEY = const SIGNER_KEY =
"0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d"; "0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d";
@@ -137,22 +138,22 @@ function loadBackground(options) {
jest.resetModules(); jest.resetModules();
const broadcastTransaction = jest.fn(); const broadcastTransaction = jest.fn();
const loadState = jest.fn(opts.loadState || (async () => {}));
// The network the wallet is on, which the tests switch under a pending // The node the transaction is populated against is on whatever chain the
// approval. The node the transaction is populated against is on the same // stored profile says, as it would be: switching networks switches the RPC
// one, as it would be: switching networks switches the RPC endpoint too. // endpoint too. The background takes the network from storage per call —
let chain = MAINNET; // it holds no in-memory copy — so this reads the record rather than a
// variable the test keeps alongside it.
const chainOf = (networkId) =>
networkId === "sepolia" ? SEPOLIA : MAINNET;
jest.doMock("../src/shared/state", () => ({
state: { rpcUrl: "https://rpc.invalid", wallets: [] },
loadState,
saveState: jest.fn(async () => {}),
currentNetwork: () => ({ chainId: chain.hex }),
}));
jest.doMock("../src/shared/balances", () => ({ jest.doMock("../src/shared/balances", () => ({
getProvider: () => getProvider: (rpcUrl, networkId) =>
fakeProvider(broadcastTransaction, opts.provider, chain.num), fakeProvider(
broadcastTransaction,
opts.provider,
chainOf(networkId).num,
),
refreshBalances: jest.fn(async () => {}), refreshBalances: jest.fn(async () => {}),
})); }));
jest.doMock("../src/shared/phishingDomains", () => ({ jest.doMock("../src/shared/phishingDomains", () => ({
@@ -177,12 +178,31 @@ function loadBackground(options) {
wallets: [ wallets: [
{ name: "Wallet 1", type: "hd", addresses: [signer.address] }, { name: "Wallet 1", type: "hd", addresses: [signer.address] },
], ],
networkId: "mainnet",
rpcUrl: "https://rpc.invalid", rpcUrl: "https://rpc.invalid",
activeAddress: signer.address, activeAddress: signer.address,
allowedSites: { [signer.address]: [HOSTNAME] }, allowedSites: { [signer.address]: [HOSTNAME] },
deniedSites: {}, 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 messageListener = null;
let windowRemovedListener = null; let windowRemovedListener = null;
let connectListener = null; let connectListener = null;
@@ -194,15 +214,7 @@ function loadBackground(options) {
const actionPopups = []; const actionPopups = [];
global.chrome = { global.chrome = {
storage: { storage,
local: {
get: jest.fn(
opts.storageGet ||
(async () => ({ autistmask: persisted })),
),
set: jest.fn(async () => {}),
},
},
runtime: { runtime: {
getURL: (path) => EXT_URL + path, getURL: (path) => EXT_URL + path,
onMessage: { onMessage: {
@@ -401,18 +413,32 @@ function loadBackground(options) {
connectApproval, connectApproval,
closeWindow, closeWindow,
broadcastTransaction, broadcastTransaction,
loadState,
created, created,
removed, removed,
storage,
// The user switching account in the toolbar popup, as the background // The user switching account in the toolbar popup, as the background
// sees it: the persisted active address changes underneath a pending // sees it: the persisted active address changes underneath a pending
// approval. // approval.
setActiveAddress: (address) => { setActiveAddress: (address) => {
persisted.activeAddress = address; storage.write("autistmask", {
...storage.read("autistmask"),
activeAddress: address,
});
}, },
// The user switching network in the toolbar popup. // The user switching network in the toolbar popup. It moves the stored
// network and the endpoint together, as a real switch does.
setNetwork: (network) => { setNetwork: (network) => {
chain = network; const networkId = network === SEPOLIA ? "sepolia" : "mainnet";
storage.write("autistmask", {
...storage.read("autistmask"),
networkId,
rpcUrl: "https://rpc-" + networkId + ".invalid",
});
},
// Make the next state reads misbehave — stall, throw — without
// touching the reads that raised the approval. Pass null to restore.
setStateReadHook: (hook) => {
storageGetHook = hook;
}, },
fromPopup: { url: EXT_URL + "src/popup/index.html" }, fromPopup: { url: EXT_URL + "src/popup/index.html" },
}; };
@@ -677,15 +703,16 @@ describe("one transaction approval at a time", () => {
// page never — and holds the slot for the life of the worker with it. // 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 () => { test("an approval whose window closed under a failed attempt is answered, and frees the next request", async () => {
const stalled = deferred(); 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(); const first = bg.requestTx();
await settle(); 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( bg.send(
{ {
type: "AUTISTMASK_TX_RESPONSE", type: "AUTISTMASK_TX_RESPONSE",
@@ -709,6 +736,7 @@ describe("one transaction approval at a time", () => {
error: { code: 4001, message: "User rejected the request." }, error: { code: 4001, message: "User rejected the request." },
}); });
bg.setStateReadHook(null);
const second = bg.requestTx(); const second = bg.requestTx();
await settle(); await settle();
expect(second.result()).toBeNull(); expect(second.result()).toBeNull();
@@ -1226,19 +1254,18 @@ describe("what the approval is verified against", () => {
// The interlock must not cost the retry the approval exists to allow. // The interlock must not cost the retry the approval exists to allow.
describe("the interlock releases a failed attempt", () => { describe("the interlock releases a failed attempt", () => {
test("a retryable failure before the broadcast leaves the approval usable", async () => { test("a retryable failure before the broadcast leaves the approval usable", async () => {
let failNext = true; const bg = loadBackground();
const bg = loadBackground({
loadState: async () => {
if (failNext) {
failNext = false;
throw new Error("storage unavailable");
}
},
});
const pending = bg.requestTx(); const pending = bg.requestTx();
await settle(); await settle();
const id = pending.id(); 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( const first = bg.send(
{ {
type: "AUTISTMASK_TX_RESPONSE", type: "AUTISTMASK_TX_RESPONSE",

View File

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

View File

@@ -0,0 +1,191 @@
// 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). The prohibition is
// therefore mechanical rather than a review item — which means the rule's
// coverage is itself load-bearing, and a hole in it is indistinguishable from
// having no rule at all.
//
// The hole this file exists to pin shut is SPECIFIER SYNTAX. The rule walks the
// require graph textually, and a first version matched only `require("x")` and
// `require('x')`. Every shape below was measured against a real `make build`:
// each one puts state.js in dist/chrome/src/background/index.js, and each one
// was invisible to the narrower match. So each is a case here, and a regression
// in the matcher fails the suite instead of shipping a sixth site.
//
// NOT covered, deliberately: a computed specifier such as
// `require("../shared/" + "state")`. esbuild cannot resolve that statically
// either, so it never reaches the bundle — there is nothing to block.
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("every specifier syntax esbuild resolves is blocked", () => {
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",
);
});
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",
);
});
});
describe("what the rule must not report", () => {
test("a background file that reaches only its own state layer", () => {
const root = fixture({
"src/background/index.js":
'const { getState } = require("./state");\n' +
'const { applyChainSwitchFields } = require("../shared/chainSwitchFields");\n' +
"module.exports = { getState, applyChainSwitchFields };\n",
"src/background/state.js":
"async function getState() {}\nmodule.exports = { getState };\n",
});
expect(lintBackground(root)).toEqual([]);
});
// The tree as it actually stands. This is the assertion that would catch a
// widened matcher that resolves something it should not: it runs the rule
// over the real background entrypoint, from the real repo root.
test("the repository's own background entrypoint", () => {
const root = path.resolve(__dirname, "..");
expect(lintBackground(root)).toEqual([]);
});
});

View File

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

View File

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

View File

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

View File

@@ -19,6 +19,14 @@ const {
balanceWarningHtml, balanceWarningHtml,
} = require("../src/popup/views/deleteAddress"); } = require("../src/popup/views/deleteAddress");
const { prices, clearPrices } = require("../src/shared/prices"); 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"; const USDC = "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48";

View File

@@ -0,0 +1,477 @@
// The lost-password route off the delete-wallet screen (issue #312).
//
// What is pinned here is that a user who has forgotten the password can
// still get out — no password is asked for and none is checked — and that
// the escape hatch destroys exactly the wallet it names and nothing else.
// The second half is the dangerous one: this is the only control in the
// product that erases key material without the password that encrypted it,
// so an off-by-one in the wallet it removes would take a wallet whose
// owner never asked for it to be touched.
//
// The assertions are made against what came back OUT of extension storage,
// not against the live `state` object. Deleting a wallet in memory and
// 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`.
//
// 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
// needs nothing else from a document.
const mockSettingsShow = jest.fn();
jest.mock("../src/popup/views/settings", () => ({
show: mockSettingsShow,
}));
jest.mock("../src/shared/vault", () => ({
decryptWithPassword: jest.fn(),
}));
const { RESTORABLE_VIEWS } = require("../src/popup/restorableViews");
const { makeStorageStub } = require("./support/storageStub");
const VIEW = "delete-wallet-lost-password";
// Fixed addresses — never used for anything but these tests.
const A0 = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
const A1 = "0xdAC17F958D2ee523a2206206994597C13D831ec7";
const B0 = "0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599";
const C0 = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48";
// ------------------------------------------------------------ DOM stub
function makeElement(id) {
const classes = new Set();
const el = {
id,
textContent: "",
value: "",
innerHTML: "",
disabled: false,
style: {},
dataset: {},
listeners: {},
classList: {
add: (...names) => names.forEach((n) => classes.add(n)),
remove: (...names) => names.forEach((n) => classes.delete(n)),
contains: (n) => classes.has(n),
toggle: (n, force) => {
const on = force === undefined ? !classes.has(n) : force;
if (on) classes.add(n);
else classes.delete(n);
return on;
},
},
addEventListener: (name, fn) => {
el.listeners[name] = el.listeners[name] || [];
el.listeners[name].push(fn);
},
appendChild: () => {},
remove: () => {},
querySelectorAll: () => [],
};
return el;
}
function makeDocument() {
const els = new Map();
return {
getElementById(id) {
// The debug banner is created on demand by helpers.js; absent
// is the state a non-debug, non-testnet popup is in.
if (id === "debug-banner") return null;
if (!els.has(id)) els.set(id, makeElement(id));
return els.get(id);
},
createElement: () => makeElement("created"),
addEventListener: () => {},
body: { prepend: () => {} },
};
}
// ------------------------------------------------------------ harness
function wallet(name, secret, addresses) {
return {
type: "hd",
name,
xpub: "xpub-" + name,
encryptedSecret: secret,
nextIndex: addresses.length,
addresses: addresses.map((address) => ({
address,
balance: "0.0000",
tokenBalances: [],
})),
};
}
function load() {
jest.resetModules();
mockSettingsShow.mockClear();
const storage = makeStorageStub();
const sent = [];
globalThis.chrome = {
storage: { local: storage.local },
runtime: { sendMessage: (msg) => sent.push(msg) },
};
globalThis.document = makeDocument();
const helpers = require("../src/popup/views/helpers");
const { state } = require("../src/shared/state");
const vault = require("../src/shared/vault");
const deleteWallet = require("../src/popup/views/deleteWallet");
state.hasWallet = true;
state.wallets = [
wallet("Wallet 1", "secret-one", [A0, A1]),
wallet("Wallet 2", "secret-two", [B0]),
wallet("Wallet 3", "secret-three", [C0]),
];
state.selectedWallet = 0;
state.selectedAddress = 0;
state.activeAddress = A0;
state.allowedSites = { [A0]: ["a.example"], [B0]: ["b.example"] };
state.deniedSites = { [B0]: ["c.example"], [C0]: ["d.example"] };
state.viewStack = ["main", "settings"];
state.currentView = "settings";
const renderWalletList = jest.fn();
deleteWallet.init({ renderWalletList });
return { helpers, state, vault, deleteWallet, storage, sent };
}
function click(id) {
const el = globalThis.document.getElementById(id);
return Promise.all((el.listeners.click || []).map((fn) => fn()));
}
function node(id) {
return globalThis.document.getElementById(id);
}
// The wallets as the extension would read them back on a cold start.
async function persistedWallets(storage) {
const result = await storage.get("autistmask");
return result.autistmask.wallets;
}
// Open the lost-password screen for a wallet, the way the user does.
async function openLostPassword(deleteWallet, walletIdx) {
deleteWallet.show(walletIdx);
await click("btn-delete-wallet-lost-password");
}
// ------------------------------------------------------------ tests
// The stub is what every persistence assertion below rests on, so its one
// dangerous failure mode is closed off first. An aliasing store passes
// 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 written = { wallets: [{ name: "Wallet 1" }] };
await storage.set({ autistmask: written });
written.wallets.push({ name: "Wallet 2" });
written.wallets[0].name = "renamed after the write";
const readBack = (await storage.get("autistmask")).autistmask;
expect(readBack.wallets).toHaveLength(1);
expect(readBack.wallets[0].name).toBe("Wallet 1");
// And the other direction: mutating what came out must not reach
// back into the store.
readBack.wallets[0].name = "renamed after the read";
const again = (await storage.get("autistmask")).autistmask;
expect(again.wallets[0].name).toBe("Wallet 1");
});
});
describe("reaching the screen", () => {
test("the delete screen offers the route", async () => {
const { deleteWallet, state } = load();
await openLostPassword(deleteWallet, 1);
expect(state.currentView).toBe(VIEW);
expect(node("delete-wallet-lost-name").textContent).toBe("Wallet 2");
expect(node("delete-wallet-lost-name-echo").textContent).toBe(
"Wallet 2",
);
});
// Both delete screens hang off Settings. Pushing one onto the other
// would leave Back on the confirm screen popping onto itself.
test("it does not push the screen it came from", async () => {
const { deleteWallet, state } = load();
await openLostPassword(deleteWallet, 1);
expect(state.viewStack).toEqual(["main", "settings"]);
});
test("Back returns to the delete screen with its wallet still chosen", async () => {
const { deleteWallet, state } = load();
await openLostPassword(deleteWallet, 1);
await click("btn-delete-wallet-lost-back");
expect(state.currentView).toBe("delete-wallet-confirm");
expect(node("delete-wallet-name").textContent).toBe("Wallet 2");
expect(state.viewStack).toEqual(["main", "settings"]);
// The confirm screen is usable, not merely on screen: the wallet
// it holds is the one that was chosen, so its own button does not
// answer "No wallet selected for deletion."
node("delete-wallet-password").value = "some password";
const { decryptWithPassword } = require("../src/shared/vault");
decryptWithPassword.mockRejectedValue(new Error("nope"));
await click("btn-delete-wallet-confirm");
expect(node("delete-wallet-flash").textContent).toBe(
"That password is incorrect. Please try again.",
);
});
});
describe("the typed confirmation", () => {
test("a name that is not the wallet's deletes nothing", async () => {
const { deleteWallet, state, storage } = load();
await openLostPassword(deleteWallet, 1);
node("delete-wallet-lost-name-input").value = "Wallet 3";
await click("btn-delete-wallet-lost-confirm");
expect(node("delete-wallet-lost-flash").textContent).toBe(
"That is not the name of this wallet. Type Wallet 2 to confirm.",
);
expect(node("delete-wallet-lost-flash").style.visibility).toBe(
"visible",
);
expect(state.wallets.map((w) => w.name)).toEqual([
"Wallet 1",
"Wallet 2",
"Wallet 3",
]);
expect(state.currentView).toBe(VIEW);
// Nothing was destroyed on disk either. Storage is not empty —
// showView() persists the current screen on the way in — so what
// is asserted is that all three wallets are still in it.
const persisted = await persistedWallets(storage);
expect(persisted.map((w) => w.encryptedSecret)).toEqual([
"secret-one",
"secret-two",
"secret-three",
]);
});
test("an empty field deletes nothing", async () => {
const { deleteWallet, state } = load();
await openLostPassword(deleteWallet, 1);
await click("btn-delete-wallet-lost-confirm");
expect(node("delete-wallet-lost-flash").style.visibility).toBe(
"visible",
);
expect(state.wallets).toHaveLength(3);
});
// Not a secret and not a password: it asks whether the user knows
// which wallet they are on. Refusing the name they can plainly read,
// over letter case, would only teach them to distrust the control.
test("case and surrounding spaces do not matter", async () => {
const { deleteWallet, state, storage } = load();
await openLostPassword(deleteWallet, 1);
node("delete-wallet-lost-name-input").value = " wALLet 2 ";
await click("btn-delete-wallet-lost-confirm");
expect(state.wallets.map((w) => w.name)).toEqual([
"Wallet 1",
"Wallet 3",
]);
expect(await persistedWallets(storage)).toHaveLength(2);
});
// A name with a doubled inner space RENDERS with one — HTML collapses
// runs of whitespace — so the string the user can see and type is not
// the string the name is stored as. Comparing the two raw would make
// this wallet's confirmation impossible to satisfy by any typing at
// all, wedging the one screen that exists to unwedge people.
test("a doubled space inside the name is typed back as one", async () => {
const { deleteWallet, state, storage } = load();
state.wallets[1].name = "My Wallet";
await openLostPassword(deleteWallet, 1);
// What the DOM was handed still has both spaces; what the user
// reads off the screen, and therefore types, has one.
expect(node("delete-wallet-lost-name").textContent).toBe("My Wallet");
node("delete-wallet-lost-name-input").value = "My Wallet";
await click("btn-delete-wallet-lost-confirm");
expect(state.wallets.map((w) => w.name)).toEqual([
"Wallet 1",
"Wallet 3",
]);
const persisted = await persistedWallets(storage);
expect(persisted.map((w) => w.encryptedSecret)).toEqual([
"secret-one",
"secret-three",
]);
});
});
describe("deleting without the password", () => {
test("no password is asked for and none is checked", async () => {
const { deleteWallet, vault, storage } = load();
await openLostPassword(deleteWallet, 1);
node("delete-wallet-lost-name-input").value = "Wallet 2";
await click("btn-delete-wallet-lost-confirm");
expect(vault.decryptWithPassword).not.toHaveBeenCalled();
expect(await persistedWallets(storage)).toHaveLength(2);
});
// The load-bearing assertion of the whole file, and the one that says
// this control is safe to give a user who cannot prove anything: it
// removes the wallet it named, and every other wallet survives intact,
// key material included.
test("exactly the named wallet is destroyed", async () => {
const { deleteWallet, storage } = load();
await openLostPassword(deleteWallet, 1);
node("delete-wallet-lost-name-input").value = "Wallet 2";
await click("btn-delete-wallet-lost-confirm");
const wallets = await persistedWallets(storage);
expect(wallets.map((w) => w.name)).toEqual(["Wallet 1", "Wallet 3"]);
expect(wallets.map((w) => w.encryptedSecret)).toEqual([
"secret-one",
"secret-three",
]);
expect(wallets.map((w) => w.xpub)).toEqual([
"xpub-Wallet 1",
"xpub-Wallet 3",
]);
expect(wallets[0].addresses.map((a) => a.address)).toEqual([A0, A1]);
expect(wallets[1].addresses.map((a) => a.address)).toEqual([C0]);
// 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");
});
test("only the deleted wallet's site permissions are dropped", async () => {
const { deleteWallet, storage } = load();
await openLostPassword(deleteWallet, 1);
node("delete-wallet-lost-name-input").value = "Wallet 2";
await click("btn-delete-wallet-lost-confirm");
const saved = (await storage.get("autistmask")).autistmask;
expect(saved.allowedSites).toEqual({ [A0]: ["a.example"] });
expect(saved.deniedSites).toEqual({ [C0]: ["d.example"] });
});
// The route shares finishDelete() with the password route, so the
// selection repair and the accountsChanged broadcast are the same on
// both. Deleting a wallet that did not own the active address must
// leave that address, and the selection, exactly where they were.
test("a selection in another wallet is left alone", async () => {
const { deleteWallet, storage, sent } = load();
await openLostPassword(deleteWallet, 1);
node("delete-wallet-lost-name-input").value = "Wallet 2";
await click("btn-delete-wallet-lost-confirm");
const saved = (await storage.get("autistmask")).autistmask;
expect(saved.activeAddress).toBe(A0);
expect(saved.selectedWallet).toBe(0);
expect(saved.selectedAddress).toBe(0);
expect(sent).toEqual([]);
// Settings is stubbed, so this is where the route hands over, not
// where it renders.
expect(mockSettingsShow).toHaveBeenCalled();
});
test("deleting the wallet holding the active address moves it and says so", async () => {
const { deleteWallet, storage, sent } = load();
await openLostPassword(deleteWallet, 0);
node("delete-wallet-lost-name-input").value = "Wallet 1";
await click("btn-delete-wallet-lost-confirm");
const saved = (await storage.get("autistmask")).autistmask;
expect(saved.wallets.map((w) => w.name)).toEqual([
"Wallet 2",
"Wallet 3",
]);
expect(saved.activeAddress).toBe(B0);
expect(sent).toEqual([{ type: "AUTISTMASK_ACTIVE_CHANGED" }]);
});
test("deleting the last wallet lands on Welcome with nothing left", async () => {
const { deleteWallet, state, storage } = load();
state.wallets = [wallet("Wallet 1", "secret-one", [A0])];
state.allowedSites = { [A0]: ["a.example"] };
state.deniedSites = {};
await openLostPassword(deleteWallet, 0);
node("delete-wallet-lost-name-input").value = "Wallet 1";
await click("btn-delete-wallet-lost-confirm");
const saved = (await storage.get("autistmask")).autistmask;
expect(saved.wallets).toEqual([]);
expect(saved.hasWallet).toBe(false);
expect(saved.activeAddress).toBeNull();
expect(saved.allowedSites).toEqual({});
expect(state.currentView).toBe("welcome");
expect(JSON.stringify(storage.read())).not.toContain("secret-one");
});
});
describe("what the screen leaves behind", () => {
test("the typed confirmation is wiped when the screen is left", async () => {
const { helpers, deleteWallet } = load();
await openLostPassword(deleteWallet, 1);
node("delete-wallet-lost-name-input").value = "Wallet 2";
// The Settings gear, which is not this screen's Back button.
helpers.showView("settings");
expect(node("delete-wallet-lost-name-input").value).toBe("");
expect(node("delete-wallet-lost-flash").textContent).toBe("");
expect(node("delete-wallet-lost-flash").style.visibility).toBe(
"hidden",
);
});
// Left mid-delete, the screen has to come back usable.
test("the confirm button is re-enabled on the way out", async () => {
const { helpers, deleteWallet } = load();
await openLostPassword(deleteWallet, 1);
node("btn-delete-wallet-lost-confirm").disabled = true;
helpers.showView("settings");
expect(node("btn-delete-wallet-lost-confirm").disabled).toBe(false);
});
// A wallet name is not a secret, so the screen is excluded for the
// other reason: reopening the popup must not land the user on a screen
// whose button erases key material.
test("the popup may not reopen onto it", () => {
expect(RESTORABLE_VIEWS.has(VIEW)).toBe(false);
expect(RESTORABLE_VIEWS.has("delete-wallet-confirm")).toBe(false);
});
});

View File

@@ -10,6 +10,7 @@
// happened. // happened.
const { networkById } = require("../src/shared/networks"); const { networkById } = require("../src/shared/networks");
const { makeStorageStub } = require("./support/storageStub");
const ADDRESS = "0x66133E8ea0f5D1d612D2502a968757D1048c214a"; const ADDRESS = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
@@ -34,25 +35,19 @@ function walletFixture() {
// saveState() wrote — so a case can reload a fresh module from the bytes an // 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 // 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. // 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) { function loadModuleWith(persisted) {
jest.resetModules(); jest.resetModules();
let written = null; const storage = makeStorageStub(persisted ? { autistmask: persisted } : {});
global.chrome = { global.chrome = { storage };
storage: {
local: {
get: jest.fn(async () =>
persisted ? { autistmask: persisted } : {},
),
set: jest.fn(async (items) => {
written = items.autistmask;
}),
},
},
};
return { return {
mod: require("../src/shared/state"), mod: require("../src/shared/state"),
chainSwitch: require("../src/shared/chainSwitch"), chainSwitch: require("../src/shared/chainSwitch"),
written: () => written, written: () => storage.read("autistmask"),
}; };
} }

View File

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

View File

@@ -4,23 +4,24 @@ function oneWallet() {
return [{ name: "Wallet 1", type: "hd", addresses: [ADDRESS] }]; 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 // 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 // before the module is loaded, and the module registry has to be reset between
// cases because `state` is a module-level singleton. // 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) { function loadModuleWith(persisted) {
jest.resetModules(); jest.resetModules();
const set = jest.fn(async () => {}); const storage = makeStorageStub(persisted ? { autistmask: persisted } : {});
global.chrome = { global.chrome = { storage };
storage: { return {
local: { mod: require("../src/shared/state"),
get: jest.fn(async () => set: storage.set,
persisted ? { autistmask: persisted } : {}, stored: () => storage.read("autistmask"),
),
set,
},
},
}; };
return { mod: require("../src/shared/state"), set };
} }
afterEach(() => { afterEach(() => {

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

@@ -0,0 +1,423 @@
// saveState() used to write the entire state blob every time
// (src/shared/state.js). Every extension page — the toolbar popup, a dApp
// approval window opened by the background, backgroundRefresh() in
// src/background/index.js — holds its own in-memory `state`, loaded once,
// and src/popup/views/helpers.js showView() saves on EVERY navigation. So
// any second page that saved after a first page had written something new
// overwrote it, with no attacker and no unusual input: a whole wallet, name,
// addresses and encrypted secret included, silently gone
// (https://git.eeqj.de/sneak/AutistMask/issues/304).
//
// 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).
const { makeStorageStub } = require("./support/storageStub");
// One extension page: a fresh module registry over the shared storage.
// state.js resolves the storage API at require time, so the stub has to be
// installed before the module is loaded, and `state` is a module-level
// singleton, so each page needs its own registry to hold its own copy.
function loadPage(storage) {
jest.resetModules();
globalThis.chrome = { storage: { local: storage.local } };
return {
state: require("../src/shared/state"),
helpers: require("../src/popup/views/helpers"),
};
}
function wallet(name, secret, address) {
return {
type: "hd",
name,
xpub: "xpub-" + name,
encryptedSecret: secret,
nextIndex: 1,
addresses: [{ address, balance: "0", tokenBalances: [] }],
};
}
const W1 = wallet(
"Wallet 1",
"secret-one",
"0x66133E8ea0f5D1d612D2502a968757D1048c214a",
);
const W2 = wallet(
"Wallet 2",
"secret-two",
"0xdAC17F958D2ee523a2206206994597C13D831ec7",
);
// Minimal DOM: showView() toggles view elements, clears the flash line and
// creates/removes the debug banner. Nothing here is asserted; it only has to
// answer without throwing, the way the popup's own index.html would.
function makeElement(id) {
const classes = new Set();
return {
id,
textContent: "",
style: {},
classList: {
add: (...n) => n.forEach((c) => classes.add(c)),
remove: (...n) => n.forEach((c) => classes.delete(c)),
toggle: (c, force) => {
const on = force === undefined ? !classes.has(c) : force;
if (on) classes.add(c);
else classes.delete(c);
return on;
},
},
remove: () => {},
};
}
function makeDocument() {
const els = new Map();
return {
getElementById(id) {
if (id === "debug-banner") return null;
if (!els.has(id)) els.set(id, makeElement(id));
return els.get(id);
},
createElement: () => makeElement("created"),
body: { prepend: () => {} },
};
}
afterEach(() => {
delete globalThis.chrome;
delete globalThis.document;
});
describe("a save from a page that never saw a wallet another page added", () => {
// The first DoD case on the issue: add a wallet in one page, then force
// 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();
await storage.set({ autistmask: { wallets: [W1] } });
// Loaded while storage held only Wallet 1, and never reloads —
// the approval window in the reproduction, or a second popup that
// has been open for a while.
const stale = loadPage(storage);
await stale.state.loadState();
expect(stale.state.state.wallets).toHaveLength(1);
// A second page, loaded after, adds a wallet — the exact sequence
// src/popup/views/addWallet.js uses.
const fresh = loadPage(storage);
await fresh.state.loadState();
fresh.state.state.wallets.push(W2);
fresh.state.state.hasWallet = true;
await fresh.state.saveState();
expect(
(await storage.get("autistmask")).autistmask.wallets,
).toHaveLength(2);
// The stale page saves something that has nothing to do with
// wallets — exactly what showView() does on every navigation, and
// what backgroundRefresh() does after a balance poll.
stale.state.state.currentView = "settings";
await stale.state.saveState();
const persisted = (await storage.get("autistmask")).autistmask;
expect(persisted.wallets.map((w) => w.name)).toEqual([
"Wallet 1",
"Wallet 2",
]);
expect(persisted.wallets.map((w) => w.encryptedSecret)).toEqual([
"secret-one",
"secret-two",
]);
});
});
describe("the approval-window reproduction", () => {
// approval window open, add a wallet in the popup, confirm the approval
// — the exact sequence from the issue. The approval window and the
// popup are the same popup code with a different starting view, so
// showView() is the real save path in both: src/popup/views/approval.js
// showTxApproval() calls showView("approve-tx") when the window opens,
// and a successful confirm calls
// src/popup/views/txStatus.js showWait() -> startWait(), which calls
// showView("wait-tx") — the save that clobbered the second wallet in
// the reproduction on the issue.
test("the wallet added in the popup survives confirming the approval", async () => {
globalThis.document = makeDocument();
const storage = makeStorageStub();
await storage.set({ autistmask: { wallets: [W1] } });
// The background opens the approval window on the approve-tx
// screen; nothing else has happened yet.
const approvalWindow = loadPage(storage);
await approvalWindow.state.loadState();
approvalWindow.helpers.showView("approve-tx");
// showView() does not await its own saveState(); an extra save
// joins the same queue and only resolves once that one has too,
// which is the black-box way to know it landed.
await approvalWindow.state.saveState();
// The user adds a wallet in the popup — a separate page, loaded
// after the approval window.
const popup = loadPage(storage);
await popup.state.loadState();
popup.state.state.wallets.push(W2);
popup.state.state.hasWallet = true;
await popup.state.saveState();
expect(
(await storage.get("autistmask")).autistmask.wallets,
).toHaveLength(2);
// The user confirms the approval. The approval window navigates
// approve-tx -> wait-tx, saving again from state it loaded before
// Wallet 2 ever existed.
approvalWindow.helpers.showView("wait-tx");
await approvalWindow.state.saveState();
const persisted = (await storage.get("autistmask")).autistmask;
expect(persisted.wallets.map((w) => w.name)).toEqual([
"Wallet 1",
"Wallet 2",
]);
expect(persisted.wallets.map((w) => w.encryptedSecret)).toEqual([
"secret-one",
"secret-two",
]);
});
});
// backgroundRefresh() (src/background/index.js) loads state, spends seconds
// on network I/O in refreshBalances() (src/shared/balances.js) mutating
// addr.balance/ensName/tokenBalances IN PLACE on the wallets it already
// knew about, then saves. Precondition 2 on the issue: that refresh window
// overlapping a membership change (add or delete) on another page must not
// clobber or resurrect a wallet — a whole-field diff on `wallets` failed
// this, because "background changed a balance" and "another page changed
// 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();
await storage.set({ autistmask: { wallets: [W1] } });
// "background": loads first, and its save is the one that lands
// last, modeling the multi-second network round trip in between.
const background = loadPage(storage);
await background.state.loadState();
background.state.state.wallets[0].addresses[0].balance = "1.2345";
// A second page, loaded after, adds a wallet while background's
// refresh is still in flight.
const popup = loadPage(storage);
await popup.state.loadState();
popup.state.state.wallets.push(W2);
popup.state.state.hasWallet = true;
await popup.state.saveState();
expect(
(await storage.get("autistmask")).autistmask.wallets,
).toHaveLength(2);
// background's save lands last, carrying only its balance update.
await background.state.saveState();
const persisted = (await storage.get("autistmask")).autistmask;
expect(persisted.wallets.map((w) => w.name)).toEqual([
"Wallet 1",
"Wallet 2",
]);
expect(persisted.wallets.map((w) => w.encryptedSecret)).toEqual([
"secret-one",
"secret-two",
]);
// The balance update itself must not be lost either — this is a
// merge, not deletion-always-wins.
expect(persisted.wallets[0].addresses[0].balance).toBe("1.2345");
});
});
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();
await storage.set({ autistmask: { wallets: [W1, W2] } });
const background = loadPage(storage);
await background.state.loadState();
background.state.state.wallets[0].addresses[0].balance = "1.2345";
// A second page deletes Wallet 2 while background's refresh is in
// flight — the same splice deleteWallet.js's removeWalletFromState()
// does.
const popup = loadPage(storage);
await popup.state.loadState();
popup.state.state.wallets.splice(1, 1);
popup.state.state.hasWallet = popup.state.state.wallets.length > 0;
await popup.state.saveState();
expect(
(await storage.get("autistmask")).autistmask.wallets,
).toHaveLength(1);
await background.state.saveState();
const persisted = (await storage.get("autistmask")).autistmask;
expect(persisted.wallets.map((w) => w.name)).toEqual(["Wallet 1"]);
expect(persisted.wallets[0].addresses[0].balance).toBe("1.2345");
});
});
// allowedSites/deniedSites: { [address]: [hostname, ...] }. Mutated in place
// from two different contexts — src/background/index.js:592-599 pushes a
// newly approved hostname onto state.allowedSites[activeAddress], and the
// Settings "revoke" button (src/popup/views/settings.js:55-68) filters a
// hostname out of state[key][addr] in place, deleting the address key
// entirely once its list is empty — the exact membership-vs-whole-field
// pattern that made the whole-field `wallets` diff unsafe, on a
// security-relevant field: a stale whole-field save here can resurrect a
// revoked permission or wipe a freshly granted one.
const ADDR1 = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
const ADDR2 = "0xdAC17F958D2ee523a2206206994597C13D831ec7";
function approveSite(pageState, address, hostname) {
if (!pageState.allowedSites[address]) {
pageState.allowedSites[address] = [];
}
if (!pageState.allowedSites[address].includes(hostname)) {
pageState.allowedSites[address].push(hostname);
}
}
function revokeSite(pageState, hostname) {
for (const addr of Object.keys(pageState.allowedSites)) {
pageState.allowedSites[addr] = pageState.allowedSites[addr].filter(
(h) => h !== hostname,
);
if (pageState.allowedSites[addr].length === 0) {
delete pageState.allowedSites[addr];
}
}
}
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();
await storage.set({
autistmask: {
wallets: [W1],
allowedSites: { [ADDR2]: ["other.example"] },
},
});
// Settings loads first, and its save lands last — before either has
// any idea a dApp approval happened elsewhere in between.
const settings = loadPage(storage);
await settings.state.loadState();
// A dApp approval window, opened later, approves a new site for a
// different address and saves — the real sequence at
// src/background/index.js:592-599.
const approval = loadPage(storage);
await approval.state.loadState();
approveSite(approval.state.state, ADDR1, "dapp.example");
await approval.state.saveState();
expect(
(await storage.get("autistmask")).autistmask.allowedSites[ADDR1],
).toEqual(["dapp.example"]);
// Settings revokes its own, unrelated site — the real sequence at
// src/popup/views/settings.js:55-68 — and saves from state loaded
// before the dApp approval ever happened.
revokeSite(settings.state.state, "other.example");
await settings.state.saveState();
const persisted = (await storage.get("autistmask")).autistmask;
expect(persisted.allowedSites[ADDR1]).toEqual(["dapp.example"]);
expect(persisted.allowedSites[ADDR2]).toBeUndefined();
});
});
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();
await storage.set({
autistmask: {
wallets: [W1],
allowedSites: { [ADDR1]: ["evil.example"] },
},
});
// A stale page loads while the permission still stands.
const stale = loadPage(storage);
await stale.state.loadState();
// Settings revokes it — src/popup/views/settings.js:55-68 — from a
// second page.
const settings = loadPage(storage);
await settings.state.loadState();
revokeSite(settings.state.state, "evil.example");
await settings.state.saveState();
expect(
(await storage.get("autistmask")).autistmask.allowedSites[ADDR1],
).toBeUndefined();
// The stale page, unaware of the revoke, approves an unrelated site
// for a different address and saves — src/background/index.js:592-599.
approveSite(stale.state.state, ADDR2, "good.example");
await stale.state.saveState();
const persisted = (await storage.get("autistmask")).autistmask;
expect(persisted.allowedSites[ADDR2]).toEqual(["good.example"]);
expect(persisted.allowedSites[ADDR1]).toBeUndefined();
});
});
// mergeListByIdentity()'s identity function is not guaranteed collision-free
// — walletIdentity() falls back to one shared "addr:" value for any wallet
// with neither an xpub nor a populated first address (a legacy or corrupt
// record). Two such records created independently on two different pages
// must not silently collapse into one, dropping the loser's
// encryptedSecret with no error and no log.
function legacyWallet(name, secret) {
return {
type: "legacy",
name,
encryptedSecret: secret,
nextIndex: 0,
addresses: [],
};
}
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();
await storage.set({ autistmask: { wallets: [W1] } });
// Both pages load before either has created their malformed wallet,
// so neither has baseline knowledge of the other's.
const pageA = loadPage(storage);
await pageA.state.loadState();
const pageB = loadPage(storage);
await pageB.state.loadState();
pageA.state.state.wallets.push(legacyWallet("Legacy A", "secret-a"));
pageA.state.state.hasWallet = true;
await pageA.state.saveState();
expect(
(await storage.get("autistmask")).autistmask.wallets,
).toHaveLength(2);
pageB.state.state.wallets.push(legacyWallet("Legacy B", "secret-b"));
pageB.state.state.hasWallet = true;
await pageB.state.saveState();
const persisted = (await storage.get("autistmask")).autistmask;
const secrets = persisted.wallets.map((w) => w.encryptedSecret);
expect(secrets).toContain("secret-one");
expect(secrets).toContain("secret-a");
expect(secrets).toContain("secret-b");
});
});

View File

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

View File

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

View File

@@ -30,8 +30,11 @@ global.fetch = jest.fn(() => {
}); });
// state.js reads chrome.storage.local at module load; stub it so the // state.js reads chrome.storage.local at module load; stub it so the
// default settings can be asserted against what the README promises. // default settings can be asserted against what the README promises. Empty
global.chrome = { storage: { local: {} } }; // storage, so a load produces exactly the defaults.
const { makeStorageStub } = require("./support/storageStub");
global.chrome = { storage: makeStorageStub() };
const { const {
fetchRecentTransactions, fetchRecentTransactions,
@@ -745,6 +748,16 @@ describe("dust threshold filtering", () => {
}); });
describe("filter defaults promised by the README and Settings", () => { 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", () => { test("all four toggles default to on and the threshold to 100,000 gwei", () => {
expect(state.hideSpoofedSymbols).toBe(true); expect(state.hideSpoofedSymbols).toBe(true);
expect(state.hideLowHolderTokens).toBe(true); expect(state.hideLowHolderTokens).toBe(true);

View File

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