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/features/ark.js | 5 ++ src/features/index.js | 7 ++- src/features/swaps.js | 27 ++++++--- src/features/sync.js | 20 ++++++- src/features/zaps.js | 163 ++++++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 212 insertions(+), 10 deletions(-) create mode 100644 src/features/zaps.js (limited to 'src/features') diff --git a/src/features/ark.js b/src/features/ark.js index f22d7be..08e44de 100644 --- a/src/features/ark.js +++ b/src/features/ark.js @@ -547,6 +547,11 @@ export function arkFeature(ctx) { h('div', { class: 'small muted', style: 'word-break:break-all' }, z.npub), z.status === 'lookup' ? h('div', { class: 'row gap6', style: 'align-items:center' }, h('span', { class: 'spinner sm' }), h('span', { class: 'small muted' }, t('arkZapLookup'))) : null, z.status === 'noark' ? h('div', { class: 'notice err' }, t('arkZapNoArk')) : null, + // No Ark address, but they may still take a Lightning zap — hand off to + // the zaps feature (present alongside swaps). + (z.status === 'noark' || z.status === 'wrongnet') && ctx.hook('canLnZap') + ? h('button', { class: 'btn-primary btn-block', onClick: () => { ctx.hook('lnZapNpub', z.pk, z.npub); } }, '⚡ ' + t('lnZapFallback')) + : null, z.status === 'wrongnet' ? h('div', { class: 'notice err' }, t('arkGiftWrongNet', { net: z.net })) : null, z.status === 'ready' ? h('div', { class: 'col gap6' }, diff --git a/src/features/index.js b/src/features/index.js index e63e816..e52b172 100644 --- a/src/features/index.js +++ b/src/features/index.js @@ -7,11 +7,14 @@ import { giftsFeature } from './gifts.js'; import { swapsFeature } from './swaps.js'; import { arkFeature } from './ark.js'; +import { zapsFeature } from './zaps.js'; import { spFeature } from './sp.js'; import { syncFeature } from './sync.js'; // NB order is meaningful where hooks stack: gifts' receive takeover and -// balance line come before ark's, matching the pre-plugin layout. +// balance line come before ark's, matching the pre-plugin layout. zaps sits +// after ark so an npub prefers an instant Ark zap and only falls through to a +// Lightning zap when Ark can't serve it. export function buildFeatures(ctx) { - return [giftsFeature(ctx), swapsFeature(ctx), arkFeature(ctx), spFeature(ctx), syncFeature(ctx)]; + return [giftsFeature(ctx), swapsFeature(ctx), arkFeature(ctx), zapsFeature(ctx), spFeature(ctx), syncFeature(ctx)]; } diff --git a/src/features/swaps.js b/src/features/swaps.js index 3e97412..091ca32 100644 --- a/src/features/swaps.js +++ b/src/features/swaps.js @@ -132,8 +132,8 @@ export function swapsFeature(ctx) { line(t('status'), swap.status === 'claimed' ? 'Settled ✓' : swap.status)); } - async function startLnSend(invoice) { - ui.sendError = ''; ui.lnSend = null; ui.lnSent = null; ui.lnSendBusy = true; render(); + async function startLnSend(invoice, meta = null) { + ui.sendError = ''; ui.lnSend = null; ui.lnSent = null; ui.lnPayMeta = meta; ui.lnSendBusy = true; render(); try { ui.lnSend = await swaps.quoteSubmarine(invoice); } catch (e) { ui.sendError = e.message; } ui.lnSendBusy = false; render(); @@ -149,21 +149,25 @@ export function swapsFeature(ctx) { function lnSendReview() { const u = ' ' + unitLabel(); + const zap = ui.lnPayMeta; // set when a NIP-57 zap routed through here if (ui.lnSent) { return h('div', { class: 'col', style: 'gap:16px' }, h('div', { class: 'card col', style: 'align-items:center;text-align:center;gap:8px' }, h('div', { class: 'check-badge' }, '⚡'), - h('h2', { style: 'margin:0' }, t('lnPaySentTitle')), + h('h2', { style: 'margin:0' }, zap ? t('arkZapSentTitle') : t('lnPaySentTitle')), + zap && zap.name ? h('div', { class: 'small muted' }, zap.name) : null, ui.lnSent.amount ? h('div', { class: 'amount-pos', style: 'font-size:18px' }, fmtAmount(ui.lnSent.amount) + u) : null, - h('p', { class: 'muted', style: 'margin:0' }, t('lnPaySentBody'))), - h('button', { class: 'btn-primary btn-block', onClick: () => { ui.lnSent = null; ui.lnSend = null; ui.send = blankSend(); ui.tab = 'history'; render(); } }, t('done'))); + h('p', { class: 'muted', style: 'margin:0' }, zap ? t('lnZapSentBody') : t('lnPaySentBody'))), + h('button', { class: 'btn-primary btn-block', onClick: () => { ui.lnSent = null; ui.lnSend = null; ui.lnPayMeta = null; ui.send = blankSend(); ui.tab = 'history'; render(); } }, t('done'))); } const q = ui.lnSend; const row = (label, sats, bold) => h('div', { class: 'row', style: 'justify-content:space-between' + (bold ? ';font-weight:600' : '') }, h('span', { class: bold ? '' : 'small muted' }, label), h('span', {}, fmtAmount(sats) + u)); return h('div', { class: 'col', style: 'gap:12px' }, h('div', { class: 'card col', style: 'gap:10px' }, - h('h3', { style: 'margin:0' }, t('lnPayTitle')), + h('h3', { style: 'margin:0' }, zap ? '⚡ ' + t('lnZapReviewTitle') : t('lnPayTitle')), + zap && (zap.name || zap.address) ? h('div', { class: 'small muted', style: 'word-break:break-all' }, zap.name || zap.address) : null, + zap && zap.comment ? h('div', { class: 'small faint', style: 'font-style:italic;word-break:break-word' }, '“' + zap.comment + '”') : null, q.invoiceAmount != null ? row(t('lnPayAmount'), q.invoiceAmount) : null, q.boltzFee != null ? row(t('lnPayBoltzFee'), q.boltzFee) : null, row(t('lnPayNetworkFee'), q.networkFee), @@ -171,7 +175,7 @@ export function swapsFeature(ctx) { row(t('lnPayTotal'), q.total, true)), ui.sendError ? h('div', { class: 'notice err' }, ui.sendError) : null, h('button', { class: 'btn-primary btn-block', disabled: !!ui.lnSendBusy, onClick: doLnPay }, ui.lnSendBusy ? h('span', { class: 'spinner' }) : t('lnPayConfirm')), - h('button', { class: 'btn-ghost btn-block', onClick: () => { ui.lnSend = null; ui.sendError = ''; render(); } }, t('back'))); + h('button', { class: 'btn-ghost btn-block', onClick: () => { ui.lnSend = null; ui.lnPayMeta = null; ui.sendError = ''; render(); } }, t('back'))); } return { @@ -215,5 +219,14 @@ export function swapsFeature(ctx) { }, findByTxid(txid) { return swaps.findByTxid(txid); }, settingsCards() { return [boltzProviderCard()]; }, + // ---- cross-feature seam (the zaps feature pays a resolved bolt11) ---- + // True when Lightning payment is possible from this wallet/build — the + // zaps feature checks this before offering a Lightning zap. + canLnPay() { return !wallet.watchOnly; }, + // Pay a bolt11 that another feature resolved (e.g. a NIP-57 zap), reusing + // this feature's quote → review → confirm → success flow. `meta` decorates + // the review as a zap (recipient name, comment). Returns true so the hook + // dispatcher stops here. + startLnPay(invoice, meta) { startLnSend(invoice, meta || null); return true; }, }; } diff --git a/src/features/sync.js b/src/features/sync.js index 715c789..1e2db41 100644 --- a/src/features/sync.js +++ b/src/features/sync.js @@ -3,7 +3,7 @@ // (used e.g. to DM locked-gift claim codes). Installed onto the core wallet; // a build without sync ships none of it and never talks to a relay. -import { NostrSync, getSyncConfig, setSyncConfig, npubOf, syncDtag, deviceDtag, isOurDtag } from '../nostr.js'; +import { NostrSync, getSyncConfig, setSyncConfig, npubOf, syncDtag, deviceDtag, isOurDtag, fetchNostrProfile, PROFILE_RELAYS } from '../nostr.js'; import { t } from '../i18n.js'; // Keep a published snapshot under the relay's event-size cap. relay.coinos.io @@ -62,6 +62,24 @@ export function installSyncWallet(wallet) { return this.nostr.subscribeEvents(filter, onEvent); }, + // Sign an event with our nostr key without publishing (NIP-57 zap request). + nostrSign(partial) { return this.nostr ? this.nostr.signEvent(partial) : null; }, + + // Fetch a pubkey's profile (name/picture + lud16/lud06 for zaps) from the + // broad profile relays — independent of whether cross-device sync is on. + async nostrProfile(pkHex) { + if (this.offline) return null; + return fetchNostrProfile(pkHex); + }, + + // Relays to advertise in a zap request so we can later find the receipt: + // our sync relays plus the broad profile relays. + nostrRelays() { + const sync = getSyncConfig(); + const base = sync.enabled ? sync.relays : []; + return [...new Set([...base, ...PROFILE_RELAYS])].slice(0, 8); + }, + // Pull the latest state from Nostr; apply it if it's newer than what we have. // Returns true if state was applied (so the caller can skip a full scan). async syncFromNostr() { diff --git a/src/features/zaps.js b/src/features/zaps.js new file mode 100644 index 0000000..36afb12 --- /dev/null +++ b/src/features/zaps.js @@ -0,0 +1,163 @@ +// NIP-57 Lightning zaps — pay an npub / lightning address / lnurl over +// Lightning, attaching a signed zap request so the recipient's server posts a +// public zap receipt. This feature only does the *resolution* half (target → +// LNURL params → signed request → bolt11); the actual payment reuses the swaps +// feature's Boltz submarine flow via the `startLnPay` hook, so there's one +// tested LN pay/review/confirm/success path. +// +// Routing (see features/index.js order): a bolt11 is caught by swaps first; an +// npub is caught by ark first (an instant free Ark zap) when Ark is available, +// and only reaches here otherwise — plus ark's "no Ark address" screen hands +// off to us explicitly via the lnZapNpub hook. Lightning addresses and lnurls +// land here directly. + +import { t } from '../i18n.js'; +import { npubOf } from '../nostr.js'; +import { parseZapTarget, zapTargetFromProfile, fetchPayParams, buildZapRequest, requestInvoice } from '../lnurl.js'; + +export function zapsFeature(ctx) { + const { h, ui, render, wallet, blankSend, parseAmount, getUnit, unitTag, hook } = ctx; + + // Lightning payment has to be possible for a zap to make sense. + const canPay = () => !wallet.watchOnly && !!hook('canLnPay'); + + // A lightning address (name@domain) matches its regex mid-typing — at + // `a@b.co` before the user finishes `.com` — so we debounce those; npub and + // lnurl carry a bech32 checksum and only match when complete, so they fire + // immediately. + let resolveTimer = null; + const clearResolve = () => { if (resolveTimer) { clearTimeout(resolveTimer); resolveTimer = null; } }; + + const shortNpub = (npub) => (npub && npub.length > 16 ? npub.slice(0, 12) + '…' + npub.slice(-6) : npub); + + // Kick off resolution of a parsed target. `display` is what we show as the + // recipient until a profile name arrives. + function begin(target, display) { + ui.arkZap = null; ui.arkZapped = null; // in case we were handed off from ark + ui.sendError = ''; + const z = (ui.zap = { status: 'resolving', target, name: display, address: target.address || null, amount: '', comment: '' }); + render(); + resolve(z).catch((e) => { if (ui.zap === z) { z.status = 'error'; z.error = e.message; render(); } }); + } + + async function resolve(z) { + let target = z.target; + // An npub needs a profile lookup to find its lud16/lud06. + if (target.kind === 'npub') { + const profile = await wallet.nostrProfile(target.pk); + if (profile && profile.name) z.name = profile.name; + const lnTarget = zapTargetFromProfile(profile, target.pk); + if (!lnTarget) { z.status = 'error'; z.error = t('lnZapNoAddress'); render(); return; } + z.target = target = lnTarget; + z.address = lnTarget.address || z.address; + } + const params = await fetchPayParams(target.url); + if (ui.zap !== z) return; // user navigated away + z.params = params; + z.status = 'ready'; + render(); + } + + async function confirm() { + const z = ui.zap; + if (!z || z.status !== 'ready') return; + const sats = parseAmount(z.amount, getUnit()); + const p = z.params; + if (!sats || sats <= 0) { z.error = t('enterValidAmtForN', { n: 1 }); return render(); } + const msat = sats * 1000; + if (msat < p.minSendable || msat > p.maxSendable) { + z.error = t('lnZapRange', { min: Math.ceil(p.minSendable / 1000).toLocaleString(), max: Math.floor(p.maxSendable / 1000).toLocaleString() }); + return render(); + } + z.error = ''; z.status = 'invoicing'; render(); + try { + // Attribute a real zap only when the server supports it and we know the + // recipient's nostr key; otherwise fall back to a plain LNURL-pay. + let zapRequest = null; + const canZap = p.allowsNostr && z.target.pk && wallet.nostrSign; + if (canZap) { + zapRequest = buildZapRequest({ + amountMsat: msat, relays: wallet.nostrRelays(), lnurlBech32: z.target.lnurlBech32, + recipientPk: z.target.pk, comment: z.comment, signFn: (partial) => wallet.nostrSign(partial), + }); + } + const invoice = await requestInvoice(p, { amountMsat: msat, zapRequest, lnurlBech32: z.target.lnurlBech32, comment: z.comment }); + const meta = { name: z.name, address: z.address, comment: canZap ? z.comment : '', isZap: !!zapRequest }; + ui.zap = null; + // Hand the bolt11 to the swaps feature's pay flow (review → confirm). + hook('startLnPay', invoice, meta); + render(); + } catch (e) { + z.status = 'ready'; z.error = e.message; render(); + } + } + + function zapView() { + const z = ui.zap; + if (!z) return null; + const back = () => { ui.zap = null; ui.sendError = ''; ui.send = blankSend(); render(); }; + if (z.status === 'resolving' || z.status === 'invoicing') { + return h('div', { class: 'card col', style: 'align-items:center;gap:14px;padding:32px 14px' }, + h('span', { class: 'spinner' }), + h('p', { class: 'muted', style: 'margin:0' }, z.status === 'invoicing' ? t('lnZapRequesting') : t('lnZapResolving'))); + } + if (z.status === 'error') { + return h('div', { class: 'card col', style: 'gap:12px' }, + h('h3', {}, '⚡ ' + t('lnZapTitle')), + h('div', { class: 'small muted', style: 'word-break:break-all' }, z.name || ''), + h('div', { class: 'notice err' }, z.error || t('lnZapFailed')), + h('button', { class: 'btn-ghost btn-block', onClick: back }, t('back'))); + } + // ready: amount + optional comment + const p = z.params; + const min = Math.ceil(p.minSendable / 1000), max = Math.floor(p.maxSendable / 1000); + const commentOk = p.allowsNostr || p.commentAllowed > 0; + return h('div', { class: 'card col', style: 'gap:12px' }, + h('h3', {}, '⚡ ' + t('lnZapTitle')), + h('div', { class: 'small muted', style: 'word-break:break-all' }, z.name || z.address || ''), + h('div', { class: 'col gap6' }, + h('div', { class: 'input-group' }, + h('input', { type: 'number', min: '0', inputmode: 'decimal', placeholder: t('lnPayAmount'), value: z.amount, + onInput: (e) => { z.amount = e.target.value; } }), + h('div', { style: 'display:flex;align-items:center' }, unitTag())), + h('div', { class: 'small faint' }, `Min ${min.toLocaleString()} · max ${max.toLocaleString()} sats`), + commentOk + ? h('input', { type: 'text', class: 'mono-input', placeholder: t('arkZapCommentPh'), value: z.comment, + maxlength: p.allowsNostr ? '280' : String(p.commentAllowed || 280), + onInput: (e) => { z.comment = e.target.value; } }) + : null, + h('div', { class: 'small faint' }, p.allowsNostr ? t('lnZapHintNostr') : t('lnZapHintPlain'))), + z.error ? h('div', { class: 'notice err' }, z.error) : null, + h('div', { class: 'row gap6' }, + h('button', { class: 'btn-ghost', onClick: back }, t('back')), + h('button', { class: 'btn-primary grow', onClick: confirm }, t('arkZapBtn')))); + } + + return { + id: 'zaps', + // A lightning address / lnurl (always), or an npub that Ark didn't claim. + matchSendText(text) { + if (!canPay() || !ui.send || ui.send.recipients.length !== 1) { clearResolve(); return false; } + const target = parseZapTarget(text); + if (!target) { clearResolve(); return false; } + // npub with no nostr seam to look up a profile → can't resolve here. + if (target.kind === 'npub' && !wallet.nostrProfile) return false; + const display = target.kind === 'npub' ? shortNpub(npubOf(target.pk) || text.trim()) : (target.address || text.trim()); + clearResolve(); + if (target.kind === 'lnaddr') resolveTimer = setTimeout(() => { resolveTimer = null; begin(target, display); }, 600); + else begin(target, display); + return true; + }, + // True when an npub can be zapped over Lightning from this build/wallet — + // ark's "no Ark address" screen checks this before offering the fallback. + canLnZap() { return canPay() && !!wallet.nostrProfile; }, + // Ark's "no Ark address published" screen offers a Lightning fallback, + // which lands here with the recipient's pubkey. + lnZapNpub(pk, npub) { + if (!canPay() || !wallet.nostrProfile) return false; + begin({ kind: 'npub', pk }, shortNpub(npub || npubOf(pk))); + return true; + }, + sendView() { return zapView(); }, + }; +} -- cgit v1.2.3