// Background NWC spending state — what the service worker answers (and pays) // from while the app is closed. // // The record mirrors the wallet's REAL ark state: its vtxos (with the signed // bytes), the chain-3 private keys for exactly those coins plus a window of // upcoming indices for change, the NWC connections' service keys, and the // worker's spend log. The seed itself never leaves localStorage. // // THREAT MODEL, stated plainly. Anything with same-origin script access, or // a stolen unlocked device, can read IndexedDB and therefore spend the ark // balance. That is the accepted cost of a wallet that answers while closed — // the ark balance is treated as walking-around money, and the on-chain wallet // (whose keys are never mirrored) stays out of reach. Per-connection NWC // budgets are enforced by the worker before it pays. A user who does not // enable background answering has no record here and loses nothing. const DB = 'hal-nwc-pouch'; // name kept from the pouch era so upgrades are seamless const STORE = 'pouch'; const VERSION = 1; function openDb() { return new Promise((resolve, reject) => { if (typeof indexedDB === 'undefined') return reject(new Error('IndexedDB unavailable')); const req = indexedDB.open(DB, VERSION); req.onupgradeneeded = () => { const db = req.result; if (!db.objectStoreNames.contains(STORE)) db.createObjectStore(STORE); }; req.onsuccess = () => resolve(req.result); req.onerror = () => reject(req.error); }); } async function tx(mode, fn) { const db = await openDb(); try { return await new Promise((resolve, reject) => { const t = db.transaction(STORE, mode); const s = t.objectStore(STORE); let out; Promise.resolve(fn(s)).then((v) => { out = v; }).catch(reject); t.oncomplete = () => resolve(out); t.onerror = () => reject(t.error); t.onabort = () => reject(t.error); }); } finally { db.close(); } } const get = (s, k) => new Promise((res, rej) => { const r = s.get(k); r.onsuccess = () => res(r.result); r.onerror = () => rej(r.error); }); // One record per wallet, keyed by the wallet's cache key. export async function loadBg(walletKey) { try { return (await tx('readonly', (s) => get(s, walletKey))) || null; } catch { return null; } } export async function saveBg(walletKey, rec) { return tx('readwrite', (s) => { s.put(rec, walletKey); }); } export async function clearBg(walletKey) { return tx('readwrite', (s) => { s.delete(walletKey); }); } // The worker's diary: what it did while the app was closed — each push, the // steps of a payment, every notification shown or refused. A phone stops a // worker without a trace, and the diary's last line says where it got to. // Settings → Notifications → Copy hands it to whoever is looking into it. // Appended line by line (a worker that dies mid-push keeps what it wrote), // one writer at a time, a few hundred lines kept. const LOG_KEY = '__log'; const LOG_MAX = 400; let logChain = Promise.resolve(); export function appendBgLog(line) { logChain = logChain.then(() => tx('readwrite', async (s) => { const cur = (await get(s, LOG_KEY)) || []; cur.push(line); s.put(cur.length > LOG_MAX ? cur.slice(-LOG_MAX) : cur, LOG_KEY); })).catch(() => {}); return logChain; } export async function readBgLog() { try { return (await tx('readonly', (s) => get(s, LOG_KEY))) || []; } catch { return []; } } export async function clearBgLog() { return tx('readwrite', (s) => { s.delete(LOG_KEY); }); } // A lease on the worker's wallet work, kept in this store. Two pushes seconds // apart ran side by side on iOS, each in its own worker (two "worker started" // lines), and nothing kept them apart: both finished one send, and the // second, over a stale copy, marked a paid send 'revoking'. The store is // the one thing both see, and a readwrite transaction is atomic across them. // A lease outlives a stopped worker only for its ttl. export async function takeLease(name, { ttl = 12_000, wait = 5_000 } = {}) { const key = '__lease:' + name; const me = Math.random().toString(36).slice(2); const start = Date.now(); for (;;) { let got = false; try { got = await tx('readwrite', async (s) => { const cur = await get(s, key); if (cur && cur.until > Date.now() && cur.owner !== me) return false; s.put({ owner: me, until: Date.now() + ttl }, key); return true; }); } catch { got = true; } // no store, no lease: run if (got) break; if (Date.now() - start > wait) break; // a holder that overstays: go anyway await new Promise((r) => setTimeout(r, 250)); } const waited = Date.now() - start; const release = () => tx('readwrite', async (s) => { const cur = await get(s, key); if (cur && cur.owner === me) s.delete(key); }).catch(() => {}); return { waited, release }; } // The service worker doesn't know wallet cache keys — it finds the right // record by the connection's service pubkey across all of them. export async function allBgs() { try { return await tx('readonly', (s) => new Promise((res, rej) => { const out = []; const cur = s.openCursor(); cur.onsuccess = () => { const c = cur.result; if (!c) return res(out); // the worker's diary shares the store under a reserved key if (!(typeof c.key === 'string' && c.key.startsWith('__'))) out.push({ walletKey: c.key, rec: c.value }); c.continue(); }; cur.onerror = () => rej(cur.error); })); } catch { return []; } } // How many upcoming change/refund indices travel with the record: the worker // cannot derive keys, so its allocations must land inside this window. export const BG_KEY_LOOKAHEAD = 25; // { ark } endpoints, the manager state to mirror, and a keyFor(chain, index) // derive function (the caller owns the account node — it never enters this // module). Chain 3 covers coins + change, chain 4/0 the mailbox, chain 5 a // window of receive preimages — everything the worker needs to pay AND to // mint invoices while the app is closed. export function buildBg({ ark, mgrState, keyFor, connections, offer, autowithdraw, custody }) { const keys = {}; const idx = new Set((mgrState.vtxos || []).filter((v) => v.state !== 'spent').map((v) => v.keyIndex)); for (let i = 0; i < BG_KEY_LOOKAHEAD; i++) idx.add((mgrState.nextKeyIndex || 1) + i); // A standing renewal order (and a renewal in flight) burned its output key // when it was registered, below the counter by now: the worker needs that // key to claim the renewed coin while the app is closed. for (const e of mgrState.scheduled || []) if (e.outKeyIndex != null) idx.add(e.outKeyIndex); for (const a of mgrState.actions || []) if (a.type === 'refresh' && a.step !== 'done' && a.outKeyIndex != null) idx.add(a.outKeyIndex); idx.add(0); // the receive-address key: mailbox/refund defaults land on it for (const i of idx) keys[String(i)] = keyFor(3, i); const keys5 = {}; for (let i = 0; i < 15; i++) { const n = (mgrState.nextLnRecvIndex || 0) + i; keys5[String(n)] = keyFor(5, n); } return { v: 3, updated: Date.now(), ark, mgr: mgrState, keys, keys5, key4: keyFor(4, 0), offer: offer || null, // { sk, pk } — the CLINK offer service keypair // { on, threshold, keep, target: { kind, address, url?, spkHex? } } — the // destination is resolved by the app, so the worker never needs a relay autowithdraw: autowithdraw || null, // the custody tier ('custodial' | 'self' | 'early'): whether the worker // registers renewal orders for bare coins, and how far ahead custody: custody || null, // The budget's spent counter rides along (stamped with its window) so the // worker counts what the app spent; a revoked one rides along flagged, to // be refused rather than ignored. connections: (connections || []).map((c) => ({ id: c.id, name: c.name || '', servicePk: c.servicePk, serviceSk: c.serviceSk, clientPk: c.clientPk, maxSat: c.maxSat, dailySat: c.dailySat, period: c.period || 'day', spentDate: c.spentDate || null, spentToday: c.spentToday || 0, revoked: !!c.revoked, })), }; } export const bgSpendableSat = (rec) => ((rec && rec.mgr && rec.mgr.vtxos) || []).filter((v) => v.state === 'spendable') .reduce((n, v) => n + (v.amountSat || 0), 0); // After (re)writing a wallet's record: strip its connections/offer from any // OTHER record that still carries them — an old network's mirror, say — so // exactly one record ever answers for a connection. The sibling's coin state // stays put for a later merge; only its answering role is removed. A stale // copy that races the live one answers from the wrong wallet (and a wrong- // network mirror fails instantly, beating the real payer's reply). export async function disarmSiblingRecords(walletKey, rec, { loadAll = allBgs, save = saveBg } = {}) { const pks = new Set((rec.connections || []).map((c) => c.servicePk)); if (rec.offer?.pk) pks.add(rec.offer.pk); if (!pks.size) return 0; let disarmed = 0; for (const r of await loadAll()) { if (r.walletKey === walletKey || !r.rec || r.rec.v < 3) continue; const conns = r.rec.connections || []; const keep = conns.filter((c) => !pks.has(c.servicePk)); const offerHit = !!(r.rec.offer?.pk && pks.has(r.rec.offer.pk)); if (keep.length === conns.length && !offerHit) continue; r.rec.connections = keep; if (offerHit) r.rec.offer = null; await save(r.walletKey, r.rec); disarmed++; } return disarmed; } // The worker records what it spent so the app can absorb it into budgets and // history on next open. export async function noteBgSpend(walletKey, spend) { const rec = await loadBg(walletKey); if (!rec) return null; rec.spends = rec.spends || []; rec.spends.push({ ...spend, ts: Date.now() }); if (rec.spends.length > 200) rec.spends = rec.spends.slice(-200); await saveBg(walletKey, rec); return rec; }