aboutsummaryrefslogtreecommitdiffstats
path: root/src/nwc-bg.js
diff options
context:
space:
mode:
Diffstat (limited to 'src/nwc-bg.js')
-rw-r--r--src/nwc-bg.js117
1 files changed, 117 insertions, 0 deletions
diff --git a/src/nwc-bg.js b/src/nwc-bg.js
new file mode 100644
index 0000000..f539e45
--- /dev/null
+++ b/src/nwc-bg.js
@@ -0,0 +1,117 @@
+// 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 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);
+ 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(index) derive
+// function (the caller owns the account node — it never enters this module).
+export function buildBg({ ark, mgrState, keyFor, connections }) {
+ 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);
+ idx.add(0); // the receive-address key: mailbox/refund defaults land on it
+ for (const i of idx) keys[String(i)] = keyFor(i);
+ return {
+ v: 3,
+ updated: Date.now(),
+ ark,
+ mgr: mgrState,
+ keys,
+ connections: (connections || []).map((c) => ({
+ id: c.id, servicePk: c.servicePk, serviceSk: c.serviceSk, clientPk: c.clientPk,
+ maxSat: c.maxSat, dailySat: c.dailySat,
+ })),
+ };
+}
+
+export const bgSpendableSat = (rec) =>
+ ((rec && rec.mgr && rec.mgr.vtxos) || []).filter((v) => v.state === 'spendable')
+ .reduce((n, v) => n + (v.amountSat || 0), 0);
+
+// 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;
+}