fix: drive background refresh and phishing update from alarms (closes #158)
Some checks failed
check / check (push) Has been cancelled

This commit was merged in pull request #208.
This commit is contained in:
2026-08-11 15:38:28 +02:00
parent 74c137dadf
commit 6f6bc2e7b5
11 changed files with 1326 additions and 89 deletions

View File

@@ -12,13 +12,20 @@ const {
currentNetwork,
} = require("../shared/state");
const { refreshBalances, getProvider } = require("../shared/balances");
const { debugFetch } = require("../shared/log");
const { debugFetch, log } = require("../shared/log");
const { verifySignedTx, verifySignature } = require("../shared/approvalVerify");
const {
isPhishingDomain,
updatePhishingList,
startPeriodicRefresh,
refreshPhishingListOnSchedule,
initPhishingList,
} = require("../shared/phishingDomains");
const {
BALANCE_REFRESH_ALARM,
PHISHING_REFRESH_ALARM,
BALANCE_REFRESH_PERIOD_MINUTES,
ensureRecurringAlarms,
registerAlarmHandlers,
} = require("../shared/alarms");
const storageApi =
typeof browser !== "undefined"
@@ -591,12 +598,22 @@ 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.
const BACKGROUND_REFRESH_INTERVAL = 60000;
//
// 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);
async function backgroundRefresh() {
await loadState();
const now = Date.now();
if (now - (state.lastBalanceRefresh || 0) < BACKGROUND_REFRESH_INTERVAL)
if (now - (state.lastBalanceRefresh || 0) < RECENT_BALANCE_REFRESH_MS)
return;
if (state.wallets.length === 0) return;
await refreshBalances(
@@ -609,12 +626,58 @@ async function backgroundRefresh() {
await saveState();
}
setInterval(backgroundRefresh, BACKGROUND_REFRESH_INTERVAL);
// Both recurring jobs run off alarms, not timers. On Chrome MV3 this file is
// a service worker that the browser terminates after about 30 seconds idle,
// so a setInterval would only ever survive until the first idle period and
// module-level state does not outlive it. Alarms are held by the browser and
// 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,
});
// Fetch the phishing domain blocklist delta on startup and refresh every 24h.
// The vendored blocklist is bundled at build time; this fetches only new entries.
updatePhishingList();
startPeriodicRefresh();
// 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;
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;
}
if (runtime.onInstalled) {
runtime.onInstalled.addListener(startBackgroundJobs);
}
if (runtime.onStartup) {
runtime.onStartup.addListener(startBackgroundJobs);
}
startBackgroundJobs();
// When approval window is closed without a response, treat as rejection
if (windowsApi && windowsApi.onRemoved) {

114
src/shared/alarms.js Normal file
View File

@@ -0,0 +1,114 @@
// Periodic scheduling for the background context.
//
// The Chrome MV3 service worker is terminated after roughly 30 seconds idle,
// which takes every setInterval/setTimeout with it. The extension alarms API
// is the mechanism that survives: the browser holds the schedule and wakes
// the worker to deliver onAlarm. Firefox MV2 runs a persistent background
// page where timers would survive, but alarms behave identically there, so
// both targets share this path and both manifests declare the "alarms"
// permission.
//
// 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";
const MIN_ALARM_PERIOD_MINUTES = 1;
const BALANCE_REFRESH_PERIOD_MINUTES = 1;
const PHISHING_REFRESH_PERIOD_MINUTES = 24 * 60;
// Resolved on use rather than captured at module load: the worker is torn
// down and re-evaluated repeatedly, and tests install a stub after requiring
// the module.
function alarmsApi() {
if (typeof browser !== "undefined" && browser.alarms) return browser.alarms;
if (typeof chrome !== "undefined" && chrome.alarms) return chrome.alarms;
return null;
}
/**
* Create an alarm unless one with the requested period already exists.
*
* 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.
*/
async function ensureAlarm(name, periodInMinutes) {
const api = alarmsApi();
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;
api.create(name, {
periodInMinutes: period,
delayInMinutes: period,
});
return true;
}
/**
* Ensure both recurring background jobs are scheduled. Safe to call on every
* worker start, on onInstalled and on onStartup.
*
* @returns {Promise<{balance: boolean, phishing: boolean}>} which alarms this
* call had to create.
*/
async function ensureRecurringAlarms() {
const balance = await ensureAlarm(
BALANCE_REFRESH_ALARM,
BALANCE_REFRESH_PERIOD_MINUTES,
);
const phishing = await ensureAlarm(
PHISHING_REFRESH_ALARM,
PHISHING_REFRESH_PERIOD_MINUTES,
);
return { balance, phishing };
}
/**
* Register per-alarm handlers. One listener dispatches by alarm name so the
* worker only ever installs a single onAlarm listener.
*
* @param {Object<string, function>} handlers
* @returns {boolean} true if the listener was installed.
*/
function registerAlarmHandlers(handlers) {
const api = alarmsApi();
if (!api || !api.onAlarm) return false;
api.onAlarm.addListener((alarm) => {
const handler = handlers[alarm && alarm.name];
if (handler) handler();
});
return true;
}
module.exports = {
BALANCE_REFRESH_ALARM,
PHISHING_REFRESH_ALARM,
MIN_ALARM_PERIOD_MINUTES,
BALANCE_REFRESH_PERIOD_MINUTES,
PHISHING_REFRESH_PERIOD_MINUTES,
ensureAlarm,
ensureRecurringAlarms,
registerAlarmHandlers,
};

View File

@@ -1,6 +1,11 @@
// Cached ENS reverse resolution.
// Resolves addresses to ENS names via ethers provider.lookupAddress(),
// caching results in localStorage with a 12-hour TTL.
//
// POPUP ONLY. localStorage does not exist in the Chrome MV3 service worker,
// so this module must not be pulled into src/background/. Anything the
// background context needs to cache goes in extension storage instead (see
// shared/phishingDomains.js).
const { getProvider } = require("./balances");
const { log } = require("./log");

View File

@@ -8,8 +8,14 @@
// The domain-checker checks the in-memory delta first (fresh/recent scam
// sites), then falls back to the vendored list.
//
// If the delta is under 256 KiB it is persisted to localStorage so it
// survives extension/service-worker restarts.
// If the delta and its fetch timestamp fit in 256 KiB they are persisted to
// 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().
const vendoredConfig = require("./phishingBlocklist.json");
@@ -17,7 +23,14 @@ 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
const REFRESH_INTERVAL_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
@@ -29,45 +42,104 @@ 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 refreshTimer = null;
let loadPromise = null;
// Resolved on use rather than captured at module load, so a test can install
// a stub after requiring the module and so the popup — which has no reason to
// touch the delta — does not fail to load where the API is absent.
function storageApi() {
if (typeof browser !== "undefined" && browser.storage) {
return browser.storage.local;
}
if (typeof chrome !== "undefined" && chrome.storage) {
return chrome.storage.local;
}
return null;
}
/**
* Load delta entries from localStorage on startup.
* Called once during module initialization in the background script.
* 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 loadDeltaFromStorage() {
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.
* Runs once per worker lifetime; every entry point funnels through
* ensureDeltaLoaded() so a wake from termination restores state exactly once.
*
* @returns {Promise<void>}
*/
async function loadDeltaFromStorage() {
const storage = storageApi();
if (!storage) return;
try {
const raw = localStorage.getItem(DELTA_STORAGE_KEY);
if (!raw) return;
const data = JSON.parse(raw);
if (data.blacklist && Array.isArray(data.blacklist)) {
const result = await storage.get(DELTA_STORAGE_KEY);
const data = result && result[DELTA_STORAGE_KEY];
if (!data) return;
if (Array.isArray(data.blacklist)) {
deltaBlacklist = new Set(
data.blacklist.map((d) => d.toLowerCase()),
);
}
lastFetchTime = sanitizeTimestamp(data.lastFetchTime);
lastAttemptTime = sanitizeTimestamp(data.lastAttemptTime);
} catch {
// localStorage unavailable or corrupt — start empty
// Storage unavailable or corrupt — start empty and re-fetch.
}
}
function ensureDeltaLoaded() {
if (!loadPromise) loadPromise = loadDeltaFromStorage();
return loadPromise;
}
/**
* Persist delta to localStorage if it fits within MAX_DELTA_BYTES.
* Persist the delta and its timestamps 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.
*
* @returns {Promise<void>}
*/
function saveDeltaToStorage() {
async function saveDeltaToStorage() {
const storage = storageApi();
if (!storage) return;
try {
const data = {
blacklist: Array.from(deltaBlacklist),
lastFetchTime,
lastAttemptTime,
};
const json = JSON.stringify(data);
if (json.length < MAX_DELTA_BYTES) {
localStorage.setItem(DELTA_STORAGE_KEY, json);
await storage.set({ [DELTA_STORAGE_KEY]: data });
} else if (lastAttemptTime > 0) {
await storage.set({ [DELTA_STORAGE_KEY]: { lastAttemptTime } });
} else {
// Too large — remove stale key if present
localStorage.removeItem(DELTA_STORAGE_KEY);
await storage.remove(DELTA_STORAGE_KEY);
}
} catch {
// localStorage unavailable — skip silently
// Storage unavailable — skip silently
}
}
@@ -76,6 +148,7 @@ function saveDeltaToStorage() {
* Used for both live fetches and testing.
*
* @param {{ blacklist?: string[] }} config
* @returns {Promise<void>} resolves once the delta has been persisted.
*/
function loadConfig(config) {
const liveBlacklist = (config.blacklist || []).map((d) => d.toLowerCase());
@@ -86,7 +159,7 @@ function loadConfig(config) {
);
lastFetchTime = Date.now();
saveDeltaToStorage();
return saveDeltaToStorage();
}
/**
@@ -111,6 +184,11 @@ function hostnameVariants(hostname) {
* Check if a hostname is on the phishing blocklist.
* Checks delta first (fresh/recent scam sites), then vendored list.
*
* Synchronous by design — callers answer an approval prompt with it. On a
* worker that has just woken, the persisted delta may still be loading; the
* vendored list, which is bundled and always present, carries the check until
* it lands.
*
* @param {string} hostname - The hostname to check.
* @returns {boolean}
*/
@@ -127,28 +205,59 @@ function isPhishingDomain(hostname) {
/**
* Fetch the latest blocklist and compute delta against vendored data.
* De-duplicates concurrent fetches. Results are cached for CACHE_TTL_MS.
* 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() {
// Skip if recently fetched
if (Date.now() - lastFetchTime < CACHE_TTL_MS && lastFetchTime > 0) {
return;
async function updatePhishingList({ force = false } = {}) {
// 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
) {
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();
loadConfig(config);
await loadConfig(config);
} catch {
// Silently fail — vendored list still provides coverage.
// We'll retry next time.
// 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();
} finally {
fetchPromise = null;
}
@@ -158,12 +267,29 @@ async function updatePhishingList() {
}
/**
* Start periodic refresh of the phishing list.
* Should be called once from the background script on startup.
* Restore persisted state and fetch if the list is overdue.
*
* Called from the background script every time it starts — a fresh install,
* a browser start, and every revival of a terminated service worker all land
* here. The recurring 24-hour schedule itself is an alarm (see
* shared/alarms.js), not a timer, because timers die with the worker.
*
* @returns {Promise<void>}
*/
function startPeriodicRefresh() {
if (refreshTimer) return;
refreshTimer = setInterval(updatePhishingList, REFRESH_INTERVAL_MS);
async function initPhishingList() {
await ensureDeltaLoaded();
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 });
}
/**
@@ -190,21 +316,22 @@ function getDeltaSize() {
function _reset() {
deltaBlacklist = new Set();
lastFetchTime = 0;
lastAttemptTime = 0;
fetchPromise = null;
if (refreshTimer) {
clearInterval(refreshTimer);
refreshTimer = null;
}
loadPromise = null;
}
// Load persisted delta on module initialization
loadDeltaFromStorage();
module.exports = {
isPhishingDomain,
updatePhishingList,
startPeriodicRefresh,
refreshPhishingListOnSchedule,
initPhishingList,
loadDeltaFromStorage,
loadConfig,
CACHE_TTL_MS,
MIN_FETCH_ATTEMPT_INTERVAL_MS,
DELTA_STORAGE_KEY,
MAX_DELTA_BYTES,
getBlocklistSize,
getDeltaSize,
hostnameVariants,