aboutsummaryrefslogtreecommitdiffstats
path: root/src/lnurl.js
blob: bfebf0dac8b400393e6b16f995d3408ec43845a3 (plain) (blame)
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
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
// 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'/<n1>/<n2>/<n3>/<n4>. 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;
}