diff options
| author | Adam Soltys <[email protected]> | 2026-11-14 23:02:39 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-11-14 23:02:39 -0700 |
| commit | 31bdd26474436d80204bd51a8d75c674bfe762b1 (patch) | |
| tree | 8cf87c221034ea83d5dd1b42146b13dbb7fa5410 | |
| parent | 1424cc46382266a51123a8425424f3c8cb943e8e (diff) | |
| download | coinosv3-31bdd26474436d80204bd51a8d75c674bfe762b1.tar.gz coinosv3-31bdd26474436d80204bd51a8d75c674bfe762b1.zip | |
A zap to you gets one receipt, not one per device. Helen's 88-sat zap on Adam's comment (2026-11-15 05:33) had two identical kind 9735 receipts a minute apart; Adam guessed "probably my phone and laptop". It was: the zaps waiting for their receipt live in the synced wallet state and every device holds the offer key that signs them, so each device published one when it saw the payment complete. coinos's notifications fold receipts by invoice (two rows were two zaps), other clients may not. Now a device skips a receipt it already sent (zapDone, synced, last 100) or that the relays already carry (one query by the offer key and recipient for that bolt11), and drops it from the pending list either way. noffer-test: the receipt goes out once however often the pass runs, and a second device holding the same pending zap finds it on the relays and stays quiet; nwc, notif-app-zap and features-smoke pass
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01VMan6UKGJyuGyZFxddYRxS
| -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); |
