diff options
| -rw-r--r-- | src/features/nwc.js | 15 | ||||
| -rw-r--r-- | tools/noffer-test.js | 22 |
2 files changed, 36 insertions, 1 deletions
diff --git a/src/features/nwc.js b/src/features/nwc.js index 9768740..741b0a4 100644 --- a/src/features/nwc.js +++ b/src/features/nwc.js @@ -627,12 +627,23 @@ export function nwcFeature(ctx) { if (!o || !pend.length) return; const moves = hook('arkMovements') || []; const keep = []; + // The pending list rides the synced wallet state, and every device holds + // the offer key: phone and laptop each published the same receipt a + // minute apart (Helen's 88-sat zap, Adam 2026-11-14). A receipt already + // out — sent from here, or found on the relays — isn't sent again. + const done = new Set(st.zapDone || []); + const sent = []; for (const z of pend) { if (Date.now() - z.ts > 86400_000) continue; // expired unpaid + if (done.has(z.invoice)) continue; const paid = moves.find((m) => m.type === 'ln-receive' && m.status === 'complete' && m.invoice === z.invoice); if (!paid) { keep.push(z); continue; } try { const zapReq = JSON.parse(z.zap); + const offerPk = getPublicKey(hex.decode(o.sk)); + const to = (zapReq.tags.find((t2) => t2[0] === 'p') || [])[1]; + const out = await query(NWC_RELAYS, { kinds: [9735], authors: [offerPk], ...(to ? { '#p': [to] } : {}), since: Math.floor(z.ts / 1000) - 120 }, 2500).catch(() => []); + if ((out || []).some((r) => (r.tags || []).some((t2) => t2[0] === 'bolt11' && t2[1] === z.invoice))) { sent.push(z.invoice); continue; } const tags = [ ...zapReq.tags.filter((t2) => t2[0] === 'p' || t2[0] === 'e'), ['P', zapReq.pubkey], @@ -644,10 +655,12 @@ export function nwcFeature(ctx) { const relayTag = zapReq.tags.find((t2) => t2[0] === 'relays'); const relays = [...new Set([...(relayTag ? relayTag.slice(1) : []), ...NWC_RELAYS])].slice(0, 8); await publish(relays, receipt); + sent.push(z.invoice); } catch (e) { console.warn('nwc: zap receipt failed', e.message); } } const st2 = load(); st2.zapPending = keep; + st2.zapDone = [...new Set([...(st2.zapDone || []), ...sent])].slice(-100); save(st2); } @@ -1106,6 +1119,8 @@ export function nwcFeature(ctx) { id: 'nwc', nwcOfferPubkey() { return (hook('arkReady') && !wallet.watchOnly) ? offerKeys().pk : null; }, nwcOfferString() { return hook('arkReady') && !wallet.watchOnly ? offerString() : null; }, + // tests: the receipt pass on demand (normally the watchdog's and the reconcile's) + nwcPublishZapReceipts() { return publishZapReceipts(); }, init() { listen(); startWatchdog(); refreshRegistration(); reconcileBg(); ensureBackground(false); serveWake(); }, stop() { stop(); if (watchdog) { clearInterval(watchdog); watchdog = null; } }, nostrSettingsCards() { return [nwcCard()]; }, diff --git a/tools/noffer-test.js b/tools/noffer-test.js index 152ef0b..5886f84 100644 --- a/tools/noffer-test.js +++ b/tools/noffer-test.js @@ -31,8 +31,9 @@ const published = []; const nwcTransport = { subscribe: (relays, filter, on) => { subHandlers.push({ filter, on }); return () => {}; }, publish: async (relays, evt) => { published.push(evt); return true; }, - query: async () => [], + query: async (relays, filter) => queryImpl(relays, filter), }; +let queryImpl = async () => []; const wallet = { watchOnly: false, loadFeatureState: (n, fb) => JSON.parse(store['fs:' + n] || 'null') || fb, @@ -95,5 +96,24 @@ await offerSub.on(reqEv); await new Promise((r) => setTimeout(r, 60)); check('replayed request ignored', published.filter((e) => e.kind === 21001).length === before); +console.log('\n[the receipt, from two devices]'); +// the zap is paid: this device publishes its receipt once +hooks.arkMovements = () => [{ type: 'ln-receive', status: 'complete', invoice: 'lnbc_mock_21', preimage: 'ee'.repeat(32) }]; +const receipts = () => published.filter((e) => e.kind === 9735); +queryImpl = async (relays, filter) => published.filter((e) => (filter.kinds || []).includes(e.kind) && (!filter.authors || filter.authors.includes(e.pubkey))); +await f.nwcPublishZapReceipts(); +check('the paid zap gets its receipt', receipts().length === 1 && receipts()[0].pubkey === offer.pubkey); +await f.nwcPublishZapReceipts(); +check('…once, however often the pass runs', receipts().length === 1); +// another device: the synced state still lists it pending (it synced before +// this device's pass), and the relays have the receipt already +const st3 = JSON.parse(store['fs:nwc']); +st3.zapPending = [{ zap: JSON.stringify(zapReq), invoice: 'lnbc_mock_21', ts: Date.now() }]; +st3.zapDone = []; +store['fs:nwc'] = JSON.stringify(st3); +await f.nwcPublishZapReceipts(); +check('a second device finds it on the relays and stays quiet', receipts().length === 1, receipts().length + ' receipts'); +check('…and drops it from the pending list', (JSON.parse(store['fs:nwc']).zapPending || []).length === 0); + console.log(fails ? `\n❌ ${fails} failure(s)` : '\n✅ offer service behaves'); process.exit(fails ? 1 : 0); |
