thunder-bridge 1.5.0 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -39,6 +39,7 @@ __export(src_exports, {
39
39
  ThunderBridge: () => ThunderBridge,
40
40
  UnverifiedRecipientError: () => UnverifiedRecipientError,
41
41
  WrapRefusedError: () => WrapRefusedError,
42
+ agreesWithItself: () => agreesWithItself,
42
43
  answerVerifyChallenge: () => answerVerifyChallenge,
43
44
  carriesProof: () => carriesProof,
44
45
  decodeInvoice: () => decodeInvoice,
@@ -157,11 +158,11 @@ var BLOCK_BYTES = 64;
157
158
  var LENGTH_BYTES = 8;
158
159
  var SCHEDULE_WORDS = 64;
159
160
  function sha256(bytes) {
160
- const padded = withPadding(bytes);
161
- const reader = new DataView(padded.buffer);
161
+ const padded2 = withPadding(bytes);
162
+ const reader = new DataView(padded2.buffer);
162
163
  const state = Uint32Array.from(INITIAL_STATE);
163
164
  const schedule = new Uint32Array(SCHEDULE_WORDS);
164
- for (let block = 0; block < padded.length; block += BLOCK_BYTES) {
165
+ for (let block = 0; block < padded2.length; block += BLOCK_BYTES) {
165
166
  fillSchedule(schedule, reader, block);
166
167
  compress(state, schedule);
167
168
  }
@@ -171,13 +172,13 @@ function sha256Hex(text3) {
171
172
  return bytesToHex(sha256(new TextEncoder().encode(text3)));
172
173
  }
173
174
  function withPadding(bytes) {
174
- const padded = new Uint8Array(
175
+ const padded2 = new Uint8Array(
175
176
  bytes.length + 1 + LENGTH_BYTES + BLOCK_BYTES - 1 & ~(BLOCK_BYTES - 1)
176
177
  );
177
- padded.set(bytes);
178
- padded[bytes.length] = 128;
179
- new DataView(padded.buffer).setUint32(padded.length - 4, bytes.length * 8, false);
180
- return padded;
178
+ padded2.set(bytes);
179
+ padded2[bytes.length] = 128;
180
+ new DataView(padded2.buffer).setUint32(padded2.length - 4, bytes.length * 8, false);
181
+ return padded2;
181
182
  }
182
183
  function fillSchedule(schedule, reader, block) {
183
184
  for (let at = 0; at < 16; at++) {
@@ -234,6 +235,7 @@ function rotate(value, by) {
234
235
 
235
236
  // ../core/bolt11.ts
236
237
  var BECH32_CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l";
238
+ var BECH32_GENERATOR = [996825010, 642813549, 513874426, 1027748829, 705979059];
237
239
  var CHECKSUM_WORDS = 6;
238
240
  var TIMESTAMP_WORDS = 7;
239
241
  var SIGNATURE_WORDS = 104;
@@ -251,18 +253,27 @@ var HRP_MULTIPLIER_MSAT = {
251
253
  n: MSAT_PER_BTC / 1e9,
252
254
  p: MSAT_PER_BTC / 1e12
253
255
  };
254
- var HRP_AMOUNT = /^ln[a-z]+?(\d+)([munp]?)$/;
256
+ var HRP = /^ln(?:bcrt|bc|tbs|tb|sb)(?:(\d+)([munp]?))?$/;
255
257
  function decodeInvoice(bolt11) {
256
258
  const parts = splitBech32(bolt11);
257
259
  if (parts === null) {
258
- return { paymentHash: null, descriptionHash: null, amountMsat: null, expiresAt: null };
260
+ return {
261
+ paymentHash: null,
262
+ descriptionHash: null,
263
+ amountMsat: null,
264
+ issuedAt: null,
265
+ expiresAt: null
266
+ };
259
267
  }
260
268
  const tagged = taggedFields(parts.words);
269
+ const issuedAt = issuedAtOf(parts.words);
270
+ const expiry = tagged.get(EXPIRY_TAG);
261
271
  return {
262
272
  paymentHash: hexTag(tagged, PAYMENT_HASH_TAG, PAYMENT_HASH_WORDS),
263
273
  descriptionHash: hexTag(tagged, DESCRIPTION_HASH_TAG, DESCRIPTION_HASH_WORDS),
264
274
  amountMsat: amountFromHrp(parts.hrp),
265
- expiresAt: expiryOf(parts.words, tagged)
275
+ issuedAt,
276
+ expiresAt: issuedAt === null ? null : issuedAt + (expiry ? readNumber(expiry) : DEFAULT_EXPIRY_SECS)
266
277
  };
267
278
  }
268
279
  function preimageMatchesHash(preimage, paymentHash) {
@@ -273,7 +284,7 @@ function preimageMatchesHash(preimage, paymentHash) {
273
284
  }
274
285
  function splitBech32(bolt11) {
275
286
  const lower = bolt11.toLowerCase();
276
- if (isBolt12Encoding(lower)) {
287
+ if (isBolt12Encoding(lower) || bolt11 !== lower && bolt11 !== bolt11.toUpperCase()) {
277
288
  return null;
278
289
  }
279
290
  const separator = lower.lastIndexOf("1");
@@ -292,7 +303,28 @@ function splitBech32(bolt11) {
292
303
  }
293
304
  words.push(value);
294
305
  }
295
- return { hrp: lower.slice(0, separator), words: words.slice(0, -CHECKSUM_WORDS) };
306
+ const hrp = lower.slice(0, separator);
307
+ if (!HRP.test(hrp) || bech32Polymod([...expandedHrp(hrp), ...words]) !== 1) {
308
+ return null;
309
+ }
310
+ return { hrp, words: words.slice(0, -CHECKSUM_WORDS) };
311
+ }
312
+ function expandedHrp(hrp) {
313
+ const codes = [...hrp].map((char) => char.charCodeAt(0));
314
+ return [...codes.map((code) => code >> 5), 0, ...codes.map((code) => code & 31)];
315
+ }
316
+ function bech32Polymod(values) {
317
+ let checksum = 1;
318
+ for (const value of values) {
319
+ const top = checksum >> 25;
320
+ checksum = (checksum & 33554431) << 5 ^ value;
321
+ for (let bit = 0; bit < 5; bit++) {
322
+ if (top >> bit & 1) {
323
+ checksum ^= BECH32_GENERATOR[bit];
324
+ }
325
+ }
326
+ }
327
+ return checksum;
296
328
  }
297
329
  function isBolt12Encoding(lower) {
298
330
  return lower.startsWith("lno") || lower.startsWith("lni") || lower.startsWith("lnr");
@@ -322,19 +354,17 @@ function hexTag(fields, tag, expectedWords) {
322
354
  }
323
355
  return bytesToHex(wordsToBytes(words));
324
356
  }
325
- function expiryOf(words, fields) {
357
+ function issuedAtOf(words) {
326
358
  if (words.length < TIMESTAMP_WORDS + SIGNATURE_WORDS) {
327
359
  return null;
328
360
  }
329
- const expiry = fields.get(EXPIRY_TAG);
330
- return readNumber(words.slice(0, TIMESTAMP_WORDS)) + (expiry ? readNumber(expiry) : DEFAULT_EXPIRY_SECS);
361
+ return readNumber(words.slice(0, TIMESTAMP_WORDS));
331
362
  }
332
363
  function amountFromHrp(hrp) {
333
- const match = HRP_AMOUNT.exec(hrp);
334
- if (match === null) {
364
+ const [, digits, multiplier] = HRP.exec(hrp) ?? [];
365
+ if (digits === void 0 || multiplier === "p" && !digits.endsWith("0")) {
335
366
  return null;
336
367
  }
337
- const [, digits, multiplier] = match;
338
368
  return multiplier ? Number(digits) * HRP_MULTIPLIER_MSAT[multiplier] : Number(digits) * MSAT_PER_BTC;
339
369
  }
340
370
  function readNumber(words) {
@@ -355,49 +385,148 @@ function wordsToBytes(words) {
355
385
  return Uint8Array.from(bytes);
356
386
  }
357
387
 
388
+ // ../core/hmac.ts
389
+ async function hmacHex(secret, payload) {
390
+ const raw = typeof secret === "string" ? new TextEncoder().encode(secret) : secret;
391
+ const key = await crypto.subtle.importKey(
392
+ "raw",
393
+ asBuffer(raw),
394
+ { name: "HMAC", hash: "SHA-256" },
395
+ false,
396
+ ["sign"]
397
+ );
398
+ const message = typeof payload === "string" ? new TextEncoder().encode(payload) : payload;
399
+ return bytesToHex(new Uint8Array(await crypto.subtle.sign("HMAC", key, asBuffer(message))));
400
+ }
401
+ function equalInConstantTime(one, other) {
402
+ if (one.length !== other.length) {
403
+ return false;
404
+ }
405
+ let difference = 0;
406
+ for (let at = 0; at < one.length; at++) {
407
+ difference |= one.charCodeAt(at) ^ other.charCodeAt(at);
408
+ }
409
+ return difference === 0;
410
+ }
411
+ function asBuffer(bytes) {
412
+ return bytes instanceof Uint8Array && bytes.buffer instanceof ArrayBuffer ? bytes : new Uint8Array(bytes);
413
+ }
414
+
358
415
  // ../core/sealed.ts
359
- var VERSION = "v1";
416
+ var VERSION = "v2";
417
+ var UNPADDED_VERSION = "v1";
418
+ var PURPOSE_VERSION = "v3";
360
419
  var IV_BYTES = 12;
361
420
  var MIN_SECRET_CHARS = 32;
362
421
  var MAX_PLAIN_BYTES = 3e3;
363
- 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}`);
368
- }
369
- 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));
372
- const joined = new Uint8Array(iv.length + cipher.length);
373
- joined.set(iv);
374
- joined.set(cipher, iv.length);
375
- return `${VERSION}.${toBase64Url(joined)}`;
376
- }
377
- async function unseal(secret, sealed) {
378
- const key = await keyFor(secret);
379
- if (!sealed.startsWith(`${VERSION}.`)) {
422
+ var SIZE_CLASSES = [256, 1024, MAX_PLAIN_BYTES];
423
+ var END_OF_TEXT = 128;
424
+ var INFO = "thunder-bridge/sealed";
425
+ async function seal(secret, plaintext, paymentHash) {
426
+ return await sealUnder(
427
+ await keyFor(secret, INFO),
428
+ plaintext,
429
+ crypto.getRandomValues(new Uint8Array(IV_BYTES)),
430
+ VERSION,
431
+ boundTo(paymentHash)
432
+ );
433
+ }
434
+ async function sealFor(purpose, secret, plaintext, { stable = false } = {}) {
435
+ return await sealUnder(
436
+ await keyFor(secret, `${INFO}/${purpose}`),
437
+ plaintext,
438
+ stable ? await nonceOf(secret, plaintext) : crypto.getRandomValues(new Uint8Array(IV_BYTES)),
439
+ PURPOSE_VERSION,
440
+ new TextEncoder().encode(PURPOSE_VERSION)
441
+ );
442
+ }
443
+ async function unsealFor(purpose, secret, sealed) {
444
+ if (!sealed.startsWith(`${PURPOSE_VERSION}.`)) {
445
+ return await unseal(secret, sealed);
446
+ }
447
+ return await opened(
448
+ await keyFor(secret, `${INFO}/${purpose}`),
449
+ sealed.slice(PURPOSE_VERSION.length + 1),
450
+ new TextEncoder().encode(PURPOSE_VERSION)
451
+ );
452
+ }
453
+ async function nonceOf(secret, plaintext) {
454
+ const derived = await hmacHex(secret, `sealed-nonce|${plaintext}`);
455
+ const nonce = new Uint8Array(IV_BYTES);
456
+ for (let at = 0; at < IV_BYTES; at++) {
457
+ nonce[at] = Number.parseInt(derived.slice(at * 2, at * 2 + 2), 16);
458
+ }
459
+ return nonce;
460
+ }
461
+ async function sealUnder(key, plaintext, iv, version, additionalData) {
462
+ const body = padToClass(plaintext);
463
+ const cipher = new Uint8Array(
464
+ await crypto.subtle.encrypt({ name: "AES-GCM", iv, additionalData }, key, body)
465
+ );
466
+ const joined2 = new Uint8Array(iv.length + cipher.length);
467
+ joined2.set(iv);
468
+ joined2.set(cipher, iv.length);
469
+ return `${version}.${toBase64Url(joined2)}`;
470
+ }
471
+ async function unseal(secret, sealed, paymentHash) {
472
+ const key = await keyFor(secret, INFO);
473
+ const padded2 = sealed.startsWith(`${VERSION}.`);
474
+ const unbound = !padded2 && sealed.startsWith(`${UNPADDED_VERSION}.`);
475
+ if (!padded2 && (!unbound || paymentHash !== void 0)) {
380
476
  return null;
381
477
  }
382
- const joined = fromBase64Url(sealed.slice(VERSION.length + 1));
383
- if (joined === null || joined.length <= IV_BYTES) {
478
+ return await opened(
479
+ key,
480
+ sealed.slice(VERSION.length + 1),
481
+ padded2 ? boundTo(paymentHash) : void 0
482
+ );
483
+ }
484
+ async function opened(key, encoded, additionalData) {
485
+ const joined2 = fromBase64Url(encoded);
486
+ if (joined2 === null || joined2.length <= IV_BYTES) {
384
487
  return null;
385
488
  }
386
489
  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)
490
+ const body = new Uint8Array(
491
+ await crypto.subtle.decrypt(
492
+ { name: "AES-GCM", iv: joined2.slice(0, IV_BYTES), additionalData },
493
+ key,
494
+ joined2.slice(IV_BYTES)
495
+ )
391
496
  );
392
- return new TextDecoder().decode(body);
497
+ const text3 = additionalData === void 0 ? body : unpad(body);
498
+ return text3 === null ? null : new TextDecoder().decode(text3);
393
499
  } catch {
394
500
  return null;
395
501
  }
396
502
  }
397
- async function keyFor(secret) {
503
+ function refuseAWeakSecret(secret) {
398
504
  if (secret.length < MIN_SECRET_CHARS) {
399
505
  throw new Error(`the sealing secret needs ${MIN_SECRET_CHARS} characters of randomness`);
400
506
  }
507
+ }
508
+ function boundTo(paymentHash) {
509
+ return new TextEncoder().encode(
510
+ paymentHash === void 0 ? VERSION : `${VERSION}|${paymentHash.toLowerCase()}`
511
+ );
512
+ }
513
+ function padToClass(plaintext) {
514
+ const text3 = new TextEncoder().encode(plaintext);
515
+ const size = SIZE_CLASSES.find((limit) => text3.length <= limit);
516
+ if (size === void 0) {
517
+ throw new Error(`sealed takes at most ${MAX_PLAIN_BYTES} bytes, this was ${text3.length}`);
518
+ }
519
+ const body = new Uint8Array(size + 1);
520
+ body.set(text3);
521
+ body[text3.length] = END_OF_TEXT;
522
+ return body;
523
+ }
524
+ function unpad(body) {
525
+ const end = body.lastIndexOf(END_OF_TEXT);
526
+ return end === -1 ? null : body.subarray(0, end);
527
+ }
528
+ async function keyFor(secret, info) {
529
+ refuseAWeakSecret(secret);
401
530
  const material = await crypto.subtle.importKey(
402
531
  "raw",
403
532
  new TextEncoder().encode(secret),
@@ -406,7 +535,12 @@ async function keyFor(secret) {
406
535
  ["deriveKey"]
407
536
  );
408
537
  return crypto.subtle.deriveKey(
409
- { name: "HKDF", hash: "SHA-256", salt: new Uint8Array(0), info: INFO },
538
+ {
539
+ name: "HKDF",
540
+ hash: "SHA-256",
541
+ salt: new Uint8Array(0),
542
+ info: new TextEncoder().encode(info)
543
+ },
410
544
  material,
411
545
  { name: "AES-GCM", length: 256 },
412
546
  false,
@@ -594,11 +728,11 @@ function coinbase(baseUrl = "https://api.coinbase.com") {
594
728
  return async (currency) => {
595
729
  const asked = currency.toUpperCase();
596
730
  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}`);
731
+ const quoted2 = answer.data;
732
+ if (quoted2?.currency !== asked) {
733
+ throw new Error(`coinbase quoted ${quoted2?.currency} when asked for ${asked}`);
600
734
  }
601
- return asMinor(quoted.amount, "coinbase", asked);
735
+ return asMinor(quoted2.amount, "coinbase", asked);
602
736
  };
603
737
  }
604
738
  function kraken(baseUrl = "https://api.kraken.com") {
@@ -647,19 +781,19 @@ function medianOf(tickers = [coinbase(), kraken(), bitstamp(), coinmate()], opti
647
781
  return remembered.price;
648
782
  }
649
783
  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) {
784
+ const quoted2 = answers.filter((answer) => answer.status === "fulfilled").map((answer) => answer.value).sort((one, other) => one - other);
785
+ if (quoted2.length < minVenues) {
652
786
  throw new Error(
653
- `${quoted.length} of ${tickers.length} venues quoted ${asked}, ${minVenues} wanted: ${refusals(answers)}`
787
+ `${quoted2.length} of ${tickers.length} venues quoted ${asked}, ${minVenues} wanted: ${refusals(answers)}`
654
788
  );
655
789
  }
656
- const spread2 = spreadBpsOf(quoted);
790
+ const spread2 = spreadBpsOf(quoted2);
657
791
  if (spread2 > maxSpread) {
658
792
  throw new Error(
659
- `venues disagree on ${asked} by ${Math.round(spread2)} bps, more than the ${maxSpread} allowed: ${quoted.join(", ")}`
793
+ `venues disagree on ${asked} by ${Math.round(spread2)} bps, more than the ${maxSpread} allowed: ${quoted2.join(", ")}`
660
794
  );
661
795
  }
662
- const price = middleOf(quoted);
796
+ const price = middleOf(quoted2);
663
797
  held.set(asked, { at: Date.now(), price });
664
798
  return price;
665
799
  };
@@ -690,10 +824,10 @@ async function asJson(url, venue) {
690
824
  }
691
825
  return answer.json();
692
826
  }
693
- function asMinor(quoted, venue, currency) {
694
- const major2 = Number(quoted);
827
+ function asMinor(quoted2, venue, currency) {
828
+ const major2 = Number(quoted2);
695
829
  if (!Number.isFinite(major2) || major2 <= 0) {
696
- throw new Error(`${venue} quoted ${String(quoted)} for ${currency.toUpperCase()}`);
830
+ throw new Error(`${venue} quoted ${String(quoted2)} for ${currency.toUpperCase()}`);
697
831
  }
698
832
  return Math.round(major2 * minorScaleOf(currency));
699
833
  }
@@ -781,6 +915,16 @@ var SEED_BYTES = 32;
781
915
  var SIGNATURE_BYTES = 64;
782
916
  var PUBLIC_KEY_BYTES = 32;
783
917
  var PKCS8_HEADER = hexToBytes("302e020100300506032b657004220420");
918
+ var SIGN_BIT_CLEARED = 127;
919
+ var SMALL_ORDER = [
920
+ "00".repeat(32),
921
+ `01${"00".repeat(31)}`,
922
+ "26e8958fc2b227b045c3f489f2ef98f0d5dfac05d3c63339b13802886d53fc05",
923
+ "c7176a703d4dd84fba3c0b760d10670f2a2053fa2c39ccc64ec7fd7792ac037a",
924
+ `ec${"ff".repeat(30)}7f`,
925
+ `ed${"ff".repeat(30)}7f`,
926
+ `ee${"ff".repeat(30)}7f`
927
+ ];
784
928
  async function signingKeyFromSeed(seed) {
785
929
  if (seed.length !== SEED_BYTES) {
786
930
  throw new Error(`an ed25519 seed is ${SEED_BYTES} bytes`);
@@ -788,24 +932,27 @@ async function signingKeyFromSeed(seed) {
788
932
  const wrapped = new Uint8Array(PKCS8_HEADER.length + SEED_BYTES);
789
933
  wrapped.set(PKCS8_HEADER);
790
934
  wrapped.set(seed, PKCS8_HEADER.length);
791
- const key = await crypto.subtle.importKey("pkcs8", asBuffer(wrapped), ALGORITHM, true, ["sign"]);
935
+ const key = await crypto.subtle.importKey("pkcs8", asBuffer2(wrapped), ALGORITHM, true, ["sign"]);
792
936
  const jwk = await crypto.subtle.exportKey("jwk", key);
793
937
  return {
794
938
  publicKeyHex: bytesToHex(fromBase64Url2(jwk.x ?? "")),
795
- sign: async (payload) => bytesToHex(new Uint8Array(await crypto.subtle.sign(ALGORITHM, key, asBuffer(payload))))
939
+ sign: async (payload) => bytesToHex(new Uint8Array(await crypto.subtle.sign(ALGORITHM, key, asBuffer2(payload))))
796
940
  };
797
941
  }
798
942
  async function verifyHex(publicKeyHex, signatureHex, payload) {
799
943
  if (!isHex(publicKeyHex) || publicKeyHex.length !== PUBLIC_KEY_BYTES * 2) {
800
944
  return false;
801
945
  }
946
+ if (hasSmallOrder(publicKeyHex)) {
947
+ return false;
948
+ }
802
949
  if (!isHex(signatureHex) || signatureHex.length !== SIGNATURE_BYTES * 2) {
803
950
  return false;
804
951
  }
805
952
  try {
806
953
  const key = await crypto.subtle.importKey(
807
954
  "raw",
808
- asBuffer(hexToBytes(publicKeyHex)),
955
+ asBuffer2(hexToBytes(publicKeyHex)),
809
956
  ALGORITHM,
810
957
  false,
811
958
  ["verify"]
@@ -813,58 +960,38 @@ async function verifyHex(publicKeyHex, signatureHex, payload) {
813
960
  return await crypto.subtle.verify(
814
961
  ALGORITHM,
815
962
  key,
816
- asBuffer(hexToBytes(signatureHex)),
817
- asBuffer(payload)
963
+ asBuffer2(hexToBytes(signatureHex)),
964
+ asBuffer2(payload)
818
965
  );
819
966
  } catch {
820
967
  return false;
821
968
  }
822
969
  }
970
+ function hasSmallOrder(publicKeyHex) {
971
+ const point = hexToBytes(publicKeyHex);
972
+ point[PUBLIC_KEY_BYTES - 1] = point[PUBLIC_KEY_BYTES - 1] & SIGN_BIT_CLEARED;
973
+ return SMALL_ORDER.includes(bytesToHex(point));
974
+ }
823
975
  function fromBase64Url2(text3) {
824
- const padded = text3.replace(/-/g, "+").replace(/_/g, "/");
825
- const raw = atob(padded + "=".repeat((4 - padded.length % 4) % 4));
976
+ const padded2 = text3.replace(/-/g, "+").replace(/_/g, "/");
977
+ const raw = atob(padded2 + "=".repeat((4 - padded2.length % 4) % 4));
826
978
  const bytes = new Uint8Array(raw.length);
827
979
  for (let at = 0; at < raw.length; at++) {
828
980
  bytes[at] = raw.charCodeAt(at);
829
981
  }
830
982
  return bytes;
831
983
  }
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
984
  function asBuffer2(bytes) {
860
- return bytes instanceof Uint8Array && bytes.buffer instanceof ArrayBuffer ? bytes : new Uint8Array(bytes);
985
+ return bytes.buffer instanceof ArrayBuffer ? bytes : new Uint8Array(bytes);
861
986
  }
862
987
 
863
988
  // ../core/caller.ts
864
989
  var KEY_HEADER = "x-client-key";
865
990
  var SIGNATURE_HEADER = "x-signature";
866
991
  var TIMESTAMP_HEADER = "x-timestamp";
992
+ var NONCE_HEADER = "x-nonce";
867
993
  var PREFIX = "ed25519=";
994
+ var NONCE_BYTES = 16;
868
995
  var SIGNING_LABEL = "request-signing";
869
996
  async function callerKey(secret) {
870
997
  return await signingKeyFromSeed(hexToBytes(await hmacHex(secret, SIGNING_LABEL)));
@@ -872,17 +999,21 @@ async function callerKey(secret) {
872
999
  function paymentNamedBy(caller, paymentHash) {
873
1000
  return sha256Hex(`${caller}.payment-id.${paymentHash}`);
874
1001
  }
875
- async function signedAs(key, method, path, body) {
1002
+ async function signedAs(key, method, path, body, host) {
876
1003
  const timestamp = String(Math.floor(Date.now() / 1e3));
1004
+ const nonce = bytesToHex(crypto.getRandomValues(new Uint8Array(NONCE_BYTES)));
877
1005
  return {
878
1006
  [KEY_HEADER]: key.publicKeyHex,
879
1007
  [TIMESTAMP_HEADER]: timestamp,
880
- [SIGNATURE_HEADER]: `${PREFIX}${await key.sign(spoken(timestamp, method, path, body))}`
1008
+ [NONCE_HEADER]: nonce,
1009
+ [SIGNATURE_HEADER]: `${PREFIX}${await key.sign(spokenTo(host, timestamp, nonce, method, path, body))}`
881
1010
  };
882
1011
  }
883
- function spoken(timestamp, method, path, body) {
1012
+ function spokenTo(host, timestamp, nonce, method, path, body) {
884
1013
  return new TextEncoder().encode(
885
- `${timestamp}.${method.toUpperCase()}.${path}.${sha256Hex(body)}`
1014
+ ["v2", host.toLowerCase(), timestamp, nonce, method.toUpperCase(), path, sha256Hex(body)].join(
1015
+ "\n"
1016
+ )
886
1017
  );
887
1018
  }
888
1019
 
@@ -935,8 +1066,8 @@ function ipv4IsGlobal(literal) {
935
1066
  if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) {
936
1067
  return false;
937
1068
  }
938
- const [a, b] = parts;
939
- if (a === 0 || a === 10 || a === 127 || a >= 240) {
1069
+ const [a, b, c] = parts;
1070
+ if (a === 0 || a === 10 || a === 127 || a >= 224) {
940
1071
  return false;
941
1072
  }
942
1073
  if (a === 100 && b >= 64 && b < 128) {
@@ -948,7 +1079,13 @@ function ipv4IsGlobal(literal) {
948
1079
  if (a === 172 && b >= 16 && b < 32) {
949
1080
  return false;
950
1081
  }
951
- if (a === 192 && b === 168) {
1082
+ if (a === 192 && (b === 168 || b === 0 && (c === 0 || c === 2))) {
1083
+ return false;
1084
+ }
1085
+ if (a === 198 && (b === 18 || b === 19 || b === 51 && c === 100)) {
1086
+ return false;
1087
+ }
1088
+ if (a === 203 && b === 0 && c === 113) {
952
1089
  return false;
953
1090
  }
954
1091
  return true;
@@ -958,29 +1095,59 @@ function ipv6IsGlobal(literal) {
958
1095
  if (groups.length !== 8 || groups.some(Number.isNaN)) {
959
1096
  return false;
960
1097
  }
961
- const leading = groups.slice(0, 5).every((group) => group === 0);
962
- if (leading && groups[5] === 0 && groups[6] === 0 && groups[7] <= 1) {
1098
+ const [first, second, third, fourth, fifth, sixth, high, low] = groups;
1099
+ const embedded = (one, other) => ipv4IsGlobal(`${one >> 8}.${one & 255}.${other >> 8}.${other & 255}`);
1100
+ const zeroBefore = (count) => groups.slice(0, count).every((group) => group === 0);
1101
+ if (zeroBefore(5) && sixth === 65535) {
1102
+ return embedded(high, low);
1103
+ }
1104
+ if (zeroBefore(6) || zeroBefore(4) && fifth === 65535 && sixth === 0) {
1105
+ return false;
1106
+ }
1107
+ if (first === 100 && second === 65435) {
1108
+ return third === 0 && fourth === 0 && fifth === 0 && sixth === 0 && embedded(high, low);
1109
+ }
1110
+ if (first === 8194) {
1111
+ return embedded(second, third);
1112
+ }
1113
+ if (first === 8193 && second === 0) {
963
1114
  return false;
964
1115
  }
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}`);
1116
+ if (first === 256 && second === 0 && third === 0 && fourth === 0) {
1117
+ return false;
968
1118
  }
969
- const first = groups[0];
970
- if ((first & 65024) === 64512) {
1119
+ if ((first & 65024) === 64512 || (first & 65280) === 65280) {
971
1120
  return false;
972
1121
  }
973
- if ((first & 65472) === 65152) {
1122
+ if ((first & 65472) === 65152 || (first & 65472) === 65216) {
974
1123
  return false;
975
1124
  }
976
1125
  return true;
977
1126
  }
978
1127
  function ipv6Groups(address) {
979
- const [head = "", tail] = address.split("::");
1128
+ const halves = withDottedTailAsGroups(address).split("::");
1129
+ const [head = "", tail] = halves;
980
1130
  const left = head.split(":").filter((group) => group !== "");
981
1131
  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));
1132
+ const missing = 8 - left.length - right.length;
1133
+ if (halves.length > 2 || missing < 0) {
1134
+ return [Number.NaN];
1135
+ }
1136
+ const gap = tail === void 0 ? [] : Array(missing).fill("0");
1137
+ return [...left, ...gap, ...right].map(
1138
+ (group) => /^[0-9a-f]{1,4}$/.test(group) ? parseInt(group, 16) : Number.NaN
1139
+ );
1140
+ }
1141
+ function withDottedTailAsGroups(address) {
1142
+ const dotted = /^(.*:)(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/.exec(address);
1143
+ if (dotted === null) {
1144
+ return address;
1145
+ }
1146
+ const [a, b, c, d] = dotted[2].split(".").map(Number);
1147
+ if ([a, b, c, d].some((part) => part > 255)) {
1148
+ return `${dotted[1]}x`;
1149
+ }
1150
+ return `${dotted[1]}${(a << 8 | b).toString(16)}:${(c << 8 | d).toString(16)}`;
984
1151
  }
985
1152
 
986
1153
  // ../core/outbound.ts
@@ -1008,6 +1175,9 @@ async function ask(send, url, sent = {}) {
1008
1175
  }
1009
1176
  await response.body?.cancel();
1010
1177
  const next = new URL(location, target).toString();
1178
+ if (sent.staysOnOrigin && !sameOrigin(target, next)) {
1179
+ throw new Error(`${target} redirected off its own origin, to ${next}`);
1180
+ }
1011
1181
  carried = KEEPS_THE_METHOD.includes(response.status) ? carried : withoutBody(carried);
1012
1182
  carried = sameOrigin(target, next) ? carried : withoutCredentials(carried);
1013
1183
  target = next;
@@ -1025,14 +1195,39 @@ async function addressesToReach(url) {
1025
1195
  return found;
1026
1196
  }
1027
1197
  async function resolved(url) {
1028
- const { lookup } = await import("dns/promises");
1029
- const { setTimeout: sleep } = await import("timers/promises");
1198
+ const { isIP } = await import("net");
1030
1199
  const host = new URL(url).hostname.replace(/^\[|]$/g, "");
1200
+ const literal = isIP(host);
1201
+ if (literal !== 0) {
1202
+ return [{ address: host, family: literal }];
1203
+ }
1204
+ const { Resolver } = await import("dns/promises");
1205
+ const { setTimeout: sleep } = await import("timers/promises");
1206
+ const resolver = new Resolver({ timeout: LOOKUP_TIMEOUT_MS, tries: 1 });
1031
1207
  const found = await Promise.race([
1032
- lookup(host, { all: true, verbatim: true }).catch(() => []),
1208
+ Promise.all([
1209
+ resolver.resolve4(host).catch(() => []),
1210
+ resolver.resolve6(host).catch(() => [])
1211
+ ]),
1033
1212
  sleep(LOOKUP_TIMEOUT_MS, null, { ref: false })
1034
1213
  ]);
1035
- return found ?? [];
1214
+ cancelled(resolver);
1215
+ if (found === null) {
1216
+ return [];
1217
+ }
1218
+ const [four, six] = found;
1219
+ return [
1220
+ ...four.map((address) => ({ address, family: 4 })),
1221
+ ...six.map((address) => ({ address, family: 6 }))
1222
+ ];
1223
+ }
1224
+ function cancelled(resolver) {
1225
+ try {
1226
+ resolver.cancel();
1227
+ return true;
1228
+ } catch {
1229
+ return false;
1230
+ }
1036
1231
  }
1037
1232
  async function answerOf(response) {
1038
1233
  const { status, ok, headers } = response;
@@ -1054,15 +1249,17 @@ async function answerOf(response) {
1054
1249
  return { status, ok, body: text(read), truncated: true, headers };
1055
1250
  }
1056
1251
  function withoutBody(sent) {
1057
- const headers = { ...sent.headers };
1058
- delete headers["content-type"];
1252
+ const headers = Object.fromEntries(
1253
+ Object.entries(sent.headers ?? {}).filter(([name]) => name.toLowerCase() !== "content-type")
1254
+ );
1059
1255
  return { ...sent, method: "GET", headers, body: void 0 };
1060
1256
  }
1061
1257
  function withoutCredentials(sent) {
1062
- const headers = { ...sent.headers };
1063
- for (const named of CREDENTIALS) {
1064
- delete headers[named];
1065
- }
1258
+ const headers = Object.fromEntries(
1259
+ Object.entries(sent.headers ?? {}).filter(
1260
+ ([name]) => !CREDENTIALS.includes(name.toLowerCase())
1261
+ )
1262
+ );
1066
1263
  return { ...sent, headers };
1067
1264
  }
1068
1265
  function text(chunks) {
@@ -1105,6 +1302,22 @@ async function resolve(send, addresses, amountMsat) {
1105
1302
  );
1106
1303
  return served.won;
1107
1304
  }
1305
+ async function quote(send, addresses, amountMsat) {
1306
+ const served = await firstThatServes(
1307
+ addresses,
1308
+ (address2, deadline) => probe(send, address2, amountMsat, deadline)
1309
+ );
1310
+ const { address, pay } = served.won;
1311
+ return {
1312
+ won: {
1313
+ address,
1314
+ minMsat: pay.minSendable,
1315
+ maxMsat: pay.maxSendable,
1316
+ metadata: pay.metadata
1317
+ },
1318
+ refusals: served.refusals
1319
+ };
1320
+ }
1108
1321
  async function firstThatServes(addresses, attempt) {
1109
1322
  const deadline = AbortSignal.timeout(RESOLVE_TIMEOUT_MS);
1110
1323
  const refusals3 = [];
@@ -1197,7 +1410,7 @@ function decodeIssued(address, bolt11) {
1197
1410
  return { paymentHash, descriptionHash, amountMsat, expiresAt };
1198
1411
  }
1199
1412
  async function checkSettled(send, verifyUrl, paymentHash) {
1200
- const answer = await answeredJson(send, verifyUrl);
1413
+ const answer = await answeredJson(send, verifyUrl, { staysOnOrigin: true });
1201
1414
  const asked = {
1202
1415
  pace: paceAskedFor(answer.headers),
1203
1416
  ceiling: ceilingAskedFor(answer.headers)
@@ -1255,10 +1468,10 @@ function isNothingButAHost(domain) {
1255
1468
  }
1256
1469
  }
1257
1470
  async function fetchJson(send, url, deadline) {
1258
- return (await answeredJson(send, url, deadline)).said;
1471
+ return (await answeredJson(send, url, { deadline })).said;
1259
1472
  }
1260
- async function answeredJson(send, url, deadline) {
1261
- const answer = await ask(send, url, { headers: { accept: "application/json" }, deadline });
1473
+ async function answeredJson(send, url, sent) {
1474
+ const answer = await ask(send, url, { headers: { accept: "application/json" }, ...sent });
1262
1475
  if (!answer.ok) {
1263
1476
  throw new Error(`${url} answered ${answer.status}`);
1264
1477
  }
@@ -1286,12 +1499,17 @@ var pinnedToTheAddressWeVerified = async (url, sent, signal, at) => {
1286
1499
  }
1287
1500
  const call = request(reaching(asked, sent, at, first), (answer) => {
1288
1501
  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
- );
1502
+ try {
1503
+ settle(
1504
+ new Response(
1505
+ CARRIES_NO_BODY.includes(status) ? null : Readable.toWeb(answer),
1506
+ { status, headers: headersOf(answer.headers) }
1507
+ )
1508
+ );
1509
+ } catch (unreadable) {
1510
+ answer.destroy();
1511
+ fail(new Error(`${url} answered with something HTTP cannot carry`, { cause: unreadable }));
1512
+ }
1295
1513
  });
1296
1514
  const give = () => call.destroy(new Error(`${url} took longer than allowed`));
1297
1515
  signal.addEventListener("abort", give, { once: true });
@@ -1338,6 +1556,182 @@ function headersOf(raw) {
1338
1556
  return headers;
1339
1557
  }
1340
1558
 
1559
+ // ../core/delivery.ts
1560
+ function deliverySigned(url, timestamp, body) {
1561
+ const endpoint = new URL(url);
1562
+ return new TextEncoder().encode(
1563
+ ["v2", `${endpoint.origin}${endpoint.pathname}`, timestamp, body].join("\n")
1564
+ );
1565
+ }
1566
+
1567
+ // src/verify.ts
1568
+ var HTTP_TIMEOUT_MS2 = 15e3;
1569
+ var ISSUED_SKEW_SECS = 300;
1570
+ var NAMES_AN_ADDRESS = ["text/identifier", "text/email"];
1571
+ var DEFAULT_WRAP_PROPORTION = 0.01;
1572
+ var DEFAULT_WRAP_BASE_MSAT = 1e3;
1573
+ async function proveOrigin(payment, asked, askedAt) {
1574
+ const cheat = (code) => new GatewayCheatError(code, payment.id);
1575
+ const listed2 = asked.paidTo.find((address) => equalIgnoringCase(address, payment.lnAddress));
1576
+ if (listed2 === void 0) {
1577
+ throw cheat("address_not_requested");
1578
+ }
1579
+ if (payment.amountMsat !== asked.amountMsat) {
1580
+ throw cheat("amount_mismatch");
1581
+ }
1582
+ const invoice = decodeInvoice(payment.bolt11);
1583
+ if (invoice.paymentHash === null || !equalIgnoringCase(invoice.paymentHash, payment.paymentHash)) {
1584
+ throw cheat("hash_mismatch");
1585
+ }
1586
+ if (invoice.amountMsat !== asked.amountMsat) {
1587
+ throw cheat("amount_mismatch");
1588
+ }
1589
+ if (askedAt !== void 0 && (invoice.issuedAt ?? 0) < askedAt - ISSUED_SKEW_SECS) {
1590
+ throw cheat("invoice_stale");
1591
+ }
1592
+ const payRequest = await payRequestFor(listed2, payment);
1593
+ if (typeof payRequest.metadata !== "string" || typeof payRequest.callback !== "string") {
1594
+ throw new UnverifiedRecipientError(payment.lnAddress, payment.id, "no payRequest served");
1595
+ }
1596
+ if (!namesTheAddress(payRequest.metadata, listed2)) {
1597
+ throw new UnverifiedRecipientError(
1598
+ payment.lnAddress,
1599
+ payment.id,
1600
+ "its metadata names no text/identifier for this address, so no invoice binds to it"
1601
+ );
1602
+ }
1603
+ if (invoice.descriptionHash !== sha256Hex(payRequest.metadata)) {
1604
+ throw cheat("description_hash_mismatch");
1605
+ }
1606
+ if (!sameOrigin(payment.verifyUrl, payRequest.callback)) {
1607
+ throw cheat("verify_url_foreign");
1608
+ }
1609
+ const issued = await reachable(payment.verifyUrl, payment);
1610
+ if (typeof issued.pr !== "string" || !equalIgnoringCase(issued.pr, payment.bolt11)) {
1611
+ throw cheat("invoice_not_issued");
1612
+ }
1613
+ if (askedAt !== void 0 && issued.settled === true) {
1614
+ throw cheat("invoice_settled");
1615
+ }
1616
+ }
1617
+ function namesTheAddress(metadata, address) {
1618
+ try {
1619
+ const entries = JSON.parse(metadata);
1620
+ return Array.isArray(entries) && entries.some(
1621
+ (entry) => Array.isArray(entry) && NAMES_AN_ADDRESS.includes(entry[0]) && typeof entry[1] === "string" && equalIgnoringCase(entry[1], address)
1622
+ );
1623
+ } catch {
1624
+ return false;
1625
+ }
1626
+ }
1627
+ async function proveSettlement(payment, asked) {
1628
+ await proveOrigin(payment, asked);
1629
+ const verified = await reachable(payment.verifyUrl, payment);
1630
+ if (verified.settled !== true || typeof verified.preimage !== "string") {
1631
+ return null;
1632
+ }
1633
+ if (!preimageMatchesHash(verified.preimage, payment.paymentHash)) {
1634
+ throw new GatewayCheatError("preimage_mismatch", payment.id);
1635
+ }
1636
+ return verified.preimage;
1637
+ }
1638
+ function agreesWithItself(report) {
1639
+ if (report.status !== "paid" || report.preimage === null) {
1640
+ return false;
1641
+ }
1642
+ if (report.bolt11 !== void 0 && report.bolt11 !== null) {
1643
+ const invoiceHash = decodeInvoice(report.bolt11).paymentHash;
1644
+ if (invoiceHash === null || !equalIgnoringCase(invoiceHash, report.paymentHash)) {
1645
+ return false;
1646
+ }
1647
+ }
1648
+ return preimageMatchesHash(report.preimage, report.paymentHash);
1649
+ }
1650
+ var carriesProof = agreesWithItself;
1651
+ function wrapFeeCeiling(amountMsat, allowance) {
1652
+ const proportional = amountMsat * (allowance?.proportion ?? DEFAULT_WRAP_PROPORTION);
1653
+ return msat(Math.max(allowance?.baseMsat ?? DEFAULT_WRAP_BASE_MSAT, Math.floor(proportional)));
1654
+ }
1655
+ function proveWrapped(wrapped, recipient, allowance) {
1656
+ const offered = decodeInvoice(wrapped);
1657
+ const owed = decodeInvoice(recipient);
1658
+ const refuse2 = (code, detail) => {
1659
+ throw new WrapRefusedError(code, detail);
1660
+ };
1661
+ if (offered.paymentHash === null || offered.amountMsat === null || offered.expiresAt === null) {
1662
+ refuse2("undecodable", "the wrapped invoice carries no hash, amount or expiry");
1663
+ }
1664
+ if (owed.paymentHash === null || owed.amountMsat === null || owed.expiresAt === null) {
1665
+ refuse2("undecodable", "the recipient's invoice carries no hash, amount or expiry");
1666
+ }
1667
+ if (offered.paymentHash?.toLowerCase() !== owed.paymentHash?.toLowerCase()) {
1668
+ refuse2("hash_mismatch", "settling the wrap would not settle the recipient");
1669
+ }
1670
+ const asked = offered.amountMsat ?? 0;
1671
+ const due = owed.amountMsat ?? 0;
1672
+ if (asked < due) {
1673
+ refuse2("amount_below_recipient", `${asked} msat cannot forward ${due} msat`);
1674
+ }
1675
+ const ceiling = wrapFeeCeiling(due, allowance);
1676
+ if (asked - due > ceiling) {
1677
+ refuse2("fee_above_allowance", `${asked - due} msat over ${due}, and ${ceiling} was allowed`);
1678
+ }
1679
+ if ((owed.expiresAt ?? 0) <= (offered.expiresAt ?? 0)) {
1680
+ refuse2("recipient_expires_first", "the operator could be paid and find it cannot deliver");
1681
+ }
1682
+ }
1683
+ async function payRequestFor(listed2, payment) {
1684
+ const at = listed2.indexOf("@");
1685
+ const name = listed2.slice(0, at);
1686
+ const domain = listed2.slice(at + 1).toLowerCase();
1687
+ if (at < 1 || !publicHttps(`https://${domain}/`)) {
1688
+ throw new UnverifiedRecipientError(payment.lnAddress, payment.id, "not a usable address");
1689
+ }
1690
+ return reachable(`https://${domain}/.well-known/lnurlp/${name}`, payment);
1691
+ }
1692
+ async function reachable(url, payment) {
1693
+ try {
1694
+ if (!publicHttps(url)) {
1695
+ throw new Error(`${url} is not a public https URL`);
1696
+ }
1697
+ const response = await fetch(url, {
1698
+ signal: AbortSignal.timeout(HTTP_TIMEOUT_MS2),
1699
+ headers: { accept: "application/json" }
1700
+ });
1701
+ if (response.redirected && !sameOrigin(response.url, url)) {
1702
+ throw new Error(`redirected off its own origin, to ${response.url}`);
1703
+ }
1704
+ if (!response.ok) {
1705
+ throw new Error(`answered ${response.status}`);
1706
+ }
1707
+ const body = JSON.parse(await boundedText(response));
1708
+ if (body === null || typeof body !== "object") {
1709
+ throw new Error("answered with no JSON object");
1710
+ }
1711
+ return body;
1712
+ } catch (cause) {
1713
+ throw new UnverifiedRecipientError(payment.lnAddress, payment.id, cause);
1714
+ }
1715
+ }
1716
+ async function boundedText(response) {
1717
+ const reader = response.body?.getReader();
1718
+ const decoder = new TextDecoder();
1719
+ let text3 = "";
1720
+ let bytes = 0;
1721
+ for (let chunk = await reader?.read(); chunk && !chunk.done; chunk = await reader?.read()) {
1722
+ bytes += chunk.value.length;
1723
+ if (bytes > BODY_LIMIT_BYTES) {
1724
+ await reader?.cancel();
1725
+ throw new Error(`answered with more than the ${BODY_LIMIT_BYTES} bytes a proof reads`);
1726
+ }
1727
+ text3 += decoder.decode(chunk.value, { stream: true });
1728
+ }
1729
+ return text3 + decoder.decode();
1730
+ }
1731
+ function equalIgnoringCase(one, other) {
1732
+ return one.toLowerCase() === other.toLowerCase();
1733
+ }
1734
+
1341
1735
  // src/wire.ts
1342
1736
  var ASSET_CODE = "BTC";
1343
1737
  var ASSET_SCALE = 11;
@@ -1535,7 +1929,7 @@ function isStatus(value) {
1535
1929
  }
1536
1930
 
1537
1931
  // src/webhook.ts
1538
- var SIGNATURE_HEADER2 = "x-signature";
1932
+ var SIGNATURE_HEADER2 = "x-signature-v2";
1539
1933
  var TIMESTAMP_HEADER2 = "x-timestamp";
1540
1934
  var GATEWAY_KEY_PREFIX = "ed25519=";
1541
1935
  var DEFAULT_TOLERANCE_SECS = 300;
@@ -1543,22 +1937,22 @@ var CHALLENGE = "webhook-challenge";
1543
1937
  var VERIFY_CHALLENGE = "verify-challenge";
1544
1938
  async function readPayment(request, credential, options = {}) {
1545
1939
  const body = await believable(request, credential, options);
1546
- return body === null ? null : decoded(body, paymentFromWire);
1940
+ return body === null ? null : proved(decoded(body, paymentFromWire));
1547
1941
  }
1548
1942
  async function readSettlement(request, credential, options = {}) {
1549
1943
  const body = await believable(request, credential, options);
1550
- return body === null ? null : decoded(body, settlementFromWire);
1944
+ return body === null ? null : proved(decoded(body, settlementFromWire));
1551
1945
  }
1552
1946
  async function answerWebhookChallenge(request, credential, options = {}) {
1553
1947
  const body = await believable(request, credential, options);
1554
- const nonce = body === null ? null : nonceOf(body, CHALLENGE);
1948
+ const nonce = body === null ? null : nonceOf2(body, CHALLENGE);
1555
1949
  return nonce === null ? null : answered2(nonce);
1556
1950
  }
1557
1951
  async function answerVerifyChallenge(request) {
1558
1952
  if (request.method !== "POST") {
1559
1953
  return null;
1560
1954
  }
1561
- const nonce = nonceOf(await request.clone().text(), VERIFY_CHALLENGE);
1955
+ const nonce = nonceOf2(await request.clone().text(), VERIFY_CHALLENGE);
1562
1956
  return nonce === null ? null : answered2(nonce);
1563
1957
  }
1564
1958
  async function believable(request, credential, options) {
@@ -1577,10 +1971,13 @@ async function believable(request, credential, options) {
1577
1971
  const signed = await verifyHex(
1578
1972
  credential.publicKey.toLowerCase(),
1579
1973
  signature.slice(GATEWAY_KEY_PREFIX.length).toLowerCase(),
1580
- stamped(timestamp, body)
1974
+ deliverySigned(options.url ?? request.url, timestamp, body)
1581
1975
  );
1582
1976
  return signed ? body : null;
1583
1977
  }
1978
+ function proved(read) {
1979
+ return read !== null && read.status === "paid" && !agreesWithItself(read) ? null : read;
1980
+ }
1584
1981
  function decoded(body, from) {
1585
1982
  try {
1586
1983
  return from(JSON.parse(body));
@@ -1593,7 +1990,7 @@ function answered2(nonce) {
1593
1990
  headers: { "content-type": "application/json" }
1594
1991
  });
1595
1992
  }
1596
- function nonceOf(text3, type) {
1993
+ function nonceOf2(text3, type) {
1597
1994
  try {
1598
1995
  const said = JSON.parse(text3);
1599
1996
  if (said["type"] !== type || typeof said["nonce"] !== "string") {
@@ -1611,9 +2008,6 @@ function recent(timestamp, toleranceSecs) {
1611
2008
  }
1612
2009
  return Math.abs(Math.floor(Date.now() / 1e3) - sent) <= toleranceSecs;
1613
2010
  }
1614
- function stamped(timestamp, body) {
1615
- return new TextEncoder().encode(`${timestamp}.${body}`);
1616
- }
1617
2011
 
1618
2012
  // src/bank.ts
1619
2013
  var DEFAULT_CURRENCY = "CZK";
@@ -1621,30 +2015,39 @@ var DEFAULT_LOOK_BACK_SECS = 7 * 24 * 60 * 60;
1621
2015
  var DEFAULT_POLL_EVERY_SECS = 30;
1622
2016
  var IBAN = /^[A-Z]{2}[0-9]{2}[0-9A-Z]{8,30}$/;
1623
2017
  var FORBIDDEN_IN_SPD = "*";
2018
+ var MAX_REFERENCE_CHARS = 60;
1624
2019
  async function bankTransfer(gateway, params) {
1625
2020
  const currency = params.currency ?? DEFAULT_CURRENCY;
1626
- refuseUnusable(params, currency);
2021
+ const iban = accountOf(params.iban);
2022
+ refuseUnusable(params, iban, currency);
1627
2023
  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();
2024
+ const spd = shortPaymentDescriptor(params, iban, currency);
2025
+ const subject = subjectOf({
2026
+ iban,
2027
+ amountMinor: params.amountMinor,
2028
+ currency,
2029
+ expiresAt: params.expiresAt,
2030
+ reference: params.reference
2031
+ });
2032
+ const verifyUrl = await answeredAt(params, subject);
2033
+ const paymentHash = hashOf(await hmacHex(params.secret, `preimage|${subject}`));
1636
2034
  const watched = await gateway.watch({
1637
- paymentHash: hashOf(await hmacHex(params.secret, `preimage|${subject}`)),
2035
+ paymentHash,
1638
2036
  verifyUrl,
1639
2037
  expiresAt: params.expiresAt,
1640
2038
  trigger: params.trigger,
1641
2039
  replay: params.replay,
1642
- sealed: params.sealed,
2040
+ sealed: params.sealed ? await seal(params.sealed.secret, JSON.stringify(params.sealed.data), paymentHash) : void 0,
1643
2041
  webhookUrl: params.webhookUrl
1644
2042
  });
1645
- return { id: watched.id, paymentHash: watched.paymentHash, verifyUrl, spd };
2043
+ return { id: watched.id, paymentHash, verifyUrl, spd };
1646
2044
  }
1647
2045
  function bankVerifyEndpoint(config) {
2046
+ refuseAWeakSecret(config.secret);
2047
+ const answersFor = accountOf(config.iban);
2048
+ if (!IBAN.test(answersFor)) {
2049
+ throw new Error(`${config.iban} is not an IBAN, so this endpoint answers for no account`);
2050
+ }
1648
2051
  const paced = {
1649
2052
  "cache-control": `max-age=${config.pollEverySecs ?? DEFAULT_POLL_EVERY_SECS}`
1650
2053
  };
@@ -1653,51 +2056,82 @@ function bankVerifyEndpoint(config) {
1653
2056
  if (consented !== null) {
1654
2057
  return consented;
1655
2058
  }
1656
- const asked = readQuery(new URL(request.url));
1657
- if (asked === null) {
2059
+ const sealed = new URL(request.url).searchParams.get("q");
2060
+ if (sealed === null) {
1658
2061
  return Response.json({ settled: false }, { status: 400 });
1659
2062
  }
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)) {
2063
+ const subject = await unsealFor("bank-verify", config.secret, sealed);
2064
+ const asked = subject === null ? null : askedFrom(subject);
2065
+ if (asked === null || asked.iban !== answersFor) {
1663
2066
  return Response.json({ settled: false }, { status: 403 });
1664
2067
  }
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
- );
2068
+ const preimage = await creditedPreimage({
2069
+ secret: config.secret,
2070
+ asked,
2071
+ statement: config.statement,
2072
+ lookBackSecs: config.lookBackSecs
2073
+ });
2074
+ return preimage === null ? Response.json({ settled: false }, { headers: paced }) : Response.json({ settled: true, preimage }, { headers: paced });
1674
2075
  };
1675
2076
  }
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) {
2077
+ async function answeredAt(params, subject) {
2078
+ if (params.answerBy === "agent") {
2079
+ return `agent:${(await callerKey(params.secret)).publicKeyHex}`;
2080
+ }
2081
+ const polling = new URL(params.verifyUrl ?? "");
2082
+ polling.searchParams.set(
2083
+ "q",
2084
+ await sealFor("bank-verify", params.secret, subject, { stable: true })
2085
+ );
2086
+ return polling.toString();
2087
+ }
2088
+ async function creditedPreimage(params) {
2089
+ const since = unixNow() - (params.lookBackSecs ?? DEFAULT_LOOK_BACK_SECS);
2090
+ const landed = (await params.statement(since)).some((credit) => pays(credit, params.asked));
2091
+ if (!landed) {
1682
2092
  return null;
1683
2093
  }
1684
- if (!Number.isInteger(minor) || minor <= 0) {
2094
+ return await hmacHex(params.secret, `preimage|${subjectOf(params.asked)}`);
2095
+ }
2096
+ function askedFrom(subject) {
2097
+ const [iban, minorText, currency, expiresText, ...rest] = subject.split("|");
2098
+ const reference = rest.join("|");
2099
+ const amountMinor = Number(minorText);
2100
+ const expiresAt = Number(expiresText);
2101
+ if (!iban || !currency || reference.length === 0) {
1685
2102
  return null;
1686
2103
  }
1687
- return { reference, amountMinor: minor, currency, signature };
2104
+ if (!Number.isInteger(amountMinor) || amountMinor <= 0 || !Number.isSafeInteger(expiresAt)) {
2105
+ return null;
2106
+ }
2107
+ return { iban, reference, amountMinor, currency, expiresAt };
1688
2108
  }
1689
2109
  function pays(credit, asked) {
1690
- return credit.amountMinor === asked.amountMinor && credit.currency.toUpperCase() === asked.currency.toUpperCase() && credit.reference.toUpperCase().includes(asked.reference.toUpperCase());
2110
+ return credit.amountMinor === asked.amountMinor && credit.currency.toUpperCase() === asked.currency.toUpperCase() && namesOnly(credit.reference, asked.reference);
2111
+ }
2112
+ function namesOnly(written, reference) {
2113
+ const wanted = reference.toUpperCase();
2114
+ const alike = [...written.toUpperCase().matchAll(wordsShapedLike(wanted))].map(([word]) => word);
2115
+ return alike.includes(wanted) && (!/\d/.test(wanted) || alike.every((word) => word === wanted));
2116
+ }
2117
+ function wordsShapedLike(reference) {
2118
+ const shape = [...reference].map((char) => {
2119
+ if (/\d/.test(char)) {
2120
+ return "\\d";
2121
+ }
2122
+ return /\p{L}/u.test(char) ? "\\p{L}" : char.replace(/[\^$\\.*+?()[\]{}|/]/g, "\\$&");
2123
+ }).join("");
2124
+ return new RegExp(`(?<![\\p{L}\\p{N}])${shape}(?![\\p{L}\\p{N}])`, "gu");
1691
2125
  }
1692
- function subjectOf(reference, amountMinor, currency) {
1693
- return `${reference}|${amountMinor}|${currency.toUpperCase()}`;
2126
+ function subjectOf({ iban, amountMinor, currency, expiresAt, reference }) {
2127
+ return `${iban}|${amountMinor}|${currency.toUpperCase()}|${expiresAt}|${reference}`;
1694
2128
  }
1695
2129
  function hashOf(preimage) {
1696
2130
  return bytesToHex(sha256(hexToBytes(preimage)));
1697
2131
  }
1698
- function shortPaymentDescriptor(params, currency) {
2132
+ function shortPaymentDescriptor(params, iban, currency) {
1699
2133
  const fields = [
1700
- `ACC:${params.iban}`,
2134
+ `ACC:${iban}`,
1701
2135
  `AM:${major(params.amountMinor, currency)}`,
1702
2136
  `CC:${currency.toUpperCase()}`,
1703
2137
  `MSG:${params.reference}`
@@ -1718,19 +2152,28 @@ async function refuseAnOpenGateway(gateway, params) {
1718
2152
  return;
1719
2153
  }
1720
2154
  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"
2155
+ "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
2156
  );
1723
2157
  }
1724
- function refuseUnusable(params, currency) {
1725
- if (!IBAN.test(params.iban)) {
2158
+ function refuseUnusable(params, iban, currency) {
2159
+ refuseAWeakSecret(params.secret);
2160
+ if (params.answerBy !== "agent" && params.verifyUrl === void 0) {
2161
+ throw new Error("a transfer the gateway polls needs the verify url it is polled at");
2162
+ }
2163
+ if (!IBAN.test(iban)) {
1726
2164
  throw new Error(`${params.iban} is not an IBAN`);
1727
2165
  }
1728
- if (!Number.isInteger(params.amountMinor) || params.amountMinor <= 0) {
2166
+ if (!Number.isSafeInteger(params.amountMinor) || params.amountMinor <= 0) {
1729
2167
  throw new Error("amountMinor must be a whole number of minor units above zero");
1730
2168
  }
1731
2169
  if (params.reference.length === 0) {
1732
2170
  throw new Error("a transfer with no reference cannot be found");
1733
2171
  }
2172
+ if (params.reference.length > MAX_REFERENCE_CHARS) {
2173
+ throw new Error(
2174
+ `a reference is at most ${MAX_REFERENCE_CHARS} characters, as many as a QR payment's message carries and its sealed query hides`
2175
+ );
2176
+ }
1734
2177
  if (params.reference.includes(FORBIDDEN_IN_SPD)) {
1735
2178
  throw new Error("a reference cannot contain an asterisk, it separates the QR fields");
1736
2179
  }
@@ -1738,10 +2181,168 @@ function refuseUnusable(params, currency) {
1738
2181
  throw new Error("a variable symbol is up to ten digits");
1739
2182
  }
1740
2183
  }
2184
+ function accountOf(iban) {
2185
+ return iban.replace(/\s+/g, "").toUpperCase();
2186
+ }
1741
2187
  function unixNow() {
1742
2188
  return Math.floor(Date.now() / 1e3);
1743
2189
  }
1744
2190
 
2191
+ // src/nostr.ts
2192
+ var import_chacha = require("@noble/ciphers/chacha.js");
2193
+ var import_secp256k1 = require("@noble/curves/secp256k1.js");
2194
+ var import_hkdf = require("@noble/hashes/hkdf.js");
2195
+ var import_hmac4 = require("@noble/hashes/hmac.js");
2196
+ var import_sha2 = require("@noble/hashes/sha2.js");
2197
+ var NIP44_SALT = new TextEncoder().encode("nip44-v2");
2198
+ var NIP44_VERSION = 2;
2199
+ var NONCE_BYTES2 = 32;
2200
+ var MAC_BYTES = 32;
2201
+ var MESSAGE_KEYS_BYTES = 76;
2202
+ var CHACHA_KEY_END = 32;
2203
+ var CHACHA_NONCE_END = 44;
2204
+ var LENGTH_PREFIX_BYTES = 2;
2205
+ var SMALLEST_PAD = 32;
2206
+ var CHUNKS_STAY_SMALL_TO = 256;
2207
+ var MAX_PLAINTEXT_BYTES = 65535;
2208
+ function publicKeyFor(privateKeyHex) {
2209
+ return bytesToHex(import_secp256k1.schnorr.getPublicKey(hexToBytes(privateKeyHex)));
2210
+ }
2211
+ function conversationKeyFor(privateKeyHex, publicKeyHex) {
2212
+ const shared = import_secp256k1.secp256k1.getSharedSecret(
2213
+ hexToBytes(privateKeyHex),
2214
+ hexToBytes(`02${publicKeyHex}`)
2215
+ );
2216
+ return (0, import_hkdf.extract)(import_sha2.sha256, shared.subarray(1, 33), NIP44_SALT);
2217
+ }
2218
+ function signEvent(privateKeyHex, draft) {
2219
+ const pubkey = publicKeyFor(privateKeyHex);
2220
+ const id = idOf(pubkey, draft);
2221
+ return {
2222
+ id,
2223
+ pubkey,
2224
+ created_at: draft.created_at,
2225
+ kind: draft.kind,
2226
+ tags: draft.tags,
2227
+ content: draft.content,
2228
+ sig: bytesToHex(import_secp256k1.schnorr.sign(hexToBytes(id), hexToBytes(privateKeyHex)))
2229
+ };
2230
+ }
2231
+ function verifyEvent(event) {
2232
+ try {
2233
+ return idOf(event.pubkey, event) === event.id && import_secp256k1.schnorr.verify(hexToBytes(event.sig), hexToBytes(event.id), hexToBytes(event.pubkey));
2234
+ } catch {
2235
+ return false;
2236
+ }
2237
+ }
2238
+ function idOf(pubkey, draft) {
2239
+ return bytesToHex(
2240
+ (0, import_sha2.sha256)(
2241
+ new TextEncoder().encode(
2242
+ JSON.stringify([0, pubkey, draft.created_at, draft.kind, draft.tags, draft.content])
2243
+ )
2244
+ )
2245
+ );
2246
+ }
2247
+ function encryptNip44(conversationKey, plaintext) {
2248
+ return sealNip44(conversationKey, plaintext, crypto.getRandomValues(new Uint8Array(NONCE_BYTES2)));
2249
+ }
2250
+ function sealNip44(conversationKey, plaintext, nonce) {
2251
+ const keys = messageKeysFrom(conversationKey, nonce);
2252
+ const ciphertext = (0, import_chacha.chacha20)(keys.chachaKey, keys.chachaNonce, padded(plaintext));
2253
+ const mac = (0, import_hmac4.hmac)(import_sha2.sha256, keys.hmacKey, joined(nonce, ciphertext));
2254
+ return toBase64(joined(Uint8Array.of(NIP44_VERSION), nonce, ciphertext, mac));
2255
+ }
2256
+ function decryptNip44(conversationKey, payload) {
2257
+ if (payload.startsWith("#")) {
2258
+ throw new Error("nip44 payload announces an encryption version we do not speak");
2259
+ }
2260
+ const raw = fromBase64(payload);
2261
+ if (raw.length < 1 + NONCE_BYTES2 + MAC_BYTES) {
2262
+ throw new Error(`nip44 payload is ${raw.length} bytes, too short to hold a nonce and a mac`);
2263
+ }
2264
+ if (raw[0] !== NIP44_VERSION) {
2265
+ throw new Error(`nip44 payload is version ${raw[0]}, not ${NIP44_VERSION}`);
2266
+ }
2267
+ const nonce = raw.subarray(1, 1 + NONCE_BYTES2);
2268
+ const ciphertext = raw.subarray(1 + NONCE_BYTES2, raw.length - MAC_BYTES);
2269
+ const mac = raw.subarray(raw.length - MAC_BYTES);
2270
+ const keys = messageKeysFrom(conversationKey, nonce);
2271
+ if (!sameBytes((0, import_hmac4.hmac)(import_sha2.sha256, keys.hmacKey, joined(nonce, ciphertext)), mac)) {
2272
+ throw new Error("nip44 payload failed its mac, so it was not written with this key");
2273
+ }
2274
+ return unpadded((0, import_chacha.chacha20)(keys.chachaKey, keys.chachaNonce, ciphertext));
2275
+ }
2276
+ function messageKeysFrom(conversationKey, nonce) {
2277
+ if (conversationKey.length !== NONCE_BYTES2 || nonce.length !== NONCE_BYTES2) {
2278
+ throw new Error("a nip44 conversation key and nonce are both 32 bytes");
2279
+ }
2280
+ const keys = (0, import_hkdf.expand)(import_sha2.sha256, conversationKey, nonce, MESSAGE_KEYS_BYTES);
2281
+ return {
2282
+ chachaKey: keys.subarray(0, CHACHA_KEY_END),
2283
+ chachaNonce: keys.subarray(CHACHA_KEY_END, CHACHA_NONCE_END),
2284
+ hmacKey: keys.subarray(CHACHA_NONCE_END)
2285
+ };
2286
+ }
2287
+ function paddedLengthFor(unpadded2) {
2288
+ if (unpadded2 <= SMALLEST_PAD) {
2289
+ return SMALLEST_PAD;
2290
+ }
2291
+ const nextPower = 1 << Math.floor(Math.log2(unpadded2 - 1)) + 1;
2292
+ const chunk = nextPower <= CHUNKS_STAY_SMALL_TO ? SMALLEST_PAD : nextPower / 8;
2293
+ return chunk * (Math.floor((unpadded2 - 1) / chunk) + 1);
2294
+ }
2295
+ function padded(plaintext) {
2296
+ const body = new TextEncoder().encode(plaintext);
2297
+ if (body.length === 0 || body.length > MAX_PLAINTEXT_BYTES) {
2298
+ throw new Error(`nip44 takes 1 to ${MAX_PLAINTEXT_BYTES} bytes, this was ${body.length}`);
2299
+ }
2300
+ const buffer = new Uint8Array(LENGTH_PREFIX_BYTES + paddedLengthFor(body.length));
2301
+ buffer.set(Uint8Array.of(body.length >>> 8, body.length & 255));
2302
+ buffer.set(body, LENGTH_PREFIX_BYTES);
2303
+ return buffer;
2304
+ }
2305
+ function unpadded(buffer) {
2306
+ const length = (buffer[0] ?? 0) << 8 | (buffer[1] ?? 0);
2307
+ if (length === 0 || buffer.length !== LENGTH_PREFIX_BYTES + paddedLengthFor(length)) {
2308
+ throw new Error("nip44 plaintext does not match the length its own padding declares");
2309
+ }
2310
+ return new TextDecoder().decode(
2311
+ buffer.subarray(LENGTH_PREFIX_BYTES, LENGTH_PREFIX_BYTES + length)
2312
+ );
2313
+ }
2314
+ function joined(...parts) {
2315
+ const whole = new Uint8Array(parts.reduce((total, part) => total + part.length, 0));
2316
+ let at = 0;
2317
+ for (const part of parts) {
2318
+ whole.set(part, at);
2319
+ at += part.length;
2320
+ }
2321
+ return whole;
2322
+ }
2323
+ function sameBytes(left, right) {
2324
+ let different = left.length ^ right.length;
2325
+ for (let at = 0; at < left.length && at < right.length; at++) {
2326
+ different |= (left[at] ?? 0) ^ (right[at] ?? 0);
2327
+ }
2328
+ return different === 0;
2329
+ }
2330
+ function toBase64(bytes) {
2331
+ let binary = "";
2332
+ for (const byte of bytes) {
2333
+ binary += String.fromCharCode(byte);
2334
+ }
2335
+ return btoa(binary);
2336
+ }
2337
+ function fromBase64(text3) {
2338
+ const binary = atob(text3);
2339
+ const bytes = new Uint8Array(binary.length);
2340
+ for (let at = 0; at < bytes.length; at++) {
2341
+ bytes[at] = binary.charCodeAt(at);
2342
+ }
2343
+ return bytes;
2344
+ }
2345
+
1745
2346
  // src/qr.ts
1746
2347
  var import_uqr = require("uqr");
1747
2348
  var SAFE_COLOR = /^#(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$|^rgba?\([\d.,%/\s]+\)$|^[a-z]+$/i;
@@ -1752,44 +2353,276 @@ function toLightningUri(destination) {
1752
2353
  function invoiceToSvg(destination, options) {
1753
2354
  return svgOf(toLightningUri(destination), options);
1754
2355
  }
1755
- function svgOf(scanned, options) {
1756
- const size = options?.size ?? 256;
1757
- const color = options?.color ?? "#000";
1758
- if (!SAFE_COLOR.test(color)) {
1759
- throw new Error(`Invalid color: ${color}`);
2356
+ function svgOf(scanned, options) {
2357
+ const size = options?.size ?? 256;
2358
+ const color = options?.color ?? "#000";
2359
+ if (!SAFE_COLOR.test(color)) {
2360
+ throw new Error(`Invalid color: ${color}`);
2361
+ }
2362
+ return renderSvg(scanned, size, color);
2363
+ }
2364
+ function isLnAddress(destination) {
2365
+ const at = destination.indexOf("@");
2366
+ return at > 0 && destination.slice(at + 1).includes(".");
2367
+ }
2368
+ function renderSvg(content, size, color) {
2369
+ const { data } = (0, import_uqr.encode)(content);
2370
+ const modules = data.length;
2371
+ const cellSize = size / modules;
2372
+ let path = "";
2373
+ for (let y = 0; y < modules; y++) {
2374
+ for (let x = 0; x < modules; x++) {
2375
+ if (data[y][x]) {
2376
+ path += `M${x * cellSize},${y * cellSize}h${cellSize}v${cellSize}h-${cellSize}z`;
2377
+ }
2378
+ }
2379
+ }
2380
+ return [
2381
+ `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${size} ${size}" width="${size}" height="${size}">`,
2382
+ `<rect width="100%" height="100%" fill="white"/>`,
2383
+ `<path d="${path}" fill="${color}"/>`,
2384
+ `</svg>`
2385
+ ].join("");
2386
+ }
2387
+
2388
+ // src/nwc.ts
2389
+ var REQUEST_KIND = 23194;
2390
+ var RESPONSE_KIND = 23195;
2391
+ var NIP44_V2 = "nip44_v2";
2392
+ var DEFAULT_ASK_TIMEOUT_MS = 1e4;
2393
+ var DEFAULT_POLL_EVERY_SECS2 = 5;
2394
+ var HASH = "h";
2395
+ var REFUSAL_BY_CODE = {
2396
+ INSUFFICIENT_BALANCE: "amount-not-accepted",
2397
+ QUOTA_EXCEEDED: "amount-not-accepted",
2398
+ RESTRICTED: "address-unusable",
2399
+ UNAUTHORIZED: "address-unusable",
2400
+ UNSUPPORTED_ENCRYPTION: "address-unusable"
2401
+ };
2402
+ async function nwcInvoice(connection, amountMsat, description, timeoutMs = DEFAULT_ASK_TIMEOUT_MS) {
2403
+ const answered3 = await askWallet(
2404
+ connection,
2405
+ "make_invoice",
2406
+ { amount: amountMsat, description },
2407
+ timeoutMs
2408
+ );
2409
+ const bolt11 = String(answered3.invoice ?? "");
2410
+ const issued = decodeInvoice(bolt11);
2411
+ if (issued.paymentHash === null || issued.expiresAt === null) {
2412
+ throw new WalletRefused("invoice-refused", "the nwc wallet issued an invoice we cannot decode");
2413
+ }
2414
+ if (issued.amountMsat !== amountMsat) {
2415
+ throw new WalletRefused(
2416
+ "invoice-refused",
2417
+ `the nwc wallet issued an invoice for ${issued.amountMsat} msat, not ${amountMsat}`
2418
+ );
2419
+ }
2420
+ return { bolt11, paymentHash: issued.paymentHash, expiresAt: issued.expiresAt };
2421
+ }
2422
+ async function nwcSettlement(connection, paymentHash, timeoutMs = DEFAULT_ASK_TIMEOUT_MS) {
2423
+ const answered3 = await askWallet(
2424
+ connection,
2425
+ "lookup_invoice",
2426
+ { payment_hash: paymentHash },
2427
+ timeoutMs
2428
+ );
2429
+ const preimage = answered3.preimage;
2430
+ if (typeof preimage !== "string" || preimage.length === 0) {
2431
+ return null;
2432
+ }
2433
+ if (!preimageMatchesHash(preimage, paymentHash)) {
2434
+ throw new Error(`the nwc wallet returned a preimage that does not hash to ${paymentHash}`);
2435
+ }
2436
+ return preimage;
2437
+ }
2438
+ function nwcVerifyEndpoint(config) {
2439
+ const paced = {
2440
+ "cache-control": `max-age=${config.pollEverySecs ?? DEFAULT_POLL_EVERY_SECS2}`
2441
+ };
2442
+ return async (request) => {
2443
+ const consented = await answerVerifyChallenge(request);
2444
+ if (consented !== null) {
2445
+ return consented;
2446
+ }
2447
+ const sealed = new URL(request.url).searchParams.get(HASH);
2448
+ if (sealed === null) {
2449
+ return Response.json({ settled: false }, { status: 400 });
2450
+ }
2451
+ const paymentHash = await unsealFor("nwc-verify", config.secret, sealed);
2452
+ if (paymentHash === null) {
2453
+ return Response.json({ settled: false }, { status: 403 });
2454
+ }
2455
+ const preimage = await nwcSettlement(config.connection, paymentHash, config.askTimeoutMs).catch(
2456
+ () => void 0
2457
+ );
2458
+ if (preimage === void 0) {
2459
+ return Response.json({ settled: false }, { status: 502 });
2460
+ }
2461
+ return Response.json({ settled: preimage !== null, preimage }, { headers: paced });
2462
+ };
2463
+ }
2464
+ async function nwcVerifyUrl(endpoint, paymentHash, secret) {
2465
+ const sealed = new URL(endpoint);
2466
+ sealed.searchParams.set(HASH, await sealFor("nwc-verify", secret, paymentHash));
2467
+ return sealed.toString();
2468
+ }
2469
+ async function askWallet(connection, method, params, timeoutMs = DEFAULT_ASK_TIMEOUT_MS) {
2470
+ const key = conversationKeyFor(connection.secret, connection.walletPubkey);
2471
+ const request = signEvent(connection.secret, {
2472
+ kind: REQUEST_KIND,
2473
+ tags: [
2474
+ ["p", connection.walletPubkey],
2475
+ ["encryption", NIP44_V2]
2476
+ ],
2477
+ content: encryptNip44(key, JSON.stringify({ method, params })),
2478
+ created_at: Math.floor(Date.now() / 1e3)
2479
+ });
2480
+ const answer = await overFirstRelayThatAnswers(connection, request, timeoutMs);
2481
+ return resultOf(JSON.parse(decryptNip44(key, answer.content)), method);
2482
+ }
2483
+ function resultOf(said, method) {
2484
+ const failure = said.error;
2485
+ if (failure) {
2486
+ throw new WalletRefused(
2487
+ REFUSAL_BY_CODE[failure.code ?? ""] ?? "invoice-refused",
2488
+ `the nwc wallet refused ${method}: ${failure.code ?? "unknown"} ${failure.message ?? ""}`.trim()
2489
+ );
2490
+ }
2491
+ const result = said.result;
2492
+ if (result === null || typeof result !== "object") {
2493
+ throw new WalletRefused("invoice-refused", `the nwc wallet answered ${method} with no result`);
2494
+ }
2495
+ return result;
2496
+ }
2497
+ async function overFirstRelayThatAnswers(connection, request, timeoutMs) {
2498
+ const deadline = AbortSignal.timeout(timeoutMs);
2499
+ let lastFailure = null;
2500
+ for (const relay of connection.relays) {
2501
+ if (deadline.aborted) {
2502
+ break;
2503
+ }
2504
+ try {
2505
+ return await overRelay(relay, connection, request, deadline);
2506
+ } catch (failure) {
2507
+ lastFailure = failure;
2508
+ }
2509
+ }
2510
+ throw new WalletRefused("unreachable", `no nwc relay answered: ${String(lastFailure)}`);
2511
+ }
2512
+ function overRelay(relay, connection, request, deadline) {
2513
+ return new Promise((resolve2, reject) => {
2514
+ const socket = new WebSocket(relay);
2515
+ const subscription = request.id.slice(0, 16);
2516
+ const expired = () => {
2517
+ close();
2518
+ reject(new Error(`${relay} did not answer before the deadline`));
2519
+ };
2520
+ deadline.addEventListener("abort", expired);
2521
+ function close() {
2522
+ deadline.removeEventListener("abort", expired);
2523
+ socket.close();
2524
+ }
2525
+ socket.addEventListener("open", () => {
2526
+ socket.send(
2527
+ JSON.stringify([
2528
+ "REQ",
2529
+ subscription,
2530
+ {
2531
+ kinds: [RESPONSE_KIND],
2532
+ authors: [connection.walletPubkey],
2533
+ "#p": [publicKeyFor(connection.secret)],
2534
+ "#e": [request.id]
2535
+ }
2536
+ ])
2537
+ );
2538
+ socket.send(JSON.stringify(["EVENT", request]));
2539
+ });
2540
+ socket.addEventListener("error", () => {
2541
+ close();
2542
+ reject(new Error(`${relay} could not be reached`));
2543
+ });
2544
+ socket.addEventListener("message", (delivered) => {
2545
+ const frame = String(delivered.data);
2546
+ const rejection = rejectionOf(frame, request.id);
2547
+ if (rejection !== null) {
2548
+ close();
2549
+ reject(new Error(`${relay} refused the request: ${rejection}`));
2550
+ return;
2551
+ }
2552
+ const answer = answeredEvent(frame, subscription, connection, request.id);
2553
+ if (answer === null) {
2554
+ return;
2555
+ }
2556
+ close();
2557
+ resolve2(answer);
2558
+ });
2559
+ });
2560
+ }
2561
+ function rejectionOf(frame, requestId) {
2562
+ const parsed = parsedFrame(frame);
2563
+ if (parsed === null || parsed[0] !== "OK" || parsed[1] !== requestId || parsed[2] !== false) {
2564
+ return null;
1760
2565
  }
1761
- return renderSvg(scanned, size, color);
2566
+ return typeof parsed[3] === "string" && parsed[3].length > 0 ? parsed[3] : "no reason given";
1762
2567
  }
1763
- function isLnAddress(destination) {
1764
- const at = destination.indexOf("@");
1765
- return at > 0 && destination.slice(at + 1).includes(".");
2568
+ function answeredEvent(frame, subscription, connection, requestId) {
2569
+ const parsed = parsedFrame(frame);
2570
+ if (parsed === null || parsed[0] !== "EVENT" || parsed[1] !== subscription) {
2571
+ return null;
2572
+ }
2573
+ const event = parsed[2];
2574
+ const answers = event?.kind === RESPONSE_KIND && event.pubkey === connection.walletPubkey && Array.isArray(event.tags) && event.tags.some((tag) => tag?.[0] === "e" && tag[1] === requestId) && verifyEvent(event);
2575
+ return answers ? event : null;
1766
2576
  }
1767
- function renderSvg(content, size, color) {
1768
- const { data } = (0, import_uqr.encode)(content);
1769
- const modules = data.length;
1770
- const cellSize = size / modules;
1771
- let path = "";
1772
- for (let y = 0; y < modules; y++) {
1773
- for (let x = 0; x < modules; x++) {
1774
- if (data[y][x]) {
1775
- path += `M${x * cellSize},${y * cellSize}h${cellSize}v${cellSize}h-${cellSize}z`;
1776
- }
1777
- }
2577
+ function parsedFrame(frame) {
2578
+ try {
2579
+ const parsed = JSON.parse(frame);
2580
+ return Array.isArray(parsed) ? parsed : null;
2581
+ } catch {
2582
+ return null;
1778
2583
  }
1779
- return [
1780
- `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${size} ${size}" width="${size}" height="${size}">`,
1781
- `<rect width="100%" height="100%" fill="white"/>`,
1782
- `<path d="${path}" fill="${color}"/>`,
1783
- `</svg>`
1784
- ].join("");
2584
+ }
2585
+ function nwcRail(gateway, config) {
2586
+ return async (order) => {
2587
+ const invoice = await nwcInvoice(
2588
+ config.connection,
2589
+ await msatForOrder(order, config.amount, config.rate),
2590
+ config.description?.(order) ?? order.reference
2591
+ );
2592
+ const watched = await gateway.watch({
2593
+ paymentHash: invoice.paymentHash,
2594
+ verifyUrl: await nwcVerifyUrl(
2595
+ config.verifyThrough.endpoint,
2596
+ invoice.paymentHash,
2597
+ config.verifyThrough.secret
2598
+ ),
2599
+ expiresAt: invoice.expiresAt,
2600
+ trigger: config.trigger,
2601
+ replay: config.replay,
2602
+ sealed: config.sealed ? await seal(
2603
+ config.sealed.secret,
2604
+ JSON.stringify(config.sealed.data(order)),
2605
+ invoice.paymentHash
2606
+ ) : void 0,
2607
+ webhookUrl: config.webhookUrl
2608
+ });
2609
+ return {
2610
+ id: watched.id,
2611
+ paymentHash: invoice.paymentHash,
2612
+ rail: config.name ?? "lightning",
2613
+ scan: invoice.bolt11,
2614
+ qr: toLightningUri(invoice.bolt11),
2615
+ expiresAt: invoice.expiresAt
2616
+ };
2617
+ };
1785
2618
  }
1786
2619
 
1787
2620
  // src/relay.ts
1788
- var DEFAULT_POLL_EVERY_SECS2 = 5;
2621
+ var DEFAULT_POLL_EVERY_SECS3 = 5;
1789
2622
  var WALLET = "w";
1790
2623
  function lightningVerifyEndpoint(config) {
1791
2624
  const paced = {
1792
- "cache-control": `max-age=${config.pollEverySecs ?? DEFAULT_POLL_EVERY_SECS2}`
2625
+ "cache-control": `max-age=${config.pollEverySecs ?? DEFAULT_POLL_EVERY_SECS3}`
1793
2626
  };
1794
2627
  return async (request) => {
1795
2628
  const consented = await answerVerifyChallenge(request);
@@ -1800,11 +2633,11 @@ function lightningVerifyEndpoint(config) {
1800
2633
  if (sealed === null) {
1801
2634
  return Response.json({ settled: false }, { status: 400 });
1802
2635
  }
1803
- const opened = await unseal(config.secret, sealed);
1804
- if (opened === null) {
2636
+ const opened2 = await unsealFor("relay", config.secret, sealed);
2637
+ const wallet = opened2 === null ? null : relayedFrom(opened2);
2638
+ if (wallet === null) {
1805
2639
  return Response.json({ settled: false }, { status: 403 });
1806
2640
  }
1807
- const wallet = JSON.parse(opened);
1808
2641
  const asked = await checkSettled(
1809
2642
  config.send ?? pinnedToTheAddressWeVerified,
1810
2643
  wallet.url,
@@ -1819,9 +2652,17 @@ function lightningVerifyEndpoint(config) {
1819
2652
  );
1820
2653
  };
1821
2654
  }
2655
+ function relayedFrom(opened2) {
2656
+ try {
2657
+ const said = JSON.parse(opened2);
2658
+ return typeof said?.url === "string" && typeof said.hash === "string" ? { url: said.url, hash: said.hash } : null;
2659
+ } catch {
2660
+ return null;
2661
+ }
2662
+ }
1822
2663
  async function relayedVerifyUrl(endpoint, wallet, secret) {
1823
2664
  const relayed = new URL(endpoint);
1824
- relayed.searchParams.set(WALLET, await seal(secret, JSON.stringify(wallet)));
2665
+ relayed.searchParams.set(WALLET, await sealFor("relay", secret, JSON.stringify(wallet)));
1825
2666
  return relayed.toString();
1826
2667
  }
1827
2668
 
@@ -1841,13 +2682,14 @@ function bankRail(gateway, config) {
1841
2682
  expiresAt,
1842
2683
  trigger: config.trigger,
1843
2684
  replay: config.replay,
1844
- sealed: await config.sealed?.(order),
2685
+ sealed: config.sealed && { secret: config.sealed.secret, data: config.sealed.data(order) },
1845
2686
  variableSymbol: config.variableSymbol?.(order),
1846
2687
  webhookUrl: config.webhookUrl,
1847
2688
  allowPublicGateway: config.allowPublicGateway
1848
2689
  });
1849
2690
  return {
1850
2691
  id: transfer.id,
2692
+ paymentHash: transfer.paymentHash,
1851
2693
  rail: config.name ?? BANK,
1852
2694
  scan: transfer.spd,
1853
2695
  qr: transfer.spd,
@@ -1871,6 +2713,7 @@ function lightningRail(gateway, config) {
1871
2713
  );
1872
2714
  return {
1873
2715
  id: payment.id,
2716
+ paymentHash: payment.paymentHash,
1874
2717
  rail: config.name ?? LIGHTNING,
1875
2718
  scan: payment.bolt11,
1876
2719
  qr: toLightningUri(payment.bolt11),
@@ -1896,11 +2739,16 @@ function blindLightningRail(gateway, config) {
1896
2739
  expiresAt: resolved2.expiresAt,
1897
2740
  trigger: config.trigger,
1898
2741
  replay: config.replay,
1899
- sealed: await config.sealed?.(order),
2742
+ sealed: config.sealed ? await seal(
2743
+ config.sealed.secret,
2744
+ JSON.stringify(config.sealed.data(order)),
2745
+ resolved2.paymentHash
2746
+ ) : void 0,
1900
2747
  webhookUrl: config.webhookUrl
1901
2748
  });
1902
2749
  return {
1903
2750
  id: watched.id,
2751
+ paymentHash: resolved2.paymentHash,
1904
2752
  rail: config.name ?? LIGHTNING,
1905
2753
  scan: resolved2.bolt11,
1906
2754
  qr: toLightningUri(resolved2.bolt11),
@@ -1945,6 +2793,15 @@ var Rails = class {
1945
2793
  bank(config) {
1946
2794
  return bankRail(this.gateway, config);
1947
2795
  }
2796
+ /**
2797
+ * Lightning against a wallet of your own over NIP-47, for a wallet that has no
2798
+ * LUD-21 address to be watched at. Your node mints the invoice and releases the
2799
+ * preimage, so the proof comes from one hop nearer than any hosted address can
2800
+ * manage, and the gateway sees a hash and a URL of yours
2801
+ */
2802
+ nwc(config) {
2803
+ return nwcRail(this.gateway, config);
2804
+ }
1948
2805
  /**
1949
2806
  * One bank transfer without building a rail first, for a shop that asks for
1950
2807
  * them one at a time rather than beside another payment method
@@ -1954,130 +2811,6 @@ var Rails = class {
1954
2811
  }
1955
2812
  };
1956
2813
 
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
2814
  // src/request.ts
2082
2815
  function paymentRequestOf(gateway, payment, options) {
2083
2816
  return {
@@ -2089,13 +2822,13 @@ function paymentRequestOf(gateway, payment, options) {
2089
2822
  expiresAt: payment.expiresAt,
2090
2823
  qr: invoiceToSvg(payment.bolt11, options?.qr),
2091
2824
  payment,
2092
- paid: async (waiting) => paidOnly(await gateway.settled(payment.id, { ...waitingOf(options), ...waiting })),
2825
+ paid: async (waiting) => paidOnly(await gateway.settled(payment, { ...waitingOf(options), ...waiting })),
2093
2826
  prove: () => proveSettlement(payment, { paidTo: [payment.lnAddress], amountMsat: payment.amountMsat }),
2094
2827
  onPaid: (arrived, failed) => {
2095
2828
  const stop = new AbortController();
2096
2829
  const waiting = waitingOf(options);
2097
2830
  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) => {
2831
+ gateway.settled(payment, { ...waiting, signal }).then((ended) => arrived(paidOnly(ended))).catch((reason) => {
2099
2832
  if (!stop.signal.aborted) {
2100
2833
  failed?.(reason);
2101
2834
  }
@@ -2118,7 +2851,7 @@ function waitingOf(options) {
2118
2851
  }
2119
2852
 
2120
2853
  // src/trigger.ts
2121
- var NONCE_BYTES = 16;
2854
+ var NONCE_BYTES3 = 16;
2122
2855
  function lnurlPayEndpoint(gateway, config) {
2123
2856
  return async (request) => {
2124
2857
  const url = new URL(request.url);
@@ -2144,22 +2877,37 @@ function publicWatchTicketEndpoint(gateway, config) {
2144
2877
  }
2145
2878
  async function offer(gateway, config, url) {
2146
2879
  const { least, most } = await spread(config.amount);
2147
- const quote = await gateway.quote({ paidTo: config.paidTo, amount: msat(least) });
2880
+ const winner = await quoted(gateway, config, least);
2148
2881
  const nonce = randomNonce();
2149
2882
  const callback = new URL(config.baseUrl ?? `${url.origin}${url.pathname}`);
2150
- callback.searchParams.set("to", quote.lnAddress);
2883
+ callback.searchParams.set("to", winner.address);
2151
2884
  callback.searchParams.set("least", String(least));
2152
2885
  callback.searchParams.set("most", String(most));
2153
2886
  callback.searchParams.set("n", nonce);
2154
- callback.searchParams.set("sig", await sign(config.secret, quote.lnAddress, least, most, nonce));
2887
+ callback.searchParams.set("sig", await sign(config.secret, winner.address, least, most, nonce));
2155
2888
  return Response.json({
2156
2889
  tag: "payRequest",
2157
2890
  callback: callback.toString(),
2158
- metadata: quote.metadata,
2891
+ metadata: winner.metadata,
2159
2892
  minSendable: least,
2160
2893
  maxSendable: most
2161
2894
  });
2162
2895
  }
2896
+ async function quoted(gateway, config, amountMsat) {
2897
+ if (config.blind) {
2898
+ const { won } = await quote(
2899
+ config.send ?? pinnedToTheAddressWeVerified,
2900
+ listed(config.paidTo),
2901
+ amountMsat
2902
+ );
2903
+ return won;
2904
+ }
2905
+ const { lnAddress, metadata } = await gateway.quote({
2906
+ paidTo: config.paidTo,
2907
+ amount: msat(amountMsat)
2908
+ });
2909
+ return { address: lnAddress, metadata };
2910
+ }
2163
2911
  async function spread(amount) {
2164
2912
  if (typeof amount === "number" || typeof amount === "function") {
2165
2913
  const pinned = await amountNow(amount);
@@ -2181,6 +2929,9 @@ async function mint(gateway, config, url, address) {
2181
2929
  if (!equalInConstantTime(signature, expected)) {
2182
2930
  return refuse("this callback was not signed here");
2183
2931
  }
2932
+ if (!listed(config.paidTo).some((one) => one.toLowerCase() === address.toLowerCase())) {
2933
+ return refuse(`${address} is not paid through this trigger`);
2934
+ }
2184
2935
  const asked = url.searchParams.get("amount");
2185
2936
  const amountMsat = asked === null ? least : Number(asked);
2186
2937
  if (!Number.isSafeInteger(amountMsat) || amountMsat < least || amountMsat > most) {
@@ -2223,7 +2974,7 @@ async function mintBlind(gateway, config, address, amountMsat) {
2223
2974
  expiresAt: resolved2.expiresAt,
2224
2975
  trigger: config.watchSecret,
2225
2976
  replay: config.replay,
2226
- sealed: locked ? await seal(locked.secret, JSON.stringify(locked.data(minted))) : void 0
2977
+ sealed: locked ? await seal(locked.secret, JSON.stringify(locked.data(minted)), minted.paymentHash) : void 0
2227
2978
  });
2228
2979
  } catch (refused) {
2229
2980
  if (!alreadyWatched(refused)) {
@@ -2232,14 +2983,17 @@ async function mintBlind(gateway, config, address, amountMsat) {
2232
2983
  }
2233
2984
  return { bolt11: resolved2.bolt11, verifyUrl: resolved2.verifyUrl };
2234
2985
  }
2986
+ function listed(paidTo) {
2987
+ return typeof paidTo === "string" ? [paidTo] : paidTo;
2988
+ }
2235
2989
  function alreadyWatched(refused) {
2236
2990
  return refused instanceof ProblemError && isProblemType(refused, PAYMENT_ALREADY_WATCHED);
2237
2991
  }
2238
2992
  function sign(secret, address, least, most, nonce) {
2239
- return hmacHex(secret, `${address}|${least}|${most}|${nonce}`);
2993
+ return hmacHex(secret, `lnurl-callback|${address}|${least}|${most}|${nonce}`);
2240
2994
  }
2241
2995
  function randomNonce() {
2242
- const bytes = crypto.getRandomValues(new Uint8Array(NONCE_BYTES));
2996
+ const bytes = crypto.getRandomValues(new Uint8Array(NONCE_BYTES3));
2243
2997
  let hex = "";
2244
2998
  for (const byte of bytes) {
2245
2999
  hex += byte.toString(16).padStart(2, "0");
@@ -2289,23 +3043,39 @@ var Serve = class {
2289
3043
  * A verify endpoint of your own that asks the recipient's wallet for you, so
2290
3044
  * the gateway polls you and never the wallet
2291
3045
  */
2292
- verify(config) {
3046
+ lightningVerify(config) {
2293
3047
  return lightningVerifyEndpoint(config);
2294
3048
  }
3049
+ /**
3050
+ * What `lightningVerify` was called before 2.2.0
3051
+ *
3052
+ * @deprecated Use `lightningVerify`, beside `bankVerify` and `nwcVerify`
3053
+ */
3054
+ verify(config) {
3055
+ return this.lightningVerify(config);
3056
+ }
2295
3057
  /** The verify endpoint a bank rail is polled at, answering off your own statement */
2296
3058
  bankVerify(config) {
2297
3059
  return bankVerifyEndpoint(config);
2298
3060
  }
3061
+ /**
3062
+ * The verify endpoint an NWC rail is polled at, asking your own wallet over
3063
+ * NIP-47, so the gateway never learns the connection, the relay or the wallet
3064
+ */
3065
+ nwcVerify(config) {
3066
+ return nwcVerifyEndpoint(config);
3067
+ }
2299
3068
  /**
2300
3069
  * The whole webhook route: it answers the gateway's challenge, checks the
2301
3070
  * signature against the key the gateway publishes, refuses a settlement that
2302
- * proves nothing, and calls you for the one that does.
3071
+ * proves nothing, and calls you once for the one that does.
2303
3072
  *
2304
3073
  * `export const POST = gateway.serve.webhook({ onSettled: fulfil })` is the
2305
3074
  * entire integration
2306
3075
  */
2307
3076
  webhook(handlers) {
2308
- const options = { toleranceSecs: handlers.toleranceSecs };
3077
+ const options = { toleranceSecs: handlers.toleranceSecs, url: handlers.url };
3078
+ const once = onceWhileReplayable(handlers.toleranceSecs ?? DEFAULT_TOLERANCE_SECS);
2309
3079
  return async (request) => {
2310
3080
  const credential = await this.credential(handlers);
2311
3081
  const challenge = await answerWebhookChallenge(request, credential, options);
@@ -2314,12 +3084,17 @@ var Serve = class {
2314
3084
  }
2315
3085
  const settlement = await readSettlement(request, credential, options);
2316
3086
  if (settlement !== null) {
2317
- return await acted(settlement, handlers);
3087
+ return await once(
3088
+ `${settlement.id}:${settlement.status}`,
3089
+ () => acted(settlement, handlers)
3090
+ );
2318
3091
  }
2319
3092
  const payment = await readPayment(request, credential, options);
2320
3093
  if (payment !== null) {
2321
- await handlers.onPayment?.(payment);
2322
- return new Response("ok");
3094
+ return await once(`${payment.id}:${payment.status}`, async () => {
3095
+ await handlers.onPayment?.(payment);
3096
+ return new Response("ok");
3097
+ });
2323
3098
  }
2324
3099
  return new Response("bad signature", { status: 401 });
2325
3100
  };
@@ -2340,8 +3115,34 @@ var Serve = class {
2340
3115
  return handlers.credential ?? { publicKey: await this.gateway.webhookKey() };
2341
3116
  }
2342
3117
  };
3118
+ function onceWhileReplayable(toleranceSecs) {
3119
+ const acting = /* @__PURE__ */ new Map();
3120
+ const replayableForMs = 2 * toleranceSecs * 1e3;
3121
+ return async (delivery, act) => {
3122
+ const now = Date.now();
3123
+ for (const [earlier2, { forgetAt }] of acting) {
3124
+ if (forgetAt > now) {
3125
+ break;
3126
+ }
3127
+ acting.delete(earlier2);
3128
+ }
3129
+ const earlier = acting.get(delivery);
3130
+ if (earlier !== void 0) {
3131
+ await earlier.done;
3132
+ return new Response("ok");
3133
+ }
3134
+ const done = act();
3135
+ acting.set(delivery, { forgetAt: now + replayableForMs, done });
3136
+ try {
3137
+ return await done;
3138
+ } catch (failure) {
3139
+ acting.delete(delivery);
3140
+ throw failure;
3141
+ }
3142
+ };
3143
+ }
2343
3144
  async function acted(settlement, handlers) {
2344
- if (carriesProof(settlement)) {
3145
+ if (agreesWithItself(settlement)) {
2345
3146
  await handlers.onSettled?.(settlement);
2346
3147
  return new Response("ok");
2347
3148
  }
@@ -2371,6 +3172,21 @@ function backoffMs(firstDelay, attempt) {
2371
3172
  const grown = Math.min(firstDelay * 2 ** (attempt - 1), RECONNECT_CAP_MS);
2372
3173
  return Math.round(grown * (0.5 + Math.random() / 2));
2373
3174
  }
3175
+ function askFromWire(raw) {
3176
+ let said;
3177
+ try {
3178
+ said = JSON.parse(raw);
3179
+ } catch {
3180
+ return null;
3181
+ }
3182
+ if (typeof said !== "object" || said === null) {
3183
+ return null;
3184
+ }
3185
+ const fields = said;
3186
+ const ask2 = fields["ask"];
3187
+ const paymentHash = fields["payment_hash"];
3188
+ return typeof ask2 === "string" && typeof paymentHash === "string" ? { ask: ask2, paymentHash } : null;
3189
+ }
2374
3190
  function gatewayAt(baseUrl) {
2375
3191
  const at = URL.canParse(baseUrl) ? new URL(baseUrl) : null;
2376
3192
  if (at === null || at.protocol !== "http:" && at.protocol !== "https:") {
@@ -2380,8 +3196,8 @@ function gatewayAt(baseUrl) {
2380
3196
  }
2381
3197
  var ThunderBridge = class {
2382
3198
  baseUrl;
2383
- verify;
2384
3199
  token;
3200
+ handedOut = /* @__PURE__ */ new Map();
2385
3201
  secret;
2386
3202
  strangers = null;
2387
3203
  speaks = null;
@@ -2392,7 +3208,6 @@ var ThunderBridge = class {
2392
3208
  rails;
2393
3209
  constructor(baseUrl, options) {
2394
3210
  this.baseUrl = gatewayAt(baseUrl);
2395
- this.verify = options?.verify ?? true;
2396
3211
  this.token = options?.token ?? null;
2397
3212
  this.secret = options?.secret ?? null;
2398
3213
  this.serve = new Serve(this);
@@ -2434,6 +3249,7 @@ var ThunderBridge = class {
2434
3249
  * and `GatewayCheatError` when what comes back is not what you asked for
2435
3250
  */
2436
3251
  async mint(charge, options) {
3252
+ const askedAt = Math.floor(Date.now() / 1e3);
2437
3253
  const asked = await priced(charge);
2438
3254
  const sent = createRequestBody(
2439
3255
  asked,
@@ -2454,9 +3270,8 @@ var ThunderBridge = class {
2454
3270
  throw await problemFrom(response);
2455
3271
  }
2456
3272
  const payment = await mintedFrom(response);
2457
- if (this.verify) {
2458
- await proveOrigin(payment, asked);
2459
- }
3273
+ await proveOrigin(payment, asked, askedAt);
3274
+ this.handOut(payment, options?.idempotencyKey ?? null);
2460
3275
  return payment;
2461
3276
  }
2462
3277
  /**
@@ -2467,7 +3282,8 @@ var ThunderBridge = class {
2467
3282
  * it for one, and asking mints it
2468
3283
  */
2469
3284
  async quote(charge) {
2470
- const sent = quoteRequestBody(await priced(charge));
3285
+ const asked = await priced(charge);
3286
+ const sent = quoteRequestBody(asked);
2471
3287
  const response = await fetch(`${this.baseUrl}/quotes`, {
2472
3288
  method: "POST",
2473
3289
  headers: await this.sending("/quotes", sent),
@@ -2476,14 +3292,17 @@ var ThunderBridge = class {
2476
3292
  if (!response.ok) {
2477
3293
  throw await problemFrom(response);
2478
3294
  }
2479
- const quote = quoteFromWire(await response.json().catch(() => null));
2480
- if (quote === null) {
3295
+ const quote2 = quoteFromWire(await response.json().catch(() => null));
3296
+ if (quote2 === null) {
2481
3297
  throw new ProblemError({
2482
3298
  status: response.status,
2483
3299
  title: "The gateway answered with something that is not a quote"
2484
3300
  });
2485
3301
  }
2486
- return quote;
3302
+ if (!asked.paidTo.some((address) => address.toLowerCase() === quote2.lnAddress.toLowerCase())) {
3303
+ throw new GatewayCheatError("address_not_requested", "");
3304
+ }
3305
+ return quote2;
2487
3306
  }
2488
3307
  /**
2489
3308
  * The key this gateway signs webhooks with when you registered none of your own.
@@ -2516,8 +3335,8 @@ var ThunderBridge = class {
2516
3335
  * for both sorts: `kind` says whether the gateway minted it or was handed it,
2517
3336
  * and the address, amount and invoice are null on one it was never told
2518
3337
  */
2519
- async payment(id) {
2520
- const path = `/incoming-payments/${encodeURIComponent(id)}`;
3338
+ async payment(held) {
3339
+ const path = `/incoming-payments/${encodeURIComponent(held.id)}`;
2521
3340
  const response = await fetch(`${this.baseUrl}${path}`, {
2522
3341
  headers: await this.reading("GET", path)
2523
3342
  });
@@ -2527,7 +3346,7 @@ var ThunderBridge = class {
2527
3346
  if (!response.ok) {
2528
3347
  throw await problemFrom(response);
2529
3348
  }
2530
- return this.proven(await paymentFrom(response));
3349
+ return this.proven(await paymentFrom(response), held);
2531
3350
  }
2532
3351
  /**
2533
3352
  * List what this gateway is watching, newest first. Only a gateway started
@@ -2547,14 +3366,14 @@ var ThunderBridge = class {
2547
3366
  throw await problemFrom(response);
2548
3367
  }
2549
3368
  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") {
3369
+ const listed2 = Array.isArray(body?.payments) ? body.payments.map(paymentFromWire).filter((one) => one !== null).filter((one) => one.status !== "paid" || agreesWithItself(one)) : null;
3370
+ if (listed2 === null || typeof body?.settled_scanned !== "number") {
2552
3371
  throw new ProblemError({
2553
3372
  status: response.status,
2554
3373
  title: "The gateway answered with something that is not a payment list"
2555
3374
  });
2556
3375
  }
2557
- return { payments: listed, scanned: body.settled_scanned };
3376
+ return { payments: listed2, scanned: body.settled_scanned };
2558
3377
  }
2559
3378
  /**
2560
3379
  * Follow a payment over WebSocket until it is paid or expired, reconnecting
@@ -2562,15 +3381,15 @@ var ThunderBridge = class {
2562
3381
  * one that has answered is followed until its own expiry, so the wait always
2563
3382
  * ends by itself
2564
3383
  */
2565
- async settled(id, options) {
2566
- const ended = paymentFromWire(await this.followed(id, options));
3384
+ async settled(held, options) {
3385
+ const ended = paymentFromWire(await this.followed(held.id, options));
2567
3386
  if (ended === null) {
2568
3387
  throw new ProblemError({
2569
3388
  status: 200,
2570
3389
  title: "The gateway answered with something that is not a payment"
2571
3390
  });
2572
3391
  }
2573
- return this.proven(ended);
3392
+ return this.proven(ended, held);
2574
3393
  }
2575
3394
  followed(id, options) {
2576
3395
  const base = this.baseUrl.replace(/^http/, "ws");
@@ -2632,9 +3451,9 @@ var ThunderBridge = class {
2632
3451
  if (settled) {
2633
3452
  return;
2634
3453
  }
2635
- const opened = new WebSocket(url);
2636
- socket = opened;
2637
- opened.onmessage = (event) => {
3454
+ const opened2 = new WebSocket(url);
3455
+ socket = opened2;
3456
+ opened2.onmessage = (event) => {
2638
3457
  try {
2639
3458
  const frame = JSON.parse(String(event.data));
2640
3459
  const watched = paymentFromWire(frame);
@@ -2651,7 +3470,7 @@ var ThunderBridge = class {
2651
3470
  settle(() => reject(refused));
2652
3471
  }
2653
3472
  };
2654
- opened.onclose = again;
3473
+ opened2.onclose = again;
2655
3474
  };
2656
3475
  options?.signal?.addEventListener("abort", abort, { once: true });
2657
3476
  void connect();
@@ -2674,25 +3493,33 @@ var ThunderBridge = class {
2674
3493
  * the recipient's own wallet minted it, so a payer who pays the loser afterwards
2675
3494
  * really does pay twice and that shows up on `follow` as a second settlement to
2676
3495
  * refund.
3496
+ *
3497
+ * A leg the gateway is caught lying about before any leg is paid ends the wait
3498
+ * with its `GatewayCheatError`, even when another leg is paid after it, so a
3499
+ * detected cheat is never traded for a later win.
2677
3500
  */
2678
- async firstSettled(ids, options) {
2679
- if (ids.length === 0) {
3501
+ async firstSettled(held, options) {
3502
+ if (held.length === 0) {
2680
3503
  return null;
2681
3504
  }
2682
3505
  const stopLosers = new AbortController();
2683
3506
  const signal = options?.signal ? AbortSignal.any([stopLosers.signal, options.signal]) : stopLosers.signal;
2684
3507
  let refused = null;
2685
3508
  try {
2686
- const winner = await new Promise((resolve2) => {
2687
- let waiting = ids.length;
3509
+ const winner = await new Promise((resolve2, caught) => {
3510
+ let waiting = held.length;
2688
3511
  const lost = () => {
2689
3512
  waiting -= 1;
2690
3513
  if (waiting === 0) {
2691
3514
  resolve2(null);
2692
3515
  }
2693
3516
  };
2694
- for (const id of ids) {
2695
- this.settled(id, { ...options, signal }).then((watched) => watched.status === "paid" ? resolve2(watched) : lost()).catch((failure) => {
3517
+ for (const leg of held) {
3518
+ this.settled(leg, { ...options, signal }).then((watched) => watched.status === "paid" ? resolve2(watched) : lost()).catch((failure) => {
3519
+ if (failure instanceof GatewayCheatError) {
3520
+ caught(failure);
3521
+ return;
3522
+ }
2696
3523
  refused ??= failure;
2697
3524
  lost();
2698
3525
  });
@@ -2736,6 +3563,9 @@ var ThunderBridge = class {
2736
3563
  if (named !== null && watched.id !== named) {
2737
3564
  throw new GatewayCheatError("id_not_mine", watched.id);
2738
3565
  }
3566
+ if (watched.paymentHash.toLowerCase() !== handover.paymentHash.toLowerCase()) {
3567
+ throw new GatewayCheatError("hash_mismatch", watched.id);
3568
+ }
2739
3569
  return watched;
2740
3570
  }
2741
3571
  /**
@@ -2748,6 +3578,69 @@ var ThunderBridge = class {
2748
3578
  async nameFor(paymentHash) {
2749
3579
  return this.secret === null ? null : paymentNamedBy((await this.speaking()).publicKeyHex, paymentHash);
2750
3580
  }
3581
+ /**
3582
+ * Hold a socket open and answer what the gateway asks about this caller's own
3583
+ * payments, so a watch addressed to this caller settles without anybody
3584
+ * hosting a URL. Reconnects on its own until the returned function is called
3585
+ */
3586
+ attend(options) {
3587
+ const base = this.baseUrl.replace(/^http/, "ws");
3588
+ const firstDelay = options.reconnectDelayMs ?? RECONNECT_DELAY_MS;
3589
+ let socket = null;
3590
+ let retry;
3591
+ let stopped = false;
3592
+ let attempt = 0;
3593
+ const again = () => {
3594
+ if (stopped || options.reconnect === false) {
3595
+ return;
3596
+ }
3597
+ retry = setTimeout(() => void connect(), backoffMs(firstDelay, attempt));
3598
+ };
3599
+ const answered3 = async (opened2, raw) => {
3600
+ const asked = askFromWire(raw);
3601
+ if (asked === null) {
3602
+ return;
3603
+ }
3604
+ const preimage = await options.answer(asked.paymentHash);
3605
+ opened2.send(
3606
+ JSON.stringify(
3607
+ preimage === null ? { ask: asked.ask, settled: false } : { ask: asked.ask, settled: true, preimage }
3608
+ )
3609
+ );
3610
+ };
3611
+ const connect = async () => {
3612
+ attempt += 1;
3613
+ let url;
3614
+ try {
3615
+ url = `${base}/ws/tickets/${await this.wsTicket({ agent: true })}`;
3616
+ } catch (refused) {
3617
+ options.onError?.(refused);
3618
+ again();
3619
+ return;
3620
+ }
3621
+ if (stopped) {
3622
+ return;
3623
+ }
3624
+ const opened2 = new WebSocket(url);
3625
+ socket = opened2;
3626
+ opened2.onopen = () => {
3627
+ attempt = 1;
3628
+ };
3629
+ opened2.onmessage = (event) => {
3630
+ void answered3(opened2, String(event.data)).catch(
3631
+ (refused) => options.onError?.(refused)
3632
+ );
3633
+ };
3634
+ opened2.onerror = () => options.onError?.(new Error(`could not attend ${base}`));
3635
+ opened2.onclose = again;
3636
+ };
3637
+ void connect();
3638
+ return () => {
3639
+ stopped = true;
3640
+ clearTimeout(retry);
3641
+ socket?.close();
3642
+ };
3643
+ }
2751
3644
  /**
2752
3645
  * Follow every payment made to one trigger, replayed from the recent ones on
2753
3646
  * connect and then live, reconnecting on its own until the returned function
@@ -2824,7 +3717,7 @@ var ThunderBridge = class {
2824
3717
  });
2825
3718
  }
2826
3719
  needsTicket(asked) {
2827
- return asked === true || this.token !== null;
3720
+ return asked === true || this.token !== null || this.secret !== null;
2828
3721
  }
2829
3722
  async wsTicket(body) {
2830
3723
  return encodeURIComponent((await this.mintedTicket(body)).ticket);
@@ -2854,15 +3747,33 @@ var ThunderBridge = class {
2854
3747
  async reading(method, path, body = "") {
2855
3748
  return {
2856
3749
  ...this.token === null ? {} : { authorization: `Bearer ${this.token}` },
2857
- ...this.secret === null ? {} : await signedAs(await this.speaking(), method, path, body)
3750
+ ...this.secret === null ? {} : await signedAs(await this.speaking(), method, path, body, new URL(this.baseUrl).host)
2858
3751
  };
2859
3752
  }
2860
3753
  async speaking() {
2861
3754
  this.speaks ??= callerKey(this.secret ?? "");
2862
3755
  return await this.speaks;
2863
3756
  }
2864
- proven(payment) {
2865
- if (this.verify && payment.status === "paid" && !carriesProof(payment)) {
3757
+ handOut(payment, key) {
3758
+ const now = Math.floor(Date.now() / 1e3);
3759
+ for (const [hash2, earlier2] of this.handedOut) {
3760
+ if (earlier2.expiresAt <= now) {
3761
+ this.handedOut.delete(hash2);
3762
+ }
3763
+ }
3764
+ const hash = payment.paymentHash.toLowerCase();
3765
+ const earlier = this.handedOut.get(hash);
3766
+ if (earlier !== void 0 && (earlier.key === null || earlier.key !== key)) {
3767
+ throw new GatewayCheatError("invoice_reused", payment.id);
3768
+ }
3769
+ this.handedOut.set(hash, { key, expiresAt: payment.expiresAt });
3770
+ }
3771
+ proven(payment, held) {
3772
+ const other = held !== void 0 && (payment.id !== held.id || payment.paymentHash.toLowerCase() !== held.paymentHash.toLowerCase());
3773
+ if (other) {
3774
+ throw new GatewayCheatError("hash_mismatch", payment.id);
3775
+ }
3776
+ if (payment.status === "paid" && !agreesWithItself(payment)) {
2866
3777
  throw new GatewayCheatError("preimage_mismatch", payment.id);
2867
3778
  }
2868
3779
  return payment;
@@ -2916,6 +3827,7 @@ var Gateways = class {
2916
3827
  each;
2917
3828
  urls;
2918
3829
  onRefused;
3830
+ onCaught;
2919
3831
  constructor(baseUrls, options) {
2920
3832
  if (baseUrls.length === 0) {
2921
3833
  throw new Error("name at least one gateway to watch at");
@@ -2924,6 +3836,8 @@ var Gateways = class {
2924
3836
  this.each = baseUrls.map((baseUrl) => new ThunderBridge(baseUrl, options));
2925
3837
  this.onRefused = options?.onRefused ?? (() => {
2926
3838
  });
3839
+ this.onCaught = options?.onCaught ?? (() => {
3840
+ });
2927
3841
  }
2928
3842
  /** What this payment is called, which every gateway here will agree on */
2929
3843
  async nameFor(paymentHash) {
@@ -2957,7 +3871,7 @@ var Gateways = class {
2957
3871
  * saying so. When they all end without a payment, the first ending is the answer,
2958
3872
  * and when they all fail, the first failure is thrown
2959
3873
  */
2960
- async settled(id, options) {
3874
+ async settled(held, options) {
2961
3875
  const stopLosers = new AbortController();
2962
3876
  const signal = options?.signal ? AbortSignal.any([stopLosers.signal, options.signal]) : stopLosers.signal;
2963
3877
  const unpaid = [];
@@ -2971,14 +3885,17 @@ var Gateways = class {
2971
3885
  resolve2(null);
2972
3886
  }
2973
3887
  };
2974
- for (const gateway of this.each) {
2975
- gateway.settled(id, { ...options, signal }).then((watched) => {
3888
+ for (const [at, gateway] of this.each.entries()) {
3889
+ gateway.settled(held, { ...options, signal }).then((watched) => {
2976
3890
  if (watched.status === "paid") {
2977
3891
  return resolve2(watched);
2978
3892
  }
2979
3893
  unpaid.push(watched);
2980
3894
  lost();
2981
3895
  }).catch((failure) => {
3896
+ if (failure instanceof GatewayCheatError) {
3897
+ this.onCaught(this.urls[at], failure);
3898
+ }
2982
3899
  refused ??= failure;
2983
3900
  lost();
2984
3901
  });
@@ -3007,6 +3924,7 @@ var Gateways = class {
3007
3924
  ThunderBridge,
3008
3925
  UnverifiedRecipientError,
3009
3926
  WrapRefusedError,
3927
+ agreesWithItself,
3010
3928
  answerVerifyChallenge,
3011
3929
  carriesProof,
3012
3930
  decodeInvoice,