aboutsummaryrefslogtreecommitdiffstats
path: root/src/nwc-respond.js
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-08-01 19:02:25 -0700
committerAdam Soltys <[email protected]>2026-08-01 19:02:25 -0700
commitb73bb9ee51b9be33bc1859fd56d3e11905c3d456 (patch)
tree8a008d57e450961d1ab203a663e9d8fa233a2686 /src/nwc-respond.js
parent2a7a825ce5850a2a836effae820adbae0b66d093 (diff)
downloadcoinosv3-b73bb9ee51b9be33bc1859fd56d3e11905c3d456.tar.gz
coinosv3-b73bb9ee51b9be33bc1859fd56d3e11905c3d456.zip
Background NWC: the worker now answers — and pays — from the pouch
The no-tap half of background wallet-connect. The push payload carries the (encrypted, client-signed) request event; a bundled service-worker responder (src/sw-nwc.js -> nwc-respond.js) finds the matching pouch in IndexedDB, decrypts with the connection's service key, and answers get_info/get_balance/list_transactions from pouch state. pay_invoice pays through the swap bridge with an ArkManager running over the pouch's chain-6 key window (one HTLC lock, one HTTP swap), enforcing per-payment and daily budgets from the pouch's own spend log, checking the notifier's /answered endpoint so it never double-pays or shouts over an open device, and persisting the swap before handing it off so the app can refund a crashed flight. Anything it can't handle falls back to the wake-the-user notification. The notifier grows /publish (verified 23195s only, rate-limited) as the worker's reply pipe and /answered for race avoidance. The app gains pouch funding (mailbox-less arkoor self-send whose recipient bytes we keep), emptying, spend/budget reconciliation on open, and refund recovery with the pouch's own keys. Co-Authored-By: Claude Fable 5 <[email protected]>
Diffstat (limited to 'src/nwc-respond.js')
-rw-r--r--src/nwc-respond.js245
1 files changed, 245 insertions, 0 deletions
diff --git a/src/nwc-respond.js b/src/nwc-respond.js
new file mode 100644
index 0000000..e7bb716
--- /dev/null
+++ b/src/nwc-respond.js
@@ -0,0 +1,245 @@
+// NWC auto-answer from the pouch — the half of background wallet-connect
+// that pays without a tap. Runs inside the service worker's push handler:
+// the push payload carries the encrypted request event, the pouch (IndexedDB)
+// carries the connection service keys and a bounded set of coins, and the
+// reply goes back through the notifier's /publish endpoint — no WebSocket,
+// no seed, nothing outside the pouch's blast radius.
+//
+// Trust: the notifier transports ciphertext both ways. Requests arrive
+// nip04/nip44-encrypted and signed by the client; replies leave encrypted and
+// signed by the per-connection service key. The notifier can neither read nor
+// forge either. Spending happens directly against the ASP/bridge over fetch.
+//
+// Anything this module can't handle returns false, and the caller falls back
+// to today's behavior: a notification that opens the wallet.
+
+import { hex } from '@scure/base';
+import { sha256 } from '@noble/hashes/sha256';
+import * as nip04 from 'nostr-tools/nip04';
+import * as nip44 from 'nostr-tools/nip44';
+import { finalizeEvent } from 'nostr-tools/pure';
+import { ArkManager } from './ark/manager.js';
+import { maybeBolt11 } from './ark/lightning.js';
+import { allPouches, savePouch, POUCH_KEY_WINDOW, POUCH_CHANGE_BASE } from './nwc-pouch.js';
+
+const MAX_AGE_SEC = 120;
+const nowSec = () => Math.floor(Date.now() / 1000);
+const errRes = (t, code, message) => ({ result_type: t, error: { code, message } });
+
+const decrypt = async (scheme, skHex, pk, ct) => (scheme === 'nip44_v2'
+ ? nip44.decrypt(ct, nip44.getConversationKey(hex.decode(skHex), pk))
+ : Promise.resolve(nip04.decrypt(hex.decode(skHex), pk, ct)));
+
+async function buildReply(conn, ev, scheme, payload) {
+ const sk = hex.decode(conn.serviceSk);
+ const content = scheme === 'nip44_v2'
+ ? nip44.encrypt(JSON.stringify(payload), nip44.getConversationKey(sk, ev.pubkey))
+ : await Promise.resolve(nip04.encrypt(sk, ev.pubkey, JSON.stringify(payload)));
+ return finalizeEvent({
+ kind: 23195, created_at: nowSec(),
+ tags: [['p', ev.pubkey], ['e', ev.id], ['encryption', scheme]],
+ content,
+ }, sk);
+}
+
+const seedState = (pouch) => ({
+ v: 1,
+ serverPubkey: pouch.ark.serverPubkey || null,
+ mailboxCheckpoint: 0,
+ nextKeyIndex: pouch.nextKeyIndex || POUCH_CHANGE_BASE,
+ vtxos: (pouch.vtxos || []).map((v) => ({ ...v, state: v.state || 'spendable' })),
+ actions: [],
+ movements: [],
+});
+
+const spendableVtxos = (pouch) =>
+ ((pouch.mgr && pouch.mgr.vtxos) || seedState(pouch).vtxos).filter((v) => v.state === 'spendable');
+
+// data: the push payload { type:'nwc', servicePubkey, event }. Returns true
+// when the request was fully dealt with (reply published or deliberately
+// left to another device); false means "wake the user instead".
+export async function respondFromPouch(data, {
+ notifier, fetchFn = fetch, pouchesFn = allPouches, saveFn = savePouch, log = () => {},
+} = {}) {
+ const ev = data && data.event;
+ if (!ev || ev.kind !== 23194 || !ev.id || !ev.pubkey) return false;
+ if (nowSec() - (ev.created_at || 0) > MAX_AGE_SEC) return false;
+
+ const target = (ev.tags?.find((t) => t[0] === 'p') || [])[1];
+ if (!target) return false;
+ let walletKey = null, pouch = null, conn = null;
+ for (const p of await pouchesFn()) {
+ const c = (p.pouch.connections || []).find((x) => x.servicePk === target);
+ if (c) { walletKey = p.walletKey; pouch = p.pouch; conn = c; break; }
+ }
+ if (!conn) return false;
+ if (ev.pubkey !== conn.clientPk) return false;
+
+ const scheme = ev.tags?.find((x) => x[0] === 'encryption')?.[1] === 'nip44_v2' ? 'nip44_v2' : 'nip04';
+ let req;
+ try { req = JSON.parse(await decrypt(scheme, conn.serviceSk, ev.pubkey, ev.content)); } catch { return false; }
+ const method = req?.method;
+ const params = req?.params || {};
+
+ const publish = async (payload) => {
+ const evt = await buildReply(conn, ev, scheme, payload);
+ const r = await fetchFn(`${notifier}/publish`, {
+ method: 'POST', headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ event: evt }),
+ });
+ if (!r.ok) throw new Error(`publish refused: ${r.status}`);
+ return true;
+ };
+ // Another device (an open tab elsewhere) may have answered already — a
+ // paid zap must never be shouted over with a late error, and paying twice
+ // wastes a lock cycle. The notifier tracks recent reply e-tags.
+ const answered = async () => {
+ try {
+ const r = await fetchFn(`${notifier}/answered?event=${ev.id}`);
+ return !!(await r.json()).answered;
+ } catch { return false; }
+ };
+
+ if (method === 'get_info') {
+ await publish({
+ result_type: 'get_info',
+ result: {
+ alias: 'Hal', color: '#f7931a',
+ network: pouch.ark.network === 'mainnet' ? 'mainnet' : pouch.ark.network,
+ block_height: 0, block_hash: '',
+ methods: ['get_info', 'get_balance', 'pay_invoice', 'list_transactions'],
+ },
+ });
+ return true;
+ }
+ if (method === 'get_balance') {
+ const sat = spendableVtxos(pouch).reduce((n, v) => n + (v.amountSat || 0), 0);
+ await publish({ result_type: 'get_balance', result: { balance: sat * 1000 } });
+ return true;
+ }
+ if (method === 'list_transactions') {
+ const txs = (pouch.spends || []).slice(-(params.limit || 20)).map((s) => ({
+ type: 'outgoing', invoice: s.invoice || '', preimage: s.preimage || '',
+ amount: (s.amountSat || 0) * 1000, fees_paid: (s.feeSat || 0) * 1000,
+ created_at: Math.floor((s.ts || Date.now()) / 1000),
+ settled_at: Math.floor((s.ts || Date.now()) / 1000),
+ description: '',
+ }));
+ await publish({ result_type: 'list_transactions', result: { transactions: txs } });
+ return true;
+ }
+ if (method !== 'pay_invoice') {
+ await publish(errRes(method, 'NOT_IMPLEMENTED', 'not available while the wallet is closed'));
+ return true;
+ }
+
+ // ---- pay_invoice ------------------------------------------------------
+ if (await answered()) { log('already answered elsewhere'); return true; }
+
+ const invoice = params.invoice;
+ const dec = invoice && maybeBolt11(invoice);
+ if (!dec) { await publish(errRes('pay_invoice', 'OTHER', 'not a bolt11 invoice')); return true; }
+ if (!dec.amountSat) { await publish(errRes('pay_invoice', 'OTHER', 'zero-amount invoices are not supported')); return true; }
+ if (dec.amountSat > conn.maxSat) {
+ await publish(errRes('pay_invoice', 'QUOTA_EXCEEDED', `over the ${conn.maxSat} sat per-payment limit`));
+ return true;
+ }
+ const today = new Date().toISOString().slice(0, 10);
+ const spentToday = (pouch.spends || [])
+ .filter((s) => s.connId === conn.id && new Date(s.ts).toISOString().slice(0, 10) === today)
+ .reduce((n, s) => n + (s.amountSat || 0) + (s.feeSat || 0), 0);
+ if (spentToday + dec.amountSat > conn.dailySat) {
+ await publish(errRes('pay_invoice', 'QUOTA_EXCEEDED', `over the remaining daily budget (${Math.max(0, conn.dailySat - spentToday)} sat)`));
+ return true;
+ }
+
+ // The worker only pays through the bridge (one HTLC lock, one HTTP swap).
+ // No bridge, no coins, or an exhausted key window → wake the user instead.
+ if (!pouch.bridge || !pouch.bridge.url) return false;
+ const nextIdx = (pouch.mgr && pouch.mgr.nextKeyIndex) || pouch.nextKeyIndex || POUCH_CHANGE_BASE;
+ if (nextIdx >= POUCH_KEY_WINDOW - 1) { log('pouch key window exhausted'); return false; }
+
+ const auth = pouch.bridge.token ? { authorization: `Bearer ${pouch.bridge.token}` } : {};
+ let quote;
+ try {
+ const r = await fetchFn(`${pouch.bridge.url}/quote?invoice=${encodeURIComponent(invoice)}`, { headers: auth });
+ quote = await r.json();
+ if (!r.ok || quote.error) throw new Error(quote.error || `quote failed (${r.status})`);
+ } catch (e) { log('quote failed: ' + e.message); return false; }
+ if (!spendableVtxos(pouch).some((v) => v.amountSat >= quote.totalSat)) {
+ log('pouch cannot cover ' + quote.totalSat);
+ return false; // underfunded pouch: the user should top up — notify
+ }
+
+ const storage = {
+ load: () => pouch.mgr || seedState(pouch),
+ save: (s) => { pouch.mgr = s; saveFn(walletKey, pouch).catch(() => {}); },
+ };
+ const keyShim = { deriveChild: () => ({ deriveChild: (i) => ({ privateKey: hex.decode(pouch.keys[String(i)] || pouch.keys['0']) }) }) };
+ let mgr;
+ try {
+ mgr = await new ArkManager({
+ account: keyShim, storage,
+ arkUrl: pouch.ark.arkUrl, esploraUrl: pouch.ark.esploraUrl, network: pouch.ark.network,
+ }).init();
+ } catch (e) { log('ark init failed: ' + e.message); return false; }
+
+ let lock;
+ try {
+ lock = await mgr.htlcLock({
+ amountSat: quote.totalSat, claimPubkey: quote.claimPubkey,
+ paymentHash: quote.paymentHash, htlcExpiry: quote.htlcExpiry,
+ });
+ } catch (e) { log('htlc lock failed: ' + e.message); return false; }
+
+ // Persist the swap BEFORE handing it to the bridge: if we die mid-flight,
+ // the app finds the record on next open and runs its refund machinery.
+ pouch.swaps = pouch.swaps || [];
+ const rec = {
+ paymentHash: quote.paymentHash, invoice, bridgeUrl: pouch.bridge.url,
+ amountSat: quote.amountSat, feeSat: quote.feeSat,
+ htlcVtxo: lock.htlcVtxos[0], refundIndex: lock.refundIndex,
+ htlcExpiry: quote.htlcExpiry, step: 'submitted', ts: Date.now(),
+ };
+ pouch.swaps.push(rec);
+ await saveFn(walletKey, pouch);
+
+ let res;
+ try {
+ const r = await fetchFn(`${pouch.bridge.url}/swap`, {
+ method: 'POST', headers: { 'content-type': 'application/json', ...auth },
+ body: JSON.stringify({ invoice, htlcVtxo: lock.htlcVtxos[0] }),
+ });
+ res = await r.json();
+ if (!r.ok || res.error) throw new Error(res.error || `bridge error (${r.status})`);
+ if (res.step !== 'done') throw new Error(res.error || 'bridge did not pay');
+ if (!res.preimage || hex.encode(sha256(hex.decode(res.preimage))) !== quote.paymentHash) {
+ throw new Error('bridge returned an invalid preimage');
+ }
+ } catch (e) {
+ rec.step = 'refundable';
+ rec.error = e.message;
+ await saveFn(walletKey, pouch);
+ // the winner may have paid while we were locking — never shout over it
+ if (!(await answered())) {
+ await publish(errRes('pay_invoice', 'INTERNAL', e.message)).catch(() => {});
+ }
+ return true;
+ }
+
+ rec.step = 'done';
+ rec.preimage = res.preimage;
+ pouch.spends = pouch.spends || [];
+ pouch.spends.push({
+ connId: conn.id, amountSat: quote.amountSat, feeSat: quote.feeSat,
+ paymentHash: quote.paymentHash, preimage: res.preimage, invoice, ts: Date.now(),
+ });
+ if (pouch.spends.length > 200) pouch.spends = pouch.spends.slice(-200);
+ await saveFn(walletKey, pouch);
+ await publish({
+ result_type: 'pay_invoice',
+ result: { preimage: res.preimage, fees_paid: (quote.feeSat || 0) * 1000 },
+ });
+ log(`paid ${quote.amountSat} sat via bridge from the pouch`);
+ return true;
+}