thunder-bridge 1.0.0 → 1.3.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.cjs CHANGED
@@ -31,6 +31,7 @@ __export(index_exports, {
31
31
  REQUEST_IN_FLIGHT: () => REQUEST_IN_FLIGHT,
32
32
  ThunderBridge: () => ThunderBridge,
33
33
  UnverifiedRecipientError: () => UnverifiedRecipientError,
34
+ WrapRefusedError: () => WrapRefusedError,
34
35
  answerVerifyChallenge: () => answerVerifyChallenge,
35
36
  answerVerifyChallengeRequest: () => answerVerifyChallengeRequest,
36
37
  answerWebhookChallenge: () => answerWebhookChallenge,
@@ -65,6 +66,7 @@ __export(index_exports, {
65
66
  preimageMatchesHash: () => preimageMatchesHash,
66
67
  proveOrigin: () => proveOrigin,
67
68
  proveSettlement: () => proveSettlement,
69
+ proveWrapped: () => proveWrapped,
68
70
  qrToDataUrl: () => qrToDataUrl,
69
71
  qrToSvg: () => qrToSvg,
70
72
  seal: () => seal,
@@ -72,74 +74,17 @@ __export(index_exports, {
72
74
  spdToSvg: () => spdToSvg,
73
75
  toLnurl: () => toLnurl,
74
76
  unseal: () => unseal,
75
- verifyWebhookSignature: () => verifyWebhookSignature
77
+ verifyWebhookSignature: () => verifyWebhookSignature,
78
+ wrapFeeCeiling: () => wrapFeeCeiling
76
79
  });
77
80
  module.exports = __toCommonJS(index_exports);
78
81
 
79
- // src/errors.ts
80
- var GatewayCheatError = class extends Error {
81
- code;
82
- paymentId;
83
- constructor(code, paymentId) {
84
- super(`gateway verification failed: ${code}`);
85
- this.name = "GatewayCheatError";
86
- this.code = code;
87
- this.paymentId = paymentId;
88
- }
89
- };
90
- var UnverifiedRecipientError = class extends Error {
91
- lnAddress;
92
- paymentId;
93
- constructor(lnAddress, paymentId, cause) {
94
- super(`could not reach ${lnAddress} to verify the invoice: ${String(cause)}`);
95
- this.name = "UnverifiedRecipientError";
96
- this.lnAddress = lnAddress;
97
- this.paymentId = paymentId;
98
- }
99
- };
100
- var ProblemError = class extends Error {
101
- type;
102
- title;
103
- status;
104
- detail;
105
- constructor(problem) {
106
- const title = problem.title ?? "Request failed";
107
- super(problem.detail ? `${title}: ${problem.detail}` : title);
108
- this.name = "ProblemError";
109
- this.type = problem.type ?? "about:blank";
110
- this.title = title;
111
- this.status = problem.status ?? 0;
112
- this.detail = problem.detail ?? null;
113
- }
114
- };
115
- function isProblemType(problem, type) {
116
- return problem.type === type;
117
- }
118
- var NO_WALLET_AVAILABLE = "urn:problem-type:thunder-bridge:no-wallet-available";
119
- var REQUEST_IN_FLIGHT = "urn:problem-type:thunder-bridge:request-in-flight";
120
- var IDEMPOTENCY_KEY_REUSED = "urn:problem-type:thunder-bridge:idempotency-key-reused";
121
- var PAYMENT_ALREADY_WATCHED = "urn:problem-type:thunder-bridge:payment-already-watched";
122
- var IdempotencyConflictError = class extends ProblemError {
123
- conflict;
124
- constructor(problem, conflict) {
125
- super(problem);
126
- this.name = "IdempotencyConflictError";
127
- this.conflict = conflict;
128
- }
129
- };
130
- var NoWalletAvailableError = class extends ProblemError {
131
- wallets;
132
- constructor(problem, wallets) {
133
- super({ ...problem, type: NO_WALLET_AVAILABLE });
134
- this.name = "NoWalletAvailableError";
135
- this.wallets = wallets;
136
- }
137
- };
138
-
139
82
  // ../core/bytes.ts
140
83
  function bytesToHex(bytes) {
141
84
  let hex = "";
142
- for (const byte of bytes) hex += byte.toString(16).padStart(2, "0");
85
+ for (const byte of bytes) {
86
+ hex += byte.toString(16).padStart(2, "0");
87
+ }
143
88
  return hex;
144
89
  }
145
90
  function hexToBytes(hex) {
@@ -257,7 +202,9 @@ function withPadding(bytes) {
257
202
  return padded;
258
203
  }
259
204
  function fillSchedule(schedule, reader, block) {
260
- for (let at = 0; at < 16; at++) schedule[at] = reader.getUint32(block + at * 4, false);
205
+ for (let at = 0; at < 16; at++) {
206
+ schedule[at] = reader.getUint32(block + at * 4, false);
207
+ }
261
208
  for (let at = 16; at < SCHEDULE_WORDS; at++) {
262
209
  const recent2 = schedule[at - 15];
263
210
  const distant = schedule[at - 2];
@@ -291,12 +238,16 @@ function compress(state, schedule) {
291
238
  a = carried + (mixedA + majority | 0) | 0;
292
239
  }
293
240
  const rolling = [a, b, c, d, e, f, g, h];
294
- for (let at = 0; at < state.length; at++) state[at] = state[at] + rolling[at] | 0;
241
+ for (let at = 0; at < state.length; at++) {
242
+ state[at] = state[at] + rolling[at] | 0;
243
+ }
295
244
  }
296
245
  function asBytes(state) {
297
246
  const digest = new Uint8Array(state.length * 4);
298
247
  const writer = new DataView(digest.buffer);
299
- for (let at = 0; at < state.length; at++) writer.setUint32(at * 4, state[at], false);
248
+ for (let at = 0; at < state.length; at++) {
249
+ writer.setUint32(at * 4, state[at], false);
250
+ }
300
251
  return digest;
301
252
  }
302
253
  function rotate(value, by) {
@@ -337,20 +288,30 @@ function decodeInvoice(bolt11) {
337
288
  };
338
289
  }
339
290
  function preimageMatchesHash(preimage, paymentHash) {
340
- if (!isHex(preimage)) return false;
291
+ if (!isHex(preimage)) {
292
+ return false;
293
+ }
341
294
  return bytesToHex(sha256(hexToBytes(preimage))) === paymentHash.toLowerCase();
342
295
  }
343
296
  function splitBech32(bolt11) {
344
297
  const lower = bolt11.toLowerCase();
345
- if (isBolt12Encoding(lower)) return null;
298
+ if (isBolt12Encoding(lower)) {
299
+ return null;
300
+ }
346
301
  const separator = lower.lastIndexOf("1");
347
- if (separator < 1) return null;
302
+ if (separator < 1) {
303
+ return null;
304
+ }
348
305
  const dataChars = lower.slice(separator + 1);
349
- if (dataChars.length < CHECKSUM_WORDS) return null;
306
+ if (dataChars.length < CHECKSUM_WORDS) {
307
+ return null;
308
+ }
350
309
  const words = [];
351
310
  for (const char of dataChars) {
352
311
  const value = BECH32_CHARSET.indexOf(char);
353
- if (value === -1) return null;
312
+ if (value === -1) {
313
+ return null;
314
+ }
354
315
  words.push(value);
355
316
  }
356
317
  return { hrp: lower.slice(0, separator), words: words.slice(0, -CHECKSUM_WORDS) };
@@ -366,25 +327,35 @@ function taggedFields(words) {
366
327
  const lengthWords = words[cursor + 1] * 32 + words[cursor + 2];
367
328
  const dataStart = cursor + 1 + TAGGED_FIELD_LENGTH_WORDS;
368
329
  const dataEnd = dataStart + lengthWords;
369
- if (dataEnd > signatureStart) return fields;
370
- if (!fields.has(words[cursor])) fields.set(words[cursor], words.slice(dataStart, dataEnd));
330
+ if (dataEnd > signatureStart) {
331
+ return fields;
332
+ }
333
+ if (!fields.has(words[cursor])) {
334
+ fields.set(words[cursor], words.slice(dataStart, dataEnd));
335
+ }
371
336
  cursor = dataEnd;
372
337
  }
373
338
  return fields;
374
339
  }
375
340
  function hexTag(fields, tag, expectedWords) {
376
341
  const words = fields.get(tag);
377
- if (words === void 0 || words.length !== expectedWords) return null;
342
+ if (words === void 0 || words.length !== expectedWords) {
343
+ return null;
344
+ }
378
345
  return bytesToHex(wordsToBytes(words));
379
346
  }
380
347
  function expiryOf(words, fields) {
381
- if (words.length < TIMESTAMP_WORDS + SIGNATURE_WORDS) return null;
348
+ if (words.length < TIMESTAMP_WORDS + SIGNATURE_WORDS) {
349
+ return null;
350
+ }
382
351
  const expiry = fields.get(EXPIRY_TAG);
383
352
  return readNumber(words.slice(0, TIMESTAMP_WORDS)) + (expiry ? readNumber(expiry) : DEFAULT_EXPIRY_SECS);
384
353
  }
385
354
  function amountFromHrp(hrp) {
386
355
  const match = HRP_AMOUNT.exec(hrp);
387
- if (match === null) return null;
356
+ if (match === null) {
357
+ return null;
358
+ }
388
359
  const [, digits, multiplier] = match;
389
360
  return multiplier ? Number(digits) * HRP_MULTIPLIER_MSAT[multiplier] : Number(digits) * MSAT_PER_BTC;
390
361
  }
@@ -406,105 +377,6 @@ function wordsToBytes(words) {
406
377
  return Uint8Array.from(bytes);
407
378
  }
408
379
 
409
- // ../core/ed25519.ts
410
- var ALGORITHM = { name: "Ed25519" };
411
- var SEED_BYTES = 32;
412
- var SIGNATURE_BYTES = 64;
413
- var PUBLIC_KEY_BYTES = 32;
414
- var PKCS8_HEADER = hexToBytes("302e020100300506032b657004220420");
415
- async function signingKeyFromSeed(seed) {
416
- if (seed.length !== SEED_BYTES) throw new Error(`an ed25519 seed is ${SEED_BYTES} bytes`);
417
- const wrapped = new Uint8Array(PKCS8_HEADER.length + SEED_BYTES);
418
- wrapped.set(PKCS8_HEADER);
419
- wrapped.set(seed, PKCS8_HEADER.length);
420
- const key = await crypto.subtle.importKey("pkcs8", asBuffer(wrapped), ALGORITHM, true, ["sign"]);
421
- const jwk = await crypto.subtle.exportKey("jwk", key);
422
- return {
423
- publicKeyHex: bytesToHex(fromBase64Url(jwk.x ?? "")),
424
- sign: async (payload) => bytesToHex(new Uint8Array(await crypto.subtle.sign(ALGORITHM, key, asBuffer(payload))))
425
- };
426
- }
427
- async function verifyHex(publicKeyHex, signatureHex, payload) {
428
- if (!isHex(publicKeyHex) || publicKeyHex.length !== PUBLIC_KEY_BYTES * 2) return false;
429
- if (!isHex(signatureHex) || signatureHex.length !== SIGNATURE_BYTES * 2) return false;
430
- try {
431
- const key = await crypto.subtle.importKey(
432
- "raw",
433
- asBuffer(hexToBytes(publicKeyHex)),
434
- ALGORITHM,
435
- false,
436
- ["verify"]
437
- );
438
- return await crypto.subtle.verify(
439
- ALGORITHM,
440
- key,
441
- asBuffer(hexToBytes(signatureHex)),
442
- asBuffer(payload)
443
- );
444
- } catch {
445
- return false;
446
- }
447
- }
448
- function fromBase64Url(text2) {
449
- const padded = text2.replace(/-/g, "+").replace(/_/g, "/");
450
- const raw = atob(padded + "=".repeat((4 - padded.length % 4) % 4));
451
- const bytes = new Uint8Array(raw.length);
452
- for (let at = 0; at < raw.length; at++) bytes[at] = raw.charCodeAt(at);
453
- return bytes;
454
- }
455
- function asBuffer(bytes) {
456
- return bytes.buffer instanceof ArrayBuffer ? bytes : new Uint8Array(bytes);
457
- }
458
-
459
- // ../core/hmac.ts
460
- async function hmacHex(secret, payload) {
461
- const raw = typeof secret === "string" ? new TextEncoder().encode(secret) : secret;
462
- const key = await crypto.subtle.importKey(
463
- "raw",
464
- asBuffer2(raw),
465
- { name: "HMAC", hash: "SHA-256" },
466
- false,
467
- ["sign"]
468
- );
469
- const message = typeof payload === "string" ? new TextEncoder().encode(payload) : payload;
470
- return bytesToHex(new Uint8Array(await crypto.subtle.sign("HMAC", key, asBuffer2(message))));
471
- }
472
- function equalInConstantTime(one, other) {
473
- if (one.length !== other.length) return false;
474
- let difference = 0;
475
- for (let at = 0; at < one.length; at++) difference |= one.charCodeAt(at) ^ other.charCodeAt(at);
476
- return difference === 0;
477
- }
478
- function asBuffer2(bytes) {
479
- return bytes instanceof Uint8Array && bytes.buffer instanceof ArrayBuffer ? bytes : new Uint8Array(bytes);
480
- }
481
-
482
- // ../core/caller.ts
483
- var KEY_HEADER = "x-client-key";
484
- var SIGNATURE_HEADER = "x-signature";
485
- var TIMESTAMP_HEADER = "x-timestamp";
486
- var PREFIX = "ed25519=";
487
- var SIGNING_LABEL = "request-signing";
488
- async function callerKey(secret) {
489
- return await signingKeyFromSeed(hexToBytes(await hmacHex(secret, SIGNING_LABEL)));
490
- }
491
- function paymentNamedBy(caller, paymentHash) {
492
- return sha256Hex(`${caller}.payment-id.${paymentHash}`);
493
- }
494
- async function signedAs(key, method, path, body) {
495
- const timestamp = String(Math.floor(Date.now() / 1e3));
496
- return {
497
- [KEY_HEADER]: key.publicKeyHex,
498
- [TIMESTAMP_HEADER]: timestamp,
499
- [SIGNATURE_HEADER]: `${PREFIX}${await key.sign(spoken(timestamp, method, path, body))}`
500
- };
501
- }
502
- function spoken(timestamp, method, path, body) {
503
- return new TextEncoder().encode(
504
- `${timestamp}.${method.toUpperCase()}.${path}.${sha256Hex(body)}`
505
- );
506
- }
507
-
508
380
  // ../core/url.ts
509
381
  var PRIVATE_SUFFIXES = ["localhost", "local", "internal", "lan", "arpa", "test", "invalid"];
510
382
  function publicHttps(raw) {
@@ -527,11 +399,17 @@ function publicAddress(literal) {
527
399
  return literal.includes(":") ? ipv6IsGlobal(literal) : ipv4IsGlobal(literal);
528
400
  }
529
401
  function hostIsPublic(host) {
530
- if (host.startsWith("[")) return publicAddress(host.slice(1, -1));
402
+ if (host.startsWith("[")) {
403
+ return publicAddress(host.slice(1, -1));
404
+ }
531
405
  const name = host.replace(/\.$/, "");
532
- if (/^\d+\.\d+\.\d+\.\d+$/.test(name)) return publicAddress(name);
406
+ if (/^\d+\.\d+\.\d+\.\d+$/.test(name)) {
407
+ return publicAddress(name);
408
+ }
533
409
  const labels = name.split(".");
534
- if (labels.length < 2 || labels.some((label) => label === "")) return false;
410
+ if (labels.length < 2 || labels.some((label) => label === "")) {
411
+ return false;
412
+ }
535
413
  return !PRIVATE_SUFFIXES.includes(labels[labels.length - 1]);
536
414
  }
537
415
  function ipv4IsGlobal(literal) {
@@ -540,25 +418,43 @@ function ipv4IsGlobal(literal) {
540
418
  return false;
541
419
  }
542
420
  const [a, b] = parts;
543
- if (a === 0 || a === 10 || a === 127 || a >= 240) return false;
544
- if (a === 100 && b >= 64 && b < 128) return false;
545
- if (a === 169 && b === 254) return false;
546
- if (a === 172 && b >= 16 && b < 32) return false;
547
- if (a === 192 && b === 168) return false;
421
+ if (a === 0 || a === 10 || a === 127 || a >= 240) {
422
+ return false;
423
+ }
424
+ if (a === 100 && b >= 64 && b < 128) {
425
+ return false;
426
+ }
427
+ if (a === 169 && b === 254) {
428
+ return false;
429
+ }
430
+ if (a === 172 && b >= 16 && b < 32) {
431
+ return false;
432
+ }
433
+ if (a === 192 && b === 168) {
434
+ return false;
435
+ }
548
436
  return true;
549
437
  }
550
438
  function ipv6IsGlobal(literal) {
551
439
  const groups = ipv6Groups(literal.toLowerCase());
552
- if (groups.length !== 8 || groups.some(Number.isNaN)) return false;
440
+ if (groups.length !== 8 || groups.some(Number.isNaN)) {
441
+ return false;
442
+ }
553
443
  const leading = groups.slice(0, 5).every((group) => group === 0);
554
- if (leading && groups[5] === 0 && groups[6] === 0 && groups[7] <= 1) return false;
444
+ if (leading && groups[5] === 0 && groups[6] === 0 && groups[7] <= 1) {
445
+ return false;
446
+ }
555
447
  if (leading && groups[5] === 65535) {
556
448
  const [high, low] = [groups[6], groups[7]];
557
449
  return ipv4IsGlobal(`${high >> 8}.${high & 255}.${low >> 8}.${low & 255}`);
558
450
  }
559
451
  const first = groups[0];
560
- if ((first & 65024) === 64512) return false;
561
- if ((first & 65472) === 65152) return false;
452
+ if ((first & 65024) === 64512) {
453
+ return false;
454
+ }
455
+ if ((first & 65472) === 65152) {
456
+ return false;
457
+ }
562
458
  return true;
563
459
  }
564
460
  function ipv6Groups(address) {
@@ -569,74 +465,276 @@ function ipv6Groups(address) {
569
465
  return [...left, ...gap, ...right].map((group) => parseInt(group, 16));
570
466
  }
571
467
 
572
- // src/verify.ts
573
- var HTTP_TIMEOUT_MS = 15e3;
574
- async function proveOrigin(payment, request) {
575
- const cheat = (code) => new GatewayCheatError(code, payment.id);
576
- const listed = request.lnAddresses.find(
577
- (address) => equalIgnoringCase(address, payment.lnAddress)
578
- );
579
- if (listed === void 0) throw cheat("address_not_requested");
580
- if (payment.amountMsat !== request.amountMsat) throw cheat("amount_mismatch");
581
- const invoice = decodeInvoice(payment.bolt11);
582
- if (invoice.paymentHash === null || !equalIgnoringCase(invoice.paymentHash, payment.paymentHash)) {
583
- throw cheat("hash_mismatch");
584
- }
585
- if (invoice.amountMsat !== request.amountMsat) throw cheat("amount_mismatch");
586
- const payRequest = await payRequestFor(listed, payment);
587
- if (typeof payRequest.metadata !== "string" || typeof payRequest.callback !== "string") {
588
- throw new UnverifiedRecipientError(payment.lnAddress, payment.id, "no payRequest served");
589
- }
590
- if (invoice.descriptionHash !== sha256Hex(payRequest.metadata)) {
591
- throw cheat("description_hash_mismatch");
592
- }
593
- if (!sameOrigin(payment.verifyUrl, payRequest.callback)) throw cheat("verify_url_foreign");
594
- const issued = await reachable(payment.verifyUrl, payment);
595
- if (typeof issued.pr !== "string" || !equalIgnoringCase(issued.pr, payment.bolt11)) {
596
- throw cheat("invoice_not_issued");
468
+ // ../core/lnurl.ts
469
+ var BECH32_CHARSET2 = "qpzry9x8gf2tvdw0s3jn54khce6mua7l";
470
+ var BECH32_GENERATOR = [996825010, 642813549, 513874426, 1027748829, 705979059];
471
+ var CHECKSUM_SLOTS = [0, 1, 2, 3, 4, 5];
472
+ var LNURL_HRP = "lnurl";
473
+ function toLnurl(endpoint) {
474
+ const scheme = parsedScheme(endpoint);
475
+ if (scheme !== "https:" && scheme !== "http:") {
476
+ throw new Error(`${endpoint} is not an http or https URL`);
597
477
  }
478
+ const words = toWords(new TextEncoder().encode(endpoint));
479
+ const data = [...words, ...checksumWords(LNURL_HRP, words)];
480
+ return `${LNURL_HRP}1${data.map((word) => BECH32_CHARSET2[word]).join("")}`.toUpperCase();
598
481
  }
599
- async function proveSettlement(payment, request) {
600
- await proveOrigin(payment, request);
601
- const verified = await reachable(payment.verifyUrl, payment);
602
- if (verified.settled !== true || typeof verified.preimage !== "string") return null;
603
- if (!preimageMatchesHash(verified.preimage, payment.paymentHash)) {
604
- throw new GatewayCheatError("preimage_mismatch", payment.id);
482
+ function parsedScheme(endpoint) {
483
+ try {
484
+ return new URL(endpoint).protocol;
485
+ } catch {
486
+ return null;
605
487
  }
606
- return verified.preimage;
607
- }
608
- function isProvablyPaid(payment) {
609
- if (payment.status !== "paid" || payment.preimage === null) return false;
610
- const invoiceHash = decodeInvoice(payment.bolt11).paymentHash;
611
- if (invoiceHash === null || !equalIgnoringCase(invoiceHash, payment.paymentHash)) return false;
612
- return preimageMatchesHash(payment.preimage, invoiceHash);
613
488
  }
614
- async function payRequestFor(listed, payment) {
615
- const at = listed.indexOf("@");
616
- const name = listed.slice(0, at);
617
- const domain = listed.slice(at + 1).toLowerCase();
618
- if (at < 1 || !publicHttps(`https://${domain}/`)) {
619
- throw new UnverifiedRecipientError(payment.lnAddress, payment.id, "not a usable address");
489
+ function toWords(bytes) {
490
+ const words = [];
491
+ let accumulator = 0;
492
+ let bits = 0;
493
+ for (const byte of bytes) {
494
+ accumulator = accumulator << 8 | byte;
495
+ bits += 8;
496
+ while (bits >= 5) {
497
+ bits -= 5;
498
+ words.push(accumulator >> bits & 31);
499
+ }
620
500
  }
621
- return reachable(`https://${domain}/.well-known/lnurlp/${name}`, payment);
501
+ if (bits > 0) {
502
+ words.push(accumulator << 5 - bits & 31);
503
+ }
504
+ return words;
622
505
  }
623
- async function reachable(url, payment) {
506
+ function checksumWords(hrp, words) {
507
+ const expanded = [...hrp].map((char) => char.charCodeAt(0));
508
+ const values = [
509
+ ...expanded.map((code) => code >> 5),
510
+ 0,
511
+ ...expanded.map((code) => code & 31),
512
+ ...words,
513
+ ...CHECKSUM_SLOTS.map(() => 0)
514
+ ];
515
+ const polymod = bech32Polymod(values) ^ 1;
516
+ return CHECKSUM_SLOTS.map((slot) => polymod >> 5 * (5 - slot) & 31);
517
+ }
518
+ function bech32Polymod(values) {
519
+ let checksum = 1;
520
+ for (const value of values) {
521
+ const top = checksum >> 25;
522
+ checksum = (checksum & 33554431) << 5 ^ value;
523
+ for (let bit = 0; bit < 5; bit++) {
524
+ if (top >> bit & 1) {
525
+ checksum ^= BECH32_GENERATOR[bit];
526
+ }
527
+ }
528
+ }
529
+ return checksum;
530
+ }
531
+
532
+ // ../core/sealed.ts
533
+ var VERSION = "v1";
534
+ var IV_BYTES = 12;
535
+ var MIN_SECRET_CHARS = 32;
536
+ var MAX_PLAIN_BYTES = 3e3;
537
+ var INFO = new TextEncoder().encode("thunder-bridge/sealed");
538
+ async function seal(secret, plaintext) {
539
+ const body = new TextEncoder().encode(plaintext);
540
+ if (body.length > MAX_PLAIN_BYTES) {
541
+ throw new Error(`sealed takes at most ${MAX_PLAIN_BYTES} bytes, this was ${body.length}`);
542
+ }
543
+ const key = await keyFor(secret);
544
+ const iv = crypto.getRandomValues(new Uint8Array(IV_BYTES));
545
+ const cipher = new Uint8Array(await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, body));
546
+ const joined = new Uint8Array(iv.length + cipher.length);
547
+ joined.set(iv);
548
+ joined.set(cipher, iv.length);
549
+ return `${VERSION}.${toBase64Url(joined)}`;
550
+ }
551
+ async function unseal(secret, sealed) {
552
+ const key = await keyFor(secret);
553
+ if (!sealed.startsWith(`${VERSION}.`)) {
554
+ return null;
555
+ }
556
+ const joined = fromBase64Url(sealed.slice(VERSION.length + 1));
557
+ if (joined === null || joined.length <= IV_BYTES) {
558
+ return null;
559
+ }
624
560
  try {
625
- if (!publicHttps(url)) throw new Error(`${url} is not a public https URL`);
626
- const response = await fetch(url, {
627
- signal: AbortSignal.timeout(HTTP_TIMEOUT_MS),
628
- headers: { accept: "application/json" }
629
- });
630
- if (!response.ok) throw new Error(`answered ${response.status}`);
631
- const body = await response.json();
632
- if (body === null || typeof body !== "object") throw new Error("answered with no JSON object");
633
- return body;
634
- } catch (cause) {
635
- throw new UnverifiedRecipientError(payment.lnAddress, payment.id, cause);
561
+ const body = await crypto.subtle.decrypt(
562
+ { name: "AES-GCM", iv: joined.slice(0, IV_BYTES) },
563
+ key,
564
+ joined.slice(IV_BYTES)
565
+ );
566
+ return new TextDecoder().decode(body);
567
+ } catch {
568
+ return null;
636
569
  }
637
570
  }
638
- function equalIgnoringCase(one, other) {
639
- return one.toLowerCase() === other.toLowerCase();
571
+ async function keyFor(secret) {
572
+ if (secret.length < MIN_SECRET_CHARS) {
573
+ throw new Error(`the sealing secret needs ${MIN_SECRET_CHARS} characters of randomness`);
574
+ }
575
+ const material = await crypto.subtle.importKey(
576
+ "raw",
577
+ new TextEncoder().encode(secret),
578
+ "HKDF",
579
+ false,
580
+ ["deriveKey"]
581
+ );
582
+ return crypto.subtle.deriveKey(
583
+ { name: "HKDF", hash: "SHA-256", salt: new Uint8Array(0), info: INFO },
584
+ material,
585
+ { name: "AES-GCM", length: 256 },
586
+ false,
587
+ ["encrypt", "decrypt"]
588
+ );
589
+ }
590
+ function toBase64Url(bytes) {
591
+ let binary = "";
592
+ for (const byte of bytes) {
593
+ binary += String.fromCharCode(byte);
594
+ }
595
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
596
+ }
597
+ function fromBase64Url(text2) {
598
+ if (text2.length === 0 || !/^[A-Za-z0-9_-]+$/.test(text2)) {
599
+ return null;
600
+ }
601
+ try {
602
+ const binary = atob(text2.replace(/-/g, "+").replace(/_/g, "/"));
603
+ const bytes = new Uint8Array(binary.length);
604
+ for (let at = 0; at < bytes.length; at++) {
605
+ bytes[at] = binary.charCodeAt(at);
606
+ }
607
+ return bytes;
608
+ } catch {
609
+ return null;
610
+ }
611
+ }
612
+
613
+ // ../core/hmac.ts
614
+ async function hmacHex(secret, payload) {
615
+ const raw = typeof secret === "string" ? new TextEncoder().encode(secret) : secret;
616
+ const key = await crypto.subtle.importKey(
617
+ "raw",
618
+ asBuffer(raw),
619
+ { name: "HMAC", hash: "SHA-256" },
620
+ false,
621
+ ["sign"]
622
+ );
623
+ const message = typeof payload === "string" ? new TextEncoder().encode(payload) : payload;
624
+ return bytesToHex(new Uint8Array(await crypto.subtle.sign("HMAC", key, asBuffer(message))));
625
+ }
626
+ function equalInConstantTime(one, other) {
627
+ if (one.length !== other.length) {
628
+ return false;
629
+ }
630
+ let difference = 0;
631
+ for (let at = 0; at < one.length; at++) {
632
+ difference |= one.charCodeAt(at) ^ other.charCodeAt(at);
633
+ }
634
+ return difference === 0;
635
+ }
636
+ function asBuffer(bytes) {
637
+ return bytes instanceof Uint8Array && bytes.buffer instanceof ArrayBuffer ? bytes : new Uint8Array(bytes);
638
+ }
639
+
640
+ // src/currency.ts
641
+ var ISO_4217_MINOR_UNITS = {
642
+ BHD: 3,
643
+ BIF: 0,
644
+ CLP: 0,
645
+ CZK: 2,
646
+ DJF: 0,
647
+ EUR: 2,
648
+ GBP: 2,
649
+ ISK: 0,
650
+ IQD: 3,
651
+ JOD: 3,
652
+ JPY: 0,
653
+ KRW: 0,
654
+ KWD: 3,
655
+ LYD: 3,
656
+ OMR: 3,
657
+ PLN: 2,
658
+ PYG: 0,
659
+ RWF: 0,
660
+ TND: 3,
661
+ UGX: 0,
662
+ USD: 2,
663
+ VND: 0,
664
+ VUV: 0,
665
+ XAF: 0,
666
+ XOF: 0,
667
+ XPF: 0
668
+ };
669
+ function minorUnitsOf(currency) {
670
+ const digits = ISO_4217_MINOR_UNITS[currency.toUpperCase()];
671
+ if (digits === void 0) {
672
+ throw new Error(
673
+ `${currency.toUpperCase()} is not a currency this knows the ISO 4217 minor unit of`
674
+ );
675
+ }
676
+ return digits;
677
+ }
678
+ function minorScaleOf(currency) {
679
+ return 10 ** minorUnitsOf(currency);
680
+ }
681
+
682
+ // ../core/ed25519.ts
683
+ var ALGORITHM = { name: "Ed25519" };
684
+ var SEED_BYTES = 32;
685
+ var SIGNATURE_BYTES = 64;
686
+ var PUBLIC_KEY_BYTES = 32;
687
+ var PKCS8_HEADER = hexToBytes("302e020100300506032b657004220420");
688
+ async function signingKeyFromSeed(seed) {
689
+ if (seed.length !== SEED_BYTES) {
690
+ throw new Error(`an ed25519 seed is ${SEED_BYTES} bytes`);
691
+ }
692
+ const wrapped = new Uint8Array(PKCS8_HEADER.length + SEED_BYTES);
693
+ wrapped.set(PKCS8_HEADER);
694
+ wrapped.set(seed, PKCS8_HEADER.length);
695
+ const key = await crypto.subtle.importKey("pkcs8", asBuffer2(wrapped), ALGORITHM, true, ["sign"]);
696
+ const jwk = await crypto.subtle.exportKey("jwk", key);
697
+ return {
698
+ publicKeyHex: bytesToHex(fromBase64Url2(jwk.x ?? "")),
699
+ sign: async (payload) => bytesToHex(new Uint8Array(await crypto.subtle.sign(ALGORITHM, key, asBuffer2(payload))))
700
+ };
701
+ }
702
+ async function verifyHex(publicKeyHex, signatureHex, payload) {
703
+ if (!isHex(publicKeyHex) || publicKeyHex.length !== PUBLIC_KEY_BYTES * 2) {
704
+ return false;
705
+ }
706
+ if (!isHex(signatureHex) || signatureHex.length !== SIGNATURE_BYTES * 2) {
707
+ return false;
708
+ }
709
+ try {
710
+ const key = await crypto.subtle.importKey(
711
+ "raw",
712
+ asBuffer2(hexToBytes(publicKeyHex)),
713
+ ALGORITHM,
714
+ false,
715
+ ["verify"]
716
+ );
717
+ return await crypto.subtle.verify(
718
+ ALGORITHM,
719
+ key,
720
+ asBuffer2(hexToBytes(signatureHex)),
721
+ asBuffer2(payload)
722
+ );
723
+ } catch {
724
+ return false;
725
+ }
726
+ }
727
+ function fromBase64Url2(text2) {
728
+ const padded = text2.replace(/-/g, "+").replace(/_/g, "/");
729
+ const raw = atob(padded + "=".repeat((4 - padded.length % 4) % 4));
730
+ const bytes = new Uint8Array(raw.length);
731
+ for (let at = 0; at < raw.length; at++) {
732
+ bytes[at] = raw.charCodeAt(at);
733
+ }
734
+ return bytes;
735
+ }
736
+ function asBuffer2(bytes) {
737
+ return bytes.buffer instanceof ArrayBuffer ? bytes : new Uint8Array(bytes);
640
738
  }
641
739
 
642
740
  // src/wire.ts
@@ -649,12 +747,13 @@ var REASONS = /* @__PURE__ */ new Set([
649
747
  "cannot-prove-delivery",
650
748
  "invoice-refused"
651
749
  ]);
652
- function createRequestBody(params, trigger) {
750
+ function createRequestBody(params, trigger, replay) {
653
751
  return JSON.stringify({
654
752
  ln_addresses: params.lnAddresses,
655
753
  incoming_amount: toAmount(params.amountMsat),
656
754
  webhook: params.webhookUrl ? { url: params.webhookUrl } : void 0,
657
- trigger: trigger ?? void 0
755
+ trigger: trigger ?? void 0,
756
+ replay
658
757
  });
659
758
  }
660
759
  function quoteRequestBody(params) {
@@ -665,7 +764,9 @@ function quoteRequestBody(params) {
665
764
  }
666
765
  function quoteFromWire(body) {
667
766
  const wire = asObject(body);
668
- if (wire === null) return null;
767
+ if (wire === null) {
768
+ return null;
769
+ }
669
770
  const lnAddress = text(wire["ln_address"]);
670
771
  const amountMsat = msatFrom(wire["amount"], 1);
671
772
  const feeMsat = msatFrom(wire["fee"], 0);
@@ -673,13 +774,19 @@ function quoteFromWire(body) {
673
774
  const maxMsat = msatFrom(wire["max_amount"], 0);
674
775
  const metadata = text(wire["metadata"]);
675
776
  const refusals3 = refusalsFrom(wire["refusals"]);
676
- if (lnAddress === null || amountMsat === null || feeMsat === null) return null;
677
- if (minMsat === null || maxMsat === null || metadata === null || refusals3 === null) return null;
777
+ if (lnAddress === null || amountMsat === null || feeMsat === null) {
778
+ return null;
779
+ }
780
+ if (minMsat === null || maxMsat === null || metadata === null || refusals3 === null) {
781
+ return null;
782
+ }
678
783
  return { lnAddress, amountMsat, feeMsat, minMsat, maxMsat, metadata, refusals: refusals3 };
679
784
  }
680
785
  function paymentFromWire(body) {
681
786
  const wire = asObject(body);
682
- if (wire === null) return null;
787
+ if (wire === null) {
788
+ return null;
789
+ }
683
790
  const id = text(wire["id"]);
684
791
  const lnAddress = text(wire["ln_address"]);
685
792
  const bolt11 = text(wire["bolt11"]);
@@ -690,11 +797,21 @@ function paymentFromWire(body) {
690
797
  const createdAt = secondsFrom(wire["created_at"]);
691
798
  const status = wire["status"];
692
799
  const preimage = wire["preimage"] ?? null;
693
- if (id === null || lnAddress === null || bolt11 === null) return null;
694
- if (paymentHash === null || verifyUrl === null) return null;
695
- if (amountMsat === null || expiresAt === null || createdAt === null) return null;
696
- if (!isStatus(status)) return null;
697
- if (preimage !== null && typeof preimage !== "string") return null;
800
+ if (id === null || lnAddress === null || bolt11 === null) {
801
+ return null;
802
+ }
803
+ if (paymentHash === null || verifyUrl === null) {
804
+ return null;
805
+ }
806
+ if (amountMsat === null || expiresAt === null || createdAt === null) {
807
+ return null;
808
+ }
809
+ if (!isStatus(status)) {
810
+ return null;
811
+ }
812
+ if (preimage !== null && typeof preimage !== "string") {
813
+ return null;
814
+ }
698
815
  return {
699
816
  id,
700
817
  lnAddress,
@@ -718,25 +835,34 @@ function watchRequestBody(params, trigger) {
718
835
  verify_url: params.verifyUrl,
719
836
  expires_at: expiresAt.toISOString(),
720
837
  trigger: trigger ?? void 0,
838
+ replay: params.replay,
721
839
  sealed: params.sealed,
722
840
  webhook: params.webhookUrl ? { url: params.webhookUrl } : void 0
723
841
  });
724
842
  }
725
843
  function settlementFromWire(body) {
726
844
  const wire = asObject(body);
727
- if (wire === null) return null;
845
+ if (wire === null) {
846
+ return null;
847
+ }
728
848
  const id = text(wire["id"]);
729
849
  const paymentHash = text(wire["payment_hash"]);
730
850
  const settledAt = secondsFrom(wire["settled_at"]);
731
851
  const status = wire["status"];
732
852
  const preimage = wire["preimage"] ?? null;
733
- if (id === null || paymentHash === null || settledAt === null || !isStatus(status)) return null;
734
- if (preimage !== null && typeof preimage !== "string") return null;
853
+ if (id === null || paymentHash === null || settledAt === null || !isStatus(status)) {
854
+ return null;
855
+ }
856
+ if (preimage !== null && typeof preimage !== "string") {
857
+ return null;
858
+ }
735
859
  return { id, status, paymentHash, preimage, settledAt };
736
860
  }
737
861
  function triggerEventFromWire(body) {
738
862
  const wire = asObject(body);
739
- if (wire === null) return null;
863
+ if (wire === null) {
864
+ return null;
865
+ }
740
866
  const id = text(wire["id"]);
741
867
  const paymentHash = text(wire["payment_hash"]);
742
868
  const verifyUrl = text(wire["verify_url"]);
@@ -745,10 +871,18 @@ function triggerEventFromWire(body) {
745
871
  const status = wire["status"];
746
872
  const preimage = wire["preimage"] ?? null;
747
873
  const sealed = wire["sealed"] ?? null;
748
- if (id === null || paymentHash === null || verifyUrl === null) return null;
749
- if (expiresAt === null || createdAt === null || !isStatus(status)) return null;
750
- if (preimage !== null && typeof preimage !== "string") return null;
751
- if (sealed !== null && typeof sealed !== "string") return null;
874
+ if (id === null || paymentHash === null || verifyUrl === null) {
875
+ return null;
876
+ }
877
+ if (expiresAt === null || createdAt === null || !isStatus(status)) {
878
+ return null;
879
+ }
880
+ if (preimage !== null && typeof preimage !== "string") {
881
+ return null;
882
+ }
883
+ if (sealed !== null && typeof sealed !== "string") {
884
+ return null;
885
+ }
752
886
  return {
753
887
  id,
754
888
  kind: kindOf(wire),
@@ -765,14 +899,18 @@ function triggerEventFromWire(body) {
765
899
  }
766
900
  function kindOf(wire) {
767
901
  const said = wire["kind"];
768
- if (said === "minted" || said === "watched") return said;
902
+ if (said === "minted" || said === "watched") {
903
+ return said;
904
+ }
769
905
  return text(wire["ln_address"]) === null ? "watched" : "minted";
770
906
  }
771
907
  function toAmount(msat) {
772
908
  return { value: String(msat), asset_code: ASSET_CODE, asset_scale: ASSET_SCALE };
773
909
  }
774
910
  function asObject(value) {
775
- if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
911
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
912
+ return null;
913
+ }
776
914
  return value;
777
915
  }
778
916
  function text(value) {
@@ -780,27 +918,39 @@ function text(value) {
780
918
  }
781
919
  function msatFrom(value, least) {
782
920
  const amount = asObject(value);
783
- if (amount === null) return null;
784
- if (amount["asset_code"] !== ASSET_CODE || amount["asset_scale"] !== ASSET_SCALE) return null;
921
+ if (amount === null) {
922
+ return null;
923
+ }
924
+ if (amount["asset_code"] !== ASSET_CODE || amount["asset_scale"] !== ASSET_SCALE) {
925
+ return null;
926
+ }
785
927
  const digits = amount["value"];
786
- if (typeof digits !== "string" || !/^[0-9]+$/.test(digits)) return null;
787
- const msat = Number(digits);
928
+ if (typeof digits !== "string" || !/^[0-9]+$/.test(digits)) {
929
+ return null;
930
+ }
931
+ const msat = Number(digits);
788
932
  return Number.isSafeInteger(msat) && msat >= least ? msat : null;
789
933
  }
790
934
  function refusalsFrom(value) {
791
- if (!Array.isArray(value)) return null;
935
+ if (!Array.isArray(value)) {
936
+ return null;
937
+ }
792
938
  const refusals3 = [];
793
939
  for (const entry of value) {
794
940
  const failure = asObject(entry);
795
941
  const address = failure === null ? null : text(failure["address"]);
796
942
  const reason = failure?.["reason"];
797
- if (address === null || typeof reason !== "string" || !REASONS.has(reason)) return null;
943
+ if (address === null || typeof reason !== "string" || !REASONS.has(reason)) {
944
+ return null;
945
+ }
798
946
  refusals3.push({ address, reason });
799
947
  }
800
948
  return refusals3;
801
949
  }
802
950
  function secondsFrom(value) {
803
- if (typeof value !== "string") return null;
951
+ if (typeof value !== "string") {
952
+ return null;
953
+ }
804
954
  const milliseconds = Date.parse(value);
805
955
  return Number.isNaN(milliseconds) ? null : Math.floor(milliseconds / 1e3);
806
956
  }
@@ -808,910 +958,1048 @@ function isStatus(value) {
808
958
  return value === "pending" || value === "paid" || value === "expired";
809
959
  }
810
960
 
811
- // src/client.ts
812
- var TERMINAL = /* @__PURE__ */ new Set(["paid", "expired"]);
813
- var RECONNECT_DELAY_MS = 3e3;
814
- var RECONNECT_CAP_MS = 3e4;
815
- var UNANSWERED_ATTEMPTS = 5;
816
- var TRIGGER_MIN_CHARS = 16;
817
- var STRANGER_PROBE = "is-this-gateway-yours";
818
- function unguessable(trigger) {
819
- if (trigger.length < TRIGGER_MIN_CHARS) {
820
- throw new Error(
821
- `a trigger secret is the only thing guarding its stream, and nothing rate limits a guess at it, so it has to be at least ${TRIGGER_MIN_CHARS} characters nobody can predict`
822
- );
961
+ // src/webhook.ts
962
+ var SIGNATURE_HEADER = "x-signature";
963
+ var TIMESTAMP_HEADER = "x-timestamp";
964
+ var GATEWAY_KEY_PREFIX = "ed25519=";
965
+ var DEFAULT_TOLERANCE_SECS = 300;
966
+ var CHALLENGE = "webhook-challenge";
967
+ var VERIFY_CHALLENGE = "verify-challenge";
968
+ async function verifyWebhookSignature(body, signature, credential, timestamp, options = {}) {
969
+ if (!recent(timestamp, options.toleranceSecs ?? DEFAULT_TOLERANCE_SECS)) {
970
+ return false;
823
971
  }
824
- return trigger;
825
- }
826
- function backoffMs(firstDelay, attempt) {
827
- const grown = Math.min(firstDelay * 2 ** (attempt - 1), RECONNECT_CAP_MS);
828
- return Math.round(grown * (0.5 + Math.random() / 2));
972
+ if (!signature.startsWith(GATEWAY_KEY_PREFIX)) {
973
+ return false;
974
+ }
975
+ return await verifyHex(
976
+ credential.publicKey.toLowerCase(),
977
+ signature.slice(GATEWAY_KEY_PREFIX.length).toLowerCase(),
978
+ signed(timestamp, body)
979
+ );
829
980
  }
830
- var ThunderBridge = class {
831
- baseUrl;
832
- verify;
833
- token;
834
- secret;
835
- strangers = null;
836
- speaks = null;
837
- constructor(baseUrl, options) {
838
- this.baseUrl = baseUrl.replace(/\/+$/, "");
839
- this.verify = options?.verify ?? true;
840
- this.token = options?.token ?? null;
841
- this.secret = options?.secret ?? null;
981
+ async function parseWebhook(body, signature, credential, timestamp, options = {}) {
982
+ if (!await verifyWebhookSignature(body, signature, credential, timestamp, options)) {
983
+ return null;
842
984
  }
843
- /**
844
- * Whether a token was given, which is what makes an instance yours: a gateway
845
- * started with `GATEWAY_TOKEN` answers nobody else, so anything you hand it
846
- * stays between you and it
847
- */
848
- get isPrivate() {
849
- return this.token !== null;
985
+ const text2 = typeof body === "string" ? body : new TextDecoder().decode(body);
986
+ try {
987
+ return paymentFromWire(JSON.parse(text2));
988
+ } catch {
989
+ return null;
850
990
  }
851
- /**
852
- * Whether the gateway turns away a caller carrying no token, asked by making
853
- * one unauthenticated read it would have to refuse. `isPrivate` answers only
854
- * whether you configured a token, which is your side of the arrangement and
855
- * says nothing about the gateway's, so a made-up token against a public
856
- * instance reads as private and is not. Asked once and remembered, because an
857
- * instance does not change its mind. Anything other than a refusal counts as
858
- * open, so an unreachable gateway fails closed
859
- */
860
- async refusesStrangers() {
861
- this.strangers ??= fetch(`${this.baseUrl}/incoming-payments/${STRANGER_PROBE}`, {
862
- headers: { accept: "application/json" }
863
- }).then((answer) => answer.status === 401).catch(() => false);
864
- return await this.strangers;
991
+ }
992
+ async function parseWebhookRequest(request, credential, options = {}) {
993
+ const signature = request.headers.get(SIGNATURE_HEADER);
994
+ const timestamp = request.headers.get(TIMESTAMP_HEADER);
995
+ if (signature === null || timestamp === null) {
996
+ return null;
865
997
  }
866
- /**
867
- * Ask the gateway for an invoice payable to the first address on your list
868
- * that can issue a provable one, throws `NoWalletAvailableError` when none can
869
- * and `GatewayCheatError` when what comes back is not what you asked for
870
- */
871
- async createPayment(params, options) {
872
- const sent = createRequestBody(
873
- params,
874
- options?.trigger ? sha256Hex(unguessable(options.trigger)) : null
875
- );
876
- const headers = await this.sending("/incoming-payments", sent);
877
- if (options?.idempotencyKey) headers["idempotency-key"] = options.idempotencyKey;
878
- const response = await fetch(`${this.baseUrl}/incoming-payments`, {
879
- method: "POST",
880
- headers,
881
- body: sent
882
- });
883
- if (!response.ok) throw await problemFrom(response);
884
- const payment = await paymentFrom(response);
885
- if (this.verify) await proveOrigin(payment, params);
886
- return payment;
998
+ return parseWebhook(await request.text(), signature, credential, timestamp, options);
999
+ }
1000
+ async function parseWatchedWebhook(body, signature, credential, timestamp, options = {}) {
1001
+ if (!await verifyWebhookSignature(body, signature, credential, timestamp, options)) {
1002
+ return null;
887
1003
  }
888
- /**
889
- * Ask which address would serve an amount without minting anything, throws
890
- * `NoWalletAvailableError` when none would. A quote is a probe and not a
891
- * promise: the address it names can still be refused at create time, because
892
- * whether a wallet returns a provable invoice cannot be known without asking
893
- * it for one, and asking mints it
894
- */
895
- async createQuote(params) {
896
- const sent = quoteRequestBody(params);
897
- const response = await fetch(`${this.baseUrl}/quotes`, {
898
- method: "POST",
899
- headers: await this.sending("/quotes", sent),
900
- body: sent
901
- });
902
- if (!response.ok) throw await problemFrom(response);
903
- const quote = quoteFromWire(await response.json().catch(() => null));
904
- if (quote === null) {
905
- throw new ProblemError({
906
- status: response.status,
907
- title: "The gateway answered with something that is not a quote"
908
- });
909
- }
910
- return quote;
1004
+ const text2 = typeof body === "string" ? body : new TextDecoder().decode(body);
1005
+ try {
1006
+ return triggerEventFromWire(JSON.parse(text2));
1007
+ } catch {
1008
+ return null;
911
1009
  }
912
- /** Read a payment back, null when the gateway has never heard of it */
913
- /**
914
- * The key this gateway signs webhooks with when you registered none of your own,
915
- * ready to hand to `parseWebhookRequest` as `{ publicKey }`. Fetch it once and
916
- * keep it: it is the same for every instance in the cluster
917
- */
918
- async webhookKey() {
919
- const response = await fetch(`${this.baseUrl}/webhook-key`);
920
- if (!response.ok) throw await problemFrom(response);
921
- const body = await response.json().catch(() => null);
922
- if (body?.algorithm !== "ed25519" || typeof body.public_key !== "string") {
923
- throw new ProblemError({
924
- status: response.status,
925
- title: "The gateway published no ed25519 webhook key"
926
- });
927
- }
928
- return body.public_key;
1010
+ }
1011
+ async function parseWatchedWebhookRequest(request, credential, options = {}) {
1012
+ const signature = request.headers.get(SIGNATURE_HEADER);
1013
+ const timestamp = request.headers.get(TIMESTAMP_HEADER);
1014
+ if (signature === null || timestamp === null) {
1015
+ return null;
929
1016
  }
930
- async getPayment(id) {
931
- const path = `/incoming-payments/${encodeURIComponent(id)}`;
932
- const response = await fetch(`${this.baseUrl}${path}`, {
933
- headers: await this.reading("GET", path)
934
- });
935
- if (response.status === 404) return null;
936
- if (!response.ok) throw await problemFrom(response);
937
- return this.checked(await paymentFrom(response));
1017
+ return parseWatchedWebhook(await request.text(), signature, credential, timestamp, options);
1018
+ }
1019
+ async function parseSettlement(body, signature, credential, timestamp, options = {}) {
1020
+ if (!await verifyWebhookSignature(body, signature, credential, timestamp, options)) {
1021
+ return null;
938
1022
  }
939
- /**
940
- * Read back a payment the gateway is only watching, null when it has never
941
- * heard of it. A watched payment carries no address, amount or invoice, so
942
- * `getPayment` refuses it and this reads the shape both rails share
943
- */
944
- async getWatched(id) {
945
- const path = `/incoming-payments/${encodeURIComponent(id)}`;
946
- const response = await fetch(`${this.baseUrl}${path}`, {
947
- headers: await this.reading("GET", path)
948
- });
949
- if (response.status === 404) return null;
950
- if (!response.ok) throw await problemFrom(response);
951
- const watched = triggerEventFromWire(await response.json().catch(() => null));
952
- if (watched === null) {
953
- throw new ProblemError({
954
- status: response.status,
955
- title: "The gateway answered with something that is not a payment"
956
- });
957
- }
958
- return this.proven(watched);
1023
+ try {
1024
+ return settlementFromWire(JSON.parse(textOf(body)));
1025
+ } catch {
1026
+ return null;
959
1027
  }
960
- /**
961
- * List what this gateway is watching, newest first. Only a gateway started
962
- * with `GATEWAY_TOKEN` serves this, because on a shared one it would hand
963
- * every caller everyone else's payments, so a public gateway answers 404.
964
- *
965
- * `scanned` says how many settled records were looked at to build the page.
966
- * Anything older than that window is not in the answer, and the list does not
967
- * pretend otherwise
968
- */
969
- async listPayments(limit) {
970
- const path = `/incoming-payments${limit === void 0 ? "" : `?limit=${limit}`}`;
971
- const response = await fetch(`${this.baseUrl}${path}`, {
972
- headers: await this.reading("GET", path)
973
- });
974
- if (!response.ok) throw await problemFrom(response);
975
- const body = await response.json().catch(() => null);
976
- const listed = Array.isArray(body?.payments) ? body.payments.map(triggerEventFromWire).filter((one) => one !== null) : null;
977
- if (listed === null || typeof body?.settled_scanned !== "number") {
978
- throw new ProblemError({
979
- status: response.status,
980
- title: "The gateway answered with something that is not a payment list"
981
- });
982
- }
983
- return { payments: listed, scanned: body.settled_scanned };
1028
+ }
1029
+ async function parseSettlementRequest(request, credential, options = {}) {
1030
+ const signature = request.headers.get(SIGNATURE_HEADER);
1031
+ const timestamp = request.headers.get(TIMESTAMP_HEADER);
1032
+ if (signature === null || timestamp === null) {
1033
+ return null;
984
1034
  }
985
- /**
986
- * Follow a payment over WebSocket until it is paid or expired, reconnecting
987
- * through a drop. A payment that never answers gives up after a few tries, and
988
- * one that has answered is followed until its own expiry, so the wait always
989
- * ends by itself
990
- */
991
- async waitForPayment(id, options) {
992
- const payment = paymentFromWire(await this.followed(id, options));
993
- if (payment === null) {
994
- throw new ProblemError({
995
- status: 200,
996
- title: `payment ${id} is watched rather than minted here, read it with waitForWatched`
997
- });
998
- }
999
- return this.checked(payment);
1035
+ return await parseSettlement(await request.text(), signature, credential, timestamp, options);
1036
+ }
1037
+ function isProvablySettled(settled) {
1038
+ if (settled.status !== "paid" || settled.preimage === null) {
1039
+ return false;
1000
1040
  }
1001
- /**
1002
- * Follow a payment the gateway is only watching, one it did not mint, until it
1003
- * is paid or expired.
1004
- *
1005
- * A watched payment carries no address, no amount and no invoice, because the
1006
- * gateway was told none of them, so it reads back as the shape a trigger
1007
- * streams rather than as a `Payment`. That is every bank transfer, and every
1008
- * Lightning invoice registered with `watchPayment` instead of `createPayment`.
1009
- */
1010
- async waitForWatched(id, options) {
1011
- const watched = triggerEventFromWire(await this.followed(id, options));
1012
- if (watched === null) {
1013
- throw new ProblemError({
1014
- status: 200,
1015
- title: "The gateway answered with something that is not a payment"
1016
- });
1017
- }
1018
- return this.proven(watched);
1041
+ return preimageMatchesHash(settled.preimage, settled.paymentHash);
1042
+ }
1043
+ async function answerWebhookChallenge(body, signature, credential, timestamp, options = {}) {
1044
+ if (!await verifyWebhookSignature(body, signature, credential, timestamp, options)) {
1045
+ return null;
1019
1046
  }
1020
- followed(id, options) {
1021
- const base = this.baseUrl.replace(/^http/, "ws");
1022
- const direct = `${base}/ws/incoming-payments/${encodeURIComponent(id)}`;
1023
- return new Promise((resolve2, reject) => {
1024
- const aborted = () => new Error(`waiting for payment ${id} was aborted`);
1025
- if (options?.signal?.aborted) {
1026
- reject(aborted());
1027
- return;
1028
- }
1029
- let socket = null;
1030
- let settled = false;
1031
- let attempt = 0;
1032
- let expiresAt = null;
1033
- let retry;
1034
- const abort = () => settle(() => reject(aborted()));
1035
- const settle = (finish) => {
1036
- if (settled) return;
1037
- settled = true;
1038
- options?.signal?.removeEventListener("abort", abort);
1039
- clearTimeout(retry);
1040
- finish();
1041
- socket?.close();
1042
- };
1043
- const again = () => {
1044
- if (settled) return;
1045
- if (expiresAt === null && attempt >= UNANSWERED_ATTEMPTS) {
1046
- settle(() => reject(new Error(`no gateway answered for payment ${id}`)));
1047
- return;
1048
- }
1049
- if (expiresAt !== null && Date.now() >= expiresAt * 1e3) {
1050
- settle(() => reject(new Error(`payment ${id} went unreported past its expiry`)));
1051
- return;
1052
- }
1053
- retry = setTimeout(() => void connect(), backoffMs(RECONNECT_DELAY_MS, attempt));
1054
- };
1055
- const connect = async () => {
1056
- if (settled) return;
1057
- attempt += 1;
1058
- let url = direct;
1059
- if (this.needsTicket(options?.tickets)) {
1060
- try {
1061
- url = `${base}/ws/tickets/${await this.wsTicket({ payment_id: id })}`;
1062
- } catch (refused) {
1063
- if (refused instanceof ProblemError) settle(() => reject(refused));
1064
- else again();
1065
- return;
1066
- }
1067
- }
1068
- if (settled) return;
1069
- const opened = new WebSocket(url);
1070
- socket = opened;
1071
- opened.onmessage = (event) => {
1072
- try {
1073
- const frame = JSON.parse(String(event.data));
1074
- const watched = triggerEventFromWire(frame);
1075
- if (watched === null) return;
1076
- expiresAt = watched.expiresAt;
1077
- attempt = 1;
1078
- if (!TERMINAL.has(watched.status)) return;
1079
- settle(() => resolve2(frame));
1080
- } catch (refused) {
1081
- settle(() => reject(refused));
1082
- }
1083
- };
1084
- opened.onclose = again;
1085
- };
1086
- options?.signal?.addEventListener("abort", abort, { once: true });
1087
- void connect();
1088
- });
1047
+ const nonce = challenged(typeof body === "string" ? body : new TextDecoder().decode(body));
1048
+ return nonce === null ? null : JSON.stringify({ nonce });
1049
+ }
1050
+ async function answerWebhookChallengeRequest(request, credential, options = {}) {
1051
+ const signature = request.headers.get(SIGNATURE_HEADER);
1052
+ const timestamp = request.headers.get(TIMESTAMP_HEADER);
1053
+ if (signature === null || timestamp === null) {
1054
+ return null;
1089
1055
  }
1090
- /**
1091
- * Wait on several payments and keep the first one that is really paid, then stop
1092
- * waiting on the losers, which closes their sockets.
1093
- *
1094
- * This is how one order offers two rails. A Lightning invoice and a bank
1095
- * transfer for the same thing are two payments here, and the payer picks one, so
1096
- * what you want is the one that arrives and nothing further from the other.
1097
- *
1098
- * A leg that expires is a loser, not a winner, which is the whole reason this is
1099
- * not a race: `waitForPayment` ends on `paid` and on `expired` alike, and a
1100
- * Lightning invoice expires in an hour while a bank transfer takes days. `null`
1101
- * means every leg ended without being paid.
1102
- *
1103
- * Stopping the wait is not revoking the invoice. Nobody can revoke one, because
1104
- * the recipient's own wallet minted it, so a payer who pays the loser afterwards
1105
- * really does pay twice and that shows up on `followTrigger` as a second
1106
- * settlement to refund.
1107
- */
1108
- async firstToSettle(ids, options) {
1109
- if (ids.length === 0) return null;
1110
- const stopLosers = new AbortController();
1111
- const signal = options?.signal ? AbortSignal.any([stopLosers.signal, options.signal]) : stopLosers.signal;
1112
- let refused = null;
1113
- try {
1114
- const winner = await new Promise((resolve2) => {
1115
- let waiting = ids.length;
1116
- const lost = () => {
1117
- waiting -= 1;
1118
- if (waiting === 0) resolve2(null);
1119
- };
1120
- for (const id of ids) {
1121
- this.waitForWatched(id, { ...options, signal }).then((watched) => watched.status === "paid" ? resolve2(watched) : lost()).catch((failure) => {
1122
- refused ??= failure;
1123
- lost();
1124
- });
1125
- }
1126
- });
1127
- if (winner === null && refused !== null) throw refused;
1128
- return winner;
1129
- } finally {
1130
- stopLosers.abort();
1056
+ const answer = await answerWebhookChallenge(
1057
+ await request.clone().text(),
1058
+ signature,
1059
+ credential,
1060
+ timestamp,
1061
+ options
1062
+ );
1063
+ return answer === null ? null : new Response(answer, { headers: { "content-type": "application/json" } });
1064
+ }
1065
+ function textOf(body) {
1066
+ return typeof body === "string" ? body : new TextDecoder().decode(body);
1067
+ }
1068
+ function challenged(text2) {
1069
+ return nonceOf(text2, CHALLENGE);
1070
+ }
1071
+ function nonceOf(text2, type) {
1072
+ try {
1073
+ const said = JSON.parse(text2);
1074
+ if (said["type"] !== type || typeof said["nonce"] !== "string") {
1075
+ return null;
1131
1076
  }
1077
+ return said["nonce"];
1078
+ } catch {
1079
+ return null;
1132
1080
  }
1133
- /**
1134
- * Hand over an invoice you obtained yourself so the gateway watches it without
1135
- * being told the address or the amount. It can then only refuse everyone
1136
- * rather than one recipient, which is what makes leaving it cheap. Anything
1137
- * the watcher needs goes in `sealed`, which the gateway cannot read
1138
- */
1139
- async watchPayment(params) {
1140
- const sent = watchRequestBody(
1141
- params,
1142
- params.trigger ? sha256Hex(unguessable(params.trigger)) : null
1143
- );
1144
- const response = await fetch(`${this.baseUrl}/watched-payments`, {
1145
- method: "POST",
1146
- headers: await this.sending("/watched-payments", sent),
1147
- body: sent
1148
- });
1149
- if (!response.ok) throw await problemFrom(response);
1150
- const watched = triggerEventFromWire(await response.json().catch(() => null));
1151
- if (watched === null) {
1152
- throw new ProblemError({
1153
- status: response.status,
1154
- title: "The gateway answered with something that is not a watched payment"
1155
- });
1081
+ }
1082
+ function answerVerifyChallenge(body) {
1083
+ const nonce = nonceOf(body, VERIFY_CHALLENGE);
1084
+ return nonce === null ? null : JSON.stringify({ nonce });
1085
+ }
1086
+ async function answerVerifyChallengeRequest(request) {
1087
+ if (request.method !== "POST") {
1088
+ return null;
1089
+ }
1090
+ const answer = answerVerifyChallenge(await request.clone().text());
1091
+ return answer === null ? null : new Response(answer, { headers: { "content-type": "application/json" } });
1092
+ }
1093
+ function recent(timestamp, toleranceSecs) {
1094
+ const sent = Number(timestamp);
1095
+ if (!Number.isFinite(sent)) {
1096
+ return false;
1097
+ }
1098
+ return Math.abs(Math.floor(Date.now() / 1e3) - sent) <= toleranceSecs;
1099
+ }
1100
+ function signed(timestamp, body) {
1101
+ const encoder = new TextEncoder();
1102
+ const stamp = encoder.encode(`${timestamp}.`);
1103
+ const rest = typeof body === "string" ? encoder.encode(body) : new Uint8Array(body);
1104
+ const payload = new Uint8Array(stamp.length + rest.length);
1105
+ payload.set(stamp);
1106
+ payload.set(rest, stamp.length);
1107
+ return payload;
1108
+ }
1109
+
1110
+ // src/bank.ts
1111
+ var DEFAULT_CURRENCY = "CZK";
1112
+ var DEFAULT_LOOK_BACK_SECS = 7 * 24 * 60 * 60;
1113
+ var DEFAULT_POLL_EVERY_SECS = 30;
1114
+ var IBAN = /^[A-Z]{2}[0-9]{2}[0-9A-Z]{8,30}$/;
1115
+ var FORBIDDEN_IN_SPD = "*";
1116
+ async function bankTransfer(params) {
1117
+ const currency = params.currency ?? DEFAULT_CURRENCY;
1118
+ refuseUnusable(params, currency);
1119
+ await refuseAnOpenGateway(params);
1120
+ const spd = shortPaymentDescriptor(params, currency);
1121
+ const subject = subjectOf(params.reference, params.amountMinor, currency);
1122
+ const polling = new URL(params.verifyUrl);
1123
+ polling.searchParams.set("ref", params.reference);
1124
+ polling.searchParams.set("minor", String(params.amountMinor));
1125
+ polling.searchParams.set("cc", currency);
1126
+ polling.searchParams.set("sig", await hmacHex(params.secret, `verify|${subject}`));
1127
+ const verifyUrl = polling.toString();
1128
+ const watched = await params.gateway.watchPayment({
1129
+ paymentHash: hashOf(await hmacHex(params.secret, `preimage|${subject}`)),
1130
+ verifyUrl,
1131
+ expiresAt: params.expiresAt,
1132
+ trigger: params.trigger,
1133
+ replay: params.replay,
1134
+ sealed: params.sealed,
1135
+ webhookUrl: params.webhookUrl
1136
+ });
1137
+ return { id: watched.id, paymentHash: watched.paymentHash, verifyUrl, spd };
1138
+ }
1139
+ function bankVerifyEndpoint(config) {
1140
+ const paced = {
1141
+ "cache-control": `max-age=${config.pollEverySecs ?? DEFAULT_POLL_EVERY_SECS}`
1142
+ };
1143
+ return async (request) => {
1144
+ const consented = await answerVerifyChallengeRequest(request);
1145
+ if (consented !== null) {
1146
+ return consented;
1156
1147
  }
1157
- const named = await this.nameFor(params.paymentHash);
1158
- if (named !== null && watched.id !== named) {
1159
- throw new GatewayCheatError("id_not_mine", watched.id);
1148
+ const asked = readQuery(new URL(request.url));
1149
+ if (asked === null) {
1150
+ return Response.json({ settled: false }, { status: 400 });
1160
1151
  }
1161
- return watched;
1152
+ const subject = subjectOf(asked.reference, asked.amountMinor, asked.currency);
1153
+ const expected = await hmacHex(config.secret, `verify|${subject}`);
1154
+ if (!equalInConstantTime(asked.signature, expected)) {
1155
+ return Response.json({ settled: false }, { status: 403 });
1156
+ }
1157
+ const since = unixNow() - (config.lookBackSecs ?? DEFAULT_LOOK_BACK_SECS);
1158
+ const landed = (await config.statement(since)).some((credit) => pays(credit, asked));
1159
+ if (!landed) {
1160
+ return Response.json({ settled: false }, { headers: paced });
1161
+ }
1162
+ return Response.json(
1163
+ { settled: true, preimage: await hmacHex(config.secret, `preimage|${subject}`) },
1164
+ { headers: paced }
1165
+ );
1166
+ };
1167
+ }
1168
+ function readQuery(url) {
1169
+ const reference = url.searchParams.get("ref");
1170
+ const minor = Number(url.searchParams.get("minor"));
1171
+ const currency = url.searchParams.get("cc");
1172
+ const signature = url.searchParams.get("sig");
1173
+ if (!reference || !currency || !signature) {
1174
+ return null;
1162
1175
  }
1163
- /**
1164
- * What this payment is called, which you can work out before any gateway has
1165
- * heard of it. Every gateway you hand the same invoice to answers with the same
1166
- * name, so watching at several of them adds up to one payment rather than
1167
- * several, and no gateway's key is in the answer. Null when no secret was given,
1168
- * because then the gateway names the payment and only it can
1169
- */
1170
- async nameFor(paymentHash) {
1171
- return this.secret === null ? null : paymentNamedBy((await this.speaking()).publicKeyHex, paymentHash);
1176
+ if (!Number.isInteger(minor) || minor <= 0) {
1177
+ return null;
1172
1178
  }
1173
- /**
1174
- * Follow every payment made to one trigger, replayed from the recent ones on
1175
- * connect and then live, reconnecting on its own until the returned function
1176
- * is called. A trigger has no terminal state, so this never resolves
1177
- */
1178
- followTrigger(secret, options) {
1179
- const base = this.baseUrl.replace(/^http/, "ws");
1180
- const direct = `${base}/ws/triggers/${encodeURIComponent(unguessable(secret))}`;
1181
- const firstDelay = options.reconnectDelayMs ?? RECONNECT_DELAY_MS;
1182
- let socket = null;
1183
- let retry;
1184
- let stopped = false;
1185
- let attempt = 0;
1186
- const again = () => {
1187
- if (stopped || options.reconnect === false) return;
1188
- retry = setTimeout(() => void connect(), backoffMs(firstDelay, attempt));
1189
- };
1190
- const connect = async () => {
1191
- attempt += 1;
1192
- let url = direct;
1193
- if (this.needsTicket(options.tickets)) {
1194
- try {
1195
- url = `${base}/ws/tickets/${await this.wsTicket({ trigger_secret: secret })}`;
1196
- } catch (refused) {
1197
- options.onError?.(refused);
1198
- again();
1199
- return;
1200
- }
1201
- }
1202
- if (stopped) return;
1203
- socket = new WebSocket(url);
1204
- socket.onopen = () => {
1205
- attempt = 1;
1206
- };
1207
- socket.onmessage = (event) => {
1208
- try {
1209
- const settled = triggerEventFromWire(JSON.parse(String(event.data)));
1210
- if (settled !== null) options.onPayment(this.proven(settled));
1211
- } catch (refused) {
1212
- options.onError?.(refused);
1213
- }
1214
- };
1215
- socket.onerror = () => options.onError?.(new Error(`could not follow trigger at ${base}`));
1216
- socket.onclose = again;
1217
- };
1218
- void connect();
1219
- return () => {
1220
- stopped = true;
1221
- clearTimeout(retry);
1222
- socket?.close();
1223
- };
1224
- }
1225
- needsTicket(asked) {
1226
- return asked === true || this.token !== null;
1179
+ return { reference, amountMinor: minor, currency, signature };
1180
+ }
1181
+ function pays(credit, asked) {
1182
+ return credit.amountMinor === asked.amountMinor && credit.currency.toUpperCase() === asked.currency.toUpperCase() && credit.reference.toUpperCase().includes(asked.reference.toUpperCase());
1183
+ }
1184
+ function subjectOf(reference, amountMinor, currency) {
1185
+ return `${reference}|${amountMinor}|${currency.toUpperCase()}`;
1186
+ }
1187
+ function hashOf(preimage) {
1188
+ return bytesToHex(sha256(hexToBytes(preimage)));
1189
+ }
1190
+ function shortPaymentDescriptor(params, currency) {
1191
+ const fields = [
1192
+ `ACC:${params.iban}`,
1193
+ `AM:${major(params.amountMinor, currency)}`,
1194
+ `CC:${currency.toUpperCase()}`,
1195
+ `MSG:${params.reference}`
1196
+ ];
1197
+ if (params.variableSymbol) {
1198
+ fields.push(`X-VS:${params.variableSymbol}`);
1227
1199
  }
1228
- async wsTicket(body) {
1229
- const sent = JSON.stringify(body);
1230
- const response = await fetch(`${this.baseUrl}/ws-tickets`, {
1231
- method: "POST",
1232
- headers: await this.sending("/ws-tickets", sent),
1233
- body: sent
1234
- });
1235
- if (!response.ok) throw await problemFrom(response);
1236
- const minted = await response.json().catch(() => null);
1237
- if (typeof minted?.ticket !== "string") {
1238
- throw new ProblemError({
1239
- status: response.status,
1240
- title: "The gateway answered with something that is not a ticket"
1241
- });
1242
- }
1243
- return encodeURIComponent(minted.ticket);
1200
+ return `SPD*1.0*${fields.join("*")}`;
1201
+ }
1202
+ function major(amountMinor, currency) {
1203
+ return (amountMinor / minorScaleOf(currency)).toFixed(minorUnitsOf(currency));
1204
+ }
1205
+ async function refuseAnOpenGateway(params) {
1206
+ if (params.allowPublicGateway === true) {
1207
+ return;
1244
1208
  }
1245
- async sending(path, body) {
1246
- return { "content-type": "application/json", ...await this.reading("POST", path, body) };
1209
+ if (await params.gateway.refusesStrangers()) {
1210
+ return;
1247
1211
  }
1248
- async reading(method, path, body = "") {
1249
- return {
1250
- ...this.token === null ? {} : { authorization: `Bearer ${this.token}` },
1251
- ...this.secret === null ? {} : await signedAs(await this.speaking(), method, path, body)
1252
- };
1212
+ throw new Error(
1213
+ "this gateway serves callers with no token, so it is not yours, and its operator would read the amount and the reference off every verify URL. Point at one that answers 401 to a stranger, or say allowPublicGateway"
1214
+ );
1215
+ }
1216
+ function refuseUnusable(params, currency) {
1217
+ if (!IBAN.test(params.iban)) {
1218
+ throw new Error(`${params.iban} is not an IBAN`);
1253
1219
  }
1254
- async speaking() {
1255
- this.speaks ??= callerKey(this.secret ?? "");
1256
- return await this.speaks;
1220
+ if (!Number.isInteger(params.amountMinor) || params.amountMinor <= 0) {
1221
+ throw new Error("amountMinor must be a whole number of minor units above zero");
1257
1222
  }
1258
- proven(settled) {
1259
- const unproven = settled.status === "paid" && (settled.preimage === null || !preimageMatchesHash(settled.preimage, settled.paymentHash));
1260
- if (this.verify && unproven) throw new GatewayCheatError("preimage_mismatch", settled.id);
1261
- return settled;
1223
+ if (params.reference.length === 0) {
1224
+ throw new Error("a transfer with no reference cannot be found");
1262
1225
  }
1263
- checked(payment) {
1264
- if (this.verify && payment.status === "paid" && !isProvablyPaid(payment)) {
1265
- throw new GatewayCheatError("preimage_mismatch", payment.id);
1266
- }
1267
- return payment;
1226
+ if (params.reference.includes(FORBIDDEN_IN_SPD)) {
1227
+ throw new Error("a reference cannot contain an asterisk, it separates the QR fields");
1268
1228
  }
1269
- };
1270
- async function paymentFrom(response) {
1271
- const payment = paymentFromWire(await response.json().catch(() => null));
1272
- if (payment === null) {
1273
- throw new ProblemError({
1274
- status: response.status,
1275
- title: "The gateway answered with something that is not a payment"
1276
- });
1229
+ if (params.variableSymbol && !/^[0-9]{1,10}$/.test(params.variableSymbol)) {
1230
+ throw new Error("a variable symbol is up to ten digits");
1277
1231
  }
1278
- return payment;
1279
1232
  }
1280
- async function problemFrom(response) {
1281
- const problem = await response.json().catch(() => ({}));
1282
- const document = { ...problem, status: response.status };
1283
- if (isProblemType(problem, NO_WALLET_AVAILABLE)) {
1284
- return new NoWalletAvailableError(document, refusals(problem.wallets));
1285
- }
1286
- if (isProblemType(problem, REQUEST_IN_FLIGHT)) {
1287
- return new IdempotencyConflictError(document, "request-in-flight");
1288
- }
1289
- if (isProblemType(problem, IDEMPOTENCY_KEY_REUSED)) {
1290
- return new IdempotencyConflictError(document, "key-reused");
1291
- }
1292
- return new ProblemError(document);
1233
+ function unixNow() {
1234
+ return Math.floor(Date.now() / 1e3);
1293
1235
  }
1294
- function refusals(wallets) {
1295
- if (!Array.isArray(wallets)) return [];
1296
- return wallets.filter((wallet) => {
1297
- return typeof wallet === "object" && wallet !== null && "address" in wallet;
1298
- });
1236
+
1237
+ // ../core/caller.ts
1238
+ var KEY_HEADER = "x-client-key";
1239
+ var SIGNATURE_HEADER2 = "x-signature";
1240
+ var TIMESTAMP_HEADER2 = "x-timestamp";
1241
+ var PREFIX = "ed25519=";
1242
+ var SIGNING_LABEL = "request-signing";
1243
+ async function callerKey(secret) {
1244
+ return await signingKeyFromSeed(hexToBytes(await hmacHex(secret, SIGNING_LABEL)));
1245
+ }
1246
+ function paymentNamedBy(caller, paymentHash) {
1247
+ return sha256Hex(`${caller}.payment-id.${paymentHash}`);
1248
+ }
1249
+ async function signedAs(key, method, path, body) {
1250
+ const timestamp = String(Math.floor(Date.now() / 1e3));
1251
+ return {
1252
+ [KEY_HEADER]: key.publicKeyHex,
1253
+ [TIMESTAMP_HEADER2]: timestamp,
1254
+ [SIGNATURE_HEADER2]: `${PREFIX}${await key.sign(spoken(timestamp, method, path, body))}`
1255
+ };
1256
+ }
1257
+ function spoken(timestamp, method, path, body) {
1258
+ return new TextEncoder().encode(
1259
+ `${timestamp}.${method.toUpperCase()}.${path}.${sha256Hex(body)}`
1260
+ );
1299
1261
  }
1300
1262
 
1301
- // src/gateways.ts
1302
- var Gateways = class {
1303
- each;
1304
- urls;
1305
- onRefused;
1306
- constructor(baseUrls, options) {
1307
- if (baseUrls.length === 0) throw new Error("name at least one gateway to watch at");
1308
- this.urls = [...baseUrls];
1309
- this.each = baseUrls.map((baseUrl) => new ThunderBridge(baseUrl, options));
1310
- this.onRefused = options?.onRefused ?? (() => {
1311
- });
1263
+ // src/errors.ts
1264
+ var GatewayCheatError = class extends Error {
1265
+ code;
1266
+ paymentId;
1267
+ constructor(code, paymentId) {
1268
+ super(`gateway verification failed: ${code}`);
1269
+ this.name = "GatewayCheatError";
1270
+ this.code = code;
1271
+ this.paymentId = paymentId;
1312
1272
  }
1313
- /** What this payment is called, which every gateway here will agree on */
1314
- async nameFor(paymentHash) {
1315
- return await this.each[0].nameFor(paymentHash);
1273
+ };
1274
+ var WrapRefusedError = class extends Error {
1275
+ code;
1276
+ constructor(code, detail) {
1277
+ super(`wrapped invoice refused: ${code}, ${detail}`);
1278
+ this.name = "WrapRefusedError";
1279
+ this.code = code;
1316
1280
  }
1317
- /**
1318
- * Hand the same invoice to every gateway. Throws only when none of them took it,
1319
- * carrying the first refusal, because one gateway that agreed is enough to be
1320
- * watched
1321
- */
1322
- async watchPayment(params) {
1323
- const asked = await Promise.allSettled(this.each.map((gateway) => gateway.watchPayment(params)));
1324
- const taken = [];
1325
- const refusals3 = [];
1326
- for (const [at, answer] of asked.entries()) {
1327
- if (answer.status === "fulfilled") {
1328
- taken.push(answer.value);
1329
- continue;
1330
- }
1331
- refusals3.push(answer.reason);
1332
- this.onRefused(this.urls[at], answer.reason);
1333
- }
1334
- if (taken[0] === void 0) throw refusals3[0];
1335
- return taken[0];
1281
+ };
1282
+ var UnverifiedRecipientError = class extends Error {
1283
+ lnAddress;
1284
+ paymentId;
1285
+ constructor(lnAddress, paymentId, cause) {
1286
+ super(`could not reach ${lnAddress} to verify the invoice: ${String(cause)}`);
1287
+ this.name = "UnverifiedRecipientError";
1288
+ this.lnAddress = lnAddress;
1289
+ this.paymentId = paymentId;
1336
1290
  }
1337
- /**
1338
- * Wait for whichever gateway speaks first. A settlement from any of them is the
1339
- * settlement, and each has already proved the preimage against the hash before
1340
- * saying so. When they all end without a payment, the first ending is the answer,
1341
- * and when they all fail, the first failure is thrown
1342
- */
1343
- async waitForWatched(id, options) {
1344
- const stopLosers = new AbortController();
1345
- const signal = options?.signal ? AbortSignal.any([stopLosers.signal, options.signal]) : stopLosers.signal;
1346
- const unpaid = [];
1347
- let refused = null;
1348
- try {
1349
- const settled = await new Promise((resolve2) => {
1350
- let waiting = this.each.length;
1351
- const lost = () => {
1352
- waiting -= 1;
1353
- if (waiting === 0) resolve2(null);
1354
- };
1355
- for (const gateway of this.each) {
1356
- gateway.waitForWatched(id, { ...options, signal }).then((watched) => {
1357
- if (watched.status === "paid") return resolve2(watched);
1358
- unpaid.push(watched);
1359
- lost();
1360
- }).catch((failure) => {
1361
- refused ??= failure;
1362
- lost();
1363
- });
1364
- }
1365
- });
1366
- if (settled !== null) return settled;
1367
- if (unpaid.length > 0) return unpaid[0];
1368
- throw refused;
1369
- } finally {
1370
- stopLosers.abort();
1371
- }
1291
+ };
1292
+ var ProblemError = class extends Error {
1293
+ type;
1294
+ title;
1295
+ status;
1296
+ detail;
1297
+ constructor(problem) {
1298
+ const title = problem.title ?? "Request failed";
1299
+ super(problem.detail ? `${title}: ${problem.detail}` : title);
1300
+ this.name = "ProblemError";
1301
+ this.type = problem.type ?? "about:blank";
1302
+ this.title = title;
1303
+ this.status = problem.status ?? 0;
1304
+ this.detail = problem.detail ?? null;
1372
1305
  }
1373
1306
  };
1374
-
1375
- // ../core/sealed.ts
1376
- var VERSION = "v1";
1377
- var IV_BYTES = 12;
1378
- var MIN_SECRET_CHARS = 32;
1379
- var MAX_PLAIN_BYTES = 3e3;
1380
- var INFO = new TextEncoder().encode("thunder-bridge/sealed");
1381
- async function seal(secret, plaintext) {
1382
- const body = new TextEncoder().encode(plaintext);
1383
- if (body.length > MAX_PLAIN_BYTES) {
1384
- throw new Error(`sealed takes at most ${MAX_PLAIN_BYTES} bytes, this was ${body.length}`);
1385
- }
1386
- const key = await keyFor(secret);
1387
- const iv = crypto.getRandomValues(new Uint8Array(IV_BYTES));
1388
- const cipher = new Uint8Array(await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, body));
1389
- const joined = new Uint8Array(iv.length + cipher.length);
1390
- joined.set(iv);
1391
- joined.set(cipher, iv.length);
1392
- return `${VERSION}.${toBase64Url(joined)}`;
1307
+ function isProblemType(problem, type) {
1308
+ return problem.type === type;
1393
1309
  }
1394
- async function unseal(secret, sealed) {
1395
- const key = await keyFor(secret);
1396
- if (!sealed.startsWith(`${VERSION}.`)) return null;
1397
- const joined = fromBase64Url2(sealed.slice(VERSION.length + 1));
1398
- if (joined === null || joined.length <= IV_BYTES) return null;
1399
- try {
1400
- const body = await crypto.subtle.decrypt(
1401
- { name: "AES-GCM", iv: joined.slice(0, IV_BYTES) },
1402
- key,
1403
- joined.slice(IV_BYTES)
1404
- );
1405
- return new TextDecoder().decode(body);
1406
- } catch {
1407
- return null;
1310
+ var NO_WALLET_AVAILABLE = "urn:problem-type:thunder-bridge:no-wallet-available";
1311
+ var REQUEST_IN_FLIGHT = "urn:problem-type:thunder-bridge:request-in-flight";
1312
+ var IDEMPOTENCY_KEY_REUSED = "urn:problem-type:thunder-bridge:idempotency-key-reused";
1313
+ var PAYMENT_ALREADY_WATCHED = "urn:problem-type:thunder-bridge:payment-already-watched";
1314
+ var IdempotencyConflictError = class extends ProblemError {
1315
+ conflict;
1316
+ constructor(problem, conflict) {
1317
+ super(problem);
1318
+ this.name = "IdempotencyConflictError";
1319
+ this.conflict = conflict;
1408
1320
  }
1409
- }
1410
- async function keyFor(secret) {
1411
- if (secret.length < MIN_SECRET_CHARS) {
1412
- throw new Error(`the sealing secret needs ${MIN_SECRET_CHARS} characters of randomness`);
1321
+ };
1322
+ var NoWalletAvailableError = class extends ProblemError {
1323
+ wallets;
1324
+ constructor(problem, wallets) {
1325
+ super({ ...problem, type: NO_WALLET_AVAILABLE });
1326
+ this.name = "NoWalletAvailableError";
1327
+ this.wallets = wallets;
1413
1328
  }
1414
- const material = await crypto.subtle.importKey(
1415
- "raw",
1416
- new TextEncoder().encode(secret),
1417
- "HKDF",
1418
- false,
1419
- ["deriveKey"]
1420
- );
1421
- return crypto.subtle.deriveKey(
1422
- { name: "HKDF", hash: "SHA-256", salt: new Uint8Array(0), info: INFO },
1423
- material,
1424
- { name: "AES-GCM", length: 256 },
1425
- false,
1426
- ["encrypt", "decrypt"]
1329
+ };
1330
+
1331
+ // src/verify.ts
1332
+ var HTTP_TIMEOUT_MS = 15e3;
1333
+ var DEFAULT_WRAP_PROPORTION = 0.01;
1334
+ var DEFAULT_WRAP_BASE_MSAT = 1e3;
1335
+ async function proveOrigin(payment, request) {
1336
+ const cheat = (code) => new GatewayCheatError(code, payment.id);
1337
+ const listed = request.lnAddresses.find(
1338
+ (address) => equalIgnoringCase(address, payment.lnAddress)
1427
1339
  );
1340
+ if (listed === void 0) {
1341
+ throw cheat("address_not_requested");
1342
+ }
1343
+ if (payment.amountMsat !== request.amountMsat) {
1344
+ throw cheat("amount_mismatch");
1345
+ }
1346
+ const invoice = decodeInvoice(payment.bolt11);
1347
+ if (invoice.paymentHash === null || !equalIgnoringCase(invoice.paymentHash, payment.paymentHash)) {
1348
+ throw cheat("hash_mismatch");
1349
+ }
1350
+ if (invoice.amountMsat !== request.amountMsat) {
1351
+ throw cheat("amount_mismatch");
1352
+ }
1353
+ const payRequest = await payRequestFor(listed, payment);
1354
+ if (typeof payRequest.metadata !== "string" || typeof payRequest.callback !== "string") {
1355
+ throw new UnverifiedRecipientError(payment.lnAddress, payment.id, "no payRequest served");
1356
+ }
1357
+ if (invoice.descriptionHash !== sha256Hex(payRequest.metadata)) {
1358
+ throw cheat("description_hash_mismatch");
1359
+ }
1360
+ if (!sameOrigin(payment.verifyUrl, payRequest.callback)) {
1361
+ throw cheat("verify_url_foreign");
1362
+ }
1363
+ const issued = await reachable(payment.verifyUrl, payment);
1364
+ if (typeof issued.pr !== "string" || !equalIgnoringCase(issued.pr, payment.bolt11)) {
1365
+ throw cheat("invoice_not_issued");
1366
+ }
1428
1367
  }
1429
- function toBase64Url(bytes) {
1430
- let binary = "";
1431
- for (const byte of bytes) binary += String.fromCharCode(byte);
1432
- return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
1433
- }
1434
- function fromBase64Url2(text2) {
1435
- if (text2.length === 0 || !/^[A-Za-z0-9_-]+$/.test(text2)) return null;
1436
- try {
1437
- const binary = atob(text2.replace(/-/g, "+").replace(/_/g, "/"));
1438
- const bytes = new Uint8Array(binary.length);
1439
- for (let at = 0; at < bytes.length; at++) bytes[at] = binary.charCodeAt(at);
1440
- return bytes;
1441
- } catch {
1368
+ async function proveSettlement(payment, request) {
1369
+ await proveOrigin(payment, request);
1370
+ const verified = await reachable(payment.verifyUrl, payment);
1371
+ if (verified.settled !== true || typeof verified.preimage !== "string") {
1442
1372
  return null;
1443
1373
  }
1444
- }
1445
-
1446
- // src/currency.ts
1447
- var ISO_4217_MINOR_UNITS = {
1448
- BHD: 3,
1449
- BIF: 0,
1450
- CLP: 0,
1451
- CZK: 2,
1452
- DJF: 0,
1453
- EUR: 2,
1454
- GBP: 2,
1455
- ISK: 0,
1456
- IQD: 3,
1457
- JOD: 3,
1458
- JPY: 0,
1459
- KRW: 0,
1460
- KWD: 3,
1461
- LYD: 3,
1462
- OMR: 3,
1463
- PLN: 2,
1464
- PYG: 0,
1465
- RWF: 0,
1466
- TND: 3,
1467
- UGX: 0,
1468
- USD: 2,
1469
- VND: 0,
1470
- VUV: 0,
1471
- XAF: 0,
1472
- XOF: 0,
1473
- XPF: 0
1474
- };
1475
- function minorUnitsOf(currency) {
1476
- const digits = ISO_4217_MINOR_UNITS[currency.toUpperCase()];
1477
- if (digits === void 0) {
1478
- throw new Error(
1479
- `${currency.toUpperCase()} is not a currency this knows the ISO 4217 minor unit of`
1480
- );
1374
+ if (!preimageMatchesHash(verified.preimage, payment.paymentHash)) {
1375
+ throw new GatewayCheatError("preimage_mismatch", payment.id);
1481
1376
  }
1482
- return digits;
1483
- }
1484
- function minorScaleOf(currency) {
1485
- return 10 ** minorUnitsOf(currency);
1486
- }
1487
-
1488
- // src/webhook.ts
1489
- var SIGNATURE_HEADER2 = "x-signature";
1490
- var TIMESTAMP_HEADER2 = "x-timestamp";
1491
- var GATEWAY_KEY_PREFIX = "ed25519=";
1492
- var DEFAULT_TOLERANCE_SECS = 300;
1493
- var CHALLENGE = "webhook-challenge";
1494
- var VERIFY_CHALLENGE = "verify-challenge";
1495
- async function verifyWebhookSignature(body, signature, credential, timestamp, options = {}) {
1496
- if (!recent(timestamp, options.toleranceSecs ?? DEFAULT_TOLERANCE_SECS)) return false;
1497
- if (!signature.startsWith(GATEWAY_KEY_PREFIX)) return false;
1498
- return await verifyHex(
1499
- credential.publicKey.toLowerCase(),
1500
- signature.slice(GATEWAY_KEY_PREFIX.length).toLowerCase(),
1501
- signed(timestamp, body)
1502
- );
1377
+ return verified.preimage;
1503
1378
  }
1504
- async function parseWebhook(body, signature, credential, timestamp, options = {}) {
1505
- if (!await verifyWebhookSignature(body, signature, credential, timestamp, options)) return null;
1506
- const text2 = typeof body === "string" ? body : new TextDecoder().decode(body);
1507
- try {
1508
- return paymentFromWire(JSON.parse(text2));
1509
- } catch {
1510
- return null;
1379
+ function isProvablyPaid(payment) {
1380
+ if (payment.status !== "paid" || payment.preimage === null) {
1381
+ return false;
1511
1382
  }
1383
+ const invoiceHash = decodeInvoice(payment.bolt11).paymentHash;
1384
+ if (invoiceHash === null || !equalIgnoringCase(invoiceHash, payment.paymentHash)) {
1385
+ return false;
1386
+ }
1387
+ return preimageMatchesHash(payment.preimage, invoiceHash);
1512
1388
  }
1513
- async function parseWebhookRequest(request, credential, options = {}) {
1514
- const signature = request.headers.get(SIGNATURE_HEADER2);
1515
- const timestamp = request.headers.get(TIMESTAMP_HEADER2);
1516
- if (signature === null || timestamp === null) return null;
1517
- return parseWebhook(await request.text(), signature, credential, timestamp, options);
1389
+ function wrapFeeCeiling(amountMsat, allowance) {
1390
+ const proportional = amountMsat * (allowance?.proportion ?? DEFAULT_WRAP_PROPORTION);
1391
+ return Math.max(allowance?.baseMsat ?? DEFAULT_WRAP_BASE_MSAT, Math.floor(proportional));
1518
1392
  }
1519
- async function parseWatchedWebhook(body, signature, credential, timestamp, options = {}) {
1520
- if (!await verifyWebhookSignature(body, signature, credential, timestamp, options)) return null;
1521
- const text2 = typeof body === "string" ? body : new TextDecoder().decode(body);
1522
- try {
1523
- return triggerEventFromWire(JSON.parse(text2));
1524
- } catch {
1525
- return null;
1393
+ function proveWrapped(wrapped, recipient, allowance) {
1394
+ const offered = decodeInvoice(wrapped);
1395
+ const owed = decodeInvoice(recipient);
1396
+ const refuse = (code, detail) => {
1397
+ throw new WrapRefusedError(code, detail);
1398
+ };
1399
+ if (offered.paymentHash === null || offered.amountMsat === null || offered.expiresAt === null) {
1400
+ refuse("undecodable", "the wrapped invoice carries no hash, amount or expiry");
1401
+ }
1402
+ if (owed.paymentHash === null || owed.amountMsat === null || owed.expiresAt === null) {
1403
+ refuse("undecodable", "the recipient's invoice carries no hash, amount or expiry");
1404
+ }
1405
+ if (offered.paymentHash?.toLowerCase() !== owed.paymentHash?.toLowerCase()) {
1406
+ refuse("hash_mismatch", "settling the wrap would not settle the recipient");
1407
+ }
1408
+ const asked = offered.amountMsat ?? 0;
1409
+ const due = owed.amountMsat ?? 0;
1410
+ if (asked < due) {
1411
+ refuse("amount_below_recipient", `${asked} msat cannot forward ${due} msat`);
1412
+ }
1413
+ const ceiling = wrapFeeCeiling(due, allowance);
1414
+ if (asked - due > ceiling) {
1415
+ refuse("fee_above_allowance", `${asked - due} msat over ${due}, and ${ceiling} was allowed`);
1416
+ }
1417
+ if ((owed.expiresAt ?? 0) <= (offered.expiresAt ?? 0)) {
1418
+ refuse("recipient_expires_first", "the operator could be paid and find it cannot deliver");
1526
1419
  }
1527
1420
  }
1528
- async function parseWatchedWebhookRequest(request, credential, options = {}) {
1529
- const signature = request.headers.get(SIGNATURE_HEADER2);
1530
- const timestamp = request.headers.get(TIMESTAMP_HEADER2);
1531
- if (signature === null || timestamp === null) return null;
1532
- return parseWatchedWebhook(await request.text(), signature, credential, timestamp, options);
1421
+ async function payRequestFor(listed, payment) {
1422
+ const at = listed.indexOf("@");
1423
+ const name = listed.slice(0, at);
1424
+ const domain = listed.slice(at + 1).toLowerCase();
1425
+ if (at < 1 || !publicHttps(`https://${domain}/`)) {
1426
+ throw new UnverifiedRecipientError(payment.lnAddress, payment.id, "not a usable address");
1427
+ }
1428
+ return reachable(`https://${domain}/.well-known/lnurlp/${name}`, payment);
1533
1429
  }
1534
- async function parseSettlement(body, signature, credential, timestamp, options = {}) {
1535
- if (!await verifyWebhookSignature(body, signature, credential, timestamp, options)) return null;
1430
+ async function reachable(url, payment) {
1536
1431
  try {
1537
- return settlementFromWire(JSON.parse(textOf(body)));
1538
- } catch {
1539
- return null;
1432
+ if (!publicHttps(url)) {
1433
+ throw new Error(`${url} is not a public https URL`);
1434
+ }
1435
+ const response = await fetch(url, {
1436
+ signal: AbortSignal.timeout(HTTP_TIMEOUT_MS),
1437
+ headers: { accept: "application/json" }
1438
+ });
1439
+ if (!response.ok) {
1440
+ throw new Error(`answered ${response.status}`);
1441
+ }
1442
+ const body = await response.json();
1443
+ if (body === null || typeof body !== "object") {
1444
+ throw new Error("answered with no JSON object");
1445
+ }
1446
+ return body;
1447
+ } catch (cause) {
1448
+ throw new UnverifiedRecipientError(payment.lnAddress, payment.id, cause);
1540
1449
  }
1541
1450
  }
1542
- async function parseSettlementRequest(request, credential, options = {}) {
1543
- const signature = request.headers.get(SIGNATURE_HEADER2);
1544
- const timestamp = request.headers.get(TIMESTAMP_HEADER2);
1545
- if (signature === null || timestamp === null) return null;
1546
- return await parseSettlement(await request.text(), signature, credential, timestamp, options);
1547
- }
1548
- function isProvablySettled(settled) {
1549
- if (settled.status !== "paid" || settled.preimage === null) return false;
1550
- return preimageMatchesHash(settled.preimage, settled.paymentHash);
1551
- }
1552
- async function answerWebhookChallenge(body, signature, credential, timestamp, options = {}) {
1553
- if (!await verifyWebhookSignature(body, signature, credential, timestamp, options)) return null;
1554
- const nonce = challenged(typeof body === "string" ? body : new TextDecoder().decode(body));
1555
- return nonce === null ? null : JSON.stringify({ nonce });
1556
- }
1557
- async function answerWebhookChallengeRequest(request, credential, options = {}) {
1558
- const signature = request.headers.get(SIGNATURE_HEADER2);
1559
- const timestamp = request.headers.get(TIMESTAMP_HEADER2);
1560
- if (signature === null || timestamp === null) return null;
1561
- const answer = await answerWebhookChallenge(
1562
- await request.clone().text(),
1563
- signature,
1564
- credential,
1565
- timestamp,
1566
- options
1567
- );
1568
- return answer === null ? null : new Response(answer, { headers: { "content-type": "application/json" } });
1569
- }
1570
- function textOf(body) {
1571
- return typeof body === "string" ? body : new TextDecoder().decode(body);
1572
- }
1573
- function challenged(text2) {
1574
- return nonceOf(text2, CHALLENGE);
1451
+ function equalIgnoringCase(one, other) {
1452
+ return one.toLowerCase() === other.toLowerCase();
1575
1453
  }
1576
- function nonceOf(text2, type) {
1577
- try {
1578
- const said = JSON.parse(text2);
1579
- if (said["type"] !== type || typeof said["nonce"] !== "string") return null;
1580
- return said["nonce"];
1581
- } catch {
1582
- return null;
1454
+
1455
+ // src/client.ts
1456
+ var TERMINAL = /* @__PURE__ */ new Set(["paid", "expired"]);
1457
+ var RECONNECT_DELAY_MS = 3e3;
1458
+ var RECONNECT_CAP_MS = 3e4;
1459
+ var UNANSWERED_ATTEMPTS = 5;
1460
+ var TRIGGER_MIN_CHARS = 16;
1461
+ var STRANGER_PROBE = "is-this-gateway-yours";
1462
+ function unguessable(trigger) {
1463
+ if (trigger.length < TRIGGER_MIN_CHARS) {
1464
+ throw new Error(
1465
+ `a trigger secret is the only thing guarding its stream, and nothing rate limits a guess at it, so it has to be at least ${TRIGGER_MIN_CHARS} characters nobody can predict`
1466
+ );
1583
1467
  }
1468
+ return trigger;
1584
1469
  }
1585
- function answerVerifyChallenge(body) {
1586
- const nonce = nonceOf(body, VERIFY_CHALLENGE);
1587
- return nonce === null ? null : JSON.stringify({ nonce });
1588
- }
1589
- async function answerVerifyChallengeRequest(request) {
1590
- if (request.method !== "POST") return null;
1591
- const answer = answerVerifyChallenge(await request.clone().text());
1592
- return answer === null ? null : new Response(answer, { headers: { "content-type": "application/json" } });
1593
- }
1594
- function recent(timestamp, toleranceSecs) {
1595
- const sent = Number(timestamp);
1596
- if (!Number.isFinite(sent)) return false;
1597
- return Math.abs(Math.floor(Date.now() / 1e3) - sent) <= toleranceSecs;
1598
- }
1599
- function signed(timestamp, body) {
1600
- const encoder = new TextEncoder();
1601
- const stamp = encoder.encode(`${timestamp}.`);
1602
- const rest = typeof body === "string" ? encoder.encode(body) : new Uint8Array(body);
1603
- const payload = new Uint8Array(stamp.length + rest.length);
1604
- payload.set(stamp);
1605
- payload.set(rest, stamp.length);
1606
- return payload;
1607
- }
1608
-
1609
- // src/bank.ts
1610
- var DEFAULT_CURRENCY = "CZK";
1611
- var DEFAULT_LOOK_BACK_SECS = 7 * 24 * 60 * 60;
1612
- var DEFAULT_POLL_EVERY_SECS = 30;
1613
- var IBAN = /^[A-Z]{2}[0-9]{2}[0-9A-Z]{8,30}$/;
1614
- var FORBIDDEN_IN_SPD = "*";
1615
- async function bankTransfer(params) {
1616
- const currency = params.currency ?? DEFAULT_CURRENCY;
1617
- refuseUnusable(params, currency);
1618
- await refuseAnOpenGateway(params);
1619
- const spd = shortPaymentDescriptor(params, currency);
1620
- const subject = subjectOf(params.reference, params.amountMinor, currency);
1621
- const polling = new URL(params.verifyUrl);
1622
- polling.searchParams.set("ref", params.reference);
1623
- polling.searchParams.set("minor", String(params.amountMinor));
1624
- polling.searchParams.set("cc", currency);
1625
- polling.searchParams.set("sig", await hmacHex(params.secret, `verify|${subject}`));
1626
- const verifyUrl = polling.toString();
1627
- const watched = await params.gateway.watchPayment({
1628
- paymentHash: hashOf(await hmacHex(params.secret, `preimage|${subject}`)),
1629
- verifyUrl,
1630
- expiresAt: params.expiresAt,
1631
- trigger: params.trigger,
1632
- sealed: params.sealed,
1633
- webhookUrl: params.webhookUrl
1634
- });
1635
- return { id: watched.id, paymentHash: watched.paymentHash, verifyUrl, spd };
1470
+ function backoffMs(firstDelay, attempt) {
1471
+ const grown = Math.min(firstDelay * 2 ** (attempt - 1), RECONNECT_CAP_MS);
1472
+ return Math.round(grown * (0.5 + Math.random() / 2));
1636
1473
  }
1637
- function bankVerifyEndpoint(config) {
1638
- const paced = {
1639
- "cache-control": `max-age=${config.pollEverySecs ?? DEFAULT_POLL_EVERY_SECS}`
1640
- };
1641
- return async (request) => {
1642
- const consented = await answerVerifyChallengeRequest(request);
1643
- if (consented !== null) return consented;
1644
- const asked = readQuery(new URL(request.url));
1645
- if (asked === null) return Response.json({ settled: false }, { status: 400 });
1646
- const subject = subjectOf(asked.reference, asked.amountMinor, asked.currency);
1647
- const expected = await hmacHex(config.secret, `verify|${subject}`);
1648
- if (!equalInConstantTime(asked.signature, expected)) {
1649
- return Response.json({ settled: false }, { status: 403 });
1474
+ var ThunderBridge = class {
1475
+ baseUrl;
1476
+ verify;
1477
+ token;
1478
+ secret;
1479
+ strangers = null;
1480
+ speaks = null;
1481
+ constructor(baseUrl, options) {
1482
+ this.baseUrl = baseUrl.replace(/\/+$/, "");
1483
+ this.verify = options?.verify ?? true;
1484
+ this.token = options?.token ?? null;
1485
+ this.secret = options?.secret ?? null;
1486
+ }
1487
+ /**
1488
+ * Whether a token was given, which is what makes an instance yours: a gateway
1489
+ * started with `GATEWAY_TOKEN` answers nobody else, so anything you hand it
1490
+ * stays between you and it
1491
+ */
1492
+ get isPrivate() {
1493
+ return this.token !== null;
1494
+ }
1495
+ /**
1496
+ * Whether the gateway turns away a caller carrying no token, asked by making
1497
+ * one unauthenticated read it would have to refuse. `isPrivate` answers only
1498
+ * whether you configured a token, which is your side of the arrangement and
1499
+ * says nothing about the gateway's, so a made-up token against a public
1500
+ * instance reads as private and is not. Asked once and remembered, because an
1501
+ * instance does not change its mind. Anything other than a refusal counts as
1502
+ * open, so an unreachable gateway fails closed
1503
+ */
1504
+ async refusesStrangers() {
1505
+ this.strangers ??= fetch(`${this.baseUrl}/incoming-payments/${STRANGER_PROBE}`, {
1506
+ headers: { accept: "application/json" }
1507
+ }).then((answer) => answer.status === 401).catch(() => false);
1508
+ return await this.strangers;
1509
+ }
1510
+ /**
1511
+ * Ask the gateway for an invoice payable to the first address on your list
1512
+ * that can issue a provable one, throws `NoWalletAvailableError` when none can
1513
+ * and `GatewayCheatError` when what comes back is not what you asked for
1514
+ */
1515
+ async createPayment(params, options) {
1516
+ const sent = createRequestBody(
1517
+ params,
1518
+ options?.trigger ? sha256Hex(unguessable(options.trigger)) : null,
1519
+ options?.replay
1520
+ );
1521
+ const headers = await this.sending("/incoming-payments", sent);
1522
+ if (options?.idempotencyKey) {
1523
+ headers["idempotency-key"] = options.idempotencyKey;
1524
+ }
1525
+ const response = await fetch(`${this.baseUrl}/incoming-payments`, {
1526
+ method: "POST",
1527
+ headers,
1528
+ body: sent
1529
+ });
1530
+ if (!response.ok) {
1531
+ throw await problemFrom(response);
1532
+ }
1533
+ const payment = await paymentFrom(response);
1534
+ if (this.verify) {
1535
+ await proveOrigin(payment, params);
1536
+ }
1537
+ return payment;
1538
+ }
1539
+ /**
1540
+ * Ask which address would serve an amount without minting anything, throws
1541
+ * `NoWalletAvailableError` when none would. A quote is a probe and not a
1542
+ * promise: the address it names can still be refused at create time, because
1543
+ * whether a wallet returns a provable invoice cannot be known without asking
1544
+ * it for one, and asking mints it
1545
+ */
1546
+ async createQuote(params) {
1547
+ const sent = quoteRequestBody(params);
1548
+ const response = await fetch(`${this.baseUrl}/quotes`, {
1549
+ method: "POST",
1550
+ headers: await this.sending("/quotes", sent),
1551
+ body: sent
1552
+ });
1553
+ if (!response.ok) {
1554
+ throw await problemFrom(response);
1555
+ }
1556
+ const quote = quoteFromWire(await response.json().catch(() => null));
1557
+ if (quote === null) {
1558
+ throw new ProblemError({
1559
+ status: response.status,
1560
+ title: "The gateway answered with something that is not a quote"
1561
+ });
1562
+ }
1563
+ return quote;
1564
+ }
1565
+ /** Read a payment back, null when the gateway has never heard of it */
1566
+ /**
1567
+ * The key this gateway signs webhooks with when you registered none of your own,
1568
+ * ready to hand to `parseWebhookRequest` as `{ publicKey }`. Fetch it once and
1569
+ * keep it: it is the same for every instance in the cluster
1570
+ */
1571
+ async webhookKey() {
1572
+ const response = await fetch(`${this.baseUrl}/webhook-key`);
1573
+ if (!response.ok) {
1574
+ throw await problemFrom(response);
1575
+ }
1576
+ const body = await response.json().catch(() => null);
1577
+ if (body?.algorithm !== "ed25519" || typeof body.public_key !== "string") {
1578
+ throw new ProblemError({
1579
+ status: response.status,
1580
+ title: "The gateway published no ed25519 webhook key"
1581
+ });
1582
+ }
1583
+ return body.public_key;
1584
+ }
1585
+ async getPayment(id) {
1586
+ const path = `/incoming-payments/${encodeURIComponent(id)}`;
1587
+ const response = await fetch(`${this.baseUrl}${path}`, {
1588
+ headers: await this.reading("GET", path)
1589
+ });
1590
+ if (response.status === 404) {
1591
+ return null;
1592
+ }
1593
+ if (!response.ok) {
1594
+ throw await problemFrom(response);
1595
+ }
1596
+ return this.checked(await paymentFrom(response));
1597
+ }
1598
+ /**
1599
+ * Read back a payment the gateway is only watching, null when it has never
1600
+ * heard of it. A watched payment carries no address, amount or invoice, so
1601
+ * `getPayment` refuses it and this reads the shape both rails share
1602
+ */
1603
+ async getWatched(id) {
1604
+ const path = `/incoming-payments/${encodeURIComponent(id)}`;
1605
+ const response = await fetch(`${this.baseUrl}${path}`, {
1606
+ headers: await this.reading("GET", path)
1607
+ });
1608
+ if (response.status === 404) {
1609
+ return null;
1610
+ }
1611
+ if (!response.ok) {
1612
+ throw await problemFrom(response);
1613
+ }
1614
+ const watched = triggerEventFromWire(await response.json().catch(() => null));
1615
+ if (watched === null) {
1616
+ throw new ProblemError({
1617
+ status: response.status,
1618
+ title: "The gateway answered with something that is not a payment"
1619
+ });
1620
+ }
1621
+ return this.proven(watched);
1622
+ }
1623
+ /**
1624
+ * List what this gateway is watching, newest first. Only a gateway started
1625
+ * with `GATEWAY_TOKEN` serves this, because on a shared one it would hand
1626
+ * every caller everyone else's payments, so a public gateway answers 404.
1627
+ *
1628
+ * `scanned` says how many settled records were looked at to build the page.
1629
+ * Anything older than that window is not in the answer, and the list does not
1630
+ * pretend otherwise
1631
+ */
1632
+ async listPayments(limit) {
1633
+ const path = `/incoming-payments${limit === void 0 ? "" : `?limit=${limit}`}`;
1634
+ const response = await fetch(`${this.baseUrl}${path}`, {
1635
+ headers: await this.reading("GET", path)
1636
+ });
1637
+ if (!response.ok) {
1638
+ throw await problemFrom(response);
1639
+ }
1640
+ const body = await response.json().catch(() => null);
1641
+ const listed = Array.isArray(body?.payments) ? body.payments.map(triggerEventFromWire).filter((one) => one !== null) : null;
1642
+ if (listed === null || typeof body?.settled_scanned !== "number") {
1643
+ throw new ProblemError({
1644
+ status: response.status,
1645
+ title: "The gateway answered with something that is not a payment list"
1646
+ });
1647
+ }
1648
+ return { payments: listed, scanned: body.settled_scanned };
1649
+ }
1650
+ /**
1651
+ * Follow a payment over WebSocket until it is paid or expired, reconnecting
1652
+ * through a drop. A payment that never answers gives up after a few tries, and
1653
+ * one that has answered is followed until its own expiry, so the wait always
1654
+ * ends by itself
1655
+ */
1656
+ async waitForPayment(id, options) {
1657
+ const payment = paymentFromWire(await this.followed(id, options));
1658
+ if (payment === null) {
1659
+ throw new ProblemError({
1660
+ status: 200,
1661
+ title: `payment ${id} is watched rather than minted here, read it with waitForWatched`
1662
+ });
1663
+ }
1664
+ return this.checked(payment);
1665
+ }
1666
+ /**
1667
+ * Follow a payment the gateway is only watching, one it did not mint, until it
1668
+ * is paid or expired.
1669
+ *
1670
+ * A watched payment carries no address, no amount and no invoice, because the
1671
+ * gateway was told none of them, so it reads back as the shape a trigger
1672
+ * streams rather than as a `Payment`. That is every bank transfer, and every
1673
+ * Lightning invoice registered with `watchPayment` instead of `createPayment`.
1674
+ */
1675
+ async waitForWatched(id, options) {
1676
+ const watched = triggerEventFromWire(await this.followed(id, options));
1677
+ if (watched === null) {
1678
+ throw new ProblemError({
1679
+ status: 200,
1680
+ title: "The gateway answered with something that is not a payment"
1681
+ });
1682
+ }
1683
+ return this.proven(watched);
1684
+ }
1685
+ followed(id, options) {
1686
+ const base = this.baseUrl.replace(/^http/, "ws");
1687
+ const direct = `${base}/ws/incoming-payments/${encodeURIComponent(id)}`;
1688
+ return new Promise((resolve2, reject) => {
1689
+ const aborted = () => new Error(`waiting for payment ${id} was aborted`);
1690
+ if (options?.signal?.aborted) {
1691
+ reject(aborted());
1692
+ return;
1693
+ }
1694
+ let socket = null;
1695
+ let settled = false;
1696
+ let attempt = 0;
1697
+ let expiresAt = null;
1698
+ let retry;
1699
+ const abort = () => settle(() => reject(aborted()));
1700
+ const settle = (finish) => {
1701
+ if (settled) {
1702
+ return;
1703
+ }
1704
+ settled = true;
1705
+ options?.signal?.removeEventListener("abort", abort);
1706
+ clearTimeout(retry);
1707
+ finish();
1708
+ socket?.close();
1709
+ };
1710
+ const again = () => {
1711
+ if (settled) {
1712
+ return;
1713
+ }
1714
+ if (expiresAt === null && attempt >= UNANSWERED_ATTEMPTS) {
1715
+ settle(() => reject(new Error(`no gateway answered for payment ${id}`)));
1716
+ return;
1717
+ }
1718
+ if (expiresAt !== null && Date.now() >= expiresAt * 1e3) {
1719
+ settle(() => reject(new Error(`payment ${id} went unreported past its expiry`)));
1720
+ return;
1721
+ }
1722
+ retry = setTimeout(() => void connect(), backoffMs(RECONNECT_DELAY_MS, attempt));
1723
+ };
1724
+ const connect = async () => {
1725
+ if (settled) {
1726
+ return;
1727
+ }
1728
+ attempt += 1;
1729
+ let url = direct;
1730
+ if (this.needsTicket(options?.tickets)) {
1731
+ try {
1732
+ url = `${base}/ws/tickets/${await this.wsTicket({ payment_id: id })}`;
1733
+ } catch (refused) {
1734
+ if (refused instanceof ProblemError) {
1735
+ settle(() => reject(refused));
1736
+ } else {
1737
+ again();
1738
+ }
1739
+ return;
1740
+ }
1741
+ }
1742
+ if (settled) {
1743
+ return;
1744
+ }
1745
+ const opened = new WebSocket(url);
1746
+ socket = opened;
1747
+ opened.onmessage = (event) => {
1748
+ try {
1749
+ const frame = JSON.parse(String(event.data));
1750
+ const watched = triggerEventFromWire(frame);
1751
+ if (watched === null) {
1752
+ return;
1753
+ }
1754
+ expiresAt = watched.expiresAt;
1755
+ attempt = 1;
1756
+ if (!TERMINAL.has(watched.status)) {
1757
+ return;
1758
+ }
1759
+ settle(() => resolve2(frame));
1760
+ } catch (refused) {
1761
+ settle(() => reject(refused));
1762
+ }
1763
+ };
1764
+ opened.onclose = again;
1765
+ };
1766
+ options?.signal?.addEventListener("abort", abort, { once: true });
1767
+ void connect();
1768
+ });
1769
+ }
1770
+ /**
1771
+ * Wait on several payments and keep the first one that is really paid, then stop
1772
+ * waiting on the losers, which closes their sockets.
1773
+ *
1774
+ * This is how one order offers two rails. A Lightning invoice and a bank
1775
+ * transfer for the same thing are two payments here, and the payer picks one, so
1776
+ * what you want is the one that arrives and nothing further from the other.
1777
+ *
1778
+ * A leg that expires is a loser, not a winner, which is the whole reason this is
1779
+ * not a race: `waitForPayment` ends on `paid` and on `expired` alike, and a
1780
+ * Lightning invoice expires in an hour while a bank transfer takes days. `null`
1781
+ * means every leg ended without being paid.
1782
+ *
1783
+ * Stopping the wait is not revoking the invoice. Nobody can revoke one, because
1784
+ * the recipient's own wallet minted it, so a payer who pays the loser afterwards
1785
+ * really does pay twice and that shows up on `followTrigger` as a second
1786
+ * settlement to refund.
1787
+ */
1788
+ async firstToSettle(ids, options) {
1789
+ if (ids.length === 0) {
1790
+ return null;
1791
+ }
1792
+ const stopLosers = new AbortController();
1793
+ const signal = options?.signal ? AbortSignal.any([stopLosers.signal, options.signal]) : stopLosers.signal;
1794
+ let refused = null;
1795
+ try {
1796
+ const winner = await new Promise((resolve2) => {
1797
+ let waiting = ids.length;
1798
+ const lost = () => {
1799
+ waiting -= 1;
1800
+ if (waiting === 0) {
1801
+ resolve2(null);
1802
+ }
1803
+ };
1804
+ for (const id of ids) {
1805
+ this.waitForWatched(id, { ...options, signal }).then((watched) => watched.status === "paid" ? resolve2(watched) : lost()).catch((failure) => {
1806
+ refused ??= failure;
1807
+ lost();
1808
+ });
1809
+ }
1810
+ });
1811
+ if (winner === null && refused !== null) {
1812
+ throw refused;
1813
+ }
1814
+ return winner;
1815
+ } finally {
1816
+ stopLosers.abort();
1817
+ }
1818
+ }
1819
+ /**
1820
+ * Hand over an invoice you obtained yourself so the gateway watches it without
1821
+ * being told the address or the amount. It can then only refuse everyone
1822
+ * rather than one recipient, which is what makes leaving it cheap. Anything
1823
+ * the watcher needs goes in `sealed`, which the gateway cannot read
1824
+ */
1825
+ async watchPayment(params) {
1826
+ const sent = watchRequestBody(
1827
+ params,
1828
+ params.trigger ? sha256Hex(unguessable(params.trigger)) : null
1829
+ );
1830
+ const response = await fetch(`${this.baseUrl}/watched-payments`, {
1831
+ method: "POST",
1832
+ headers: await this.sending("/watched-payments", sent),
1833
+ body: sent
1834
+ });
1835
+ if (!response.ok) {
1836
+ throw await problemFrom(response);
1837
+ }
1838
+ const watched = triggerEventFromWire(await response.json().catch(() => null));
1839
+ if (watched === null) {
1840
+ throw new ProblemError({
1841
+ status: response.status,
1842
+ title: "The gateway answered with something that is not a watched payment"
1843
+ });
1844
+ }
1845
+ const named = await this.nameFor(params.paymentHash);
1846
+ if (named !== null && watched.id !== named) {
1847
+ throw new GatewayCheatError("id_not_mine", watched.id);
1848
+ }
1849
+ return watched;
1850
+ }
1851
+ /**
1852
+ * What this payment is called, which you can work out before any gateway has
1853
+ * heard of it. Every gateway you hand the same invoice to answers with the same
1854
+ * name, so watching at several of them adds up to one payment rather than
1855
+ * several, and no gateway's key is in the answer. Null when no secret was given,
1856
+ * because then the gateway names the payment and only it can
1857
+ */
1858
+ async nameFor(paymentHash) {
1859
+ return this.secret === null ? null : paymentNamedBy((await this.speaking()).publicKeyHex, paymentHash);
1860
+ }
1861
+ /**
1862
+ * Follow every payment made to one trigger, replayed from the recent ones on
1863
+ * connect and then live, reconnecting on its own until the returned function
1864
+ * is called. A trigger has no terminal state, so this never resolves
1865
+ */
1866
+ followTrigger(secret, options) {
1867
+ const base = this.baseUrl.replace(/^http/, "ws");
1868
+ const asked = options.replay === void 0 ? "" : `?replay=${options.replay}`;
1869
+ const direct = `${base}/ws/triggers/${encodeURIComponent(unguessable(secret))}${asked}`;
1870
+ const firstDelay = options.reconnectDelayMs ?? RECONNECT_DELAY_MS;
1871
+ let socket = null;
1872
+ let retry;
1873
+ let stopped = false;
1874
+ let attempt = 0;
1875
+ const again = () => {
1876
+ if (stopped || options.reconnect === false) {
1877
+ return;
1878
+ }
1879
+ retry = setTimeout(() => void connect(), backoffMs(firstDelay, attempt));
1880
+ };
1881
+ const connect = async () => {
1882
+ attempt += 1;
1883
+ let url = direct;
1884
+ if (this.needsTicket(options.tickets)) {
1885
+ try {
1886
+ url = `${base}/ws/tickets/${await this.wsTicket({
1887
+ trigger_secret: secret,
1888
+ replay: options.replay
1889
+ })}`;
1890
+ } catch (refused) {
1891
+ options.onError?.(refused);
1892
+ again();
1893
+ return;
1894
+ }
1895
+ }
1896
+ if (stopped) {
1897
+ return;
1898
+ }
1899
+ socket = new WebSocket(url);
1900
+ socket.onopen = () => {
1901
+ attempt = 1;
1902
+ };
1903
+ socket.onmessage = (event) => {
1904
+ try {
1905
+ const settled = triggerEventFromWire(JSON.parse(String(event.data)));
1906
+ if (settled !== null) {
1907
+ options.onPayment(this.proven(settled));
1908
+ }
1909
+ } catch (refused) {
1910
+ options.onError?.(refused);
1911
+ }
1912
+ };
1913
+ socket.onerror = () => options.onError?.(new Error(`could not follow trigger at ${base}`));
1914
+ socket.onclose = again;
1915
+ };
1916
+ void connect();
1917
+ return () => {
1918
+ stopped = true;
1919
+ clearTimeout(retry);
1920
+ socket?.close();
1921
+ };
1922
+ }
1923
+ needsTicket(asked) {
1924
+ return asked === true || this.token !== null;
1925
+ }
1926
+ async wsTicket(body) {
1927
+ const sent = JSON.stringify(body);
1928
+ const response = await fetch(`${this.baseUrl}/ws-tickets`, {
1929
+ method: "POST",
1930
+ headers: await this.sending("/ws-tickets", sent),
1931
+ body: sent
1932
+ });
1933
+ if (!response.ok) {
1934
+ throw await problemFrom(response);
1650
1935
  }
1651
- const since = unixNow() - (config.lookBackSecs ?? DEFAULT_LOOK_BACK_SECS);
1652
- const landed = (await config.statement(since)).some((credit) => pays(credit, asked));
1653
- if (!landed) return Response.json({ settled: false }, { headers: paced });
1654
- return Response.json(
1655
- { settled: true, preimage: await hmacHex(config.secret, `preimage|${subject}`) },
1656
- { headers: paced }
1657
- );
1658
- };
1659
- }
1660
- function readQuery(url) {
1661
- const reference = url.searchParams.get("ref");
1662
- const minor = Number(url.searchParams.get("minor"));
1663
- const currency = url.searchParams.get("cc");
1664
- const signature = url.searchParams.get("sig");
1665
- if (!reference || !currency || !signature) return null;
1666
- if (!Number.isInteger(minor) || minor <= 0) return null;
1667
- return { reference, amountMinor: minor, currency, signature };
1668
- }
1669
- function pays(credit, asked) {
1670
- return credit.amountMinor === asked.amountMinor && credit.currency.toUpperCase() === asked.currency.toUpperCase() && credit.reference.toUpperCase().includes(asked.reference.toUpperCase());
1671
- }
1672
- function subjectOf(reference, amountMinor, currency) {
1673
- return `${reference}|${amountMinor}|${currency.toUpperCase()}`;
1674
- }
1675
- function hashOf(preimage) {
1676
- return bytesToHex(sha256(hexToBytes(preimage)));
1677
- }
1678
- function shortPaymentDescriptor(params, currency) {
1679
- const fields = [
1680
- `ACC:${params.iban}`,
1681
- `AM:${major(params.amountMinor, currency)}`,
1682
- `CC:${currency.toUpperCase()}`,
1683
- `MSG:${params.reference}`
1684
- ];
1685
- if (params.variableSymbol) fields.push(`X-VS:${params.variableSymbol}`);
1686
- return `SPD*1.0*${fields.join("*")}`;
1687
- }
1688
- function major(amountMinor, currency) {
1689
- return (amountMinor / minorScaleOf(currency)).toFixed(minorUnitsOf(currency));
1690
- }
1691
- async function refuseAnOpenGateway(params) {
1692
- if (params.allowPublicGateway === true) return;
1693
- if (await params.gateway.refusesStrangers()) return;
1694
- throw new Error(
1695
- "this gateway serves callers with no token, so it is not yours, and its operator would read the amount and the reference off every verify URL. Point at one that answers 401 to a stranger, or say allowPublicGateway"
1696
- );
1697
- }
1698
- function refuseUnusable(params, currency) {
1699
- if (!IBAN.test(params.iban)) throw new Error(`${params.iban} is not an IBAN`);
1700
- if (!Number.isInteger(params.amountMinor) || params.amountMinor <= 0) {
1701
- throw new Error("amountMinor must be a whole number of minor units above zero");
1936
+ const minted = await response.json().catch(() => null);
1937
+ if (typeof minted?.ticket !== "string") {
1938
+ throw new ProblemError({
1939
+ status: response.status,
1940
+ title: "The gateway answered with something that is not a ticket"
1941
+ });
1942
+ }
1943
+ return encodeURIComponent(minted.ticket);
1702
1944
  }
1703
- if (params.reference.length === 0) {
1704
- throw new Error("a transfer with no reference cannot be found");
1945
+ async sending(path, body) {
1946
+ return { "content-type": "application/json", ...await this.reading("POST", path, body) };
1705
1947
  }
1706
- if (params.reference.includes(FORBIDDEN_IN_SPD)) {
1707
- throw new Error("a reference cannot contain an asterisk, it separates the QR fields");
1948
+ async reading(method, path, body = "") {
1949
+ return {
1950
+ ...this.token === null ? {} : { authorization: `Bearer ${this.token}` },
1951
+ ...this.secret === null ? {} : await signedAs(await this.speaking(), method, path, body)
1952
+ };
1708
1953
  }
1709
- if (params.variableSymbol && !/^[0-9]{1,10}$/.test(params.variableSymbol)) {
1710
- throw new Error("a variable symbol is up to ten digits");
1954
+ async speaking() {
1955
+ this.speaks ??= callerKey(this.secret ?? "");
1956
+ return await this.speaks;
1957
+ }
1958
+ proven(settled) {
1959
+ const unproven = settled.status === "paid" && (settled.preimage === null || !preimageMatchesHash(settled.preimage, settled.paymentHash));
1960
+ if (this.verify && unproven) {
1961
+ throw new GatewayCheatError("preimage_mismatch", settled.id);
1962
+ }
1963
+ return settled;
1964
+ }
1965
+ checked(payment) {
1966
+ if (this.verify && payment.status === "paid" && !isProvablyPaid(payment)) {
1967
+ throw new GatewayCheatError("preimage_mismatch", payment.id);
1968
+ }
1969
+ return payment;
1970
+ }
1971
+ };
1972
+ async function paymentFrom(response) {
1973
+ const payment = paymentFromWire(await response.json().catch(() => null));
1974
+ if (payment === null) {
1975
+ throw new ProblemError({
1976
+ status: response.status,
1977
+ title: "The gateway answered with something that is not a payment"
1978
+ });
1711
1979
  }
1980
+ return payment;
1712
1981
  }
1713
- function unixNow() {
1714
- return Math.floor(Date.now() / 1e3);
1982
+ async function problemFrom(response) {
1983
+ const problem = await response.json().catch(() => ({}));
1984
+ const document = { ...problem, status: response.status };
1985
+ if (isProblemType(problem, NO_WALLET_AVAILABLE)) {
1986
+ return new NoWalletAvailableError(document, refusals(problem.wallets));
1987
+ }
1988
+ if (isProblemType(problem, REQUEST_IN_FLIGHT)) {
1989
+ return new IdempotencyConflictError(document, "request-in-flight");
1990
+ }
1991
+ if (isProblemType(problem, IDEMPOTENCY_KEY_REUSED)) {
1992
+ return new IdempotencyConflictError(document, "key-reused");
1993
+ }
1994
+ return new ProblemError(document);
1995
+ }
1996
+ function refusals(wallets) {
1997
+ if (!Array.isArray(wallets)) {
1998
+ return [];
1999
+ }
2000
+ return wallets.filter((wallet) => {
2001
+ return typeof wallet === "object" && wallet !== null && "address" in wallet;
2002
+ });
1715
2003
  }
1716
2004
 
1717
2005
  // src/fio.ts
@@ -1737,7 +2025,9 @@ var WHERE_A_PAYER_WRITES = [
1737
2025
  var UTC_OFFSET = /^[+-]\d{4}$/;
1738
2026
  function fioStatement(config) {
1739
2027
  const named = typeof config.token === "string" ? [config.token] : config.token;
1740
- if (named.length === 0) throw new Error("fioStatement needs at least one token to read with");
2028
+ if (named.length === 0) {
2029
+ throw new Error("fioStatement needs at least one token to read with");
2030
+ }
1741
2031
  const usedAt = new Map(named.map((token) => [token, Number.NEGATIVE_INFINITY]));
1742
2032
  const tokenWindowMs = (config.minIntervalSecs ?? DEFAULT_MIN_INTERVAL_SECS) * MILLIS;
1743
2033
  const paceMs = tokenWindowMs / usedAt.size;
@@ -1745,9 +2035,13 @@ function fioStatement(config) {
1745
2035
  let credits = [];
1746
2036
  return async (sinceUnix) => {
1747
2037
  const now = Date.now();
1748
- if (now - lastRead < paceMs) return credits;
2038
+ if (now - lastRead < paceMs) {
2039
+ return credits;
2040
+ }
1749
2041
  const idlest = longestUnused(usedAt);
1750
- if (now - idlest.usedAt < tokenWindowMs) return credits;
2042
+ if (now - idlest.usedAt < tokenWindowMs) {
2043
+ return credits;
2044
+ }
1751
2045
  usedAt.set(idlest.token, now);
1752
2046
  lastRead = now;
1753
2047
  const url = [
@@ -1762,7 +2056,9 @@ function fioStatement(config) {
1762
2056
  if (answer.status === TOO_SOON) {
1763
2057
  throw new Error("fio refuses a second read of this token inside its 30 second window");
1764
2058
  }
1765
- if (!answer.ok) throw new Error(`fio answered ${answer.status} reading the statement`);
2059
+ if (!answer.ok) {
2060
+ throw new Error(`fio answered ${answer.status} reading the statement`);
2061
+ }
1766
2062
  const read = await answer.json();
1767
2063
  credits = (read.accountStatement?.transactionList?.transaction ?? []).filter(isCredit).map(asCredit);
1768
2064
  return credits;
@@ -1792,86 +2088,250 @@ function asCredit(transaction) {
1792
2088
  }
1793
2089
  function bookedAtIn(cell) {
1794
2090
  const value = cell?.value;
1795
- if (typeof value === "number") return Math.floor(value / MILLIS);
2091
+ if (typeof value === "number") {
2092
+ return Math.floor(value / MILLIS);
2093
+ }
1796
2094
  const at = Date.parse(asIso8601(textIn(cell)));
1797
2095
  return Number.isFinite(at) ? Math.floor(at / MILLIS) : 0;
1798
2096
  }
1799
2097
  function asIso8601(booked) {
1800
2098
  const day = booked.slice(0, DATE_CHARS);
1801
2099
  const zone = booked.slice(DATE_CHARS);
1802
- if (!UTC_OFFSET.test(zone)) return `${day}T00:00:00Z`;
2100
+ if (!UTC_OFFSET.test(zone)) {
2101
+ return `${day}T00:00:00Z`;
2102
+ }
1803
2103
  return `${day}T00:00:00${zone.slice(0, 3)}:${zone.slice(3)}`;
1804
2104
  }
1805
- function numberIn(cell) {
1806
- const value = Number(cell?.value ?? Number.NaN);
1807
- return Number.isFinite(value) ? value : 0;
2105
+ function numberIn(cell) {
2106
+ const value = Number(cell?.value ?? Number.NaN);
2107
+ return Number.isFinite(value) ? value : 0;
2108
+ }
2109
+ function textIn(cell) {
2110
+ const value = cell?.value;
2111
+ return value === null || value === void 0 ? "" : String(value).trim();
2112
+ }
2113
+ function asDate(unix) {
2114
+ return new Date(unix * MILLIS).toISOString().slice(0, DATE_CHARS);
2115
+ }
2116
+
2117
+ // src/gateways.ts
2118
+ var Gateways = class {
2119
+ each;
2120
+ urls;
2121
+ onRefused;
2122
+ constructor(baseUrls, options) {
2123
+ if (baseUrls.length === 0) {
2124
+ throw new Error("name at least one gateway to watch at");
2125
+ }
2126
+ this.urls = [...baseUrls];
2127
+ this.each = baseUrls.map((baseUrl) => new ThunderBridge(baseUrl, options));
2128
+ this.onRefused = options?.onRefused ?? (() => {
2129
+ });
2130
+ }
2131
+ /** What this payment is called, which every gateway here will agree on */
2132
+ async nameFor(paymentHash) {
2133
+ return await this.each[0].nameFor(paymentHash);
2134
+ }
2135
+ /**
2136
+ * Hand the same invoice to every gateway. Throws only when none of them took it,
2137
+ * carrying the first refusal, because one gateway that agreed is enough to be
2138
+ * watched
2139
+ */
2140
+ async watchPayment(params) {
2141
+ const asked = await Promise.allSettled(
2142
+ this.each.map((gateway) => gateway.watchPayment(params))
2143
+ );
2144
+ const taken = [];
2145
+ const refusals3 = [];
2146
+ for (const [at, answer] of asked.entries()) {
2147
+ if (answer.status === "fulfilled") {
2148
+ taken.push(answer.value);
2149
+ continue;
2150
+ }
2151
+ refusals3.push(answer.reason);
2152
+ this.onRefused(this.urls[at], answer.reason);
2153
+ }
2154
+ if (taken[0] === void 0) {
2155
+ throw refusals3[0];
2156
+ }
2157
+ return taken[0];
2158
+ }
2159
+ /**
2160
+ * Wait for whichever gateway speaks first. A settlement from any of them is the
2161
+ * settlement, and each has already proved the preimage against the hash before
2162
+ * saying so. When they all end without a payment, the first ending is the answer,
2163
+ * and when they all fail, the first failure is thrown
2164
+ */
2165
+ async waitForWatched(id, options) {
2166
+ const stopLosers = new AbortController();
2167
+ const signal = options?.signal ? AbortSignal.any([stopLosers.signal, options.signal]) : stopLosers.signal;
2168
+ const unpaid = [];
2169
+ let refused = null;
2170
+ try {
2171
+ const settled = await new Promise((resolve2) => {
2172
+ let waiting = this.each.length;
2173
+ const lost = () => {
2174
+ waiting -= 1;
2175
+ if (waiting === 0) {
2176
+ resolve2(null);
2177
+ }
2178
+ };
2179
+ for (const gateway of this.each) {
2180
+ gateway.waitForWatched(id, { ...options, signal }).then((watched) => {
2181
+ if (watched.status === "paid") {
2182
+ return resolve2(watched);
2183
+ }
2184
+ unpaid.push(watched);
2185
+ lost();
2186
+ }).catch((failure) => {
2187
+ refused ??= failure;
2188
+ lost();
2189
+ });
2190
+ }
2191
+ });
2192
+ if (settled !== null) {
2193
+ return settled;
2194
+ }
2195
+ if (unpaid.length > 0) {
2196
+ return unpaid[0];
2197
+ }
2198
+ throw refused;
2199
+ } finally {
2200
+ stopLosers.abort();
2201
+ }
2202
+ }
2203
+ };
2204
+
2205
+ // src/price.ts
2206
+ var MSAT_PER_BTC2 = 1e11;
2207
+ var BASIS_POINTS = 1e4;
2208
+ var DEFAULT_HOLD_SECS = 60;
2209
+ var DEFAULT_MIN_VENUES = 2;
2210
+ var DEFAULT_MAX_SPREAD_BPS = 200;
2211
+ var MILLIS2 = 1e3;
2212
+ function coinbase(baseUrl = "https://api.coinbase.com") {
2213
+ return async (currency) => {
2214
+ const asked = currency.toUpperCase();
2215
+ const answer = await asJson(`${baseUrl}/v2/prices/BTC-${asked}/spot`, "coinbase");
2216
+ const quoted = answer.data;
2217
+ if (quoted?.currency !== asked) {
2218
+ throw new Error(`coinbase quoted ${quoted?.currency} when asked for ${asked}`);
2219
+ }
2220
+ return asMinor(quoted.amount, "coinbase", asked);
2221
+ };
2222
+ }
2223
+ function kraken(baseUrl = "https://api.kraken.com") {
2224
+ return async (currency) => {
2225
+ const asked = currency.toUpperCase();
2226
+ const answer = await asJson(`${baseUrl}/0/public/Ticker?pair=XBT${asked}`, "kraken");
2227
+ if (answer.error?.length) {
2228
+ throw new Error(`kraken refused XBT${asked}: ${answer.error[0]}`);
2229
+ }
2230
+ const pairs = Object.values(answer.result ?? {});
2231
+ if (pairs.length !== 1) {
2232
+ throw new Error(`kraken answered ${pairs.length} pairs for XBT${asked}`);
2233
+ }
2234
+ return asMinor(pairs[0]?.c?.[0], "kraken", asked);
2235
+ };
2236
+ }
2237
+ function bitstamp(baseUrl = "https://www.bitstamp.net") {
2238
+ return async (currency) => {
2239
+ const pair = `btc${currency.toLowerCase()}`;
2240
+ const answer = await asJson(`${baseUrl}/api/v2/ticker/${pair}/`, "bitstamp");
2241
+ if (Array.isArray(answer)) {
2242
+ throw new Error(`bitstamp has no ${pair} pair`);
2243
+ }
2244
+ return asMinor(answer.last, "bitstamp", currency);
2245
+ };
1808
2246
  }
1809
- function textIn(cell) {
1810
- const value = cell?.value;
1811
- return value === null || value === void 0 ? "" : String(value).trim();
2247
+ function coinmate(baseUrl = "https://coinmate.io") {
2248
+ return async (currency) => {
2249
+ const pair = `BTC_${currency.toUpperCase()}`;
2250
+ const answer = await asJson(`${baseUrl}/api/ticker?currencyPair=${pair}`, "coinmate");
2251
+ if (answer.error !== false) {
2252
+ throw new Error(`coinmate refused ${pair}: ${answer.errorMessage ?? "no reason"}`);
2253
+ }
2254
+ return asMinor(answer.data?.last, "coinmate", currency);
2255
+ };
1812
2256
  }
1813
- function asDate(unix) {
1814
- return new Date(unix * MILLIS).toISOString().slice(0, DATE_CHARS);
2257
+ function medianOf(tickers = [coinbase(), kraken(), bitstamp(), coinmate()], options = {}) {
2258
+ const holdFor = (options.holdForSecs ?? DEFAULT_HOLD_SECS) * MILLIS2;
2259
+ const minVenues = options.minVenues ?? DEFAULT_MIN_VENUES;
2260
+ const maxSpread = options.maxSpreadBps ?? DEFAULT_MAX_SPREAD_BPS;
2261
+ const held = /* @__PURE__ */ new Map();
2262
+ return async (currency) => {
2263
+ const asked = currency.toUpperCase();
2264
+ const remembered = held.get(asked);
2265
+ if (remembered && Date.now() - remembered.at < holdFor) {
2266
+ return remembered.price;
2267
+ }
2268
+ const answers = await Promise.allSettled(tickers.map((ticker) => ticker(asked)));
2269
+ const quoted = answers.filter((answer) => answer.status === "fulfilled").map((answer) => answer.value).sort((one, other) => one - other);
2270
+ if (quoted.length < minVenues) {
2271
+ throw new Error(
2272
+ `${quoted.length} of ${tickers.length} venues quoted ${asked}, ${minVenues} wanted: ${refusals2(answers)}`
2273
+ );
2274
+ }
2275
+ const spread = spreadBpsOf(quoted);
2276
+ if (spread > maxSpread) {
2277
+ throw new Error(
2278
+ `venues disagree on ${asked} by ${Math.round(spread)} bps, more than the ${maxSpread} allowed: ${quoted.join(", ")}`
2279
+ );
2280
+ }
2281
+ const price = middleOf(quoted);
2282
+ held.set(asked, { at: Date.now(), price });
2283
+ return price;
2284
+ };
1815
2285
  }
1816
-
1817
- // ../core/lnurl.ts
1818
- var BECH32_CHARSET2 = "qpzry9x8gf2tvdw0s3jn54khce6mua7l";
1819
- var BECH32_GENERATOR = [996825010, 642813549, 513874426, 1027748829, 705979059];
1820
- var CHECKSUM_SLOTS = [0, 1, 2, 3, 4, 5];
1821
- var LNURL_HRP = "lnurl";
1822
- function toLnurl(endpoint) {
1823
- const scheme = parsedScheme(endpoint);
1824
- if (scheme !== "https:" && scheme !== "http:") {
1825
- throw new Error(`${endpoint} is not an http or https URL`);
2286
+ function msatFor(amountMinor, priceMinorPerBtc, options = {}) {
2287
+ if (!Number.isInteger(amountMinor) || amountMinor <= 0) {
2288
+ throw new Error("amountMinor must be a whole number of minor units above zero");
1826
2289
  }
1827
- const words = toWords(new TextEncoder().encode(endpoint));
1828
- const data = [...words, ...checksumWords(LNURL_HRP, words)];
1829
- return `${LNURL_HRP}1${data.map((word) => BECH32_CHARSET2[word]).join("")}`.toUpperCase();
2290
+ if (!Number.isFinite(priceMinorPerBtc) || priceMinorPerBtc <= 0) {
2291
+ throw new Error(`${priceMinorPerBtc} is not a usable price`);
2292
+ }
2293
+ const spreadBps = options.spreadBps ?? 0;
2294
+ if (!Number.isInteger(spreadBps) || spreadBps < 0) {
2295
+ throw new Error("spreadBps must be a whole number of basis points, none or more");
2296
+ }
2297
+ const wanted = BigInt(amountMinor) * BigInt(MSAT_PER_BTC2) * BigInt(BASIS_POINTS + spreadBps);
2298
+ const per = BigInt(Math.round(priceMinorPerBtc)) * BigInt(BASIS_POINTS);
2299
+ const msat = (wanted + per - 1n) / per;
2300
+ if (msat > BigInt(Number.MAX_SAFE_INTEGER)) {
2301
+ throw new Error(`${amountMinor} at ${priceMinorPerBtc} is more millisatoshi than exist`);
2302
+ }
2303
+ return Number(msat);
1830
2304
  }
1831
- function parsedScheme(endpoint) {
1832
- try {
1833
- return new URL(endpoint).protocol;
1834
- } catch {
1835
- return null;
2305
+ async function asJson(url, venue) {
2306
+ const answer = await fetch(url, { headers: { accept: "application/json" } });
2307
+ if (!answer.ok) {
2308
+ throw new Error(`${venue} answered ${answer.status}`);
1836
2309
  }
2310
+ return answer.json();
1837
2311
  }
1838
- function toWords(bytes) {
1839
- const words = [];
1840
- let accumulator = 0;
1841
- let bits = 0;
1842
- for (const byte of bytes) {
1843
- accumulator = accumulator << 8 | byte;
1844
- bits += 8;
1845
- while (bits >= 5) {
1846
- bits -= 5;
1847
- words.push(accumulator >> bits & 31);
1848
- }
2312
+ function asMinor(quoted, venue, currency) {
2313
+ const major2 = Number(quoted);
2314
+ if (!Number.isFinite(major2) || major2 <= 0) {
2315
+ throw new Error(`${venue} quoted ${String(quoted)} for ${currency.toUpperCase()}`);
1849
2316
  }
1850
- if (bits > 0) words.push(accumulator << 5 - bits & 31);
1851
- return words;
2317
+ return Math.round(major2 * minorScaleOf(currency));
1852
2318
  }
1853
- function checksumWords(hrp, words) {
1854
- const expanded = [...hrp].map((char) => char.charCodeAt(0));
1855
- const values = [
1856
- ...expanded.map((code) => code >> 5),
1857
- 0,
1858
- ...expanded.map((code) => code & 31),
1859
- ...words,
1860
- ...CHECKSUM_SLOTS.map(() => 0)
1861
- ];
1862
- const polymod = bech32Polymod(values) ^ 1;
1863
- return CHECKSUM_SLOTS.map((slot) => polymod >> 5 * (5 - slot) & 31);
2319
+ function spreadBpsOf(sorted) {
2320
+ const lowest = sorted[0] ?? 0;
2321
+ const highest = sorted[sorted.length - 1] ?? 0;
2322
+ return lowest === 0 ? Number.POSITIVE_INFINITY : (highest - lowest) / lowest * BASIS_POINTS;
1864
2323
  }
1865
- function bech32Polymod(values) {
1866
- let checksum = 1;
1867
- for (const value of values) {
1868
- const top = checksum >> 25;
1869
- checksum = (checksum & 33554431) << 5 ^ value;
1870
- for (let bit = 0; bit < 5; bit++) {
1871
- if (top >> bit & 1) checksum ^= BECH32_GENERATOR[bit];
1872
- }
2324
+ function middleOf(sorted) {
2325
+ const middle = Math.floor(sorted.length / 2);
2326
+ if (sorted.length % 2 === 1) {
2327
+ return sorted[middle] ?? 0;
1873
2328
  }
1874
- return checksum;
2329
+ return Math.round(((sorted[middle - 1] ?? 0) + (sorted[middle] ?? 0)) / 2);
2330
+ }
2331
+ function refusals2(answers) {
2332
+ return answers.filter((answer) => answer.status === "rejected").map(
2333
+ (answer) => answer.reason instanceof Error ? answer.reason.message : String(answer.reason)
2334
+ ).join("; ");
1875
2335
  }
1876
2336
 
1877
2337
  // src/qr.ts
@@ -1908,7 +2368,9 @@ function qrToDataUrl(payload, options) {
1908
2368
  function svgOf(scanned, options) {
1909
2369
  const size = options?.size ?? 256;
1910
2370
  const color = options?.color ?? "#000";
1911
- if (!SAFE_COLOR.test(color)) throw new Error(`Invalid color: ${color}`);
2371
+ if (!SAFE_COLOR.test(color)) {
2372
+ throw new Error(`Invalid color: ${color}`);
2373
+ }
1912
2374
  return renderSvg(scanned, size, color);
1913
2375
  }
1914
2376
  function asDataUrl(svg) {
@@ -1954,6 +2416,7 @@ function bankRail(config) {
1954
2416
  currency: order.currency,
1955
2417
  expiresAt,
1956
2418
  trigger: config.trigger,
2419
+ replay: config.replay,
1957
2420
  sealed: await config.sealed?.(order),
1958
2421
  variableSymbol: config.variableSymbol?.(order),
1959
2422
  webhookUrl: config.webhookUrl,
@@ -1976,7 +2439,11 @@ function lightningRail(config) {
1976
2439
  amountMsat: await config.amountMsat(order),
1977
2440
  webhookUrl: config.webhookUrl
1978
2441
  },
1979
- { idempotencyKey: config.idempotencyKey?.(order), trigger: config.trigger }
2442
+ {
2443
+ idempotencyKey: config.idempotencyKey?.(order),
2444
+ trigger: config.trigger,
2445
+ replay: config.replay
2446
+ }
1980
2447
  );
1981
2448
  return {
1982
2449
  id: payment.id,
@@ -1987,128 +2454,6 @@ function lightningRail(config) {
1987
2454
  };
1988
2455
  };
1989
2456
  }
1990
-
1991
- // src/price.ts
1992
- var MSAT_PER_BTC2 = 1e11;
1993
- var BASIS_POINTS = 1e4;
1994
- var DEFAULT_HOLD_SECS = 60;
1995
- var DEFAULT_MIN_VENUES = 2;
1996
- var DEFAULT_MAX_SPREAD_BPS = 200;
1997
- var MILLIS2 = 1e3;
1998
- function coinbase(baseUrl = "https://api.coinbase.com") {
1999
- return async (currency) => {
2000
- const asked = currency.toUpperCase();
2001
- const answer = await asJson(`${baseUrl}/v2/prices/BTC-${asked}/spot`, "coinbase");
2002
- const quoted = answer.data;
2003
- if (quoted?.currency !== asked) {
2004
- throw new Error(`coinbase quoted ${quoted?.currency} when asked for ${asked}`);
2005
- }
2006
- return asMinor(quoted.amount, "coinbase", asked);
2007
- };
2008
- }
2009
- function kraken(baseUrl = "https://api.kraken.com") {
2010
- return async (currency) => {
2011
- const asked = currency.toUpperCase();
2012
- const answer = await asJson(`${baseUrl}/0/public/Ticker?pair=XBT${asked}`, "kraken");
2013
- if (answer.error?.length) throw new Error(`kraken refused XBT${asked}: ${answer.error[0]}`);
2014
- const pairs = Object.values(answer.result ?? {});
2015
- if (pairs.length !== 1) {
2016
- throw new Error(`kraken answered ${pairs.length} pairs for XBT${asked}`);
2017
- }
2018
- return asMinor(pairs[0]?.c?.[0], "kraken", asked);
2019
- };
2020
- }
2021
- function bitstamp(baseUrl = "https://www.bitstamp.net") {
2022
- return async (currency) => {
2023
- const pair = `btc${currency.toLowerCase()}`;
2024
- const answer = await asJson(`${baseUrl}/api/v2/ticker/${pair}/`, "bitstamp");
2025
- if (Array.isArray(answer)) throw new Error(`bitstamp has no ${pair} pair`);
2026
- return asMinor(answer.last, "bitstamp", currency);
2027
- };
2028
- }
2029
- function coinmate(baseUrl = "https://coinmate.io") {
2030
- return async (currency) => {
2031
- const pair = `BTC_${currency.toUpperCase()}`;
2032
- const answer = await asJson(`${baseUrl}/api/ticker?currencyPair=${pair}`, "coinmate");
2033
- if (answer.error !== false) {
2034
- throw new Error(`coinmate refused ${pair}: ${answer.errorMessage ?? "no reason"}`);
2035
- }
2036
- return asMinor(answer.data?.last, "coinmate", currency);
2037
- };
2038
- }
2039
- function medianOf(tickers = [coinbase(), kraken(), bitstamp(), coinmate()], options = {}) {
2040
- const holdFor = (options.holdForSecs ?? DEFAULT_HOLD_SECS) * MILLIS2;
2041
- const minVenues = options.minVenues ?? DEFAULT_MIN_VENUES;
2042
- const maxSpread = options.maxSpreadBps ?? DEFAULT_MAX_SPREAD_BPS;
2043
- const held = /* @__PURE__ */ new Map();
2044
- return async (currency) => {
2045
- const asked = currency.toUpperCase();
2046
- const remembered = held.get(asked);
2047
- if (remembered && Date.now() - remembered.at < holdFor) return remembered.price;
2048
- const answers = await Promise.allSettled(tickers.map((ticker) => ticker(asked)));
2049
- const quoted = answers.filter((answer) => answer.status === "fulfilled").map((answer) => answer.value).sort((one, other) => one - other);
2050
- if (quoted.length < minVenues) {
2051
- throw new Error(
2052
- `${quoted.length} of ${tickers.length} venues quoted ${asked}, ${minVenues} wanted: ${refusals2(answers)}`
2053
- );
2054
- }
2055
- const spread = spreadBpsOf(quoted);
2056
- if (spread > maxSpread) {
2057
- throw new Error(
2058
- `venues disagree on ${asked} by ${Math.round(spread)} bps, more than the ${maxSpread} allowed: ${quoted.join(", ")}`
2059
- );
2060
- }
2061
- const price = middleOf(quoted);
2062
- held.set(asked, { at: Date.now(), price });
2063
- return price;
2064
- };
2065
- }
2066
- function msatFor(amountMinor, priceMinorPerBtc, options = {}) {
2067
- if (!Number.isInteger(amountMinor) || amountMinor <= 0) {
2068
- throw new Error("amountMinor must be a whole number of minor units above zero");
2069
- }
2070
- if (!Number.isFinite(priceMinorPerBtc) || priceMinorPerBtc <= 0) {
2071
- throw new Error(`${priceMinorPerBtc} is not a usable price`);
2072
- }
2073
- const spreadBps = options.spreadBps ?? 0;
2074
- if (!Number.isInteger(spreadBps) || spreadBps < 0) {
2075
- throw new Error("spreadBps must be a whole number of basis points, none or more");
2076
- }
2077
- const wanted = BigInt(amountMinor) * BigInt(MSAT_PER_BTC2) * BigInt(BASIS_POINTS + spreadBps);
2078
- const per = BigInt(Math.round(priceMinorPerBtc)) * BigInt(BASIS_POINTS);
2079
- const msat = (wanted + per - 1n) / per;
2080
- if (msat > BigInt(Number.MAX_SAFE_INTEGER)) {
2081
- throw new Error(`${amountMinor} at ${priceMinorPerBtc} is more millisatoshi than exist`);
2082
- }
2083
- return Number(msat);
2084
- }
2085
- async function asJson(url, venue) {
2086
- const answer = await fetch(url, { headers: { accept: "application/json" } });
2087
- if (!answer.ok) throw new Error(`${venue} answered ${answer.status}`);
2088
- return answer.json();
2089
- }
2090
- function asMinor(quoted, venue, currency) {
2091
- const major2 = Number(quoted);
2092
- if (!Number.isFinite(major2) || major2 <= 0) {
2093
- throw new Error(`${venue} quoted ${String(quoted)} for ${currency.toUpperCase()}`);
2094
- }
2095
- return Math.round(major2 * minorScaleOf(currency));
2096
- }
2097
- function spreadBpsOf(sorted) {
2098
- const lowest = sorted[0] ?? 0;
2099
- const highest = sorted[sorted.length - 1] ?? 0;
2100
- return lowest === 0 ? Number.POSITIVE_INFINITY : (highest - lowest) / lowest * BASIS_POINTS;
2101
- }
2102
- function middleOf(sorted) {
2103
- const middle = Math.floor(sorted.length / 2);
2104
- if (sorted.length % 2 === 1) return sorted[middle] ?? 0;
2105
- return Math.round(((sorted[middle - 1] ?? 0) + (sorted[middle] ?? 0)) / 2);
2106
- }
2107
- function refusals2(answers) {
2108
- return answers.filter((answer) => answer.status === "rejected").map(
2109
- (answer) => answer.reason instanceof Error ? answer.reason.message : String(answer.reason)
2110
- ).join("; ");
2111
- }
2112
2457
  // Annotate the CommonJS export names for ESM import in node:
2113
2458
  0 && (module.exports = {
2114
2459
  GatewayCheatError,
@@ -2122,6 +2467,7 @@ function refusals2(answers) {
2122
2467
  REQUEST_IN_FLIGHT,
2123
2468
  ThunderBridge,
2124
2469
  UnverifiedRecipientError,
2470
+ WrapRefusedError,
2125
2471
  answerVerifyChallenge,
2126
2472
  answerVerifyChallengeRequest,
2127
2473
  answerWebhookChallenge,
@@ -2156,6 +2502,7 @@ function refusals2(answers) {
2156
2502
  preimageMatchesHash,
2157
2503
  proveOrigin,
2158
2504
  proveSettlement,
2505
+ proveWrapped,
2159
2506
  qrToDataUrl,
2160
2507
  qrToSvg,
2161
2508
  seal,
@@ -2163,5 +2510,6 @@ function refusals2(answers) {
2163
2510
  spdToSvg,
2164
2511
  toLnurl,
2165
2512
  unseal,
2166
- verifyWebhookSignature
2513
+ verifyWebhookSignature,
2514
+ wrapFeeCeiling
2167
2515
  });