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.
This commit is contained in:
clawbot
2026-08-11 12:24:55 +00:00
parent 19cb1ca1b0
commit cafffe5ab9
11 changed files with 709 additions and 79 deletions

View File

@@ -129,10 +129,11 @@ page, which it does not by default — `script/test-e2e` sets
`PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS=1` for it. Because that flag is
experimental, the harness does not take it on trust. At launch it waits for the
background worker's **own** startup request — the phishing blocklist fetch that
`src/background/index.js` issues unconditionally — to arrive in the route
handler, and aborts the entire suite if none does within 30 seconds
(`tests/e2e/harness.js`). The check is passive on purpose: a synthetic probe
fetched from inside the worker via `worker.evaluate()` was tried first and
`src/background/index.js` issues on startup, which on the suite's throwaway
profile always happens because no previous fetch timestamp is persisted — to
arrive in the route handler, and aborts the entire suite if none does within 30
seconds (`tests/e2e/harness.js`). The check is passive on purpose: a synthetic
probe fetched from inside the worker via `worker.evaluate()` was tried first and
rejected, because evaluating in an extension service worker that early kills the
worker outright, destroying the thing being measured. Observing traffic the
extension already generates perturbs nothing. Losing the race fails closed — the
@@ -192,9 +193,10 @@ src/
styles/main.css — Tailwind source
views/ — one JS module per screen (home, send, approval, etc.)
shared/ — modules used by both popup and background
alarms.js — recurring background jobs (extension alarms API)
balances.js — ETH + ERC-20 balance fetching via RPC + Blockscout
constants.js — chain IDs, default RPC endpoint, ERC-20 ABI
ens.js — ENS forward/reverse resolution
ens.js — ENS forward/reverse resolution (popup only)
prices.js — ETH/USD and token/USD via CoinDesk API
scamlist.js — known fraud contract addresses
state.js — persisted state (extension storage)
@@ -208,6 +210,39 @@ manifest/
firefox.json — Manifest V2 for Firefox
```
### Background scheduling
Chrome runs `src/background/index.js` as a Manifest V3 service worker, which the
browser terminates after roughly 30 seconds idle and re-evaluates from scratch
on the next event. Two consequences shape every recurring job in the background:
- `setInterval` and `setTimeout` are useless. They are destroyed with the
worker, so a job scheduled that way runs until the first idle period and never
again. Both recurring jobs — the 60-second balance refresh and the 24-hour
phishing blocklist refresh — are scheduled through the extension alarms API
(`src/shared/alarms.js`) instead. The browser holds the schedule and wakes the
worker to deliver it. Alarm periods are clamped to a one-minute minimum, so
the balance refresh is expressed as exactly one minute and nothing is silently
slowed down.
- Module-level variables do not survive either. Anything that must be remembered
across a restart goes in extension storage, including the timestamp of the
last phishing list fetch: without it a revived worker would either re-fetch on
every wake or, with a naive in-memory guard, never notice that an update is
due. `localStorage` does not exist in a service worker at all — the one
remaining user of it, `src/shared/ens.js`, runs only in the popup and is
marked as such.
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. 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,
so there is a single code path to reason about; `"alarms"` is declared in both
`manifest/chrome.json` and `manifest/firefox.json`.
### UI Design Philosophy
The UI is inspired by _Universal Paperclips_. It's deliberately minimal,
@@ -707,8 +742,11 @@ CoinDesk price API, and Blockscout API), AutistMask also contacts:
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. Only the delta (domains not already in the vendored list) is kept in
memory, keeping runtime memory usage small. The delta is persisted to
localStorage if it is under 256 KiB.
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
@@ -928,6 +966,10 @@ 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, 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
banner alerting the user. The domain checker matches exact hostnames and all