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.js CHANGED
@@ -175,6 +175,7 @@ function rotate(value, by) {
175
175
 
176
176
  // ../core/bolt11.ts
177
177
  var BECH32_CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l";
178
+ var BECH32_GENERATOR = [996825010, 642813549, 513874426, 1027748829, 705979059];
178
179
  var CHECKSUM_WORDS = 6;
179
180
  var TIMESTAMP_WORDS = 7;
180
181
  var SIGNATURE_WORDS = 104;
@@ -192,18 +193,27 @@ var HRP_MULTIPLIER_MSAT = {
192
193
  n: MSAT_PER_BTC / 1e9,
193
194
  p: MSAT_PER_BTC / 1e12
194
195
  };
195
- var HRP_AMOUNT = /^ln[a-z]+?(\d+)([munp]?)$/;
196
+ var HRP = /^ln(?:bcrt|bc|tbs|tb|sb)(?:(\d+)([munp]?))?$/;
196
197
  function decodeInvoice(bolt11) {
197
198
  const parts = splitBech32(bolt11);
198
199
  if (parts === null) {
199
- return { paymentHash: null, descriptionHash: null, amountMsat: null, expiresAt: null };
200
+ return {
201
+ paymentHash: null,
202
+ descriptionHash: null,
203
+ amountMsat: null,
204
+ issuedAt: null,
205
+ expiresAt: null
206
+ };
200
207
  }
201
208
  const tagged = taggedFields(parts.words);
209
+ const issuedAt = issuedAtOf(parts.words);
210
+ const expiry = tagged.get(EXPIRY_TAG);
202
211
  return {
203
212
  paymentHash: hexTag(tagged, PAYMENT_HASH_TAG, PAYMENT_HASH_WORDS),
204
213
  descriptionHash: hexTag(tagged, DESCRIPTION_HASH_TAG, DESCRIPTION_HASH_WORDS),
205
214
  amountMsat: amountFromHrp(parts.hrp),
206
- expiresAt: expiryOf(parts.words, tagged)
215
+ issuedAt,
216
+ expiresAt: issuedAt === null ? null : issuedAt + (expiry ? readNumber(expiry) : DEFAULT_EXPIRY_SECS)
207
217
  };
208
218
  }
209
219
  function preimageMatchesHash(preimage, paymentHash) {
@@ -214,7 +224,7 @@ function preimageMatchesHash(preimage, paymentHash) {
214
224
  }
215
225
  function splitBech32(bolt11) {
216
226
  const lower = bolt11.toLowerCase();
217
- if (isBolt12Encoding(lower)) {
227
+ if (isBolt12Encoding(lower) || bolt11 !== lower && bolt11 !== bolt11.toUpperCase()) {
218
228
  return null;
219
229
  }
220
230
  const separator = lower.lastIndexOf("1");
@@ -233,7 +243,28 @@ function splitBech32(bolt11) {
233
243
  }
234
244
  words.push(value);
235
245
  }
236
- return { hrp: lower.slice(0, separator), words: words.slice(0, -CHECKSUM_WORDS) };
246
+ const hrp = lower.slice(0, separator);
247
+ if (!HRP.test(hrp) || bech32Polymod([...expandedHrp(hrp), ...words]) !== 1) {
248
+ return null;
249
+ }
250
+ return { hrp, words: words.slice(0, -CHECKSUM_WORDS) };
251
+ }
252
+ function expandedHrp(hrp) {
253
+ const codes = [...hrp].map((char) => char.charCodeAt(0));
254
+ return [...codes.map((code) => code >> 5), 0, ...codes.map((code) => code & 31)];
255
+ }
256
+ function bech32Polymod(values) {
257
+ let checksum = 1;
258
+ for (const value of values) {
259
+ const top = checksum >> 25;
260
+ checksum = (checksum & 33554431) << 5 ^ value;
261
+ for (let bit = 0; bit < 5; bit++) {
262
+ if (top >> bit & 1) {
263
+ checksum ^= BECH32_GENERATOR[bit];
264
+ }
265
+ }
266
+ }
267
+ return checksum;
237
268
  }
238
269
  function isBolt12Encoding(lower) {
239
270
  return lower.startsWith("lno") || lower.startsWith("lni") || lower.startsWith("lnr");
@@ -263,19 +294,17 @@ function hexTag(fields, tag, expectedWords) {
263
294
  }
264
295
  return bytesToHex(wordsToBytes(words));
265
296
  }
266
- function expiryOf(words, fields) {
297
+ function issuedAtOf(words) {
267
298
  if (words.length < TIMESTAMP_WORDS + SIGNATURE_WORDS) {
268
299
  return null;
269
300
  }
270
- const expiry = fields.get(EXPIRY_TAG);
271
- return readNumber(words.slice(0, TIMESTAMP_WORDS)) + (expiry ? readNumber(expiry) : DEFAULT_EXPIRY_SECS);
301
+ return readNumber(words.slice(0, TIMESTAMP_WORDS));
272
302
  }
273
303
  function amountFromHrp(hrp) {
274
- const match = HRP_AMOUNT.exec(hrp);
275
- if (match === null) {
304
+ const [, digits, multiplier] = HRP.exec(hrp) ?? [];
305
+ if (digits === void 0 || multiplier === "p" && !digits.endsWith("0")) {
276
306
  return null;
277
307
  }
278
- const [, digits, multiplier] = match;
279
308
  return multiplier ? Number(digits) * HRP_MULTIPLIER_MSAT[multiplier] : Number(digits) * MSAT_PER_BTC;
280
309
  }
281
310
  function readNumber(words) {
@@ -296,28 +325,74 @@ function wordsToBytes(words) {
296
325
  return Uint8Array.from(bytes);
297
326
  }
298
327
 
328
+ // ../core/hmac.ts
329
+ async function hmacHex(secret, payload) {
330
+ const raw = typeof secret === "string" ? new TextEncoder().encode(secret) : secret;
331
+ const key = await crypto.subtle.importKey(
332
+ "raw",
333
+ asBuffer(raw),
334
+ { name: "HMAC", hash: "SHA-256" },
335
+ false,
336
+ ["sign"]
337
+ );
338
+ const message = typeof payload === "string" ? new TextEncoder().encode(payload) : payload;
339
+ return bytesToHex(new Uint8Array(await crypto.subtle.sign("HMAC", key, asBuffer(message))));
340
+ }
341
+ function equalInConstantTime(one, other) {
342
+ if (one.length !== other.length) {
343
+ return false;
344
+ }
345
+ let difference = 0;
346
+ for (let at = 0; at < one.length; at++) {
347
+ difference |= one.charCodeAt(at) ^ other.charCodeAt(at);
348
+ }
349
+ return difference === 0;
350
+ }
351
+ function asBuffer(bytes) {
352
+ return bytes instanceof Uint8Array && bytes.buffer instanceof ArrayBuffer ? bytes : new Uint8Array(bytes);
353
+ }
354
+
299
355
  // ../core/sealed.ts
300
- var VERSION = "v1";
356
+ var VERSION = "v2";
357
+ var UNPADDED_VERSION = "v1";
301
358
  var IV_BYTES = 12;
302
359
  var MIN_SECRET_CHARS = 32;
303
360
  var MAX_PLAIN_BYTES = 3e3;
361
+ var SIZE_CLASSES = [256, 1024, MAX_PLAIN_BYTES];
362
+ var END_OF_TEXT = 128;
304
363
  var INFO = new TextEncoder().encode("thunder-bridge/sealed");
305
- async function seal(secret, plaintext) {
306
- const body = new TextEncoder().encode(plaintext);
307
- if (body.length > MAX_PLAIN_BYTES) {
308
- throw new Error(`sealed takes at most ${MAX_PLAIN_BYTES} bytes, this was ${body.length}`);
364
+ async function seal(secret, plaintext, paymentHash) {
365
+ return await sealUnder(
366
+ secret,
367
+ plaintext,
368
+ crypto.getRandomValues(new Uint8Array(IV_BYTES)),
369
+ boundTo(paymentHash)
370
+ );
371
+ }
372
+ async function sealStable(secret, plaintext) {
373
+ const derived = await hmacHex(secret, `sealed-nonce|${plaintext}`);
374
+ const nonce = new Uint8Array(IV_BYTES);
375
+ for (let at = 0; at < IV_BYTES; at++) {
376
+ nonce[at] = Number.parseInt(derived.slice(at * 2, at * 2 + 2), 16);
309
377
  }
378
+ return await sealUnder(secret, plaintext, nonce, boundTo(void 0));
379
+ }
380
+ async function sealUnder(secret, plaintext, iv, additionalData) {
381
+ const body = padToClass(plaintext);
310
382
  const key = await keyFor(secret);
311
- const iv = crypto.getRandomValues(new Uint8Array(IV_BYTES));
312
- const cipher = new Uint8Array(await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, body));
383
+ const cipher = new Uint8Array(
384
+ await crypto.subtle.encrypt({ name: "AES-GCM", iv, additionalData }, key, body)
385
+ );
313
386
  const joined = new Uint8Array(iv.length + cipher.length);
314
387
  joined.set(iv);
315
388
  joined.set(cipher, iv.length);
316
389
  return `${VERSION}.${toBase64Url(joined)}`;
317
390
  }
318
- async function unseal(secret, sealed) {
391
+ async function unseal(secret, sealed, paymentHash) {
319
392
  const key = await keyFor(secret);
320
- if (!sealed.startsWith(`${VERSION}.`)) {
393
+ const padded = sealed.startsWith(`${VERSION}.`);
394
+ const unbound = !padded && sealed.startsWith(`${UNPADDED_VERSION}.`);
395
+ if (!padded && (!unbound || paymentHash !== void 0)) {
321
396
  return null;
322
397
  }
323
398
  const joined = fromBase64Url(sealed.slice(VERSION.length + 1));
@@ -325,20 +400,50 @@ async function unseal(secret, sealed) {
325
400
  return null;
326
401
  }
327
402
  try {
328
- const body = await crypto.subtle.decrypt(
329
- { name: "AES-GCM", iv: joined.slice(0, IV_BYTES) },
330
- key,
331
- joined.slice(IV_BYTES)
403
+ const body = new Uint8Array(
404
+ await crypto.subtle.decrypt(
405
+ {
406
+ name: "AES-GCM",
407
+ iv: joined.slice(0, IV_BYTES),
408
+ additionalData: padded ? boundTo(paymentHash) : void 0
409
+ },
410
+ key,
411
+ joined.slice(IV_BYTES)
412
+ )
332
413
  );
333
- return new TextDecoder().decode(body);
414
+ const text3 = padded ? unpad(body) : body;
415
+ return text3 === null ? null : new TextDecoder().decode(text3);
334
416
  } catch {
335
417
  return null;
336
418
  }
337
419
  }
338
- async function keyFor(secret) {
420
+ function refuseAWeakSecret(secret) {
339
421
  if (secret.length < MIN_SECRET_CHARS) {
340
422
  throw new Error(`the sealing secret needs ${MIN_SECRET_CHARS} characters of randomness`);
341
423
  }
424
+ }
425
+ function boundTo(paymentHash) {
426
+ return new TextEncoder().encode(
427
+ paymentHash === void 0 ? VERSION : `${VERSION}|${paymentHash.toLowerCase()}`
428
+ );
429
+ }
430
+ function padToClass(plaintext) {
431
+ const text3 = new TextEncoder().encode(plaintext);
432
+ const size = SIZE_CLASSES.find((limit) => text3.length <= limit);
433
+ if (size === void 0) {
434
+ throw new Error(`sealed takes at most ${MAX_PLAIN_BYTES} bytes, this was ${text3.length}`);
435
+ }
436
+ const body = new Uint8Array(size + 1);
437
+ body.set(text3);
438
+ body[text3.length] = END_OF_TEXT;
439
+ return body;
440
+ }
441
+ function unpad(body) {
442
+ const end = body.lastIndexOf(END_OF_TEXT);
443
+ return end === -1 ? null : body.subarray(0, end);
444
+ }
445
+ async function keyFor(secret) {
446
+ refuseAWeakSecret(secret);
342
447
  const material = await crypto.subtle.importKey(
343
448
  "raw",
344
449
  new TextEncoder().encode(secret),
@@ -535,11 +640,11 @@ function coinbase(baseUrl = "https://api.coinbase.com") {
535
640
  return async (currency) => {
536
641
  const asked = currency.toUpperCase();
537
642
  const answer = await asJson(`${baseUrl}/v2/prices/BTC-${asked}/spot`, "coinbase");
538
- const quoted = answer.data;
539
- if (quoted?.currency !== asked) {
540
- throw new Error(`coinbase quoted ${quoted?.currency} when asked for ${asked}`);
643
+ const quoted2 = answer.data;
644
+ if (quoted2?.currency !== asked) {
645
+ throw new Error(`coinbase quoted ${quoted2?.currency} when asked for ${asked}`);
541
646
  }
542
- return asMinor(quoted.amount, "coinbase", asked);
647
+ return asMinor(quoted2.amount, "coinbase", asked);
543
648
  };
544
649
  }
545
650
  function kraken(baseUrl = "https://api.kraken.com") {
@@ -588,19 +693,19 @@ function medianOf(tickers = [coinbase(), kraken(), bitstamp(), coinmate()], opti
588
693
  return remembered.price;
589
694
  }
590
695
  const answers = await Promise.allSettled(tickers.map((ticker) => ticker(asked)));
591
- const quoted = answers.filter((answer) => answer.status === "fulfilled").map((answer) => answer.value).sort((one, other) => one - other);
592
- if (quoted.length < minVenues) {
696
+ const quoted2 = answers.filter((answer) => answer.status === "fulfilled").map((answer) => answer.value).sort((one, other) => one - other);
697
+ if (quoted2.length < minVenues) {
593
698
  throw new Error(
594
- `${quoted.length} of ${tickers.length} venues quoted ${asked}, ${minVenues} wanted: ${refusals(answers)}`
699
+ `${quoted2.length} of ${tickers.length} venues quoted ${asked}, ${minVenues} wanted: ${refusals(answers)}`
595
700
  );
596
701
  }
597
- const spread2 = spreadBpsOf(quoted);
702
+ const spread2 = spreadBpsOf(quoted2);
598
703
  if (spread2 > maxSpread) {
599
704
  throw new Error(
600
- `venues disagree on ${asked} by ${Math.round(spread2)} bps, more than the ${maxSpread} allowed: ${quoted.join(", ")}`
705
+ `venues disagree on ${asked} by ${Math.round(spread2)} bps, more than the ${maxSpread} allowed: ${quoted2.join(", ")}`
601
706
  );
602
707
  }
603
- const price = middleOf(quoted);
708
+ const price = middleOf(quoted2);
604
709
  held.set(asked, { at: Date.now(), price });
605
710
  return price;
606
711
  };
@@ -631,10 +736,10 @@ async function asJson(url, venue) {
631
736
  }
632
737
  return answer.json();
633
738
  }
634
- function asMinor(quoted, venue, currency) {
635
- const major2 = Number(quoted);
739
+ function asMinor(quoted2, venue, currency) {
740
+ const major2 = Number(quoted2);
636
741
  if (!Number.isFinite(major2) || major2 <= 0) {
637
- throw new Error(`${venue} quoted ${String(quoted)} for ${currency.toUpperCase()}`);
742
+ throw new Error(`${venue} quoted ${String(quoted2)} for ${currency.toUpperCase()}`);
638
743
  }
639
744
  return Math.round(major2 * minorScaleOf(currency));
640
745
  }
@@ -722,6 +827,16 @@ var SEED_BYTES = 32;
722
827
  var SIGNATURE_BYTES = 64;
723
828
  var PUBLIC_KEY_BYTES = 32;
724
829
  var PKCS8_HEADER = hexToBytes("302e020100300506032b657004220420");
830
+ var SIGN_BIT_CLEARED = 127;
831
+ var SMALL_ORDER = [
832
+ "00".repeat(32),
833
+ `01${"00".repeat(31)}`,
834
+ "26e8958fc2b227b045c3f489f2ef98f0d5dfac05d3c63339b13802886d53fc05",
835
+ "c7176a703d4dd84fba3c0b760d10670f2a2053fa2c39ccc64ec7fd7792ac037a",
836
+ `ec${"ff".repeat(30)}7f`,
837
+ `ed${"ff".repeat(30)}7f`,
838
+ `ee${"ff".repeat(30)}7f`
839
+ ];
725
840
  async function signingKeyFromSeed(seed) {
726
841
  if (seed.length !== SEED_BYTES) {
727
842
  throw new Error(`an ed25519 seed is ${SEED_BYTES} bytes`);
@@ -729,24 +844,27 @@ async function signingKeyFromSeed(seed) {
729
844
  const wrapped = new Uint8Array(PKCS8_HEADER.length + SEED_BYTES);
730
845
  wrapped.set(PKCS8_HEADER);
731
846
  wrapped.set(seed, PKCS8_HEADER.length);
732
- const key = await crypto.subtle.importKey("pkcs8", asBuffer(wrapped), ALGORITHM, true, ["sign"]);
847
+ const key = await crypto.subtle.importKey("pkcs8", asBuffer2(wrapped), ALGORITHM, true, ["sign"]);
733
848
  const jwk = await crypto.subtle.exportKey("jwk", key);
734
849
  return {
735
850
  publicKeyHex: bytesToHex(fromBase64Url2(jwk.x ?? "")),
736
- sign: async (payload) => bytesToHex(new Uint8Array(await crypto.subtle.sign(ALGORITHM, key, asBuffer(payload))))
851
+ sign: async (payload) => bytesToHex(new Uint8Array(await crypto.subtle.sign(ALGORITHM, key, asBuffer2(payload))))
737
852
  };
738
853
  }
739
854
  async function verifyHex(publicKeyHex, signatureHex, payload) {
740
855
  if (!isHex(publicKeyHex) || publicKeyHex.length !== PUBLIC_KEY_BYTES * 2) {
741
856
  return false;
742
857
  }
858
+ if (hasSmallOrder(publicKeyHex)) {
859
+ return false;
860
+ }
743
861
  if (!isHex(signatureHex) || signatureHex.length !== SIGNATURE_BYTES * 2) {
744
862
  return false;
745
863
  }
746
864
  try {
747
865
  const key = await crypto.subtle.importKey(
748
866
  "raw",
749
- asBuffer(hexToBytes(publicKeyHex)),
867
+ asBuffer2(hexToBytes(publicKeyHex)),
750
868
  ALGORITHM,
751
869
  false,
752
870
  ["verify"]
@@ -754,13 +872,18 @@ async function verifyHex(publicKeyHex, signatureHex, payload) {
754
872
  return await crypto.subtle.verify(
755
873
  ALGORITHM,
756
874
  key,
757
- asBuffer(hexToBytes(signatureHex)),
758
- asBuffer(payload)
875
+ asBuffer2(hexToBytes(signatureHex)),
876
+ asBuffer2(payload)
759
877
  );
760
878
  } catch {
761
879
  return false;
762
880
  }
763
881
  }
882
+ function hasSmallOrder(publicKeyHex) {
883
+ const point = hexToBytes(publicKeyHex);
884
+ point[PUBLIC_KEY_BYTES - 1] = point[PUBLIC_KEY_BYTES - 1] & SIGN_BIT_CLEARED;
885
+ return SMALL_ORDER.includes(bytesToHex(point));
886
+ }
764
887
  function fromBase64Url2(text3) {
765
888
  const padded = text3.replace(/-/g, "+").replace(/_/g, "/");
766
889
  const raw = atob(padded + "=".repeat((4 - padded.length % 4) % 4));
@@ -770,35 +893,8 @@ function fromBase64Url2(text3) {
770
893
  }
771
894
  return bytes;
772
895
  }
773
- function asBuffer(bytes) {
774
- return bytes.buffer instanceof ArrayBuffer ? bytes : new Uint8Array(bytes);
775
- }
776
-
777
- // ../core/hmac.ts
778
- async function hmacHex(secret, payload) {
779
- const raw = typeof secret === "string" ? new TextEncoder().encode(secret) : secret;
780
- const key = await crypto.subtle.importKey(
781
- "raw",
782
- asBuffer2(raw),
783
- { name: "HMAC", hash: "SHA-256" },
784
- false,
785
- ["sign"]
786
- );
787
- const message = typeof payload === "string" ? new TextEncoder().encode(payload) : payload;
788
- return bytesToHex(new Uint8Array(await crypto.subtle.sign("HMAC", key, asBuffer2(message))));
789
- }
790
- function equalInConstantTime(one, other) {
791
- if (one.length !== other.length) {
792
- return false;
793
- }
794
- let difference = 0;
795
- for (let at = 0; at < one.length; at++) {
796
- difference |= one.charCodeAt(at) ^ other.charCodeAt(at);
797
- }
798
- return difference === 0;
799
- }
800
896
  function asBuffer2(bytes) {
801
- return bytes instanceof Uint8Array && bytes.buffer instanceof ArrayBuffer ? bytes : new Uint8Array(bytes);
897
+ return bytes.buffer instanceof ArrayBuffer ? bytes : new Uint8Array(bytes);
802
898
  }
803
899
 
804
900
  // ../core/caller.ts
@@ -876,8 +972,8 @@ function ipv4IsGlobal(literal) {
876
972
  if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) {
877
973
  return false;
878
974
  }
879
- const [a, b] = parts;
880
- if (a === 0 || a === 10 || a === 127 || a >= 240) {
975
+ const [a, b, c] = parts;
976
+ if (a === 0 || a === 10 || a === 127 || a >= 224) {
881
977
  return false;
882
978
  }
883
979
  if (a === 100 && b >= 64 && b < 128) {
@@ -889,7 +985,13 @@ function ipv4IsGlobal(literal) {
889
985
  if (a === 172 && b >= 16 && b < 32) {
890
986
  return false;
891
987
  }
892
- if (a === 192 && b === 168) {
988
+ if (a === 192 && (b === 168 || b === 0 && (c === 0 || c === 2))) {
989
+ return false;
990
+ }
991
+ if (a === 198 && (b === 18 || b === 19 || b === 51 && c === 100)) {
992
+ return false;
993
+ }
994
+ if (a === 203 && b === 0 && c === 113) {
893
995
  return false;
894
996
  }
895
997
  return true;
@@ -899,29 +1001,59 @@ function ipv6IsGlobal(literal) {
899
1001
  if (groups.length !== 8 || groups.some(Number.isNaN)) {
900
1002
  return false;
901
1003
  }
902
- const leading = groups.slice(0, 5).every((group) => group === 0);
903
- if (leading && groups[5] === 0 && groups[6] === 0 && groups[7] <= 1) {
1004
+ const [first, second, third, fourth, fifth, sixth, high, low] = groups;
1005
+ const embedded = (one, other) => ipv4IsGlobal(`${one >> 8}.${one & 255}.${other >> 8}.${other & 255}`);
1006
+ const zeroBefore = (count) => groups.slice(0, count).every((group) => group === 0);
1007
+ if (zeroBefore(5) && sixth === 65535) {
1008
+ return embedded(high, low);
1009
+ }
1010
+ if (zeroBefore(6) || zeroBefore(4) && fifth === 65535 && sixth === 0) {
1011
+ return false;
1012
+ }
1013
+ if (first === 100 && second === 65435) {
1014
+ return third === 0 && fourth === 0 && fifth === 0 && sixth === 0 && embedded(high, low);
1015
+ }
1016
+ if (first === 8194) {
1017
+ return embedded(second, third);
1018
+ }
1019
+ if (first === 8193 && second === 0) {
904
1020
  return false;
905
1021
  }
906
- if (leading && groups[5] === 65535) {
907
- const [high, low] = [groups[6], groups[7]];
908
- return ipv4IsGlobal(`${high >> 8}.${high & 255}.${low >> 8}.${low & 255}`);
1022
+ if (first === 256 && second === 0 && third === 0 && fourth === 0) {
1023
+ return false;
909
1024
  }
910
- const first = groups[0];
911
- if ((first & 65024) === 64512) {
1025
+ if ((first & 65024) === 64512 || (first & 65280) === 65280) {
912
1026
  return false;
913
1027
  }
914
- if ((first & 65472) === 65152) {
1028
+ if ((first & 65472) === 65152 || (first & 65472) === 65216) {
915
1029
  return false;
916
1030
  }
917
1031
  return true;
918
1032
  }
919
1033
  function ipv6Groups(address) {
920
- const [head = "", tail] = address.split("::");
1034
+ const halves = withDottedTailAsGroups(address).split("::");
1035
+ const [head = "", tail] = halves;
921
1036
  const left = head.split(":").filter((group) => group !== "");
922
1037
  const right = (tail ?? "").split(":").filter((group) => group !== "");
923
- const gap = tail === void 0 ? [] : Array(8 - left.length - right.length).fill("0");
924
- return [...left, ...gap, ...right].map((group) => parseInt(group, 16));
1038
+ const missing = 8 - left.length - right.length;
1039
+ if (halves.length > 2 || missing < 0) {
1040
+ return [Number.NaN];
1041
+ }
1042
+ const gap = tail === void 0 ? [] : Array(missing).fill("0");
1043
+ return [...left, ...gap, ...right].map(
1044
+ (group) => /^[0-9a-f]{1,4}$/.test(group) ? parseInt(group, 16) : Number.NaN
1045
+ );
1046
+ }
1047
+ function withDottedTailAsGroups(address) {
1048
+ const dotted = /^(.*:)(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/.exec(address);
1049
+ if (dotted === null) {
1050
+ return address;
1051
+ }
1052
+ const [a, b, c, d] = dotted[2].split(".").map(Number);
1053
+ if ([a, b, c, d].some((part) => part > 255)) {
1054
+ return `${dotted[1]}x`;
1055
+ }
1056
+ return `${dotted[1]}${(a << 8 | b).toString(16)}:${(c << 8 | d).toString(16)}`;
925
1057
  }
926
1058
 
927
1059
  // ../core/outbound.ts
@@ -949,6 +1081,9 @@ async function ask(send, url, sent = {}) {
949
1081
  }
950
1082
  await response.body?.cancel();
951
1083
  const next = new URL(location, target).toString();
1084
+ if (sent.staysOnOrigin && !sameOrigin(target, next)) {
1085
+ throw new Error(`${target} redirected off its own origin, to ${next}`);
1086
+ }
952
1087
  carried = KEEPS_THE_METHOD.includes(response.status) ? carried : withoutBody(carried);
953
1088
  carried = sameOrigin(target, next) ? carried : withoutCredentials(carried);
954
1089
  target = next;
@@ -995,15 +1130,17 @@ async function answerOf(response) {
995
1130
  return { status, ok, body: text(read), truncated: true, headers };
996
1131
  }
997
1132
  function withoutBody(sent) {
998
- const headers = { ...sent.headers };
999
- delete headers["content-type"];
1133
+ const headers = Object.fromEntries(
1134
+ Object.entries(sent.headers ?? {}).filter(([name]) => name.toLowerCase() !== "content-type")
1135
+ );
1000
1136
  return { ...sent, method: "GET", headers, body: void 0 };
1001
1137
  }
1002
1138
  function withoutCredentials(sent) {
1003
- const headers = { ...sent.headers };
1004
- for (const named of CREDENTIALS) {
1005
- delete headers[named];
1006
- }
1139
+ const headers = Object.fromEntries(
1140
+ Object.entries(sent.headers ?? {}).filter(
1141
+ ([name]) => !CREDENTIALS.includes(name.toLowerCase())
1142
+ )
1143
+ );
1007
1144
  return { ...sent, headers };
1008
1145
  }
1009
1146
  function text(chunks) {
@@ -1046,6 +1183,22 @@ async function resolve(send, addresses, amountMsat) {
1046
1183
  );
1047
1184
  return served.won;
1048
1185
  }
1186
+ async function quote(send, addresses, amountMsat) {
1187
+ const served = await firstThatServes(
1188
+ addresses,
1189
+ (address2, deadline) => probe(send, address2, amountMsat, deadline)
1190
+ );
1191
+ const { address, pay } = served.won;
1192
+ return {
1193
+ won: {
1194
+ address,
1195
+ minMsat: pay.minSendable,
1196
+ maxMsat: pay.maxSendable,
1197
+ metadata: pay.metadata
1198
+ },
1199
+ refusals: served.refusals
1200
+ };
1201
+ }
1049
1202
  async function firstThatServes(addresses, attempt) {
1050
1203
  const deadline = AbortSignal.timeout(RESOLVE_TIMEOUT_MS);
1051
1204
  const refusals3 = [];
@@ -1138,7 +1291,7 @@ function decodeIssued(address, bolt11) {
1138
1291
  return { paymentHash, descriptionHash, amountMsat, expiresAt };
1139
1292
  }
1140
1293
  async function checkSettled(send, verifyUrl, paymentHash) {
1141
- const answer = await answeredJson(send, verifyUrl);
1294
+ const answer = await answeredJson(send, verifyUrl, { staysOnOrigin: true });
1142
1295
  const asked = {
1143
1296
  pace: paceAskedFor(answer.headers),
1144
1297
  ceiling: ceilingAskedFor(answer.headers)
@@ -1196,10 +1349,10 @@ function isNothingButAHost(domain) {
1196
1349
  }
1197
1350
  }
1198
1351
  async function fetchJson(send, url, deadline) {
1199
- return (await answeredJson(send, url, deadline)).said;
1352
+ return (await answeredJson(send, url, { deadline })).said;
1200
1353
  }
1201
- async function answeredJson(send, url, deadline) {
1202
- const answer = await ask(send, url, { headers: { accept: "application/json" }, deadline });
1354
+ async function answeredJson(send, url, sent) {
1355
+ const answer = await ask(send, url, { headers: { accept: "application/json" }, ...sent });
1203
1356
  if (!answer.ok) {
1204
1357
  throw new Error(`${url} answered ${answer.status}`);
1205
1358
  }
@@ -1227,12 +1380,17 @@ var pinnedToTheAddressWeVerified = async (url, sent, signal, at) => {
1227
1380
  }
1228
1381
  const call = request(reaching(asked, sent, at, first), (answer) => {
1229
1382
  const status = answer.statusCode ?? 502;
1230
- settle(
1231
- new Response(
1232
- CARRIES_NO_BODY.includes(status) ? null : Readable.toWeb(answer),
1233
- { status, headers: headersOf(answer.headers) }
1234
- )
1235
- );
1383
+ try {
1384
+ settle(
1385
+ new Response(
1386
+ CARRIES_NO_BODY.includes(status) ? null : Readable.toWeb(answer),
1387
+ { status, headers: headersOf(answer.headers) }
1388
+ )
1389
+ );
1390
+ } catch (unreadable) {
1391
+ answer.destroy();
1392
+ fail(new Error(`${url} answered with something HTTP cannot carry`, { cause: unreadable }));
1393
+ }
1236
1394
  });
1237
1395
  const give = () => call.destroy(new Error(`${url} took longer than allowed`));
1238
1396
  signal.addEventListener("abort", give, { once: true });
@@ -1279,6 +1437,173 @@ function headersOf(raw) {
1279
1437
  return headers;
1280
1438
  }
1281
1439
 
1440
+ // src/verify.ts
1441
+ var HTTP_TIMEOUT_MS2 = 15e3;
1442
+ var ISSUED_SKEW_SECS = 300;
1443
+ var NAMES_AN_ADDRESS = ["text/identifier", "text/email"];
1444
+ var DEFAULT_WRAP_PROPORTION = 0.01;
1445
+ var DEFAULT_WRAP_BASE_MSAT = 1e3;
1446
+ async function proveOrigin(payment, asked, askedAt) {
1447
+ const cheat = (code) => new GatewayCheatError(code, payment.id);
1448
+ const listed2 = asked.paidTo.find((address) => equalIgnoringCase(address, payment.lnAddress));
1449
+ if (listed2 === void 0) {
1450
+ throw cheat("address_not_requested");
1451
+ }
1452
+ if (payment.amountMsat !== asked.amountMsat) {
1453
+ throw cheat("amount_mismatch");
1454
+ }
1455
+ const invoice = decodeInvoice(payment.bolt11);
1456
+ if (invoice.paymentHash === null || !equalIgnoringCase(invoice.paymentHash, payment.paymentHash)) {
1457
+ throw cheat("hash_mismatch");
1458
+ }
1459
+ if (invoice.amountMsat !== asked.amountMsat) {
1460
+ throw cheat("amount_mismatch");
1461
+ }
1462
+ if (askedAt !== void 0 && (invoice.issuedAt ?? 0) < askedAt - ISSUED_SKEW_SECS) {
1463
+ throw cheat("invoice_stale");
1464
+ }
1465
+ const payRequest = await payRequestFor(listed2, payment);
1466
+ if (typeof payRequest.metadata !== "string" || typeof payRequest.callback !== "string") {
1467
+ throw new UnverifiedRecipientError(payment.lnAddress, payment.id, "no payRequest served");
1468
+ }
1469
+ if (!namesTheAddress(payRequest.metadata, listed2)) {
1470
+ throw new UnverifiedRecipientError(
1471
+ payment.lnAddress,
1472
+ payment.id,
1473
+ "its metadata names no text/identifier for this address, so no invoice binds to it"
1474
+ );
1475
+ }
1476
+ if (invoice.descriptionHash !== sha256Hex(payRequest.metadata)) {
1477
+ throw cheat("description_hash_mismatch");
1478
+ }
1479
+ if (!sameOrigin(payment.verifyUrl, payRequest.callback)) {
1480
+ throw cheat("verify_url_foreign");
1481
+ }
1482
+ const issued = await reachable(payment.verifyUrl, payment);
1483
+ if (typeof issued.pr !== "string" || !equalIgnoringCase(issued.pr, payment.bolt11)) {
1484
+ throw cheat("invoice_not_issued");
1485
+ }
1486
+ if (askedAt !== void 0 && issued.settled === true) {
1487
+ throw cheat("invoice_settled");
1488
+ }
1489
+ }
1490
+ function namesTheAddress(metadata, address) {
1491
+ try {
1492
+ const entries = JSON.parse(metadata);
1493
+ return Array.isArray(entries) && entries.some(
1494
+ (entry) => Array.isArray(entry) && NAMES_AN_ADDRESS.includes(entry[0]) && typeof entry[1] === "string" && equalIgnoringCase(entry[1], address)
1495
+ );
1496
+ } catch {
1497
+ return false;
1498
+ }
1499
+ }
1500
+ async function proveSettlement(payment, asked) {
1501
+ await proveOrigin(payment, asked);
1502
+ const verified = await reachable(payment.verifyUrl, payment);
1503
+ if (verified.settled !== true || typeof verified.preimage !== "string") {
1504
+ return null;
1505
+ }
1506
+ if (!preimageMatchesHash(verified.preimage, payment.paymentHash)) {
1507
+ throw new GatewayCheatError("preimage_mismatch", payment.id);
1508
+ }
1509
+ return verified.preimage;
1510
+ }
1511
+ function carriesProof(report) {
1512
+ if (report.status !== "paid" || report.preimage === null) {
1513
+ return false;
1514
+ }
1515
+ if (report.bolt11 !== void 0 && report.bolt11 !== null) {
1516
+ const invoiceHash = decodeInvoice(report.bolt11).paymentHash;
1517
+ if (invoiceHash === null || !equalIgnoringCase(invoiceHash, report.paymentHash)) {
1518
+ return false;
1519
+ }
1520
+ }
1521
+ return preimageMatchesHash(report.preimage, report.paymentHash);
1522
+ }
1523
+ function wrapFeeCeiling(amountMsat, allowance) {
1524
+ const proportional = amountMsat * (allowance?.proportion ?? DEFAULT_WRAP_PROPORTION);
1525
+ return msat(Math.max(allowance?.baseMsat ?? DEFAULT_WRAP_BASE_MSAT, Math.floor(proportional)));
1526
+ }
1527
+ function proveWrapped(wrapped, recipient, allowance) {
1528
+ const offered = decodeInvoice(wrapped);
1529
+ const owed = decodeInvoice(recipient);
1530
+ const refuse2 = (code, detail) => {
1531
+ throw new WrapRefusedError(code, detail);
1532
+ };
1533
+ if (offered.paymentHash === null || offered.amountMsat === null || offered.expiresAt === null) {
1534
+ refuse2("undecodable", "the wrapped invoice carries no hash, amount or expiry");
1535
+ }
1536
+ if (owed.paymentHash === null || owed.amountMsat === null || owed.expiresAt === null) {
1537
+ refuse2("undecodable", "the recipient's invoice carries no hash, amount or expiry");
1538
+ }
1539
+ if (offered.paymentHash?.toLowerCase() !== owed.paymentHash?.toLowerCase()) {
1540
+ refuse2("hash_mismatch", "settling the wrap would not settle the recipient");
1541
+ }
1542
+ const asked = offered.amountMsat ?? 0;
1543
+ const due = owed.amountMsat ?? 0;
1544
+ if (asked < due) {
1545
+ refuse2("amount_below_recipient", `${asked} msat cannot forward ${due} msat`);
1546
+ }
1547
+ const ceiling = wrapFeeCeiling(due, allowance);
1548
+ if (asked - due > ceiling) {
1549
+ refuse2("fee_above_allowance", `${asked - due} msat over ${due}, and ${ceiling} was allowed`);
1550
+ }
1551
+ if ((owed.expiresAt ?? 0) <= (offered.expiresAt ?? 0)) {
1552
+ refuse2("recipient_expires_first", "the operator could be paid and find it cannot deliver");
1553
+ }
1554
+ }
1555
+ async function payRequestFor(listed2, payment) {
1556
+ const at = listed2.indexOf("@");
1557
+ const name = listed2.slice(0, at);
1558
+ const domain = listed2.slice(at + 1).toLowerCase();
1559
+ if (at < 1 || !publicHttps(`https://${domain}/`)) {
1560
+ throw new UnverifiedRecipientError(payment.lnAddress, payment.id, "not a usable address");
1561
+ }
1562
+ return reachable(`https://${domain}/.well-known/lnurlp/${name}`, payment);
1563
+ }
1564
+ async function reachable(url, payment) {
1565
+ try {
1566
+ if (!publicHttps(url)) {
1567
+ throw new Error(`${url} is not a public https URL`);
1568
+ }
1569
+ const response = await fetch(url, {
1570
+ signal: AbortSignal.timeout(HTTP_TIMEOUT_MS2),
1571
+ headers: { accept: "application/json" }
1572
+ });
1573
+ if (response.redirected && !sameOrigin(response.url, url)) {
1574
+ throw new Error(`redirected off its own origin, to ${response.url}`);
1575
+ }
1576
+ if (!response.ok) {
1577
+ throw new Error(`answered ${response.status}`);
1578
+ }
1579
+ const body = JSON.parse(await boundedText(response));
1580
+ if (body === null || typeof body !== "object") {
1581
+ throw new Error("answered with no JSON object");
1582
+ }
1583
+ return body;
1584
+ } catch (cause) {
1585
+ throw new UnverifiedRecipientError(payment.lnAddress, payment.id, cause);
1586
+ }
1587
+ }
1588
+ async function boundedText(response) {
1589
+ const reader = response.body?.getReader();
1590
+ const decoder = new TextDecoder();
1591
+ let text3 = "";
1592
+ let bytes = 0;
1593
+ for (let chunk = await reader?.read(); chunk && !chunk.done; chunk = await reader?.read()) {
1594
+ bytes += chunk.value.length;
1595
+ if (bytes > BODY_LIMIT_BYTES) {
1596
+ await reader?.cancel();
1597
+ throw new Error(`answered with more than the ${BODY_LIMIT_BYTES} bytes a proof reads`);
1598
+ }
1599
+ text3 += decoder.decode(chunk.value, { stream: true });
1600
+ }
1601
+ return text3 + decoder.decode();
1602
+ }
1603
+ function equalIgnoringCase(one, other) {
1604
+ return one.toLowerCase() === other.toLowerCase();
1605
+ }
1606
+
1282
1607
  // src/wire.ts
1283
1608
  var ASSET_CODE = "BTC";
1284
1609
  var ASSET_SCALE = 11;
@@ -1484,11 +1809,11 @@ var CHALLENGE = "webhook-challenge";
1484
1809
  var VERIFY_CHALLENGE = "verify-challenge";
1485
1810
  async function readPayment(request, credential, options = {}) {
1486
1811
  const body = await believable(request, credential, options);
1487
- return body === null ? null : decoded(body, paymentFromWire);
1812
+ return body === null ? null : proved(decoded(body, paymentFromWire));
1488
1813
  }
1489
1814
  async function readSettlement(request, credential, options = {}) {
1490
1815
  const body = await believable(request, credential, options);
1491
- return body === null ? null : decoded(body, settlementFromWire);
1816
+ return body === null ? null : proved(decoded(body, settlementFromWire));
1492
1817
  }
1493
1818
  async function answerWebhookChallenge(request, credential, options = {}) {
1494
1819
  const body = await believable(request, credential, options);
@@ -1522,6 +1847,9 @@ async function believable(request, credential, options) {
1522
1847
  );
1523
1848
  return signed ? body : null;
1524
1849
  }
1850
+ function proved(read) {
1851
+ return read !== null && read.status === "paid" && !carriesProof(read) ? null : read;
1852
+ }
1525
1853
  function decoded(body, from) {
1526
1854
  try {
1527
1855
  return from(JSON.parse(body));
@@ -1562,30 +1890,39 @@ var DEFAULT_LOOK_BACK_SECS = 7 * 24 * 60 * 60;
1562
1890
  var DEFAULT_POLL_EVERY_SECS = 30;
1563
1891
  var IBAN = /^[A-Z]{2}[0-9]{2}[0-9A-Z]{8,30}$/;
1564
1892
  var FORBIDDEN_IN_SPD = "*";
1893
+ var MAX_REFERENCE_CHARS = 60;
1565
1894
  async function bankTransfer(gateway, params) {
1566
1895
  const currency = params.currency ?? DEFAULT_CURRENCY;
1567
- refuseUnusable(params, currency);
1896
+ const iban = accountOf(params.iban);
1897
+ refuseUnusable(params, iban, currency);
1568
1898
  await refuseAnOpenGateway(gateway, params);
1569
- const spd = shortPaymentDescriptor(params, currency);
1570
- const subject = subjectOf(params.reference, params.amountMinor, currency);
1571
- const polling = new URL(params.verifyUrl);
1572
- polling.searchParams.set("ref", params.reference);
1573
- polling.searchParams.set("minor", String(params.amountMinor));
1574
- polling.searchParams.set("cc", currency);
1575
- polling.searchParams.set("sig", await hmacHex(params.secret, `verify|${subject}`));
1576
- const verifyUrl = polling.toString();
1899
+ const spd = shortPaymentDescriptor(params, iban, currency);
1900
+ const subject = subjectOf({
1901
+ iban,
1902
+ amountMinor: params.amountMinor,
1903
+ currency,
1904
+ expiresAt: params.expiresAt,
1905
+ reference: params.reference
1906
+ });
1907
+ const verifyUrl = await answeredAt(params, subject);
1908
+ const paymentHash = hashOf(await hmacHex(params.secret, `preimage|${subject}`));
1577
1909
  const watched = await gateway.watch({
1578
- paymentHash: hashOf(await hmacHex(params.secret, `preimage|${subject}`)),
1910
+ paymentHash,
1579
1911
  verifyUrl,
1580
1912
  expiresAt: params.expiresAt,
1581
1913
  trigger: params.trigger,
1582
1914
  replay: params.replay,
1583
- sealed: params.sealed,
1915
+ sealed: params.sealed ? await seal(params.sealed.secret, JSON.stringify(params.sealed.data), paymentHash) : void 0,
1584
1916
  webhookUrl: params.webhookUrl
1585
1917
  });
1586
- return { id: watched.id, paymentHash: watched.paymentHash, verifyUrl, spd };
1918
+ return { id: watched.id, paymentHash, verifyUrl, spd };
1587
1919
  }
1588
1920
  function bankVerifyEndpoint(config) {
1921
+ refuseAWeakSecret(config.secret);
1922
+ const answersFor = accountOf(config.iban);
1923
+ if (!IBAN.test(answersFor)) {
1924
+ throw new Error(`${config.iban} is not an IBAN, so this endpoint answers for no account`);
1925
+ }
1589
1926
  const paced = {
1590
1927
  "cache-control": `max-age=${config.pollEverySecs ?? DEFAULT_POLL_EVERY_SECS}`
1591
1928
  };
@@ -1594,51 +1931,79 @@ function bankVerifyEndpoint(config) {
1594
1931
  if (consented !== null) {
1595
1932
  return consented;
1596
1933
  }
1597
- const asked = readQuery(new URL(request.url));
1598
- if (asked === null) {
1934
+ const sealed = new URL(request.url).searchParams.get("q");
1935
+ if (sealed === null) {
1599
1936
  return Response.json({ settled: false }, { status: 400 });
1600
1937
  }
1601
- const subject = subjectOf(asked.reference, asked.amountMinor, asked.currency);
1602
- const expected = await hmacHex(config.secret, `verify|${subject}`);
1603
- if (!equalInConstantTime(asked.signature, expected)) {
1938
+ const subject = await unseal(config.secret, sealed);
1939
+ const asked = subject === null ? null : askedFrom(subject);
1940
+ if (asked === null || asked.iban !== answersFor) {
1604
1941
  return Response.json({ settled: false }, { status: 403 });
1605
1942
  }
1606
- const since = unixNow() - (config.lookBackSecs ?? DEFAULT_LOOK_BACK_SECS);
1607
- const landed = (await config.statement(since)).some((credit) => pays(credit, asked));
1608
- if (!landed) {
1609
- return Response.json({ settled: false }, { headers: paced });
1610
- }
1611
- return Response.json(
1612
- { settled: true, preimage: await hmacHex(config.secret, `preimage|${subject}`) },
1613
- { headers: paced }
1614
- );
1943
+ const preimage = await creditedPreimage({
1944
+ secret: config.secret,
1945
+ asked,
1946
+ statement: config.statement,
1947
+ lookBackSecs: config.lookBackSecs
1948
+ });
1949
+ return preimage === null ? Response.json({ settled: false }, { headers: paced }) : Response.json({ settled: true, preimage }, { headers: paced });
1615
1950
  };
1616
1951
  }
1617
- function readQuery(url) {
1618
- const reference = url.searchParams.get("ref");
1619
- const minor = Number(url.searchParams.get("minor"));
1620
- const currency = url.searchParams.get("cc");
1621
- const signature = url.searchParams.get("sig");
1622
- if (!reference || !currency || !signature) {
1952
+ async function answeredAt(params, subject) {
1953
+ if (params.answerBy === "agent") {
1954
+ return `agent:${(await callerKey(params.secret)).publicKeyHex}`;
1955
+ }
1956
+ const polling = new URL(params.verifyUrl ?? "");
1957
+ polling.searchParams.set("q", await sealStable(params.secret, subject));
1958
+ return polling.toString();
1959
+ }
1960
+ async function creditedPreimage(params) {
1961
+ const since = unixNow() - (params.lookBackSecs ?? DEFAULT_LOOK_BACK_SECS);
1962
+ const landed = (await params.statement(since)).some((credit) => pays(credit, params.asked));
1963
+ if (!landed) {
1623
1964
  return null;
1624
1965
  }
1625
- if (!Number.isInteger(minor) || minor <= 0) {
1966
+ return await hmacHex(params.secret, `preimage|${subjectOf(params.asked)}`);
1967
+ }
1968
+ function askedFrom(subject) {
1969
+ const [iban, minorText, currency, expiresText, ...rest] = subject.split("|");
1970
+ const reference = rest.join("|");
1971
+ const amountMinor = Number(minorText);
1972
+ const expiresAt = Number(expiresText);
1973
+ if (!iban || !currency || reference.length === 0) {
1626
1974
  return null;
1627
1975
  }
1628
- return { reference, amountMinor: minor, currency, signature };
1976
+ if (!Number.isInteger(amountMinor) || amountMinor <= 0 || !Number.isSafeInteger(expiresAt)) {
1977
+ return null;
1978
+ }
1979
+ return { iban, reference, amountMinor, currency, expiresAt };
1629
1980
  }
1630
1981
  function pays(credit, asked) {
1631
- return credit.amountMinor === asked.amountMinor && credit.currency.toUpperCase() === asked.currency.toUpperCase() && credit.reference.toUpperCase().includes(asked.reference.toUpperCase());
1982
+ return credit.amountMinor === asked.amountMinor && credit.currency.toUpperCase() === asked.currency.toUpperCase() && namesOnly(credit.reference, asked.reference);
1983
+ }
1984
+ function namesOnly(written, reference) {
1985
+ const wanted = reference.toUpperCase();
1986
+ const alike = [...written.toUpperCase().matchAll(wordsShapedLike(wanted))].map(([word]) => word);
1987
+ return alike.includes(wanted) && (!/\d/.test(wanted) || alike.every((word) => word === wanted));
1632
1988
  }
1633
- function subjectOf(reference, amountMinor, currency) {
1634
- return `${reference}|${amountMinor}|${currency.toUpperCase()}`;
1989
+ function wordsShapedLike(reference) {
1990
+ const shape = [...reference].map((char) => {
1991
+ if (/\d/.test(char)) {
1992
+ return "\\d";
1993
+ }
1994
+ return /\p{L}/u.test(char) ? "\\p{L}" : char.replace(/[\^$\\.*+?()[\]{}|/]/g, "\\$&");
1995
+ }).join("");
1996
+ return new RegExp(`(?<![\\p{L}\\p{N}])${shape}(?![\\p{L}\\p{N}])`, "gu");
1997
+ }
1998
+ function subjectOf({ iban, amountMinor, currency, expiresAt, reference }) {
1999
+ return `${iban}|${amountMinor}|${currency.toUpperCase()}|${expiresAt}|${reference}`;
1635
2000
  }
1636
2001
  function hashOf(preimage) {
1637
2002
  return bytesToHex(sha256(hexToBytes(preimage)));
1638
2003
  }
1639
- function shortPaymentDescriptor(params, currency) {
2004
+ function shortPaymentDescriptor(params, iban, currency) {
1640
2005
  const fields = [
1641
- `ACC:${params.iban}`,
2006
+ `ACC:${iban}`,
1642
2007
  `AM:${major(params.amountMinor, currency)}`,
1643
2008
  `CC:${currency.toUpperCase()}`,
1644
2009
  `MSG:${params.reference}`
@@ -1659,19 +2024,28 @@ async function refuseAnOpenGateway(gateway, params) {
1659
2024
  return;
1660
2025
  }
1661
2026
  throw new Error(
1662
- "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"
2027
+ "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"
1663
2028
  );
1664
2029
  }
1665
- function refuseUnusable(params, currency) {
1666
- if (!IBAN.test(params.iban)) {
2030
+ function refuseUnusable(params, iban, currency) {
2031
+ refuseAWeakSecret(params.secret);
2032
+ if (params.answerBy !== "agent" && params.verifyUrl === void 0) {
2033
+ throw new Error("a transfer the gateway polls needs the verify url it is polled at");
2034
+ }
2035
+ if (!IBAN.test(iban)) {
1667
2036
  throw new Error(`${params.iban} is not an IBAN`);
1668
2037
  }
1669
- if (!Number.isInteger(params.amountMinor) || params.amountMinor <= 0) {
2038
+ if (!Number.isSafeInteger(params.amountMinor) || params.amountMinor <= 0) {
1670
2039
  throw new Error("amountMinor must be a whole number of minor units above zero");
1671
2040
  }
1672
2041
  if (params.reference.length === 0) {
1673
2042
  throw new Error("a transfer with no reference cannot be found");
1674
2043
  }
2044
+ if (params.reference.length > MAX_REFERENCE_CHARS) {
2045
+ throw new Error(
2046
+ `a reference is at most ${MAX_REFERENCE_CHARS} characters, as many as a QR payment's message carries and its sealed query hides`
2047
+ );
2048
+ }
1675
2049
  if (params.reference.includes(FORBIDDEN_IN_SPD)) {
1676
2050
  throw new Error("a reference cannot contain an asterisk, it separates the QR fields");
1677
2051
  }
@@ -1679,6 +2053,9 @@ function refuseUnusable(params, currency) {
1679
2053
  throw new Error("a variable symbol is up to ten digits");
1680
2054
  }
1681
2055
  }
2056
+ function accountOf(iban) {
2057
+ return iban.replace(/\s+/g, "").toUpperCase();
2058
+ }
1682
2059
  function unixNow() {
1683
2060
  return Math.floor(Date.now() / 1e3);
1684
2061
  }
@@ -1742,10 +2119,10 @@ function lightningVerifyEndpoint(config) {
1742
2119
  return Response.json({ settled: false }, { status: 400 });
1743
2120
  }
1744
2121
  const opened = await unseal(config.secret, sealed);
1745
- if (opened === null) {
2122
+ const wallet = opened === null ? null : relayedFrom(opened);
2123
+ if (wallet === null) {
1746
2124
  return Response.json({ settled: false }, { status: 403 });
1747
2125
  }
1748
- const wallet = JSON.parse(opened);
1749
2126
  const asked = await checkSettled(
1750
2127
  config.send ?? pinnedToTheAddressWeVerified,
1751
2128
  wallet.url,
@@ -1760,6 +2137,14 @@ function lightningVerifyEndpoint(config) {
1760
2137
  );
1761
2138
  };
1762
2139
  }
2140
+ function relayedFrom(opened) {
2141
+ try {
2142
+ const said = JSON.parse(opened);
2143
+ return typeof said?.url === "string" && typeof said.hash === "string" ? { url: said.url, hash: said.hash } : null;
2144
+ } catch {
2145
+ return null;
2146
+ }
2147
+ }
1763
2148
  async function relayedVerifyUrl(endpoint, wallet, secret) {
1764
2149
  const relayed = new URL(endpoint);
1765
2150
  relayed.searchParams.set(WALLET, await seal(secret, JSON.stringify(wallet)));
@@ -1782,13 +2167,14 @@ function bankRail(gateway, config) {
1782
2167
  expiresAt,
1783
2168
  trigger: config.trigger,
1784
2169
  replay: config.replay,
1785
- sealed: await config.sealed?.(order),
2170
+ sealed: config.sealed && { secret: config.sealed.secret, data: config.sealed.data(order) },
1786
2171
  variableSymbol: config.variableSymbol?.(order),
1787
2172
  webhookUrl: config.webhookUrl,
1788
2173
  allowPublicGateway: config.allowPublicGateway
1789
2174
  });
1790
2175
  return {
1791
2176
  id: transfer.id,
2177
+ paymentHash: transfer.paymentHash,
1792
2178
  rail: config.name ?? BANK,
1793
2179
  scan: transfer.spd,
1794
2180
  qr: transfer.spd,
@@ -1812,6 +2198,7 @@ function lightningRail(gateway, config) {
1812
2198
  );
1813
2199
  return {
1814
2200
  id: payment.id,
2201
+ paymentHash: payment.paymentHash,
1815
2202
  rail: config.name ?? LIGHTNING,
1816
2203
  scan: payment.bolt11,
1817
2204
  qr: toLightningUri(payment.bolt11),
@@ -1837,11 +2224,16 @@ function blindLightningRail(gateway, config) {
1837
2224
  expiresAt: resolved2.expiresAt,
1838
2225
  trigger: config.trigger,
1839
2226
  replay: config.replay,
1840
- sealed: await config.sealed?.(order),
2227
+ sealed: config.sealed ? await seal(
2228
+ config.sealed.secret,
2229
+ JSON.stringify(config.sealed.data(order)),
2230
+ resolved2.paymentHash
2231
+ ) : void 0,
1841
2232
  webhookUrl: config.webhookUrl
1842
2233
  });
1843
2234
  return {
1844
2235
  id: watched.id,
2236
+ paymentHash: resolved2.paymentHash,
1845
2237
  rail: config.name ?? LIGHTNING,
1846
2238
  scan: resolved2.bolt11,
1847
2239
  qr: toLightningUri(resolved2.bolt11),
@@ -1895,130 +2287,6 @@ var Rails = class {
1895
2287
  }
1896
2288
  };
1897
2289
 
1898
- // src/verify.ts
1899
- var HTTP_TIMEOUT_MS2 = 15e3;
1900
- var DEFAULT_WRAP_PROPORTION = 0.01;
1901
- var DEFAULT_WRAP_BASE_MSAT = 1e3;
1902
- async function proveOrigin(payment, asked) {
1903
- const cheat = (code) => new GatewayCheatError(code, payment.id);
1904
- const listed = asked.paidTo.find((address) => equalIgnoringCase(address, payment.lnAddress));
1905
- if (listed === void 0) {
1906
- throw cheat("address_not_requested");
1907
- }
1908
- if (payment.amountMsat !== asked.amountMsat) {
1909
- throw cheat("amount_mismatch");
1910
- }
1911
- const invoice = decodeInvoice(payment.bolt11);
1912
- if (invoice.paymentHash === null || !equalIgnoringCase(invoice.paymentHash, payment.paymentHash)) {
1913
- throw cheat("hash_mismatch");
1914
- }
1915
- if (invoice.amountMsat !== asked.amountMsat) {
1916
- throw cheat("amount_mismatch");
1917
- }
1918
- const payRequest = await payRequestFor(listed, payment);
1919
- if (typeof payRequest.metadata !== "string" || typeof payRequest.callback !== "string") {
1920
- throw new UnverifiedRecipientError(payment.lnAddress, payment.id, "no payRequest served");
1921
- }
1922
- if (invoice.descriptionHash !== sha256Hex(payRequest.metadata)) {
1923
- throw cheat("description_hash_mismatch");
1924
- }
1925
- if (!sameOrigin(payment.verifyUrl, payRequest.callback)) {
1926
- throw cheat("verify_url_foreign");
1927
- }
1928
- const issued = await reachable(payment.verifyUrl, payment);
1929
- if (typeof issued.pr !== "string" || !equalIgnoringCase(issued.pr, payment.bolt11)) {
1930
- throw cheat("invoice_not_issued");
1931
- }
1932
- }
1933
- async function proveSettlement(payment, asked) {
1934
- await proveOrigin(payment, asked);
1935
- const verified = await reachable(payment.verifyUrl, payment);
1936
- if (verified.settled !== true || typeof verified.preimage !== "string") {
1937
- return null;
1938
- }
1939
- if (!preimageMatchesHash(verified.preimage, payment.paymentHash)) {
1940
- throw new GatewayCheatError("preimage_mismatch", payment.id);
1941
- }
1942
- return verified.preimage;
1943
- }
1944
- function carriesProof(report) {
1945
- if (report.status !== "paid" || report.preimage === null) {
1946
- return false;
1947
- }
1948
- if (report.bolt11 !== void 0 && report.bolt11 !== null) {
1949
- const invoiceHash = decodeInvoice(report.bolt11).paymentHash;
1950
- if (invoiceHash === null || !equalIgnoringCase(invoiceHash, report.paymentHash)) {
1951
- return false;
1952
- }
1953
- }
1954
- return preimageMatchesHash(report.preimage, report.paymentHash);
1955
- }
1956
- function wrapFeeCeiling(amountMsat, allowance) {
1957
- const proportional = amountMsat * (allowance?.proportion ?? DEFAULT_WRAP_PROPORTION);
1958
- return msat(Math.max(allowance?.baseMsat ?? DEFAULT_WRAP_BASE_MSAT, Math.floor(proportional)));
1959
- }
1960
- function proveWrapped(wrapped, recipient, allowance) {
1961
- const offered = decodeInvoice(wrapped);
1962
- const owed = decodeInvoice(recipient);
1963
- const refuse2 = (code, detail) => {
1964
- throw new WrapRefusedError(code, detail);
1965
- };
1966
- if (offered.paymentHash === null || offered.amountMsat === null || offered.expiresAt === null) {
1967
- refuse2("undecodable", "the wrapped invoice carries no hash, amount or expiry");
1968
- }
1969
- if (owed.paymentHash === null || owed.amountMsat === null || owed.expiresAt === null) {
1970
- refuse2("undecodable", "the recipient's invoice carries no hash, amount or expiry");
1971
- }
1972
- if (offered.paymentHash?.toLowerCase() !== owed.paymentHash?.toLowerCase()) {
1973
- refuse2("hash_mismatch", "settling the wrap would not settle the recipient");
1974
- }
1975
- const asked = offered.amountMsat ?? 0;
1976
- const due = owed.amountMsat ?? 0;
1977
- if (asked < due) {
1978
- refuse2("amount_below_recipient", `${asked} msat cannot forward ${due} msat`);
1979
- }
1980
- const ceiling = wrapFeeCeiling(due, allowance);
1981
- if (asked - due > ceiling) {
1982
- refuse2("fee_above_allowance", `${asked - due} msat over ${due}, and ${ceiling} was allowed`);
1983
- }
1984
- if ((owed.expiresAt ?? 0) <= (offered.expiresAt ?? 0)) {
1985
- refuse2("recipient_expires_first", "the operator could be paid and find it cannot deliver");
1986
- }
1987
- }
1988
- async function payRequestFor(listed, payment) {
1989
- const at = listed.indexOf("@");
1990
- const name = listed.slice(0, at);
1991
- const domain = listed.slice(at + 1).toLowerCase();
1992
- if (at < 1 || !publicHttps(`https://${domain}/`)) {
1993
- throw new UnverifiedRecipientError(payment.lnAddress, payment.id, "not a usable address");
1994
- }
1995
- return reachable(`https://${domain}/.well-known/lnurlp/${name}`, payment);
1996
- }
1997
- async function reachable(url, payment) {
1998
- try {
1999
- if (!publicHttps(url)) {
2000
- throw new Error(`${url} is not a public https URL`);
2001
- }
2002
- const response = await fetch(url, {
2003
- signal: AbortSignal.timeout(HTTP_TIMEOUT_MS2),
2004
- headers: { accept: "application/json" }
2005
- });
2006
- if (!response.ok) {
2007
- throw new Error(`answered ${response.status}`);
2008
- }
2009
- const body = await response.json();
2010
- if (body === null || typeof body !== "object") {
2011
- throw new Error("answered with no JSON object");
2012
- }
2013
- return body;
2014
- } catch (cause) {
2015
- throw new UnverifiedRecipientError(payment.lnAddress, payment.id, cause);
2016
- }
2017
- }
2018
- function equalIgnoringCase(one, other) {
2019
- return one.toLowerCase() === other.toLowerCase();
2020
- }
2021
-
2022
2290
  // src/request.ts
2023
2291
  function paymentRequestOf(gateway, payment, options) {
2024
2292
  return {
@@ -2030,13 +2298,13 @@ function paymentRequestOf(gateway, payment, options) {
2030
2298
  expiresAt: payment.expiresAt,
2031
2299
  qr: invoiceToSvg(payment.bolt11, options?.qr),
2032
2300
  payment,
2033
- paid: async (waiting) => paidOnly(await gateway.settled(payment.id, { ...waitingOf(options), ...waiting })),
2301
+ paid: async (waiting) => paidOnly(await gateway.settled(payment, { ...waitingOf(options), ...waiting })),
2034
2302
  prove: () => proveSettlement(payment, { paidTo: [payment.lnAddress], amountMsat: payment.amountMsat }),
2035
2303
  onPaid: (arrived, failed) => {
2036
2304
  const stop = new AbortController();
2037
2305
  const waiting = waitingOf(options);
2038
2306
  const signal = waiting.signal ? AbortSignal.any([stop.signal, waiting.signal]) : stop.signal;
2039
- gateway.settled(payment.id, { ...waiting, signal }).then((ended) => arrived(paidOnly(ended))).catch((reason) => {
2307
+ gateway.settled(payment, { ...waiting, signal }).then((ended) => arrived(paidOnly(ended))).catch((reason) => {
2040
2308
  if (!stop.signal.aborted) {
2041
2309
  failed?.(reason);
2042
2310
  }
@@ -2085,22 +2353,37 @@ function publicWatchTicketEndpoint(gateway, config) {
2085
2353
  }
2086
2354
  async function offer(gateway, config, url) {
2087
2355
  const { least, most } = await spread(config.amount);
2088
- const quote = await gateway.quote({ paidTo: config.paidTo, amount: msat(least) });
2356
+ const winner = await quoted(gateway, config, least);
2089
2357
  const nonce = randomNonce();
2090
2358
  const callback = new URL(config.baseUrl ?? `${url.origin}${url.pathname}`);
2091
- callback.searchParams.set("to", quote.lnAddress);
2359
+ callback.searchParams.set("to", winner.address);
2092
2360
  callback.searchParams.set("least", String(least));
2093
2361
  callback.searchParams.set("most", String(most));
2094
2362
  callback.searchParams.set("n", nonce);
2095
- callback.searchParams.set("sig", await sign(config.secret, quote.lnAddress, least, most, nonce));
2363
+ callback.searchParams.set("sig", await sign(config.secret, winner.address, least, most, nonce));
2096
2364
  return Response.json({
2097
2365
  tag: "payRequest",
2098
2366
  callback: callback.toString(),
2099
- metadata: quote.metadata,
2367
+ metadata: winner.metadata,
2100
2368
  minSendable: least,
2101
2369
  maxSendable: most
2102
2370
  });
2103
2371
  }
2372
+ async function quoted(gateway, config, amountMsat) {
2373
+ if (config.blind) {
2374
+ const { won } = await quote(
2375
+ config.send ?? pinnedToTheAddressWeVerified,
2376
+ listed(config.paidTo),
2377
+ amountMsat
2378
+ );
2379
+ return won;
2380
+ }
2381
+ const { lnAddress, metadata } = await gateway.quote({
2382
+ paidTo: config.paidTo,
2383
+ amount: msat(amountMsat)
2384
+ });
2385
+ return { address: lnAddress, metadata };
2386
+ }
2104
2387
  async function spread(amount) {
2105
2388
  if (typeof amount === "number" || typeof amount === "function") {
2106
2389
  const pinned = await amountNow(amount);
@@ -2122,6 +2405,9 @@ async function mint(gateway, config, url, address) {
2122
2405
  if (!equalInConstantTime(signature, expected)) {
2123
2406
  return refuse("this callback was not signed here");
2124
2407
  }
2408
+ if (!listed(config.paidTo).some((one) => one.toLowerCase() === address.toLowerCase())) {
2409
+ return refuse(`${address} is not paid through this trigger`);
2410
+ }
2125
2411
  const asked = url.searchParams.get("amount");
2126
2412
  const amountMsat = asked === null ? least : Number(asked);
2127
2413
  if (!Number.isSafeInteger(amountMsat) || amountMsat < least || amountMsat > most) {
@@ -2164,7 +2450,7 @@ async function mintBlind(gateway, config, address, amountMsat) {
2164
2450
  expiresAt: resolved2.expiresAt,
2165
2451
  trigger: config.watchSecret,
2166
2452
  replay: config.replay,
2167
- sealed: locked ? await seal(locked.secret, JSON.stringify(locked.data(minted))) : void 0
2453
+ sealed: locked ? await seal(locked.secret, JSON.stringify(locked.data(minted)), minted.paymentHash) : void 0
2168
2454
  });
2169
2455
  } catch (refused) {
2170
2456
  if (!alreadyWatched(refused)) {
@@ -2173,6 +2459,9 @@ async function mintBlind(gateway, config, address, amountMsat) {
2173
2459
  }
2174
2460
  return { bolt11: resolved2.bolt11, verifyUrl: resolved2.verifyUrl };
2175
2461
  }
2462
+ function listed(paidTo) {
2463
+ return typeof paidTo === "string" ? [paidTo] : paidTo;
2464
+ }
2176
2465
  function alreadyWatched(refused) {
2177
2466
  return refused instanceof ProblemError && isProblemType(refused, PAYMENT_ALREADY_WATCHED);
2178
2467
  }
@@ -2312,6 +2601,21 @@ function backoffMs(firstDelay, attempt) {
2312
2601
  const grown = Math.min(firstDelay * 2 ** (attempt - 1), RECONNECT_CAP_MS);
2313
2602
  return Math.round(grown * (0.5 + Math.random() / 2));
2314
2603
  }
2604
+ function askFromWire(raw) {
2605
+ let said;
2606
+ try {
2607
+ said = JSON.parse(raw);
2608
+ } catch {
2609
+ return null;
2610
+ }
2611
+ if (typeof said !== "object" || said === null) {
2612
+ return null;
2613
+ }
2614
+ const fields = said;
2615
+ const ask2 = fields["ask"];
2616
+ const paymentHash = fields["payment_hash"];
2617
+ return typeof ask2 === "string" && typeof paymentHash === "string" ? { ask: ask2, paymentHash } : null;
2618
+ }
2315
2619
  function gatewayAt(baseUrl) {
2316
2620
  const at = URL.canParse(baseUrl) ? new URL(baseUrl) : null;
2317
2621
  if (at === null || at.protocol !== "http:" && at.protocol !== "https:") {
@@ -2321,8 +2625,8 @@ function gatewayAt(baseUrl) {
2321
2625
  }
2322
2626
  var ThunderBridge = class {
2323
2627
  baseUrl;
2324
- verify;
2325
2628
  token;
2629
+ handedOut = /* @__PURE__ */ new Map();
2326
2630
  secret;
2327
2631
  strangers = null;
2328
2632
  speaks = null;
@@ -2333,7 +2637,6 @@ var ThunderBridge = class {
2333
2637
  rails;
2334
2638
  constructor(baseUrl, options) {
2335
2639
  this.baseUrl = gatewayAt(baseUrl);
2336
- this.verify = options?.verify ?? true;
2337
2640
  this.token = options?.token ?? null;
2338
2641
  this.secret = options?.secret ?? null;
2339
2642
  this.serve = new Serve(this);
@@ -2375,6 +2678,7 @@ var ThunderBridge = class {
2375
2678
  * and `GatewayCheatError` when what comes back is not what you asked for
2376
2679
  */
2377
2680
  async mint(charge, options) {
2681
+ const askedAt = Math.floor(Date.now() / 1e3);
2378
2682
  const asked = await priced(charge);
2379
2683
  const sent = createRequestBody(
2380
2684
  asked,
@@ -2395,9 +2699,8 @@ var ThunderBridge = class {
2395
2699
  throw await problemFrom(response);
2396
2700
  }
2397
2701
  const payment = await mintedFrom(response);
2398
- if (this.verify) {
2399
- await proveOrigin(payment, asked);
2400
- }
2702
+ await proveOrigin(payment, asked, askedAt);
2703
+ this.handOut(payment, options?.idempotencyKey ?? null);
2401
2704
  return payment;
2402
2705
  }
2403
2706
  /**
@@ -2408,7 +2711,8 @@ var ThunderBridge = class {
2408
2711
  * it for one, and asking mints it
2409
2712
  */
2410
2713
  async quote(charge) {
2411
- const sent = quoteRequestBody(await priced(charge));
2714
+ const asked = await priced(charge);
2715
+ const sent = quoteRequestBody(asked);
2412
2716
  const response = await fetch(`${this.baseUrl}/quotes`, {
2413
2717
  method: "POST",
2414
2718
  headers: await this.sending("/quotes", sent),
@@ -2417,14 +2721,17 @@ var ThunderBridge = class {
2417
2721
  if (!response.ok) {
2418
2722
  throw await problemFrom(response);
2419
2723
  }
2420
- const quote = quoteFromWire(await response.json().catch(() => null));
2421
- if (quote === null) {
2724
+ const quote2 = quoteFromWire(await response.json().catch(() => null));
2725
+ if (quote2 === null) {
2422
2726
  throw new ProblemError({
2423
2727
  status: response.status,
2424
2728
  title: "The gateway answered with something that is not a quote"
2425
2729
  });
2426
2730
  }
2427
- return quote;
2731
+ if (!asked.paidTo.some((address) => address.toLowerCase() === quote2.lnAddress.toLowerCase())) {
2732
+ throw new GatewayCheatError("address_not_requested", "");
2733
+ }
2734
+ return quote2;
2428
2735
  }
2429
2736
  /**
2430
2737
  * The key this gateway signs webhooks with when you registered none of your own.
@@ -2457,8 +2764,8 @@ var ThunderBridge = class {
2457
2764
  * for both sorts: `kind` says whether the gateway minted it or was handed it,
2458
2765
  * and the address, amount and invoice are null on one it was never told
2459
2766
  */
2460
- async payment(id) {
2461
- const path = `/incoming-payments/${encodeURIComponent(id)}`;
2767
+ async payment(held) {
2768
+ const path = `/incoming-payments/${encodeURIComponent(held.id)}`;
2462
2769
  const response = await fetch(`${this.baseUrl}${path}`, {
2463
2770
  headers: await this.reading("GET", path)
2464
2771
  });
@@ -2468,7 +2775,7 @@ var ThunderBridge = class {
2468
2775
  if (!response.ok) {
2469
2776
  throw await problemFrom(response);
2470
2777
  }
2471
- return this.proven(await paymentFrom(response));
2778
+ return this.proven(await paymentFrom(response), held);
2472
2779
  }
2473
2780
  /**
2474
2781
  * List what this gateway is watching, newest first. Only a gateway started
@@ -2488,14 +2795,14 @@ var ThunderBridge = class {
2488
2795
  throw await problemFrom(response);
2489
2796
  }
2490
2797
  const body = await response.json().catch(() => null);
2491
- const listed = Array.isArray(body?.payments) ? body.payments.map(paymentFromWire).filter((one) => one !== null) : null;
2492
- if (listed === null || typeof body?.settled_scanned !== "number") {
2798
+ const listed2 = Array.isArray(body?.payments) ? body.payments.map(paymentFromWire).filter((one) => one !== null).filter((one) => one.status !== "paid" || carriesProof(one)) : null;
2799
+ if (listed2 === null || typeof body?.settled_scanned !== "number") {
2493
2800
  throw new ProblemError({
2494
2801
  status: response.status,
2495
2802
  title: "The gateway answered with something that is not a payment list"
2496
2803
  });
2497
2804
  }
2498
- return { payments: listed, scanned: body.settled_scanned };
2805
+ return { payments: listed2, scanned: body.settled_scanned };
2499
2806
  }
2500
2807
  /**
2501
2808
  * Follow a payment over WebSocket until it is paid or expired, reconnecting
@@ -2503,15 +2810,15 @@ var ThunderBridge = class {
2503
2810
  * one that has answered is followed until its own expiry, so the wait always
2504
2811
  * ends by itself
2505
2812
  */
2506
- async settled(id, options) {
2507
- const ended = paymentFromWire(await this.followed(id, options));
2813
+ async settled(held, options) {
2814
+ const ended = paymentFromWire(await this.followed(held.id, options));
2508
2815
  if (ended === null) {
2509
2816
  throw new ProblemError({
2510
2817
  status: 200,
2511
2818
  title: "The gateway answered with something that is not a payment"
2512
2819
  });
2513
2820
  }
2514
- return this.proven(ended);
2821
+ return this.proven(ended, held);
2515
2822
  }
2516
2823
  followed(id, options) {
2517
2824
  const base = this.baseUrl.replace(/^http/, "ws");
@@ -2616,8 +2923,8 @@ var ThunderBridge = class {
2616
2923
  * really does pay twice and that shows up on `follow` as a second settlement to
2617
2924
  * refund.
2618
2925
  */
2619
- async firstSettled(ids, options) {
2620
- if (ids.length === 0) {
2926
+ async firstSettled(held, options) {
2927
+ if (held.length === 0) {
2621
2928
  return null;
2622
2929
  }
2623
2930
  const stopLosers = new AbortController();
@@ -2625,15 +2932,15 @@ var ThunderBridge = class {
2625
2932
  let refused = null;
2626
2933
  try {
2627
2934
  const winner = await new Promise((resolve2) => {
2628
- let waiting = ids.length;
2935
+ let waiting = held.length;
2629
2936
  const lost = () => {
2630
2937
  waiting -= 1;
2631
2938
  if (waiting === 0) {
2632
2939
  resolve2(null);
2633
2940
  }
2634
2941
  };
2635
- for (const id of ids) {
2636
- this.settled(id, { ...options, signal }).then((watched) => watched.status === "paid" ? resolve2(watched) : lost()).catch((failure) => {
2942
+ for (const leg of held) {
2943
+ this.settled(leg, { ...options, signal }).then((watched) => watched.status === "paid" ? resolve2(watched) : lost()).catch((failure) => {
2637
2944
  refused ??= failure;
2638
2945
  lost();
2639
2946
  });
@@ -2677,6 +2984,9 @@ var ThunderBridge = class {
2677
2984
  if (named !== null && watched.id !== named) {
2678
2985
  throw new GatewayCheatError("id_not_mine", watched.id);
2679
2986
  }
2987
+ if (watched.paymentHash.toLowerCase() !== handover.paymentHash.toLowerCase()) {
2988
+ throw new GatewayCheatError("hash_mismatch", watched.id);
2989
+ }
2680
2990
  return watched;
2681
2991
  }
2682
2992
  /**
@@ -2689,6 +2999,69 @@ var ThunderBridge = class {
2689
2999
  async nameFor(paymentHash) {
2690
3000
  return this.secret === null ? null : paymentNamedBy((await this.speaking()).publicKeyHex, paymentHash);
2691
3001
  }
3002
+ /**
3003
+ * Hold a socket open and answer what the gateway asks about this caller's own
3004
+ * payments, so a watch addressed to this caller settles without anybody
3005
+ * hosting a URL. Reconnects on its own until the returned function is called
3006
+ */
3007
+ attend(options) {
3008
+ const base = this.baseUrl.replace(/^http/, "ws");
3009
+ const firstDelay = options.reconnectDelayMs ?? RECONNECT_DELAY_MS;
3010
+ let socket = null;
3011
+ let retry;
3012
+ let stopped = false;
3013
+ let attempt = 0;
3014
+ const again = () => {
3015
+ if (stopped || options.reconnect === false) {
3016
+ return;
3017
+ }
3018
+ retry = setTimeout(() => void connect(), backoffMs(firstDelay, attempt));
3019
+ };
3020
+ const answered3 = async (opened, raw) => {
3021
+ const asked = askFromWire(raw);
3022
+ if (asked === null) {
3023
+ return;
3024
+ }
3025
+ const preimage = await options.answer(asked.paymentHash);
3026
+ opened.send(
3027
+ JSON.stringify(
3028
+ preimage === null ? { ask: asked.ask, settled: false } : { ask: asked.ask, settled: true, preimage }
3029
+ )
3030
+ );
3031
+ };
3032
+ const connect = async () => {
3033
+ attempt += 1;
3034
+ let url;
3035
+ try {
3036
+ url = `${base}/ws/tickets/${await this.wsTicket({ agent: true })}`;
3037
+ } catch (refused) {
3038
+ options.onError?.(refused);
3039
+ again();
3040
+ return;
3041
+ }
3042
+ if (stopped) {
3043
+ return;
3044
+ }
3045
+ const opened = new WebSocket(url);
3046
+ socket = opened;
3047
+ opened.onopen = () => {
3048
+ attempt = 1;
3049
+ };
3050
+ opened.onmessage = (event) => {
3051
+ void answered3(opened, String(event.data)).catch(
3052
+ (refused) => options.onError?.(refused)
3053
+ );
3054
+ };
3055
+ opened.onerror = () => options.onError?.(new Error(`could not attend ${base}`));
3056
+ opened.onclose = again;
3057
+ };
3058
+ void connect();
3059
+ return () => {
3060
+ stopped = true;
3061
+ clearTimeout(retry);
3062
+ socket?.close();
3063
+ };
3064
+ }
2692
3065
  /**
2693
3066
  * Follow every payment made to one trigger, replayed from the recent ones on
2694
3067
  * connect and then live, reconnecting on its own until the returned function
@@ -2765,7 +3138,7 @@ var ThunderBridge = class {
2765
3138
  });
2766
3139
  }
2767
3140
  needsTicket(asked) {
2768
- return asked === true || this.token !== null;
3141
+ return asked === true || this.token !== null || this.secret !== null;
2769
3142
  }
2770
3143
  async wsTicket(body) {
2771
3144
  return encodeURIComponent((await this.mintedTicket(body)).ticket);
@@ -2802,8 +3175,26 @@ var ThunderBridge = class {
2802
3175
  this.speaks ??= callerKey(this.secret ?? "");
2803
3176
  return await this.speaks;
2804
3177
  }
2805
- proven(payment) {
2806
- if (this.verify && payment.status === "paid" && !carriesProof(payment)) {
3178
+ handOut(payment, key) {
3179
+ const now = Math.floor(Date.now() / 1e3);
3180
+ for (const [hash2, earlier2] of this.handedOut) {
3181
+ if (earlier2.expiresAt <= now) {
3182
+ this.handedOut.delete(hash2);
3183
+ }
3184
+ }
3185
+ const hash = payment.paymentHash.toLowerCase();
3186
+ const earlier = this.handedOut.get(hash);
3187
+ if (earlier !== void 0 && (earlier.key === null || earlier.key !== key)) {
3188
+ throw new GatewayCheatError("invoice_reused", payment.id);
3189
+ }
3190
+ this.handedOut.set(hash, { key, expiresAt: payment.expiresAt });
3191
+ }
3192
+ proven(payment, held) {
3193
+ const other = held !== void 0 && (payment.id !== held.id || payment.paymentHash.toLowerCase() !== held.paymentHash.toLowerCase());
3194
+ if (other) {
3195
+ throw new GatewayCheatError("hash_mismatch", payment.id);
3196
+ }
3197
+ if (payment.status === "paid" && !carriesProof(payment)) {
2807
3198
  throw new GatewayCheatError("preimage_mismatch", payment.id);
2808
3199
  }
2809
3200
  return payment;
@@ -2898,7 +3289,7 @@ var Gateways = class {
2898
3289
  * saying so. When they all end without a payment, the first ending is the answer,
2899
3290
  * and when they all fail, the first failure is thrown
2900
3291
  */
2901
- async settled(id, options) {
3292
+ async settled(held, options) {
2902
3293
  const stopLosers = new AbortController();
2903
3294
  const signal = options?.signal ? AbortSignal.any([stopLosers.signal, options.signal]) : stopLosers.signal;
2904
3295
  const unpaid = [];
@@ -2913,7 +3304,7 @@ var Gateways = class {
2913
3304
  }
2914
3305
  };
2915
3306
  for (const gateway of this.each) {
2916
- gateway.settled(id, { ...options, signal }).then((watched) => {
3307
+ gateway.settled(held, { ...options, signal }).then((watched) => {
2917
3308
  if (watched.status === "paid") {
2918
3309
  return resolve2(watched);
2919
3310
  }