aboutsummaryrefslogtreecommitdiffstats
path: root/src/nwc-bg.js
blob: 92786c0289abb3557bb7756a2eb513e6c72d11f2 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
// 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(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 }) {
  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(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,
    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);

// 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;
}