Compare commits

..

1 Commits

Author SHA1 Message Date
clawbot
cafffe5ab9 fix: drive background refresh and phishing update from alarms (closes #158)
All checks were successful
check / check (push) Successful in 30s
The Chrome MV3 service worker is terminated after roughly 30 seconds idle,
which destroyed both recurring jobs: the 60-second balance refresh and the
24-hour phishing blocklist refresh were setInterval schedules, so in
practice each ran only while the worker happened to be alive. The phishing
delta was persisted to localStorage, which does not exist in a service
worker, so on Chrome it was never persisted at all.

Both jobs now run off the extension alarms API in the new
src/shared/alarms.js: the browser holds the schedule and wakes the worker to
deliver it. The balance refresh is one minute and the phishing refresh is
1440 minutes, both whole minutes at or above the one-minute minimum, so
neither is silently clamped. Alarms are created only when missing, because
creating one restarts its period and the startup path runs on every wake.

The phishing delta and the timestamp of the fetch that produced it now live
in extension storage, and updatePhishingList() reloads that record before
deciding whether a fetch is due. A revived worker therefore neither
re-fetches on every wake nor sleeps through an overdue update. The 256 KiB
cap covers the whole record: an oversized delta is dropped together with its
timestamp so the next start fetches again.

The startup path (ensureRecurringAlarms plus the phishing list init) is
registered on onInstalled and onStartup as well as running at the top level
of the worker, and is idempotent.

Firefox MV2 has a persistent background page where timers would have
survived, but both browsers are built from one bundle and both take the
alarm path, so there is a single code path; "alarms" is declared in both
manifests.

src/shared/ens.js keeps its localStorage cache and gains a comment recording
that it is popup-only, so it does not get pulled into the worker later.
2026-08-11 12:27:08 +00:00
20 changed files with 332 additions and 2414 deletions

View File

@@ -1,6 +1,3 @@
# .git is deliberately NOT excluded: build.js shells out to `git rev-parse` for
# build-info stamping and the Dockerfile runs `make build`, so excluding it
# would make every built extension report commitHash "unknown".
node_modules
.DS_Store
dist

View File

@@ -11,7 +11,7 @@ setup:
@script/setup
install:
@yarn install --frozen-lockfile
@yarn install
test:
@script/test

534
README.md
View File

@@ -31,13 +31,10 @@ list exists to detect symbol spoofing attacks and improve UX.
```bash
git clone https://git.eeqj.de/sneak/autistmask.git
cd autistmask
make setup
make install
make build
```
`make setup` is the entrypoint for a fresh clone: it installs dependencies from
the lockfile and installs the git pre-commit hook.
Load the extension:
- **Chrome**: Navigate to `chrome://extensions/`, enable "Developer mode", click
@@ -100,19 +97,6 @@ provide:
- `script/precommit` — run by the git pre-commit hook; runs `script/check`
- `script/install-precommit` — install the git pre-commit hook
The Makefile shims to those. It also carries a few targets that have no
`script/` counterpart and are Makefile-only conveniences:
- `make install``yarn install --frozen-lockfile` on its own, without the rest
of `script/bootstrap`. Frozen so a stale `yarn.lock` fails instead of being
silently rewritten. Use `make setup` for a fresh clone.
- `make hooks` — shims to `script/install-precommit`
- `make build` — build the extension into `dist/chrome/` and `dist/firefox/`
- `make build-debug` — the same build with `AUTISTMASK_DEBUG=1` (see
[Debug Builds](#debug-builds))
- `make clean` — remove `dist/`
- `make dev` — build in watch mode
## End-to-End Tests
`make test-e2e` builds `dist/chrome/` and drives the **real popup in a real
@@ -248,46 +232,11 @@ on the next event. Two consequences shape every recurring job in the background:
remaining user of it, `src/shared/ens.js`, runs only in the popup and is
marked as such.
Both jobs also carry a freshness guard, and a guard must never be timed to the
alarm period it gates. Each guard is measured from the moment the last run
finished, which is one run-duration after the alarm that started it, so a guard
of exactly one period vetoes the very next tick and the real cadence becomes two
periods. The two jobs solve this differently, because their guards exist for
different reasons:
- The phishing refresh has a 24-hour cache TTL whose job is to keep the worker
off the network on the wakes between scheduled refreshes — Chrome revives the
worker every ~30 seconds while the browser is busy, and every revival runs the
startup path. The scheduled alarm tick is not one of those wakes, so it
bypasses the TTL and fetches unconditionally. Shortening the TTL instead would
not work: the startup path re-checks it on every wake, so a shorter TTL simply
becomes the real refresh rate.
- The balance refresh guard exists to skip work an open popup has already done —
the popup refreshes every 10 seconds and stamps the same field. That has to
keep applying on the scheduled tick, so the guard is shortened to half the
alarm period instead of bypassed: comfortably above the popup's 10 seconds, so
an open popup still suppresses the background job, and comfortably below the
60-second period, so the schedule always wins.
Two timestamps are persisted for the phishing list, not one. `lastFetchTime`
records a fetch that produced a usable delta and drives the TTL.
`lastAttemptTime` records that the network was contacted at all, and is written
even when the result is unusable — a failed request, or a delta over the 256 KiB
cap. Without it those cases leave no freshness mark and the worker re-downloads
the full blocklist on every wake, indefinitely; with it, unscheduled retries are
floored at one hour. Both are discarded on load if they are in the future, since
a stamp from a skewed clock or a restored backup would otherwise suppress
updates until that time arrives, permanently and with no way out.
The startup path (`ensureRecurringAlarms()` plus the phishing list init) runs on
`onInstalled`, on `onStartup`, and at the top level of the worker, so every way
the background context can start re-establishes the schedule. On a fresh install
more than one of those fires, so they share a single in-flight run rather than
racing. It is idempotent: an alarm that already exists with the period the code
asks for is left alone, because re-creating one restarts its schedule and a busy
extension would push the next fire out indefinitely. An alarm carrying a
different period — one created by an earlier version — is re-created once, or a
period changed in a new release would never reach an existing install.
the background context can start re-establishes the schedule. It is idempotent:
an alarm that already exists is left alone, because re-creating one restarts its
period and a busy extension would push the next fire out indefinitely.
Firefox uses Manifest V2 with a persistent background page, where timers would
survive. Both browsers are built from one bundle and both take the alarm path,
@@ -357,10 +306,10 @@ on a different table knows exactly tf I am talking about.
Every interactive element must visually indicate that it is clickable. Buttons
use a visible border, padding, and a hover state (invert to white-on-black).
Text that triggers an action (e.g. "Add additional wallet...") uses an
underline. No invisible hit targets, no bare text that happens to have a click
handler. If it does something when you click it, it must look like it does
something when you click it.
Text that triggers an action (e.g. "Import private key") uses an underline. No
invisible hit targets, no bare text that happens to have a click handler. If it
does something when you click it, it must look like it does something when you
click it.
#### Display Consistency
@@ -420,181 +369,115 @@ attack.
The core hierarchy is **Wallets → Addresses**:
- A **wallet** is one of three types:
- An **HD wallet** (`type: "hd"`, recovery phrase): generates multiple
addresses from a single 12/24 word recovery phrase using BIP-39/BIP-44
derivation. The user can add more addresses with a "+" button.
- A **key wallet** (`type: "key"`, private key): a single address imported
directly from a private key. No "+" button since there is only one
address.
- An **xprv wallet** (`type: "xprv"`, extended private key): the same
multi-address behavior as an HD wallet, including the "+" button and the
address scan on import, but imported from an extended private key rather
than a recovery phrase. It therefore has no recovery phrase to display or
back up.
- An **address** holds ETH and ERC-20 tokens.
- A **wallet** is either:
- An **HD wallet** (recovery phrase): generates multiple addresses from a
single 12/24 word recovery phrase using BIP-39/BIP-44 derivation. The user
can add more addresses with a "+" button.
- A **key wallet** (private key): a single address imported directly from a
private key. No "+" button since there is only one address.
- An **address** holds ETH and any user-added ERC-20 tokens.
- The user can have multiple wallets, each with multiple addresses (HD) or a
single address (key).
Which tokens an address shows is decided by `fetchTokenBalances()` in
`src/shared/balances.js`, from the Blockscout `token-balances` response, so
tokens do appear without the user adding them. An ERC-20 is shown when its
balance is nonzero and it is in the bundled top-250 token list, is tracked by
the user, or has 1,000 or more holders; a token claiming a symbol from the
bundled list from any other contract address is always dropped. That filter is
unconditional — the "Hide tokens with fewer than 1,000 holders" setting governs
the transaction history and the send-screen token selector, not this list.
Tracked tokens with a zero balance are listed as well while "Show tracked tokens
with zero balance" is on.
#### Navigation
The main view shows all addresses grouped by wallet, with ETH balances inline.
The user taps an address to see its detail view (full address, balance, tokens,
send/receive). Navigation is a stack: each forward action pushes the current
screen, and every view has a "Back" or "Cancel" button that pops back to it (see
the Screen Map below). There is no hamburger menu and no persistent tab bar; the
Settings gear in the title bar is the only global control. Two screens carry an
in-screen control beyond that: AddWallet uses three tabs to select the import
mode, and AddressDetail keeps its one rarely-used action ("Export Private Key")
behind a "···" menu.
send/receive). Navigation is flat — every view has a "Back" or "Cancel" button
that returns to the previous context. No deep nesting, no tabs, no hamburger
menus.
### Screen Map
Navigation uses a stack model (like iOS): each forward action pushes the current
screen onto `state.viewStack`, and "Back" pops it (`pushCurrentView()` and
`goBack()` in `src/popup/views/helpers.js`). The root screen is either Welcome
(no wallets) or Home (has wallets). Each screen below gives its view id in
parentheses; the registry of view ids is the `VIEWS` array in
`src/popup/views/helpers.js`, and the markup for a screen is the element with id
`view-` plus that view id in `src/popup/index.html`.
Navigation uses a stack model (like iOS): each action pushes a screen onto the
stack, and "Back" pops it. The root screen is either Welcome (no wallets) or
Home (has wallets). Screens are listed below with their elements and
transitions.
Three elements sit outside the screens and are present on all of them: the title
bar ("AutistMask by @sneak" plus the Settings gear), the flash message line
under it, and the red banner at the very top that appears on a debug build, when
runtime debug mode is on, or when the active network is a testnet. They are not
repeated in the element lists below.
#### Welcome
Closing and reopening the popup returns to the screen the user was last on only
for the views listed in `RESTORABLE_VIEWS` (`src/popup/index.js`). Every other
screen, including ExportPrivKey, falls back to Home.
#### Welcome (`welcome`)
- **When**: No wallets exist yet (`state.hasWallet` is false). This is the root
screen in that case.
- **Elements**:
- "Welcome! To get started, add a wallet." text
- "Add wallet" button
- **When**: No wallets exist yet.
- **Elements**: "AutistMask" heading, brief intro text, "Add wallet" button.
- **Transitions**:
- "Add wallet" → **AddWallet**
#### Home (`main`)
#### Home
- **When**: At least one wallet exists. This is the root screen.
- **Elements**:
- Active address ETH balance (large) + USD value in parentheses
- "Total:" USD value across ETH and every token shown for the active address
- Header: "AutistMask", Settings gear button
- Active address ETH balance (large) + USD value (inline parentheses)
- Total USD value across all tokens (small text)
- Active address (color dot, full address, etherscan link, tap to copy)
- Send / Receive quick-action buttons, both acting on the active address
- Send / Receive quick-action buttons
- ETH/USD price display
- Wallet list: each wallet shows its name (tap to rename inline) and a "+"
button for HD and xprv wallets, then one block per address with "Address
N" (bold when active), the ENS name if resolved, the full address, an
`[info]` button, the address USD total, and a balance line for ETH and for
each token shown for that address
- "Recent Transactions": up to 25 transactions merged across every address
of every wallet, deduplicated by hash and filtered
- Wallet list: each wallet shows name (tap to rename), "+" button (HD only),
and its addresses with color dots, balances, and `[info]` buttons
- Recent transactions across all addresses (merged, deduplicated, filtered)
- "Add additional wallet..." link at bottom
- **Transitions**:
- Tap address row → sets the active address and broadcasts
`AUTISTMASK_ACTIVE_CHANGED` (no screen change)
- Tap wallet name → inline rename field (no screen change)
- "+" on wallet → derives the next address inline (no screen change)
- Tap address row → sets active address (no screen change)
- `[info]` on address → **AddressDetail**
- "Send" → **Send** (refuses with a flash message on a zero balance)
- "Send" → **Send** (selects active address)
- "Receive" → **Receive** (shows active address QR)
- Tap home tx row → **TransactionDetail**
- "+" on wallet → derives next address inline
- "Add additional wallet..." → **AddWallet**
- Settings gear → **Settings** (toggles; tap again to return)
- Tap home tx row → **AddressDetail** (for the address involved)
#### AddWallet (`add-wallet`)
#### AddWallet
- **When**: User wants to add a new wallet (from Welcome, Home, or Settings).
This one screen covers all three import modes; there is no separate import
screen.
- **When**: User wants to add a new wallet (from Home, Welcome, or Settings).
- **Elements**:
- "Back" button, "Add Wallet" heading
- Three tabs — "From Phrase" (`tab-mnemonic`), "From Key" (`tab-privkey`),
"From xprv" (`tab-xprv`) — each showing its own form section:
- **From Phrase**: instruction text, a die button that generates a
random recovery phrase, a recovery phrase textarea, and a backup
warning box that becomes visible once the die button has been used
- **From Key**: instruction text and a masked private key input
- **From xprv**: instruction text and a masked extended private key
input
- Password + confirm password inputs, with a hint line whose wording depends
on the selected tab
- "Add Wallet" heading, "Back" button
- Instruction text
- Die button `[die]` (generates random recovery phrase)
- Recovery phrase textarea
- Backup warning box (shown after die is clicked)
- Password + confirm password inputs
- "Add" button
- "Have a private key instead?" link
- **Transitions**:
- "Add" (valid phrase + password) → **Home**
- "Back" → previous screen (Home or Welcome)
- "Have a private key instead?" → **ImportKey**
#### ImportKey
- **When**: User wants to import a single private key.
- **Elements**:
- "Import Private Key" heading, "Back" button
- Instruction text
- Private key input (password-masked)
- Password + confirm password inputs
- "Import" button
- **Transitions**:
- "Import" with a valid entry and a matching password of at least 12
characters → creates the wallet, clears the navigation stack, and →
**Home**. The phrase and xprv modes then scan for further used addresses
and report the count as a flash message.
- "Import" with an invalid entry, a duplicate wallet or address, or a short
or mismatched password → flash message, no screen change
- "Back" → previous screen (Welcome, Home, or Settings)
- "Import" (valid key + password) → **Home**
- "Back" → **AddWallet**
#### AddressDetail (`address`)
#### AddressDetail
- **When**: User tapped `[info]` on an address from Home.
- **Elements**:
- "Back" button
- Blockie identicon (48px, centered)
- Title: "Wallet Name — Address N"
- ENS name (if resolved, bold above the address)
- ENS name (if resolved, bold with color dot)
- Full address (color dot, etherscan link, tap to copy)
- USD total for address
- Balance list: ETH + the ERC-20 tokens shown for this address (4 decimal
places, USD inline). Each balance row is clickable → **AddressToken**
- Send / Receive / + Token buttons and a "···" menu button
- "···" dropdown containing a single "Export Private Key" entry
- Balance list: ETH + tracked ERC-20 tokens (4 decimal places, USD inline).
Each balance row is clickable → **AddressToken**
- Send / Receive / + Token buttons
- Transaction list (with ENS resolution for counterparties)
- **Transitions**:
- Tap balance row → **AddressToken** (for that token)
- "Send" → **Send** (refuses with a flash message on a zero balance)
- "Send" → **Send**
- "Receive" → **Receive**
- "+ Token" → **AddToken**
- "···" → "Export Private Key" → **ExportPrivKey**
- Tap transaction row → **TransactionDetail**
- "Back" → previous screen (Home)
- "Back" → **Home**
#### ExportPrivKey (`export-privkey`)
- **When**: User chose "Export Private Key" from the "···" menu on
AddressDetail. This screen discloses secret material.
- **Elements**:
- "Back" button
- Blockie identicon (48px, centered)
- "Export Private Key" heading
- "Wallet Name — Address N" and the full address (etherscan link, tap to
copy)
- Warning that anyone holding the private key can transfer all funds from
the address
- Error line
- Password input and "Reveal" button, shown until the key is revealed
- The private key on a highlighted background, tap to copy, shown only after
the password has been accepted
- **Transitions**:
- "Reveal" (correct password) → decrypts the wallet secret, derives this
address's key, hides the password input and shows the key (no screen
change)
- "Reveal" (wrong password) → "Wrong password." on the error line, nothing
revealed
- "Back" → clears the key and password from the DOM, then → previous screen
(AddressDetail)
#### AddressToken (`address-token`)
#### AddressToken
- **When**: User clicked a specific token balance on AddressDetail.
- **Elements**:
@@ -605,64 +488,49 @@ screen, including ExportPrivKey, falls back to Home.
- USD total for this token
- Single token balance line (4 decimal places)
- Send / Receive buttons
- Token contract well (ERC-20 only): full contract address (tap to copy,
etherscan link) plus name, symbol, decimals, holder count and project
website where known
- Token-filtered transaction list (only this token's transfers)
- **Transitions**:
- "Send" → **Send** (token locked: the dropdown is replaced by a static
symbol and contract address)
- "Send" → **Send** (token pre-selected and locked in dropdown)
- "Receive" → **Receive** (ERC-20 warning shown for non-ETH tokens)
- Tap transaction row → **TransactionDetail**
- "Back" → previous screen (AddressDetail)
- "Back" → **AddressDetail**
#### Send (`send`)
#### Send
- **When**: User wants to send ETH or a token, from Home, AddressDetail, or
AddressToken.
- **When**: User wants to send ETH or a token from this address.
- **Elements**:
- "Back" button, "Send" heading
- "Send" heading, "Back" button
- From: address with color dot + etherscan link
- What to send: token dropdown (or static display with contract address when
locked from AddressToken)
- To: address or ENS name input, with an inline validation message
- To: address or ENS name input
- Amount input with current balance display
- "Review" button, disabled until the recipient validates
- "Review" button
- **Transitions**:
- "Review" (valid inputs, ENS resolved) → **ConfirmTx**
- "Review" with an unresolvable ENS name or an invalid amount → flash
message, no screen change
- "Back" → previous screen (Home, AddressDetail, or AddressToken)
- "Back" → **AddressToken** (if came from token view) or **AddressDetail**
#### ConfirmTx (`confirm-tx`)
#### ConfirmTx
- **When**: User reviewed send details and is ready to authorize.
- **Elements**:
- "Back" button, "Confirm Transaction" heading
- "Confirm Transaction" heading, "Back" button
- Type: "Native ETH transfer" or "ERC-20 token transfer (SYMBOL)"
- Token contract: full address + etherscan link (ERC-20 only)
- From: blockie + color dot + full address + etherscan link + wallet title
- To: blockie + color dot + full address + etherscan link + ENS name
- Amount: value + symbol (USD in parentheses)
- Your balance: value + symbol (USD in parentheses)
- Estimated network fee: "Estimating..." then the ETH amount (USD in
parentheses) or "Unable to estimate", fetched async
- Warnings: inline warnings from the local checks (scam address, self-send)
plus four reserved warning boxes made visible by the async checks —
recipient with no transaction history, recipient is a contract, burn
address, and an Etherscan phishing/scam label
- Estimated network fee: ETH amount (USD in parentheses), fetched async
- Warnings (scam address, self-send)
- Errors (insufficient balance)
- Password: an inline field on this screen, not a modal, with its own error
line
- "Sign & Send" button (disabled if errors)
- "Send" button (disabled if errors)
- **Transitions**:
- "Sign & Send" (correct password) → broadcast tx → **WaitTx**
- "Sign & Send" (correct password) → broadcast fails → **ErrorTx**
- "Sign & Send" (wrong password) → "Wrong password." on the password error
line, no screen change
- "Send" → password modal → broadcast tx → **WaitTx**
- "Send" → password modal → broadcast fails → **ErrorTx**
- "Back" → **Send**
#### WaitTx (`wait-tx`)
#### WaitTx
- **When**: Transaction has been broadcast, waiting for on-chain confirmation.
- **Elements**:
@@ -676,24 +544,20 @@ screen, including ExportPrivKey, falls back to Home.
- Receipt found → **SuccessTx**
- 60 seconds without confirmation → **ErrorTx** (timeout message)
#### SuccessTx (`success-tx`)
#### SuccessTx
- **When**: Transaction confirmed on-chain.
- **Elements**:
- "Transaction Confirmed" heading
- Decoded action well (shown when the transaction carried recognized
calldata; the top-level Amount and To are hidden in that case)
- Amount + symbol
- To: color dot + full address + etherscan link
- Block number
- Transaction hash: full hash (tap to copy) + etherscan link
- "Done" button
- **Transitions**:
- "Done" in the approval popup → closes the popup window
- "Done" otherwise → resets the navigation stack, then → **AddressToken**
(if `selectedToken` set) or **AddressDetail**
- "Done" **AddressToken** (if `selectedToken` set) or **AddressDetail**
#### ErrorTx (`error-tx`)
#### ErrorTx
- **When**: Transaction broadcast failed, or timed out waiting for confirmation.
- **Elements**:
@@ -705,28 +569,24 @@ screen, including ExportPrivKey, falls back to Home.
full hash (tap to copy) + etherscan link
- "Done" button
- **Transitions**:
- "Done" in the approval popup → closes the popup window
- "Done" otherwise → resets the navigation stack, then → **AddressToken**
(if `selectedToken` set) or **AddressDetail**
- "Done" **AddressToken** (if `selectedToken` set) or **AddressDetail**
#### Receive (`receive`)
#### Receive
- **When**: User wants to receive funds at this address, from Home,
AddressDetail, or AddressToken.
- **When**: User wants to receive funds at this address.
- **Elements**:
- "Back" button, "Receive" heading
- "Receive" heading, "Back" button
- Instruction text
- QR code encoding the address
- Full address (color dot, selectable, etherscan link)
- "Copy address" button
- ERC-20 warning (shown when navigating from AddressToken for non-ETH token)
- **Transitions**:
- "Back" → previous screen (Home, AddressDetail, or AddressToken)
- "Back" → **AddressToken** (if `selectedToken` set) or **AddressDetail**
#### TransactionDetail (`transaction`)
#### TransactionDetail
- **When**: User tapped a transaction row on Home, AddressDetail, or
AddressToken.
- **When**: User tapped a transaction row from AddressDetail or AddressToken.
- **Elements** (grouped into logical blocks using light well containers; field
labels are self-explanatory so groups have no headings):
- "Transaction" heading, "Back" button
@@ -751,182 +611,91 @@ screen, including ExportPrivKey, falls back to Home.
- Raw data (shown when calldata is present): full calldata in monospace
dashed border
- **Transitions**:
- "Back" → previous screen (Home, AddressDetail, or AddressToken)
- "Back" → **AddressToken** (if `selectedToken` set) or **AddressDetail**
#### AddToken (`add-token`)
#### AddToken
- **When**: User wants to track an ERC-20 token, reached from "+ Token" on
AddressDetail.
- **When**: User wants to track an ERC-20 token on this address.
- **Elements**:
- "Back" button, "Add Token" heading
- "Add Token" heading, "Back" button
- Instruction text (find contract address on Etherscan)
- Contract address input
- Status line ("Looking up token...", cleared or replaced on failure)
- Common token quick-pick buttons (top 25 by market cap), which fill the
contract address input
- Token info preview (name, symbol — fetched from contract)
- Common token quick-pick buttons
- "Add" button
- **Transitions**:
- "Add" (valid contract) → tracks the token, pops the stack, and re-renders
**AddressDetail**
- "Add" with a token already tracked, a scam-listed address, or a failed
contract lookup → flash message, no screen change
- "Back" → previous screen (AddressDetail)
- "Add" (valid contract) → **AddressDetail**
- "Back" → **AddressDetail**
#### Settings (`settings`)
#### Settings
- **When**: User tapped the Settings gear.
- **When**: User tapped Settings gear from Home.
- **Elements**:
- "Back" button, "Settings" heading
- Wallets: one row per wallet with its name (tap to rename inline) and an
`[x]` delete button, plus a "+ Add wallet" button
- Tracked Tokens: one row per tracked token with an `[x]` remove button,
plus a "+ Add token" button
- Display: "Show tracked tokens with zero balance" checkbox and a Theme
selector (System / Light / Dark)
- Network: network selector (Ethereum Mainnet / Sepolia Testnet); switching
resets the RPC and Blockscout endpoints to that network's defaults
- Ethereum RPC: endpoint URL input + "Save" button (validated against
`eth_chainId` before being saved)
- Blockscout API: endpoint URL input + "Save" button (validated against
`/stats` before being saved)
- "Settings" heading, "Back" button
- Wallets: "+ Add wallet" button
- Display: "Show tracked tokens with zero balance" checkbox
- Ethereum RPC: endpoint URL input + "Save" button
- Blockscout API: endpoint URL input + "Save" button
- Token Spam Protection:
- "Hide tokens with fewer than 1,000 holders" checkbox
- "Hide transactions from detected fraud contracts" checkbox
- "Hide dust transactions below N gwei" checkbox + threshold input
- "UTC Timestamps" checkbox
- Allowed Sites: list with remove buttons
- Denied Sites: list with remove buttons
- About: project link, license, author, version, release date, and the
commit, which links to the commit in the repository
- Debug: hidden until revealed, then an "Enable debug mode" checkbox that
turns on the red banner and verbose logging
- **Transitions**:
- "+ Add wallet" → **AddWallet**
- "+ Add token" → **SettingsAddToken**
- `[x]` on a wallet → **DeleteWallet**
- Tap wallet name → inline rename field (no screen change)
- `[x]` on a tracked token or a site → removes it in place (no screen
change)
- Ten clicks on the version → reveals the Debug well (no screen change)
- "Back" (or Settings gear again) → previous screen (Home)
- "Back" (or Settings gear again) → **Home**
#### DeleteWallet (`delete-wallet-confirm`)
#### SiteApproval
- **When**: User tapped the `[x]` next to a wallet in Settings.
- **Elements**:
- "Back" button, "Delete Wallet" heading
- Warning naming the wallet and stating that deletion is permanent and any
funds are unrecoverable without the recovery phrase
- Error line
- Password input
- "Confirm Delete" button
- **Transitions**:
- "Confirm Delete" (correct password, other wallets remain) → deletes the
wallet and its site permissions, then → **Settings** with a "Wallet
deleted." flash message
- "Confirm Delete" (correct password, last wallet) → deletes the wallet,
clears the selection and the navigation stack, then → **Welcome**
- Either way, the active address moves only if it belonged to the deleted
wallet, and `AUTISTMASK_ACTIVE_CHANGED` is broadcast when it does
(`src/shared/walletDelete.js`)
- "Confirm Delete" (wrong password) → "Wrong password." on the error line,
nothing deleted
- "Back" → previous screen (Settings)
#### SettingsAddToken (`settings-addtoken`)
- **When**: User tapped "+ Add token" in Settings. Tokens added here are tracked
across every address, unlike AddToken which is reached from one address.
- **Elements**:
- "Back" button, "Add Token" heading
- Instruction text
- "Top tokens:" quick-pick buttons (top 10 by market cap; already-tracked
tokens are disabled)
- "Or pick from top 100:" dropdown (already-tracked tokens are disabled) +
"Add selected" button
- "Or enter contract address:" input, a status line, and an "Add" button
- **Transitions**:
- Any of the three add paths, on success → adds the token and shows an
"Added SYMBOL" flash message (no screen change)
- A duplicate, a scam-listed address, or a failed contract lookup → flash
message, no screen change
- "Back" → previous screen (Settings)
#### SiteApproval (`approve-site`)
- **When**: A website requests wallet access via `eth_requestAccounts` or
`wallet_requestPermissions` and is on neither the allowed nor the denied list.
The background script prefers the toolbar popup (`action.openPopup()`) and
falls back to a separate popup window (`src/background/index.js`,
`requestApproval()`).
- **When**: A website requests wallet access via `eth_requestAccounts`. Opened
in a separate popup by the background script.
- **Elements**:
- "Connection Request" heading
- Phishing warning banner (shown when the hostname is on the phishing
blocklist)
- Site hostname (bold) + "wants to connect to your wallet"
- Site hostname (bold)
- Address that will be shared (color dot + full address + etherscan link)
- "Remember my choice for this site" checkbox
- "Allow" / "Deny" buttons
- **Transitions**:
- "Allow" / "Deny" → closes popup (returns result to background script; the
choice is persisted to the allowed or denied list when "Remember" is
checked)
- Popup closed without answering → treated as a denial
- "Allow" / "Deny" → closes popup (returns result to background script)
#### TxApproval (`approve-tx`)
#### TxApproval
- **When**: A connected website requests a transaction via
`eth_sendTransaction`. Always opened in a separate popup window by the
background script (`windows.create()`), because the request is triggered
programmatically rather than by a user gesture.
`eth_sendTransaction`. Opened via the toolbar popup by the background script.
- **Elements**:
- "Transaction Request" heading
- Phishing warning banner (shown when the hostname is on the phishing
blocklist)
- Site hostname (bold) + "wants to send a transaction"
- Decoded action (if calldata is recognized): action name, token details,
amounts, steps, deadline (see Transaction Decoding)
- From: color dot + full address + etherscan link
- Contract: color dot + full address + etherscan link (or "contract
- To/Contract: color dot + full address + etherscan link (or "contract
creation"), token symbol label if known
- Value: amount in ETH (4 decimal places, USD in parentheses)
- Value: amount in ETH (4 decimal places)
- Raw data: full calldata displayed inline (shown if present)
- Password input and an error line
- Password input
- "Confirm" / "Reject" buttons
- **Transitions**:
- "Confirm" (correct password) → decrypts and signs in the popup, hands the
signed transaction to the background to broadcast, then → **WaitTx** in
the same popup window
- "Confirm" (wrong password) → error line, no screen change
- "Confirm" (with password) → closes popup (returns result to background)
- "Reject" → closes popup (returns rejection to background)
- Popup window closed without answering → the request is rejected with
EIP-1193 code 4001
#### SignApproval (`approve-sign`)
#### SignApproval
- **When**: A connected website requests a message signature via
`personal_sign`, `eth_sign`, or `eth_signTypedData_v4`. Opened the same way as
TxApproval, in a separate popup window.
`personal_sign`, `eth_sign`, or `eth_signTypedData_v4`. Opened via the toolbar
popup by the background script.
- **Elements**:
- "Signature Request" heading
- Phishing warning banner (shown when the hostname is on the phishing
blocklist)
- Site hostname (bold) + "wants you to sign a message"
- Danger warning box (shown for `eth_sign`, which signs a raw hash)
- Type: "Personal message" or "Typed data (EIP-712)"
- From: color dot + full address + etherscan link
- Message: decoded UTF-8 text (personal_sign) or formatted domain/type/
message fields (EIP-712 typed data)
- Password input and an error line
- Password input
- "Sign" / "Reject" buttons
- **Transitions**:
- "Sign" (correct password) → signs locally → closes popup (returns
signature)
- "Sign" (wrong password, or a signing failure) → error line, no screen
change
- "Sign" (with password) → signs locally → closes popup (returns signature)
- "Reject" → closes popup (returns rejection to background)
- Popup window closed without answering → the request is rejected with
EIP-1193 code 4001
### External Services
@@ -962,7 +731,7 @@ communicates with three external services to function as a wallet:
What the extension does NOT do:
- No analytics or telemetry services
- No token list APIs (the top-250 token list is bundled at build time)
- No token list APIs (user adds tokens manually by contract address)
- No Infura/Alchemy dependency (any JSON-RPC endpoint works)
- No backend servers operated by the developer
@@ -972,14 +741,12 @@ CoinDesk price API, and Blockscout API), AutistMask also contacts:
- **Phishing domain blocklist**: A community-maintained phishing domain
blocklist is vendored into the extension at build time. At runtime, the
extension fetches the live list once every 24 hours to detect newly added
domains, plus once on a start where the list is more than 24 hours old. Only
the delta (domains not already in the vendored list) is kept in memory,
keeping runtime memory usage small. The delta and the timestamp of the fetch
that produced it are persisted to extension storage if the record is under 256
KiB; an oversized delta is dropped along with its timestamp, so a later start
fetches again rather than claiming freshness for data it no longer holds. A
fetch that fails, or one whose delta was too large to store, is not retried
more than once an hour outside the 24-hour schedule.
domains. Only the delta (domains not already in the vendored list) is kept in
memory, keeping runtime memory usage small. The delta and the timestamp of the
fetch that produced it are persisted to extension storage if the record is
under 256 KiB; an oversized delta is dropped along with its timestamp, so the
next start fetches again rather than claiming freshness for data it no longer
holds.
- **Etherscan address labels**: When confirming a transaction, the extension
performs a best-effort lookup of the recipient address on Etherscan to check
for phishing/scam labels. This is a direct page fetch with no API key; the
@@ -1086,12 +853,10 @@ hardcoded test phrase.
- Create new HD wallet (generates 12-word recovery phrase)
- Import HD wallet from existing 12 or 24 word recovery phrase
- Import single-address wallet from private key
- Import multi-address wallet from an extended private key (`xprv`)
- Add multiple addresses within an HD wallet
- Manage multiple wallets simultaneously
- View ETH balance per address
- View ERC-20 token balances (bundled top-250 tokens, tokens with 1,000 or more
holders, and tokens the user adds by contract address)
- View ERC-20 token balances (user adds token by contract address)
- Send ETH to an address
- Send ERC-20 tokens to an address
- Receive ETH/tokens (display address, copy to clipboard, QR code)
@@ -1201,11 +966,9 @@ live list once every 24 hours and keeps only the delta (newly added domains not
in the vendored list) in memory. This architecture keeps runtime memory usage
small while ensuring fresh coverage of new phishing domains.
The 24-hour cadence is an alarm, not a timer; the alarm tick fetches
unconditionally rather than re-checking the 24-hour cache TTL that gates the
startup path; and the fetch timestamps live in extension storage rather than in
module variables — see [Background scheduling](#background-scheduling) for why
all three are required.
The 24-hour cadence is an alarm, not a timer, and the fetch timestamp lives in
extension storage rather than in a module variable — see
[Background scheduling](#background-scheduling) for why both are required.
When a dApp on a blocklisted domain requests a wallet connection, transaction
approval, or signature, the approval popup displays a prominent red warning
@@ -1243,8 +1006,7 @@ Currently supported:
- Built in token swaps (use a DEX in the browser)
- Analytics, telemetry, or tracking of any kind
- Advertisements or promotions
- Obscure token list auto-discovery — nothing outside the bundled list, the
1,000-holder floor, and the tokens the user added by contract address
- Obscure token list auto-discovery (user adds tokens manually)
- We detect common/popular ERC20s in the basic case
- Fiat on/off ramps
- Extensive transaction decoding/parsing
@@ -1266,12 +1028,12 @@ Currently supported:
### Transactions
- [x] Gas estimation and fee display before confirming
- [ ] Gas estimation and fee display before confirming
### Testing
- [x] Tests for mnemonic generation and address derivation
- [x] Tests for xpub derivation and child address generation
- [ ] Tests for mnemonic generation and address derivation
- [ ] Tests for xpub derivation and child address generation
- [ ] Test on Firefox (Manifest V2)
### Scam List
@@ -1302,17 +1064,13 @@ covered by the GPL-3.0 license above. These files, their copyright holders, and
their licenses are:
| File | Source | Copyright | License |
| ---------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- | --------------------------------- | -------------------------------------------------------------- |
| `src/shared/phishingBlocklist.json` | `eth-phishing-detect` community-maintained phishing domain blocklist, vendored from its `src/config.json` | Copyright (c) 2018 kumavis | [DBAD (Don't Be a Dick)](https://github.com/philsturgeon/dbad) |
| ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | --------------------------------- | -------------------------------------------------------------- |
| `src/shared/phishingBlocklist.json` | [eth-phishing-detect](https://github.com/AugurProject/eth-phishing-detect) community-maintained phishing domain blocklist | Copyright (c) 2018 kumavis | [DBAD (Don't Be a Dick)](https://github.com/philsturgeon/dbad) |
| `src/shared/scamlist.js` (address data from MyEtherWallet) | [ethereum-lists](https://github.com/MyEtherWallet/ethereum-lists) `addresses-darklist.json` | Copyright (c) 2020 MyEtherWallet | MIT |
| `src/shared/scamlist.js` (address data from EtherScamDB) | [EtherScamDB](https://github.com/MrLuit/EtherScamDB) `scams.yaml` | Copyright (c) 2018 Luit Hollander | MIT |
The full license texts for these third-party files are included in the
[LICENSE](LICENSE) file. The `eth-phishing-detect` row carries no repository
link because the upstream is hosted under a competitor's organization name,
which project policy keeps out of code and documentation; the vendored copy and
the runtime refresh both come from that upstream, whose URL is the
`BLOCKLIST_URL` constant in `src/shared/phishingDomains.js`.
[LICENSE](LICENSE) file.
## Author

33
TODO.md
View File

@@ -46,45 +46,16 @@ undefined identifiers, which is how
- 2026-08-11: the balance refresh and the 24-hour phishing list refresh moved
from `setInterval` to the extension alarms API, with the phishing delta and
its fetch timestamps persisted to extension storage, so neither job dies with
the MV3 service worker. Each job's freshness guard was decoupled from its
alarm period at the same time — timed to the period, a guard vetoes its own
scheduled tick and halves the real refresh rate
its fetch timestamp persisted to extension storage, so neither job dies with
the MV3 service worker
([#158](https://git.eeqj.de/sneak/AutistMask/issues/158)).
- 2026-08-11: Policy compliance sweep — conditional verbose test rerun, local
Tailwind binary instead of `npx`, `--frozen-lockfile` on `make install`, and
the Makefile-only targets documented in the README
([#166](https://git.eeqj.de/sneak/AutistMask/issues/166)).
- 2026-08-11: `script/verify-build` diagnostics corrected: the both-markers
message now states what is and is not proven, an unreadable bundle is
diagnosed as an I/O fault rather than as changed output, the `*.js` assumption
lives only in `build.js`, and the unlisted-bundle scan hard-fails when it
cannot enumerate `dist/`
([#180](https://git.eeqj.de/sneak/AutistMask/issues/180)).
- 2026-08-11: Known-answer test coverage for the crypto core — BIP-39/BIP-32
derivation in `wallet.js` and the Argon2id vault in `vault.js`
([#159](https://git.eeqj.de/sneak/AutistMask/issues/159)).
- 2026-08-11: Three `README.md` claims corrected against the code — blocklist
attribution, token-display rule, navigation model
([#213](https://git.eeqj.de/sneak/AutistMask/issues/213)).
- 2026-08-11: README Screen Map rebuilt from the code — every screen, element
and transition re-verified against `src/popup/`
([#164](https://git.eeqj.de/sneak/AutistMask/issues/164)).
- 2026-08-11: `docs/README.md` rewritten against the code: no competitor names,
all five network destinations documented, password/Settings/Add Wallet
sections corrected ([#163](https://git.eeqj.de/sneak/AutistMask/issues/163)).
- 2026-08-11: `loadState()` now derives `hasWallet` from the wallet list instead
of trusting the persisted flag, so a profile already saved inconsistent no
longer stays broken on every load
([#195](https://git.eeqj.de/sneak/AutistMask/issues/195)).
- 2026-08-11: Wallet deletion repairs its own state — `hasWallet` follows the
remaining wallets, the selection only moves when it was deleted, and the
active-address change is broadcast to connected sites
([#156](https://git.eeqj.de/sneak/AutistMask/issues/156)).
- 2026-08-11: One row per on-chain value movement in transaction history: the
merge moved into the pure `mergeTransactions` and the zero-ETH native side of
a plain ERC-20 transfer absorbed into its token row
([#177](https://git.eeqj.de/sneak/AutistMask/issues/177)).
- 2026-08-11: `TODO.md` Workflow rewritten to the branch-and-PR-per-issue model
on `next`, with Status and Next Step refreshed
([#191](https://git.eeqj.de/sneak/AutistMask/issues/191)).

View File

@@ -29,12 +29,6 @@ function repoRelative(p) {
// reports every input that contributed to an output in the metafile, which is
// the authoritative answer to "is constants.js in this bundle" — unlike
// searching the minified text, it does not depend on what survived minification.
//
// The ".js" filter below is the only place that assumption lives:
// script/verify-build searches every file and symlink under dist/ for a
// marker, without filtering by extension, and hard-fails if it cannot walk the
// whole tree, so a bundle emitted under some other extension fails there as
// unlisted rather than escaping both checks at once.
function outputsContainingAuditedModule(metafile) {
return Object.entries(metafile.outputs)
.filter(([outFile, info]) => {
@@ -121,17 +115,8 @@ async function build() {
// build that never gets around to writing one cannot be verified against
// a stale list.
fs.rmSync(BUNDLE_MANIFEST, { force: true });
// The locally installed binary, not `npx` — npx silently fetches from the
// registry when the binary is absent, which is an unpinned network fetch
// in the middle of a build.
const tailwindBin = path.join(
__dirname,
"node_modules",
".bin",
"tailwindcss",
);
execSync(
`"${tailwindBin}" -i "${tailwindInput}" -o "${tailwindOutput}" --minify`,
`npx @tailwindcss/cli -i ${tailwindInput} -o ${tailwindOutput} --minify`,
{ stdio: "inherit" },
);

View File

@@ -133,11 +133,9 @@ user-configurable.
When it is contacted: when the background script starts, if the last fetch was
more than 24 hours ago, and every 24 hours after that. The time of the last
fetch is remembered across browser and background restarts, so restarting does
not cause a re-download. If a fetch fails, or the list is too large to keep, the
extension waits an hour before trying again outside that 24-hour schedule rather
than retrying on every restart. It is a plain download of a public file —
nothing about you is sent, but the host sees your IP address. If the fetch
fails, the bundled copy is still used.
not cause a re-download. It is a plain download of a public file — nothing about
you is sent, but the host sees your IP address. If the fetch fails, the bundled
copy is still used.
**Etherscan address labels** (`etherscan.io`; `sepolia.etherscan.io` on Sepolia)

View File

@@ -7,7 +7,6 @@
"private": true,
"scripts": {
"test": "jest --forceExit",
"test:verbose": "jest --forceExit --verbose",
"build": "node build.js",
"lint": "prettier --check .",
"fmt": "prettier --write .",

View File

@@ -7,13 +7,7 @@ ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() {
cd "$ROOT"
echo "Running tests..."
timeout 30 yarn run test 2>&1 || {
echo "--- Rerunning with --verbose for details ---"
timeout 30 yarn run test:verbose 2>&1 || true
# Always fail: the first run already proved the tests are broken, so a
# flaky pass on the rerun must not turn the build green.
exit 1
}
timeout 30 yarn run test 2>&1
}
main "$@"

View File

@@ -34,51 +34,16 @@ fail() {
exit 1
}
# Is the literal $1 present in the file $2? Match (grep exit 0) and no-match
# (exit 1) are answers about the emitted output. Anything else (exit 2: the
# file could not be read) is not an answer at all, and must not be reported as
# "no marker" — that would blame the bundle for a permissions or I/O fault.
has_marker() {
_hm_status=0
grep -q -F -e "$1" -- "$2" || _hm_status=$?
case "$_hm_status" in
0) return 0 ;;
1) return 1 ;;
*)
fail "grep exited $_hm_status reading $2, so the file could not be
searched and its DEBUG state was not checked at all. That is a permissions
or I/O fault on the artifact, not a change in the emitted output. Refusing
to report success."
;;
esac
}
# Does the manifest list the path $1, as a whole line? Same discipline as
# has_marker: exit 0 and 1 are answers about the manifest, exit 2 means the
# manifest could not be read and is not an answer at all. Without this, an
# unreadable manifest reads as "this file is not listed" and every emitted
# bundle gets reported as an unlisted one.
is_listed() {
_il_status=0
grep -q -x -F -e "$1" -- "$MANIFEST" || _il_status=$?
case "$_il_status" in
0) return 0 ;;
1) return 1 ;;
*)
fail "grep exited $_il_status reading $MANIFEST, so it could not be
searched and nothing was established about which bundles it lists. That is
a permissions or I/O fault on the manifest, not a stale manifest. Refusing
to report success."
;;
esac
grep -q -F "$1" "$2" 2>/dev/null
}
# Read one bundle's DEBUG state into MARKER. Exactly one marker must be
# present. Both means the ternary in constants.js was never folded, which is
# what happens when the __BUILD_DEBUG__ define goes missing from build.js:
# DEBUG stops being known at build time. Neither means we are reading output
# we do not understand. Both are hard failures; neither is ever treated as
# absence of a problem.
# DEBUG stops being known at build time and the debug branch is live again.
# Neither means we are reading output we do not understand. Both are hard
# failures; neither is ever treated as absence of a problem.
read_marker() {
_file="$1"
_on=no
@@ -87,14 +52,9 @@ read_marker() {
if has_marker "$MARKER_OFF" "$_file"; then _off=yes; fi
if [ "$_on" = yes ] && [ "$_off" = yes ]; then
fail "$_file carries both debug markers, so DEBUG was not resolved at
build time: the ternary in src/shared/constants.js survived into the
emitted output. This does not mean the debug branch is live in this
artifact: an unresolved __BUILD_DEBUG__ is undeclared in extension
context, so DEBUG evaluates to false at runtime. It does mean the
release/debug distinction is no longer enforced at build time, and which
way that fallback happens to evaluate is then an accident a refactor can
flip. Check that build.js still defines __BUILD_DEBUG__."
fail "$_file carries both debug markers, so the build-time DEBUG value
was never resolved and the debug branch is still live. Check that build.js
still defines __BUILD_DEBUG__."
fi
if [ "$_on" = no ] && [ "$_off" = no ]; then
fail "$_file carries no debug marker, so its DEBUG state cannot be
@@ -110,43 +70,13 @@ read_marker() {
}
# The manifest says which bundles must carry a marker. This says no other
# emitted file may carry one, which catches a manifest that has gone stale
# emitted bundle may carry one, which catches a manifest that has gone stale
# or short rather than trusting whatever it happens to list.
#
# Deliberately unfiltered by extension. build.js selects manifest entries with
# an endsWith(".js") test; repeating that literal here would mean a bundle
# emitted under some other extension escaped the manifest AND this check at
# once, which is the correlated blind spot the two-source design exists to
# avoid. Every file under dist/ is searched, so build.js's filter is the only
# place the assumption lives and this check is what catches it being wrong.
#
# That claim only holds if the walk is exhaustive, so two things are enforced
# here rather than assumed:
#
# - find's exit status is checked. A subtree it cannot descend is reported on
# stderr and then simply missing from the listing, so an unchecked status
# turns "could not look" into "nothing was there" — the same conflation
# has_marker exists to prevent. The status cannot be read off a pipeline
# ending in sort, so the sort is a separate step.
# - symlinks are walked too (-type l), not skipped. A marker-carrying bundle
# reachable under an unlisted path in dist/ is a stale manifest whether the
# path is a link or a file, and grep reads through the link. A link that
# cannot be read through — dangling, or pointing at a directory — fails
# hard via has_marker's exit-2 path, which is the fail-closed answer: the
# build emits neither, so their DEBUG state is unproven, not fine.
check_unlisted_bundles() {
_find_status=0
_listing="$(find dist \( -type f -o -type l \) -print)" || _find_status=$?
[ "$_find_status" -eq 0 ] ||
fail "find exited $_find_status enumerating dist/, so part of the tree
was never walked and nothing was established about the files in it. Any
unlisted bundle there went unchecked. That is a permissions or I/O fault on
the artifact, not a stale manifest. Refusing to report success."
_listing="$(printf '%s\n' "$_listing" | sort)"
_listing="$(find dist -type f -name '*.js' | sort)"
while read -r _file; do
[ -n "$_file" ] || continue
if is_listed "$_file"; then
if grep -q -x -F "$_file" "$MANIFEST"; then
continue
fi
if has_marker "$MARKER_ON" "$_file" ||
@@ -183,18 +113,12 @@ main() {
fail "$MANIFEST is empty, so no emitted bundle was found to contain
src/shared/constants.js. That is never correct, so it is a failure and not
a pass."
[ -r "$MANIFEST" ] ||
fail "$MANIFEST is not readable, so nothing was inspected. That is a
permissions or I/O fault, not a pass."
count=0
while read -r file; do
[ -n "$file" ] || continue
[ -f "$file" ] ||
fail "$MANIFEST lists $file, which does not exist."
[ -s "$file" ] ||
fail "$MANIFEST lists $file, which is empty. An empty bundle
carries no marker and proves nothing, so this is a failure and not a pass."
read_marker "$file"
[ "$MARKER" = "$expected" ] ||
fail "$file is $MARKER but this build expects $expected."

View File

@@ -12,11 +12,11 @@ const {
currentNetwork,
} = require("../shared/state");
const { refreshBalances, getProvider } = require("../shared/balances");
const { debugFetch, log } = require("../shared/log");
const { debugFetch } = require("../shared/log");
const { verifySignedTx, verifySignature } = require("../shared/approvalVerify");
const {
isPhishingDomain,
refreshPhishingListOnSchedule,
updatePhishingList,
initPhishingList,
} = require("../shared/phishingDomains");
const {
@@ -598,22 +598,12 @@ async function broadcastAccountsChanged() {
// Background balance refresh: every 60 seconds when the popup isn't open.
// When the popup IS open, its 10-second interval keeps lastBalanceRefresh
// fresh, so this naturally skips.
//
// The alarm period alone sets the cadence; this guard only suppresses a
// refresh something else has just done, so it must stay strictly shorter than
// the period. Timed to the period it would veto every tick it gates —
// lastBalanceRefresh is stamped after the refresh runs, so a tick one period
// after the last one always lands inside a guard of equal length and the real
// cadence becomes two periods. Half the period keeps it comfortably above the
// popup's 10-second refresh, so an open popup still suppresses the background
// job, and comfortably below the alarm period, so the schedule always wins.
const BALANCE_REFRESH_PERIOD_MS = BALANCE_REFRESH_PERIOD_MINUTES * 60 * 1000;
const RECENT_BALANCE_REFRESH_MS = Math.floor(BALANCE_REFRESH_PERIOD_MS / 2);
const BACKGROUND_REFRESH_INTERVAL = BALANCE_REFRESH_PERIOD_MINUTES * 60 * 1000;
async function backgroundRefresh() {
await loadState();
const now = Date.now();
if (now - (state.lastBalanceRefresh || 0) < RECENT_BALANCE_REFRESH_MS)
if (now - (state.lastBalanceRefresh || 0) < BACKGROUND_REFRESH_INTERVAL)
return;
if (state.wallets.length === 0) return;
await refreshBalances(
@@ -633,42 +623,20 @@ async function backgroundRefresh() {
// wake the worker to deliver them.
registerAlarmHandlers({
[BALANCE_REFRESH_ALARM]: backgroundRefresh,
// The scheduled refresh, which restores persisted state on a freshly
// revived worker and then fetches unconditionally. The freshness guards
// belong to the startup path; applying them here would make the tick skip
// itself.
[PHISHING_REFRESH_ALARM]: refreshPhishingListOnSchedule,
// Re-reads the persisted fetch timestamp first, so a tick that lands on a
// freshly revived worker neither re-fetches needlessly nor skips an
// overdue update.
[PHISHING_REFRESH_ALARM]: updatePhishingList,
});
// Everything the background context needs re-established on start. This runs
// on a fresh install, on browser startup, and on every revival of a
// terminated worker, so it must be idempotent: ensureRecurringAlarms() only
// creates alarms that are missing or carrying a stale period, and
// initPhishingList() fetches only when the persisted timestamps say the list
// is stale.
//
// On a fresh install the top-level call and the onInstalled listener both run,
// close enough together that both could see an alarm missing and create it.
// Sharing one in-flight run makes the "create only when missing" check
// race-free; the memo is dropped once it settles so a later onStartup runs
// again.
let backgroundJobsRun = null;
// creates alarms that are missing, and initPhishingList() fetches only when
// the persisted timestamp says the list is stale.
function startBackgroundJobs() {
if (backgroundJobsRun) return backgroundJobsRun;
backgroundJobsRun = Promise.all([
ensureRecurringAlarms(),
initPhishingList(),
])
.catch((err) => {
// An alarm that failed to schedule means a recurring job silently
// never runs again; it must not be an unhandled rejection.
log.errorf("background job startup failed:", err);
})
.finally(() => {
backgroundJobsRun = null;
});
return backgroundJobsRun;
ensureRecurringAlarms();
initPhishingList();
}
if (runtime.onInstalled) {

View File

@@ -10,14 +10,6 @@
//
// Periods are whole minutes at or above the browser-enforced one-minute
// minimum, so nothing here is silently clamped to a slower cadence.
//
// Trap for anyone changing a period: each job also carries a freshness guard
// that can veto its own scheduled tick. A guard timed to the alarm period
// halves the real cadence, because the guard is measured from when the last
// run finished and the alarm fires one run-duration earlier than that. Every
// guard must therefore either be strictly shorter than the period it gates or
// be bypassed on the scheduled tick — see backgroundRefresh() in
// src/background/index.js and updatePhishingList() in shared/phishingDomains.js.
const BALANCE_REFRESH_ALARM = "autistmask-balance-refresh";
const PHISHING_REFRESH_ALARM = "autistmask-phishing-refresh";
@@ -36,19 +28,13 @@ function alarmsApi() {
}
/**
* Create an alarm unless one with the requested period already exists.
* Create an alarm if it does not already exist.
*
* The existence check is load-bearing: creating an alarm resets its schedule,
* and this runs on every worker wake. Creating unconditionally would push the
* next fire time out on every incoming message, so a busy extension would
* never see the alarm fire at all.
*
* The period comparison is equally load-bearing in the other direction: an
* alarm created by an older version keeps its old period forever unless a
* changed constant re-creates it, so a period edit would never reach an
* existing install. Re-creating on a period change happens once and then
* settles into the existence check above.
*
* @param {string} name
* @param {number} periodInMinutes
* @returns {Promise<boolean>} true if the alarm was created by this call.
@@ -58,7 +44,7 @@ async function ensureAlarm(name, periodInMinutes) {
if (!api) return false;
const period = Math.max(periodInMinutes, MIN_ALARM_PERIOD_MINUTES);
const existing = await api.get(name);
if (existing && existing.periodInMinutes === period) return false;
if (existing) return false;
api.create(name, {
periodInMinutes: period,
delayInMinutes: period,

View File

@@ -12,10 +12,8 @@
// extension storage, so they survive termination of the MV3 service worker.
// Extension storage, not localStorage: localStorage does not exist in a
// service worker, so the previous persistence never ran on Chrome at all.
// The stored timestamps are what keep a restarted worker from re-fetching on
// every wake while still noticing an overdue update. Those guards apply to the
// startup path only; the 24-hour alarm tick bypasses them, or it would veto
// its own refresh — see updatePhishingList().
// The stored timestamp is what keeps a restarted worker from re-fetching on
// every wake while still noticing an overdue update.
const vendoredConfig = require("./phishingBlocklist.json");
@@ -23,14 +21,6 @@ const BLOCKLIST_URL =
"https://raw.githubusercontent.com/MetaMask/eth-phishing-detect/main/src/config.json";
const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
// Floor on how often an unscheduled path may hit the network. The worker is
// revived every ~30 seconds while the browser is busy, and every revival runs
// the startup path; without a persisted record of the last attempt, any state
// that leaves lastFetchTime unset — a fetch that failed, or a delta too large
// to store — would download the full list on every single wake.
const MIN_FETCH_ATTEMPT_INTERVAL_MS = 60 * 60 * 1000; // 1 hour
const DELTA_STORAGE_KEY = "phishing-delta";
const MAX_DELTA_BYTES = 256 * 1024; // 256 KiB
@@ -42,7 +32,6 @@ const vendoredBlacklist = new Set(
// Delta set — only entries from live list that are NOT in vendored.
let deltaBlacklist = new Set();
let lastFetchTime = 0;
let lastAttemptTime = 0;
let fetchPromise = null;
let loadPromise = null;
@@ -60,26 +49,7 @@ function storageApi() {
}
/**
* Sanitise a timestamp read back from storage.
*
* A value in the future is permanent poison: every guard here measures elapsed
* time as `Date.now() - stamp` and tests only the lower bound, so a stamp a
* year ahead suppresses updates for a year with no path that ever clears it.
* Clock skew and a restored profile backup both produce one. Since these
* timestamps only ever gate work, discarding an impossible one is safe: it
* costs at most a single extra fetch and restores a sane value immediately.
*
* @param {unknown} value
* @returns {number} the timestamp, or 0 if it is unusable.
*/
function sanitizeTimestamp(value) {
if (typeof value !== "number" || !Number.isFinite(value)) return 0;
if (value <= 0 || value > Date.now()) return 0;
return value;
}
/**
* Load the persisted delta and its timestamps from extension storage.
* Load the persisted delta and its fetch timestamp from extension storage.
* Runs once per worker lifetime; every entry point funnels through
* ensureDeltaLoaded() so a wake from termination restores state exactly once.
*
@@ -97,8 +67,9 @@ async function loadDeltaFromStorage() {
data.blacklist.map((d) => d.toLowerCase()),
);
}
lastFetchTime = sanitizeTimestamp(data.lastFetchTime);
lastAttemptTime = sanitizeTimestamp(data.lastAttemptTime);
if (typeof data.lastFetchTime === "number") {
lastFetchTime = data.lastFetchTime;
}
} catch {
// Storage unavailable or corrupt — start empty and re-fetch.
}
@@ -110,14 +81,11 @@ function ensureDeltaLoaded() {
}
/**
* Persist the delta and its timestamps if they fit within MAX_DELTA_BYTES.
* Persist the delta and its timestamp if they fit within MAX_DELTA_BYTES.
*
* The 256 KiB cap covers the delta and its freshness claim: when the delta is
* too large to keep, lastFetchTime goes with it, so the next start re-fetches
* rather than trusting a freshness claim for a delta it no longer holds.
* lastAttemptTime is written either way — it records that the network was
* contacted, which stays true whatever became of the response, and it is what
* stops a permanently oversized list from downloading on every worker wake.
* The cap covers the whole record: when the delta is too large to keep, the
* timestamp goes with it, so the next worker start re-fetches rather than
* trusting a freshness claim for a delta it no longer holds.
*
* @returns {Promise<void>}
*/
@@ -128,14 +96,12 @@ async function saveDeltaToStorage() {
const data = {
blacklist: Array.from(deltaBlacklist),
lastFetchTime,
lastAttemptTime,
};
const json = JSON.stringify(data);
if (json.length < MAX_DELTA_BYTES) {
await storage.set({ [DELTA_STORAGE_KEY]: data });
} else if (lastAttemptTime > 0) {
await storage.set({ [DELTA_STORAGE_KEY]: { lastAttemptTime } });
} else {
// Too large — remove stale record if present
await storage.remove(DELTA_STORAGE_KEY);
}
} catch {
@@ -208,56 +174,31 @@ function isPhishingDomain(hostname) {
* De-duplicates concurrent fetches. Results are cached for CACHE_TTL_MS,
* counted from the persisted timestamp so the cache outlives the worker.
*
* `force` is what makes the 24-hour alarm actually refresh every 24 hours.
* The alarm fires one period after the previous alarm, but lastFetchTime is
* stamped when that fetch *completed*, so an unforced tick lands one fetch
* latency inside its own TTL, skips, and turns the real cadence into 48 hours.
* Shortening the TTL instead would not fix it: the worker wakes every ~30
* seconds and the startup path re-checks the TTL each time, so a shortened TTL
* simply becomes the real cadence. The TTL is there to stop redundant fetches
* on wake, and the scheduled tick is not redundant, so it bypasses it.
*
* @param {{force?: boolean}} [opts] force: fetch unless one is already in
* flight, ignoring both the freshness and the retry guard. For the scheduled
* alarm tick only.
* @returns {Promise<void>}
*/
async function updatePhishingList({ force = false } = {}) {
async function updatePhishingList() {
// A worker that has just been revived knows nothing until the persisted
// record is back in memory; without this the freshness check below would
// always see 0 and re-fetch on every wake.
await ensureDeltaLoaded();
if (!force) {
const now = Date.now();
// Skip if recently fetched.
if (lastFetchTime > 0 && now - lastFetchTime < CACHE_TTL_MS) return;
// Skip if the network was contacted recently and the result was not
// usable — a failed fetch or an oversized delta leaves lastFetchTime
// unset, and without this every wake would retry.
if (
lastAttemptTime > 0 &&
now - lastAttemptTime < MIN_FETCH_ATTEMPT_INTERVAL_MS
) {
// Skip if recently fetched
if (Date.now() - lastFetchTime < CACHE_TTL_MS && lastFetchTime > 0) {
return;
}
}
// De-duplicate concurrent calls
if (fetchPromise) return fetchPromise;
fetchPromise = (async () => {
lastAttemptTime = Date.now();
try {
const resp = await fetch(BLOCKLIST_URL);
if (!resp.ok) throw new Error("HTTP " + resp.status);
const config = await resp.json();
await loadConfig(config);
} catch {
// Silently fail — vendored list still provides coverage. Persist
// the attempt so a persistently failing fetch is retried on the
// schedule rather than on every wake.
await saveDeltaToStorage();
// Silently fail — vendored list still provides coverage.
// We'll retry next time.
} finally {
fetchPromise = null;
}
@@ -281,17 +222,6 @@ async function initPhishingList() {
return updatePhishingList();
}
/**
* The 24-hour alarm tick. Separate from initPhishingList() because this is the
* scheduled refresh and must not be vetoed by the guards that exist to keep
* the unscheduled startup path off the network.
*
* @returns {Promise<void>}
*/
async function refreshPhishingListOnSchedule() {
return updatePhishingList({ force: true });
}
/**
* Return the total blocklist size (vendored + delta) for diagnostics.
*
@@ -316,7 +246,6 @@ function getDeltaSize() {
function _reset() {
deltaBlacklist = new Set();
lastFetchTime = 0;
lastAttemptTime = 0;
fetchPromise = null;
loadPromise = null;
}
@@ -324,12 +253,10 @@ function _reset() {
module.exports = {
isPhishingDomain,
updatePhishingList,
refreshPhishingListOnSchedule,
initPhishingList,
loadDeltaFromStorage,
loadConfig,
CACHE_TTL_MS,
MIN_FETCH_ATTEMPT_INTERVAL_MS,
DELTA_STORAGE_KEY,
MAX_DELTA_BYTES,
getBlocklistSize,

View File

@@ -84,11 +84,8 @@ async function loadState() {
const result = await storageApi.get("autistmask");
if (result.autistmask) {
const saved = result.autistmask;
state.hasWallet = saved.hasWallet;
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;

View File

@@ -113,85 +113,6 @@ function parseTokenTransfer(tt, addrLower) {
};
}
// True when a parsed native entry moved no ETH. Contract-call entries have
// their amount fields blanked by parseTx, so they are never judged here.
function movedNoEther(tx) {
if (tx.direction === "contract") return false;
return BigInt(tx.rawAmount || "0") === BigInt(0);
}
// Merge parsed normal transactions with parsed ERC-20 token transfers into
// one row per distinct value movement. Pure: it reads only its arguments
// and returns a new list sorted newest block first.
//
// The merge key is the transaction hash for the native entry and
// hash + token contract for each token transfer, so:
//
// - A display-level contract call (a swap and friends, direction
// "contract") absorbs every token leg of its hash into the single
// native entry, because the legs are hops of one operation rather
// than separate movements the user made.
// - Otherwise each distinct token contract in the transaction keeps its
// own row, so a hash carrying several genuine transfers stays several
// rows.
// - The native entry of such a transaction is dropped when it moved no
// ETH and at least one token transfer shares its hash: that entry is
// the ERC-20 call itself, already represented by the token row. A
// native entry that moved ETH survives alongside the token rows, since
// the ETH and the tokens are two real movements, and a zero-value
// native transaction with no token transfer on its hash survives too.
function mergeTransactions(txs, tokenTransfers) {
const byKey = new Map();
// Entries are copied so consolidation never writes through to the
// caller's objects.
for (const tx of txs) {
byKey.set(tx.hash, { ...tx });
}
const absorbedHashes = new Set();
for (const parsed of tokenTransfers) {
const existing = byKey.get(parsed.hash);
if (existing && existing.direction === "contract") {
// For contract calls (swaps), consolidate into the original
// tx entry. Prefer the "received" transfer (swap output)
// for the display amount. If no received transfer exists,
// fall back to the first "sent" transfer (swap input).
const isReceived = parsed.direction === "received";
const needsAmount = !existing.exactValue;
if (isReceived || needsAmount) {
existing.value = parsed.value;
existing.exactValue = parsed.exactValue;
existing.rawAmount = parsed.rawAmount;
existing.rawUnit = parsed.rawUnit;
existing.symbol = parsed.symbol;
existing.contractAddress = parsed.contractAddress;
existing.holders = parsed.holders;
}
// Keep the original tx's from/to (the user's address and the
// contract they called), not the token transfer's from/to
// which may be a router or Permit2 contract.
continue;
}
if (existing && movedNoEther(existing)) {
absorbedHashes.add(parsed.hash);
}
// Every other token transfer gets its own entry.
byKey.set(parsed.hash + ":" + (parsed.contractAddress || ""), {
...parsed,
});
}
for (const hash of absorbedHashes) {
byKey.delete(hash);
}
const merged = [...byKey.values()];
merged.sort((a, b) => b.blockNumber - a.blockNumber);
return merged;
}
async function fetchRecentTransactions(address, blockscoutUrl, count = 25) {
log.debugf("fetchRecentTransactions", address);
const addrLower = address.toLowerCase();
@@ -224,11 +145,53 @@ async function fetchRecentTransactions(address, blockscoutUrl, count = 25) {
const txJson = txResp.ok ? await txResp.json() : {};
const ttJson = ttResp.ok ? await ttResp.json() : {};
const txs = mergeTransactions(
(txJson.items || []).map((tx) => parseTx(tx, addrLower)),
(ttJson.items || []).map((tt) => parseTokenTransfer(tt, addrLower)),
);
const txsByHash = new Map();
for (const tx of txJson.items || []) {
txsByHash.set(tx.hash, parseTx(tx, addrLower));
}
// When a token transfer shares a hash with a normal tx, the normal tx
// is the contract call (0 ETH) and the token transfer has the real
// amount and symbol. For contract calls (swaps), a single transaction
// can produce multiple token transfers (input, intermediates, output).
// We consolidate these into the original tx entry using the token
// transfer where the user *receives* tokens (the swap output), so
// the transaction list shows the final result rather than confusing
// intermediate hops. We preserve the original tx's from/to so the
// user sees their own address, not a router or Permit2 contract.
for (const tt of ttJson.items || []) {
const parsed = parseTokenTransfer(tt, addrLower);
const existing = txsByHash.get(parsed.hash);
if (existing && existing.direction === "contract") {
// For contract calls (swaps), consolidate into the original
// tx entry. Prefer the "received" transfer (swap output)
// for the display amount. If no received transfer exists,
// fall back to the first "sent" transfer (swap input).
const isReceived = parsed.direction === "received";
const needsAmount = !existing.exactValue;
if (isReceived || needsAmount) {
existing.value = parsed.value;
existing.exactValue = parsed.exactValue;
existing.rawAmount = parsed.rawAmount;
existing.rawUnit = parsed.rawUnit;
existing.symbol = parsed.symbol;
existing.contractAddress = parsed.contractAddress;
existing.holders = parsed.holders;
}
// Keep the original tx's from/to (the user's address and the
// contract they called), not the token transfer's from/to
// which may be a router or Permit2 contract.
continue;
}
// Non-contract token transfers get their own entries.
const ttKey = parsed.hash + ":" + (parsed.contractAddress || "");
txsByHash.set(ttKey, parsed);
}
const txs = [...txsByHash.values()];
txs.sort((a, b) => b.blockNumber - a.blockNumber);
const result = txs.slice(0, count);
log.debugf("fetchRecentTransactions done, count:", result.length);
return result;
@@ -302,8 +265,4 @@ function filterTransactions(txs, filters = {}) {
return { transactions: filtered, newFraudContracts: newFraud };
}
module.exports = {
fetchRecentTransactions,
filterTransactions,
mergeTransactions,
};
module.exports = { fetchRecentTransactions, filterTransactions };

View File

@@ -5,43 +5,6 @@
// pin the recurring jobs to the alarms API and to the re-registration path a
// revived worker runs.
// A controllable clock plus a stubbed balance refresh, so a cadence test can
// measure the interval between refreshes that actually happened rather than
// asserting the interval someone intended.
let mockNow = 0;
const mockBalanceRefreshAt = [];
// jest.resetModules() clears the call record of every jest.fn, and loading the
// worker is exactly that call — so anything that must be counted across a load
// is counted here rather than read off a mock.
let mockSetIntervalCalls = 0;
// Extension storage reads do not take a constant amount of time, and that is
// what makes a guard timed to the alarm period bite: backgroundRefresh()
// stamps its freshness marker after awaiting loadState(), so any read that is
// quicker than the previous one puts the next tick inside a guard of exactly
// one period and the tick is skipped. A simulation with a constant latency
// would sit exactly on the boundary and hide the bug.
const MOCK_STORAGE_LATENCIES_MS = [7, 3, 11, 2, 9, 4, 13, 1, 6, 5];
const MOCK_MAX_STORAGE_LATENCY_MS = Math.max(...MOCK_STORAGE_LATENCIES_MS);
let mockStorageJitter = false;
let mockStorageOpCount = 0;
function mockStorageTick() {
if (!mockStorageJitter) return;
mockNow +=
MOCK_STORAGE_LATENCIES_MS[
mockStorageOpCount++ % MOCK_STORAGE_LATENCIES_MS.length
];
}
jest.mock("../src/shared/balances", () => ({
refreshBalances: jest.fn(async () => {
mockBalanceRefreshAt.push(Date.now());
}),
getProvider: jest.fn(() => ({})),
}));
function makeAlarmsStub() {
const alarms = new Map();
const listeners = [];
@@ -143,35 +106,6 @@ describe("alarms module", () => {
).toBeDefined();
});
test("an alarm left over with a stale period is re-created", async () => {
// An install carries its alarms across an extension update, so a
// period changed in a new release only ever reaches users if the
// stale one is reconciled.
alarmsStub.create(alarmsMod.PHISHING_REFRESH_ALARM, {
periodInMinutes: 7 * 24 * 60,
});
alarmsStub.create.mockClear();
const created = await alarmsMod.ensureRecurringAlarms();
expect(created.phishing).toBe(true);
expect(
alarmsStub.alarms.get(alarmsMod.PHISHING_REFRESH_ALARM)
.periodInMinutes,
).toBe(alarmsMod.PHISHING_REFRESH_PERIOD_MINUTES);
});
test("reconciling a period settles instead of re-creating forever", async () => {
alarmsStub.create(alarmsMod.BALANCE_REFRESH_ALARM, {
periodInMinutes: 30,
});
await alarmsMod.ensureRecurringAlarms();
alarmsStub.create.mockClear();
const again = await alarmsMod.ensureRecurringAlarms();
expect(again).toEqual({ balance: false, phishing: false });
expect(alarmsStub.create).not.toHaveBeenCalled();
});
test("handlers are dispatched by alarm name from one listener", () => {
const balance = jest.fn();
const phishing = jest.fn();
@@ -226,30 +160,23 @@ describe("alarms module", () => {
});
});
// Loads the background worker against stubbed browser APIs. The returned
// store is the extension storage the worker sees, so a test can seed wallet
// state and read back what the worker persisted.
function loadBackground(initialStore = {}) {
const storageStore = initialStore;
const alarmsStub = makeAlarmsStub();
describe("background worker scheduling", () => {
let alarmsStub;
let timers;
function loadBackground() {
const storageStore = {};
alarmsStub = makeAlarmsStub();
const listeners = { onInstalled: [], onStartup: [] };
global.chrome = {
alarms: alarmsStub,
storage: {
local: {
get: async (key) => {
mockStorageTick();
return Object.prototype.hasOwnProperty.call(
storageStore,
key,
)
get: async (key) =>
Object.prototype.hasOwnProperty.call(storageStore, key)
? { [key]: storageStore[key] }
: {};
},
set: async (items) => {
mockStorageTick();
Object.assign(storageStore, items);
},
: {},
set: async (items) => Object.assign(storageStore, items),
remove: async (key) => {
delete storageStore[key];
},
@@ -259,7 +186,9 @@ function loadBackground(initialStore = {}) {
onMessage: { addListener: jest.fn() },
onConnect: { addListener: jest.fn() },
onInstalled: {
addListener: jest.fn((fn) => listeners.onInstalled.push(fn)),
addListener: jest.fn((fn) =>
listeners.onInstalled.push(fn),
),
},
onStartup: {
addListener: jest.fn((fn) => listeners.onStartup.push(fn)),
@@ -280,29 +209,14 @@ function loadBackground(initialStore = {}) {
}));
jest.resetModules();
require("../src/background/index");
return { alarmsStub, listeners, store: storageStore };
}
// Flush the promise chains the startup path and the alarm handlers run on.
async function settle() {
for (let i = 0; i < 3; i++) {
await new Promise((resolve) => setImmediate(resolve));
return listeners;
}
}
describe("background worker scheduling", () => {
let alarmsStub;
let timers;
beforeEach(() => {
mockSetIntervalCalls = 0;
timers = {
setInterval: jest
.spyOn(global, "setInterval")
.mockImplementation(() => {
mockSetIntervalCalls++;
return 0;
}),
.mockImplementation(() => 0),
};
});
@@ -314,9 +228,9 @@ describe("background worker scheduling", () => {
});
test("startup schedules the recurring jobs as alarms, not timers", async () => {
alarmsStub = loadBackground().alarmsStub;
loadBackground();
// Let the startup path's promises settle.
await settle();
await new Promise((resolve) => setImmediate(resolve));
const names = alarmsStub.created.map((c) => c.name).sort();
const {
@@ -326,143 +240,27 @@ describe("background worker scheduling", () => {
expect(names).toEqual(
[BALANCE_REFRESH_ALARM, PHISHING_REFRESH_ALARM].sort(),
);
expect(mockSetIntervalCalls).toBe(0);
expect(timers.setInterval).not.toHaveBeenCalled();
});
test("an onAlarm listener is installed on startup", async () => {
alarmsStub = loadBackground().alarmsStub;
await settle();
loadBackground();
await new Promise((resolve) => setImmediate(resolve));
expect(alarmsStub.listenerCount()).toBe(1);
});
test("onInstalled and onStartup both re-establish the schedule", async () => {
const loaded = loadBackground();
alarmsStub = loaded.alarmsStub;
await settle();
const listeners = loadBackground();
await new Promise((resolve) => setImmediate(resolve));
expect(loaded.listeners.onInstalled).toHaveLength(1);
expect(loaded.listeners.onStartup).toHaveLength(1);
expect(listeners.onInstalled).toHaveLength(1);
expect(listeners.onStartup).toHaveLength(1);
// A browser start after the alarms were dropped must put them back.
alarmsStub.alarms.clear();
alarmsStub.created.length = 0;
loaded.listeners.onStartup[0]();
await settle();
listeners.onStartup[0]();
await new Promise((resolve) => setImmediate(resolve));
expect(alarmsStub.created).toHaveLength(2);
});
test("the install-time listener and the top-level call share one run", async () => {
// On a fresh install both fire, close enough that both could observe
// an alarm missing and create it — and a second create restarts the
// period the first one just set.
const loaded = loadBackground();
alarmsStub = loaded.alarmsStub;
loaded.listeners.onInstalled[0]();
await settle();
expect(alarmsStub.created).toHaveLength(2);
expect(alarmsStub.created.map((c) => c.name).sort()).toEqual(
[
"autistmask-balance-refresh",
"autistmask-phishing-refresh",
].sort(),
);
});
});
// The alarm period alone must set the cadence. A freshness guard timed to the
// period vetoes the very tick it gates, because the guard is measured from
// when the last run finished and the alarm fires one run-duration before that.
// These tests measure the interval between refreshes that actually ran.
describe("balance refresh steady-state cadence", () => {
const {
BALANCE_REFRESH_PERIOD_MINUTES,
BALANCE_REFRESH_ALARM,
} = require("../src/shared/alarms");
const PERIOD_MS = BALANCE_REFRESH_PERIOD_MINUTES * 60 * 1000;
let clockSpy;
let timerSpy;
function seededStore() {
return {
autistmask: {
hasWallet: true,
wallets: [
{ address: "0x0000000000000000000000000000000000000001" },
],
lastBalanceRefresh: 0,
},
};
}
beforeEach(() => {
mockNow = Date.UTC(2026, 0, 1, 0, 0, 0);
mockBalanceRefreshAt.length = 0;
mockSetIntervalCalls = 0;
mockStorageOpCount = 0;
mockStorageJitter = false;
clockSpy = jest.spyOn(Date, "now").mockImplementation(() => mockNow);
timerSpy = jest.spyOn(global, "setInterval").mockImplementation(() => {
mockSetIntervalCalls++;
return 0;
});
});
afterEach(() => {
mockStorageJitter = false;
clockSpy.mockRestore();
timerSpy.mockRestore();
delete global.chrome;
delete global.fetch;
jest.resetModules();
});
test("ten alarm ticks produce ten refreshes, one per period", async () => {
const { alarmsStub } = loadBackground(seededStore());
await settle();
mockStorageJitter = true;
const TICKS = 10;
let tickAt = mockNow + PERIOD_MS;
for (let i = 0; i < TICKS; i++) {
mockNow = tickAt;
tickAt += PERIOD_MS;
alarmsStub.fire(BALANCE_REFRESH_ALARM);
await settle();
}
// No tick was a no-op. This is the assertion that fails when the guard
// is timed to the alarm period.
expect(mockBalanceRefreshAt).toHaveLength(TICKS);
// And the observed cadence is one period, not two.
const intervals = mockBalanceRefreshAt
.slice(1)
.map((t, i) => t - mockBalanceRefreshAt[i]);
for (const interval of intervals) {
expect(interval).toBeGreaterThanOrEqual(
PERIOD_MS - MOCK_MAX_STORAGE_LATENCY_MS,
);
expect(interval).toBeLessThanOrEqual(
PERIOD_MS + MOCK_MAX_STORAGE_LATENCY_MS,
);
}
});
test("a refresh an open popup just did still suppresses the tick", async () => {
// The guard's actual job, and the reason it is shortened rather than
// removed: while the popup is open it refreshes every 10 seconds and
// stamps the same field, and the background job has nothing to add.
const store = seededStore();
const { alarmsStub } = loadBackground(store);
await settle();
mockNow += PERIOD_MS;
store.autistmask.lastBalanceRefresh = mockNow - 10 * 1000;
alarmsStub.fire(BALANCE_REFRESH_ALARM);
await settle();
expect(mockBalanceRefreshAt).toHaveLength(0);
});
});

View File

@@ -40,15 +40,6 @@ function clearStorage() {
}
}
// The MV3 service worker is torn down when idle and re-evaluated on the next
// event, which wipes every module-level variable. Re-requiring the module with
// the registry reset is exactly that: fresh in-memory state, same extension
// storage underneath.
function restartWorker() {
jest.resetModules();
return require("../src/shared/phishingDomains");
}
// Reset delta state before each test to avoid cross-test contamination.
// Note: vendored sets are immutable and always present.
beforeEach(() => {
@@ -243,7 +234,16 @@ describe("phishingDomains", () => {
});
});
// The MV3 service worker is torn down when idle and re-evaluated on the next
// event, which wipes every module-level variable. Re-requiring the module
// with the module registry reset is exactly that: fresh in-memory state, same
// extension storage underneath.
describe("phishing list across a service worker restart", () => {
function restartWorker() {
jest.resetModules();
return require("../src/shared/phishingDomains");
}
beforeEach(() => {
clearStorage();
jest.resetModules();
@@ -314,8 +314,8 @@ describe("phishing list across a service worker restart", () => {
});
test("updatePhishingList honours the persisted timestamp on its own", async () => {
// The startup path calls updatePhishingList() directly, so it must
// load persisted state itself rather than relying on anything else
// The alarm handler calls updatePhishingList() directly, so it must
// load persisted state itself rather than relying on the startup path
// having finished first.
const first = require("../src/shared/phishingDomains");
await first.loadConfig({ blacklist: ["alarm-tick-scam-xyz.com"] });
@@ -328,246 +328,3 @@ describe("phishing list across a service worker restart", () => {
expect(revived.isPhishingDomain("alarm-tick-scam-xyz.com")).toBe(true);
});
});
// The alarm period alone must set the cadence. lastFetchTime is stamped when
// the fetch completes, so it lands one fetch latency after the alarm that
// caused it; a freshness guard timed to the alarm period therefore vetoes
// every scheduled tick and halves the real refresh rate. These tests measure
// the interval between fetches that actually happened.
describe("phishing refresh steady-state cadence", () => {
const { PHISHING_REFRESH_PERIOD_MINUTES } = require("../src/shared/alarms");
const PERIOD_MS = PHISHING_REFRESH_PERIOD_MINUTES * 60 * 1000;
let clockSpy;
let now;
beforeEach(() => {
clearStorage();
jest.resetModules();
now = Date.UTC(2026, 0, 1, 0, 0, 0);
clockSpy = jest.spyOn(Date, "now").mockImplementation(() => now);
});
afterEach(() => {
clockSpy.mockRestore();
delete global.fetch;
});
function fetchStub(latencyMs, seen) {
return jest.fn(async () => {
seen.push(now);
// A network fetch takes time, and lastFetchTime is stamped after
// it, not when the alarm fired.
now += latencyMs;
return { ok: true, json: async () => ({ blacklist: [] }) };
});
}
test("ten alarm ticks produce ten fetches, one per period", async () => {
const fetchedAt = [];
global.fetch = fetchStub(5000, fetchedAt);
const startup = require("../src/shared/phishingDomains");
const T0 = now;
await startup.initPhishingList();
expect(fetchedAt).toEqual([T0]);
const TICKS = 10;
let tickAt = T0 + PERIOD_MS;
for (let i = 0; i < TICKS; i++) {
now = tickAt;
tickAt += PERIOD_MS;
// The browser wakes a terminated worker to deliver the alarm, so
// every tick starts from cold memory and the persisted record.
const revived = restartWorker();
await revived.refreshPhishingListOnSchedule();
}
expect(fetchedAt).toHaveLength(TICKS + 1);
const intervals = fetchedAt.slice(1).map((t, i) => t - fetchedAt[i]);
expect(intervals).toEqual(new Array(TICKS).fill(PERIOD_MS));
});
test("the scheduled tick fetches whatever the last fetch's latency was", async () => {
// The alarm fires one period after the previous alarm, which is
// `latency` short of one period since the fetch it caused completed.
for (const latency of [200, 1000, 5000]) {
clearStorage();
jest.resetModules();
storageStore[DELTA_STORAGE_KEY] = {
blacklist: [],
lastFetchTime: now - PERIOD_MS + latency,
lastAttemptTime: now - PERIOD_MS,
};
const mod = require("../src/shared/phishingDomains");
const fetchedAt = [];
global.fetch = fetchStub(latency, fetchedAt);
await mod.refreshPhishingListOnSchedule();
expect(fetchedAt).toHaveLength(1);
}
});
test("a worker wake inside the cache window still does not fetch", async () => {
// The TTL is not removed, only taken off the scheduled path. Chrome
// revives the worker every ~30 seconds and every revival runs the
// startup path, so the TTL still has to keep that off the network.
storageStore[DELTA_STORAGE_KEY] = {
blacklist: [],
lastFetchTime: now - PERIOD_MS + 5000,
lastAttemptTime: now - PERIOD_MS,
};
const mod = require("../src/shared/phishingDomains");
global.fetch = jest.fn();
await mod.initPhishingList();
expect(global.fetch).not.toHaveBeenCalled();
});
});
describe("phishing list timestamps that cannot be trusted", () => {
let clockSpy;
let now;
beforeEach(() => {
clearStorage();
jest.resetModules();
now = Date.UTC(2026, 0, 1, 0, 0, 0);
clockSpy = jest.spyOn(Date, "now").mockImplementation(() => now);
});
afterEach(() => {
clockSpy.mockRestore();
delete global.fetch;
});
function okFetch() {
return jest.fn(async () => ({
ok: true,
json: async () => ({ blacklist: ["recovered-scam-xyz.com"] }),
}));
}
// jest.resetModules() clears the call record of a jest.fn, and simulating
// a worker restart is exactly that call. Anything counted across restarts
// has to be counted outside the mock.
function countingFetch(counter, response) {
return async () => {
counter.calls++;
return response();
};
}
test("a lastFetchTime in the future is discarded rather than trusted", async () => {
// Clock skew or a restored profile backup writes one. Every guard
// measures `Date.now() - stamp` and only tests the lower bound, so a
// stamp a year ahead would suppress updates for a year, and now that
// the value is persisted it would outlive every worker.
storageStore[DELTA_STORAGE_KEY] = {
blacklist: ["poisoned-scam-xyz.com"],
lastFetchTime: now + 365 * 24 * 60 * 60 * 1000,
lastAttemptTime: 0,
};
const mod = require("../src/shared/phishingDomains");
global.fetch = okFetch();
await mod.initPhishingList();
expect(global.fetch).toHaveBeenCalledTimes(1);
expect(mod.isPhishingDomain("recovered-scam-xyz.com")).toBe(true);
// And the record it leaves behind is sane, so recovery is permanent.
expect(
storageStore[DELTA_STORAGE_KEY].lastFetchTime,
).toBeLessThanOrEqual(now);
});
test("a lastAttemptTime in the future does not suppress the retry", async () => {
storageStore[DELTA_STORAGE_KEY] = {
lastAttemptTime: now + 365 * 24 * 60 * 60 * 1000,
};
const mod = require("../src/shared/phishingDomains");
global.fetch = okFetch();
await mod.initPhishingList();
expect(global.fetch).toHaveBeenCalledTimes(1);
});
test("an oversized delta does not re-download on every worker wake", async () => {
// The delta and its freshness claim are both dropped, which is right,
// but nothing then says a fetch just happened. Chrome cycles the
// worker roughly every 30 seconds idle, so without the attempt stamp
// this is a full blocklist download per wake, forever.
const huge = [];
for (let i = 0; i < 20000; i++) {
huge.push(`oversize-scam-${i}-xyzxyzxyzxyzxyz.com`);
}
const counter = { calls: 0 };
global.fetch = countingFetch(counter, () => ({
ok: true,
json: async () => ({ blacklist: huge }),
}));
for (let wake = 0; wake < 4; wake++) {
const revived = restartWorker();
await revived.initPhishingList();
now += 30 * 1000; // idle timeout, worker torn down and revived
}
expect(counter.calls).toBe(1);
expect(storageStore[DELTA_STORAGE_KEY].blacklist).toBeUndefined();
expect(typeof storageStore[DELTA_STORAGE_KEY].lastAttemptTime).toBe(
"number",
);
});
test("a failing fetch is not retried on every worker wake either", async () => {
const counter = { calls: 0 };
global.fetch = countingFetch(counter, () => ({
ok: false,
status: 503,
}));
for (let wake = 0; wake < 4; wake++) {
const revived = restartWorker();
await revived.initPhishingList();
now += 30 * 1000;
}
expect(counter.calls).toBe(1);
});
test("the retry floor expires, so a failure is not permanent", async () => {
const {
MIN_FETCH_ATTEMPT_INTERVAL_MS,
} = require("../src/shared/phishingDomains");
const counter = { calls: 0 };
global.fetch = countingFetch(counter, () => ({
ok: false,
status: 503,
}));
await restartWorker().initPhishingList();
expect(counter.calls).toBe(1);
// Still inside the floor: no retry.
now += MIN_FETCH_ATTEMPT_INTERVAL_MS - 1000;
await restartWorker().initPhishingList();
expect(counter.calls).toBe(1);
// Past it: the extension goes back to the network.
now += 2000;
await restartWorker().initPhishingList();
expect(counter.calls).toBe(2);
});
test("the scheduled tick ignores the retry floor", async () => {
// The alarm period is far above the floor, but the floor exists to
// throttle wakes, not the schedule.
storageStore[DELTA_STORAGE_KEY] = { lastAttemptTime: now - 1000 };
const mod = require("../src/shared/phishingDomains");
global.fetch = okFetch();
await mod.refreshPhishingListOnSchedule();
expect(global.fetch).toHaveBeenCalledTimes(1);
});
});

View File

@@ -1,104 +0,0 @@
const ADDRESS = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
function oneWallet() {
return [{ name: "Wallet 1", type: "hd", addresses: [ADDRESS] }];
}
// state.js resolves the storage API at require time, so the stub has to exist
// before the module is loaded, and the module registry has to be reset between
// cases because `state` is a module-level singleton.
function loadModuleWith(persisted) {
jest.resetModules();
const set = jest.fn(async () => {});
global.chrome = {
storage: {
local: {
get: jest.fn(async () =>
persisted ? { autistmask: persisted } : {},
),
set,
},
},
};
return { mod: require("../src/shared/state"), set };
}
afterEach(() => {
delete global.chrome;
});
describe("loadState hasWallet reconciliation", () => {
// A profile that deleted its last wallet on a build predating the write
// path fix keeps hasWallet: true forever. It must load as no wallet, which
// is what sends the popup to the welcome view.
test("stored hasWallet true with zero wallets loads as no wallet", async () => {
const { mod } = loadModuleWith({ hasWallet: true, wallets: [] });
await mod.loadState();
expect(mod.state.hasWallet).toBe(false);
});
test("stored hasWallet true with a missing wallets key loads as no wallet", async () => {
const { mod } = loadModuleWith({ hasWallet: true });
await mod.loadState();
expect(mod.state.wallets).toEqual([]);
expect(mod.state.hasWallet).toBe(false);
});
test("stored hasWallet false with one wallet loads as having a wallet", async () => {
const { mod } = loadModuleWith({
hasWallet: false,
wallets: oneWallet(),
});
await mod.loadState();
expect(mod.state.hasWallet).toBe(true);
});
test("absent hasWallet with wallets present loads as having a wallet", async () => {
const { mod } = loadModuleWith({ wallets: oneWallet() });
await mod.loadState();
expect(mod.state.hasWallet).toBe(true);
});
test("consistent stored states are preserved", async () => {
const withWallet = loadModuleWith({
hasWallet: true,
wallets: oneWallet(),
});
await withWallet.mod.loadState();
expect(withWallet.mod.state.hasWallet).toBe(true);
const without = loadModuleWith({ hasWallet: false, wallets: [] });
await without.mod.loadState();
expect(without.mod.state.hasWallet).toBe(false);
});
test("empty storage leaves the default no-wallet state", async () => {
const { mod } = loadModuleWith(null);
await mod.loadState();
expect(mod.state.hasWallet).toBe(false);
expect(mod.state.wallets).toEqual([]);
});
// The correction is derived on every load rather than written back, so a
// load never has a storage side effect.
test("loadState does not write to storage", async () => {
const { mod, set } = loadModuleWith({ hasWallet: true, wallets: [] });
await mod.loadState();
expect(set).not.toHaveBeenCalled();
});
// Deriving must not disturb the rest of the load.
test("other persisted fields still load", async () => {
const { mod } = loadModuleWith({
hasWallet: false,
wallets: oneWallet(),
networkId: "sepolia",
theme: "dark",
activeAddress: ADDRESS,
});
await mod.loadState();
expect(mod.state.networkId).toBe("sepolia");
expect(mod.state.theme).toBe("dark");
expect(mod.state.activeAddress).toBe(ADDRESS);
});
});

View File

@@ -36,7 +36,6 @@ global.chrome = { storage: { local: {} } };
const {
fetchRecentTransactions,
filterTransactions,
mergeTransactions,
} = require("../src/shared/transactions");
const { KNOWN_SYMBOLS } = require("../src/shared/tokenList");
const { debugFetch } = require("../src/shared/log");
@@ -686,339 +685,6 @@ describe("legitimate transactions are never filtered", () => {
});
});
// ---------------------------------------------------------------------------
// mergeTransactions is the pure core of the merge: it takes parsed native
// entries and parsed token transfers and decides how many rows one on-chain
// transaction becomes. One transaction is one row per distinct value
// movement, so the native side of a plain ERC-20 transfer must not survive
// next to its token row (the duplicate-row bug), while a hash that really
// did move several things must keep a row for each.
// ---------------------------------------------------------------------------
// A native entry as parseTx produces it for a decoded contract call: the
// amount fields are blanked and direction is "contract".
function contractCallTx(overrides = {}) {
return nativeTx({
from: VICTIM,
to: USDC_CONTRACT,
value: "",
exactValue: "",
rawAmount: "",
rawUnit: "",
valueGwei: 0,
direction: "contract",
directionLabel: "Approve",
isContractCall: true,
method: "approve",
...overrides,
});
}
// The native entry parseTx produces for a plain ERC-20 transfer: sent to the
// token contract, no ETH, and method "transfer", which is exactly why it is
// not marked as a display-level contract call.
function erc20CallTx(overrides = {}) {
return nativeTx({
from: VICTIM,
to: USDC_CONTRACT,
value: "0.0000",
exactValue: "0.0",
rawAmount: "0",
valueGwei: 0,
direction: "sent",
directionLabel: "Sent",
isContractCall: true,
method: "transfer",
...overrides,
});
}
describe("mergeTransactions: one row per value movement", () => {
const HASH = "0x" + "d".repeat(64);
const OTHER_HASH = "0x" + "e".repeat(64);
const ROUTER = "0x3fc91a3afd70395cd496c647d5a6cc9d4b2b7fad";
test("a plain ERC-20 transfer yields one row, the token row", () => {
const native = erc20CallTx({ hash: HASH });
const token = tokenTx({
hash: HASH,
from: VICTIM,
to: ORDINARY_PEER,
direction: "sent",
directionLabel: "Sent",
});
const merged = mergeTransactions([native], [token]);
expect(merged).toHaveLength(1);
expect(merged[0].symbol).toBe("USDC");
expect(merged[0].exactValue).toBe("1500.5");
expect(merged[0].contractAddress).toBe(USDC_CONTRACT);
});
test("an ETH-only transfer keeps its row unchanged", () => {
const merged = mergeTransactions([legitimateEthSend()], []);
expect(merged).toHaveLength(1);
expect(merged[0]).toEqual(legitimateEthSend());
});
test("a genuine zero-value native transaction is still displayed", () => {
const zero = nativeTx({
hash: HASH,
from: VICTIM,
to: ORDINARY_PEER,
value: "0.0000",
exactValue: "0.0",
rawAmount: "0",
valueGwei: 0,
direction: "sent",
directionLabel: "Sent",
});
const merged = mergeTransactions([zero], []);
expect(merged).toEqual([zero]);
});
test("a zero-value native row is only absorbed by a transfer sharing its hash", () => {
const zero = erc20CallTx({ hash: HASH });
const unrelated = tokenTx({ hash: OTHER_HASH });
const merged = mergeTransactions([zero], [unrelated]);
expect(merged).toHaveLength(2);
expect(merged.map((t) => t.hash).sort()).toEqual(
[HASH, OTHER_HASH].sort(),
);
});
test("a native transaction that moved ETH keeps its row beside the token row", () => {
// An undecoded call (no method name) carrying ETH that also emitted
// a token transfer: two real movements, so two rows.
const native = nativeTx({
hash: HASH,
from: VICTIM,
to: ROUTER,
value: "0.2500",
exactValue: "0.25",
rawAmount: "250000000000000000",
valueGwei: 250000000,
direction: "sent",
directionLabel: "Sent",
isContractCall: true,
});
const token = tokenTx({ hash: HASH, from: ROUTER, to: VICTIM });
const merged = mergeTransactions([native], [token]);
expect(merged).toHaveLength(2);
expect(merged.map((t) => t.symbol).sort()).toEqual(["ETH", "USDC"]);
});
test("a sub-gwei ETH movement keeps its row beside the token row", () => {
// 500000000 wei is 0.5 gwei, so parseTx's valueGwei floors to 0 while
// rawAmount stays nonzero. Deciding "moved no ETH" on valueGwei would
// delete this row and lose a real ETH movement, so the decision is made
// on rawAmount as a BigInt.
const native = nativeTx({
hash: HASH,
from: VICTIM,
to: ROUTER,
value: "0.0000",
exactValue: "0.0000000005",
rawAmount: "500000000",
valueGwei: 0,
direction: "sent",
directionLabel: "Sent",
isContractCall: true,
});
const token = tokenTx({ hash: HASH, from: ROUTER, to: VICTIM });
const merged = mergeTransactions([native], [token]);
expect(merged).toHaveLength(2);
expect(merged.map((t) => t.symbol).sort()).toEqual(["ETH", "USDC"]);
expect(merged.find((t) => t.symbol === "ETH").rawAmount).toBe(
"500000000",
);
});
test("a swap consolidates every token leg into one row, preferring the received leg", () => {
const native = contractCallTx({
hash: HASH,
to: ROUTER,
directionLabel: "Swap",
method: "execute",
});
const sentLeg = tokenTx({
hash: HASH,
from: VICTIM,
to: ROUTER,
direction: "sent",
directionLabel: "Sent",
});
const receivedLeg = tokenTx({
hash: HASH,
from: ROUTER,
to: VICTIM,
value: "0.2500",
exactValue: "0.25",
rawAmount: "250000000000000000",
rawUnit: "WETH base units (10^-18)",
symbol: "WETH",
contractAddress: WETH_CONTRACT,
holders: 850000,
});
const merged = mergeTransactions([native], [sentLeg, receivedLeg]);
expect(merged).toHaveLength(1);
expect(merged[0].symbol).toBe("WETH");
expect(merged[0].exactValue).toBe("0.25");
// The user's own address and the contract called are preserved.
expect(merged[0].from).toBe(VICTIM);
expect(merged[0].to).toBe(ROUTER);
expect(merged[0].directionLabel).toBe("Swap");
});
test("a swap whose legs are all sent takes its amount from the first sent leg", () => {
const native = contractCallTx({
hash: HASH,
to: ROUTER,
directionLabel: "Swap",
method: "execute",
});
const firstSent = tokenTx({
hash: HASH,
from: VICTIM,
to: ROUTER,
direction: "sent",
directionLabel: "Sent",
});
const secondSent = tokenTx({
hash: HASH,
from: VICTIM,
to: ROUTER,
value: "0.2500",
exactValue: "0.25",
rawAmount: "250000000000000000",
rawUnit: "WETH base units (10^-18)",
symbol: "WETH",
contractAddress: WETH_CONTRACT,
holders: 850000,
direction: "sent",
directionLabel: "Sent",
});
const merged = mergeTransactions([native], [firstSent, secondSent]);
expect(merged).toHaveLength(1);
// With no received leg the display amount comes from the first sent
// leg, and a later sent leg does not overwrite it.
expect(merged[0].symbol).toBe("USDC");
expect(merged[0].exactValue).toBe("1500.5");
expect(merged[0].contractAddress).toBe(USDC_CONTRACT);
expect(merged[0].holders).toBe(3500000);
});
test("a contract call carrying ETH plus a token transfer stays one row", () => {
const native = contractCallTx({
hash: HASH,
to: ROUTER,
directionLabel: "Swap",
method: "swapExactETHForTokens",
valueGwei: 250000000,
});
const received = tokenTx({ hash: HASH, from: ROUTER, to: VICTIM });
const merged = mergeTransactions([native], [received]);
expect(merged).toHaveLength(1);
expect(merged[0].symbol).toBe("USDC");
expect(merged[0].exactValue).toBe("1500.5");
// The ETH leg is still visible as the row's native quantity.
expect(merged[0].valueGwei).toBe(250000000);
});
test("an approve keeps its row and survives the filters", () => {
const approve = contractCallTx({ hash: HASH });
const merged = mergeTransactions([approve], []);
expect(merged).toEqual([approve]);
expect(filterTransactions(merged, filters()).transactions).toEqual([
approve,
]);
});
test("a contract creation keeps its row", () => {
const creation = nativeTx({
hash: HASH,
from: VICTIM,
to: "",
value: "0.0000",
exactValue: "0.0",
rawAmount: "0",
valueGwei: 0,
direction: "sent",
directionLabel: "Sent",
});
expect(mergeTransactions([creation], [])).toEqual([creation]);
});
test("a native self-send keeps its single row", () => {
const selfSend = nativeTx({
hash: HASH,
from: VICTIM,
to: VICTIM,
direction: "sent",
directionLabel: "Sent",
});
expect(mergeTransactions([selfSend], [])).toEqual([selfSend]);
});
test("a token self-send yields one row", () => {
const native = erc20CallTx({ hash: HASH });
const token = tokenTx({
hash: HASH,
from: VICTIM,
to: VICTIM,
direction: "sent",
directionLabel: "Sent",
});
const merged = mergeTransactions([native], [token]);
expect(merged).toHaveLength(1);
expect(merged[0].symbol).toBe("USDC");
expect(merged[0].from).toBe(VICTIM);
expect(merged[0].to).toBe(VICTIM);
});
test("several distinct tokens moved by one ERC-20 call keep a row each", () => {
const native = erc20CallTx({ hash: HASH });
const usdc = tokenTx({ hash: HASH });
const weth = tokenTx({
hash: HASH,
symbol: "WETH",
contractAddress: WETH_CONTRACT,
holders: 850000,
});
const merged = mergeTransactions([native], [usdc, weth]);
expect(merged.map((t) => t.symbol).sort()).toEqual(["USDC", "WETH"]);
});
test("rows are sorted by block number, newest first", () => {
const older = nativeTx({ hash: HASH, blockNumber: 21000000 });
const newer = nativeTx({ hash: OTHER_HASH, blockNumber: 21000010 });
const merged = mergeTransactions([older, newer], []);
expect(merged.map((t) => t.blockNumber)).toEqual([21000010, 21000000]);
});
test("the entries handed in are never mutated", () => {
const native = contractCallTx({ hash: HASH, method: "execute" });
const token = tokenTx({ hash: HASH });
const before = JSON.stringify([native, token]);
mergeTransactions([native], [token]);
expect(JSON.stringify([native, token])).toBe(before);
});
});
// ---------------------------------------------------------------------------
// fetchRecentTransactions owns the per-address merge of normal transactions
// with ERC-20 transfers. (The cross-address merge Home performs lives in
@@ -1220,12 +886,13 @@ describe("fetchRecentTransactions merge and dedup", () => {
expect(txs.map((t) => t.symbol).sort()).toEqual(["USDC", "WETH"]);
});
// Regression guard for the duplicate-row bug: for a plain ERC-20
// transfer the method is "transfer", so parseTx does not mark the entry
// as a contract call in the display sense. The native side of that
// transaction moved no ETH and is represented by the token row, so it
// must not survive the merge as a second, zero-value row.
test("a plain ERC-20 transfer produces exactly one entry", async () => {
// Documents current behaviour: for a plain ERC-20 transfer the method is
// "transfer", so parseTx does not mark the entry as a contract call in
// the display sense and the merge loop does not consolidate the token
// transfer into it. The result is two entries for one transaction: a
// zero-value native row and the real token row. The zero-value row also
// escapes dust filtering because isContractCall is true.
test("current behaviour: a plain ERC-20 transfer produces two entries", async () => {
const hash = "0x" + "5".repeat(64);
respondWith(
[
@@ -1258,15 +925,14 @@ describe("fetchRecentTransactions merge and dedup", () => {
);
const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT);
expect(txs).toHaveLength(1);
expect(txs[0].symbol).toBe("USDC");
expect(txs[0].exactValue).toBe("1.0");
expect(txs[0].direction).toBe("sent");
expect(txs[0].contractAddress).toBe(USDC_CONTRACT);
// The surviving row is the token row, and the filters keep it.
expect(txs).toHaveLength(2);
expect(txs.map((t) => t.symbol).sort()).toEqual(["ETH", "USDC"]);
const nativeRow = txs.find((t) => t.symbol === "ETH");
expect(nativeRow.exactValue).toBe("0.0");
expect(nativeRow.isContractCall).toBe(true);
// And the zero-value row is not removed by the dust filter.
const kept = filterTransactions(txs, filters()).transactions;
expect(kept).toHaveLength(1);
expect(kept[0].symbol).toBe("USDC");
expect(kept).toHaveLength(2);
});
test("entries are sorted by block number descending and capped at count", async () => {

View File

@@ -1,346 +0,0 @@
// Tests for src/shared/vault.js: the Argon2id + XSalsa20-Poly1305 encryption
// that protects recovery phrases and private keys at rest.
//
// The properties that matter here are the ones whose failure is silent. A
// vault that decrypts under the wrong password, that hands back plaintext from
// a ciphertext an attacker edited, that reuses a nonce, or that leaves the
// recovery phrase readable somewhere in the stored blob all look exactly like
// a working vault from the UI. So each test below asserts a negative: the
// thing that must not happen.
//
// Cost: every encrypt and decrypt runs one Argon2id pwhash at the production
// interactive parameters, which the module hardcodes. The parameters are not
// weakened or overridden anywhere in this file — they are pinned by the "key
// derivation cost" tests, since they are the vault's only defence against an
// offline attack on a stolen blob. The suite is kept inside script/test's
// 30-second budget by sharing one encrypted fixture across the tamper cases
// instead of re-encrypting per test.
const sodium = require("libsodium-wrappers-sumo");
const {
encryptWithPassword,
decryptWithPassword,
} = require("../src/shared/vault");
// A publicly known development phrase. Never fund it.
const SECRET = "test test test test test test test test test test test junk";
const PASSWORD = "correct horse battery staple";
const WRONG_PASSWORD = "correct horse battery stapl";
const SALT_BYTES = 16;
const NONCE_BYTES = 24;
const POLY1305_TAG_BYTES = 16;
const BASE64 = /^[A-Za-z0-9+/_-]+={0,2}$/;
function b64decode(s) {
return sodium.from_base64(s);
}
// A shallow copy with one field replaced, so the shared fixture is never
// mutated by a tamper test.
function withField(blob, field, value) {
return { ...blob, [field]: value };
}
// Flip the low bit of one byte of a base64-encoded field.
function flipByte(b64, index) {
const bytes = b64decode(b64);
bytes[index] ^= 0x01;
return sodium.to_base64(bytes);
}
let vault;
beforeAll(async () => {
await sodium.ready;
vault = await encryptWithPassword(SECRET, PASSWORD);
});
describe("stored blob shape", () => {
test("is exactly the documented { salt, nonce, ciphertext }", () => {
expect(Object.keys(vault).sort()).toEqual([
"ciphertext",
"nonce",
"salt",
]);
});
test("every field is a base64 string", () => {
for (const field of ["salt", "nonce", "ciphertext"]) {
expect(typeof vault[field]).toBe("string");
expect(vault[field]).toMatch(BASE64);
}
});
test("salt and nonce are full length", () => {
expect(b64decode(vault.salt)).toHaveLength(SALT_BYTES);
expect(b64decode(vault.nonce)).toHaveLength(NONCE_BYTES);
});
test("ciphertext carries a Poly1305 authentication tag", () => {
expect(b64decode(vault.ciphertext)).toHaveLength(
SECRET.length + POLY1305_TAG_BYTES,
);
});
test("the blob survives JSON storage unchanged", async () => {
const stored = JSON.parse(JSON.stringify(vault));
await expect(decryptWithPassword(stored, PASSWORD)).resolves.toBe(
SECRET,
);
});
});
describe("no plaintext leakage", () => {
test("the secret does not appear in the serialized vault", () => {
const serialized = JSON.stringify(vault);
expect(serialized).not.toContain(SECRET);
for (const word of new Set(SECRET.split(" "))) {
expect(serialized).not.toContain(word);
}
});
test("the ciphertext bytes do not contain the secret bytes", () => {
const bytes = Buffer.from(b64decode(vault.ciphertext));
expect(bytes.includes(Buffer.from(SECRET, "utf8"))).toBe(false);
// Not even the first word, which would betray an unencrypted prefix.
expect(bytes.includes(Buffer.from("test test", "utf8"))).toBe(false);
});
test("the password does not appear in the serialized vault", () => {
expect(JSON.stringify(vault)).not.toContain(PASSWORD);
});
});
describe("round trip", () => {
test("decrypts back to the original secret", async () => {
await expect(decryptWithPassword(vault, PASSWORD)).resolves.toBe(
SECRET,
);
});
test("survives a non-ASCII plaintext byte for byte", async () => {
const unicode = "recovery phrase é中文\u{1f600}";
const blob = await encryptWithPassword(unicode, PASSWORD);
await expect(decryptWithPassword(blob, PASSWORD)).resolves.toBe(
unicode,
);
});
test("an empty password still round-trips and is not a bypass", async () => {
const blob = await encryptWithPassword(SECRET, "");
await expect(decryptWithPassword(blob, "")).resolves.toBe(SECRET);
// An empty password must not act as a skeleton key on other vaults,
// nor may a real password open an empty-password vault.
await expect(decryptWithPassword(vault, "")).rejects.toThrow();
await expect(decryptWithPassword(blob, PASSWORD)).rejects.toThrow();
});
});
describe("fresh salt and nonce", () => {
test("two encryptions of the same plaintext differ in all three fields", async () => {
const second = await encryptWithPassword(SECRET, PASSWORD);
expect(second.salt).not.toBe(vault.salt);
expect(second.nonce).not.toBe(vault.nonce);
expect(second.ciphertext).not.toBe(vault.ciphertext);
await expect(decryptWithPassword(second, PASSWORD)).resolves.toBe(
SECRET,
);
});
});
describe("key derivation cost", () => {
// Argon2id's opslimit and memlimit are the whole of the vault's resistance
// to an offline attack on a stolen blob, and lowering them breaks nothing
// any other test here can see — the suite merely runs faster. So pin them
// directly, both to libsodium's INTERACTIVE constants and to the absolute
// values those constants must keep meaning.
const INTERACTIVE_OPSLIMIT = 2;
const INTERACTIVE_MEMLIMIT = 64 * 1024 * 1024;
test("the interactive constants still mean 2 passes over 64 MiB", () => {
expect(sodium.crypto_pwhash_OPSLIMIT_INTERACTIVE).toBe(
INTERACTIVE_OPSLIMIT,
);
expect(sodium.crypto_pwhash_MEMLIMIT_INTERACTIVE).toBe(
INTERACTIVE_MEMLIMIT,
);
// The floor these must never quietly be swapped for: _MIN is one pass
// over 8 KiB, an 8192x reduction in memory cost.
expect(sodium.crypto_pwhash_OPSLIMIT_MIN).toBeLessThan(
INTERACTIVE_OPSLIMIT,
);
expect(sodium.crypto_pwhash_MEMLIMIT_MIN).toBeLessThan(
INTERACTIVE_MEMLIMIT,
);
});
test("a key derived at the interactive parameters opens the vault", () => {
// Independent of any spy, and of the module's own code path: derive
// the key here from the vault's published salt at the interactive cost
// and open its ciphertext directly. A vault whose key came from any
// other opslimit, memlimit or Argon2id variant yields a different key
// and cannot be opened this way.
const key = sodium.crypto_pwhash(
sodium.crypto_secretbox_KEYBYTES,
PASSWORD,
b64decode(vault.salt),
INTERACTIVE_OPSLIMIT,
INTERACTIVE_MEMLIMIT,
sodium.crypto_pwhash_ALG_ARGON2ID13,
);
const opened = sodium.crypto_secretbox_open_easy(
b64decode(vault.ciphertext),
b64decode(vault.nonce),
key,
);
expect(sodium.to_string(opened)).toBe(SECRET);
});
test.each([
[
"encrypt",
async () => {
await encryptWithPassword(SECRET, PASSWORD);
},
],
[
"decrypt",
async () => {
await decryptWithPassword(vault, PASSWORD);
},
],
])("%s derives exactly one key at the interactive cost", async (_, run) => {
const spy = jest.spyOn(sodium, "crypto_pwhash");
try {
await run();
expect(spy).toHaveBeenCalledTimes(1);
const [keyBytes, , salt, opslimit, memlimit, alg] =
spy.mock.calls[0];
expect(keyBytes).toBe(sodium.crypto_secretbox_KEYBYTES);
expect(salt).toHaveLength(SALT_BYTES);
expect(opslimit).toBe(sodium.crypto_pwhash_OPSLIMIT_INTERACTIVE);
expect(memlimit).toBe(sodium.crypto_pwhash_MEMLIMIT_INTERACTIVE);
expect(alg).toBe(sodium.crypto_pwhash_ALG_ARGON2ID13);
} finally {
spy.mockRestore();
}
});
});
describe("wrong password", () => {
test("is rejected, and rejects cleanly", async () => {
// rejects.toThrow asserts a rejected promise, not a synchronous throw
// and not an unhandled rejection: the caller can catch this.
await expect(
decryptWithPassword(vault, WRONG_PASSWORD),
).rejects.toThrow();
});
test("returns no plaintext, not even partially", async () => {
const result = await decryptWithPassword(vault, WRONG_PASSWORD).catch(
(err) => err,
);
expect(result).toBeInstanceOf(Error);
expect(String(result)).not.toContain("test");
});
test("the empty password is rejected on a password-protected vault", async () => {
await expect(decryptWithPassword(vault, "")).rejects.toThrow();
});
});
describe("tampering", () => {
test("a flipped ciphertext bit is rejected by the auth tag", async () => {
const tampered = withField(
vault,
"ciphertext",
flipByte(vault.ciphertext, 0),
);
await expect(decryptWithPassword(tampered, PASSWORD)).rejects.toThrow();
});
test("a flipped bit in the authentication tag itself is rejected", async () => {
const tagStart = b64decode(vault.ciphertext).length - 1;
const tampered = withField(
vault,
"ciphertext",
flipByte(vault.ciphertext, tagStart),
);
await expect(decryptWithPassword(tampered, PASSWORD)).rejects.toThrow();
});
test("a flipped nonce bit is rejected", async () => {
const tampered = withField(vault, "nonce", flipByte(vault.nonce, 0));
await expect(decryptWithPassword(tampered, PASSWORD)).rejects.toThrow();
});
test("a flipped salt bit is rejected", async () => {
const tampered = withField(vault, "salt", flipByte(vault.salt, 0));
await expect(decryptWithPassword(tampered, PASSWORD)).rejects.toThrow();
});
test("a truncated ciphertext is rejected", async () => {
const bytes = b64decode(vault.ciphertext);
const tampered = withField(
vault,
"ciphertext",
sodium.to_base64(bytes.slice(0, bytes.length - 4)),
);
await expect(decryptWithPassword(tampered, PASSWORD)).rejects.toThrow();
});
test("a ciphertext shorter than the auth tag is rejected", async () => {
const tampered = withField(
vault,
"ciphertext",
sodium.to_base64(b64decode(vault.ciphertext).slice(0, 4)),
);
await expect(decryptWithPassword(tampered, PASSWORD)).rejects.toThrow();
});
test("a truncated nonce is rejected", async () => {
const tampered = withField(
vault,
"nonce",
sodium.to_base64(b64decode(vault.nonce).slice(0, NONCE_BYTES - 1)),
);
await expect(decryptWithPassword(tampered, PASSWORD)).rejects.toThrow();
});
test("a ciphertext from another vault is rejected", async () => {
const other = await encryptWithPassword("a different secret", PASSWORD);
const spliced = withField(vault, "ciphertext", other.ciphertext);
await expect(decryptWithPassword(spliced, PASSWORD)).rejects.toThrow();
});
test("a missing field is rejected rather than decrypted", async () => {
for (const field of ["salt", "nonce", "ciphertext"]) {
const broken = { ...vault };
delete broken[field];
await expect(
decryptWithPassword(broken, PASSWORD),
).rejects.toThrow();
}
});
});

View File

@@ -1,6 +1,4 @@
// Tests for src/shared/wallet.js: the DEBUG build flag as it gates mnemonic
// generation (first two describes), and HD key derivation against published
// known-answer vectors (rest of the file).
// Tests for the DEBUG build flag as it gates mnemonic generation.
//
// The modules read the __BUILD_DEBUG__ global that esbuild replaces at bundle
// time. Under jest the global is absent, which is exactly the release-build
@@ -94,317 +92,3 @@ describe("generateMnemonic in a debug build", () => {
);
});
});
// ---------------------------------------------------------------------------
// Key derivation.
//
// Every address below is a published constant, not something this codebase
// produced. Asserting against what the implementation happens to return today
// would pass just as happily with the wrong coin type, the wrong path depth or
// a non-empty seed passphrase, all of which silently send funds to addresses
// no other wallet can recover.
//
// Vector sources:
//
// VECTOR_PHRASE / VECTOR_ADDRESSES / VECTOR_PRIVATE_KEYS — the standard
// development recovery phrase and the first three accounts it yields at
// m/44'/60'/0'/0/n with an empty seed passphrase, as published in the
// Hardhat and Ganache documentation. Publicly known; never fund it.
//
// ZERO_ENTROPY_PHRASE / ZERO_ENTROPY_ADDRESS — the BIP-39 all-zero-entropy
// phrase (Trezor's official BIP-39 vector set, first entry) and its
// m/44'/60'/0'/0/0 Ethereum address with an empty seed passphrase. A second,
// independently published phrase so the pin is not one vector deep.
//
// BIP32_VECTOR_1_XPRV — the master key of BIP-32 test vector 1
// (seed 000102030405060708090a0b0c0d0e0f).
//
// The two Hardhat facts cross-check each other: VECTOR_PRIVATE_KEYS[n] is the
// published key for VECTOR_ADDRESSES[n], so addressFromPrivateKey and the HD
// path must meet at the same address from two different directions.
const { HDNodeWallet, Mnemonic, verifyMessage } = require("ethers");
const wallet = require("../src/shared/wallet");
const { BIP44_ETH_PATH } = require("../src/shared/constants");
const VECTOR_PHRASE =
"test test test test test test test test test test test junk";
const VECTOR_ADDRESSES = [
"0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266",
"0x70997970C51812dc3A010C7d01b50e0d17dc79C8",
"0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC",
];
const VECTOR_PRIVATE_KEYS = [
"0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80",
"0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d",
"0x5de4111afa1a4b94908f83103eb1f1706367c2e68ca870fc3fb9a804cdab365a",
];
const ZERO_ENTROPY_PHRASE =
"abandon abandon abandon abandon abandon abandon " +
"abandon abandon abandon abandon abandon about";
const ZERO_ENTROPY_ADDRESS = "0x9858EfFD232B4033E47d90003D41EC34EcaEda94";
const BIP32_VECTOR_1_XPRV =
"xprv9s21ZrQH143K3QTDL4LXw2F7HEK3wJUD2nW2nRk4stbPy6cq3jPPqji" +
"ChkVvvNKmPGJxWUtg6LnF5kejMRNNU3TGtRBeJgk33yuGBxrMPHi";
// The master (depth-0) extended private key for a phrase, which is what the
// import-an-xprv flow is handed. Built with ethers rather than with the module
// under test, so hdWalletFromXprv is not being checked against itself.
function masterXprv(phrase, passphrase = "") {
return HDNodeWallet.fromSeed(
Mnemonic.fromPhrase(phrase, passphrase).computeSeed(),
).extendedKey;
}
describe("hdWalletFromMnemonic", () => {
test("first address matches the published vector for m/44'/60'/0'/0/0", () => {
expect(wallet.hdWalletFromMnemonic(VECTOR_PHRASE).firstAddress).toBe(
VECTOR_ADDRESSES[0],
);
});
test("second published phrase derives its published address", () => {
expect(
wallet.hdWalletFromMnemonic(ZERO_ENTROPY_PHRASE).firstAddress,
).toBe(ZERO_ENTROPY_ADDRESS);
});
test("returns the account-level xpub, which is watch-only", () => {
const { xpub } = wallet.hdWalletFromMnemonic(VECTOR_PHRASE);
expect(xpub.startsWith("xpub")).toBe(true);
// A neutered ethers node exposes no private key at all, so accept
// either absent or null rather than pinning which.
expect(
HDNodeWallet.fromExtendedKey(xpub).privateKey ?? null,
).toBeNull();
expect(wallet.isValidXprv(xpub)).toBe(false);
});
test("the account path is the documented BIP-44 Ethereum path", () => {
expect(BIP44_ETH_PATH).toBe("m/44'/60'/0'/0");
});
test("rejects an invalid recovery phrase rather than deriving from it", () => {
expect(() => wallet.hdWalletFromMnemonic("not a phrase")).toThrow();
});
});
describe("deriveAddressFromXpub", () => {
const { xpub } = wallet.hdWalletFromMnemonic(VECTOR_PHRASE);
test.each([0, 1, 2])(
"child %i matches the published vector address",
(index) => {
expect(wallet.deriveAddressFromXpub(xpub, index)).toBe(
VECTOR_ADDRESSES[index],
);
},
);
test("agrees with hdWalletFromMnemonic at index 0", () => {
expect(wallet.deriveAddressFromXpub(xpub, 0)).toBe(
wallet.hdWalletFromMnemonic(VECTOR_PHRASE).firstAddress,
);
});
test("rejects garbage instead of returning an address", () => {
expect(() =>
wallet.deriveAddressFromXpub("xpub-nonsense", 0),
).toThrow();
});
});
describe("hdWalletFromMnemonic seed passphrase handling", () => {
// The vectors above are only reproducible with an empty BIP-39 seed
// passphrase. This pins that the empty string reaching
// HDNodeWallet.fromPhrase is load-bearing: with any passphrase applied the
// published address is unreachable, and a wallet derived that way could
// not be restored anywhere else from the phrase alone.
test("a non-empty seed passphrase would yield a different address", () => {
const withPassphrase = HDNodeWallet.fromPhrase(
VECTOR_PHRASE,
"TREZOR",
BIP44_ETH_PATH,
).deriveChild(0).address;
expect(withPassphrase).not.toBe(VECTOR_ADDRESSES[0]);
});
});
describe("hdWalletFromXprv", () => {
// hdWalletFromMnemonic derives the absolute path "m/44'/60'/0'/0" while
// hdWalletFromXprv derives the relative path "44'/60'/0'/0". For a
// depth-0 master key the two are the same derivation; these tests pin that
// equivalence to a published address rather than assuming it.
test("master xprv for the vector phrase yields the vector address", () => {
expect(
wallet.hdWalletFromXprv(masterXprv(VECTOR_PHRASE)).firstAddress,
).toBe(VECTOR_ADDRESSES[0]);
});
test("agrees with hdWalletFromMnemonic on xpub and address", () => {
const fromPhrase = wallet.hdWalletFromMnemonic(VECTOR_PHRASE);
const fromXprv = wallet.hdWalletFromXprv(masterXprv(VECTOR_PHRASE));
expect(fromXprv).toEqual(fromPhrase);
});
test("derived xpub generates the same child addresses", () => {
const { xpub } = wallet.hdWalletFromXprv(masterXprv(VECTOR_PHRASE));
expect(
[0, 1, 2].map((i) => wallet.deriveAddressFromXpub(xpub, i)),
).toEqual(VECTOR_ADDRESSES);
});
test("accepts the BIP-32 test vector 1 master key", () => {
const { xpub, firstAddress } =
wallet.hdWalletFromXprv(BIP32_VECTOR_1_XPRV);
expect(xpub.startsWith("xpub")).toBe(true);
expect(firstAddress).toMatch(/^0x[0-9a-fA-F]{40}$/);
});
test("rejects a watch-only xpub", () => {
const { xpub } = wallet.hdWalletFromMnemonic(VECTOR_PHRASE);
expect(() => wallet.hdWalletFromXprv(xpub)).toThrow();
});
test("rejects garbage", () => {
expect(() => wallet.hdWalletFromXprv("nonsense")).toThrow();
});
});
describe("isValidXprv", () => {
test.each([
["BIP-32 test vector 1 master key", BIP32_VECTOR_1_XPRV, true],
["the empty string", "", false],
["garbage", "not-a-key", false],
["a bare private key", VECTOR_PRIVATE_KEYS[0], false],
["a truncated xprv", BIP32_VECTOR_1_XPRV.slice(0, -6), false],
["an xprv with an extra character", BIP32_VECTOR_1_XPRV + "a", false],
])("%s -> %s", (_name, key, expected) => {
expect(wallet.isValidXprv(key)).toBe(expected);
});
test("a watch-only xpub is not an xprv", () => {
const { xpub } = wallet.hdWalletFromMnemonic(VECTOR_PHRASE);
expect(wallet.isValidXprv(xpub)).toBe(false);
});
// Skipped: this asserts the correct behaviour, which the code does not
// currently have. isValidXprv gates the paste-your-extended-private-key
// import in src/popup/views/addWallet.js:215, and it accepts a key with a
// one-character typo: ethers' HDNodeWallet.fromExtendedKey skips base58
// checksum verification whenever the decoded payload is the usual 82
// bytes, which is the whole point of that checksum. Measured on this
// vector: changing any one of the last 14 characters passes validation,
// and for 9 of those 14 positions the import silently yields a *different*
// wallet (e.g. 0x3F334f0a356d6B46B1d70B590E7437D77100d28D instead of
// 0x022b971dFF0C43305e691DEd7a14367AF19D6407) with no error shown.
// Tracked as https://git.eeqj.de/sneak/AutistMask/issues/210; out of scope
// here, which is tests only. Unskip when it is fixed.
test.skip("rejects an extended key with a one-character typo", () => {
const index = BIP32_VECTOR_1_XPRV.length - 8;
const typo =
BIP32_VECTOR_1_XPRV.slice(0, index) +
(BIP32_VECTOR_1_XPRV[index] === "a" ? "b" : "a") +
BIP32_VECTOR_1_XPRV.slice(index + 1);
expect(wallet.isValidXprv(typo)).toBe(false);
});
});
describe("isValidMnemonic", () => {
test.each([
["the vector phrase", VECTOR_PHRASE, true],
["the BIP-39 zero-entropy phrase", ZERO_ENTROPY_PHRASE, true],
[
"a 12-word phrase with a bad checksum",
"abandon abandon abandon abandon abandon abandon " +
"abandon abandon abandon abandon abandon abandon",
false,
],
["an 11-word phrase", "abandon ".repeat(10) + "about", false],
["a word outside the wordlist", VECTOR_PHRASE + " zzzzzz", false],
["the empty string", "", false],
["garbage", "correct horse battery staple", false],
])("%s -> %s", (_name, phrase, expected) => {
expect(wallet.isValidMnemonic(phrase)).toBe(expected);
});
});
describe("addressFromPrivateKey", () => {
test.each([0, 1, 2])(
"published key %i yields its published address",
(index) => {
expect(
wallet.addressFromPrivateKey(VECTOR_PRIVATE_KEYS[index]),
).toBe(VECTOR_ADDRESSES[index]);
},
);
test("rejects a key of the wrong length", () => {
expect(() => wallet.addressFromPrivateKey("0xdeadbeef")).toThrow();
});
test("rejects the empty string", () => {
expect(() => wallet.addressFromPrivateKey("")).toThrow();
});
});
describe("getSignerForAddress", () => {
test.each([0, 1, 2])("hd wallet, address index %i", (index) => {
const signer = wallet.getSignerForAddress(
{ type: "hd" },
index,
VECTOR_PHRASE,
);
expect(signer.address).toBe(VECTOR_ADDRESSES[index]);
expect(signer.privateKey).toBe(VECTOR_PRIVATE_KEYS[index]);
});
test.each([0, 1, 2])("xprv wallet, address index %i", (index) => {
const signer = wallet.getSignerForAddress(
{ type: "xprv" },
index,
masterXprv(VECTOR_PHRASE),
);
expect(signer.address).toBe(VECTOR_ADDRESSES[index]);
expect(signer.privateKey).toBe(VECTOR_PRIVATE_KEYS[index]);
});
test("single private key ignores the address index", () => {
for (const index of [0, 1, 2]) {
const signer = wallet.getSignerForAddress(
{ type: "privkey" },
index,
VECTOR_PRIVATE_KEYS[1],
);
expect(signer.address).toBe(VECTOR_ADDRESSES[1]);
}
});
test("the returned signer signs recoverably as the expected address", async () => {
const signer = wallet.getSignerForAddress(
{ type: "hd" },
1,
VECTOR_PHRASE,
);
const message = "AutistMask derivation test";
const signature = await signer.signMessage(message);
expect(verifyMessage(message, signature)).toBe(VECTOR_ADDRESSES[1]);
});
});