aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--src/features/nwc.js15
-rw-r--r--tools/noffer-test.js22
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);