1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
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;
}
|