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 /tools | |
| 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 'tools')
| -rw-r--r-- | tools/nwc-pouch-test.js | 5 | ||||
| -rw-r--r-- | tools/nwc-sw-test.js | 137 |
2 files changed, 141 insertions, 1 deletions
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); |
