diff options
| author | Adam Soltys <[email protected]> | 2026-08-01 19:38:30 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-08-01 19:38:30 -0700 |
| commit | 348a57004f94dd0f58c4e70769e65ca7ea3f7619 (patch) | |
| tree | 5f8cd2930abd9c1e05bb7c18e0bec239780281ef /src/nwc-bg.js | |
| parent | 9196f4804851a87c8356418b1c4cade6e87c54ba (diff) | |
| download | coinosv3-348a57004f94dd0f58c4e70769e65ca7ea3f7619.tar.gz coinosv3-348a57004f94dd0f58c4e70769e65ca7ea3f7619.zip | |
Simplify: one Lightning path, one balance, fewer features
Dropped: silent payments (feature, wallet chains, scan worker, indexer
config), Boltz submarine/reverse swaps (feature + swap.js + tools), the
third-party ark swap bridge (config, client flows, settings card — the
ASP's own Lightning send is the one path now), and the Consolidate
coins settings action.
The NWC pouch is gone with them: background answering now spends the
WHOLE ark balance. The IndexedDB record mirrors the real manager state
plus the chain-3 keys for its live coins and a change-index window; the
worker pays through the ASP exactly like the open app, and the app
folds the worker's spends, change, and key allocations back in on
connect (additive merge + reconcile against the server). A leftover
chain-6 pouch from the short-lived pouch era is swept home
automatically using the keys stored in its own record. The stated
tradeoff: same-origin script access now reaches the ark balance, not a
carve-out — the ark balance is walking-around money, the on-chain
wallet's keys are never mirrored, and per-connection NWC budgets remain
the enforced ceiling.
Bundle: 726KB -> 602KB.
Co-Authored-By: Claude Fable 5 <[email protected]>
Diffstat (limited to 'src/nwc-bg.js')
| -rw-r--r-- | src/nwc-bg.js | 117 |
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; +} |
