aboutsummaryrefslogtreecommitdiffstats
path: root/src/nwc-pouch.js
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-08-01 19:38:30 -0700
committerAdam Soltys <[email protected]>2026-08-01 19:38:30 -0700
commit348a57004f94dd0f58c4e70769e65ca7ea3f7619 (patch)
tree5f8cd2930abd9c1e05bb7c18e0bec239780281ef /src/nwc-pouch.js
parent9196f4804851a87c8356418b1c4cade6e87c54ba (diff)
downloadcoinosv3-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.js148
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;
-}