@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,303 @@
|
|
|
1
|
+
import { r as SparkInputError } from "./errors-5xLkExNL.mjs";
|
|
2
|
+
import { concatBytes, utf8ToBytes } from "@noble/hashes/utils.js";
|
|
3
|
+
import { secp256k1 } from "@noble/curves/secp256k1.js";
|
|
4
|
+
import { bech32 } from "@scure/base";
|
|
5
|
+
import { sha256 } from "@noble/hashes/sha2.js";
|
|
6
|
+
//#region src/core/bolt11.ts
|
|
7
|
+
const TAG_PAYMENT_HASH = 1;
|
|
8
|
+
const TAG_ROUTE_HINT = 3;
|
|
9
|
+
const TAG_FEATURES = 5;
|
|
10
|
+
const TAG_EXPIRY = 6;
|
|
11
|
+
const TAG_FALLBACK = 9;
|
|
12
|
+
const TAG_DESCRIPTION = 13;
|
|
13
|
+
const TAG_PAYMENT_SECRET = 16;
|
|
14
|
+
const TAG_PAYEE = 19;
|
|
15
|
+
const TAG_DESCRIPTION_HASH = 23;
|
|
16
|
+
const TAG_MIN_FINAL_CLTV = 24;
|
|
17
|
+
const TAG_METADATA = 27;
|
|
18
|
+
const TIMESTAMP_WORDS = 7;
|
|
19
|
+
const SIGNATURE_WORDS = 104;
|
|
20
|
+
const HASH_WORDS = 52;
|
|
21
|
+
const PAYEE_WORDS = 53;
|
|
22
|
+
const ROUTE_HOP_BYTES = 51;
|
|
23
|
+
const DEFAULT_EXPIRY_SECONDS = 3600;
|
|
24
|
+
const DEFAULT_MIN_FINAL_CLTV = 18;
|
|
25
|
+
const NETWORK_PREFIX = {
|
|
26
|
+
bc: "mainnet",
|
|
27
|
+
bcrt: "regtest",
|
|
28
|
+
tb: "testnet",
|
|
29
|
+
tbs: "signet"
|
|
30
|
+
};
|
|
31
|
+
const MULTIPLIER_MSAT = {
|
|
32
|
+
"": 100000000000n,
|
|
33
|
+
m: 100000000n,
|
|
34
|
+
u: 100000n,
|
|
35
|
+
n: 100n
|
|
36
|
+
};
|
|
37
|
+
const KNOWN_EVEN_FEATURES = /* @__PURE__ */ new Set([
|
|
38
|
+
0,
|
|
39
|
+
4,
|
|
40
|
+
6,
|
|
41
|
+
8,
|
|
42
|
+
10,
|
|
43
|
+
12,
|
|
44
|
+
14,
|
|
45
|
+
16,
|
|
46
|
+
18,
|
|
47
|
+
22,
|
|
48
|
+
24,
|
|
49
|
+
26,
|
|
50
|
+
28,
|
|
51
|
+
34,
|
|
52
|
+
36,
|
|
53
|
+
38,
|
|
54
|
+
40,
|
|
55
|
+
42,
|
|
56
|
+
44,
|
|
57
|
+
46,
|
|
58
|
+
48,
|
|
59
|
+
50,
|
|
60
|
+
60,
|
|
61
|
+
62,
|
|
62
|
+
66
|
|
63
|
+
]);
|
|
64
|
+
function fail(message) {
|
|
65
|
+
throw new SparkInputError(`invoice: ${message}`);
|
|
66
|
+
}
|
|
67
|
+
/** 5-bit words → bytes, big-endian, dropping the incomplete trailing group. */
|
|
68
|
+
function wordsToBytes(words) {
|
|
69
|
+
return wordsToBytesImpl(words, false);
|
|
70
|
+
}
|
|
71
|
+
/** 5-bit words → bytes, big-endian, zero-padding the last partial byte. */
|
|
72
|
+
function wordsToBytesPadded(words) {
|
|
73
|
+
return wordsToBytesImpl(words, true);
|
|
74
|
+
}
|
|
75
|
+
function wordsToBytesImpl(words, pad) {
|
|
76
|
+
const total = pad ? Math.ceil(words.length * 5 / 8) : Math.floor(words.length * 5 / 8);
|
|
77
|
+
const out = new Uint8Array(total);
|
|
78
|
+
let acc = 0;
|
|
79
|
+
let bits = 0;
|
|
80
|
+
let index = 0;
|
|
81
|
+
for (const word of words) {
|
|
82
|
+
acc = (acc << 5 | word) & 4294967295;
|
|
83
|
+
bits += 5;
|
|
84
|
+
while (bits >= 8) {
|
|
85
|
+
bits -= 8;
|
|
86
|
+
out[index] = acc >>> bits & 255;
|
|
87
|
+
index++;
|
|
88
|
+
}
|
|
89
|
+
acc &= (1 << bits) - 1;
|
|
90
|
+
}
|
|
91
|
+
if (pad && bits > 0) out[index] = acc << 8 - bits & 255;
|
|
92
|
+
return out;
|
|
93
|
+
}
|
|
94
|
+
function wordsToInt(words) {
|
|
95
|
+
let value = 0;
|
|
96
|
+
for (const word of words) {
|
|
97
|
+
value = value * 32 + word;
|
|
98
|
+
if (value > Number.MAX_SAFE_INTEGER) fail("integer field overflows a safe integer");
|
|
99
|
+
}
|
|
100
|
+
return value;
|
|
101
|
+
}
|
|
102
|
+
/** Feature bit `n` of a big-endian bitfield expressed as 5-bit words (bit 0 = LSB). */
|
|
103
|
+
function hasFeatureBit(words, bit) {
|
|
104
|
+
return ((words[words.length - 1 - Math.floor(bit / 5)] ?? 0) >>> bit % 5 & 1) === 1;
|
|
105
|
+
}
|
|
106
|
+
const UTF8_DECODER = new TextDecoder("utf-8", { fatal: true });
|
|
107
|
+
function parseAmount(hrpAmount) {
|
|
108
|
+
if (hrpAmount === "") return;
|
|
109
|
+
const match = /^([0-9]+)([munp]?)$/.exec(hrpAmount);
|
|
110
|
+
if (match === null) fail("amount: not a decimal integer with optional m/u/n/p multiplier");
|
|
111
|
+
const digits = match[1] ?? "";
|
|
112
|
+
const multiplier = match[2] ?? "";
|
|
113
|
+
if (digits.length > 1 && digits.startsWith("0")) fail("amount: leading zeros are not allowed");
|
|
114
|
+
const value = BigInt(digits);
|
|
115
|
+
if (multiplier === "p") {
|
|
116
|
+
if (value % 10n !== 0n) fail("amount: pico-BTC value must be a multiple of 10 (whole millisatoshis)");
|
|
117
|
+
return value / 10n;
|
|
118
|
+
}
|
|
119
|
+
const factor = MULTIPLIER_MSAT[multiplier];
|
|
120
|
+
if (factor === void 0) fail("amount: invalid multiplier");
|
|
121
|
+
return value * factor;
|
|
122
|
+
}
|
|
123
|
+
function parseRouteHint(words) {
|
|
124
|
+
const data = wordsToBytes(words);
|
|
125
|
+
if (data.length === 0 || data.length % ROUTE_HOP_BYTES !== 0) fail("route hint: data is not a multiple of the 51-byte entry size");
|
|
126
|
+
const hops = [];
|
|
127
|
+
for (let offset = 0; offset < data.length; offset += ROUTE_HOP_BYTES) {
|
|
128
|
+
const entry = data.subarray(offset, offset + ROUTE_HOP_BYTES);
|
|
129
|
+
hops.push({
|
|
130
|
+
publicKey: entry.slice(0, 33),
|
|
131
|
+
shortChannelId: entry.slice(33, 41),
|
|
132
|
+
feeBaseMsat: new DataView(entry.buffer, entry.byteOffset + 41, 8).getUint32(0),
|
|
133
|
+
feeProportionalMillionths: new DataView(entry.buffer, entry.byteOffset + 41, 8).getUint32(4),
|
|
134
|
+
cltvExpiryDelta: new DataView(entry.buffer, entry.byteOffset + 49, 2).getUint16(0)
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
return hops;
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Decodes a BOLT-11 payment request. Throws {@link SparkInputError} on malformed input: bad bech32,
|
|
141
|
+
* unknown prefix, bad amount, missing required fields, unknown even feature bits, or a signature
|
|
142
|
+
* that fails verification/recovery.
|
|
143
|
+
*/
|
|
144
|
+
function decodeBolt11(invoice) {
|
|
145
|
+
let decoded;
|
|
146
|
+
try {
|
|
147
|
+
decoded = bech32.decode(invoice, false);
|
|
148
|
+
} catch (error) {
|
|
149
|
+
throw new SparkInputError("invoice: invalid bech32", { cause: error });
|
|
150
|
+
}
|
|
151
|
+
const { prefix: hrp, words } = decoded;
|
|
152
|
+
if (!hrp.startsWith("ln")) fail("prefix: expected 'ln'");
|
|
153
|
+
const letters = /^[a-z]+/.exec(hrp.slice(2))?.[0];
|
|
154
|
+
const network = letters === void 0 ? void 0 : NETWORK_PREFIX[letters];
|
|
155
|
+
if (network === void 0) fail("prefix: unknown currency code");
|
|
156
|
+
const amountMsat = parseAmount(hrp.slice(2 + (letters?.length ?? 0)));
|
|
157
|
+
const taggedEnd = words.length - SIGNATURE_WORDS;
|
|
158
|
+
if (taggedEnd < TIMESTAMP_WORDS) fail("too short");
|
|
159
|
+
const timestamp = wordsToInt(words.slice(0, TIMESTAMP_WORDS));
|
|
160
|
+
let paymentHash;
|
|
161
|
+
let paymentSecret;
|
|
162
|
+
let description;
|
|
163
|
+
let descriptionHash;
|
|
164
|
+
let payeeNode;
|
|
165
|
+
let expirySeconds = DEFAULT_EXPIRY_SECONDS;
|
|
166
|
+
let minFinalCltvExpiry = DEFAULT_MIN_FINAL_CLTV;
|
|
167
|
+
let featuresWords;
|
|
168
|
+
const routeHints = [];
|
|
169
|
+
const fallbackAddresses = [];
|
|
170
|
+
let metadata;
|
|
171
|
+
for (let i = TIMESTAMP_WORDS; i < taggedEnd;) {
|
|
172
|
+
if (i + 3 > taggedEnd) fail("tagged field: truncated header");
|
|
173
|
+
const tag = words[i];
|
|
174
|
+
const lengthHi = words[i + 1];
|
|
175
|
+
const lengthLo = words[i + 2];
|
|
176
|
+
if (tag === void 0 || lengthHi === void 0 || lengthLo === void 0) fail("tagged field: truncated header");
|
|
177
|
+
const length = lengthHi * 32 + lengthLo;
|
|
178
|
+
if (i + 3 + length > taggedEnd) fail("tagged field: data overruns the signature");
|
|
179
|
+
const field = words.slice(i + 3, i + 3 + length);
|
|
180
|
+
switch (tag) {
|
|
181
|
+
case TAG_PAYMENT_HASH:
|
|
182
|
+
case TAG_PAYMENT_SECRET:
|
|
183
|
+
case TAG_DESCRIPTION_HASH:
|
|
184
|
+
case TAG_PAYEE: {
|
|
185
|
+
const expected = tag === TAG_PAYEE ? PAYEE_WORDS : HASH_WORDS;
|
|
186
|
+
if (field.length !== expected) break;
|
|
187
|
+
const value = wordsToBytes(field);
|
|
188
|
+
if (tag === TAG_PAYMENT_HASH && paymentHash === void 0) paymentHash = value;
|
|
189
|
+
else if (tag === TAG_PAYMENT_SECRET && paymentSecret === void 0) paymentSecret = value;
|
|
190
|
+
else if (tag === TAG_DESCRIPTION_HASH && descriptionHash === void 0) descriptionHash = value;
|
|
191
|
+
else if (tag === TAG_PAYEE && payeeNode === void 0) payeeNode = value;
|
|
192
|
+
break;
|
|
193
|
+
}
|
|
194
|
+
case TAG_DESCRIPTION:
|
|
195
|
+
try {
|
|
196
|
+
description ??= UTF8_DECODER.decode(wordsToBytes(field));
|
|
197
|
+
} catch (error) {
|
|
198
|
+
throw new SparkInputError("invoice: description: invalid UTF-8", { cause: error });
|
|
199
|
+
}
|
|
200
|
+
break;
|
|
201
|
+
case TAG_EXPIRY:
|
|
202
|
+
if (field[0] === 0) fail("expiry: non-minimal data_length");
|
|
203
|
+
expirySeconds = wordsToInt(field);
|
|
204
|
+
break;
|
|
205
|
+
case TAG_MIN_FINAL_CLTV:
|
|
206
|
+
if (field[0] === 0) fail("min_final_cltv_expiry: non-minimal data_length");
|
|
207
|
+
minFinalCltvExpiry = wordsToInt(field);
|
|
208
|
+
break;
|
|
209
|
+
case TAG_FEATURES:
|
|
210
|
+
if (field[0] === 0) fail("features: non-minimal data_length");
|
|
211
|
+
featuresWords = field;
|
|
212
|
+
break;
|
|
213
|
+
case TAG_FALLBACK: {
|
|
214
|
+
if (field.length === 0) fail("fallback: missing version");
|
|
215
|
+
const version = field[0] ?? 0;
|
|
216
|
+
fallbackAddresses.push({
|
|
217
|
+
version,
|
|
218
|
+
program: wordsToBytes(field.slice(1))
|
|
219
|
+
});
|
|
220
|
+
break;
|
|
221
|
+
}
|
|
222
|
+
case TAG_ROUTE_HINT:
|
|
223
|
+
routeHints.push(parseRouteHint(field));
|
|
224
|
+
break;
|
|
225
|
+
case TAG_METADATA: metadata ??= wordsToBytes(field);
|
|
226
|
+
}
|
|
227
|
+
i += 3 + length;
|
|
228
|
+
}
|
|
229
|
+
if (paymentHash === void 0) fail("missing payment hash (p)");
|
|
230
|
+
if (paymentSecret === void 0) fail("missing payment secret (s)");
|
|
231
|
+
if (description === void 0 && descriptionHash === void 0) fail("missing description (d) and description hash (h)");
|
|
232
|
+
if (description !== void 0 && descriptionHash !== void 0) fail("both description (d) and description hash (h) present");
|
|
233
|
+
if (featuresWords !== void 0) {
|
|
234
|
+
for (let bit = 0; bit < featuresWords.length * 5; bit += 2) if (!KNOWN_EVEN_FEATURES.has(bit) && hasFeatureBit(featuresWords, bit)) fail(`features: unknown required feature bit ${bit}`);
|
|
235
|
+
}
|
|
236
|
+
const signature = wordsToBytes(words.slice(taggedEnd));
|
|
237
|
+
const compact = signature.slice(0, 64);
|
|
238
|
+
const [recoveryId] = signature.subarray(64, 65);
|
|
239
|
+
if (recoveryId === void 0 || recoveryId > 3) fail("signature: recovery id must be 0..3");
|
|
240
|
+
const preimage = concatBytes(utf8ToBytes(hrp), wordsToBytesPadded(words.slice(0, taggedEnd)));
|
|
241
|
+
const messageHash = sha256(preimage);
|
|
242
|
+
let payee;
|
|
243
|
+
if (payeeNode !== void 0 && isValidPoint(payeeNode)) {
|
|
244
|
+
let ok;
|
|
245
|
+
try {
|
|
246
|
+
ok = secp256k1.verify(compact, messageHash, payeeNode, {
|
|
247
|
+
prehash: false,
|
|
248
|
+
lowS: true
|
|
249
|
+
});
|
|
250
|
+
} catch {
|
|
251
|
+
ok = false;
|
|
252
|
+
}
|
|
253
|
+
if (!ok) fail("signature: does not verify against the n field");
|
|
254
|
+
payee = payeeNode;
|
|
255
|
+
} else {
|
|
256
|
+
const recoverable = /* @__PURE__ */ new Uint8Array(65);
|
|
257
|
+
recoverable[0] = recoveryId;
|
|
258
|
+
recoverable.set(compact, 1);
|
|
259
|
+
try {
|
|
260
|
+
payee = secp256k1.recoverPublicKey(recoverable, messageHash, { prehash: false });
|
|
261
|
+
} catch (error) {
|
|
262
|
+
throw new SparkInputError("invoice: signature is not recoverable", { cause: error });
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
return {
|
|
266
|
+
network,
|
|
267
|
+
amountMsat,
|
|
268
|
+
timestamp,
|
|
269
|
+
paymentHash,
|
|
270
|
+
paymentSecret,
|
|
271
|
+
description,
|
|
272
|
+
descriptionHash,
|
|
273
|
+
expirySeconds,
|
|
274
|
+
minFinalCltvExpiry,
|
|
275
|
+
payee,
|
|
276
|
+
features: featuresWords === void 0 ? void 0 : wordsToBytes(featuresWords),
|
|
277
|
+
routeHints,
|
|
278
|
+
fallbackAddresses,
|
|
279
|
+
metadata
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
function isValidPoint(bytes) {
|
|
283
|
+
try {
|
|
284
|
+
secp256k1.Point.fromBytes(bytes);
|
|
285
|
+
return true;
|
|
286
|
+
} catch {
|
|
287
|
+
return false;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
/** True once `timestamp + expirySeconds` has passed at `nowMs` (unix milliseconds). */
|
|
291
|
+
function isExpired(invoice, nowMs) {
|
|
292
|
+
return nowMs > (invoice.timestamp + invoice.expirySeconds) * 1e3;
|
|
293
|
+
}
|
|
294
|
+
//#endregion
|
|
295
|
+
//#region src/core/text.ts
|
|
296
|
+
const CONTROL = /[\u0000-\u001F\u007F-\u009F]/g;
|
|
297
|
+
/** Server-supplied error text made safe to embed in an error message. */
|
|
298
|
+
function serverErrorText(text) {
|
|
299
|
+
const cleaned = text.replace(CONTROL, "");
|
|
300
|
+
return cleaned.length <= 256 ? cleaned : `${cleaned.slice(0, 256)}\u2026`;
|
|
301
|
+
}
|
|
302
|
+
//#endregion
|
|
303
|
+
export { decodeBolt11 as n, isExpired as r, serverErrorText as t };
|