fix: drive background refresh and phishing update from alarms (closes #158)
Some checks failed
check / check (push) Has been cancelled
Some checks failed
check / check (push) Has been cancelled
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 or when the existing one carries a different period, because creating one restarts its period and the startup path runs on every wake — while an alarm left at an older release's period would otherwise never be reconciled. Each job's freshness guard is decoupled from its alarm period, or the period would not be the cadence. A guard is measured from when the last run finished, which is one run-duration after the alarm that started it, so a guard timed to the period vetoes the very next tick and the real rate halves. The two are handled differently because the guards differ in purpose: the phishing cache TTL exists to keep the worker off the network on the wakes between refreshes, so the scheduled tick bypasses it and fetches unconditionally; the balance guard exists to skip work an open popup has already done, so it must keep applying on the tick and is instead shortened to half the alarm period — above the popup's 10-second refresh, below the 60-second period. The phishing delta and the timestamps 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. A timestamp read back from storage is discarded if it lies in the future: clock skew or a restored profile backup would otherwise suppress updates until that time arrived, permanently, now that the value outlives the worker. Two timestamps are kept, not one. The 256 KiB cap still drops an oversized delta together with its freshness claim, but the record of having contacted the network at all is written regardless — as it is after a failed fetch — and floors unscheduled retries at one hour. Without it, a list that is persistently oversized or a fetch that persistently fails means a full blocklist download on every worker wake, indefinitely. 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. The concurrent callers on a fresh install share one in-flight run rather than racing to create the same alarm, and a failure is logged instead of becoming an unhandled rejection. 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:
97
README.md
97
README.md
@@ -145,10 +145,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
|
||||
@@ -208,9 +209,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)
|
||||
@@ -224,6 +226,74 @@ 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.
|
||||
|
||||
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.
|
||||
|
||||
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,
|
||||
@@ -902,9 +972,14 @@ 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. 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.
|
||||
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.
|
||||
- **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
|
||||
@@ -1126,6 +1201,12 @@ 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.
|
||||
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user