thunder-bridge 1.5.0 → 2.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -234,6 +234,7 @@ function rotate(value, by) {
234
234
 
235
235
  // ../core/bolt11.ts
236
236
  var BECH32_CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l";
237
+ var BECH32_GENERATOR = [996825010, 642813549, 513874426, 1027748829, 705979059];
237
238
  var CHECKSUM_WORDS = 6;
238
239
  var TIMESTAMP_WORDS = 7;
239
240
  var SIGNATURE_WORDS = 104;
@@ -251,18 +252,27 @@ var HRP_MULTIPLIER_MSAT = {
251
252
  n: MSAT_PER_BTC / 1e9,
252
253
  p: MSAT_PER_BTC / 1e12
253
254
  };
254
- var HRP_AMOUNT = /^ln[a-z]+?(\d+)([munp]?)$/;
255
+ var HRP = /^ln(?:bcrt|bc|tbs|tb|sb)(?:(\d+)([munp]?))?$/;
255
256
  function decodeInvoice(bolt11) {
256
257
  const parts = splitBech32(bolt11);
257
258
  if (parts === null) {
258
- return { paymentHash: null, descriptionHash: null, amountMsat: null, expiresAt: null };
259
+ return {
260
+ paymentHash: null,
261
+ descriptionHash: null,
262
+ amountMsat: null,
263
+ issuedAt: null,
264
+ expiresAt: null
265
+ };
259
266
  }
260
267
  const tagged = taggedFields(parts.words);
268
+ const issuedAt = issuedAtOf(parts.words);
269
+ const expiry = tagged.get(EXPIRY_TAG);
261
270
  return {
262
271
  paymentHash: hexTag(tagged, PAYMENT_HASH_TAG, PAYMENT_HASH_WORDS),
263
272
  descriptionHash: hexTag(tagged, DESCRIPTION_HASH_TAG, DESCRIPTION_HASH_WORDS),
264
273
  amountMsat: amountFromHrp(parts.hrp),
265
- expiresAt: expiryOf(parts.words, tagged)
274
+ issuedAt,
275
+ expiresAt: issuedAt === null ? null : issuedAt + (expiry ? readNumber(expiry) : DEFAULT_EXPIRY_SECS)
266
276
  };
267
277
  }
268
278
  function preimageMatchesHash(preimage, paymentHash) {
@@ -273,7 +283,7 @@ function preimageMatchesHash(preimage, paymentHash) {
273
283
  }
274
284
  function splitBech32(bolt11) {
275
285
  const lower = bolt11.toLowerCase();
276
- if (isBolt12Encoding(lower)) {
286
+ if (isBolt12Encoding(lower) || bolt11 !== lower && bolt11 !== bolt11.toUpperCase()) {
277
287
  return null;
278
288
  }
279
289
  const separator = lower.lastIndexOf("1");
@@ -292,7 +302,28 @@ function splitBech32(bolt11) {
292
302
  }
293
303
  words.push(value);
294
304
  }
295
- return { hrp: lower.slice(0, separator), words: words.slice(0, -CHECKSUM_WORDS) };
305
+ const hrp = lower.slice(0, separator);
306
+ if (!HRP.test(hrp) || bech32Polymod([...expandedHrp(hrp), ...words]) !== 1) {
307
+ return null;
308
+ }
309
+ return { hrp, words: words.slice(0, -CHECKSUM_WORDS) };
310
+ }
311
+ function expandedHrp(hrp) {
312
+ const codes = [...hrp].map((char) => char.charCodeAt(0));
313
+ return [...codes.map((code) => code >> 5), 0, ...codes.map((code) => code & 31)];
314
+ }
315
+ function bech32Polymod(values) {
316
+ let checksum = 1;
317
+ for (const value of values) {
318
+ const top = checksum >> 25;
319
+ checksum = (checksum & 33554431) << 5 ^ value;
320
+ for (let bit = 0; bit < 5; bit++) {
321
+ if (top >> bit & 1) {
322
+ checksum ^= BECH32_GENERATOR[bit];
323
+ }
324
+ }
325
+ }
326
+ return checksum;
296
327
  }
297
328
  function isBolt12Encoding(lower) {
298
329
  return lower.startsWith("lno") || lower.startsWith("lni") || lower.startsWith("lnr");
@@ -322,19 +353,17 @@ function hexTag(fields, tag, expectedWords) {
322
353
  }
323
354
  return bytesToHex(wordsToBytes(words));
324
355
  }
325
- function expiryOf(words, fields) {
356
+ function issuedAtOf(words) {
326
357
  if (words.length < TIMESTAMP_WORDS + SIGNATURE_WORDS) {
327
358
  return null;
328
359
  }
329
- const expiry = fields.get(EXPIRY_TAG);
330
- return readNumber(words.slice(0, TIMESTAMP_WORDS)) + (expiry ? readNumber(expiry) : DEFAULT_EXPIRY_SECS);
360
+ return readNumber(words.slice(0, TIMESTAMP_WORDS));
331
361
  }
332
362
  function amountFromHrp(hrp) {
333
- const match = HRP_AMOUNT.exec(hrp);
334
- if (match === null) {
363
+ const [, digits, multiplier] = HRP.exec(hrp) ?? [];
364
+ if (digits === void 0 || multiplier === "p" && !digits.endsWith("0")) {
335
365
  return null;
336
366
  }
337
- const [, digits, multiplier] = match;
338
367
  return multiplier ? Number(digits) * HRP_MULTIPLIER_MSAT[multiplier] : Number(digits) * MSAT_PER_BTC;
339
368
  }
340
369
  function readNumber(words) {
@@ -355,28 +384,74 @@ function wordsToBytes(words) {
355
384
  return Uint8Array.from(bytes);
356
385
  }
357
386
 
387
+ // ../core/hmac.ts
388
+ async function hmacHex(secret, payload) {
389
+ const raw = typeof secret === "string" ? new TextEncoder().encode(secret) : secret;
390
+ const key = await crypto.subtle.importKey(
391
+ "raw",
392
+ asBuffer(raw),
393
+ { name: "HMAC", hash: "SHA-256" },
394
+ false,
395
+ ["sign"]
396
+ );
397
+ const message = typeof payload === "string" ? new TextEncoder().encode(payload) : payload;
398
+ return bytesToHex(new Uint8Array(await crypto.subtle.sign("HMAC", key, asBuffer(message))));
399
+ }
400
+ function equalInConstantTime(one, other) {
401
+ if (one.length !== other.length) {
402
+ return false;
403
+ }
404
+ let difference = 0;
405
+ for (let at = 0; at < one.length; at++) {
406
+ difference |= one.charCodeAt(at) ^ other.charCodeAt(at);
407
+ }
408
+ return difference === 0;
409
+ }
410
+ function asBuffer(bytes) {
411
+ return bytes instanceof Uint8Array && bytes.buffer instanceof ArrayBuffer ? bytes : new Uint8Array(bytes);
412
+ }
413
+
358
414
  // ../core/sealed.ts
359
- var VERSION = "v1";
415
+ var VERSION = "v2";
416
+ var UNPADDED_VERSION = "v1";
360
417
  var IV_BYTES = 12;
361
418
  var MIN_SECRET_CHARS = 32;
362
419
  var MAX_PLAIN_BYTES = 3e3;
420
+ var SIZE_CLASSES = [256, 1024, MAX_PLAIN_BYTES];
421
+ var END_OF_TEXT = 128;
363
422
  var INFO = new TextEncoder().encode("thunder-bridge/sealed");
364
- async function seal(secret, plaintext) {
365
- const body = new TextEncoder().encode(plaintext);
366
- if (body.length > MAX_PLAIN_BYTES) {
367
- throw new Error(`sealed takes at most ${MAX_PLAIN_BYTES} bytes, this was ${body.length}`);
423
+ async function seal(secret, plaintext, paymentHash) {
424
+ return await sealUnder(
425
+ secret,
426
+ plaintext,
427
+ crypto.getRandomValues(new Uint8Array(IV_BYTES)),
428
+ boundTo(paymentHash)
429
+ );
430
+ }
431
+ async function sealStable(secret, plaintext) {
432
+ const derived = await hmacHex(secret, `sealed-nonce|${plaintext}`);
433
+ const nonce = new Uint8Array(IV_BYTES);
434
+ for (let at = 0; at < IV_BYTES; at++) {
435
+ nonce[at] = Number.parseInt(derived.slice(at * 2, at * 2 + 2), 16);
368
436
  }
437
+ return await sealUnder(secret, plaintext, nonce, boundTo(void 0));
438
+ }
439
+ async function sealUnder(secret, plaintext, iv, additionalData) {
440
+ const body = padToClass(plaintext);
369
441
  const key = await keyFor(secret);
370
- const iv = crypto.getRandomValues(new Uint8Array(IV_BYTES));
371
- const cipher = new Uint8Array(await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, body));
442
+ const cipher = new Uint8Array(
443
+ await crypto.subtle.encrypt({ name: "AES-GCM", iv, additionalData }, key, body)
444
+ );
372
445
  const joined = new Uint8Array(iv.length + cipher.length);
373
446
  joined.set(iv);
374
447
  joined.set(cipher, iv.length);
375
448
  return `${VERSION}.${toBase64Url(joined)}`;
376
449
  }
377
- async function unseal(secret, sealed) {
450
+ async function unseal(secret, sealed, paymentHash) {
378
451
  const key = await keyFor(secret);
379
- if (!sealed.startsWith(`${VERSION}.`)) {
452
+ const padded = sealed.startsWith(`${VERSION}.`);
453
+ const unbound = !padded && sealed.startsWith(`${UNPADDED_VERSION}.`);
454
+ if (!padded && (!unbound || paymentHash !== void 0)) {
380
455
  return null;
381
456
  }
382
457
  const joined = fromBase64Url(sealed.slice(VERSION.length + 1));
@@ -384,20 +459,50 @@ async function unseal(secret, sealed) {
384
459
  return null;
385
460
  }
386
461
  try {
387
- const body = await crypto.subtle.decrypt(
388
- { name: "AES-GCM", iv: joined.slice(0, IV_BYTES) },
389
- key,
390
- joined.slice(IV_BYTES)
462
+ const body = new Uint8Array(
463
+ await crypto.subtle.decrypt(
464
+ {
465
+ name: "AES-GCM",
466
+ iv: joined.slice(0, IV_BYTES),
467
+ additionalData: padded ? boundTo(paymentHash) : void 0
468
+ },
469
+ key,
470
+ joined.slice(IV_BYTES)
471
+ )
391
472
  );
392
- return new TextDecoder().decode(body);
473
+ const text3 = padded ? unpad(body) : body;
474
+ return text3 === null ? null : new TextDecoder().decode(text3);
393
475
  } catch {
394
476
  return null;
395
477
  }
396
478
  }
397
- async function keyFor(secret) {
479
+ function refuseAWeakSecret(secret) {
398
480
  if (secret.length < MIN_SECRET_CHARS) {
399
481
  throw new Error(`the sealing secret needs ${MIN_SECRET_CHARS} characters of randomness`);
400
482
  }
483
+ }
484
+ function boundTo(paymentHash) {
485
+ return new TextEncoder().encode(
486
+ paymentHash === void 0 ? VERSION : `${VERSION}|${paymentHash.toLowerCase()}`
487
+ );
488
+ }
489
+ function padToClass(plaintext) {
490
+ const text3 = new TextEncoder().encode(plaintext);
491
+ const size = SIZE_CLASSES.find((limit) => text3.length <= limit);
492
+ if (size === void 0) {
493
+ throw new Error(`sealed takes at most ${MAX_PLAIN_BYTES} bytes, this was ${text3.length}`);
494
+ }
495
+ const body = new Uint8Array(size + 1);
496
+ body.set(text3);
497
+ body[text3.length] = END_OF_TEXT;
498
+ return body;
499
+ }
500
+ function unpad(body) {
501
+ const end = body.lastIndexOf(END_OF_TEXT);
502
+ return end === -1 ? null : body.subarray(0, end);
503
+ }
504
+ async function keyFor(secret) {
505
+ refuseAWeakSecret(secret);
401
506
  const material = await crypto.subtle.importKey(
402
507
  "raw",
403
508
  new TextEncoder().encode(secret),
@@ -594,11 +699,11 @@ function coinbase(baseUrl = "https://api.coinbase.com") {
594
699
  return async (currency) => {
595
700
  const asked = currency.toUpperCase();
596
701
  const answer = await asJson(`${baseUrl}/v2/prices/BTC-${asked}/spot`, "coinbase");
597
- const quoted = answer.data;
598
- if (quoted?.currency !== asked) {
599
- throw new Error(`coinbase quoted ${quoted?.currency} when asked for ${asked}`);
702
+ const quoted2 = answer.data;
703
+ if (quoted2?.currency !== asked) {
704
+ throw new Error(`coinbase quoted ${quoted2?.currency} when asked for ${asked}`);
600
705
  }
601
- return asMinor(quoted.amount, "coinbase", asked);
706
+ return asMinor(quoted2.amount, "coinbase", asked);
602
707
  };
603
708
  }
604
709
  function kraken(baseUrl = "https://api.kraken.com") {
@@ -647,19 +752,19 @@ function medianOf(tickers = [coinbase(), kraken(), bitstamp(), coinmate()], opti
647
752
  return remembered.price;
648
753
  }
649
754
  const answers = await Promise.allSettled(tickers.map((ticker) => ticker(asked)));
650
- const quoted = answers.filter((answer) => answer.status === "fulfilled").map((answer) => answer.value).sort((one, other) => one - other);
651
- if (quoted.length < minVenues) {
755
+ const quoted2 = answers.filter((answer) => answer.status === "fulfilled").map((answer) => answer.value).sort((one, other) => one - other);
756
+ if (quoted2.length < minVenues) {
652
757
  throw new Error(
653
- `${quoted.length} of ${tickers.length} venues quoted ${asked}, ${minVenues} wanted: ${refusals(answers)}`
758
+ `${quoted2.length} of ${tickers.length} venues quoted ${asked}, ${minVenues} wanted: ${refusals(answers)}`
654
759
  );
655
760
  }
656
- const spread2 = spreadBpsOf(quoted);
761
+ const spread2 = spreadBpsOf(quoted2);
657
762
  if (spread2 > maxSpread) {
658
763
  throw new Error(
659
- `venues disagree on ${asked} by ${Math.round(spread2)} bps, more than the ${maxSpread} allowed: ${quoted.join(", ")}`
764
+ `venues disagree on ${asked} by ${Math.round(spread2)} bps, more than the ${maxSpread} allowed: ${quoted2.join(", ")}`
660
765
  );
661
766
  }
662
- const price = middleOf(quoted);
767
+ const price = middleOf(quoted2);
663
768
  held.set(asked, { at: Date.now(), price });
664
769
  return price;
665
770
  };
@@ -690,10 +795,10 @@ async function asJson(url, venue) {
690
795
  }
691
796
  return answer.json();
692
797
  }
693
- function asMinor(quoted, venue, currency) {
694
- const major2 = Number(quoted);
798
+ function asMinor(quoted2, venue, currency) {
799
+ const major2 = Number(quoted2);
695
800
  if (!Number.isFinite(major2) || major2 <= 0) {
696
- throw new Error(`${venue} quoted ${String(quoted)} for ${currency.toUpperCase()}`);
801
+ throw new Error(`${venue} quoted ${String(quoted2)} for ${currency.toUpperCase()}`);
697
802
  }
698
803
  return Math.round(major2 * minorScaleOf(currency));
699
804
  }
@@ -781,6 +886,16 @@ var SEED_BYTES = 32;
781
886
  var SIGNATURE_BYTES = 64;
782
887
  var PUBLIC_KEY_BYTES = 32;
783
888
  var PKCS8_HEADER = hexToBytes("302e020100300506032b657004220420");
889
+ var SIGN_BIT_CLEARED = 127;
890
+ var SMALL_ORDER = [
891
+ "00".repeat(32),
892
+ `01${"00".repeat(31)}`,
893
+ "26e8958fc2b227b045c3f489f2ef98f0d5dfac05d3c63339b13802886d53fc05",
894
+ "c7176a703d4dd84fba3c0b760d10670f2a2053fa2c39ccc64ec7fd7792ac037a",
895
+ `ec${"ff".repeat(30)}7f`,
896
+ `ed${"ff".repeat(30)}7f`,
897
+ `ee${"ff".repeat(30)}7f`
898
+ ];
784
899
  async function signingKeyFromSeed(seed) {
785
900
  if (seed.length !== SEED_BYTES) {
786
901
  throw new Error(`an ed25519 seed is ${SEED_BYTES} bytes`);
@@ -788,24 +903,27 @@ async function signingKeyFromSeed(seed) {
788
903
  const wrapped = new Uint8Array(PKCS8_HEADER.length + SEED_BYTES);
789
904
  wrapped.set(PKCS8_HEADER);
790
905
  wrapped.set(seed, PKCS8_HEADER.length);
791
- const key = await crypto.subtle.importKey("pkcs8", asBuffer(wrapped), ALGORITHM, true, ["sign"]);
906
+ const key = await crypto.subtle.importKey("pkcs8", asBuffer2(wrapped), ALGORITHM, true, ["sign"]);
792
907
  const jwk = await crypto.subtle.exportKey("jwk", key);
793
908
  return {
794
909
  publicKeyHex: bytesToHex(fromBase64Url2(jwk.x ?? "")),
795
- sign: async (payload) => bytesToHex(new Uint8Array(await crypto.subtle.sign(ALGORITHM, key, asBuffer(payload))))
910
+ sign: async (payload) => bytesToHex(new Uint8Array(await crypto.subtle.sign(ALGORITHM, key, asBuffer2(payload))))
796
911
  };
797
912
  }
798
913
  async function verifyHex(publicKeyHex, signatureHex, payload) {
799
914
  if (!isHex(publicKeyHex) || publicKeyHex.length !== PUBLIC_KEY_BYTES * 2) {
800
915
  return false;
801
916
  }
917
+ if (hasSmallOrder(publicKeyHex)) {
918
+ return false;
919
+ }
802
920
  if (!isHex(signatureHex) || signatureHex.length !== SIGNATURE_BYTES * 2) {
803
921
  return false;
804
922
  }
805
923
  try {
806
924
  const key = await crypto.subtle.importKey(
807
925
  "raw",
808
- asBuffer(hexToBytes(publicKeyHex)),
926
+ asBuffer2(hexToBytes(publicKeyHex)),
809
927
  ALGORITHM,
810
928
  false,
811
929
  ["verify"]
@@ -813,13 +931,18 @@ async function verifyHex(publicKeyHex, signatureHex, payload) {
813
931
  return await crypto.subtle.verify(
814
932
  ALGORITHM,
815
933
  key,
816
- asBuffer(hexToBytes(signatureHex)),
817
- asBuffer(payload)
934
+ asBuffer2(hexToBytes(signatureHex)),
935
+ asBuffer2(payload)
818
936
  );
819
937
  } catch {
820
938
  return false;
821
939
  }
822
940
  }
941
+ function hasSmallOrder(publicKeyHex) {
942
+ const point = hexToBytes(publicKeyHex);
943
+ point[PUBLIC_KEY_BYTES - 1] = point[PUBLIC_KEY_BYTES - 1] & SIGN_BIT_CLEARED;
944
+ return SMALL_ORDER.includes(bytesToHex(point));
945
+ }
823
946
  function fromBase64Url2(text3) {
824
947
  const padded = text3.replace(/-/g, "+").replace(/_/g, "/");
825
948
  const raw = atob(padded + "=".repeat((4 - padded.length % 4) % 4));
@@ -829,35 +952,8 @@ function fromBase64Url2(text3) {
829
952
  }
830
953
  return bytes;
831
954
  }
832
- function asBuffer(bytes) {
833
- return bytes.buffer instanceof ArrayBuffer ? bytes : new Uint8Array(bytes);
834
- }
835
-
836
- // ../core/hmac.ts
837
- async function hmacHex(secret, payload) {
838
- const raw = typeof secret === "string" ? new TextEncoder().encode(secret) : secret;
839
- const key = await crypto.subtle.importKey(
840
- "raw",
841
- asBuffer2(raw),
842
- { name: "HMAC", hash: "SHA-256" },
843
- false,
844
- ["sign"]
845
- );
846
- const message = typeof payload === "string" ? new TextEncoder().encode(payload) : payload;
847
- return bytesToHex(new Uint8Array(await crypto.subtle.sign("HMAC", key, asBuffer2(message))));
848
- }
849
- function equalInConstantTime(one, other) {
850
- if (one.length !== other.length) {
851
- return false;
852
- }
853
- let difference = 0;
854
- for (let at = 0; at < one.length; at++) {
855
- difference |= one.charCodeAt(at) ^ other.charCodeAt(at);
856
- }
857
- return difference === 0;
858
- }
859
955
  function asBuffer2(bytes) {
860
- return bytes instanceof Uint8Array && bytes.buffer instanceof ArrayBuffer ? bytes : new Uint8Array(bytes);
956
+ return bytes.buffer instanceof ArrayBuffer ? bytes : new Uint8Array(bytes);
861
957
  }
862
958
 
863
959
  // ../core/caller.ts
@@ -935,8 +1031,8 @@ function ipv4IsGlobal(literal) {
935
1031
  if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) {
936
1032
  return false;
937
1033
  }
938
- const [a, b] = parts;
939
- if (a === 0 || a === 10 || a === 127 || a >= 240) {
1034
+ const [a, b, c] = parts;
1035
+ if (a === 0 || a === 10 || a === 127 || a >= 224) {
940
1036
  return false;
941
1037
  }
942
1038
  if (a === 100 && b >= 64 && b < 128) {
@@ -948,7 +1044,13 @@ function ipv4IsGlobal(literal) {
948
1044
  if (a === 172 && b >= 16 && b < 32) {
949
1045
  return false;
950
1046
  }
951
- if (a === 192 && b === 168) {
1047
+ if (a === 192 && (b === 168 || b === 0 && (c === 0 || c === 2))) {
1048
+ return false;
1049
+ }
1050
+ if (a === 198 && (b === 18 || b === 19 || b === 51 && c === 100)) {
1051
+ return false;
1052
+ }
1053
+ if (a === 203 && b === 0 && c === 113) {
952
1054
  return false;
953
1055
  }
954
1056
  return true;
@@ -958,29 +1060,59 @@ function ipv6IsGlobal(literal) {
958
1060
  if (groups.length !== 8 || groups.some(Number.isNaN)) {
959
1061
  return false;
960
1062
  }
961
- const leading = groups.slice(0, 5).every((group) => group === 0);
962
- if (leading && groups[5] === 0 && groups[6] === 0 && groups[7] <= 1) {
1063
+ const [first, second, third, fourth, fifth, sixth, high, low] = groups;
1064
+ const embedded = (one, other) => ipv4IsGlobal(`${one >> 8}.${one & 255}.${other >> 8}.${other & 255}`);
1065
+ const zeroBefore = (count) => groups.slice(0, count).every((group) => group === 0);
1066
+ if (zeroBefore(5) && sixth === 65535) {
1067
+ return embedded(high, low);
1068
+ }
1069
+ if (zeroBefore(6) || zeroBefore(4) && fifth === 65535 && sixth === 0) {
1070
+ return false;
1071
+ }
1072
+ if (first === 100 && second === 65435) {
1073
+ return third === 0 && fourth === 0 && fifth === 0 && sixth === 0 && embedded(high, low);
1074
+ }
1075
+ if (first === 8194) {
1076
+ return embedded(second, third);
1077
+ }
1078
+ if (first === 8193 && second === 0) {
963
1079
  return false;
964
1080
  }
965
- if (leading && groups[5] === 65535) {
966
- const [high, low] = [groups[6], groups[7]];
967
- return ipv4IsGlobal(`${high >> 8}.${high & 255}.${low >> 8}.${low & 255}`);
1081
+ if (first === 256 && second === 0 && third === 0 && fourth === 0) {
1082
+ return false;
968
1083
  }
969
- const first = groups[0];
970
- if ((first & 65024) === 64512) {
1084
+ if ((first & 65024) === 64512 || (first & 65280) === 65280) {
971
1085
  return false;
972
1086
  }
973
- if ((first & 65472) === 65152) {
1087
+ if ((first & 65472) === 65152 || (first & 65472) === 65216) {
974
1088
  return false;
975
1089
  }
976
1090
  return true;
977
1091
  }
978
1092
  function ipv6Groups(address) {
979
- const [head = "", tail] = address.split("::");
1093
+ const halves = withDottedTailAsGroups(address).split("::");
1094
+ const [head = "", tail] = halves;
980
1095
  const left = head.split(":").filter((group) => group !== "");
981
1096
  const right = (tail ?? "").split(":").filter((group) => group !== "");
982
- const gap = tail === void 0 ? [] : Array(8 - left.length - right.length).fill("0");
983
- return [...left, ...gap, ...right].map((group) => parseInt(group, 16));
1097
+ const missing = 8 - left.length - right.length;
1098
+ if (halves.length > 2 || missing < 0) {
1099
+ return [Number.NaN];
1100
+ }
1101
+ const gap = tail === void 0 ? [] : Array(missing).fill("0");
1102
+ return [...left, ...gap, ...right].map(
1103
+ (group) => /^[0-9a-f]{1,4}$/.test(group) ? parseInt(group, 16) : Number.NaN
1104
+ );
1105
+ }
1106
+ function withDottedTailAsGroups(address) {
1107
+ const dotted = /^(.*:)(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/.exec(address);
1108
+ if (dotted === null) {
1109
+ return address;
1110
+ }
1111
+ const [a, b, c, d] = dotted[2].split(".").map(Number);
1112
+ if ([a, b, c, d].some((part) => part > 255)) {
1113
+ return `${dotted[1]}x`;
1114
+ }
1115
+ return `${dotted[1]}${(a << 8 | b).toString(16)}:${(c << 8 | d).toString(16)}`;
984
1116
  }
985
1117
 
986
1118
  // ../core/outbound.ts
@@ -1008,6 +1140,9 @@ async function ask(send, url, sent = {}) {
1008
1140
  }
1009
1141
  await response.body?.cancel();
1010
1142
  const next = new URL(location, target).toString();
1143
+ if (sent.staysOnOrigin && !sameOrigin(target, next)) {
1144
+ throw new Error(`${target} redirected off its own origin, to ${next}`);
1145
+ }
1011
1146
  carried = KEEPS_THE_METHOD.includes(response.status) ? carried : withoutBody(carried);
1012
1147
  carried = sameOrigin(target, next) ? carried : withoutCredentials(carried);
1013
1148
  target = next;
@@ -1054,15 +1189,17 @@ async function answerOf(response) {
1054
1189
  return { status, ok, body: text(read), truncated: true, headers };
1055
1190
  }
1056
1191
  function withoutBody(sent) {
1057
- const headers = { ...sent.headers };
1058
- delete headers["content-type"];
1192
+ const headers = Object.fromEntries(
1193
+ Object.entries(sent.headers ?? {}).filter(([name]) => name.toLowerCase() !== "content-type")
1194
+ );
1059
1195
  return { ...sent, method: "GET", headers, body: void 0 };
1060
1196
  }
1061
1197
  function withoutCredentials(sent) {
1062
- const headers = { ...sent.headers };
1063
- for (const named of CREDENTIALS) {
1064
- delete headers[named];
1065
- }
1198
+ const headers = Object.fromEntries(
1199
+ Object.entries(sent.headers ?? {}).filter(
1200
+ ([name]) => !CREDENTIALS.includes(name.toLowerCase())
1201
+ )
1202
+ );
1066
1203
  return { ...sent, headers };
1067
1204
  }
1068
1205
  function text(chunks) {
@@ -1105,6 +1242,22 @@ async function resolve(send, addresses, amountMsat) {
1105
1242
  );
1106
1243
  return served.won;
1107
1244
  }
1245
+ async function quote(send, addresses, amountMsat) {
1246
+ const served = await firstThatServes(
1247
+ addresses,
1248
+ (address2, deadline) => probe(send, address2, amountMsat, deadline)
1249
+ );
1250
+ const { address, pay } = served.won;
1251
+ return {
1252
+ won: {
1253
+ address,
1254
+ minMsat: pay.minSendable,
1255
+ maxMsat: pay.maxSendable,
1256
+ metadata: pay.metadata
1257
+ },
1258
+ refusals: served.refusals
1259
+ };
1260
+ }
1108
1261
  async function firstThatServes(addresses, attempt) {
1109
1262
  const deadline = AbortSignal.timeout(RESOLVE_TIMEOUT_MS);
1110
1263
  const refusals3 = [];
@@ -1197,7 +1350,7 @@ function decodeIssued(address, bolt11) {
1197
1350
  return { paymentHash, descriptionHash, amountMsat, expiresAt };
1198
1351
  }
1199
1352
  async function checkSettled(send, verifyUrl, paymentHash) {
1200
- const answer = await answeredJson(send, verifyUrl);
1353
+ const answer = await answeredJson(send, verifyUrl, { staysOnOrigin: true });
1201
1354
  const asked = {
1202
1355
  pace: paceAskedFor(answer.headers),
1203
1356
  ceiling: ceilingAskedFor(answer.headers)
@@ -1255,10 +1408,10 @@ function isNothingButAHost(domain) {
1255
1408
  }
1256
1409
  }
1257
1410
  async function fetchJson(send, url, deadline) {
1258
- return (await answeredJson(send, url, deadline)).said;
1411
+ return (await answeredJson(send, url, { deadline })).said;
1259
1412
  }
1260
- async function answeredJson(send, url, deadline) {
1261
- const answer = await ask(send, url, { headers: { accept: "application/json" }, deadline });
1413
+ async function answeredJson(send, url, sent) {
1414
+ const answer = await ask(send, url, { headers: { accept: "application/json" }, ...sent });
1262
1415
  if (!answer.ok) {
1263
1416
  throw new Error(`${url} answered ${answer.status}`);
1264
1417
  }
@@ -1286,12 +1439,17 @@ var pinnedToTheAddressWeVerified = async (url, sent, signal, at) => {
1286
1439
  }
1287
1440
  const call = request(reaching(asked, sent, at, first), (answer) => {
1288
1441
  const status = answer.statusCode ?? 502;
1289
- settle(
1290
- new Response(
1291
- CARRIES_NO_BODY.includes(status) ? null : Readable.toWeb(answer),
1292
- { status, headers: headersOf(answer.headers) }
1293
- )
1294
- );
1442
+ try {
1443
+ settle(
1444
+ new Response(
1445
+ CARRIES_NO_BODY.includes(status) ? null : Readable.toWeb(answer),
1446
+ { status, headers: headersOf(answer.headers) }
1447
+ )
1448
+ );
1449
+ } catch (unreadable) {
1450
+ answer.destroy();
1451
+ fail(new Error(`${url} answered with something HTTP cannot carry`, { cause: unreadable }));
1452
+ }
1295
1453
  });
1296
1454
  const give = () => call.destroy(new Error(`${url} took longer than allowed`));
1297
1455
  signal.addEventListener("abort", give, { once: true });
@@ -1338,6 +1496,173 @@ function headersOf(raw) {
1338
1496
  return headers;
1339
1497
  }
1340
1498
 
1499
+ // src/verify.ts
1500
+ var HTTP_TIMEOUT_MS2 = 15e3;
1501
+ var ISSUED_SKEW_SECS = 300;
1502
+ var NAMES_AN_ADDRESS = ["text/identifier", "text/email"];
1503
+ var DEFAULT_WRAP_PROPORTION = 0.01;
1504
+ var DEFAULT_WRAP_BASE_MSAT = 1e3;
1505
+ async function proveOrigin(payment, asked, askedAt) {
1506
+ const cheat = (code) => new GatewayCheatError(code, payment.id);
1507
+ const listed2 = asked.paidTo.find((address) => equalIgnoringCase(address, payment.lnAddress));
1508
+ if (listed2 === void 0) {
1509
+ throw cheat("address_not_requested");
1510
+ }
1511
+ if (payment.amountMsat !== asked.amountMsat) {
1512
+ throw cheat("amount_mismatch");
1513
+ }
1514
+ const invoice = decodeInvoice(payment.bolt11);
1515
+ if (invoice.paymentHash === null || !equalIgnoringCase(invoice.paymentHash, payment.paymentHash)) {
1516
+ throw cheat("hash_mismatch");
1517
+ }
1518
+ if (invoice.amountMsat !== asked.amountMsat) {
1519
+ throw cheat("amount_mismatch");
1520
+ }
1521
+ if (askedAt !== void 0 && (invoice.issuedAt ?? 0) < askedAt - ISSUED_SKEW_SECS) {
1522
+ throw cheat("invoice_stale");
1523
+ }
1524
+ const payRequest = await payRequestFor(listed2, payment);
1525
+ if (typeof payRequest.metadata !== "string" || typeof payRequest.callback !== "string") {
1526
+ throw new UnverifiedRecipientError(payment.lnAddress, payment.id, "no payRequest served");
1527
+ }
1528
+ if (!namesTheAddress(payRequest.metadata, listed2)) {
1529
+ throw new UnverifiedRecipientError(
1530
+ payment.lnAddress,
1531
+ payment.id,
1532
+ "its metadata names no text/identifier for this address, so no invoice binds to it"
1533
+ );
1534
+ }
1535
+ if (invoice.descriptionHash !== sha256Hex(payRequest.metadata)) {
1536
+ throw cheat("description_hash_mismatch");
1537
+ }
1538
+ if (!sameOrigin(payment.verifyUrl, payRequest.callback)) {
1539
+ throw cheat("verify_url_foreign");
1540
+ }
1541
+ const issued = await reachable(payment.verifyUrl, payment);
1542
+ if (typeof issued.pr !== "string" || !equalIgnoringCase(issued.pr, payment.bolt11)) {
1543
+ throw cheat("invoice_not_issued");
1544
+ }
1545
+ if (askedAt !== void 0 && issued.settled === true) {
1546
+ throw cheat("invoice_settled");
1547
+ }
1548
+ }
1549
+ function namesTheAddress(metadata, address) {
1550
+ try {
1551
+ const entries = JSON.parse(metadata);
1552
+ return Array.isArray(entries) && entries.some(
1553
+ (entry) => Array.isArray(entry) && NAMES_AN_ADDRESS.includes(entry[0]) && typeof entry[1] === "string" && equalIgnoringCase(entry[1], address)
1554
+ );
1555
+ } catch {
1556
+ return false;
1557
+ }
1558
+ }
1559
+ async function proveSettlement(payment, asked) {
1560
+ await proveOrigin(payment, asked);
1561
+ const verified = await reachable(payment.verifyUrl, payment);
1562
+ if (verified.settled !== true || typeof verified.preimage !== "string") {
1563
+ return null;
1564
+ }
1565
+ if (!preimageMatchesHash(verified.preimage, payment.paymentHash)) {
1566
+ throw new GatewayCheatError("preimage_mismatch", payment.id);
1567
+ }
1568
+ return verified.preimage;
1569
+ }
1570
+ function carriesProof(report) {
1571
+ if (report.status !== "paid" || report.preimage === null) {
1572
+ return false;
1573
+ }
1574
+ if (report.bolt11 !== void 0 && report.bolt11 !== null) {
1575
+ const invoiceHash = decodeInvoice(report.bolt11).paymentHash;
1576
+ if (invoiceHash === null || !equalIgnoringCase(invoiceHash, report.paymentHash)) {
1577
+ return false;
1578
+ }
1579
+ }
1580
+ return preimageMatchesHash(report.preimage, report.paymentHash);
1581
+ }
1582
+ function wrapFeeCeiling(amountMsat, allowance) {
1583
+ const proportional = amountMsat * (allowance?.proportion ?? DEFAULT_WRAP_PROPORTION);
1584
+ return msat(Math.max(allowance?.baseMsat ?? DEFAULT_WRAP_BASE_MSAT, Math.floor(proportional)));
1585
+ }
1586
+ function proveWrapped(wrapped, recipient, allowance) {
1587
+ const offered = decodeInvoice(wrapped);
1588
+ const owed = decodeInvoice(recipient);
1589
+ const refuse2 = (code, detail) => {
1590
+ throw new WrapRefusedError(code, detail);
1591
+ };
1592
+ if (offered.paymentHash === null || offered.amountMsat === null || offered.expiresAt === null) {
1593
+ refuse2("undecodable", "the wrapped invoice carries no hash, amount or expiry");
1594
+ }
1595
+ if (owed.paymentHash === null || owed.amountMsat === null || owed.expiresAt === null) {
1596
+ refuse2("undecodable", "the recipient's invoice carries no hash, amount or expiry");
1597
+ }
1598
+ if (offered.paymentHash?.toLowerCase() !== owed.paymentHash?.toLowerCase()) {
1599
+ refuse2("hash_mismatch", "settling the wrap would not settle the recipient");
1600
+ }
1601
+ const asked = offered.amountMsat ?? 0;
1602
+ const due = owed.amountMsat ?? 0;
1603
+ if (asked < due) {
1604
+ refuse2("amount_below_recipient", `${asked} msat cannot forward ${due} msat`);
1605
+ }
1606
+ const ceiling = wrapFeeCeiling(due, allowance);
1607
+ if (asked - due > ceiling) {
1608
+ refuse2("fee_above_allowance", `${asked - due} msat over ${due}, and ${ceiling} was allowed`);
1609
+ }
1610
+ if ((owed.expiresAt ?? 0) <= (offered.expiresAt ?? 0)) {
1611
+ refuse2("recipient_expires_first", "the operator could be paid and find it cannot deliver");
1612
+ }
1613
+ }
1614
+ async function payRequestFor(listed2, payment) {
1615
+ const at = listed2.indexOf("@");
1616
+ const name = listed2.slice(0, at);
1617
+ const domain = listed2.slice(at + 1).toLowerCase();
1618
+ if (at < 1 || !publicHttps(`https://${domain}/`)) {
1619
+ throw new UnverifiedRecipientError(payment.lnAddress, payment.id, "not a usable address");
1620
+ }
1621
+ return reachable(`https://${domain}/.well-known/lnurlp/${name}`, payment);
1622
+ }
1623
+ async function reachable(url, payment) {
1624
+ try {
1625
+ if (!publicHttps(url)) {
1626
+ throw new Error(`${url} is not a public https URL`);
1627
+ }
1628
+ const response = await fetch(url, {
1629
+ signal: AbortSignal.timeout(HTTP_TIMEOUT_MS2),
1630
+ headers: { accept: "application/json" }
1631
+ });
1632
+ if (response.redirected && !sameOrigin(response.url, url)) {
1633
+ throw new Error(`redirected off its own origin, to ${response.url}`);
1634
+ }
1635
+ if (!response.ok) {
1636
+ throw new Error(`answered ${response.status}`);
1637
+ }
1638
+ const body = JSON.parse(await boundedText(response));
1639
+ if (body === null || typeof body !== "object") {
1640
+ throw new Error("answered with no JSON object");
1641
+ }
1642
+ return body;
1643
+ } catch (cause) {
1644
+ throw new UnverifiedRecipientError(payment.lnAddress, payment.id, cause);
1645
+ }
1646
+ }
1647
+ async function boundedText(response) {
1648
+ const reader = response.body?.getReader();
1649
+ const decoder = new TextDecoder();
1650
+ let text3 = "";
1651
+ let bytes = 0;
1652
+ for (let chunk = await reader?.read(); chunk && !chunk.done; chunk = await reader?.read()) {
1653
+ bytes += chunk.value.length;
1654
+ if (bytes > BODY_LIMIT_BYTES) {
1655
+ await reader?.cancel();
1656
+ throw new Error(`answered with more than the ${BODY_LIMIT_BYTES} bytes a proof reads`);
1657
+ }
1658
+ text3 += decoder.decode(chunk.value, { stream: true });
1659
+ }
1660
+ return text3 + decoder.decode();
1661
+ }
1662
+ function equalIgnoringCase(one, other) {
1663
+ return one.toLowerCase() === other.toLowerCase();
1664
+ }
1665
+
1341
1666
  // src/wire.ts
1342
1667
  var ASSET_CODE = "BTC";
1343
1668
  var ASSET_SCALE = 11;
@@ -1543,11 +1868,11 @@ var CHALLENGE = "webhook-challenge";
1543
1868
  var VERIFY_CHALLENGE = "verify-challenge";
1544
1869
  async function readPayment(request, credential, options = {}) {
1545
1870
  const body = await believable(request, credential, options);
1546
- return body === null ? null : decoded(body, paymentFromWire);
1871
+ return body === null ? null : proved(decoded(body, paymentFromWire));
1547
1872
  }
1548
1873
  async function readSettlement(request, credential, options = {}) {
1549
1874
  const body = await believable(request, credential, options);
1550
- return body === null ? null : decoded(body, settlementFromWire);
1875
+ return body === null ? null : proved(decoded(body, settlementFromWire));
1551
1876
  }
1552
1877
  async function answerWebhookChallenge(request, credential, options = {}) {
1553
1878
  const body = await believable(request, credential, options);
@@ -1581,6 +1906,9 @@ async function believable(request, credential, options) {
1581
1906
  );
1582
1907
  return signed ? body : null;
1583
1908
  }
1909
+ function proved(read) {
1910
+ return read !== null && read.status === "paid" && !carriesProof(read) ? null : read;
1911
+ }
1584
1912
  function decoded(body, from) {
1585
1913
  try {
1586
1914
  return from(JSON.parse(body));
@@ -1621,30 +1949,39 @@ var DEFAULT_LOOK_BACK_SECS = 7 * 24 * 60 * 60;
1621
1949
  var DEFAULT_POLL_EVERY_SECS = 30;
1622
1950
  var IBAN = /^[A-Z]{2}[0-9]{2}[0-9A-Z]{8,30}$/;
1623
1951
  var FORBIDDEN_IN_SPD = "*";
1952
+ var MAX_REFERENCE_CHARS = 60;
1624
1953
  async function bankTransfer(gateway, params) {
1625
1954
  const currency = params.currency ?? DEFAULT_CURRENCY;
1626
- refuseUnusable(params, currency);
1955
+ const iban = accountOf(params.iban);
1956
+ refuseUnusable(params, iban, currency);
1627
1957
  await refuseAnOpenGateway(gateway, params);
1628
- const spd = shortPaymentDescriptor(params, currency);
1629
- const subject = subjectOf(params.reference, params.amountMinor, currency);
1630
- const polling = new URL(params.verifyUrl);
1631
- polling.searchParams.set("ref", params.reference);
1632
- polling.searchParams.set("minor", String(params.amountMinor));
1633
- polling.searchParams.set("cc", currency);
1634
- polling.searchParams.set("sig", await hmacHex(params.secret, `verify|${subject}`));
1635
- const verifyUrl = polling.toString();
1958
+ const spd = shortPaymentDescriptor(params, iban, currency);
1959
+ const subject = subjectOf({
1960
+ iban,
1961
+ amountMinor: params.amountMinor,
1962
+ currency,
1963
+ expiresAt: params.expiresAt,
1964
+ reference: params.reference
1965
+ });
1966
+ const verifyUrl = await answeredAt(params, subject);
1967
+ const paymentHash = hashOf(await hmacHex(params.secret, `preimage|${subject}`));
1636
1968
  const watched = await gateway.watch({
1637
- paymentHash: hashOf(await hmacHex(params.secret, `preimage|${subject}`)),
1969
+ paymentHash,
1638
1970
  verifyUrl,
1639
1971
  expiresAt: params.expiresAt,
1640
1972
  trigger: params.trigger,
1641
1973
  replay: params.replay,
1642
- sealed: params.sealed,
1974
+ sealed: params.sealed ? await seal(params.sealed.secret, JSON.stringify(params.sealed.data), paymentHash) : void 0,
1643
1975
  webhookUrl: params.webhookUrl
1644
1976
  });
1645
- return { id: watched.id, paymentHash: watched.paymentHash, verifyUrl, spd };
1977
+ return { id: watched.id, paymentHash, verifyUrl, spd };
1646
1978
  }
1647
1979
  function bankVerifyEndpoint(config) {
1980
+ refuseAWeakSecret(config.secret);
1981
+ const answersFor = accountOf(config.iban);
1982
+ if (!IBAN.test(answersFor)) {
1983
+ throw new Error(`${config.iban} is not an IBAN, so this endpoint answers for no account`);
1984
+ }
1648
1985
  const paced = {
1649
1986
  "cache-control": `max-age=${config.pollEverySecs ?? DEFAULT_POLL_EVERY_SECS}`
1650
1987
  };
@@ -1653,51 +1990,79 @@ function bankVerifyEndpoint(config) {
1653
1990
  if (consented !== null) {
1654
1991
  return consented;
1655
1992
  }
1656
- const asked = readQuery(new URL(request.url));
1657
- if (asked === null) {
1993
+ const sealed = new URL(request.url).searchParams.get("q");
1994
+ if (sealed === null) {
1658
1995
  return Response.json({ settled: false }, { status: 400 });
1659
1996
  }
1660
- const subject = subjectOf(asked.reference, asked.amountMinor, asked.currency);
1661
- const expected = await hmacHex(config.secret, `verify|${subject}`);
1662
- if (!equalInConstantTime(asked.signature, expected)) {
1997
+ const subject = await unseal(config.secret, sealed);
1998
+ const asked = subject === null ? null : askedFrom(subject);
1999
+ if (asked === null || asked.iban !== answersFor) {
1663
2000
  return Response.json({ settled: false }, { status: 403 });
1664
2001
  }
1665
- const since = unixNow() - (config.lookBackSecs ?? DEFAULT_LOOK_BACK_SECS);
1666
- const landed = (await config.statement(since)).some((credit) => pays(credit, asked));
1667
- if (!landed) {
1668
- return Response.json({ settled: false }, { headers: paced });
1669
- }
1670
- return Response.json(
1671
- { settled: true, preimage: await hmacHex(config.secret, `preimage|${subject}`) },
1672
- { headers: paced }
1673
- );
2002
+ const preimage = await creditedPreimage({
2003
+ secret: config.secret,
2004
+ asked,
2005
+ statement: config.statement,
2006
+ lookBackSecs: config.lookBackSecs
2007
+ });
2008
+ return preimage === null ? Response.json({ settled: false }, { headers: paced }) : Response.json({ settled: true, preimage }, { headers: paced });
1674
2009
  };
1675
2010
  }
1676
- function readQuery(url) {
1677
- const reference = url.searchParams.get("ref");
1678
- const minor = Number(url.searchParams.get("minor"));
1679
- const currency = url.searchParams.get("cc");
1680
- const signature = url.searchParams.get("sig");
1681
- if (!reference || !currency || !signature) {
2011
+ async function answeredAt(params, subject) {
2012
+ if (params.answerBy === "agent") {
2013
+ return `agent:${(await callerKey(params.secret)).publicKeyHex}`;
2014
+ }
2015
+ const polling = new URL(params.verifyUrl ?? "");
2016
+ polling.searchParams.set("q", await sealStable(params.secret, subject));
2017
+ return polling.toString();
2018
+ }
2019
+ async function creditedPreimage(params) {
2020
+ const since = unixNow() - (params.lookBackSecs ?? DEFAULT_LOOK_BACK_SECS);
2021
+ const landed = (await params.statement(since)).some((credit) => pays(credit, params.asked));
2022
+ if (!landed) {
1682
2023
  return null;
1683
2024
  }
1684
- if (!Number.isInteger(minor) || minor <= 0) {
2025
+ return await hmacHex(params.secret, `preimage|${subjectOf(params.asked)}`);
2026
+ }
2027
+ function askedFrom(subject) {
2028
+ const [iban, minorText, currency, expiresText, ...rest] = subject.split("|");
2029
+ const reference = rest.join("|");
2030
+ const amountMinor = Number(minorText);
2031
+ const expiresAt = Number(expiresText);
2032
+ if (!iban || !currency || reference.length === 0) {
1685
2033
  return null;
1686
2034
  }
1687
- return { reference, amountMinor: minor, currency, signature };
2035
+ if (!Number.isInteger(amountMinor) || amountMinor <= 0 || !Number.isSafeInteger(expiresAt)) {
2036
+ return null;
2037
+ }
2038
+ return { iban, reference, amountMinor, currency, expiresAt };
1688
2039
  }
1689
2040
  function pays(credit, asked) {
1690
- return credit.amountMinor === asked.amountMinor && credit.currency.toUpperCase() === asked.currency.toUpperCase() && credit.reference.toUpperCase().includes(asked.reference.toUpperCase());
2041
+ return credit.amountMinor === asked.amountMinor && credit.currency.toUpperCase() === asked.currency.toUpperCase() && namesOnly(credit.reference, asked.reference);
2042
+ }
2043
+ function namesOnly(written, reference) {
2044
+ const wanted = reference.toUpperCase();
2045
+ const alike = [...written.toUpperCase().matchAll(wordsShapedLike(wanted))].map(([word]) => word);
2046
+ return alike.includes(wanted) && (!/\d/.test(wanted) || alike.every((word) => word === wanted));
1691
2047
  }
1692
- function subjectOf(reference, amountMinor, currency) {
1693
- return `${reference}|${amountMinor}|${currency.toUpperCase()}`;
2048
+ function wordsShapedLike(reference) {
2049
+ const shape = [...reference].map((char) => {
2050
+ if (/\d/.test(char)) {
2051
+ return "\\d";
2052
+ }
2053
+ return /\p{L}/u.test(char) ? "\\p{L}" : char.replace(/[\^$\\.*+?()[\]{}|/]/g, "\\$&");
2054
+ }).join("");
2055
+ return new RegExp(`(?<![\\p{L}\\p{N}])${shape}(?![\\p{L}\\p{N}])`, "gu");
2056
+ }
2057
+ function subjectOf({ iban, amountMinor, currency, expiresAt, reference }) {
2058
+ return `${iban}|${amountMinor}|${currency.toUpperCase()}|${expiresAt}|${reference}`;
1694
2059
  }
1695
2060
  function hashOf(preimage) {
1696
2061
  return bytesToHex(sha256(hexToBytes(preimage)));
1697
2062
  }
1698
- function shortPaymentDescriptor(params, currency) {
2063
+ function shortPaymentDescriptor(params, iban, currency) {
1699
2064
  const fields = [
1700
- `ACC:${params.iban}`,
2065
+ `ACC:${iban}`,
1701
2066
  `AM:${major(params.amountMinor, currency)}`,
1702
2067
  `CC:${currency.toUpperCase()}`,
1703
2068
  `MSG:${params.reference}`
@@ -1718,19 +2083,28 @@ async function refuseAnOpenGateway(gateway, params) {
1718
2083
  return;
1719
2084
  }
1720
2085
  throw new Error(
1721
- "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"
2086
+ "this gateway serves callers with no token, so it is not yours, and its operator would learn every watch you place. Point at one that answers 401 to a stranger, or say allowPublicGateway"
1722
2087
  );
1723
2088
  }
1724
- function refuseUnusable(params, currency) {
1725
- if (!IBAN.test(params.iban)) {
2089
+ function refuseUnusable(params, iban, currency) {
2090
+ refuseAWeakSecret(params.secret);
2091
+ if (params.answerBy !== "agent" && params.verifyUrl === void 0) {
2092
+ throw new Error("a transfer the gateway polls needs the verify url it is polled at");
2093
+ }
2094
+ if (!IBAN.test(iban)) {
1726
2095
  throw new Error(`${params.iban} is not an IBAN`);
1727
2096
  }
1728
- if (!Number.isInteger(params.amountMinor) || params.amountMinor <= 0) {
2097
+ if (!Number.isSafeInteger(params.amountMinor) || params.amountMinor <= 0) {
1729
2098
  throw new Error("amountMinor must be a whole number of minor units above zero");
1730
2099
  }
1731
2100
  if (params.reference.length === 0) {
1732
2101
  throw new Error("a transfer with no reference cannot be found");
1733
2102
  }
2103
+ if (params.reference.length > MAX_REFERENCE_CHARS) {
2104
+ throw new Error(
2105
+ `a reference is at most ${MAX_REFERENCE_CHARS} characters, as many as a QR payment's message carries and its sealed query hides`
2106
+ );
2107
+ }
1734
2108
  if (params.reference.includes(FORBIDDEN_IN_SPD)) {
1735
2109
  throw new Error("a reference cannot contain an asterisk, it separates the QR fields");
1736
2110
  }
@@ -1738,6 +2112,9 @@ function refuseUnusable(params, currency) {
1738
2112
  throw new Error("a variable symbol is up to ten digits");
1739
2113
  }
1740
2114
  }
2115
+ function accountOf(iban) {
2116
+ return iban.replace(/\s+/g, "").toUpperCase();
2117
+ }
1741
2118
  function unixNow() {
1742
2119
  return Math.floor(Date.now() / 1e3);
1743
2120
  }
@@ -1801,10 +2178,10 @@ function lightningVerifyEndpoint(config) {
1801
2178
  return Response.json({ settled: false }, { status: 400 });
1802
2179
  }
1803
2180
  const opened = await unseal(config.secret, sealed);
1804
- if (opened === null) {
2181
+ const wallet = opened === null ? null : relayedFrom(opened);
2182
+ if (wallet === null) {
1805
2183
  return Response.json({ settled: false }, { status: 403 });
1806
2184
  }
1807
- const wallet = JSON.parse(opened);
1808
2185
  const asked = await checkSettled(
1809
2186
  config.send ?? pinnedToTheAddressWeVerified,
1810
2187
  wallet.url,
@@ -1819,6 +2196,14 @@ function lightningVerifyEndpoint(config) {
1819
2196
  );
1820
2197
  };
1821
2198
  }
2199
+ function relayedFrom(opened) {
2200
+ try {
2201
+ const said = JSON.parse(opened);
2202
+ return typeof said?.url === "string" && typeof said.hash === "string" ? { url: said.url, hash: said.hash } : null;
2203
+ } catch {
2204
+ return null;
2205
+ }
2206
+ }
1822
2207
  async function relayedVerifyUrl(endpoint, wallet, secret) {
1823
2208
  const relayed = new URL(endpoint);
1824
2209
  relayed.searchParams.set(WALLET, await seal(secret, JSON.stringify(wallet)));
@@ -1841,13 +2226,14 @@ function bankRail(gateway, config) {
1841
2226
  expiresAt,
1842
2227
  trigger: config.trigger,
1843
2228
  replay: config.replay,
1844
- sealed: await config.sealed?.(order),
2229
+ sealed: config.sealed && { secret: config.sealed.secret, data: config.sealed.data(order) },
1845
2230
  variableSymbol: config.variableSymbol?.(order),
1846
2231
  webhookUrl: config.webhookUrl,
1847
2232
  allowPublicGateway: config.allowPublicGateway
1848
2233
  });
1849
2234
  return {
1850
2235
  id: transfer.id,
2236
+ paymentHash: transfer.paymentHash,
1851
2237
  rail: config.name ?? BANK,
1852
2238
  scan: transfer.spd,
1853
2239
  qr: transfer.spd,
@@ -1871,6 +2257,7 @@ function lightningRail(gateway, config) {
1871
2257
  );
1872
2258
  return {
1873
2259
  id: payment.id,
2260
+ paymentHash: payment.paymentHash,
1874
2261
  rail: config.name ?? LIGHTNING,
1875
2262
  scan: payment.bolt11,
1876
2263
  qr: toLightningUri(payment.bolt11),
@@ -1896,11 +2283,16 @@ function blindLightningRail(gateway, config) {
1896
2283
  expiresAt: resolved2.expiresAt,
1897
2284
  trigger: config.trigger,
1898
2285
  replay: config.replay,
1899
- sealed: await config.sealed?.(order),
2286
+ sealed: config.sealed ? await seal(
2287
+ config.sealed.secret,
2288
+ JSON.stringify(config.sealed.data(order)),
2289
+ resolved2.paymentHash
2290
+ ) : void 0,
1900
2291
  webhookUrl: config.webhookUrl
1901
2292
  });
1902
2293
  return {
1903
2294
  id: watched.id,
2295
+ paymentHash: resolved2.paymentHash,
1904
2296
  rail: config.name ?? LIGHTNING,
1905
2297
  scan: resolved2.bolt11,
1906
2298
  qr: toLightningUri(resolved2.bolt11),
@@ -1954,130 +2346,6 @@ var Rails = class {
1954
2346
  }
1955
2347
  };
1956
2348
 
1957
- // src/verify.ts
1958
- var HTTP_TIMEOUT_MS2 = 15e3;
1959
- var DEFAULT_WRAP_PROPORTION = 0.01;
1960
- var DEFAULT_WRAP_BASE_MSAT = 1e3;
1961
- async function proveOrigin(payment, asked) {
1962
- const cheat = (code) => new GatewayCheatError(code, payment.id);
1963
- const listed = asked.paidTo.find((address) => equalIgnoringCase(address, payment.lnAddress));
1964
- if (listed === void 0) {
1965
- throw cheat("address_not_requested");
1966
- }
1967
- if (payment.amountMsat !== asked.amountMsat) {
1968
- throw cheat("amount_mismatch");
1969
- }
1970
- const invoice = decodeInvoice(payment.bolt11);
1971
- if (invoice.paymentHash === null || !equalIgnoringCase(invoice.paymentHash, payment.paymentHash)) {
1972
- throw cheat("hash_mismatch");
1973
- }
1974
- if (invoice.amountMsat !== asked.amountMsat) {
1975
- throw cheat("amount_mismatch");
1976
- }
1977
- const payRequest = await payRequestFor(listed, payment);
1978
- if (typeof payRequest.metadata !== "string" || typeof payRequest.callback !== "string") {
1979
- throw new UnverifiedRecipientError(payment.lnAddress, payment.id, "no payRequest served");
1980
- }
1981
- if (invoice.descriptionHash !== sha256Hex(payRequest.metadata)) {
1982
- throw cheat("description_hash_mismatch");
1983
- }
1984
- if (!sameOrigin(payment.verifyUrl, payRequest.callback)) {
1985
- throw cheat("verify_url_foreign");
1986
- }
1987
- const issued = await reachable(payment.verifyUrl, payment);
1988
- if (typeof issued.pr !== "string" || !equalIgnoringCase(issued.pr, payment.bolt11)) {
1989
- throw cheat("invoice_not_issued");
1990
- }
1991
- }
1992
- async function proveSettlement(payment, asked) {
1993
- await proveOrigin(payment, asked);
1994
- const verified = await reachable(payment.verifyUrl, payment);
1995
- if (verified.settled !== true || typeof verified.preimage !== "string") {
1996
- return null;
1997
- }
1998
- if (!preimageMatchesHash(verified.preimage, payment.paymentHash)) {
1999
- throw new GatewayCheatError("preimage_mismatch", payment.id);
2000
- }
2001
- return verified.preimage;
2002
- }
2003
- function carriesProof(report) {
2004
- if (report.status !== "paid" || report.preimage === null) {
2005
- return false;
2006
- }
2007
- if (report.bolt11 !== void 0 && report.bolt11 !== null) {
2008
- const invoiceHash = decodeInvoice(report.bolt11).paymentHash;
2009
- if (invoiceHash === null || !equalIgnoringCase(invoiceHash, report.paymentHash)) {
2010
- return false;
2011
- }
2012
- }
2013
- return preimageMatchesHash(report.preimage, report.paymentHash);
2014
- }
2015
- function wrapFeeCeiling(amountMsat, allowance) {
2016
- const proportional = amountMsat * (allowance?.proportion ?? DEFAULT_WRAP_PROPORTION);
2017
- return msat(Math.max(allowance?.baseMsat ?? DEFAULT_WRAP_BASE_MSAT, Math.floor(proportional)));
2018
- }
2019
- function proveWrapped(wrapped, recipient, allowance) {
2020
- const offered = decodeInvoice(wrapped);
2021
- const owed = decodeInvoice(recipient);
2022
- const refuse2 = (code, detail) => {
2023
- throw new WrapRefusedError(code, detail);
2024
- };
2025
- if (offered.paymentHash === null || offered.amountMsat === null || offered.expiresAt === null) {
2026
- refuse2("undecodable", "the wrapped invoice carries no hash, amount or expiry");
2027
- }
2028
- if (owed.paymentHash === null || owed.amountMsat === null || owed.expiresAt === null) {
2029
- refuse2("undecodable", "the recipient's invoice carries no hash, amount or expiry");
2030
- }
2031
- if (offered.paymentHash?.toLowerCase() !== owed.paymentHash?.toLowerCase()) {
2032
- refuse2("hash_mismatch", "settling the wrap would not settle the recipient");
2033
- }
2034
- const asked = offered.amountMsat ?? 0;
2035
- const due = owed.amountMsat ?? 0;
2036
- if (asked < due) {
2037
- refuse2("amount_below_recipient", `${asked} msat cannot forward ${due} msat`);
2038
- }
2039
- const ceiling = wrapFeeCeiling(due, allowance);
2040
- if (asked - due > ceiling) {
2041
- refuse2("fee_above_allowance", `${asked - due} msat over ${due}, and ${ceiling} was allowed`);
2042
- }
2043
- if ((owed.expiresAt ?? 0) <= (offered.expiresAt ?? 0)) {
2044
- refuse2("recipient_expires_first", "the operator could be paid and find it cannot deliver");
2045
- }
2046
- }
2047
- async function payRequestFor(listed, payment) {
2048
- const at = listed.indexOf("@");
2049
- const name = listed.slice(0, at);
2050
- const domain = listed.slice(at + 1).toLowerCase();
2051
- if (at < 1 || !publicHttps(`https://${domain}/`)) {
2052
- throw new UnverifiedRecipientError(payment.lnAddress, payment.id, "not a usable address");
2053
- }
2054
- return reachable(`https://${domain}/.well-known/lnurlp/${name}`, payment);
2055
- }
2056
- async function reachable(url, payment) {
2057
- try {
2058
- if (!publicHttps(url)) {
2059
- throw new Error(`${url} is not a public https URL`);
2060
- }
2061
- const response = await fetch(url, {
2062
- signal: AbortSignal.timeout(HTTP_TIMEOUT_MS2),
2063
- headers: { accept: "application/json" }
2064
- });
2065
- if (!response.ok) {
2066
- throw new Error(`answered ${response.status}`);
2067
- }
2068
- const body = await response.json();
2069
- if (body === null || typeof body !== "object") {
2070
- throw new Error("answered with no JSON object");
2071
- }
2072
- return body;
2073
- } catch (cause) {
2074
- throw new UnverifiedRecipientError(payment.lnAddress, payment.id, cause);
2075
- }
2076
- }
2077
- function equalIgnoringCase(one, other) {
2078
- return one.toLowerCase() === other.toLowerCase();
2079
- }
2080
-
2081
2349
  // src/request.ts
2082
2350
  function paymentRequestOf(gateway, payment, options) {
2083
2351
  return {
@@ -2089,13 +2357,13 @@ function paymentRequestOf(gateway, payment, options) {
2089
2357
  expiresAt: payment.expiresAt,
2090
2358
  qr: invoiceToSvg(payment.bolt11, options?.qr),
2091
2359
  payment,
2092
- paid: async (waiting) => paidOnly(await gateway.settled(payment.id, { ...waitingOf(options), ...waiting })),
2360
+ paid: async (waiting) => paidOnly(await gateway.settled(payment, { ...waitingOf(options), ...waiting })),
2093
2361
  prove: () => proveSettlement(payment, { paidTo: [payment.lnAddress], amountMsat: payment.amountMsat }),
2094
2362
  onPaid: (arrived, failed) => {
2095
2363
  const stop = new AbortController();
2096
2364
  const waiting = waitingOf(options);
2097
2365
  const signal = waiting.signal ? AbortSignal.any([stop.signal, waiting.signal]) : stop.signal;
2098
- gateway.settled(payment.id, { ...waiting, signal }).then((ended) => arrived(paidOnly(ended))).catch((reason) => {
2366
+ gateway.settled(payment, { ...waiting, signal }).then((ended) => arrived(paidOnly(ended))).catch((reason) => {
2099
2367
  if (!stop.signal.aborted) {
2100
2368
  failed?.(reason);
2101
2369
  }
@@ -2144,22 +2412,37 @@ function publicWatchTicketEndpoint(gateway, config) {
2144
2412
  }
2145
2413
  async function offer(gateway, config, url) {
2146
2414
  const { least, most } = await spread(config.amount);
2147
- const quote = await gateway.quote({ paidTo: config.paidTo, amount: msat(least) });
2415
+ const winner = await quoted(gateway, config, least);
2148
2416
  const nonce = randomNonce();
2149
2417
  const callback = new URL(config.baseUrl ?? `${url.origin}${url.pathname}`);
2150
- callback.searchParams.set("to", quote.lnAddress);
2418
+ callback.searchParams.set("to", winner.address);
2151
2419
  callback.searchParams.set("least", String(least));
2152
2420
  callback.searchParams.set("most", String(most));
2153
2421
  callback.searchParams.set("n", nonce);
2154
- callback.searchParams.set("sig", await sign(config.secret, quote.lnAddress, least, most, nonce));
2422
+ callback.searchParams.set("sig", await sign(config.secret, winner.address, least, most, nonce));
2155
2423
  return Response.json({
2156
2424
  tag: "payRequest",
2157
2425
  callback: callback.toString(),
2158
- metadata: quote.metadata,
2426
+ metadata: winner.metadata,
2159
2427
  minSendable: least,
2160
2428
  maxSendable: most
2161
2429
  });
2162
2430
  }
2431
+ async function quoted(gateway, config, amountMsat) {
2432
+ if (config.blind) {
2433
+ const { won } = await quote(
2434
+ config.send ?? pinnedToTheAddressWeVerified,
2435
+ listed(config.paidTo),
2436
+ amountMsat
2437
+ );
2438
+ return won;
2439
+ }
2440
+ const { lnAddress, metadata } = await gateway.quote({
2441
+ paidTo: config.paidTo,
2442
+ amount: msat(amountMsat)
2443
+ });
2444
+ return { address: lnAddress, metadata };
2445
+ }
2163
2446
  async function spread(amount) {
2164
2447
  if (typeof amount === "number" || typeof amount === "function") {
2165
2448
  const pinned = await amountNow(amount);
@@ -2181,6 +2464,9 @@ async function mint(gateway, config, url, address) {
2181
2464
  if (!equalInConstantTime(signature, expected)) {
2182
2465
  return refuse("this callback was not signed here");
2183
2466
  }
2467
+ if (!listed(config.paidTo).some((one) => one.toLowerCase() === address.toLowerCase())) {
2468
+ return refuse(`${address} is not paid through this trigger`);
2469
+ }
2184
2470
  const asked = url.searchParams.get("amount");
2185
2471
  const amountMsat = asked === null ? least : Number(asked);
2186
2472
  if (!Number.isSafeInteger(amountMsat) || amountMsat < least || amountMsat > most) {
@@ -2223,7 +2509,7 @@ async function mintBlind(gateway, config, address, amountMsat) {
2223
2509
  expiresAt: resolved2.expiresAt,
2224
2510
  trigger: config.watchSecret,
2225
2511
  replay: config.replay,
2226
- sealed: locked ? await seal(locked.secret, JSON.stringify(locked.data(minted))) : void 0
2512
+ sealed: locked ? await seal(locked.secret, JSON.stringify(locked.data(minted)), minted.paymentHash) : void 0
2227
2513
  });
2228
2514
  } catch (refused) {
2229
2515
  if (!alreadyWatched(refused)) {
@@ -2232,6 +2518,9 @@ async function mintBlind(gateway, config, address, amountMsat) {
2232
2518
  }
2233
2519
  return { bolt11: resolved2.bolt11, verifyUrl: resolved2.verifyUrl };
2234
2520
  }
2521
+ function listed(paidTo) {
2522
+ return typeof paidTo === "string" ? [paidTo] : paidTo;
2523
+ }
2235
2524
  function alreadyWatched(refused) {
2236
2525
  return refused instanceof ProblemError && isProblemType(refused, PAYMENT_ALREADY_WATCHED);
2237
2526
  }
@@ -2371,6 +2660,21 @@ function backoffMs(firstDelay, attempt) {
2371
2660
  const grown = Math.min(firstDelay * 2 ** (attempt - 1), RECONNECT_CAP_MS);
2372
2661
  return Math.round(grown * (0.5 + Math.random() / 2));
2373
2662
  }
2663
+ function askFromWire(raw) {
2664
+ let said;
2665
+ try {
2666
+ said = JSON.parse(raw);
2667
+ } catch {
2668
+ return null;
2669
+ }
2670
+ if (typeof said !== "object" || said === null) {
2671
+ return null;
2672
+ }
2673
+ const fields = said;
2674
+ const ask2 = fields["ask"];
2675
+ const paymentHash = fields["payment_hash"];
2676
+ return typeof ask2 === "string" && typeof paymentHash === "string" ? { ask: ask2, paymentHash } : null;
2677
+ }
2374
2678
  function gatewayAt(baseUrl) {
2375
2679
  const at = URL.canParse(baseUrl) ? new URL(baseUrl) : null;
2376
2680
  if (at === null || at.protocol !== "http:" && at.protocol !== "https:") {
@@ -2380,8 +2684,8 @@ function gatewayAt(baseUrl) {
2380
2684
  }
2381
2685
  var ThunderBridge = class {
2382
2686
  baseUrl;
2383
- verify;
2384
2687
  token;
2688
+ handedOut = /* @__PURE__ */ new Map();
2385
2689
  secret;
2386
2690
  strangers = null;
2387
2691
  speaks = null;
@@ -2392,7 +2696,6 @@ var ThunderBridge = class {
2392
2696
  rails;
2393
2697
  constructor(baseUrl, options) {
2394
2698
  this.baseUrl = gatewayAt(baseUrl);
2395
- this.verify = options?.verify ?? true;
2396
2699
  this.token = options?.token ?? null;
2397
2700
  this.secret = options?.secret ?? null;
2398
2701
  this.serve = new Serve(this);
@@ -2434,6 +2737,7 @@ var ThunderBridge = class {
2434
2737
  * and `GatewayCheatError` when what comes back is not what you asked for
2435
2738
  */
2436
2739
  async mint(charge, options) {
2740
+ const askedAt = Math.floor(Date.now() / 1e3);
2437
2741
  const asked = await priced(charge);
2438
2742
  const sent = createRequestBody(
2439
2743
  asked,
@@ -2454,9 +2758,8 @@ var ThunderBridge = class {
2454
2758
  throw await problemFrom(response);
2455
2759
  }
2456
2760
  const payment = await mintedFrom(response);
2457
- if (this.verify) {
2458
- await proveOrigin(payment, asked);
2459
- }
2761
+ await proveOrigin(payment, asked, askedAt);
2762
+ this.handOut(payment, options?.idempotencyKey ?? null);
2460
2763
  return payment;
2461
2764
  }
2462
2765
  /**
@@ -2467,7 +2770,8 @@ var ThunderBridge = class {
2467
2770
  * it for one, and asking mints it
2468
2771
  */
2469
2772
  async quote(charge) {
2470
- const sent = quoteRequestBody(await priced(charge));
2773
+ const asked = await priced(charge);
2774
+ const sent = quoteRequestBody(asked);
2471
2775
  const response = await fetch(`${this.baseUrl}/quotes`, {
2472
2776
  method: "POST",
2473
2777
  headers: await this.sending("/quotes", sent),
@@ -2476,14 +2780,17 @@ var ThunderBridge = class {
2476
2780
  if (!response.ok) {
2477
2781
  throw await problemFrom(response);
2478
2782
  }
2479
- const quote = quoteFromWire(await response.json().catch(() => null));
2480
- if (quote === null) {
2783
+ const quote2 = quoteFromWire(await response.json().catch(() => null));
2784
+ if (quote2 === null) {
2481
2785
  throw new ProblemError({
2482
2786
  status: response.status,
2483
2787
  title: "The gateway answered with something that is not a quote"
2484
2788
  });
2485
2789
  }
2486
- return quote;
2790
+ if (!asked.paidTo.some((address) => address.toLowerCase() === quote2.lnAddress.toLowerCase())) {
2791
+ throw new GatewayCheatError("address_not_requested", "");
2792
+ }
2793
+ return quote2;
2487
2794
  }
2488
2795
  /**
2489
2796
  * The key this gateway signs webhooks with when you registered none of your own.
@@ -2516,8 +2823,8 @@ var ThunderBridge = class {
2516
2823
  * for both sorts: `kind` says whether the gateway minted it or was handed it,
2517
2824
  * and the address, amount and invoice are null on one it was never told
2518
2825
  */
2519
- async payment(id) {
2520
- const path = `/incoming-payments/${encodeURIComponent(id)}`;
2826
+ async payment(held) {
2827
+ const path = `/incoming-payments/${encodeURIComponent(held.id)}`;
2521
2828
  const response = await fetch(`${this.baseUrl}${path}`, {
2522
2829
  headers: await this.reading("GET", path)
2523
2830
  });
@@ -2527,7 +2834,7 @@ var ThunderBridge = class {
2527
2834
  if (!response.ok) {
2528
2835
  throw await problemFrom(response);
2529
2836
  }
2530
- return this.proven(await paymentFrom(response));
2837
+ return this.proven(await paymentFrom(response), held);
2531
2838
  }
2532
2839
  /**
2533
2840
  * List what this gateway is watching, newest first. Only a gateway started
@@ -2547,14 +2854,14 @@ var ThunderBridge = class {
2547
2854
  throw await problemFrom(response);
2548
2855
  }
2549
2856
  const body = await response.json().catch(() => null);
2550
- const listed = Array.isArray(body?.payments) ? body.payments.map(paymentFromWire).filter((one) => one !== null) : null;
2551
- if (listed === null || typeof body?.settled_scanned !== "number") {
2857
+ const listed2 = Array.isArray(body?.payments) ? body.payments.map(paymentFromWire).filter((one) => one !== null).filter((one) => one.status !== "paid" || carriesProof(one)) : null;
2858
+ if (listed2 === null || typeof body?.settled_scanned !== "number") {
2552
2859
  throw new ProblemError({
2553
2860
  status: response.status,
2554
2861
  title: "The gateway answered with something that is not a payment list"
2555
2862
  });
2556
2863
  }
2557
- return { payments: listed, scanned: body.settled_scanned };
2864
+ return { payments: listed2, scanned: body.settled_scanned };
2558
2865
  }
2559
2866
  /**
2560
2867
  * Follow a payment over WebSocket until it is paid or expired, reconnecting
@@ -2562,15 +2869,15 @@ var ThunderBridge = class {
2562
2869
  * one that has answered is followed until its own expiry, so the wait always
2563
2870
  * ends by itself
2564
2871
  */
2565
- async settled(id, options) {
2566
- const ended = paymentFromWire(await this.followed(id, options));
2872
+ async settled(held, options) {
2873
+ const ended = paymentFromWire(await this.followed(held.id, options));
2567
2874
  if (ended === null) {
2568
2875
  throw new ProblemError({
2569
2876
  status: 200,
2570
2877
  title: "The gateway answered with something that is not a payment"
2571
2878
  });
2572
2879
  }
2573
- return this.proven(ended);
2880
+ return this.proven(ended, held);
2574
2881
  }
2575
2882
  followed(id, options) {
2576
2883
  const base = this.baseUrl.replace(/^http/, "ws");
@@ -2675,8 +2982,8 @@ var ThunderBridge = class {
2675
2982
  * really does pay twice and that shows up on `follow` as a second settlement to
2676
2983
  * refund.
2677
2984
  */
2678
- async firstSettled(ids, options) {
2679
- if (ids.length === 0) {
2985
+ async firstSettled(held, options) {
2986
+ if (held.length === 0) {
2680
2987
  return null;
2681
2988
  }
2682
2989
  const stopLosers = new AbortController();
@@ -2684,15 +2991,15 @@ var ThunderBridge = class {
2684
2991
  let refused = null;
2685
2992
  try {
2686
2993
  const winner = await new Promise((resolve2) => {
2687
- let waiting = ids.length;
2994
+ let waiting = held.length;
2688
2995
  const lost = () => {
2689
2996
  waiting -= 1;
2690
2997
  if (waiting === 0) {
2691
2998
  resolve2(null);
2692
2999
  }
2693
3000
  };
2694
- for (const id of ids) {
2695
- this.settled(id, { ...options, signal }).then((watched) => watched.status === "paid" ? resolve2(watched) : lost()).catch((failure) => {
3001
+ for (const leg of held) {
3002
+ this.settled(leg, { ...options, signal }).then((watched) => watched.status === "paid" ? resolve2(watched) : lost()).catch((failure) => {
2696
3003
  refused ??= failure;
2697
3004
  lost();
2698
3005
  });
@@ -2736,6 +3043,9 @@ var ThunderBridge = class {
2736
3043
  if (named !== null && watched.id !== named) {
2737
3044
  throw new GatewayCheatError("id_not_mine", watched.id);
2738
3045
  }
3046
+ if (watched.paymentHash.toLowerCase() !== handover.paymentHash.toLowerCase()) {
3047
+ throw new GatewayCheatError("hash_mismatch", watched.id);
3048
+ }
2739
3049
  return watched;
2740
3050
  }
2741
3051
  /**
@@ -2748,6 +3058,69 @@ var ThunderBridge = class {
2748
3058
  async nameFor(paymentHash) {
2749
3059
  return this.secret === null ? null : paymentNamedBy((await this.speaking()).publicKeyHex, paymentHash);
2750
3060
  }
3061
+ /**
3062
+ * Hold a socket open and answer what the gateway asks about this caller's own
3063
+ * payments, so a watch addressed to this caller settles without anybody
3064
+ * hosting a URL. Reconnects on its own until the returned function is called
3065
+ */
3066
+ attend(options) {
3067
+ const base = this.baseUrl.replace(/^http/, "ws");
3068
+ const firstDelay = options.reconnectDelayMs ?? RECONNECT_DELAY_MS;
3069
+ let socket = null;
3070
+ let retry;
3071
+ let stopped = false;
3072
+ let attempt = 0;
3073
+ const again = () => {
3074
+ if (stopped || options.reconnect === false) {
3075
+ return;
3076
+ }
3077
+ retry = setTimeout(() => void connect(), backoffMs(firstDelay, attempt));
3078
+ };
3079
+ const answered3 = async (opened, raw) => {
3080
+ const asked = askFromWire(raw);
3081
+ if (asked === null) {
3082
+ return;
3083
+ }
3084
+ const preimage = await options.answer(asked.paymentHash);
3085
+ opened.send(
3086
+ JSON.stringify(
3087
+ preimage === null ? { ask: asked.ask, settled: false } : { ask: asked.ask, settled: true, preimage }
3088
+ )
3089
+ );
3090
+ };
3091
+ const connect = async () => {
3092
+ attempt += 1;
3093
+ let url;
3094
+ try {
3095
+ url = `${base}/ws/tickets/${await this.wsTicket({ agent: true })}`;
3096
+ } catch (refused) {
3097
+ options.onError?.(refused);
3098
+ again();
3099
+ return;
3100
+ }
3101
+ if (stopped) {
3102
+ return;
3103
+ }
3104
+ const opened = new WebSocket(url);
3105
+ socket = opened;
3106
+ opened.onopen = () => {
3107
+ attempt = 1;
3108
+ };
3109
+ opened.onmessage = (event) => {
3110
+ void answered3(opened, String(event.data)).catch(
3111
+ (refused) => options.onError?.(refused)
3112
+ );
3113
+ };
3114
+ opened.onerror = () => options.onError?.(new Error(`could not attend ${base}`));
3115
+ opened.onclose = again;
3116
+ };
3117
+ void connect();
3118
+ return () => {
3119
+ stopped = true;
3120
+ clearTimeout(retry);
3121
+ socket?.close();
3122
+ };
3123
+ }
2751
3124
  /**
2752
3125
  * Follow every payment made to one trigger, replayed from the recent ones on
2753
3126
  * connect and then live, reconnecting on its own until the returned function
@@ -2824,7 +3197,7 @@ var ThunderBridge = class {
2824
3197
  });
2825
3198
  }
2826
3199
  needsTicket(asked) {
2827
- return asked === true || this.token !== null;
3200
+ return asked === true || this.token !== null || this.secret !== null;
2828
3201
  }
2829
3202
  async wsTicket(body) {
2830
3203
  return encodeURIComponent((await this.mintedTicket(body)).ticket);
@@ -2861,8 +3234,26 @@ var ThunderBridge = class {
2861
3234
  this.speaks ??= callerKey(this.secret ?? "");
2862
3235
  return await this.speaks;
2863
3236
  }
2864
- proven(payment) {
2865
- if (this.verify && payment.status === "paid" && !carriesProof(payment)) {
3237
+ handOut(payment, key) {
3238
+ const now = Math.floor(Date.now() / 1e3);
3239
+ for (const [hash2, earlier2] of this.handedOut) {
3240
+ if (earlier2.expiresAt <= now) {
3241
+ this.handedOut.delete(hash2);
3242
+ }
3243
+ }
3244
+ const hash = payment.paymentHash.toLowerCase();
3245
+ const earlier = this.handedOut.get(hash);
3246
+ if (earlier !== void 0 && (earlier.key === null || earlier.key !== key)) {
3247
+ throw new GatewayCheatError("invoice_reused", payment.id);
3248
+ }
3249
+ this.handedOut.set(hash, { key, expiresAt: payment.expiresAt });
3250
+ }
3251
+ proven(payment, held) {
3252
+ const other = held !== void 0 && (payment.id !== held.id || payment.paymentHash.toLowerCase() !== held.paymentHash.toLowerCase());
3253
+ if (other) {
3254
+ throw new GatewayCheatError("hash_mismatch", payment.id);
3255
+ }
3256
+ if (payment.status === "paid" && !carriesProof(payment)) {
2866
3257
  throw new GatewayCheatError("preimage_mismatch", payment.id);
2867
3258
  }
2868
3259
  return payment;
@@ -2957,7 +3348,7 @@ var Gateways = class {
2957
3348
  * saying so. When they all end without a payment, the first ending is the answer,
2958
3349
  * and when they all fail, the first failure is thrown
2959
3350
  */
2960
- async settled(id, options) {
3351
+ async settled(held, options) {
2961
3352
  const stopLosers = new AbortController();
2962
3353
  const signal = options?.signal ? AbortSignal.any([stopLosers.signal, options.signal]) : stopLosers.signal;
2963
3354
  const unpaid = [];
@@ -2972,7 +3363,7 @@ var Gateways = class {
2972
3363
  }
2973
3364
  };
2974
3365
  for (const gateway of this.each) {
2975
- gateway.settled(id, { ...options, signal }).then((watched) => {
3366
+ gateway.settled(held, { ...options, signal }).then((watched) => {
2976
3367
  if (watched.status === "paid") {
2977
3368
  return resolve2(watched);
2978
3369
  }