@qntx/spark 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,729 @@
1
+ import { i as SparkRequestError, r as SparkInputError } from "./errors-5xLkExNL.mjs";
2
+ import { n as decodeBolt11, t as serverErrorText } from "./text-DC_Vptn6.mjs";
3
+ import { bytesToHex, hexToBytes, utf8ToBytes } from "@noble/hashes/utils.js";
4
+ import { base64, bech32 } from "@scure/base";
5
+ import { sha256 } from "@noble/hashes/sha2.js";
6
+ import { equalBytes } from "@noble/curves/utils.js";
7
+ import { cbc } from "@noble/ciphers/aes.js";
8
+ //#region src/lnurl/http.ts
9
+ function isJsonObject(value) {
10
+ return typeof value === "object" && value !== null && !Array.isArray(value);
11
+ }
12
+ /** Whole-round-trip bound for every LNURL request — request and body read. */
13
+ const LNURL_REQUEST_TIMEOUT_MS = 3e4;
14
+ function timedOut() {
15
+ return new SparkRequestError("lnurl request timed out", { code: "deadline_exceeded" });
16
+ }
17
+ function tooLarge() {
18
+ return new SparkRequestError("lnurl response: body exceeds 256 KiB", { code: "unexpected_response" });
19
+ }
20
+ /**
21
+ * Rejects before reading when a truthful `Content-Length` already exceeds the bound. A missing (or
22
+ * lying) header cannot be trusted, so the caller still reads the body once and re-checks it against
23
+ * the same bound afterwards.
24
+ */
25
+ function checkContentLength(response) {
26
+ const declared = Number(response.headers.get("content-length") ?? "");
27
+ if (Number.isSafeInteger(declared) && declared > 262144) throw tooLarge();
28
+ }
29
+ function checkBodySize(text) {
30
+ if (utf8ToBytes(text).length > 262144) throw tooLarge();
31
+ }
32
+ /**
33
+ * One GET against an LNURL endpoint with the injected fetch. HTTP failures and the protocol-level
34
+ * `{"status": "ERROR", "reason"}` envelope map onto the SSP client's error vocabulary; error
35
+ * messages never echo request URLs — withdraw callbacks carry the ephemeral `k1`, which authorizes
36
+ * a payout.
37
+ */
38
+ async function lnurlGetJson(url, fetch) {
39
+ const timeout = new AbortController();
40
+ const timer = setTimeout(() => timeout.abort(), LNURL_REQUEST_TIMEOUT_MS);
41
+ try {
42
+ let response;
43
+ try {
44
+ response = await fetch(url, { signal: timeout.signal });
45
+ } catch (error) {
46
+ if (timeout.signal.aborted) throw timedOut();
47
+ throw new SparkRequestError("lnurl request failed", { cause: error });
48
+ }
49
+ if (!response.ok) throw new SparkRequestError(`lnurl request failed with status ${response.status}`, { code: `http_${response.status}` });
50
+ checkContentLength(response);
51
+ let text;
52
+ try {
53
+ text = await response.text();
54
+ } catch (error) {
55
+ if (timeout.signal.aborted) throw timedOut();
56
+ throw new SparkRequestError("lnurl request failed", { cause: error });
57
+ }
58
+ checkBodySize(text);
59
+ let parsed;
60
+ try {
61
+ parsed = JSON.parse(text);
62
+ } catch (error) {
63
+ throw new SparkRequestError("lnurl response: body is not valid JSON", {
64
+ code: "unexpected_response",
65
+ cause: error
66
+ });
67
+ }
68
+ if (!isJsonObject(parsed)) throw new SparkRequestError("lnurl response: body is not an object", { code: "unexpected_response" });
69
+ if (parsed["status"] === "ERROR") {
70
+ const { reason } = parsed;
71
+ throw new SparkRequestError(`lnurl error: ${typeof reason === "string" ? serverErrorText(reason) : "no reason given"}`, { code: "lnurl_error" });
72
+ }
73
+ return parsed;
74
+ } finally {
75
+ clearTimeout(timer);
76
+ }
77
+ }
78
+ /**
79
+ * One JSON request with a body (POST/DELETE) against an LNURL endpoint. Same error vocabulary as
80
+ * {@link lnurlGetJson}; a 2xx response with an empty body returns `undefined`, and request bodies
81
+ * never echo into error messages — the server's response text is sanitized before it is embedded.
82
+ */
83
+ async function lnurlSendJson(url, fetch, options) {
84
+ const timeout = new AbortController();
85
+ const timer = setTimeout(() => timeout.abort(), options.timeoutMs ?? 3e4);
86
+ try {
87
+ let response;
88
+ try {
89
+ response = await fetch(url, {
90
+ method: options.method,
91
+ headers: { "content-type": "application/json" },
92
+ body: JSON.stringify(options.body),
93
+ signal: timeout.signal
94
+ });
95
+ } catch (error) {
96
+ if (timeout.signal.aborted) throw timedOut();
97
+ throw new SparkRequestError("lnurl request failed", { cause: error });
98
+ }
99
+ checkContentLength(response);
100
+ const text = await response.text().catch(() => "");
101
+ if (timeout.signal.aborted) throw timedOut();
102
+ checkBodySize(text);
103
+ if (!response.ok) {
104
+ const detail = serverErrorText(text);
105
+ throw new SparkRequestError(`lnurl request failed with status ${response.status}${detail === "" ? "" : `: ${detail}`}`, { code: `http_${response.status}` });
106
+ }
107
+ if (text === "") return;
108
+ let parsed;
109
+ try {
110
+ parsed = JSON.parse(text);
111
+ } catch (error) {
112
+ throw new SparkRequestError("lnurl response: body is not valid JSON", {
113
+ code: "unexpected_response",
114
+ cause: error
115
+ });
116
+ }
117
+ if (!isJsonObject(parsed)) throw new SparkRequestError("lnurl response: body is not an object", { code: "unexpected_response" });
118
+ if (parsed["status"] === "ERROR") {
119
+ const { reason } = parsed;
120
+ throw new SparkRequestError(`lnurl error: ${typeof reason === "string" ? serverErrorText(reason) : "no reason given"}`, { code: "lnurl_error" });
121
+ }
122
+ return parsed;
123
+ } finally {
124
+ clearTimeout(timer);
125
+ }
126
+ }
127
+ //#endregion
128
+ //#region src/lnurl/url.ts
129
+ function fail$4(message) {
130
+ throw new SparkInputError(`lnurl: ${message}`);
131
+ }
132
+ const URL_PATTERN = /^([a-zA-Z]+):\/\/([^/?#]*)(\/[^?#]*)?(?:\?([^#]*))?(?:#.*)?$/;
133
+ const HOST_LABEL = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/;
134
+ const STRICT_IPV4_LABEL = /^(0|[1-9][0-9]{0,2})$/;
135
+ const FORBIDDEN = /[\u0000-\u0020\u007F]/;
136
+ function checkHost(authority) {
137
+ if (authority.includes("@")) fail$4("url must not contain userinfo");
138
+ let host = authority;
139
+ let port;
140
+ const colon = authority.lastIndexOf(":");
141
+ if (colon !== -1) {
142
+ host = authority.slice(0, colon);
143
+ const portText = authority.slice(colon + 1);
144
+ if (!/^[0-9]+$/.test(portText)) fail$4("url has an invalid port");
145
+ port = Number(portText);
146
+ if (port < 1 || port > 65535) fail$4("url port is out of range");
147
+ }
148
+ const lower = host.toLowerCase();
149
+ if (lower.length === 0) fail$4("url has no host");
150
+ if (lower.length > 253) fail$4("url host is too long");
151
+ const labels = lower.split(".");
152
+ for (const label of labels) if (!HOST_LABEL.test(label)) fail$4("url host has an invalid label");
153
+ checkIpv4(labels);
154
+ return port === void 0 ? { host: lower } : {
155
+ host: lower,
156
+ port
157
+ };
158
+ }
159
+ function isWhatwgIpv4Number(label) {
160
+ if (/^0[xX]/.test(label)) return /^0[xX][0-9a-f]*$/.test(label);
161
+ if (label.startsWith("0") && label.length > 1) return /^0[0-7]+$/.test(label);
162
+ return /^[0-9]+$/.test(label);
163
+ }
164
+ function checkIpv4(labels) {
165
+ if (!isWhatwgIpv4Number(labels.at(-1) ?? "") || !labels.every(isWhatwgIpv4Number)) return;
166
+ if (labels.length !== 4) fail$4("url host has a non-canonical IPv4 form");
167
+ for (const label of labels) if (!STRICT_IPV4_LABEL.test(label) || Number(label) > 255) fail$4("url host has a non-canonical IPv4 form");
168
+ }
169
+ /**
170
+ * Parses `text` into strict parts. Accepts `https`/`http` schemes only, a DNS hostname or IPv4
171
+ * literal, an optional port, and a path/query; a `#fragment` is accepted and dropped. Everything
172
+ * else throws {@link SparkInputError}.
173
+ */
174
+ function parseLnurlUrl(text) {
175
+ if (FORBIDDEN.test(text)) fail$4("url contains whitespace or control characters");
176
+ const match = URL_PATTERN.exec(text);
177
+ if (match === null) fail$4("not a URL");
178
+ const schemeText = (match[1] ?? "").toLowerCase();
179
+ if (schemeText !== "https" && schemeText !== "http") fail$4("url scheme must be https or http");
180
+ const { host, port } = checkHost(match[2] ?? "");
181
+ const path = match[3] ?? "/";
182
+ const query = match[4] ?? "";
183
+ return port === void 0 ? {
184
+ scheme: schemeText,
185
+ host,
186
+ path,
187
+ query
188
+ } : {
189
+ scheme: schemeText,
190
+ host,
191
+ port,
192
+ path,
193
+ query
194
+ };
195
+ }
196
+ /** `scheme://host[:port]path[?query]` — the canonical string form. */
197
+ function formatLnurlUrl(parts) {
198
+ const port = parts.port === void 0 ? "" : `:${parts.port}`;
199
+ const query = parts.query === "" ? "" : `?${parts.query}`;
200
+ return `${parts.scheme}://${parts.host}${port}${parts.path}${query}`;
201
+ }
202
+ function decodeQueryComponent(text) {
203
+ const spaced = text.replaceAll("+", " ");
204
+ try {
205
+ return decodeURIComponent(spaced);
206
+ } catch (error) {
207
+ throw new SparkInputError("lnurl url: malformed query escape", { cause: error });
208
+ }
209
+ }
210
+ /**
211
+ * Parses a `application/x-www-form-urlencoded` query string (without the leading `?`) into a
212
+ * name→value map. Bare `name` pairs (no `=`) decode to ""; the last duplicate wins.
213
+ */
214
+ function queryParams(query) {
215
+ const params = /* @__PURE__ */ new Map();
216
+ if (query === "") return params;
217
+ for (const pair of query.split("&")) {
218
+ const eq = pair.indexOf("=");
219
+ const name = eq === -1 ? pair : pair.slice(0, eq);
220
+ const value = eq === -1 ? "" : pair.slice(eq + 1);
221
+ params.set(decodeQueryComponent(name), decodeQueryComponent(value));
222
+ }
223
+ return params;
224
+ }
225
+ /**
226
+ * Appends `encodeURIComponent`-encoded `name=value` pairs to a URL's query, preserving existing
227
+ * pairs and dropping any fragment.
228
+ */
229
+ function withQuery(url, params) {
230
+ const parts = parseLnurlUrl(url);
231
+ const encoded = params.map(([name, value]) => `${encodeURIComponent(name)}=${encodeURIComponent(value)}`);
232
+ const query = parts.query === "" ? encoded.join("&") : `${parts.query}&${encoded.join("&")}`;
233
+ return formatLnurlUrl({
234
+ ...parts,
235
+ query
236
+ });
237
+ }
238
+ //#endregion
239
+ //#region src/lnurl/auth.ts
240
+ const K1_PATTERN = /^[0-9a-f]{64}$/;
241
+ function asAction(value) {
242
+ switch (value) {
243
+ case "register":
244
+ case "login":
245
+ case "link":
246
+ case "auth": return value;
247
+ default: return;
248
+ }
249
+ }
250
+ /**
251
+ * Parses an LNURL-auth URL (the decoded form of a `lnurl1…` string or a bare https/onion URL).
252
+ * Requires `tag=login` and a 64-hex `k1` query parameter; `action` must be one of the LUD-04 enum
253
+ * values when present.
254
+ */
255
+ function parseLnurlAuth(url) {
256
+ let parts;
257
+ try {
258
+ parts = parseLnurlUrl(url);
259
+ } catch (error) {
260
+ throw new SparkInputError("lnurl auth: not a URL", { cause: error });
261
+ }
262
+ const params = queryParams(parts.query);
263
+ if (params.get("tag") !== "login") throw new SparkInputError("lnurl auth: expected tag=login");
264
+ const k1 = params.get("k1")?.toLowerCase();
265
+ if (k1 === void 0 || !K1_PATTERN.test(k1)) throw new SparkInputError("lnurl auth: k1 must be 64 hex characters");
266
+ const actionText = params.get("action");
267
+ const action = actionText === void 0 ? void 0 : asAction(actionText);
268
+ if (actionText !== void 0 && action === void 0) throw new SparkInputError("lnurl auth: unsupported action");
269
+ return {
270
+ tag: "login",
271
+ url: formatLnurlUrl(parts),
272
+ k1,
273
+ ...action === void 0 ? {} : { action },
274
+ domain: parts.host
275
+ };
276
+ }
277
+ /**
278
+ * LUD-04 step 3: signs the `k1` challenge with the domain's linking key and GETs the service URL
279
+ * with `sig` and `key` appended. The k1 challenge and the signature never appear in error
280
+ * messages.
281
+ */
282
+ async function submitLnurlAuth(request, options) {
283
+ const { linkingKey, signature } = await options.signer.sign({
284
+ domain: request.domain,
285
+ k1: hexToBytes(request.k1)
286
+ });
287
+ if ((await lnurlGetJson(withQuery(request.url, [["sig", bytesToHex(signature)], ["key", bytesToHex(linkingKey)]]), options.fetch))["status"] !== "OK") throw new SparkRequestError("lnurl auth response: status is not OK", { code: "unexpected_response" });
288
+ }
289
+ //#endregion
290
+ //#region src/lnurl/codec.ts
291
+ const UTF8_DECODER = new TextDecoder("utf-8", { fatal: true });
292
+ function fail$3(message, options) {
293
+ throw new SparkInputError(`lnurl: ${message}`, options);
294
+ }
295
+ /** LUD-01 §HTTPS or Onion: clearnet https, or http only for a `.onion` host. */
296
+ function checkLnurlUrl(url) {
297
+ const parts = parseLnurlUrl(url);
298
+ if (parts.scheme === "https") return;
299
+ if (parts.scheme === "http" && parts.host.endsWith(".onion")) return;
300
+ fail$3("url must be https, or http to an .onion host");
301
+ }
302
+ /**
303
+ * Decodes a bech32 `lnurl1…` string into the normalized URL it wraps (LUD-01). Accepts upper- or
304
+ * lowercase (never mixed) and an optional `lightning:` scheme prefix.
305
+ */
306
+ function decodeLnurl(text) {
307
+ const raw = text.toLowerCase().startsWith("lightning:") ? text.slice(10) : text;
308
+ if (/[a-z]/.test(raw) && /[A-Z]/.test(raw)) fail$3("mixed case");
309
+ let decoded;
310
+ try {
311
+ decoded = bech32.decodeToBytes(raw.toLowerCase(), false);
312
+ } catch (error) {
313
+ throw new SparkInputError("lnurl: invalid bech32", { cause: error });
314
+ }
315
+ if (decoded.prefix !== "lnurl") fail$3("prefix: expected 'lnurl'");
316
+ let url;
317
+ try {
318
+ url = UTF8_DECODER.decode(decoded.bytes);
319
+ } catch (error) {
320
+ throw new SparkInputError("lnurl: payload is not UTF-8", { cause: error });
321
+ }
322
+ checkLnurlUrl(url);
323
+ return formatLnurlUrl(parseLnurlUrl(url));
324
+ }
325
+ /** Bech32-encodes an https/onion URL into a lowercase `lnurl1…` string (LUD-01). */
326
+ function encodeLnurl(url) {
327
+ checkLnurlUrl(url);
328
+ return bech32.encode("lnurl", bech32.toWords(utf8ToBytes(formatLnurlUrl(parseLnurlUrl(url)))), false);
329
+ }
330
+ const USERNAME_PATTERN = /^[a-z0-9._+-]+$/;
331
+ /**
332
+ * LUD-16 `<username>@<domain>` → the well-known pay endpoint. `@<domain>` resolves to the default
333
+ * identifier `_`. An `.onion` domain yields http, a clearnet domain https. Usernames are lowercased
334
+ * and must match `a-z0-9-_.` plus `+` for service tags; ports, userinfo and paths are rejected.
335
+ */
336
+ function lightningAddressUrl(address) {
337
+ const lower = address.toLowerCase();
338
+ const at = lower.lastIndexOf("@");
339
+ if (at === -1) fail$3("lightning address: expected <username>@<domain>");
340
+ const username = lower.slice(0, at) || "_";
341
+ const domain = lower.slice(at + 1);
342
+ if (!USERNAME_PATTERN.test(username)) fail$3("lightning address: invalid username");
343
+ if (domain === "") fail$3("lightning address: missing domain");
344
+ let host;
345
+ try {
346
+ const parts = parseLnurlUrl(`https://${domain}`);
347
+ if (parts.port !== void 0 || parts.path !== "/") fail$3("lightning address: domain must be a bare hostname");
348
+ ({host} = parts);
349
+ } catch (error) {
350
+ throw new SparkInputError("lnurl: lightning address: invalid domain", { cause: error });
351
+ }
352
+ return `${host.endsWith(".onion") ? "http" : "https"}://${host}/.well-known/lnurlp/${username}`;
353
+ }
354
+ //#endregion
355
+ //#region src/lnurl/success-action.ts
356
+ /** LUD-10 caps the base64-decoded ciphertext at 4 KiB. */
357
+ const AES_CIPHERTEXT_MAX_BYTES = 4096;
358
+ function fail$2(message) {
359
+ throw new SparkInputError(`lnurl successAction: ${message}`);
360
+ }
361
+ function asString$2(action, field) {
362
+ const { [field]: value } = action;
363
+ if (typeof value !== "string") fail$2(`${field} is not a string`);
364
+ return value;
365
+ }
366
+ /**
367
+ * Parses a `successAction` object (LUD-09). `callbackHost` is the payRequest callback's hostname:
368
+ * `url` actions must stay on that domain. Unknown tags are rejected — a wallet that cannot show the
369
+ * action must not proceed (LUD-09).
370
+ */
371
+ function parseSuccessAction(value, callbackHost) {
372
+ if (!isJsonObject(value)) fail$2("not an object");
373
+ switch (value["tag"]) {
374
+ case "message": {
375
+ const message = asString$2(value, "message");
376
+ if (message.length > 144) fail$2("message exceeds 144 characters");
377
+ return {
378
+ tag: "message",
379
+ message
380
+ };
381
+ }
382
+ case "url": {
383
+ const description = asString$2(value, "description");
384
+ if (description.length > 144) fail$2("description exceeds 144 characters");
385
+ const url = asString$2(value, "url");
386
+ let host;
387
+ try {
388
+ ({host} = parseLnurlUrl(url));
389
+ } catch (error) {
390
+ throw new SparkInputError("lnurl successAction: url is not a URL", { cause: error });
391
+ }
392
+ if (host !== callbackHost) fail$2("url domain does not match the callback domain");
393
+ return {
394
+ tag: "url",
395
+ description,
396
+ url
397
+ };
398
+ }
399
+ case "aes": return {
400
+ tag: "aes",
401
+ description: asString$2(value, "description"),
402
+ ciphertext: asString$2(value, "ciphertext"),
403
+ iv: asString$2(value, "iv")
404
+ };
405
+ default: return fail$2("unknown tag");
406
+ }
407
+ }
408
+ /**
409
+ * LUD-10: decrypts an `aes` successAction with the payment preimage — AES-256-CBC, the preimage is
410
+ * the key, `iv` is base64 of 16 bytes, `ciphertext` base64 of at most 4 KiB, PKCS#7 padding, UTF-8
411
+ * plaintext.
412
+ */
413
+ function decryptSuccessAction(action, preimage) {
414
+ if (preimage.length !== 32) fail$2("preimage must be 32 bytes");
415
+ let iv;
416
+ let ciphertext;
417
+ try {
418
+ iv = base64.decode(action.iv);
419
+ ciphertext = base64.decode(action.ciphertext);
420
+ } catch (error) {
421
+ throw new SparkInputError("lnurl successAction: aes fields are not base64", { cause: error });
422
+ }
423
+ if (iv.length !== 16) fail$2("iv is not 16 bytes");
424
+ if (ciphertext.length === 0 || ciphertext.length > AES_CIPHERTEXT_MAX_BYTES) fail$2("ciphertext exceeds 4 KiB");
425
+ let plaintext;
426
+ try {
427
+ plaintext = cbc(preimage, iv).decrypt(ciphertext);
428
+ } catch (error) {
429
+ throw new SparkInputError("lnurl successAction: aes decryption failed", { cause: error });
430
+ }
431
+ try {
432
+ return new TextDecoder("utf-8", { fatal: true }).decode(plaintext);
433
+ } catch (error) {
434
+ throw new SparkInputError("lnurl successAction: aes plaintext is not UTF-8", { cause: error });
435
+ }
436
+ }
437
+ //#endregion
438
+ //#region src/lnurl/pay.ts
439
+ function fail$1(message) {
440
+ throw new SparkInputError(`lnurl payRequest: ${message}`);
441
+ }
442
+ function asString$1(body, field) {
443
+ const { [field]: value } = body;
444
+ if (typeof value !== "string") fail$1(`${field} is not a string`);
445
+ return value;
446
+ }
447
+ function asMsat$1(body, field) {
448
+ const { [field]: value } = body;
449
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) fail$1(`${field} is not a non-negative integer`);
450
+ return value;
451
+ }
452
+ /** LUD-06: every metadata entry is an array whose first item is the mime-like type. */
453
+ function asEntry(value) {
454
+ if (!Array.isArray(value) || value.length < 2 || typeof value[0] !== "string") fail$1("metadata entries must be [type, value] arrays");
455
+ return [value[0], value[1]];
456
+ }
457
+ /** The metadata array's single mandatory `text/plain` string (LUD-06 step 3). */
458
+ function parseDescription(metadata) {
459
+ let entries;
460
+ try {
461
+ entries = JSON.parse(metadata);
462
+ } catch (error) {
463
+ throw new SparkInputError("lnurl payRequest: metadata is not valid JSON", { cause: error });
464
+ }
465
+ if (!Array.isArray(entries)) fail$1("metadata is not an array");
466
+ let description;
467
+ for (const raw of entries) {
468
+ const [mime, content] = asEntry(raw);
469
+ if (mime !== "text/plain") continue;
470
+ if (typeof content !== "string") fail$1("metadata text/plain value is not a string");
471
+ if (description !== void 0) fail$1("metadata has more than one text/plain entry");
472
+ description = content;
473
+ }
474
+ if (description === void 0) fail$1("metadata lacks a text/plain entry");
475
+ return description;
476
+ }
477
+ function parseLnurlPayRequest(body) {
478
+ if (body["tag"] !== "payRequest") fail$1("tag is not payRequest");
479
+ const callback = asString$1(body, "callback");
480
+ try {
481
+ checkLnurlUrl(callback);
482
+ } catch (error) {
483
+ throw new SparkInputError("lnurl payRequest: callback must be a valid https or .onion http url", { cause: error });
484
+ }
485
+ const minSendableMsat = asMsat$1(body, "minSendable");
486
+ const maxSendableMsat = asMsat$1(body, "maxSendable");
487
+ if (minSendableMsat === 0) fail$1("minSendable is less than 1");
488
+ if (minSendableMsat > maxSendableMsat) fail$1("minSendable exceeds maxSendable");
489
+ const metadata = asString$1(body, "metadata");
490
+ const description = parseDescription(metadata);
491
+ const commentAllowedRaw = body["commentAllowed"];
492
+ let commentAllowed = 0;
493
+ if (commentAllowedRaw !== void 0) {
494
+ if (typeof commentAllowedRaw !== "number" || !Number.isSafeInteger(commentAllowedRaw) || commentAllowedRaw < 0) fail$1("commentAllowed is not a non-negative integer");
495
+ commentAllowed = commentAllowedRaw;
496
+ }
497
+ const allowsNostr = body["allowsNostr"] === true;
498
+ let nostrPubkey;
499
+ if (allowsNostr) {
500
+ const pubkey = body["nostrPubkey"];
501
+ if (typeof pubkey !== "string" || !/^[0-9a-fA-F]{64}$/.test(pubkey)) fail$1("allowsNostr without a 32-byte hex nostrPubkey");
502
+ nostrPubkey = hexToBytes(pubkey);
503
+ }
504
+ return {
505
+ tag: "payRequest",
506
+ callback,
507
+ minSendableMsat,
508
+ maxSendableMsat,
509
+ metadata,
510
+ description,
511
+ commentAllowed,
512
+ allowsNostr,
513
+ nostrPubkey
514
+ };
515
+ }
516
+ /**
517
+ * LUD-06 second callback: requests the invoice for `amountMsat`, then verifies it against the
518
+ * request — exact amount, invoice network, and a `h` description-hash over the raw metadata (or
519
+ * over the zap-request event alone for a nostr zap, NIP-57).
520
+ */
521
+ async function requestInvoice(request, options) {
522
+ const { amountMsat, comment, zapRequest } = options;
523
+ if (!Number.isSafeInteger(amountMsat) || amountMsat < request.minSendableMsat || amountMsat > request.maxSendableMsat) fail$1("amount is outside the sendable bounds");
524
+ if (comment !== void 0) {
525
+ if (request.commentAllowed === 0) fail$1("comment: the service does not accept comments");
526
+ if (comment.length > request.commentAllowed) fail$1("comment exceeds commentAllowed");
527
+ }
528
+ if (zapRequest !== void 0 && !request.allowsNostr) fail$1("zapRequest: the service does not allow nostr zaps");
529
+ const params = [["amount", String(amountMsat)]];
530
+ if (comment !== void 0) params.push(["comment", comment]);
531
+ if (zapRequest !== void 0) params.push(["nostr", zapRequest]);
532
+ const body = await lnurlGetJson(withQuery(request.callback, params), options.fetch);
533
+ const { pr } = body;
534
+ if (typeof pr !== "string") fail$1("response pr is not a string");
535
+ const successAction = body["successAction"] === void 0 ? void 0 : parseSuccessAction(body["successAction"], parseLnurlUrl(request.callback).host);
536
+ let decoded;
537
+ try {
538
+ decoded = decodeBolt11(pr);
539
+ } catch (error) {
540
+ throw new SparkRequestError("lnurl response: pr is not a valid BOLT-11 invoice", {
541
+ code: "unexpected_response",
542
+ cause: error
543
+ });
544
+ }
545
+ if (decoded.amountMsat !== BigInt(amountMsat)) fail$1("invoice amount does not match the requested amount");
546
+ if (decoded.network !== options.network) fail$1("invoice is for a different network");
547
+ if (decoded.descriptionHash === void 0) fail$1("invoice lacks a description hash");
548
+ const expected = sha256(utf8ToBytes(zapRequest ?? request.metadata));
549
+ if (!equalBytes(decoded.descriptionHash, expected)) fail$1("invoice description hash does not match the metadata");
550
+ return {
551
+ invoice: pr,
552
+ decoded,
553
+ successAction
554
+ };
555
+ }
556
+ //#endregion
557
+ //#region src/lnurl/withdraw.ts
558
+ function fail(message) {
559
+ throw new SparkInputError(`lnurl withdrawRequest: ${message}`);
560
+ }
561
+ function asString(body, field, required) {
562
+ const { [field]: value } = body;
563
+ if (typeof value !== "string") {
564
+ if (!required && value === void 0) return "";
565
+ fail(`${field} is not a string`);
566
+ }
567
+ return value;
568
+ }
569
+ function asMsat(body, field) {
570
+ const { [field]: value } = body;
571
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) fail(`${field} is not a non-negative integer`);
572
+ return value;
573
+ }
574
+ function parseLnurlWithdrawRequest(body) {
575
+ if (body["tag"] !== "withdrawRequest") fail("tag is not withdrawRequest");
576
+ const callback = asString(body, "callback", true);
577
+ try {
578
+ checkLnurlUrl(callback);
579
+ } catch (error) {
580
+ throw new SparkInputError("lnurl withdrawRequest: callback must be a valid https or .onion http url", { cause: error });
581
+ }
582
+ const k1 = asString(body, "k1", true);
583
+ if (k1 === "") fail("k1 is empty");
584
+ const minWithdrawableMsat = asMsat(body, "minWithdrawable");
585
+ const maxWithdrawableMsat = asMsat(body, "maxWithdrawable");
586
+ if (minWithdrawableMsat > maxWithdrawableMsat) fail("minWithdrawable exceeds maxWithdrawable");
587
+ return {
588
+ tag: "withdrawRequest",
589
+ callback,
590
+ k1,
591
+ defaultDescription: asString(body, "defaultDescription", false),
592
+ minWithdrawableMsat,
593
+ maxWithdrawableMsat
594
+ };
595
+ }
596
+ /**
597
+ * LUD-03 step 5: submits the wallet's BOLT-11 `invoice` for payment. The service pays
598
+ * asynchronously; a `{"status": "OK"}` body means it accepted — errors surface as
599
+ * `SparkRequestError` (`lnurl_error` / `http_*`).
600
+ */
601
+ async function submitWithdraw(request, options) {
602
+ if ((await lnurlGetJson(withQuery(request.callback, [["k1", request.k1], ["pr", options.invoice]]), options.fetch))["status"] !== "OK") throw new SparkRequestError("lnurl withdraw response: status is not OK", { code: "unexpected_response" });
603
+ }
604
+ //#endregion
605
+ //#region src/lnurl/fetch.ts
606
+ /**
607
+ * Resolves an `lnurl1…` string, a lightning address (`user@domain`, LUD-16) or a bare https/onion
608
+ * URL into a typed LNURL service request. A `tag=login` query is a LUD-04 auth challenge carried
609
+ * inside the LNURL itself and returns without fetching; otherwise the endpoint is fetched and only
610
+ * the interactive `payRequest` (LUD-06) and `withdrawRequest` (LUD-03) tags are supported.
611
+ */
612
+ async function resolveLnurl(input, options) {
613
+ const url = resolveUrl(input);
614
+ if (queryParams(parseLnurlUrl(url).query).get("tag") === "login") return parseLnurlAuth(url);
615
+ const body = await lnurlGetJson(url, options.fetch);
616
+ switch (body["tag"]) {
617
+ case "payRequest": return parseLnurlPayRequest(body);
618
+ case "withdrawRequest": return parseLnurlWithdrawRequest(body);
619
+ default: throw new SparkInputError(`lnurl: unsupported tag ${JSON.stringify(body["tag"] ?? null)}`);
620
+ }
621
+ }
622
+ function resolveUrl(input) {
623
+ const lower = input.toLowerCase();
624
+ if (lower.startsWith("lnurl1") || lower.startsWith("lightning:")) return decodeLnurl(input);
625
+ if (input.includes("@")) return lightningAddressUrl(input);
626
+ let url;
627
+ try {
628
+ checkLnurlUrl(input);
629
+ url = input;
630
+ } catch (error) {
631
+ throw new SparkInputError("lnurl: not an lnurl, lightning address, or URL", { cause: error });
632
+ }
633
+ return url;
634
+ }
635
+ //#endregion
636
+ //#region src/lnurl/lightning-address.ts
637
+ /**
638
+ * Builds the register request body the signed statement commits to — lowercased domain and
639
+ * username, fresh timestamp — and signs it. Split from {@link registerLightningAddress} so the same
640
+ * bytes can be handed to a server handler in tests without HTTP.
641
+ */
642
+ async function lightningAddressRegisterBody(options) {
643
+ const statement = {
644
+ kind: "lightning-address",
645
+ action: "register",
646
+ domain: options.domain.toLowerCase(),
647
+ username: options.username.toLowerCase(),
648
+ description: options.description,
649
+ timestamp: Math.floor((options.now ?? Date.now)() / 1e3)
650
+ };
651
+ const signature = await options.signer.signIdentity(statement);
652
+ return {
653
+ pubkeyHex: bytesToHex(await options.signer.identityPublicKey()),
654
+ body: {
655
+ username: statement.username,
656
+ signature: bytesToHex(signature),
657
+ timestamp: statement.timestamp,
658
+ description: options.description
659
+ }
660
+ };
661
+ }
662
+ /** The unregister counterpart of {@link lightningAddressRegisterBody}. */
663
+ async function lightningAddressUnregisterBody(options) {
664
+ const statement = {
665
+ kind: "lightning-address",
666
+ action: "unregister",
667
+ domain: options.domain.toLowerCase(),
668
+ username: options.username.toLowerCase(),
669
+ timestamp: Math.floor((options.now ?? Date.now)() / 1e3)
670
+ };
671
+ const signature = await options.signer.signIdentity(statement);
672
+ return {
673
+ pubkeyHex: bytesToHex(await options.signer.identityPublicKey()),
674
+ body: {
675
+ username: statement.username,
676
+ signature: bytesToHex(signature),
677
+ timestamp: statement.timestamp
678
+ }
679
+ };
680
+ }
681
+ function lnurlpayUrl(serverUrl, pubkeyHex) {
682
+ checkLnurlUrl(serverUrl);
683
+ const base = formatLnurlUrl(parseLnurlUrl(serverUrl));
684
+ return `${base.endsWith("/") ? base.slice(0, -1) : base}/lnurlpay/${pubkeyHex}`;
685
+ }
686
+ /**
687
+ * Claims `username` on the Lightning Address server at `serverUrl`: signs the register statement
688
+ * and POSTs it to `/lnurlpay/{pubkey}`. `serverUrl` must be https or an `.onion` http URL.
689
+ */
690
+ async function registerLightningAddress(options) {
691
+ const { pubkeyHex, body } = await lightningAddressRegisterBody(options);
692
+ const response = await lnurlSendJson(lnurlpayUrl(options.serverUrl, pubkeyHex), options.fetch, {
693
+ method: "POST",
694
+ body
695
+ });
696
+ const lnurl = response?.["lnurl"];
697
+ const lightningAddress = response?.["lightning_address"];
698
+ if (typeof lnurl !== "string" || typeof lightningAddress !== "string") throw new SparkRequestError("lnurl register response: missing lnurl or lightning_address", { code: "unexpected_response" });
699
+ const domain = options.domain.toLowerCase();
700
+ const username = options.username.toLowerCase();
701
+ if (lightningAddress !== `${username}@${domain}`) throw new SparkRequestError("lnurl register response: lightning_address is not the requested address", { code: "unexpected_response" });
702
+ let decoded;
703
+ try {
704
+ decoded = decodeLnurl(lnurl);
705
+ } catch (error) {
706
+ throw new SparkRequestError("lnurl register response: lnurl does not decode", {
707
+ code: "unexpected_response",
708
+ cause: error
709
+ });
710
+ }
711
+ if (decoded !== `https://${domain}/lnurlp/${username}`) throw new SparkRequestError("lnurl register response: lnurl does not point at the requested pay endpoint", { code: "unexpected_response" });
712
+ return {
713
+ lnurl,
714
+ lightningAddress
715
+ };
716
+ }
717
+ /**
718
+ * Gives up `username` on the server: signs the unregister statement and DELETEs
719
+ * `/lnurlpay/{pubkey}`.
720
+ */
721
+ async function unregisterLightningAddress(options) {
722
+ const { pubkeyHex, body } = await lightningAddressUnregisterBody(options);
723
+ await lnurlSendJson(lnurlpayUrl(options.serverUrl, pubkeyHex), options.fetch, {
724
+ method: "DELETE",
725
+ body
726
+ });
727
+ }
728
+ //#endregion
729
+ export { submitLnurlAuth as _, resolveLnurl as a, lnurlSendJson as b, parseLnurlPayRequest as c, parseSuccessAction as d, checkLnurlUrl as f, parseLnurlAuth as g, lightningAddressUrl as h, unregisterLightningAddress as i, requestInvoice as l, encodeLnurl as m, lightningAddressUnregisterBody as n, parseLnurlWithdrawRequest as o, decodeLnurl as p, registerLightningAddress as r, submitWithdraw as s, lightningAddressRegisterBody as t, decryptSuccessAction as u, isJsonObject as v, lnurlGetJson as y };