// 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 { secp256k1 } from '@noble/curves/secp256k1'; import { hmac } from '@noble/hashes/hmac'; import { sha256 } from '@noble/hashes/sha256'; import { bytesToHex, hexToBytes } from '@noble/hashes/utils'; 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; // LUD-17: lnurlp:// (pay) and lnurlw:// (withdraw) are the URL itself over https (http on an onion) const scheme = /^(lnurlp|lnurlw):\/\/(.+)$/i.exec(s); if (scheme) { const url = (/^[^/]+\.onion(\/|$)/i.test(scheme[2]) ? 'http://' : 'https://') + scheme[2]; return { kind: 'lnurl', url, lnurlBech32: safeEncode(url), pk }; } // 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; } } // Errors name the server: the recipient's Lightning address lives on THEIR // host, and a bare "signal is aborted without reason" (the browser's words // for our timeout) read as though the wallet had broken. async function fetchJson(url, ms = 8000) { const ctrl = new AbortController(); const timer = setTimeout(() => ctrl.abort(), ms); let host = 'their Lightning server'; try { host = new URL(url).host; } catch {} try { const r = await fetch(url, { signal: ctrl.signal, headers: { Accept: 'application/json' } }); if (!r.ok) { // LNURL servers say why in a JSON { status: 'ERROR', reason } body const reason = await r.json().then((d) => d && d.reason).catch(() => null); throw new Error(reason ? `${host}: ${reason}` : `${host} answered with an error (HTTP ${r.status})`); } return await r.json(); } catch (e) { if (ctrl.signal.aborted || e?.name === 'AbortError') throw new Error(`${host} didn’t answer in ${Math.round(ms / 1000)}s — their Lightning address may be down`); if (e instanceof TypeError) throw new Error(`couldn’t reach ${host}`); throw e; } finally { clearTimeout(timer); } } // GET the LNURL-pay params. Normalizes the bits we use; keeps `callback`. // An lnurl that turns out to be a withdraw (LUD-03) is not an error here: // the thrown error carries its terms (`withdraw`) for the caller to take up. 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'); const w = withdrawParamsOf(d); if (w) { const e = new Error('a Lightning withdraw, not a payment'); e.withdraw = w; throw e; } 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; } // ---- LNURL-auth (LUD-04): a login challenge, not a payment ---- // An lnurl whose URL carries tag=login and a 32-byte k1. Pasted into Send // and taken for a payment, their server answered "no sig query variable // provided" (Stacker News, 2026-10-16). action: login | register | link | auth. export function parseLnurlAuth(input) { const s = (input || '').trim().replace(/^lightning:/i, ''); if (!/^lnurl1[ac-hj-np-z02-9]+$/i.test(s)) return null; let url; try { url = new URL(decodeLnurl(s)); } catch { return null; } if (url.searchParams.get('tag') !== 'login') return null; const k1 = (url.searchParams.get('k1') || '').toLowerCase(); if (!/^[0-9a-f]{64}$/.test(k1)) return null; const action = /^(register|login|link|auth)$/.test(url.searchParams.get('action') || '') ? url.searchParams.get('action') : 'login'; return { url: url.href, domain: url.hostname, k1, action }; } // The linking key for a domain (LUD-05): from the wallet's master key, // m/138'/0 is the hashing key; its HMAC-SHA256 of the domain, read as four // 32-bit numbers, names the path m/138'////. The same // wallet is the same user on a site, every time, and a different one on // every other site. export function lnurlAuthKey(master, domain) { const hashing = master.derive("m/138'/0").privateKey; const mac = hmac(sha256, hashing, new TextEncoder().encode(domain)); const dv = new DataView(mac.buffer, mac.byteOffset, 16); let key = master.derive("m/138'"); for (const o of [0, 4, 8, 12]) key = key.deriveChild(dv.getUint32(o)); return key; } // Sign the challenge (ECDSA over k1, DER-encoded) and call the URL back with // sig and key. Resolves on the server's OK; throws its reason otherwise. export async function lnurlAuthLogin(auth, key) { const sig = secp256k1.sign(hexToBytes(auth.k1), key.privateKey); const url = new URL(auth.url); url.searchParams.set('sig', sig.toDERHex()); url.searchParams.set('key', bytesToHex(key.publicKey)); const d = await fetchJson(url.href); if (!d || String(d.status || '').toUpperCase() !== 'OK') throw new Error((d && d.reason) || 'login refused'); return { key: bytesToHex(key.publicKey) }; } // ---- LNURL-withdraw (LUD-03): the other side asks to PAY us ---- // Their terms: an amount range (msat), a callback and k1. We make an invoice // for the amount chosen and hand it to the callback; they pay it. export function withdrawParamsOf(d) { if (!d || d.tag !== 'withdrawRequest' || !d.callback || !d.k1) return null; const min = Math.max(0, Number(d.minWithdrawable) || 0), max = Number(d.maxWithdrawable) || 0; if (!(max > 0)) return null; let domain = ''; try { domain = new URL(d.callback).hostname; } catch {} return { callback: d.callback, k1: String(d.k1), minMsat: min, maxMsat: Math.max(min, max), description: String(d.defaultDescription || '').slice(0, 200), domain }; } export async function fetchWithdrawParams(url) { const d = await fetchJson(url); const w = withdrawParamsOf(d); if (!w) { if (d && d.status === 'ERROR') throw new Error(d.reason || 'LNURL error'); throw new Error('not a Lightning withdraw'); } return w; } // Hand them the invoice to pay. Their OK means they have it, not that it is paid. export async function submitWithdraw(params, invoice) { const url = new URL(params.callback); url.searchParams.set('k1', params.k1); url.searchParams.set('pr', invoice); const d = await fetchJson(url.href); if (!d || String(d.status || '').toUpperCase() !== 'OK') throw new Error((d && d.reason) || 'withdraw refused'); return true; }