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 | |
| 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]>
| -rw-r--r-- | build.js | 92 | ||||
| -rw-r--r-- | nwcpush/server.js | 85 | ||||
| -rw-r--r-- | src/ark/manager.js | 34 | ||||
| -rw-r--r-- | src/features/ark.js | 112 | ||||
| -rw-r--r-- | src/features/nwc.js | 84 | ||||
| -rw-r--r-- | src/i18n.js | 13 | ||||
| -rw-r--r-- | src/nwc-pouch.js | 38 | ||||
| -rw-r--r-- | src/nwc-respond.js | 245 | ||||
| -rw-r--r-- | src/sw-nwc.js | 49 | ||||
| -rw-r--r-- | tools/nwc-pouch-test.js | 5 | ||||
| -rw-r--r-- | tools/nwc-sw-test.js | 137 |
11 files changed, 830 insertions, 64 deletions
@@ -60,40 +60,11 @@ self.addEventListener('activate', (e) => { ); }); -// NWC push: the notifier wakes us when a wallet-connect request arrives. -// This handler deliberately does NOT spend. It surfaces the request so the -// user can open Hal, which then answers it from the relay. Auto-answering -// from the worker needs the pouch spend path and is a separate step. -self.addEventListener('push', (e) => { - let data = {}; - try { data = e.data ? e.data.json() : {}; } catch (_) {} - if (data.type !== 'nwc') return; - e.waitUntil((async () => { - // If a window is already open it can handle this itself — tell it, and - // stay quiet rather than nagging the user. - const clients = await self.clients.matchAll({ type: 'window', includeUncontrolled: true }); - if (clients.length) { - for (const c of clients) c.postMessage({ type: 'nwc-wake', servicePubkey: data.servicePubkey }); - return; - } - await self.registration.showNotification('Payment request', { - body: 'An app is asking your wallet to pay. Open Hal to approve.', - icon: 'icon-192.png', badge: 'icon-192.png', - tag: 'nwc-' + (data.servicePubkey || 'req'), renotify: false, - data: { url: './' }, - }); - })()); -}); - -self.addEventListener('notificationclick', (e) => { - e.notification.close(); - e.waitUntil((async () => { - const all = await self.clients.matchAll({ type: 'window', includeUncontrolled: true }); - const open = all.find((c) => 'focus' in c); - if (open) return open.focus(); - if (self.clients.openWindow) return self.clients.openWindow('./'); - })()); -}); +// NWC push handling lives in src/sw-nwc.js (bundled and appended below when +// the nwc+ark features ship): it tries to ANSWER the request from the pouch +// and falls back to a notification. {{NWC_WAKE_FALLBACK}} carries a +// notify-only handler for builds without those features. +{{NWC_WAKE_FALLBACK}} self.addEventListener('fetch', (e) => { const req = e.request; @@ -163,6 +134,50 @@ export async function buildJsQr({ minify = true } = {}) { return result.outputs[0].text(); } +// The notify-only push handler for builds without the ark+nwc features (the +// full builds get the bundled auto-answering handler from src/sw-nwc.js). +const SW_WAKE_ONLY = `self.addEventListener('push', (e) => { + let data = {}; + try { data = e.data ? e.data.json() : {}; } catch (_) {} + if (data.type !== 'nwc') return; + e.waitUntil((async () => { + const clients = await self.clients.matchAll({ type: 'window', includeUncontrolled: true }); + if (clients.length) { + for (const c of clients) c.postMessage({ type: 'nwc-wake', servicePubkey: data.servicePubkey }); + return; + } + await self.registration.showNotification('Payment request', { + body: 'An app is asking your wallet to pay. Open Hal to approve.', + icon: 'icon-192.png', badge: 'icon-192.png', + tag: 'nwc-' + (data.servicePubkey || 'req'), renotify: false, + data: { url: './' }, + }); + })()); +}); +self.addEventListener('notificationclick', (e) => { + e.notification.close(); + e.waitUntil((async () => { + const all = await self.clients.matchAll({ type: 'window', includeUncontrolled: true }); + const open = all.find((c) => 'focus' in c); + if (open) return open.focus(); + if (self.clients.openWindow) return self.clients.openWindow('./'); + })()); +});`; + +// The auto-answering NWC worker module, bundled for the SW global scope. +export async function buildSwNwc({ minify = true } = {}) { + const result = await Bun.build({ + entrypoints: ['./src/sw-nwc.js'], + target: 'browser', + minify, + }); + if (!result.success) { + for (const log of result.logs) console.error(log); + throw new Error('sw-nwc bundle failed'); + } + return result.outputs[0].text(); +} + // The silent-payment scanning worker, bundled separately (its own global scope) // and embedded as base64 so the single-file app can spawn it from a Blob URL. export async function buildSpWorker({ minify = true } = {}) { @@ -263,7 +278,14 @@ if (import.meta.main) { // PWA sidecars. await Bun.write('dist/manifest.webmanifest', JSON.stringify(MANIFEST, null, 2)); const version = Bun.hash(html).toString(36); - await Bun.write('dist/sw.js', SW.replace('{{VERSION}}', version)); + // Full builds get the auto-answering NWC worker; feature-stripped builds + // keep the notify-only handler (the responder would just dead-weight them). + const feats = enabledFeatures(); + const nwcSw = feats.includes('nwc') && feats.includes('ark'); + const swBody = SW + .replace('{{VERSION}}', version) + .replace('{{NWC_WAKE_FALLBACK}}', nwcSw ? '' : SW_WAKE_ONLY); + await Bun.write('dist/sw.js', nwcSw ? swBody + '\n' + await buildSwNwc() : swBody); for (const f of STATIC) await Bun.write('dist/' + f, Bun.file('static/' + f)); const kb = (Buffer.byteLength(html) / 1024).toFixed(0); diff --git a/nwcpush/server.js b/nwcpush/server.js index a2164d6..951869c 100644 --- a/nwcpush/server.js +++ b/nwcpush/server.js @@ -20,8 +20,21 @@ import { readFileSync, writeFileSync, renameSync, mkdirSync, existsSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { SimplePool } from 'nostr-tools/pool'; +import { verifyEvent } from 'nostr-tools/pure'; import webpush from 'web-push'; +// crude per-IP publish limiter: 20/min is far above a wallet's real reply rate +const pubRate = new Map(); +function rateOk(ip) { + const now = Date.now(); + const arr = (pubRate.get(ip) || []).filter((t) => t > now - 60_000); + if (arr.length >= 20) return false; + arr.push(now); + pubRate.set(ip, arr); + if (pubRate.size > 500) for (const [k, v] of pubRate) { if (!v.some((t) => t > now - 60_000)) pubRate.delete(k); } + return true; +} + const CFG = JSON.parse(readFileSync(process.env.NWCPUSH_CONFIG || join(import.meta.dir, 'config.json'), 'utf8')); // { port, relays: [...], vapid: { publicKey, privateKey, subject }, @@ -86,16 +99,45 @@ function resubscribe() { { onevent: (ev) => { const target = ev.tags?.find((t) => t[0] === 'p')?.[1]; - if (target) wake(target, ev.id).catch(() => {}); + if (target) { noteRequest(ev.id); wake(target, ev.id, ev).catch(() => {}); } + }, + }, + ); + // Track replies for the requests we forwarded, so an auto-answering worker + // can ask "was this already answered by an open device elsewhere?" before + // it pays. Only e-tags of requests we have seen are recorded. + try { answeredSub?.close(); } catch {} + answeredSub = pool.subscribeMany( + RELAYS, + { kinds: [RES_KIND], since: Math.floor(Date.now() / 1000) }, + { + onevent: (ev) => { + for (const t of ev.tags || []) { + if (t[0] === 'e' && recentReqs.has(t[1])) answeredIds.set(t[1], Date.now()); + } }, }, ); log(`subscribed for ${pks.length} service pubkey(s) on ${RELAYS.length} relays`); } +// requests we pushed recently, and which of them got a reply +const RES_KIND = 23195; +let answeredSub = null; +const recentReqs = new Map(); // request event id -> ts +const answeredIds = new Map(); // request event id -> ts a 23195 e-tagged it +function noteRequest(id) { + recentReqs.set(id, Date.now()); + if (recentReqs.size > 2000) { + const cut = Date.now() - 300_000; + for (const [k, t] of recentReqs) if (t < cut) recentReqs.delete(k); + for (const [k, t] of answeredIds) if (t < cut) answeredIds.delete(k); + } +} + // De-dupe: the same event arrives from several relays. const recentlyWoken = new Map(); -async function wake(servicePk, eventId) { +async function wake(servicePk, eventId, ev) { const key = servicePk + ':' + eventId; if (recentlyWoken.has(key)) return; recentlyWoken.set(key, Date.now()); @@ -106,9 +148,16 @@ async function wake(servicePk, eventId) { const targets = regs[servicePk] || []; if (!targets.length) return; - // The payload carries no request content — only enough for the service - // worker to know which connection to go look at. - const payload = JSON.stringify({ type: 'nwc', servicePubkey: servicePk }); + // The payload carries the (still encrypted, still client-signed) request + // event so an auto-answering service worker can act on it without holding + // a relay socket. Web Push payloads cap around 4KB — an oversized event is + // omitted and the worker falls back to waking the user. + const clean = ev && { + id: ev.id, pubkey: ev.pubkey, created_at: ev.created_at, + kind: ev.kind, tags: ev.tags, content: ev.content, sig: ev.sig, + }; + let payload = JSON.stringify({ type: 'nwc', servicePubkey: servicePk, event: clean }); + if (payload.length > 3800) payload = JSON.stringify({ type: 'nwc', servicePubkey: servicePk }); let ok = 0; for (const r of targets) { try { @@ -159,6 +208,32 @@ const server = Bun.serve({ // The browser needs this to create a PushSubscription. if (url.pathname === '/vapid') return json({ publicKey: CFG.vapid.publicKey }); + // Publish-back for the auto-answering service worker: it cannot hold a + // relay socket, so it hands us its (encrypted, service-key-signed) reply. + // Strictly kind 23195, size-capped, signature-verified, rate-limited — + // this is a reply pipe, not an open relay proxy. + if (url.pathname === '/publish' && req.method === 'POST') { + const ip = req.headers.get('cf-connecting-ip') || req.headers.get('x-forwarded-for') || 'local'; + if (!rateOk(ip)) return json({ error: 'rate limited' }, 429); + const body = await req.json().catch(() => null); + const ev = body?.event; + if (!ev || ev.kind !== RES_KIND) return json({ error: 'kind 23195 events only' }, 400); + if (JSON.stringify(ev).length > 4096) return json({ error: 'event too large' }, 400); + if (!verifyEvent(ev)) return json({ error: 'bad signature' }, 400); + const eTag = (ev.tags || []).find((t) => t[0] === 'e')?.[1]; + try { await Promise.allSettled(pool.publish(RELAYS, ev)); } catch {} + if (eTag) answeredIds.set(eTag, Date.now()); + log(`published a reply for request ${(eTag || '?').slice(0, 12)}`); + return json({ ok: true }); + } + + // Was a request we pushed already answered by someone? Lets a worker + // avoid double-paying (or shouting an error over) another device's reply. + if (url.pathname === '/answered') { + const id = url.searchParams.get('event') || ''; + return json({ answered: answeredIds.has(id) }); + } + // A device registers its push endpoint plus the wallet-service pubkeys it // wants woken for. Re-post periodically to stay alive. if (url.pathname === '/register' && req.method === 'POST') { diff --git a/src/ark/manager.js b/src/ark/manager.js index e70acfb..2edcd8a 100644 --- a/src/ark/manager.js +++ b/src/ark/manager.js @@ -500,6 +500,32 @@ export class ArkManager { return action.id; } + // A send whose recipient is one of the USER's own foreign-branch keys (the + // NWC pouch): no address, no mailbox delivery — the caller receives the + // signed recipient vtxo bytes directly and stores them wherever the branch + // lives. Same cosign machinery as send(), so all its failure handling holds. + async sendToPubkey(destPubkeyHex, amountSat) { + const input = this._selectInput(amountSat); + const changeSat = input.amountSat - amountSat; + const action = { + id: `send-${Date.now()}`, type: 'send', step: 'created', + inputId: input.id, amountSat, destAddress: null, + destPubkey: destPubkeyHex, destBlindedId: null, + changeIndex: changeSat > 0 ? this.state.nextKeyIndex++ : null, changeSat, + }; + input.state = 'pending'; + this.state.actions.push(action); + this._save(); + await this._driveSend(action); + if (action.step !== 'done') throw new Error(action.error || 'send did not complete'); + return { + vtxos: (action.destBytesList || []).map((b) => { + const v = decodeVtxo(hex.decode(b)); + return { id: v.id, bytes: b, amountSat: v.amountSat, expiryHeight: v.expiryHeight }; + }), + }; + } + _sendOutputs(action) { const outputs = [{ amountSat: action.amountSat, userPubkey: hex.decode(action.destPubkey) }]; if (action.changeSat > 0) { @@ -558,8 +584,12 @@ export class ArkManager { this._save(); } if (action.step === 'registered') { - await postArkoorMessage(this.arkUrl, hex.decode(action.destBlindedId), - destList().map((b) => hex.decode(b))); + // a pouch-bound send has no mailbox to deliver to — the caller takes + // the bytes from the returned action instead + if (action.destBlindedId) { + await postArkoorMessage(this.arkUrl, hex.decode(action.destBlindedId), + destList().map((b) => hex.decode(b))); + } for (const b of changeList()) { const change = this._vtxo(decodeVtxo(hex.decode(b)).id); if (change && change.state === 'pending') change.state = 'spendable'; diff --git a/src/features/ark.js b/src/features/ark.js index 8456f69..c163583 100644 --- a/src/features/ark.js +++ b/src/features/ark.js @@ -7,6 +7,7 @@ import { HDKey } from '@scure/bip32'; import { hex, base32nopad, bech32 } from '@scure/base'; import { sha256 } from '@noble/hashes/sha256'; import { ArkManager } from '../ark/manager.js'; +import { pouchKey, loadPouch, savePouch, POUCH_CHANGE_BASE } from '../nwc-pouch.js'; import { boardFee } from '../ark/board.js'; import { maybeBolt11, lnSendFee } from '../ark/lightning.js'; import { decodeVtxo, getVtxoStatus, VTXO_STATE_SPENT, concatBytes } from '../ark/proto.js'; @@ -530,6 +531,28 @@ export function arkFeature(ctx) { return res; } + // An ephemeral manager over the POUCH's chain-6 keys — used to empty the + // pouch and to refund worker-initiated swaps (their refund pubkeys are + // pouch keys, which the main manager cannot sign for). State persists back + // into the pouch record so the service worker sees the same coin set. + async function pouchManager(pouch) { + const cfg = getArkConfig(); + const account6 = { deriveChild: () => ({ deriveChild: (i) => ({ privateKey: hex.decode(pouch.keys[String(i)] || pouch.keys['0']) }) }) }; + const storage = { + load: () => pouch.mgr || { + 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: [], + }, + save: (s) => { pouch.mgr = s; savePouch(wallet._cacheKey(), pouch).catch(() => {}); }, + }; + return new ArkManager({ + account: account6, storage, + arkUrl: cfg.ark, esploraUrl: cfg.esplora, network: getNetwork(), + }).init(); + } + // Ask the bridge to hand an unpaid HTLC back, and take ownership of it. async function tryBridgeRefund(rec) { const mgr = await connectArk(); @@ -1676,11 +1699,96 @@ export function arkFeature(ctx) { return { invoice: a.invoice, paymentHash: a.paymentHash, amountSat }; }, arkMovements() { const s = arkStateNow(); return s ? (s.movements || []) : []; }, - // endpoints + server identity for the NWC pouch record + // endpoints + server identity + bridge for the NWC pouch record arkPouchContext() { const cfg = getArkConfig(); if (!cfg || !ark || !ark.info) return null; - return { arkUrl: cfg.ark, esploraUrl: cfg.esplora, network: getNetwork(), serverPubkey: ark.info.serverPubkey }; + return { + arkUrl: cfg.ark, esploraUrl: cfg.esplora, network: getNetwork(), + serverPubkey: ark.info.serverPubkey, bridge: getBridgeConfig(), + }; + }, + // Move sats INTO the pouch: an arkoor self-send to a chain-6 key whose + // signed recipient bytes we keep directly — no address, no mailbox. + async arkPouchFund(amountSat) { + const pouch = await loadPouch(wallet._cacheKey()); + if (!pouch) throw new Error('enable background answering first'); + const mgr = await connectArk(); + const idx = (pouch.fundSeq || 0) % POUCH_CHANGE_BASE; + const dest = pouchKey(wallet.account(), idx); + const out = await mgr.sendToPubkey(hex.encode(dest.pubkey), amountSat); + pouch.fundSeq = (pouch.fundSeq || 0) + 1; + const add = out.vtxos.map((v) => ({ ...v, keyIndex: idx, state: 'spendable' })); + pouch.vtxos = [...(pouch.vtxos || []), ...add]; + if (pouch.mgr) pouch.mgr.vtxos = [...(pouch.mgr.vtxos || []), ...add.map((v) => ({ ...v }))]; + pouch.updated = Date.now(); + await savePouch(wallet._cacheKey(), pouch); + return add.reduce((n, v) => n + v.amountSat, 0); + }, + // Everything back to the main wallet (ordinary arkoor sends to our own + // ark address, so they arrive like any receive). + async arkPouchEmpty() { + const pouch = await loadPouch(wallet._cacheKey()); + if (!pouch) return 0; + const pm = await pouchManager(pouch); + const mgr = await connectArk(); + const dest = mgr.address(); + let moved = 0; + for (let guard = 0; guard < 30; guard++) { + const v = (pm.state.vtxos || []).find((x) => x.state === 'spendable'); + if (!v) break; + await pm.send(dest, v.amountSat); // full amount: no change stays behind + moved += v.amountSat; + } + pouch.vtxos = (pm.state.vtxos || []).filter((x) => x.state === 'spendable'); + pouch.mgr = pm.state; + pouch.updated = Date.now(); + await savePouch(wallet._cacheKey(), pouch); + return moved; + }, + async arkPouchBalance() { + const pouch = await loadPouch(wallet._cacheKey()); + if (!pouch) return 0; + const vs = (pouch.mgr && pouch.mgr.vtxos) || pouch.vtxos || []; + return vs.filter((v) => (v.state || 'spendable') === 'spendable').reduce((n, v) => n + (v.amountSat || 0), 0); + }, + // Recover worker-initiated swaps: finished ones become history, dead ones + // are refunded with the POUCH's own keys (the refund pubkey is chain 6 — + // the main manager cannot sign for it). + async arkPouchRecover() { + const pouch = await loadPouch(wallet._cacheKey()); + if (!pouch || !(pouch.swaps || []).some((r) => ['submitted', 'refundable'].includes(r.step))) return; + const pm = await pouchManager(pouch); + for (const rec of pouch.swaps) { + if (!['submitted', 'refundable'].includes(rec.step)) continue; + try { + const st = await fetch(`${rec.bridgeUrl}/swap?paymentHash=${rec.paymentHash}`).then((r) => r.json()); + if (st.step === 'done' && st.preimage) { rec.step = 'done'; rec.preimage = st.preimage; continue; } + } catch {} + try { + const r = await fetch(`${rec.bridgeUrl}/refund`, { + method: 'POST', headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ paymentHash: rec.paymentHash }), + }); + const out = await r.json(); + if (!r.ok || out.error) throw new Error(out.error || 'refund failed'); + await pm.acceptHtlcRefund({ vtxoBytesList: out.refundVtxos, refundIndex: rec.refundIndex }); + rec.step = 'refunded'; + } catch {} + } + pouch.mgr = pm.state; + await savePouch(wallet._cacheKey(), pouch); + }, + // Pouch payments become ordinary history lines once the app opens. + async arkPouchNoteSpends(spends) { + const mgr = await connectArk(); + for (const s of spends) { + mgr._movement({ + type: 'ln-send', amountSat: s.amountSat, status: 'complete', + detail: `via pouch · fee ${s.feeSat || 0} sat`, invoice: s.invoice || '', preimage: s.preimage || '', + }); + } + mgr._save(); }, // An npub pasted into Send becomes an ark zap (needs the nostr seam from // the sync feature and a connected-able ark). A bolt11 is handled here diff --git a/src/features/nwc.js b/src/features/nwc.js index 51c0fd6..a91768d 100644 --- a/src/features/nwc.js +++ b/src/features/nwc.js @@ -30,7 +30,7 @@ import { import { t } from '../i18n.js'; import { qrSvg } from '../qr.js'; import { getNetwork } from '../api.js'; -import { buildPouch, savePouch, clearPouch, pouchKey } from '../nwc-pouch.js'; +import { buildPouch, loadPouch, savePouch, clearPouch, pouchKey } from '../nwc-pouch.js'; import { maybeBolt11 } from '../ark/lightning.js'; const REQ_KIND = 23194; @@ -496,20 +496,55 @@ export function nwcFeature(ctx) { try { await clearPouch(wallet._cacheKey()); } catch {} } - // Mirror just enough for a future worker-side responder: the connections' - // service keys and the ASP endpoints. No pouch coins are moved here — the - // funding UI comes with the spend path. + // (Re)write the pouch record the worker answers from: connections' service + // keys, ASP + bridge endpoints, the chain-6 key window — while PRESERVING + // anything the pouch already holds (its coins, the worker's spend log and + // manager state), which a rebuild must never wipe. async function writePouch() { if (!load().background) return; const cfg = hook('arkPouchContext'); if (!cfg) return; try { - await savePouch(wallet._cacheKey(), buildPouch({ - ...cfg, vtxos: [], account: wallet.account(), connections: conns(), - })); + const prev = await loadPouch(wallet._cacheKey()); + const fresh = buildPouch({ + ...cfg, vtxos: prev?.vtxos || [], account: wallet.account(), connections: conns(), + }); + if (prev) { + fresh.mgr = prev.mgr; + fresh.spends = prev.spends; + fresh.swaps = prev.swaps; + fresh.fundSeq = prev.fundSeq; + if (prev.mgr?.nextKeyIndex) fresh.nextKeyIndex = prev.mgr.nextKeyIndex; + } + await savePouch(wallet._cacheKey(), fresh); + pouchSat = await hook('arkPouchBalance'); } catch (e) { console.warn('nwc: could not write pouch', e.message); } } + // When the app opens after the worker has been answering: absorb its spends + // into the budgets and history, and rescue any swap it left hanging. + let pouchSat = null; // cached for the (synchronous) settings card + async function reconcilePouch() { + try { + if (!load().background) return; + await hook('arkPouchRecover'); + const pouch = await loadPouch(wallet._cacheKey()); + if (!pouch) return; + const fresh = (pouch.spends || []).filter((s) => !s.absorbed); + if (fresh.length) { + for (const s of fresh) { + const c = conns().find((x) => x.id === s.connId); + if (c) recordSpend(c, (s.amountSat || 0) + (s.feeSat || 0)); + s.absorbed = true; + } + await hook('arkPouchNoteSpends', fresh); + await savePouch(wallet._cacheKey(), pouch); + } + pouchSat = await hook('arkPouchBalance'); + render(); + } catch (e) { console.warn('nwc: pouch reconcile failed', e.message); } + } + // An open window handles the request itself; the worker just nudges it. if (typeof navigator !== 'undefined' && navigator.serviceWorker) { navigator.serviceWorker.addEventListener('message', (ev) => { @@ -580,11 +615,42 @@ export function nwcFeature(ctx) { h('button', { class: 'btn-sm', onClick: async () => { try { if (load().background) { await disableBackground(); toast(t('nwcBackgroundOff')); } - else { await enableBackground(); toast(t('nwcBackgroundOn')); } + else { await enableBackground(); await reconcilePouch(); toast(t('nwcBackgroundOn')); } } catch (e) { toast(e.message); } render(); } }, load().background ? t('nwcOn') : t('nwcOff'))) : null, + list.length && load().background + ? h('div', { class: 'col', style: 'gap:6px' }, + h('div', { class: 'row between' }, + h('span', { class: 'small' }, t('nwcPouch')), + h('span', { class: 'small' }, pouchSat == null ? '…' : fmtAmount(pouchSat))), + h('p', { class: 'small faint', style: 'margin:0' }, t('nwcPouchDesc')), + h('div', { class: 'row gap6' }, + h('input', { type: 'number', min: '1', placeholder: t('nwcPouchAmount'), value: ui.nwcPouchAmt || '', + style: 'flex:1', onInput: (e) => { ui.nwcPouchAmt = e.target.value; } }), + h('button', { class: 'btn-sm', onClick: async () => { + const sat = parseInt(ui.nwcPouchAmt, 10); + if (!sat) return; + try { + const moved = await hook('arkPouchFund', sat); + ui.nwcPouchAmt = ''; + pouchSat = await hook('arkPouchBalance'); + toast(t('nwcPouchFunded', { sats: fmtAmount(moved) })); + } catch (e) { toast(e.message); } + render(); + } }, t('nwcPouchFund')), + pouchSat > 0 + ? h('button', { class: 'btn-ghost btn-sm', onClick: async () => { + try { + const moved = await hook('arkPouchEmpty'); + pouchSat = await hook('arkPouchBalance'); + toast(t('nwcPouchEmptied', { sats: fmtAmount(moved) })); + } catch (e) { toast(e.message); } + render(); + } }, t('nwcPouchEmpty')) + : null)) + : null, st ? h('div', { class: 'col', style: 'gap:6px;border-top:1px solid var(--border,rgba(128,128,128,.2));padding-top:8px' }, h('input', { type: 'text', placeholder: t('nwcAppName'), value: st.name, @@ -611,7 +677,7 @@ export function nwcFeature(ctx) { return { id: 'nwc', - init() { listen(); startWatchdog(); refreshRegistration(); }, + init() { listen(); startWatchdog(); refreshRegistration(); reconcilePouch(); }, stop() { stop(); if (watchdog) { clearInterval(watchdog); watchdog = null; } }, settingsCards() { return [nwcCard()]; }, }; diff --git a/src/i18n.js b/src/i18n.js index 1fa7d74..327bf01 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -439,11 +439,18 @@ const STR = { nwcDesc: 'Let another app pay from this wallet over nostr — it never sees your keys, only spends within the limits you set, and you can cut it off at any time. Payments go out over Ark.', nwcTabWarning: 'Only works while Hal is open in a tab — it is not an always-on service.', nwcAdd: 'Add a connection', - nwcBackground: 'Alert me when closed', + nwcBackground: 'Answer while closed', nwcOn: 'On', nwcOff: 'Off', - nwcBackgroundOn: 'You will be notified of payment requests', - nwcBackgroundOff: 'Background alerts turned off', + nwcBackgroundOn: 'Requests will be answered in the background', + nwcBackgroundOff: 'Background answering turned off', + nwcPouch: 'Pouch (spendable while closed)', + nwcPouchDesc: 'Coins in the pouch can be spent by the background responder without opening Hal. Keep it small — top it up as it drains.', + nwcPouchFund: 'Add to pouch', + nwcPouchEmpty: 'Empty pouch', + nwcPouchFunded: 'Moved {sats} sats into the pouch', + nwcPouchEmptied: 'Moved {sats} sats back to the wallet', + nwcPouchAmount: 'Amount (sats)', nwcAppName: 'App name (e.g. Damus)', nwcMaxPayment: 'Max per payment (sats)', nwcDailyBudget: 'Daily budget (sats)', diff --git a/src/nwc-pouch.js b/src/nwc-pouch.js index 0d4fcbc..187ab7a 100644 --- a/src/nwc-pouch.js +++ b/src/nwc-pouch.js @@ -73,6 +73,23 @@ const get = (s, k) => new Promise((res, rej) => { const r = s.get(k); r.onsucces 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); }); } @@ -80,25 +97,32 @@ 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 pouch vtxos with their bytes, and the private keys for -// exactly those vtxos. -export function buildPouch({ arkUrl, esploraUrl, network, serverPubkey, vtxos, account, connections }) { +// 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 (const v of vtxos) { - const k = pouchKey(account, v.keyIndex); - keys[String(v.keyIndex)] = hex.encode(k.privkey); + for (let i = 0; i < POUCH_KEY_WINDOW; i++) { + keys[String(i)] = hex.encode(pouchKey(account, i).privkey); } return { - v: 1, + 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) => ({ 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; +} diff --git a/src/sw-nwc.js b/src/sw-nwc.js new file mode 100644 index 0000000..db57b99 --- /dev/null +++ b/src/sw-nwc.js @@ -0,0 +1,49 @@ +// Service-worker side of background NWC. Bundled by build.js and appended to +// the generated sw.js, replacing the notify-only push handler: a push now +// first tries to ANSWER the request from the pouch (see nwc-respond.js), and +// only falls back to the wake-the-user notification when it can't. + +import { respondFromPouch } from './nwc-respond.js'; + +const NOTIFIER = 'https://nwcpush.coinos.io'; + +self.addEventListener('push', (e) => { + let data = {}; + try { data = e.data ? e.data.json() : {}; } catch (_) {} + if (data.type !== 'nwc') return; + e.waitUntil((async () => { + // An open window handles requests itself with full wallet state — nudge + // it and stay out of the way. + const clients = await self.clients.matchAll({ type: 'window', includeUncontrolled: true }); + if (clients.length) { + for (const c of clients) c.postMessage({ type: 'nwc-wake', servicePubkey: data.servicePubkey }); + return; + } + let handled = false; + try { + handled = await respondFromPouch(data, { + notifier: NOTIFIER, + log: (m) => console.log('[sw-nwc]', m), + }); + } catch (err) { + console.warn('[sw-nwc] auto-answer failed:', err && err.message); + } + if (handled) return; + await self.registration.showNotification('Payment request', { + body: 'An app is asking your wallet to pay. Open Hal to approve.', + icon: 'icon-192.png', badge: 'icon-192.png', + tag: 'nwc-' + (data.servicePubkey || 'req'), renotify: false, + data: { url: './' }, + }); + })()); +}); + +self.addEventListener('notificationclick', (e) => { + e.notification.close(); + e.waitUntil((async () => { + const all = await self.clients.matchAll({ type: 'window', includeUncontrolled: true }); + const open = all.find((c) => 'focus' in c); + if (open) return open.focus(); + if (self.clients.openWindow) return self.clients.openWindow('./'); + })()); +}); diff --git a/tools/nwc-pouch-test.js b/tools/nwc-pouch-test.js index 090f9b1..7846585 100644 --- a/tools/nwc-pouch-test.js +++ b/tools/nwc-pouch-test.js @@ -33,7 +33,10 @@ const pouch = buildPouch({ }); check('pouch carries both vtxos', pouch.vtxos.length === 2); check('pouch balance sums', pouchBalance(pouch) === 7000, String(pouchBalance(pouch))); -check('one key per vtxo index', Object.keys(pouch.keys).sort().join() === '0,1'); +// the worker can't derive, so the pouch ships a fixed key window covering +// funding indices and the worker's change/refund allocations +check('full key window shipped', Object.keys(pouch.keys).length === 50 && pouch.keys['0'] && pouch.keys['49']); +check('change base inside the window', pouch.nextKeyIndex === 20); const blob = JSON.stringify(pouch); check('NO seed/mnemonic in the pouch', !/mnemonic|seed|xprv/i.test(blob)); // the exported keys must actually correspond to the pouch pubkeys diff --git a/tools/nwc-sw-test.js b/tools/nwc-sw-test.js new file mode 100644 index 0000000..b9826dd --- /dev/null +++ b/tools/nwc-sw-test.js @@ -0,0 +1,137 @@ +// The service worker's pouch responder, headless: request decrypt, balance, +// budgets, answered-elsewhere suppression, fallbacks. The bridge/ASP pay path +// itself is covered by the manager and bridge tests. Run: bun tools/nwc-sw-test.js +import { hex } from '@scure/base'; +import * as nip04 from 'nostr-tools/nip04'; +import * as nip44 from 'nostr-tools/nip44'; +import { finalizeEvent, generateSecretKey, getPublicKey } from 'nostr-tools/pure'; +import { respondFromPouch } from '../src/nwc-respond.js'; + +let fails = 0; +const check = (name, ok, detail = '') => { + console.log(` ${ok ? '✓' : '✗'} ${name}${detail ? ' — ' + detail : ''}`); + if (!ok) fails++; +}; + +const serviceSk = generateSecretKey(); +const clientSk = generateSecretKey(); +const conn = { + id: 'c1', servicePk: getPublicKey(serviceSk), serviceSk: hex.encode(serviceSk), + clientPk: getPublicKey(clientSk), maxSat: 1000, dailySat: 2000, +}; +const basePouch = () => ({ + v: 2, + ark: { arkUrl: 'http://ark.test', esploraUrl: 'http://esp.test', network: 'mainnet', serverPubkey: 'ab'.repeat(32) }, + bridge: { url: 'http://bridge.test', token: '' }, + vtxos: [{ id: 'v1', bytes: '00', keyIndex: 0, amountSat: 5000, expiryHeight: 1 }], + keys: { 0: hex.encode(generateSecretKey()) }, + nextKeyIndex: 20, + connections: [conn], + spends: [], +}); + +function makeReq(method, params = {}, scheme = 'nip44_v2', ageSec = 0) { + const payload = JSON.stringify({ method, params }); + const content = scheme === 'nip44_v2' + ? nip44.encrypt(payload, nip44.getConversationKey(clientSk, conn.servicePk)) + : nip04.encrypt(clientSk, conn.servicePk, payload); + return finalizeEvent({ + kind: 23194, created_at: Math.floor(Date.now() / 1000) - ageSec, + tags: [['p', conn.servicePk], ...(scheme === 'nip44_v2' ? [['encryption', 'nip44_v2']] : [])], + content, + }, clientSk); +} + +function harness({ pouch = basePouch(), answered = false } = {}) { + const published = []; + const fetchFn = async (url, opts) => { + if (url.includes('/publish')) { + published.push(JSON.parse(opts.body).event); + return { ok: true, json: async () => ({ ok: true }) }; + } + if (url.includes('/answered')) return { ok: true, json: async () => ({ answered }) }; + throw new Error('unexpected fetch ' + url); + }; + const deps = { + notifier: 'http://notif.test', + fetchFn, + pouchesFn: async () => [{ walletKey: 'w1', pouch }], + saveFn: async () => {}, + }; + const decryptReply = (ev) => { + const scheme = ev.tags.find((t) => t[0] === 'encryption')?.[1]; + return JSON.parse(scheme === 'nip44_v2' + ? nip44.decrypt(ev.content, nip44.getConversationKey(clientSk, conn.servicePk)) + : nip04.decrypt(clientSk, conn.servicePk, ev.content)); + }; + return { deps, published, decryptReply }; +} + +const ev = (e) => JSON.parse(JSON.stringify(e)); // strip verified symbol like a push payload would + +console.log('[balance + info]'); +{ + const { deps, published, decryptReply } = harness(); + const handled = await respondFromPouch({ type: 'nwc', event: ev(makeReq('get_balance')) }, deps); + check('get_balance handled', handled === true); + const r = decryptReply(published[0]); + check('pouch balance in msat', r.result?.balance === 5000000, JSON.stringify(r)); +} +{ + const { deps, published, decryptReply } = harness(); + await respondFromPouch({ type: 'nwc', event: ev(makeReq('get_info', {}, 'nip04')) }, deps); + const r = decryptReply(published[0]); + check('nip04 get_info answered in nip04', r.result?.alias === 'Hal' && published[0].tags.find((t) => t[0] === 'encryption')[1] === 'nip04'); +} + +console.log('\n[guards]'); +{ + const { deps } = harness(); + const stale = ev(makeReq('get_balance', {}, 'nip44_v2', 300)); + check('stale request refused', (await respondFromPouch({ type: 'nwc', event: stale }, deps)) === false); +} +{ + const { deps } = harness(); + const foreign = ev(finalizeEvent({ + kind: 23194, created_at: Math.floor(Date.now() / 1000), + tags: [['p', conn.servicePk]], content: 'junk', + }, generateSecretKey())); + check('foreign signer refused', (await respondFromPouch({ type: 'nwc', event: foreign }, deps)) === false); +} +{ + const { deps } = harness(); + check('no payload event → wake the user', (await respondFromPouch({ type: 'nwc', servicePubkey: conn.servicePk }, deps)) === false); +} + +console.log('\n[pay budgets]'); +// a real (long-expired) 21-sat invoice — decode only, nothing is paid +const INV21 = 'lnbc210n1p4xuk2wpp506wkjr0xk3677nu7je9c55vq4lzlkyd0ztcq2mlvumap0zpe3alqhp5ppg7g8qwpdv34hgpymhw446y37duzwcn388yp3pw05n7tlulyn2scqzysxqrrssrzjqv3dpepm8kfdxrk3sl6wzqdf49s9c0h9ljtjrek6c08r6aejlwcnur0dwyqqvusqqqqqqqlgqqqq86qqjqsp5dc0jrq94ke2f4dzx8c2dwqsc6a65eu56dt2j599l7kxp7q2hs6zq9qxpqysgqdjeft8gkl0uga24e502pvcp5vgsfap3dxuutcpgfaj33fffuqs9psmnrklshp3fg3py7vlnzsea90vj9ahqq5t9xuy67u3pk0sfnheqpn95f2g'; +{ + const pouch = basePouch(); + pouch.connections = [{ ...conn, maxSat: 10 }]; + const { deps, published, decryptReply } = harness({ pouch }); + const handled = await respondFromPouch({ type: 'nwc', event: ev(makeReq('pay_invoice', { invoice: INV21 })) }, deps); + check('over per-payment limit refused', handled && decryptReply(published[0]).error?.code === 'QUOTA_EXCEEDED'); +} +{ + const pouch = basePouch(); + pouch.spends = [{ connId: 'c1', amountSat: 1990, feeSat: 0, ts: Date.now() }]; + const { deps, published, decryptReply } = harness({ pouch }); + const handled = await respondFromPouch({ type: 'nwc', event: ev(makeReq('pay_invoice', { invoice: INV21 })) }, deps); + check('over daily budget refused', handled && decryptReply(published[0]).error?.code === 'QUOTA_EXCEEDED'); +} +{ + const { deps, published } = harness({ answered: true }); + const handled = await respondFromPouch({ type: 'nwc', event: ev(makeReq('pay_invoice', { invoice: INV21 })) }, deps); + check('answered elsewhere → silent', handled === true && published.length === 0); +} +{ + const pouch = basePouch(); + pouch.bridge = null; + const { deps } = harness({ pouch }); + const handled = await respondFromPouch({ type: 'nwc', event: ev(makeReq('pay_invoice', { invoice: INV21 })) }, deps); + check('no bridge → wake the user', handled === false); +} + +console.log(fails ? `\n❌ ${fails} failure(s)` : '\n✅ pouch responder behaves'); +process.exit(fails ? 1 : 0); |
