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-pouch.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-pouch.js')
| -rw-r--r-- | src/nwc-pouch.js | 148 |
1 files changed, 0 insertions, 148 deletions
diff --git a/src/nwc-pouch.js b/src/nwc-pouch.js deleted file mode 100644 index 187ab7a..0000000 --- a/src/nwc-pouch.js +++ /dev/null @@ -1,148 +0,0 @@ -// The NWC "pouch": a small, bounded spending capability that a service -// worker can use while the wallet itself stays out of reach. -// -// WHY THIS EXISTS -// hal keeps the seed in localStorage, which service workers cannot read — by -// design, and we are not changing that. But answering a wallet-connect -// request while the app is closed means signing in a service worker. So -// instead of exposing the wallet, we carve off a deliberately small pouch: -// -// * its own key branch (account chain 6), never used for ordinary coins -// * only the pouch's PRIVATE KEYS and vtxo bytes go into IndexedDB, which -// the service worker can read — never the seed, never chain 3 -// * it holds only what the user puts in it -// -// THREAT MODEL, stated plainly. Anything with same-origin script access, or -// a stolen unlocked device, can read IndexedDB and therefore spend the pouch. -// That is the accepted cost of answering while closed. It is bounded: the -// pouch is the blast radius, the rest of the wallet is not reachable from it. -// A user who does not enable background NWC has no pouch and loses nothing. -// -// This module owns the storage and key derivation only. Funding it is an -// ordinary arkoor send from the main wallet (see the ark feature); spending -// from it lives in the service worker (step 3). - -import { hex } from '@scure/base'; -import { secp256k1 } from '@noble/curves/secp256k1'; - -const DB = 'hal-nwc-pouch'; -const STORE = 'pouch'; -const VERSION = 1; - -// Chain 6 is the pouch. Chains 0-2 are hal's on-chain use, 3 ark vtxo keys, -// 4 the ark mailbox, 5 lightning-receive preimages — 6 must stay distinct so -// a leaked pouch key can never sign for an ordinary coin. -export const POUCH_CHAIN = 6; - -export function pouchKey(account, index) { - const node = account.deriveChild(POUCH_CHAIN).deriveChild(index); - return { privkey: node.privateKey, pubkey: secp256k1.getPublicKey(node.privateKey, true) }; -} - -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 so several wallets -// on one device keep separate pouches. -export async function loadPouch(walletKey) { - try { return (await tx('readonly', (s) => get(s, walletKey))) || null; } catch { return null; } -} -// The service worker doesn't know wallet cache keys — it finds the right -// pouch by the connection's service pubkey across all of them. -export async function allPouches() { - 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, pouch: c.value }); - c.continue(); - }; - cur.onerror = () => rej(cur.error); - })); - } catch { return []; } -} -export async function savePouch(walletKey, pouch) { - return tx('readwrite', (s) => { s.put(pouch, walletKey); }); -} -export async function clearPouch(walletKey) { - return tx('readwrite', (s) => { s.delete(walletKey); }); -} - -// The worker can't derive keys (no seed), so the pouch carries a fixed window -// of pre-derived chain-6 keys: funding uses low indices, the worker's change -// and HTLC-refund outputs allocate upward from POUCH_CHANGE_BASE. -export const POUCH_KEY_WINDOW = 50; -export const POUCH_CHANGE_BASE = 20; - -// Everything the service worker needs to spend, and nothing more: the ASP -// endpoints, the bridge, the pouch vtxos with their bytes, and the private -// keys for exactly the pouch's key window. -export function buildPouch({ arkUrl, esploraUrl, network, serverPubkey, bridge, vtxos, account, connections }) { - const keys = {}; - for (let i = 0; i < POUCH_KEY_WINDOW; i++) { - keys[String(i)] = hex.encode(pouchKey(account, i).privkey); - } - return { - v: 2, - updated: Date.now(), - ark: { arkUrl, esploraUrl, network, serverPubkey }, - bridge: bridge || null, // { url, token } — the worker pays through this - // [{ id, bytes, keyIndex, amountSat, expiryHeight }] - vtxos: vtxos.map((v) => ({ - id: v.id, bytes: v.bytes, keyIndex: v.keyIndex, - amountSat: v.amountSat, expiryHeight: v.expiryHeight, - })), - keys, - nextKeyIndex: POUCH_CHANGE_BASE, - // service keys for the connections the SW may answer for, so it can - // decrypt requests and sign replies without the wallet - connections: (connections || []).map((c) => ({ - id: c.id, servicePk: c.servicePk, serviceSk: c.serviceSk, clientPk: c.clientPk, - maxSat: c.maxSat, dailySat: c.dailySat, - })), - }; -} - -export const pouchBalance = (pouch) => - (pouch?.vtxos || []).reduce((n, v) => n + (v.amountSat || 0), 0); - -// The service worker records what it spent so the app can reconcile and the -// daily budget survives a restart. -export async function notePouchSpend(walletKey, { connId, amountSat, paymentHash, preimage }) { - const p = await loadPouch(walletKey); - if (!p) return null; - p.spends = p.spends || []; - p.spends.push({ connId, amountSat, paymentHash, preimage, ts: Date.now() }); - if (p.spends.length > 200) p.spends = p.spends.slice(-200); - await savePouch(walletKey, p); - return p; -} |
