@qntx/spark 0.0.0-stage → 0.1.1
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.
- package/LICENSE +201 -0
- package/NOTICE +6 -0
- package/README.md +59 -2
- package/dist/bolt11-DsNTTS_O.d.mts +52 -0
- package/dist/crypto.d.mts +3 -0
- package/dist/crypto.mjs +9 -0
- package/dist/errors-5xLkExNL.mjs +67 -0
- package/dist/index-B_g3vHnT.d.mts +820 -0
- package/dist/index-D1fBaHTa.d.mts +55 -0
- package/dist/index-DUSx3L3J.d.mts +69 -0
- package/dist/index.d.mts +115 -0
- package/dist/index.mjs +7322 -0
- package/dist/lightning-address-CRULmi9v.d.mts +106 -0
- package/dist/lightning-address-CZrb8x4p.mjs +92 -0
- package/dist/lnurl-kcdXU75g.mjs +729 -0
- package/dist/lnurl.d.mts +258 -0
- package/dist/lnurl.mjs +1 -0
- package/dist/server.d.mts +158 -0
- package/dist/server.mjs +373 -0
- package/dist/signer.d.mts +72 -0
- package/dist/signer.mjs +1024 -0
- package/dist/text-DC_Vptn6.mjs +303 -0
- package/dist/token-DmojVTWi.mjs +2122 -0
- package/dist/types-jBCznYd6.d.mts +322 -0
- package/package.json +98 -4
|
@@ -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 };
|