From 6713f8d4e55db8d5344684b08b062cc9129ef6b0 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Thu, 30 Jul 2026 08:43:47 -0700 Subject: Add NIP-57 Lightning zaps (npub / lightning address / lnurl) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Send a Lightning zap to a nostr npub, a lightning address (name@domain), or an lnurl. New src/lnurl.js does the resolution half — target → LNURL-pay params → signed kind-9734 zap request → callback → bolt11 — and the new zaps feature (src/features/zaps.js) drives the amount/comment UI, then hands the invoice to the existing Boltz submarine-swap flow via a new swaps.startLnPay hook, so there's one tested pay/review/confirm/success path. Routing: a bolt11 is still caught by swaps; an npub still prefers an instant free Ark zap when Ark can serve it, and only falls through to a Lightning zap otherwise — ark's "no Ark address" screen also offers an explicit "Zap over Lightning" fallback (gated on the zaps feature's canLnZap). A real zap receipt is attributed only when the server advertises allowsNostr and we know the recipient's pubkey; otherwise it degrades to a plain LNURL-pay. Nostr seam gains nostrSign (finalize without publishing), nostrProfile (kind-0 with lud16/lud06), and nostrRelays. Lightning addresses are debounced in matchSendText since their regex matches mid-typing. Verified: unit tests for parse/encode/zap-request; a live resolve→signed 9734→payable mainnet bolt11 against coinos; and an in-browser DOM smoke (zap form + LNURL params over CORS + allowsNostr hint + handoff). Co-Authored-By: Claude Opus 4.8 --- src/nostr.js | 26 +++++++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) (limited to 'src/nostr.js') diff --git a/src/nostr.js b/src/nostr.js index e73aa5d..407663c 100644 --- a/src/nostr.js +++ b/src/nostr.js @@ -61,7 +61,15 @@ export async function fetchNostrProfile(pubkeyHex, relays = PROFILE_RELAYS) { if (!best) return null; try { const m = JSON.parse(best.content); - return { name: m.display_name || m.name || null, picture: m.picture || null, nip05: m.nip05 || null }; + return { + name: m.display_name || m.name || null, + picture: m.picture || null, + nip05: m.nip05 || null, + // NIP-57/LUD-16/06: where to send a Lightning zap. lud16 is a + // lightning address (name@domain); lud06 is a bech32 lnurl. + lud16: typeof m.lud16 === 'string' ? m.lud16.trim() : null, + lud06: typeof m.lud06 === 'string' ? m.lud06.trim() : null, + }; } catch { return null; } } @@ -188,14 +196,22 @@ export class NostrSync { // Used by features that speak their own event kinds (e.g. ark zaps). async publishEvent(partial) { if (!this.sk) return null; - let evt; - try { - evt = finalizeEvent({ created_at: Math.floor(Date.now() / 1000), tags: [], content: '', ...partial }, this.sk); - } catch { return null; } + const evt = this.signEvent(partial); + if (!evt) return null; await Promise.allSettled(pool.publish(this.relays, evt)); return evt; } + // Sign an event with our key WITHOUT publishing it. A NIP-57 zap request + // (kind 9734) is signed by the sender but handed to the recipient's LNURL + // server — never broadcast to relays by us — so it needs signing alone. + signEvent(partial) { + if (!this.sk) return null; + try { + return finalizeEvent({ created_at: Math.floor(Date.now() / 1000), tags: [], content: '', ...partial }, this.sk); + } catch { return null; } + } + // Fetch events matching a filter from the relays (best-effort). async fetchEvents(filter, maxWait = 5000) { try { return await pool.querySync(this.relays, filter, { maxWait }); } catch { return []; } -- cgit v1.2.3