aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-07-30 08:43:47 -0700
committerAdam Soltys <[email protected]>2026-07-30 08:43:47 -0700
commit6713f8d4e55db8d5344684b08b062cc9129ef6b0 (patch)
tree2e547aedcef6b4c7f8de8e82a561a62c6781ce54
parent962223b0628133ebcd27ff6c89d6aa616a936142 (diff)
downloadcoinosv3-6713f8d4e55db8d5344684b08b062cc9129ef6b0.tar.gz
coinosv3-6713f8d4e55db8d5344684b08b062cc9129ef6b0.zip
Add NIP-57 Lightning zaps (npub / lightning address / lnurl)
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 <[email protected]>
-rw-r--r--build.js4
-rw-r--r--src/features/ark.js5
-rw-r--r--src/features/index.js7
-rw-r--r--src/features/swaps.js27
-rw-r--r--src/features/sync.js20
-rw-r--r--src/features/zaps.js163
-rw-r--r--src/i18n.js11
-rw-r--r--src/lnurl.js123
-rw-r--r--src/nostr.js26
9 files changed, 369 insertions, 17 deletions
diff --git a/build.js b/build.js
index 58ca0ba..438f8bc 100644
--- a/build.js
+++ b/build.js
@@ -144,11 +144,11 @@ export async function buildSpWorker({ minify = true } = {}) {
}
// Optional-feature selection: HAL_FEATURES is a comma list of enabled
-// features (gifts,swaps,ark,sp). Unset means all; "none"/"" means a minimal
+// features (gifts,swaps,ark,zaps,sp). Unset means all; "none"/"" means a minimal
// on-chain-only wallet. A build plugin swaps src/features/index.js for a
// generated module that only imports the enabled features, so a disabled
// feature's code (and its network endpoints) never enters the bundle.
-const ALL_FEATURES = { gifts: 'giftsFeature', swaps: 'swapsFeature', ark: 'arkFeature', sp: 'spFeature', sync: 'syncFeature' };
+const ALL_FEATURES = { gifts: 'giftsFeature', swaps: 'swapsFeature', ark: 'arkFeature', zaps: 'zapsFeature', sp: 'spFeature', sync: 'syncFeature' };
export function enabledFeatures(spec = process.env.HAL_FEATURES) {
if (spec == null) return Object.keys(ALL_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
+ // `[email protected]` 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(); },
+ };
+}
diff --git a/src/i18n.js b/src/i18n.js
index e439936..04e7e06 100644
--- a/src/i18n.js
+++ b/src/i18n.js
@@ -457,6 +457,17 @@ const STR = {
lnPayConfirm: 'Pay',
lnPaySentTitle: 'Lightning payment sent',
lnPaySentBody: 'The swap provider pays the invoice once the on-chain lockup confirms.',
+ lnZapTitle: 'Zap over Lightning',
+ lnZapReviewTitle: 'Zap over Lightning',
+ lnZapFallback: 'Zap over Lightning instead',
+ lnZapResolving: 'Finding their Lightning address…',
+ lnZapRequesting: 'Requesting a Lightning invoice…',
+ lnZapNoAddress: 'This nostr user hasn’t published a Lightning address.',
+ lnZapRange: 'Enter {min}–{max} sats',
+ lnZapHintNostr: 'Sent over Lightning, with a public zap receipt on nostr.',
+ lnZapHintPlain: 'Sent over Lightning (this address doesn’t support zap receipts).',
+ lnZapSentBody: 'Your zap is on its way — the swap provider pays it once the on-chain lockup confirms.',
+ lnZapFailed: 'Couldn’t complete the zap.',
spScan: 'Scan for silent payments',
spScanning: 'Scanning…',
spWatching: 'Watching for payments',
diff --git a/src/lnurl.js b/src/lnurl.js
new file mode 100644
index 0000000..f641707
--- /dev/null
+++ b/src/lnurl.js
@@ -0,0 +1,123 @@
+// LNURL-pay + NIP-57 ("lightning zap") logic — no DOM, so it stays testable.
+//
+// The wallet already pays bolt11 invoices over a Boltz submarine swap; this
+// module is the missing front half of a zap: turn an npub / lightning address
+// / lnurl into a bolt11 to pay, attaching a signed kind-9734 zap request so the
+// recipient's LNURL server publishes a public zap receipt (kind 9735).
+//
+// Flow (NIP-57 appendix): resolve target → GET the LNURL-pay params → build &
+// sign a zap request → GET the callback with amount(+nostr) → receive `pr`
+// (bolt11) → hand that to the swap engine. The recipient's server is the one
+// that broadcasts the receipt; we only sign the request.
+
+import { bech32 } from '@scure/base';
+import { parseNostrPubkey } from './nostr.js';
+
+const enc = new TextEncoder();
+const dec = new TextDecoder();
+
+// bech32 with a generous limit — LNURLs are long.
+export function decodeLnurl(s) {
+ const { prefix, words } = bech32.decode(String(s || '').trim().toLowerCase(), 2000);
+ if (prefix !== 'lnurl') throw new Error('not an lnurl');
+ return dec.decode(new Uint8Array(bech32.fromWords(words)));
+}
+export function encodeLnurl(url) {
+ return bech32.encode('lnurl', bech32.toWords(enc.encode(url)), 2000);
+}
+
+const LN_ADDRESS_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
+export function isLightningAddress(s) { return LN_ADDRESS_RE.test((s || '').trim()); }
+
+// A lightning address (name@domain) → its LNURL-pay .well-known URL. Onion
+// hosts speak http; everything else https.
+export function lnAddressToUrl(addr) {
+ const [name, domain] = (addr || '').trim().split('@');
+ const scheme = /\.onion$/i.test(domain) ? 'http' : 'https';
+ return `${scheme}://${domain}/.well-known/lnurlp/${encodeURIComponent(name)}`;
+}
+
+// Resolve any zap-able input into { url, lnurlBech32, pk } — pk is the
+// recipient's nostr pubkey when we know it (needed to attribute a real zap).
+// Returns null if the input isn't a zap target at all.
+export function parseZapTarget(input, { pk = null } = {}) {
+ const s = (input || '').trim().replace(/^lightning:/i, '');
+ if (!s) return null;
+ // An npub/hex pubkey — the caller resolves lud16/lud06 from the profile and
+ // calls fromProfile() below; here we just report the shape.
+ const npk = parseNostrPubkey(s);
+ if (npk) return { kind: 'npub', pk: npk };
+ if (isLightningAddress(s)) return { kind: 'lnaddr', url: lnAddressToUrl(s), lnurlBech32: safeEncode(lnAddressToUrl(s)), pk };
+ if (/^lnurl1[ac-hj-np-z02-9]+$/i.test(s)) {
+ try { const url = decodeLnurl(s); return { kind: 'lnurl', url, lnurlBech32: s.toLowerCase(), pk }; } catch { return null; }
+ }
+ return null;
+}
+
+// Build a zap target from a profile's lud16/lud06 (for an npub send).
+export function zapTargetFromProfile(profile, pk) {
+ if (profile && profile.lud16 && isLightningAddress(profile.lud16)) {
+ const url = lnAddressToUrl(profile.lud16);
+ return { kind: 'lnaddr', url, lnurlBech32: safeEncode(url), pk, address: profile.lud16 };
+ }
+ if (profile && profile.lud06) {
+ try { const url = decodeLnurl(profile.lud06); return { kind: 'lnurl', url, lnurlBech32: profile.lud06.toLowerCase(), pk }; } catch {}
+ }
+ return null;
+}
+
+function safeEncode(url) { try { return encodeLnurl(url); } catch { return null; } }
+
+async function fetchJson(url, ms = 8000) {
+ const ctrl = new AbortController();
+ const timer = setTimeout(() => ctrl.abort(), ms);
+ try {
+ const r = await fetch(url, { signal: ctrl.signal, headers: { Accept: 'application/json' } });
+ if (!r.ok) throw new Error(`HTTP ${r.status}`);
+ return await r.json();
+ } finally { clearTimeout(timer); }
+}
+
+// GET the LNURL-pay params. Normalizes the bits we use; keeps `callback`.
+export async function fetchPayParams(url) {
+ const d = await fetchJson(url);
+ if (!d || d.tag !== 'payRequest' || !d.callback) {
+ if (d && d.status === 'ERROR') throw new Error(d.reason || 'LNURL error');
+ throw new Error('not a Lightning pay endpoint');
+ }
+ return {
+ callback: d.callback,
+ minSendable: Number(d.minSendable) || 0, // millisats
+ maxSendable: Number(d.maxSendable) || 0, // millisats
+ commentAllowed: Number(d.commentAllowed) || 0,
+ // NIP-57: server publishes a zap receipt when it accepts a zap request.
+ allowsNostr: !!d.allowsNostr,
+ nostrPubkey: typeof d.nostrPubkey === 'string' ? d.nostrPubkey : null,
+ metadata: d.metadata || '',
+ };
+}
+
+// A signed kind-9734 zap request (NIP-57). signFn(partial) → finalized event.
+export function buildZapRequest({ amountMsat, relays, lnurlBech32, recipientPk, eventId, comment, signFn }) {
+ const tags = [['relays', ...relays], ['amount', String(amountMsat)], ['p', recipientPk]];
+ if (lnurlBech32) tags.push(['lnurl', lnurlBech32]);
+ if (eventId) tags.push(['e', eventId]);
+ return signFn({ kind: 9734, content: comment || '', tags });
+}
+
+// GET the callback for a bolt11. When zapRequest is present this is a real zap;
+// otherwise a plain LNURL-pay (comment attached if the server allows one).
+export async function requestInvoice(params, { amountMsat, zapRequest = null, lnurlBech32 = null, comment = '' }) {
+ const url = new URL(params.callback);
+ url.searchParams.set('amount', String(amountMsat));
+ if (zapRequest) {
+ url.searchParams.set('nostr', JSON.stringify(zapRequest));
+ if (lnurlBech32) url.searchParams.set('lnurl', lnurlBech32);
+ } else if (comment && params.commentAllowed > 0) {
+ url.searchParams.set('comment', comment.slice(0, params.commentAllowed));
+ }
+ const d = await fetchJson(url.toString());
+ if (!d || d.status === 'ERROR') throw new Error((d && d.reason) || 'invoice request failed');
+ if (!d.pr || typeof d.pr !== 'string') throw new Error('no invoice returned');
+ return d.pr;
+}
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 []; }