diff options
| author | Adam Soltys <[email protected]> | 2026-08-01 19:02:25 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-08-01 19:02:25 -0700 |
| commit | b73bb9ee51b9be33bc1859fd56d3e11905c3d456 (patch) | |
| tree | 8a008d57e450961d1ab203a663e9d8fa233a2686 /src/nwc-respond.js | |
| parent | 2a7a825ce5850a2a836effae820adbae0b66d093 (diff) | |
| download | coinosv3-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.js | 245 |
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; +} |
