thunder-bridge 0.8.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.
package/dist/index.js ADDED
@@ -0,0 +1,1419 @@
1
+ // src/errors.ts
2
+ var GatewayCheatError = class extends Error {
3
+ code;
4
+ paymentId;
5
+ constructor(code, paymentId) {
6
+ super(`gateway verification failed: ${code}`);
7
+ this.name = "GatewayCheatError";
8
+ this.code = code;
9
+ this.paymentId = paymentId;
10
+ }
11
+ };
12
+ var UnverifiedRecipientError = class extends Error {
13
+ lnAddress;
14
+ paymentId;
15
+ constructor(lnAddress, paymentId, cause) {
16
+ super(`could not reach ${lnAddress} to verify the invoice: ${String(cause)}`);
17
+ this.name = "UnverifiedRecipientError";
18
+ this.lnAddress = lnAddress;
19
+ this.paymentId = paymentId;
20
+ }
21
+ };
22
+ var ProblemError = class extends Error {
23
+ type;
24
+ title;
25
+ status;
26
+ detail;
27
+ constructor(problem) {
28
+ const title = problem.title ?? "Request failed";
29
+ super(problem.detail ? `${title}: ${problem.detail}` : title);
30
+ this.name = "ProblemError";
31
+ this.type = problem.type ?? "about:blank";
32
+ this.title = title;
33
+ this.status = problem.status ?? 0;
34
+ this.detail = problem.detail ?? null;
35
+ }
36
+ };
37
+ var NO_WALLET_AVAILABLE = "urn:problem-type:thunder-bridge-direct:no-wallet-available";
38
+ var REQUEST_IN_FLIGHT = "urn:problem-type:thunder-bridge-direct:request-in-flight";
39
+ var IDEMPOTENCY_KEY_REUSED = "urn:problem-type:thunder-bridge-direct:idempotency-key-reused";
40
+ var PAYMENT_ALREADY_WATCHED = "urn:problem-type:thunder-bridge-direct:payment-already-watched";
41
+ var IdempotencyConflictError = class extends ProblemError {
42
+ conflict;
43
+ constructor(problem, conflict) {
44
+ super(problem);
45
+ this.name = "IdempotencyConflictError";
46
+ this.conflict = conflict;
47
+ }
48
+ };
49
+ var NoWalletAvailableError = class extends ProblemError {
50
+ wallets;
51
+ constructor(problem, wallets) {
52
+ super({ ...problem, type: NO_WALLET_AVAILABLE });
53
+ this.name = "NoWalletAvailableError";
54
+ this.wallets = wallets;
55
+ }
56
+ };
57
+
58
+ // ../core/bytes.ts
59
+ function bytesToHex(bytes) {
60
+ let hex = "";
61
+ for (const byte of bytes) hex += byte.toString(16).padStart(2, "0");
62
+ return hex;
63
+ }
64
+ function hexToBytes(hex) {
65
+ const bytes = new Uint8Array(hex.length >> 1);
66
+ for (let at = 0; at < bytes.length; at++) {
67
+ bytes[at] = parseInt(hex.slice(at * 2, at * 2 + 2), 16);
68
+ }
69
+ return bytes;
70
+ }
71
+ function isHex(text2) {
72
+ return /^[0-9a-f]+$/i.test(text2) && text2.length % 2 === 0;
73
+ }
74
+
75
+ // ../core/sha256.ts
76
+ var ROUND_CONSTANTS = new Uint32Array([
77
+ 1116352408,
78
+ 1899447441,
79
+ 3049323471,
80
+ 3921009573,
81
+ 961987163,
82
+ 1508970993,
83
+ 2453635748,
84
+ 2870763221,
85
+ 3624381080,
86
+ 310598401,
87
+ 607225278,
88
+ 1426881987,
89
+ 1925078388,
90
+ 2162078206,
91
+ 2614888103,
92
+ 3248222580,
93
+ 3835390401,
94
+ 4022224774,
95
+ 264347078,
96
+ 604807628,
97
+ 770255983,
98
+ 1249150122,
99
+ 1555081692,
100
+ 1996064986,
101
+ 2554220882,
102
+ 2821834349,
103
+ 2952996808,
104
+ 3210313671,
105
+ 3336571891,
106
+ 3584528711,
107
+ 113926993,
108
+ 338241895,
109
+ 666307205,
110
+ 773529912,
111
+ 1294757372,
112
+ 1396182291,
113
+ 1695183700,
114
+ 1986661051,
115
+ 2177026350,
116
+ 2456956037,
117
+ 2730485921,
118
+ 2820302411,
119
+ 3259730800,
120
+ 3345764771,
121
+ 3516065817,
122
+ 3600352804,
123
+ 4094571909,
124
+ 275423344,
125
+ 430227734,
126
+ 506948616,
127
+ 659060556,
128
+ 883997877,
129
+ 958139571,
130
+ 1322822218,
131
+ 1537002063,
132
+ 1747873779,
133
+ 1955562222,
134
+ 2024104815,
135
+ 2227730452,
136
+ 2361852424,
137
+ 2428436474,
138
+ 2756734187,
139
+ 3204031479,
140
+ 3329325298
141
+ ]);
142
+ var INITIAL_STATE = new Uint32Array([
143
+ 1779033703,
144
+ 3144134277,
145
+ 1013904242,
146
+ 2773480762,
147
+ 1359893119,
148
+ 2600822924,
149
+ 528734635,
150
+ 1541459225
151
+ ]);
152
+ var BLOCK_BYTES = 64;
153
+ var LENGTH_BYTES = 8;
154
+ var SCHEDULE_WORDS = 64;
155
+ function sha256(bytes) {
156
+ const padded = withPadding(bytes);
157
+ const reader = new DataView(padded.buffer);
158
+ const state = Uint32Array.from(INITIAL_STATE);
159
+ const schedule = new Uint32Array(SCHEDULE_WORDS);
160
+ for (let block = 0; block < padded.length; block += BLOCK_BYTES) {
161
+ fillSchedule(schedule, reader, block);
162
+ compress(state, schedule);
163
+ }
164
+ return asBytes(state);
165
+ }
166
+ function sha256Hex(text2) {
167
+ return bytesToHex(sha256(new TextEncoder().encode(text2)));
168
+ }
169
+ function withPadding(bytes) {
170
+ const padded = new Uint8Array(
171
+ bytes.length + 1 + LENGTH_BYTES + BLOCK_BYTES - 1 & ~(BLOCK_BYTES - 1)
172
+ );
173
+ padded.set(bytes);
174
+ padded[bytes.length] = 128;
175
+ new DataView(padded.buffer).setUint32(padded.length - 4, bytes.length * 8, false);
176
+ return padded;
177
+ }
178
+ function fillSchedule(schedule, reader, block) {
179
+ for (let at = 0; at < 16; at++) schedule[at] = reader.getUint32(block + at * 4, false);
180
+ for (let at = 16; at < SCHEDULE_WORDS; at++) {
181
+ const recent2 = schedule[at - 15];
182
+ const distant = schedule[at - 2];
183
+ const mixedRecent = rotate(recent2, 7) ^ rotate(recent2, 18) ^ recent2 >>> 3;
184
+ const mixedDistant = rotate(distant, 17) ^ rotate(distant, 19) ^ distant >>> 10;
185
+ schedule[at] = schedule[at - 16] + mixedRecent + schedule[at - 7] + mixedDistant | 0;
186
+ }
187
+ }
188
+ function compress(state, schedule) {
189
+ let a = state[0];
190
+ let b = state[1];
191
+ let c = state[2];
192
+ let d = state[3];
193
+ let e = state[4];
194
+ let f = state[5];
195
+ let g = state[6];
196
+ let h = state[7];
197
+ for (let round = 0; round < SCHEDULE_WORDS; round++) {
198
+ const choose = e & f ^ ~e & g;
199
+ const majority = a & b ^ a & c ^ b & c;
200
+ const mixedE = rotate(e, 6) ^ rotate(e, 11) ^ rotate(e, 25);
201
+ const mixedA = rotate(a, 2) ^ rotate(a, 13) ^ rotate(a, 22);
202
+ const carried = h + mixedE + choose + ROUND_CONSTANTS[round] + schedule[round] | 0;
203
+ h = g;
204
+ g = f;
205
+ f = e;
206
+ e = d + carried | 0;
207
+ d = c;
208
+ c = b;
209
+ b = a;
210
+ a = carried + (mixedA + majority | 0) | 0;
211
+ }
212
+ const rolling = [a, b, c, d, e, f, g, h];
213
+ for (let at = 0; at < state.length; at++) state[at] = state[at] + rolling[at] | 0;
214
+ }
215
+ function asBytes(state) {
216
+ const digest = new Uint8Array(state.length * 4);
217
+ const writer = new DataView(digest.buffer);
218
+ for (let at = 0; at < state.length; at++) writer.setUint32(at * 4, state[at], false);
219
+ return digest;
220
+ }
221
+ function rotate(value, by) {
222
+ return value >>> by | value << 32 - by;
223
+ }
224
+
225
+ // ../core/bolt11.ts
226
+ var BECH32_CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l";
227
+ var CHECKSUM_WORDS = 6;
228
+ var TIMESTAMP_WORDS = 7;
229
+ var SIGNATURE_WORDS = 104;
230
+ var TAGGED_FIELD_LENGTH_WORDS = 2;
231
+ var PAYMENT_HASH_TAG = 1;
232
+ var PAYMENT_HASH_WORDS = 52;
233
+ var EXPIRY_TAG = 6;
234
+ var DESCRIPTION_HASH_TAG = 23;
235
+ var DESCRIPTION_HASH_WORDS = 52;
236
+ var DEFAULT_EXPIRY_SECS = 3600;
237
+ var MSAT_PER_BTC = 1e11;
238
+ var HRP_MULTIPLIER_MSAT = {
239
+ m: MSAT_PER_BTC / 1e3,
240
+ u: MSAT_PER_BTC / 1e6,
241
+ n: MSAT_PER_BTC / 1e9,
242
+ p: MSAT_PER_BTC / 1e12
243
+ };
244
+ var HRP_AMOUNT = /^ln[a-z]+?(\d+)([munp]?)$/;
245
+ function decodeInvoice(bolt11) {
246
+ const parts = splitBech32(bolt11);
247
+ if (parts === null) {
248
+ return { paymentHash: null, descriptionHash: null, amountMsat: null, expiresAt: null };
249
+ }
250
+ const tagged = taggedFields(parts.words);
251
+ return {
252
+ paymentHash: hexTag(tagged, PAYMENT_HASH_TAG, PAYMENT_HASH_WORDS),
253
+ descriptionHash: hexTag(tagged, DESCRIPTION_HASH_TAG, DESCRIPTION_HASH_WORDS),
254
+ amountMsat: amountFromHrp(parts.hrp),
255
+ expiresAt: expiryOf(parts.words, tagged)
256
+ };
257
+ }
258
+ function preimageMatchesHash(preimage, paymentHash) {
259
+ if (!isHex(preimage)) return false;
260
+ return bytesToHex(sha256(hexToBytes(preimage))) === paymentHash.toLowerCase();
261
+ }
262
+ function splitBech32(bolt11) {
263
+ const lower = bolt11.toLowerCase();
264
+ if (isBolt12Encoding(lower)) return null;
265
+ const separator = lower.lastIndexOf("1");
266
+ if (separator < 1) return null;
267
+ const dataChars = lower.slice(separator + 1);
268
+ if (dataChars.length < CHECKSUM_WORDS) return null;
269
+ const words = [];
270
+ for (const char of dataChars) {
271
+ const value = BECH32_CHARSET.indexOf(char);
272
+ if (value === -1) return null;
273
+ words.push(value);
274
+ }
275
+ return { hrp: lower.slice(0, separator), words: words.slice(0, -CHECKSUM_WORDS) };
276
+ }
277
+ function isBolt12Encoding(lower) {
278
+ return lower.startsWith("lno") || lower.startsWith("lni") || lower.startsWith("lnr");
279
+ }
280
+ function taggedFields(words) {
281
+ const fields = /* @__PURE__ */ new Map();
282
+ const signatureStart = words.length - SIGNATURE_WORDS;
283
+ let cursor = TIMESTAMP_WORDS;
284
+ while (cursor + 1 + TAGGED_FIELD_LENGTH_WORDS <= signatureStart) {
285
+ const lengthWords = words[cursor + 1] * 32 + words[cursor + 2];
286
+ const dataStart = cursor + 1 + TAGGED_FIELD_LENGTH_WORDS;
287
+ const dataEnd = dataStart + lengthWords;
288
+ if (dataEnd > signatureStart) return fields;
289
+ if (!fields.has(words[cursor])) fields.set(words[cursor], words.slice(dataStart, dataEnd));
290
+ cursor = dataEnd;
291
+ }
292
+ return fields;
293
+ }
294
+ function hexTag(fields, tag, expectedWords) {
295
+ const words = fields.get(tag);
296
+ if (words === void 0 || words.length !== expectedWords) return null;
297
+ return bytesToHex(wordsToBytes(words));
298
+ }
299
+ function expiryOf(words, fields) {
300
+ if (words.length < TIMESTAMP_WORDS + SIGNATURE_WORDS) return null;
301
+ const expiry = fields.get(EXPIRY_TAG);
302
+ return readNumber(words.slice(0, TIMESTAMP_WORDS)) + (expiry ? readNumber(expiry) : DEFAULT_EXPIRY_SECS);
303
+ }
304
+ function amountFromHrp(hrp) {
305
+ const match = HRP_AMOUNT.exec(hrp);
306
+ if (match === null) return null;
307
+ const [, digits, multiplier] = match;
308
+ return multiplier ? Number(digits) * HRP_MULTIPLIER_MSAT[multiplier] : Number(digits) * MSAT_PER_BTC;
309
+ }
310
+ function readNumber(words) {
311
+ return words.reduce((total, word) => total * 32 + word, 0);
312
+ }
313
+ function wordsToBytes(words) {
314
+ const bytes = [];
315
+ let accumulator = 0;
316
+ let bits = 0;
317
+ for (const word of words) {
318
+ accumulator = accumulator << 5 | word;
319
+ bits += 5;
320
+ if (bits >= 8) {
321
+ bits -= 8;
322
+ bytes.push(accumulator >>> bits & 255);
323
+ }
324
+ }
325
+ return Uint8Array.from(bytes);
326
+ }
327
+
328
+ // ../core/url.ts
329
+ var PRIVATE_SUFFIXES = ["localhost", "local", "internal", "lan", "arpa", "test", "invalid"];
330
+ function publicHttps(raw) {
331
+ let url;
332
+ try {
333
+ url = new URL(raw);
334
+ } catch {
335
+ return false;
336
+ }
337
+ return url.protocol === "https:" && hostIsPublic(url.hostname.toLowerCase());
338
+ }
339
+ function sameOrigin(one, other) {
340
+ try {
341
+ return new URL(one).origin === new URL(other).origin;
342
+ } catch {
343
+ return false;
344
+ }
345
+ }
346
+ function hostIsPublic(host) {
347
+ if (host.startsWith("[")) return ipv6IsGlobal(host.slice(1, -1));
348
+ const name = host.replace(/\.$/, "");
349
+ if (/^\d+\.\d+\.\d+\.\d+$/.test(name)) return ipv4IsGlobal(name);
350
+ const labels = name.split(".");
351
+ if (labels.length < 2 || labels.some((label) => label === "")) return false;
352
+ return !PRIVATE_SUFFIXES.includes(labels[labels.length - 1]);
353
+ }
354
+ function ipv4IsGlobal(literal) {
355
+ const parts = literal.split(".").map(Number);
356
+ if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) {
357
+ return false;
358
+ }
359
+ const [a, b] = parts;
360
+ if (a === 0 || a === 10 || a === 127 || a >= 240) return false;
361
+ if (a === 100 && b >= 64 && b < 128) return false;
362
+ if (a === 169 && b === 254) return false;
363
+ if (a === 172 && b >= 16 && b < 32) return false;
364
+ if (a === 192 && b === 168) return false;
365
+ return true;
366
+ }
367
+ function ipv6IsGlobal(literal) {
368
+ const groups = ipv6Groups(literal.toLowerCase());
369
+ if (groups.length !== 8 || groups.some(Number.isNaN)) return false;
370
+ const leading = groups.slice(0, 5).every((group) => group === 0);
371
+ if (leading && groups[5] === 0 && groups[6] === 0 && groups[7] <= 1) return false;
372
+ if (leading && groups[5] === 65535) {
373
+ const [high, low] = [groups[6], groups[7]];
374
+ return ipv4IsGlobal(`${high >> 8}.${high & 255}.${low >> 8}.${low & 255}`);
375
+ }
376
+ const first = groups[0];
377
+ if ((first & 65024) === 64512) return false;
378
+ if ((first & 65472) === 65152) return false;
379
+ return true;
380
+ }
381
+ function ipv6Groups(address) {
382
+ const [head = "", tail] = address.split("::");
383
+ const left = head.split(":").filter((group) => group !== "");
384
+ const right = (tail ?? "").split(":").filter((group) => group !== "");
385
+ const gap = tail === void 0 ? [] : Array(8 - left.length - right.length).fill("0");
386
+ return [...left, ...gap, ...right].map((group) => parseInt(group, 16));
387
+ }
388
+
389
+ // src/verify.ts
390
+ var HTTP_TIMEOUT_MS = 15e3;
391
+ async function proveOrigin(payment, request) {
392
+ const cheat = (code) => new GatewayCheatError(code, payment.id);
393
+ const listed = request.lnAddresses.find(
394
+ (address) => equalIgnoringCase(address, payment.lnAddress)
395
+ );
396
+ if (listed === void 0) throw cheat("address_not_requested");
397
+ if (payment.amountMsat !== request.amountMsat) throw cheat("amount_mismatch");
398
+ const invoice = decodeInvoice(payment.bolt11);
399
+ if (invoice.paymentHash === null || !equalIgnoringCase(invoice.paymentHash, payment.paymentHash)) {
400
+ throw cheat("hash_mismatch");
401
+ }
402
+ if (invoice.amountMsat !== request.amountMsat) throw cheat("amount_mismatch");
403
+ const payRequest = await payRequestFor(listed, payment);
404
+ if (typeof payRequest.metadata !== "string" || typeof payRequest.callback !== "string") {
405
+ throw new UnverifiedRecipientError(payment.lnAddress, payment.id, "no payRequest served");
406
+ }
407
+ if (invoice.descriptionHash !== sha256Hex(payRequest.metadata)) {
408
+ throw cheat("description_hash_mismatch");
409
+ }
410
+ if (!sameOrigin(payment.verifyUrl, payRequest.callback)) throw cheat("verify_url_foreign");
411
+ const issued = await reachable(payment.verifyUrl, payment);
412
+ if (typeof issued.pr !== "string" || !equalIgnoringCase(issued.pr, payment.bolt11)) {
413
+ throw cheat("invoice_not_issued");
414
+ }
415
+ }
416
+ async function proveSettlement(payment, request) {
417
+ await proveOrigin(payment, request);
418
+ const verified = await reachable(payment.verifyUrl, payment);
419
+ if (verified.settled !== true || typeof verified.preimage !== "string") return null;
420
+ if (!preimageMatchesHash(verified.preimage, payment.paymentHash)) {
421
+ throw new GatewayCheatError("preimage_mismatch", payment.id);
422
+ }
423
+ return verified.preimage;
424
+ }
425
+ function isProvablyPaid(payment) {
426
+ if (payment.status !== "paid" || payment.preimage === null) return false;
427
+ const invoiceHash = decodeInvoice(payment.bolt11).paymentHash;
428
+ if (invoiceHash === null || !equalIgnoringCase(invoiceHash, payment.paymentHash)) return false;
429
+ return preimageMatchesHash(payment.preimage, invoiceHash);
430
+ }
431
+ async function payRequestFor(listed, payment) {
432
+ const at = listed.indexOf("@");
433
+ const name = listed.slice(0, at);
434
+ const domain = listed.slice(at + 1).toLowerCase();
435
+ if (at < 1 || !publicHttps(`https://${domain}/`)) {
436
+ throw new UnverifiedRecipientError(payment.lnAddress, payment.id, "not a usable address");
437
+ }
438
+ return reachable(`https://${domain}/.well-known/lnurlp/${name}`, payment);
439
+ }
440
+ async function reachable(url, payment) {
441
+ try {
442
+ if (!publicHttps(url)) throw new Error(`${url} is not a public https URL`);
443
+ const response = await fetch(url, {
444
+ signal: AbortSignal.timeout(HTTP_TIMEOUT_MS),
445
+ headers: { accept: "application/json" }
446
+ });
447
+ if (!response.ok) throw new Error(`answered ${response.status}`);
448
+ const body = await response.json();
449
+ if (body === null || typeof body !== "object") throw new Error("answered with no JSON object");
450
+ return body;
451
+ } catch (cause) {
452
+ throw new UnverifiedRecipientError(payment.lnAddress, payment.id, cause);
453
+ }
454
+ }
455
+ function equalIgnoringCase(one, other) {
456
+ return one.toLowerCase() === other.toLowerCase();
457
+ }
458
+
459
+ // src/wire.ts
460
+ var ASSET_CODE = "BTC";
461
+ var ASSET_SCALE = 11;
462
+ var REASONS = /* @__PURE__ */ new Set([
463
+ "address-unusable",
464
+ "unreachable",
465
+ "amount-not-accepted",
466
+ "cannot-prove-delivery",
467
+ "invoice-refused"
468
+ ]);
469
+ function createRequestBody(params, trigger) {
470
+ return JSON.stringify({
471
+ ln_addresses: params.lnAddresses,
472
+ incoming_amount: toAmount(params.amountMsat),
473
+ webhook: params.webhookUrl ? { url: params.webhookUrl, secret: params.webhookSecret } : void 0,
474
+ trigger: trigger ?? void 0
475
+ });
476
+ }
477
+ function quoteRequestBody(params) {
478
+ return JSON.stringify({
479
+ ln_addresses: params.lnAddresses,
480
+ amount: toAmount(params.amountMsat)
481
+ });
482
+ }
483
+ function quoteFromWire(body) {
484
+ const wire = asObject(body);
485
+ if (wire === null) return null;
486
+ const lnAddress = text(wire["ln_address"]);
487
+ const amountMsat = msatFrom(wire["amount"], 1);
488
+ const feeMsat = msatFrom(wire["fee"], 0);
489
+ const minMsat = msatFrom(wire["min_amount"], 0);
490
+ const maxMsat = msatFrom(wire["max_amount"], 0);
491
+ const metadata = text(wire["metadata"]);
492
+ const refusals2 = refusalsFrom(wire["refusals"]);
493
+ if (lnAddress === null || amountMsat === null || feeMsat === null) return null;
494
+ if (minMsat === null || maxMsat === null || metadata === null || refusals2 === null) return null;
495
+ return { lnAddress, amountMsat, feeMsat, minMsat, maxMsat, metadata, refusals: refusals2 };
496
+ }
497
+ function paymentFromWire(body) {
498
+ const wire = asObject(body);
499
+ if (wire === null) return null;
500
+ const id = text(wire["id"]);
501
+ const lnAddress = text(wire["ln_address"]);
502
+ const bolt11 = text(wire["bolt11"]);
503
+ const paymentHash = text(wire["payment_hash"]);
504
+ const verifyUrl = text(wire["verify_url"]);
505
+ const amountMsat = msatFrom(wire["incoming_amount"], 1);
506
+ const expiresAt = secondsFrom(wire["expires_at"]);
507
+ const createdAt = secondsFrom(wire["created_at"]);
508
+ const status = wire["status"];
509
+ const preimage = wire["preimage"] ?? null;
510
+ if (id === null || lnAddress === null || bolt11 === null) return null;
511
+ if (paymentHash === null || verifyUrl === null) return null;
512
+ if (amountMsat === null || expiresAt === null || createdAt === null) return null;
513
+ if (!isStatus(status)) return null;
514
+ if (preimage !== null && typeof preimage !== "string") return null;
515
+ return {
516
+ id,
517
+ lnAddress,
518
+ amountMsat,
519
+ status,
520
+ paymentHash,
521
+ bolt11,
522
+ preimage,
523
+ expiresAt,
524
+ createdAt,
525
+ verifyUrl
526
+ };
527
+ }
528
+ function watchRequestBody(params, trigger) {
529
+ const expiresAt = new Date(params.expiresAt * 1e3);
530
+ if (Number.isNaN(expiresAt.getTime())) {
531
+ throw new TypeError(`expiresAt must be a usable unix time in seconds, got ${params.expiresAt}`);
532
+ }
533
+ return JSON.stringify({
534
+ payment_hash: params.paymentHash,
535
+ verify_url: params.verifyUrl,
536
+ expires_at: expiresAt.toISOString(),
537
+ trigger: trigger ?? void 0,
538
+ sealed: params.sealed
539
+ });
540
+ }
541
+ function triggerEventFromWire(body) {
542
+ const wire = asObject(body);
543
+ if (wire === null) return null;
544
+ const id = text(wire["id"]);
545
+ const paymentHash = text(wire["payment_hash"]);
546
+ const verifyUrl = text(wire["verify_url"]);
547
+ const expiresAt = secondsFrom(wire["expires_at"]);
548
+ const createdAt = secondsFrom(wire["created_at"]);
549
+ const status = wire["status"];
550
+ const preimage = wire["preimage"] ?? null;
551
+ const sealed = wire["sealed"] ?? null;
552
+ if (id === null || paymentHash === null || verifyUrl === null) return null;
553
+ if (expiresAt === null || createdAt === null || !isStatus(status)) return null;
554
+ if (preimage !== null && typeof preimage !== "string") return null;
555
+ if (sealed !== null && typeof sealed !== "string") return null;
556
+ return {
557
+ id,
558
+ paymentHash,
559
+ verifyUrl,
560
+ status,
561
+ preimage,
562
+ expiresAt,
563
+ createdAt,
564
+ sealed,
565
+ lnAddress: text(wire["ln_address"]),
566
+ amountMsat: wire["incoming_amount"] === void 0 ? null : msatFrom(wire["incoming_amount"], 1)
567
+ };
568
+ }
569
+ function toAmount(msat) {
570
+ return { value: String(msat), asset_code: ASSET_CODE, asset_scale: ASSET_SCALE };
571
+ }
572
+ function asObject(value) {
573
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
574
+ return value;
575
+ }
576
+ function text(value) {
577
+ return typeof value === "string" ? value : null;
578
+ }
579
+ function msatFrom(value, least) {
580
+ const amount = asObject(value);
581
+ if (amount === null) return null;
582
+ if (amount["asset_code"] !== ASSET_CODE || amount["asset_scale"] !== ASSET_SCALE) return null;
583
+ const digits = amount["value"];
584
+ if (typeof digits !== "string" || !/^[0-9]+$/.test(digits)) return null;
585
+ const msat = Number(digits);
586
+ return Number.isSafeInteger(msat) && msat >= least ? msat : null;
587
+ }
588
+ function refusalsFrom(value) {
589
+ if (!Array.isArray(value)) return null;
590
+ const refusals2 = [];
591
+ for (const entry of value) {
592
+ const failure = asObject(entry);
593
+ const address = failure === null ? null : text(failure["address"]);
594
+ const reason = failure?.["reason"];
595
+ if (address === null || typeof reason !== "string" || !REASONS.has(reason)) return null;
596
+ refusals2.push({ address, reason });
597
+ }
598
+ return refusals2;
599
+ }
600
+ function secondsFrom(value) {
601
+ if (typeof value !== "string") return null;
602
+ const milliseconds = Date.parse(value);
603
+ return Number.isNaN(milliseconds) ? null : Math.floor(milliseconds / 1e3);
604
+ }
605
+ function isStatus(value) {
606
+ return value === "pending" || value === "paid" || value === "expired";
607
+ }
608
+
609
+ // src/client.ts
610
+ var TERMINAL = /* @__PURE__ */ new Set(["paid", "expired"]);
611
+ var RECONNECT_DELAY_MS = 3e3;
612
+ var RECONNECT_CAP_MS = 3e4;
613
+ var UNANSWERED_ATTEMPTS = 5;
614
+ function backoffMs(firstDelay, attempt) {
615
+ const grown = Math.min(firstDelay * 2 ** (attempt - 1), RECONNECT_CAP_MS);
616
+ return Math.round(grown * (0.5 + Math.random() / 2));
617
+ }
618
+ var ThunderBridge = class {
619
+ baseUrl;
620
+ verify;
621
+ token;
622
+ constructor(baseUrl, options) {
623
+ this.baseUrl = baseUrl.replace(/\/+$/, "");
624
+ this.verify = options?.verify ?? true;
625
+ this.token = options?.token ?? null;
626
+ }
627
+ /**
628
+ * Ask the gateway for an invoice payable to the first address on your list
629
+ * that can issue a provable one, throws `NoWalletAvailableError` when none can
630
+ * and `GatewayCheatError` when what comes back is not what you asked for
631
+ */
632
+ async createPayment(params, options) {
633
+ const headers = this.sending();
634
+ if (options?.idempotencyKey) headers["idempotency-key"] = options.idempotencyKey;
635
+ const response = await fetch(`${this.baseUrl}/incoming-payments`, {
636
+ method: "POST",
637
+ headers,
638
+ body: createRequestBody(params, options?.trigger ? sha256Hex(options.trigger) : null)
639
+ });
640
+ if (!response.ok) throw await problemFrom(response);
641
+ const payment = await paymentFrom(response);
642
+ if (this.verify) await proveOrigin(payment, params);
643
+ return payment;
644
+ }
645
+ /**
646
+ * Ask which address would serve an amount without minting anything, throws
647
+ * `NoWalletAvailableError` when none would. A quote is a probe and not a
648
+ * promise: the address it names can still be refused at create time, because
649
+ * whether a wallet returns a provable invoice cannot be known without asking
650
+ * it for one, and asking mints it
651
+ */
652
+ async createQuote(params) {
653
+ const response = await fetch(`${this.baseUrl}/quotes`, {
654
+ method: "POST",
655
+ headers: this.sending(),
656
+ body: quoteRequestBody(params)
657
+ });
658
+ if (!response.ok) throw await problemFrom(response);
659
+ const quote = quoteFromWire(await response.json().catch(() => null));
660
+ if (quote === null) {
661
+ throw new ProblemError({
662
+ status: response.status,
663
+ title: "The gateway answered with something that is not a quote"
664
+ });
665
+ }
666
+ return quote;
667
+ }
668
+ /** Read a payment back, null when the gateway has never heard of it */
669
+ async getPayment(id) {
670
+ const response = await fetch(`${this.baseUrl}/incoming-payments/${encodeURIComponent(id)}`, {
671
+ headers: this.reading()
672
+ });
673
+ if (response.status === 404) return null;
674
+ if (!response.ok) throw await problemFrom(response);
675
+ return this.checked(await paymentFrom(response));
676
+ }
677
+ /**
678
+ * List what this gateway is watching, newest first. Only a gateway started
679
+ * with `GATEWAY_TOKEN` serves this, because on a shared one it would hand
680
+ * every caller everyone else's payments, so a public gateway answers 404.
681
+ *
682
+ * `scanned` says how many settled records were looked at to build the page.
683
+ * Anything older than that window is not in the answer, and the list does not
684
+ * pretend otherwise
685
+ */
686
+ async listPayments(limit) {
687
+ const query = limit === void 0 ? "" : `?limit=${limit}`;
688
+ const response = await fetch(`${this.baseUrl}/incoming-payments${query}`, {
689
+ headers: this.reading()
690
+ });
691
+ if (!response.ok) throw await problemFrom(response);
692
+ const body = await response.json().catch(() => null);
693
+ const listed = Array.isArray(body?.payments) ? body.payments.map(triggerEventFromWire).filter((one) => one !== null) : null;
694
+ if (listed === null || typeof body?.settled_scanned !== "number") {
695
+ throw new ProblemError({
696
+ status: response.status,
697
+ title: "The gateway answered with something that is not a payment list"
698
+ });
699
+ }
700
+ return { payments: listed, scanned: body.settled_scanned };
701
+ }
702
+ /**
703
+ * Follow a payment over WebSocket until it is paid or expired, reconnecting
704
+ * through a drop. A payment that never answers gives up after a few tries, and
705
+ * one that has answered is followed until its own expiry, so the wait always
706
+ * ends by itself
707
+ */
708
+ waitForPayment(id, options) {
709
+ const base = this.baseUrl.replace(/^http/, "ws");
710
+ const direct = `${base}/ws/incoming-payments/${encodeURIComponent(id)}`;
711
+ return new Promise((resolve2, reject) => {
712
+ const aborted = () => new Error(`waiting for payment ${id} was aborted`);
713
+ if (options?.signal?.aborted) {
714
+ reject(aborted());
715
+ return;
716
+ }
717
+ let socket = null;
718
+ let settled = false;
719
+ let attempt = 0;
720
+ let expiresAt = null;
721
+ let retry;
722
+ const abort = () => settle(() => reject(aborted()));
723
+ const settle = (finish) => {
724
+ if (settled) return;
725
+ settled = true;
726
+ options?.signal?.removeEventListener("abort", abort);
727
+ clearTimeout(retry);
728
+ finish();
729
+ socket?.close();
730
+ };
731
+ const again = () => {
732
+ if (settled) return;
733
+ if (expiresAt === null && attempt >= UNANSWERED_ATTEMPTS) {
734
+ settle(() => reject(new Error(`no gateway answered for payment ${id}`)));
735
+ return;
736
+ }
737
+ if (expiresAt !== null && Date.now() >= expiresAt * 1e3) {
738
+ settle(() => reject(new Error(`payment ${id} went unreported past its expiry`)));
739
+ return;
740
+ }
741
+ retry = setTimeout(() => void connect(), backoffMs(RECONNECT_DELAY_MS, attempt));
742
+ };
743
+ const connect = async () => {
744
+ if (settled) return;
745
+ attempt += 1;
746
+ let url = direct;
747
+ if (this.needsTicket(options?.tickets)) {
748
+ try {
749
+ url = `${base}/ws/tickets/${await this.wsTicket({ payment_id: id })}`;
750
+ } catch (refused) {
751
+ if (refused instanceof ProblemError) settle(() => reject(refused));
752
+ else again();
753
+ return;
754
+ }
755
+ }
756
+ if (settled) return;
757
+ const opened = new WebSocket(url);
758
+ socket = opened;
759
+ opened.onmessage = (event) => {
760
+ try {
761
+ const payment = paymentFromWire(JSON.parse(String(event.data)));
762
+ if (payment === null) return;
763
+ expiresAt = payment.expiresAt;
764
+ attempt = 1;
765
+ if (!TERMINAL.has(payment.status)) return;
766
+ const checked = this.checked(payment);
767
+ settle(() => resolve2(checked));
768
+ } catch (refused) {
769
+ settle(() => reject(refused));
770
+ }
771
+ };
772
+ opened.onclose = again;
773
+ };
774
+ options?.signal?.addEventListener("abort", abort, { once: true });
775
+ void connect();
776
+ });
777
+ }
778
+ /**
779
+ * Hand over an invoice you obtained yourself so the gateway watches it without
780
+ * being told the address or the amount. It can then only refuse everyone
781
+ * rather than one recipient, which is what makes leaving it cheap. Anything
782
+ * the watcher needs goes in `sealed`, which the gateway cannot read
783
+ */
784
+ async watchPayment(params) {
785
+ const response = await fetch(`${this.baseUrl}/watched-payments`, {
786
+ method: "POST",
787
+ headers: this.sending(),
788
+ body: watchRequestBody(params, params.trigger ? sha256Hex(params.trigger) : null)
789
+ });
790
+ if (!response.ok) throw await problemFrom(response);
791
+ const watched = triggerEventFromWire(await response.json().catch(() => null));
792
+ if (watched === null) {
793
+ throw new ProblemError({
794
+ status: response.status,
795
+ title: "The gateway answered with something that is not a watched payment"
796
+ });
797
+ }
798
+ return watched;
799
+ }
800
+ /**
801
+ * Follow every payment made to one trigger, replayed from the recent ones on
802
+ * connect and then live, reconnecting on its own until the returned function
803
+ * is called. A trigger has no terminal state, so this never resolves
804
+ */
805
+ followTrigger(secret, options) {
806
+ const base = this.baseUrl.replace(/^http/, "ws");
807
+ const direct = `${base}/ws/triggers/${encodeURIComponent(secret)}`;
808
+ const firstDelay = options.reconnectDelayMs ?? RECONNECT_DELAY_MS;
809
+ let socket = null;
810
+ let retry;
811
+ let stopped = false;
812
+ let attempt = 0;
813
+ const again = () => {
814
+ if (stopped || options.reconnect === false) return;
815
+ retry = setTimeout(() => void connect(), backoffMs(firstDelay, attempt));
816
+ };
817
+ const connect = async () => {
818
+ attempt += 1;
819
+ let url = direct;
820
+ if (this.needsTicket(options.tickets)) {
821
+ try {
822
+ url = `${base}/ws/tickets/${await this.wsTicket({ trigger_secret: secret })}`;
823
+ } catch (refused) {
824
+ options.onError?.(refused);
825
+ again();
826
+ return;
827
+ }
828
+ }
829
+ if (stopped) return;
830
+ socket = new WebSocket(url);
831
+ socket.onopen = () => {
832
+ attempt = 1;
833
+ };
834
+ socket.onmessage = (event) => {
835
+ try {
836
+ const settled = triggerEventFromWire(JSON.parse(String(event.data)));
837
+ if (settled !== null) options.onPayment(this.proven(settled));
838
+ } catch (refused) {
839
+ options.onError?.(refused);
840
+ }
841
+ };
842
+ socket.onerror = () => options.onError?.(new Error(`could not follow trigger at ${base}`));
843
+ socket.onclose = again;
844
+ };
845
+ void connect();
846
+ return () => {
847
+ stopped = true;
848
+ clearTimeout(retry);
849
+ socket?.close();
850
+ };
851
+ }
852
+ needsTicket(asked) {
853
+ return asked === true || this.token !== null;
854
+ }
855
+ async wsTicket(body) {
856
+ const response = await fetch(`${this.baseUrl}/ws-tickets`, {
857
+ method: "POST",
858
+ headers: this.sending(),
859
+ body: JSON.stringify(body)
860
+ });
861
+ if (!response.ok) throw await problemFrom(response);
862
+ const minted = await response.json().catch(() => null);
863
+ if (typeof minted?.ticket !== "string") {
864
+ throw new ProblemError({
865
+ status: response.status,
866
+ title: "The gateway answered with something that is not a ticket"
867
+ });
868
+ }
869
+ return encodeURIComponent(minted.ticket);
870
+ }
871
+ sending() {
872
+ return { "content-type": "application/json", ...this.reading() };
873
+ }
874
+ reading() {
875
+ return this.token === null ? {} : { authorization: `Bearer ${this.token}` };
876
+ }
877
+ proven(settled) {
878
+ const unproven = settled.status === "paid" && (settled.preimage === null || !preimageMatchesHash(settled.preimage, settled.paymentHash));
879
+ if (this.verify && unproven) throw new GatewayCheatError("preimage_mismatch", settled.id);
880
+ return settled;
881
+ }
882
+ checked(payment) {
883
+ if (this.verify && payment.status === "paid" && !isProvablyPaid(payment)) {
884
+ throw new GatewayCheatError("preimage_mismatch", payment.id);
885
+ }
886
+ return payment;
887
+ }
888
+ };
889
+ async function paymentFrom(response) {
890
+ const payment = paymentFromWire(await response.json().catch(() => null));
891
+ if (payment === null) {
892
+ throw new ProblemError({
893
+ status: response.status,
894
+ title: "The gateway answered with something that is not a payment"
895
+ });
896
+ }
897
+ return payment;
898
+ }
899
+ async function problemFrom(response) {
900
+ const problem = await response.json().catch(() => ({}));
901
+ const document = { ...problem, status: response.status };
902
+ if (problem.type === NO_WALLET_AVAILABLE) {
903
+ return new NoWalletAvailableError(document, refusals(problem.wallets));
904
+ }
905
+ if (problem.type === REQUEST_IN_FLIGHT) {
906
+ return new IdempotencyConflictError(document, "request-in-flight");
907
+ }
908
+ if (problem.type === IDEMPOTENCY_KEY_REUSED) {
909
+ return new IdempotencyConflictError(document, "key-reused");
910
+ }
911
+ return new ProblemError(document);
912
+ }
913
+ function refusals(wallets) {
914
+ if (!Array.isArray(wallets)) return [];
915
+ return wallets.filter((wallet) => {
916
+ return typeof wallet === "object" && wallet !== null && "address" in wallet;
917
+ });
918
+ }
919
+
920
+ // ../core/sealed.ts
921
+ var VERSION = "v1";
922
+ var IV_BYTES = 12;
923
+ var MIN_SECRET_CHARS = 32;
924
+ var MAX_PLAIN_BYTES = 3e3;
925
+ var INFO = new TextEncoder().encode("thunder-bridge-direct/sealed");
926
+ async function seal(secret, plaintext) {
927
+ const body = new TextEncoder().encode(plaintext);
928
+ if (body.length > MAX_PLAIN_BYTES) {
929
+ throw new Error(`sealed takes at most ${MAX_PLAIN_BYTES} bytes, this was ${body.length}`);
930
+ }
931
+ const key = await keyFor(secret);
932
+ const iv = crypto.getRandomValues(new Uint8Array(IV_BYTES));
933
+ const cipher = new Uint8Array(await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, body));
934
+ const joined = new Uint8Array(iv.length + cipher.length);
935
+ joined.set(iv);
936
+ joined.set(cipher, iv.length);
937
+ return `${VERSION}.${toBase64Url(joined)}`;
938
+ }
939
+ async function unseal(secret, sealed) {
940
+ const key = await keyFor(secret);
941
+ if (!sealed.startsWith(`${VERSION}.`)) return null;
942
+ const joined = fromBase64Url(sealed.slice(VERSION.length + 1));
943
+ if (joined === null || joined.length <= IV_BYTES) return null;
944
+ try {
945
+ const body = await crypto.subtle.decrypt(
946
+ { name: "AES-GCM", iv: joined.slice(0, IV_BYTES) },
947
+ key,
948
+ joined.slice(IV_BYTES)
949
+ );
950
+ return new TextDecoder().decode(body);
951
+ } catch {
952
+ return null;
953
+ }
954
+ }
955
+ async function keyFor(secret) {
956
+ if (secret.length < MIN_SECRET_CHARS) {
957
+ throw new Error(`the sealing secret needs ${MIN_SECRET_CHARS} characters of randomness`);
958
+ }
959
+ const material = await crypto.subtle.importKey(
960
+ "raw",
961
+ new TextEncoder().encode(secret),
962
+ "HKDF",
963
+ false,
964
+ ["deriveKey"]
965
+ );
966
+ return crypto.subtle.deriveKey(
967
+ { name: "HKDF", hash: "SHA-256", salt: new Uint8Array(0), info: INFO },
968
+ material,
969
+ { name: "AES-GCM", length: 256 },
970
+ false,
971
+ ["encrypt", "decrypt"]
972
+ );
973
+ }
974
+ function toBase64Url(bytes) {
975
+ let binary = "";
976
+ for (const byte of bytes) binary += String.fromCharCode(byte);
977
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
978
+ }
979
+ function fromBase64Url(text2) {
980
+ if (text2.length === 0 || !/^[A-Za-z0-9_-]+$/.test(text2)) return null;
981
+ try {
982
+ const binary = atob(text2.replace(/-/g, "+").replace(/_/g, "/"));
983
+ const bytes = new Uint8Array(binary.length);
984
+ for (let at = 0; at < bytes.length; at++) bytes[at] = binary.charCodeAt(at);
985
+ return bytes;
986
+ } catch {
987
+ return null;
988
+ }
989
+ }
990
+
991
+ // ../core/hmac.ts
992
+ async function hmacHex(secret, payload) {
993
+ const raw = typeof secret === "string" ? new TextEncoder().encode(secret) : secret;
994
+ const key = await crypto.subtle.importKey(
995
+ "raw",
996
+ asBuffer(raw),
997
+ { name: "HMAC", hash: "SHA-256" },
998
+ false,
999
+ ["sign"]
1000
+ );
1001
+ const message = typeof payload === "string" ? new TextEncoder().encode(payload) : payload;
1002
+ return bytesToHex(new Uint8Array(await crypto.subtle.sign("HMAC", key, asBuffer(message))));
1003
+ }
1004
+ function equalInConstantTime(one, other) {
1005
+ if (one.length !== other.length) return false;
1006
+ let difference = 0;
1007
+ for (let at = 0; at < one.length; at++) difference |= one.charCodeAt(at) ^ other.charCodeAt(at);
1008
+ return difference === 0;
1009
+ }
1010
+ function asBuffer(bytes) {
1011
+ return bytes instanceof Uint8Array && bytes.buffer instanceof ArrayBuffer ? bytes : new Uint8Array(bytes);
1012
+ }
1013
+
1014
+ // ../core/refusal.ts
1015
+ var WalletRefused = class extends Error {
1016
+ reason;
1017
+ constructor(reason, message) {
1018
+ super(message);
1019
+ this.reason = reason;
1020
+ }
1021
+ };
1022
+ var NoWalletAvailable = class extends Error {
1023
+ wallets;
1024
+ constructor(wallets) {
1025
+ super(wallets.map((wallet) => `${wallet.address} ${wallet.reason}`).join(", "));
1026
+ this.wallets = wallets;
1027
+ }
1028
+ };
1029
+
1030
+ // ../core/lnurl.ts
1031
+ var VERIFY_WITHOUT_PREIMAGE = ["zeuspay.com", "zeusnuts.com", "ecash.love"];
1032
+ var HTTP_TIMEOUT_MS2 = 15e3;
1033
+ var BECH32_CHARSET2 = "qpzry9x8gf2tvdw0s3jn54khce6mua7l";
1034
+ var BECH32_GENERATOR = [996825010, 642813549, 513874426, 1027748829, 705979059];
1035
+ var CHECKSUM_SLOTS = [0, 1, 2, 3, 4, 5];
1036
+ var LNURL_HRP = "lnurl";
1037
+ var RESOLVE_TIMEOUT_MS = 3e4;
1038
+ async function resolve(addresses, amountMsat) {
1039
+ const served = await firstThatServes(
1040
+ addresses,
1041
+ (address, deadline) => resolveAddress(address, amountMsat, deadline)
1042
+ );
1043
+ return served.won;
1044
+ }
1045
+ async function firstThatServes(addresses, attempt) {
1046
+ const deadline = AbortSignal.timeout(RESOLVE_TIMEOUT_MS);
1047
+ const refusals2 = [];
1048
+ for (const address of addresses) {
1049
+ if (deadline.aborted) break;
1050
+ try {
1051
+ return { won: await attempt(address, deadline), refusals: refusals2 };
1052
+ } catch (refusal) {
1053
+ if (!(refusal instanceof WalletRefused)) throw refusal;
1054
+ console.warn(`wallet ${address} refused, ${refusal.reason}: ${refusal.message}`);
1055
+ refusals2.push({ address, reason: refusal.reason });
1056
+ }
1057
+ }
1058
+ throw new NoWalletAvailable(refusals2);
1059
+ }
1060
+ async function probe(address, amountMsat, deadline) {
1061
+ const [user, domain] = splitAddress(address);
1062
+ if (cannotReleaseAPreimage(domain)) {
1063
+ throw new WalletRefused("cannot-prove-delivery", `${address} never releases a preimage`);
1064
+ }
1065
+ const pay = await answered(
1066
+ `https://${domain}/.well-known/lnurlp/${user}`,
1067
+ deadline
1068
+ );
1069
+ if (pay.tag !== "payRequest" || !pay.callback) {
1070
+ throw new WalletRefused("unreachable", `${address} answered with no payRequest`);
1071
+ }
1072
+ if (amountMsat < pay.minSendable || amountMsat > pay.maxSendable) {
1073
+ throw new WalletRefused(
1074
+ "amount-not-accepted",
1075
+ `${address} takes [${pay.minSendable}, ${pay.maxSendable}] msat, not ${amountMsat}`
1076
+ );
1077
+ }
1078
+ return { address, pay };
1079
+ }
1080
+ async function resolveAddress(address, amountMsat, deadline) {
1081
+ const { pay } = await probe(address, amountMsat, deadline);
1082
+ const invoice = await answered(withAmount(pay.callback, amountMsat), deadline);
1083
+ if (!invoice.pr) throw new WalletRefused("unreachable", `${address} returned no invoice`);
1084
+ if (!invoice.verify || !publicHttps(invoice.verify)) {
1085
+ throw new WalletRefused(
1086
+ "cannot-prove-delivery",
1087
+ `${address} publishes no usable LUD-21 verify URL`
1088
+ );
1089
+ }
1090
+ const issued = decodeIssued(address, invoice.pr);
1091
+ if (issued.amountMsat !== amountMsat) {
1092
+ throw new WalletRefused(
1093
+ "invoice-refused",
1094
+ `${address} issued an invoice for ${issued.amountMsat} msat, not ${amountMsat}`
1095
+ );
1096
+ }
1097
+ if (issued.descriptionHash !== sha256Hex(pay.metadata)) {
1098
+ throw new WalletRefused(
1099
+ "invoice-refused",
1100
+ `${address} does not bind its invoice to the metadata it serves`
1101
+ );
1102
+ }
1103
+ return {
1104
+ address,
1105
+ bolt11: invoice.pr,
1106
+ verifyUrl: invoice.verify,
1107
+ paymentHash: issued.paymentHash,
1108
+ expiresAt: issued.expiresAt
1109
+ };
1110
+ }
1111
+ async function answered(url, deadline) {
1112
+ try {
1113
+ return await fetchJson(url, deadline);
1114
+ } catch (cause) {
1115
+ throw new WalletRefused("unreachable", String(cause));
1116
+ }
1117
+ }
1118
+ function decodeIssued(address, bolt11) {
1119
+ const { paymentHash, descriptionHash, amountMsat, expiresAt } = decodeInvoice(bolt11);
1120
+ if (paymentHash === null || expiresAt === null) {
1121
+ throw new WalletRefused("invoice-refused", `${address} issued an invoice we cannot decode`);
1122
+ }
1123
+ return { paymentHash, descriptionHash, amountMsat, expiresAt };
1124
+ }
1125
+ function cannotReleaseAPreimage(host) {
1126
+ const lowered = host.toLowerCase();
1127
+ return VERIFY_WITHOUT_PREIMAGE.some(
1128
+ (known) => lowered === known || lowered.endsWith(`.${known}`)
1129
+ );
1130
+ }
1131
+ function toLnurl(endpoint) {
1132
+ const scheme = parsedScheme(endpoint);
1133
+ if (scheme !== "https:" && scheme !== "http:") {
1134
+ throw new Error(`${endpoint} is not an http or https URL`);
1135
+ }
1136
+ const words = toWords(new TextEncoder().encode(endpoint));
1137
+ const data = [...words, ...checksumWords(LNURL_HRP, words)];
1138
+ return `${LNURL_HRP}1${data.map((word) => BECH32_CHARSET2[word]).join("")}`.toUpperCase();
1139
+ }
1140
+ function parsedScheme(endpoint) {
1141
+ try {
1142
+ return new URL(endpoint).protocol;
1143
+ } catch {
1144
+ return null;
1145
+ }
1146
+ }
1147
+ function toWords(bytes) {
1148
+ const words = [];
1149
+ let accumulator = 0;
1150
+ let bits = 0;
1151
+ for (const byte of bytes) {
1152
+ accumulator = accumulator << 8 | byte;
1153
+ bits += 8;
1154
+ while (bits >= 5) {
1155
+ bits -= 5;
1156
+ words.push(accumulator >> bits & 31);
1157
+ }
1158
+ }
1159
+ if (bits > 0) words.push(accumulator << 5 - bits & 31);
1160
+ return words;
1161
+ }
1162
+ function checksumWords(hrp, words) {
1163
+ const expanded = [...hrp].map((char) => char.charCodeAt(0));
1164
+ const values = [
1165
+ ...expanded.map((code) => code >> 5),
1166
+ 0,
1167
+ ...expanded.map((code) => code & 31),
1168
+ ...words,
1169
+ ...CHECKSUM_SLOTS.map(() => 0)
1170
+ ];
1171
+ const polymod = bech32Polymod(values) ^ 1;
1172
+ return CHECKSUM_SLOTS.map((slot) => polymod >> 5 * (5 - slot) & 31);
1173
+ }
1174
+ function bech32Polymod(values) {
1175
+ let checksum = 1;
1176
+ for (const value of values) {
1177
+ const top = checksum >> 25;
1178
+ checksum = (checksum & 33554431) << 5 ^ value;
1179
+ for (let bit = 0; bit < 5; bit++) {
1180
+ if (top >> bit & 1) checksum ^= BECH32_GENERATOR[bit];
1181
+ }
1182
+ }
1183
+ return checksum;
1184
+ }
1185
+ function withAmount(callback, amountMsat) {
1186
+ const separator = callback.includes("?") ? "&" : "?";
1187
+ return `${callback}${separator}amount=${amountMsat}`;
1188
+ }
1189
+ function splitAddress(address) {
1190
+ const [user, domain, ...rest] = address.split("@");
1191
+ if (!user || !domain || rest.length > 0 || !publicHttps(`https://${domain}/`)) {
1192
+ throw new WalletRefused("address-unusable", `${address} is not a usable lightning address`);
1193
+ }
1194
+ return [user, domain];
1195
+ }
1196
+ async function fetchJson(url, deadline) {
1197
+ if (!publicHttps(url)) throw new Error(`${url} is not a public https URL`);
1198
+ const capped = AbortSignal.timeout(HTTP_TIMEOUT_MS2);
1199
+ const response = await fetch(url, {
1200
+ signal: deadline ? AbortSignal.any([deadline, capped]) : capped,
1201
+ headers: { accept: "application/json" }
1202
+ });
1203
+ if (!response.ok) throw new Error(`${url} answered ${response.status}`);
1204
+ return await response.json();
1205
+ }
1206
+
1207
+ // src/trigger.ts
1208
+ var NONCE_BYTES = 16;
1209
+ function lnurlPayEndpoint(config) {
1210
+ return async (request) => {
1211
+ const url = new URL(request.url);
1212
+ const asked = url.searchParams.get("to");
1213
+ try {
1214
+ return asked === null ? await offer(config, url) : await mint(config, url, asked);
1215
+ } catch (failure) {
1216
+ return refuse(failure instanceof Error ? failure.message : "the trigger could not be served");
1217
+ }
1218
+ };
1219
+ }
1220
+ async function offer(config, url) {
1221
+ const amountMsat = await config.amountMsat();
1222
+ const quote = await config.gateway.createQuote({
1223
+ lnAddresses: config.lnAddresses,
1224
+ amountMsat
1225
+ });
1226
+ const nonce = randomNonce();
1227
+ const callback = new URL(config.baseUrl ?? `${url.origin}${url.pathname}`);
1228
+ callback.searchParams.set("to", quote.lnAddress);
1229
+ callback.searchParams.set("msat", String(amountMsat));
1230
+ callback.searchParams.set("n", nonce);
1231
+ callback.searchParams.set("sig", await sign(config.secret, quote.lnAddress, amountMsat, nonce));
1232
+ return Response.json({
1233
+ tag: "payRequest",
1234
+ callback: callback.toString(),
1235
+ metadata: quote.metadata,
1236
+ minSendable: amountMsat,
1237
+ maxSendable: amountMsat
1238
+ });
1239
+ }
1240
+ async function mint(config, url, address) {
1241
+ const amountMsat = Number(url.searchParams.get("msat"));
1242
+ const nonce = url.searchParams.get("n") ?? "";
1243
+ const signature = url.searchParams.get("sig") ?? "";
1244
+ const expected = await sign(config.secret, address, amountMsat, nonce);
1245
+ if (!equalInConstantTime(signature, expected)) return refuse("this callback was not signed here");
1246
+ const wanted = url.searchParams.get("amount");
1247
+ if (wanted !== null && Number(wanted) !== amountMsat) {
1248
+ return refuse(`this trigger costs exactly ${amountMsat} msat`);
1249
+ }
1250
+ const minted = config.blind ? await mintBlind(config, address, amountMsat) : await mintThroughGateway(config, address, amountMsat, nonce);
1251
+ return Response.json({
1252
+ status: "OK",
1253
+ pr: minted.bolt11,
1254
+ routes: [],
1255
+ verify: minted.verifyUrl
1256
+ });
1257
+ }
1258
+ async function mintThroughGateway(config, address, amountMsat, nonce) {
1259
+ const payment = await config.gateway.createPayment(
1260
+ { lnAddresses: [address], amountMsat },
1261
+ { idempotencyKey: nonce, trigger: config.watchSecret }
1262
+ );
1263
+ return { bolt11: payment.bolt11, verifyUrl: payment.verifyUrl };
1264
+ }
1265
+ async function mintBlind(config, address, amountMsat) {
1266
+ const resolved = await resolve([address], amountMsat);
1267
+ const minted = { ...resolved, amountMsat, lnAddress: resolved.address };
1268
+ const locked = config.sealed;
1269
+ try {
1270
+ await config.gateway.watchPayment({
1271
+ paymentHash: resolved.paymentHash,
1272
+ verifyUrl: resolved.verifyUrl,
1273
+ expiresAt: resolved.expiresAt,
1274
+ trigger: config.watchSecret,
1275
+ sealed: locked ? await seal(locked.secret, JSON.stringify(locked.data(minted))) : void 0
1276
+ });
1277
+ } catch (refused) {
1278
+ if (!alreadyWatched(refused)) throw refused;
1279
+ }
1280
+ return { bolt11: resolved.bolt11, verifyUrl: resolved.verifyUrl };
1281
+ }
1282
+ function alreadyWatched(refused) {
1283
+ return refused instanceof ProblemError && refused.type === PAYMENT_ALREADY_WATCHED;
1284
+ }
1285
+ function sign(secret, address, amountMsat, nonce) {
1286
+ return hmacHex(secret, `${address}|${amountMsat}|${nonce}`);
1287
+ }
1288
+ function randomNonce() {
1289
+ const bytes = crypto.getRandomValues(new Uint8Array(NONCE_BYTES));
1290
+ let hex = "";
1291
+ for (const byte of bytes) hex += byte.toString(16).padStart(2, "0");
1292
+ return hex;
1293
+ }
1294
+ function refuse(reason) {
1295
+ return Response.json({ status: "ERROR", reason });
1296
+ }
1297
+
1298
+ // src/qr.ts
1299
+ import { encode } from "uqr";
1300
+ var SAFE_COLOR = /^#(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$|^rgba?\([\d.,%/\s]+\)$|^[a-z]+$/i;
1301
+ function encodeForQr(destination) {
1302
+ const scanned = isLnAddress(destination) ? destination : destination.toUpperCase();
1303
+ return `LIGHTNING:${scanned}`;
1304
+ }
1305
+ function invoiceToSvg(destination, options) {
1306
+ return svgOf(encodeForQr(destination), options);
1307
+ }
1308
+ function invoiceToDataUrl(destination, options) {
1309
+ return asDataUrl(invoiceToSvg(destination, options));
1310
+ }
1311
+ function lnurlToSvg(endpoint, options) {
1312
+ return svgOf(toLnurl(endpoint), options);
1313
+ }
1314
+ function lnurlToDataUrl(endpoint, options) {
1315
+ return asDataUrl(lnurlToSvg(endpoint, options));
1316
+ }
1317
+ function svgOf(scanned, options) {
1318
+ const size = options?.size ?? 256;
1319
+ const color = options?.color ?? "#000";
1320
+ if (!SAFE_COLOR.test(color)) throw new Error(`Invalid color: ${color}`);
1321
+ return renderSvg(scanned, size, color);
1322
+ }
1323
+ function asDataUrl(svg) {
1324
+ return `data:image/svg+xml,${encodeURIComponent(svg)}`;
1325
+ }
1326
+ function isLnAddress(destination) {
1327
+ const at = destination.indexOf("@");
1328
+ return at > 0 && destination.slice(at + 1).includes(".");
1329
+ }
1330
+ function renderSvg(content, size, color) {
1331
+ const { data } = encode(content);
1332
+ const modules = data.length;
1333
+ const cellSize = size / modules;
1334
+ let path = "";
1335
+ for (let y = 0; y < modules; y++) {
1336
+ for (let x = 0; x < modules; x++) {
1337
+ if (data[y][x]) {
1338
+ path += `M${x * cellSize},${y * cellSize}h${cellSize}v${cellSize}h-${cellSize}z`;
1339
+ }
1340
+ }
1341
+ }
1342
+ return [
1343
+ `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${size} ${size}" width="${size}" height="${size}">`,
1344
+ `<rect width="100%" height="100%" fill="white"/>`,
1345
+ `<path d="${path}" fill="${color}"/>`,
1346
+ `</svg>`
1347
+ ].join("");
1348
+ }
1349
+
1350
+ // src/webhook.ts
1351
+ var SIGNATURE_HEADER = "x-signature";
1352
+ var TIMESTAMP_HEADER = "x-timestamp";
1353
+ var SIGNATURE_PREFIX = "sha256=";
1354
+ var DEFAULT_TOLERANCE_SECS = 300;
1355
+ async function verifyWebhookSignature(body, signature, secret, timestamp, options = {}) {
1356
+ if (!recent(timestamp, options.toleranceSecs ?? DEFAULT_TOLERANCE_SECS)) return false;
1357
+ const received = signature.startsWith(SIGNATURE_PREFIX) ? signature.slice(SIGNATURE_PREFIX.length) : signature;
1358
+ return equalInConstantTime(await sign2(secret, timestamp, body), received.toLowerCase());
1359
+ }
1360
+ async function parseWebhook(body, signature, secret, timestamp, options = {}) {
1361
+ if (!await verifyWebhookSignature(body, signature, secret, timestamp, options)) return null;
1362
+ const text2 = typeof body === "string" ? body : new TextDecoder().decode(body);
1363
+ try {
1364
+ return paymentFromWire(JSON.parse(text2));
1365
+ } catch {
1366
+ return null;
1367
+ }
1368
+ }
1369
+ async function parseWebhookRequest(request, secret, options = {}) {
1370
+ const signature = request.headers.get(SIGNATURE_HEADER);
1371
+ const timestamp = request.headers.get(TIMESTAMP_HEADER);
1372
+ if (signature === null || timestamp === null) return null;
1373
+ return parseWebhook(await request.text(), signature, secret, timestamp, options);
1374
+ }
1375
+ function recent(timestamp, toleranceSecs) {
1376
+ const sent = Number(timestamp);
1377
+ if (!Number.isFinite(sent)) return false;
1378
+ return Math.abs(Math.floor(Date.now() / 1e3) - sent) <= toleranceSecs;
1379
+ }
1380
+ async function sign2(secret, timestamp, body) {
1381
+ return hmacHex(secret, signed(timestamp, body));
1382
+ }
1383
+ function signed(timestamp, body) {
1384
+ const encoder = new TextEncoder();
1385
+ const stamp = encoder.encode(`${timestamp}.`);
1386
+ const rest = typeof body === "string" ? encoder.encode(body) : new Uint8Array(body);
1387
+ const payload = new Uint8Array(stamp.length + rest.length);
1388
+ payload.set(stamp);
1389
+ payload.set(rest, stamp.length);
1390
+ return payload;
1391
+ }
1392
+ export {
1393
+ GatewayCheatError,
1394
+ IDEMPOTENCY_KEY_REUSED,
1395
+ IdempotencyConflictError,
1396
+ NO_WALLET_AVAILABLE,
1397
+ NoWalletAvailableError,
1398
+ PAYMENT_ALREADY_WATCHED,
1399
+ ProblemError,
1400
+ REQUEST_IN_FLIGHT,
1401
+ ThunderBridge,
1402
+ UnverifiedRecipientError,
1403
+ decodeInvoice,
1404
+ invoiceToDataUrl,
1405
+ invoiceToSvg,
1406
+ isProvablyPaid,
1407
+ lnurlPayEndpoint,
1408
+ lnurlToDataUrl,
1409
+ lnurlToSvg,
1410
+ parseWebhook,
1411
+ parseWebhookRequest,
1412
+ preimageMatchesHash,
1413
+ proveOrigin,
1414
+ proveSettlement,
1415
+ seal,
1416
+ toLnurl,
1417
+ unseal,
1418
+ verifyWebhookSignature
1419
+ };