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.js CHANGED
@@ -98,11 +98,11 @@ var BLOCK_BYTES = 64;
98
98
  var LENGTH_BYTES = 8;
99
99
  var SCHEDULE_WORDS = 64;
100
100
  function sha256(bytes) {
101
- const padded = withPadding(bytes);
102
- const reader = new DataView(padded.buffer);
101
+ const padded2 = withPadding(bytes);
102
+ const reader = new DataView(padded2.buffer);
103
103
  const state = Uint32Array.from(INITIAL_STATE);
104
104
  const schedule = new Uint32Array(SCHEDULE_WORDS);
105
- for (let block = 0; block < padded.length; block += BLOCK_BYTES) {
105
+ for (let block = 0; block < padded2.length; block += BLOCK_BYTES) {
106
106
  fillSchedule(schedule, reader, block);
107
107
  compress(state, schedule);
108
108
  }
@@ -112,13 +112,13 @@ function sha256Hex(text3) {
112
112
  return bytesToHex(sha256(new TextEncoder().encode(text3)));
113
113
  }
114
114
  function withPadding(bytes) {
115
- const padded = new Uint8Array(
115
+ const padded2 = new Uint8Array(
116
116
  bytes.length + 1 + LENGTH_BYTES + BLOCK_BYTES - 1 & ~(BLOCK_BYTES - 1)
117
117
  );
118
- padded.set(bytes);
119
- padded[bytes.length] = 128;
120
- new DataView(padded.buffer).setUint32(padded.length - 4, bytes.length * 8, false);
121
- return padded;
118
+ padded2.set(bytes);
119
+ padded2[bytes.length] = 128;
120
+ new DataView(padded2.buffer).setUint32(padded2.length - 4, bytes.length * 8, false);
121
+ return padded2;
122
122
  }
123
123
  function fillSchedule(schedule, reader, block) {
124
124
  for (let at = 0; at < 16; at++) {
@@ -175,6 +175,7 @@ function rotate(value, by) {
175
175
 
176
176
  // ../core/bolt11.ts
177
177
  var BECH32_CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l";
178
+ var BECH32_GENERATOR = [996825010, 642813549, 513874426, 1027748829, 705979059];
178
179
  var CHECKSUM_WORDS = 6;
179
180
  var TIMESTAMP_WORDS = 7;
180
181
  var SIGNATURE_WORDS = 104;
@@ -192,18 +193,27 @@ var HRP_MULTIPLIER_MSAT = {
192
193
  n: MSAT_PER_BTC / 1e9,
193
194
  p: MSAT_PER_BTC / 1e12
194
195
  };
195
- var HRP_AMOUNT = /^ln[a-z]+?(\d+)([munp]?)$/;
196
+ var HRP = /^ln(?:bcrt|bc|tbs|tb|sb)(?:(\d+)([munp]?))?$/;
196
197
  function decodeInvoice(bolt11) {
197
198
  const parts = splitBech32(bolt11);
198
199
  if (parts === null) {
199
- return { paymentHash: null, descriptionHash: null, amountMsat: null, expiresAt: null };
200
+ return {
201
+ paymentHash: null,
202
+ descriptionHash: null,
203
+ amountMsat: null,
204
+ issuedAt: null,
205
+ expiresAt: null
206
+ };
200
207
  }
201
208
  const tagged = taggedFields(parts.words);
209
+ const issuedAt = issuedAtOf(parts.words);
210
+ const expiry = tagged.get(EXPIRY_TAG);
202
211
  return {
203
212
  paymentHash: hexTag(tagged, PAYMENT_HASH_TAG, PAYMENT_HASH_WORDS),
204
213
  descriptionHash: hexTag(tagged, DESCRIPTION_HASH_TAG, DESCRIPTION_HASH_WORDS),
205
214
  amountMsat: amountFromHrp(parts.hrp),
206
- expiresAt: expiryOf(parts.words, tagged)
215
+ issuedAt,
216
+ expiresAt: issuedAt === null ? null : issuedAt + (expiry ? readNumber(expiry) : DEFAULT_EXPIRY_SECS)
207
217
  };
208
218
  }
209
219
  function preimageMatchesHash(preimage, paymentHash) {
@@ -214,7 +224,7 @@ function preimageMatchesHash(preimage, paymentHash) {
214
224
  }
215
225
  function splitBech32(bolt11) {
216
226
  const lower = bolt11.toLowerCase();
217
- if (isBolt12Encoding(lower)) {
227
+ if (isBolt12Encoding(lower) || bolt11 !== lower && bolt11 !== bolt11.toUpperCase()) {
218
228
  return null;
219
229
  }
220
230
  const separator = lower.lastIndexOf("1");
@@ -233,7 +243,28 @@ function splitBech32(bolt11) {
233
243
  }
234
244
  words.push(value);
235
245
  }
236
- return { hrp: lower.slice(0, separator), words: words.slice(0, -CHECKSUM_WORDS) };
246
+ const hrp = lower.slice(0, separator);
247
+ if (!HRP.test(hrp) || bech32Polymod([...expandedHrp(hrp), ...words]) !== 1) {
248
+ return null;
249
+ }
250
+ return { hrp, words: words.slice(0, -CHECKSUM_WORDS) };
251
+ }
252
+ function expandedHrp(hrp) {
253
+ const codes = [...hrp].map((char) => char.charCodeAt(0));
254
+ return [...codes.map((code) => code >> 5), 0, ...codes.map((code) => code & 31)];
255
+ }
256
+ function bech32Polymod(values) {
257
+ let checksum = 1;
258
+ for (const value of values) {
259
+ const top = checksum >> 25;
260
+ checksum = (checksum & 33554431) << 5 ^ value;
261
+ for (let bit = 0; bit < 5; bit++) {
262
+ if (top >> bit & 1) {
263
+ checksum ^= BECH32_GENERATOR[bit];
264
+ }
265
+ }
266
+ }
267
+ return checksum;
237
268
  }
238
269
  function isBolt12Encoding(lower) {
239
270
  return lower.startsWith("lno") || lower.startsWith("lni") || lower.startsWith("lnr");
@@ -263,19 +294,17 @@ function hexTag(fields, tag, expectedWords) {
263
294
  }
264
295
  return bytesToHex(wordsToBytes(words));
265
296
  }
266
- function expiryOf(words, fields) {
297
+ function issuedAtOf(words) {
267
298
  if (words.length < TIMESTAMP_WORDS + SIGNATURE_WORDS) {
268
299
  return null;
269
300
  }
270
- const expiry = fields.get(EXPIRY_TAG);
271
- return readNumber(words.slice(0, TIMESTAMP_WORDS)) + (expiry ? readNumber(expiry) : DEFAULT_EXPIRY_SECS);
301
+ return readNumber(words.slice(0, TIMESTAMP_WORDS));
272
302
  }
273
303
  function amountFromHrp(hrp) {
274
- const match = HRP_AMOUNT.exec(hrp);
275
- if (match === null) {
304
+ const [, digits, multiplier] = HRP.exec(hrp) ?? [];
305
+ if (digits === void 0 || multiplier === "p" && !digits.endsWith("0")) {
276
306
  return null;
277
307
  }
278
- const [, digits, multiplier] = match;
279
308
  return multiplier ? Number(digits) * HRP_MULTIPLIER_MSAT[multiplier] : Number(digits) * MSAT_PER_BTC;
280
309
  }
281
310
  function readNumber(words) {
@@ -296,49 +325,148 @@ function wordsToBytes(words) {
296
325
  return Uint8Array.from(bytes);
297
326
  }
298
327
 
328
+ // ../core/hmac.ts
329
+ async function hmacHex(secret, payload) {
330
+ const raw = typeof secret === "string" ? new TextEncoder().encode(secret) : secret;
331
+ const key = await crypto.subtle.importKey(
332
+ "raw",
333
+ asBuffer(raw),
334
+ { name: "HMAC", hash: "SHA-256" },
335
+ false,
336
+ ["sign"]
337
+ );
338
+ const message = typeof payload === "string" ? new TextEncoder().encode(payload) : payload;
339
+ return bytesToHex(new Uint8Array(await crypto.subtle.sign("HMAC", key, asBuffer(message))));
340
+ }
341
+ function equalInConstantTime(one, other) {
342
+ if (one.length !== other.length) {
343
+ return false;
344
+ }
345
+ let difference = 0;
346
+ for (let at = 0; at < one.length; at++) {
347
+ difference |= one.charCodeAt(at) ^ other.charCodeAt(at);
348
+ }
349
+ return difference === 0;
350
+ }
351
+ function asBuffer(bytes) {
352
+ return bytes instanceof Uint8Array && bytes.buffer instanceof ArrayBuffer ? bytes : new Uint8Array(bytes);
353
+ }
354
+
299
355
  // ../core/sealed.ts
300
- var VERSION = "v1";
356
+ var VERSION = "v2";
357
+ var UNPADDED_VERSION = "v1";
358
+ var PURPOSE_VERSION = "v3";
301
359
  var IV_BYTES = 12;
302
360
  var MIN_SECRET_CHARS = 32;
303
361
  var MAX_PLAIN_BYTES = 3e3;
304
- var INFO = new TextEncoder().encode("thunder-bridge/sealed");
305
- async function seal(secret, plaintext) {
306
- const body = new TextEncoder().encode(plaintext);
307
- if (body.length > MAX_PLAIN_BYTES) {
308
- throw new Error(`sealed takes at most ${MAX_PLAIN_BYTES} bytes, this was ${body.length}`);
309
- }
310
- const key = await keyFor(secret);
311
- const iv = crypto.getRandomValues(new Uint8Array(IV_BYTES));
312
- const cipher = new Uint8Array(await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, body));
313
- const joined = new Uint8Array(iv.length + cipher.length);
314
- joined.set(iv);
315
- joined.set(cipher, iv.length);
316
- return `${VERSION}.${toBase64Url(joined)}`;
317
- }
318
- async function unseal(secret, sealed) {
319
- const key = await keyFor(secret);
320
- if (!sealed.startsWith(`${VERSION}.`)) {
362
+ var SIZE_CLASSES = [256, 1024, MAX_PLAIN_BYTES];
363
+ var END_OF_TEXT = 128;
364
+ var INFO = "thunder-bridge/sealed";
365
+ async function seal(secret, plaintext, paymentHash) {
366
+ return await sealUnder(
367
+ await keyFor(secret, INFO),
368
+ plaintext,
369
+ crypto.getRandomValues(new Uint8Array(IV_BYTES)),
370
+ VERSION,
371
+ boundTo(paymentHash)
372
+ );
373
+ }
374
+ async function sealFor(purpose, secret, plaintext, { stable = false } = {}) {
375
+ return await sealUnder(
376
+ await keyFor(secret, `${INFO}/${purpose}`),
377
+ plaintext,
378
+ stable ? await nonceOf(secret, plaintext) : crypto.getRandomValues(new Uint8Array(IV_BYTES)),
379
+ PURPOSE_VERSION,
380
+ new TextEncoder().encode(PURPOSE_VERSION)
381
+ );
382
+ }
383
+ async function unsealFor(purpose, secret, sealed) {
384
+ if (!sealed.startsWith(`${PURPOSE_VERSION}.`)) {
385
+ return await unseal(secret, sealed);
386
+ }
387
+ return await opened(
388
+ await keyFor(secret, `${INFO}/${purpose}`),
389
+ sealed.slice(PURPOSE_VERSION.length + 1),
390
+ new TextEncoder().encode(PURPOSE_VERSION)
391
+ );
392
+ }
393
+ async function nonceOf(secret, plaintext) {
394
+ const derived = await hmacHex(secret, `sealed-nonce|${plaintext}`);
395
+ const nonce = new Uint8Array(IV_BYTES);
396
+ for (let at = 0; at < IV_BYTES; at++) {
397
+ nonce[at] = Number.parseInt(derived.slice(at * 2, at * 2 + 2), 16);
398
+ }
399
+ return nonce;
400
+ }
401
+ async function sealUnder(key, plaintext, iv, version, additionalData) {
402
+ const body = padToClass(plaintext);
403
+ const cipher = new Uint8Array(
404
+ await crypto.subtle.encrypt({ name: "AES-GCM", iv, additionalData }, key, body)
405
+ );
406
+ const joined2 = new Uint8Array(iv.length + cipher.length);
407
+ joined2.set(iv);
408
+ joined2.set(cipher, iv.length);
409
+ return `${version}.${toBase64Url(joined2)}`;
410
+ }
411
+ async function unseal(secret, sealed, paymentHash) {
412
+ const key = await keyFor(secret, INFO);
413
+ const padded2 = sealed.startsWith(`${VERSION}.`);
414
+ const unbound = !padded2 && sealed.startsWith(`${UNPADDED_VERSION}.`);
415
+ if (!padded2 && (!unbound || paymentHash !== void 0)) {
321
416
  return null;
322
417
  }
323
- const joined = fromBase64Url(sealed.slice(VERSION.length + 1));
324
- if (joined === null || joined.length <= IV_BYTES) {
418
+ return await opened(
419
+ key,
420
+ sealed.slice(VERSION.length + 1),
421
+ padded2 ? boundTo(paymentHash) : void 0
422
+ );
423
+ }
424
+ async function opened(key, encoded, additionalData) {
425
+ const joined2 = fromBase64Url(encoded);
426
+ if (joined2 === null || joined2.length <= IV_BYTES) {
325
427
  return null;
326
428
  }
327
429
  try {
328
- const body = await crypto.subtle.decrypt(
329
- { name: "AES-GCM", iv: joined.slice(0, IV_BYTES) },
330
- key,
331
- joined.slice(IV_BYTES)
430
+ const body = new Uint8Array(
431
+ await crypto.subtle.decrypt(
432
+ { name: "AES-GCM", iv: joined2.slice(0, IV_BYTES), additionalData },
433
+ key,
434
+ joined2.slice(IV_BYTES)
435
+ )
332
436
  );
333
- return new TextDecoder().decode(body);
437
+ const text3 = additionalData === void 0 ? body : unpad(body);
438
+ return text3 === null ? null : new TextDecoder().decode(text3);
334
439
  } catch {
335
440
  return null;
336
441
  }
337
442
  }
338
- async function keyFor(secret) {
443
+ function refuseAWeakSecret(secret) {
339
444
  if (secret.length < MIN_SECRET_CHARS) {
340
445
  throw new Error(`the sealing secret needs ${MIN_SECRET_CHARS} characters of randomness`);
341
446
  }
447
+ }
448
+ function boundTo(paymentHash) {
449
+ return new TextEncoder().encode(
450
+ paymentHash === void 0 ? VERSION : `${VERSION}|${paymentHash.toLowerCase()}`
451
+ );
452
+ }
453
+ function padToClass(plaintext) {
454
+ const text3 = new TextEncoder().encode(plaintext);
455
+ const size = SIZE_CLASSES.find((limit) => text3.length <= limit);
456
+ if (size === void 0) {
457
+ throw new Error(`sealed takes at most ${MAX_PLAIN_BYTES} bytes, this was ${text3.length}`);
458
+ }
459
+ const body = new Uint8Array(size + 1);
460
+ body.set(text3);
461
+ body[text3.length] = END_OF_TEXT;
462
+ return body;
463
+ }
464
+ function unpad(body) {
465
+ const end = body.lastIndexOf(END_OF_TEXT);
466
+ return end === -1 ? null : body.subarray(0, end);
467
+ }
468
+ async function keyFor(secret, info) {
469
+ refuseAWeakSecret(secret);
342
470
  const material = await crypto.subtle.importKey(
343
471
  "raw",
344
472
  new TextEncoder().encode(secret),
@@ -347,7 +475,12 @@ async function keyFor(secret) {
347
475
  ["deriveKey"]
348
476
  );
349
477
  return crypto.subtle.deriveKey(
350
- { name: "HKDF", hash: "SHA-256", salt: new Uint8Array(0), info: INFO },
478
+ {
479
+ name: "HKDF",
480
+ hash: "SHA-256",
481
+ salt: new Uint8Array(0),
482
+ info: new TextEncoder().encode(info)
483
+ },
351
484
  material,
352
485
  { name: "AES-GCM", length: 256 },
353
486
  false,
@@ -535,11 +668,11 @@ function coinbase(baseUrl = "https://api.coinbase.com") {
535
668
  return async (currency) => {
536
669
  const asked = currency.toUpperCase();
537
670
  const answer = await asJson(`${baseUrl}/v2/prices/BTC-${asked}/spot`, "coinbase");
538
- const quoted = answer.data;
539
- if (quoted?.currency !== asked) {
540
- throw new Error(`coinbase quoted ${quoted?.currency} when asked for ${asked}`);
671
+ const quoted2 = answer.data;
672
+ if (quoted2?.currency !== asked) {
673
+ throw new Error(`coinbase quoted ${quoted2?.currency} when asked for ${asked}`);
541
674
  }
542
- return asMinor(quoted.amount, "coinbase", asked);
675
+ return asMinor(quoted2.amount, "coinbase", asked);
543
676
  };
544
677
  }
545
678
  function kraken(baseUrl = "https://api.kraken.com") {
@@ -588,19 +721,19 @@ function medianOf(tickers = [coinbase(), kraken(), bitstamp(), coinmate()], opti
588
721
  return remembered.price;
589
722
  }
590
723
  const answers = await Promise.allSettled(tickers.map((ticker) => ticker(asked)));
591
- const quoted = answers.filter((answer) => answer.status === "fulfilled").map((answer) => answer.value).sort((one, other) => one - other);
592
- if (quoted.length < minVenues) {
724
+ const quoted2 = answers.filter((answer) => answer.status === "fulfilled").map((answer) => answer.value).sort((one, other) => one - other);
725
+ if (quoted2.length < minVenues) {
593
726
  throw new Error(
594
- `${quoted.length} of ${tickers.length} venues quoted ${asked}, ${minVenues} wanted: ${refusals(answers)}`
727
+ `${quoted2.length} of ${tickers.length} venues quoted ${asked}, ${minVenues} wanted: ${refusals(answers)}`
595
728
  );
596
729
  }
597
- const spread2 = spreadBpsOf(quoted);
730
+ const spread2 = spreadBpsOf(quoted2);
598
731
  if (spread2 > maxSpread) {
599
732
  throw new Error(
600
- `venues disagree on ${asked} by ${Math.round(spread2)} bps, more than the ${maxSpread} allowed: ${quoted.join(", ")}`
733
+ `venues disagree on ${asked} by ${Math.round(spread2)} bps, more than the ${maxSpread} allowed: ${quoted2.join(", ")}`
601
734
  );
602
735
  }
603
- const price = middleOf(quoted);
736
+ const price = middleOf(quoted2);
604
737
  held.set(asked, { at: Date.now(), price });
605
738
  return price;
606
739
  };
@@ -631,10 +764,10 @@ async function asJson(url, venue) {
631
764
  }
632
765
  return answer.json();
633
766
  }
634
- function asMinor(quoted, venue, currency) {
635
- const major2 = Number(quoted);
767
+ function asMinor(quoted2, venue, currency) {
768
+ const major2 = Number(quoted2);
636
769
  if (!Number.isFinite(major2) || major2 <= 0) {
637
- throw new Error(`${venue} quoted ${String(quoted)} for ${currency.toUpperCase()}`);
770
+ throw new Error(`${venue} quoted ${String(quoted2)} for ${currency.toUpperCase()}`);
638
771
  }
639
772
  return Math.round(major2 * minorScaleOf(currency));
640
773
  }
@@ -722,6 +855,16 @@ var SEED_BYTES = 32;
722
855
  var SIGNATURE_BYTES = 64;
723
856
  var PUBLIC_KEY_BYTES = 32;
724
857
  var PKCS8_HEADER = hexToBytes("302e020100300506032b657004220420");
858
+ var SIGN_BIT_CLEARED = 127;
859
+ var SMALL_ORDER = [
860
+ "00".repeat(32),
861
+ `01${"00".repeat(31)}`,
862
+ "26e8958fc2b227b045c3f489f2ef98f0d5dfac05d3c63339b13802886d53fc05",
863
+ "c7176a703d4dd84fba3c0b760d10670f2a2053fa2c39ccc64ec7fd7792ac037a",
864
+ `ec${"ff".repeat(30)}7f`,
865
+ `ed${"ff".repeat(30)}7f`,
866
+ `ee${"ff".repeat(30)}7f`
867
+ ];
725
868
  async function signingKeyFromSeed(seed) {
726
869
  if (seed.length !== SEED_BYTES) {
727
870
  throw new Error(`an ed25519 seed is ${SEED_BYTES} bytes`);
@@ -729,24 +872,27 @@ async function signingKeyFromSeed(seed) {
729
872
  const wrapped = new Uint8Array(PKCS8_HEADER.length + SEED_BYTES);
730
873
  wrapped.set(PKCS8_HEADER);
731
874
  wrapped.set(seed, PKCS8_HEADER.length);
732
- const key = await crypto.subtle.importKey("pkcs8", asBuffer(wrapped), ALGORITHM, true, ["sign"]);
875
+ const key = await crypto.subtle.importKey("pkcs8", asBuffer2(wrapped), ALGORITHM, true, ["sign"]);
733
876
  const jwk = await crypto.subtle.exportKey("jwk", key);
734
877
  return {
735
878
  publicKeyHex: bytesToHex(fromBase64Url2(jwk.x ?? "")),
736
- sign: async (payload) => bytesToHex(new Uint8Array(await crypto.subtle.sign(ALGORITHM, key, asBuffer(payload))))
879
+ sign: async (payload) => bytesToHex(new Uint8Array(await crypto.subtle.sign(ALGORITHM, key, asBuffer2(payload))))
737
880
  };
738
881
  }
739
882
  async function verifyHex(publicKeyHex, signatureHex, payload) {
740
883
  if (!isHex(publicKeyHex) || publicKeyHex.length !== PUBLIC_KEY_BYTES * 2) {
741
884
  return false;
742
885
  }
886
+ if (hasSmallOrder(publicKeyHex)) {
887
+ return false;
888
+ }
743
889
  if (!isHex(signatureHex) || signatureHex.length !== SIGNATURE_BYTES * 2) {
744
890
  return false;
745
891
  }
746
892
  try {
747
893
  const key = await crypto.subtle.importKey(
748
894
  "raw",
749
- asBuffer(hexToBytes(publicKeyHex)),
895
+ asBuffer2(hexToBytes(publicKeyHex)),
750
896
  ALGORITHM,
751
897
  false,
752
898
  ["verify"]
@@ -754,58 +900,38 @@ async function verifyHex(publicKeyHex, signatureHex, payload) {
754
900
  return await crypto.subtle.verify(
755
901
  ALGORITHM,
756
902
  key,
757
- asBuffer(hexToBytes(signatureHex)),
758
- asBuffer(payload)
903
+ asBuffer2(hexToBytes(signatureHex)),
904
+ asBuffer2(payload)
759
905
  );
760
906
  } catch {
761
907
  return false;
762
908
  }
763
909
  }
910
+ function hasSmallOrder(publicKeyHex) {
911
+ const point = hexToBytes(publicKeyHex);
912
+ point[PUBLIC_KEY_BYTES - 1] = point[PUBLIC_KEY_BYTES - 1] & SIGN_BIT_CLEARED;
913
+ return SMALL_ORDER.includes(bytesToHex(point));
914
+ }
764
915
  function fromBase64Url2(text3) {
765
- const padded = text3.replace(/-/g, "+").replace(/_/g, "/");
766
- const raw = atob(padded + "=".repeat((4 - padded.length % 4) % 4));
916
+ const padded2 = text3.replace(/-/g, "+").replace(/_/g, "/");
917
+ const raw = atob(padded2 + "=".repeat((4 - padded2.length % 4) % 4));
767
918
  const bytes = new Uint8Array(raw.length);
768
919
  for (let at = 0; at < raw.length; at++) {
769
920
  bytes[at] = raw.charCodeAt(at);
770
921
  }
771
922
  return bytes;
772
923
  }
773
- function asBuffer(bytes) {
774
- return bytes.buffer instanceof ArrayBuffer ? bytes : new Uint8Array(bytes);
775
- }
776
-
777
- // ../core/hmac.ts
778
- async function hmacHex(secret, payload) {
779
- const raw = typeof secret === "string" ? new TextEncoder().encode(secret) : secret;
780
- const key = await crypto.subtle.importKey(
781
- "raw",
782
- asBuffer2(raw),
783
- { name: "HMAC", hash: "SHA-256" },
784
- false,
785
- ["sign"]
786
- );
787
- const message = typeof payload === "string" ? new TextEncoder().encode(payload) : payload;
788
- return bytesToHex(new Uint8Array(await crypto.subtle.sign("HMAC", key, asBuffer2(message))));
789
- }
790
- function equalInConstantTime(one, other) {
791
- if (one.length !== other.length) {
792
- return false;
793
- }
794
- let difference = 0;
795
- for (let at = 0; at < one.length; at++) {
796
- difference |= one.charCodeAt(at) ^ other.charCodeAt(at);
797
- }
798
- return difference === 0;
799
- }
800
924
  function asBuffer2(bytes) {
801
- return bytes instanceof Uint8Array && bytes.buffer instanceof ArrayBuffer ? bytes : new Uint8Array(bytes);
925
+ return bytes.buffer instanceof ArrayBuffer ? bytes : new Uint8Array(bytes);
802
926
  }
803
927
 
804
928
  // ../core/caller.ts
805
929
  var KEY_HEADER = "x-client-key";
806
930
  var SIGNATURE_HEADER = "x-signature";
807
931
  var TIMESTAMP_HEADER = "x-timestamp";
932
+ var NONCE_HEADER = "x-nonce";
808
933
  var PREFIX = "ed25519=";
934
+ var NONCE_BYTES = 16;
809
935
  var SIGNING_LABEL = "request-signing";
810
936
  async function callerKey(secret) {
811
937
  return await signingKeyFromSeed(hexToBytes(await hmacHex(secret, SIGNING_LABEL)));
@@ -813,17 +939,21 @@ async function callerKey(secret) {
813
939
  function paymentNamedBy(caller, paymentHash) {
814
940
  return sha256Hex(`${caller}.payment-id.${paymentHash}`);
815
941
  }
816
- async function signedAs(key, method, path, body) {
942
+ async function signedAs(key, method, path, body, host) {
817
943
  const timestamp = String(Math.floor(Date.now() / 1e3));
944
+ const nonce = bytesToHex(crypto.getRandomValues(new Uint8Array(NONCE_BYTES)));
818
945
  return {
819
946
  [KEY_HEADER]: key.publicKeyHex,
820
947
  [TIMESTAMP_HEADER]: timestamp,
821
- [SIGNATURE_HEADER]: `${PREFIX}${await key.sign(spoken(timestamp, method, path, body))}`
948
+ [NONCE_HEADER]: nonce,
949
+ [SIGNATURE_HEADER]: `${PREFIX}${await key.sign(spokenTo(host, timestamp, nonce, method, path, body))}`
822
950
  };
823
951
  }
824
- function spoken(timestamp, method, path, body) {
952
+ function spokenTo(host, timestamp, nonce, method, path, body) {
825
953
  return new TextEncoder().encode(
826
- `${timestamp}.${method.toUpperCase()}.${path}.${sha256Hex(body)}`
954
+ ["v2", host.toLowerCase(), timestamp, nonce, method.toUpperCase(), path, sha256Hex(body)].join(
955
+ "\n"
956
+ )
827
957
  );
828
958
  }
829
959
 
@@ -876,8 +1006,8 @@ function ipv4IsGlobal(literal) {
876
1006
  if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) {
877
1007
  return false;
878
1008
  }
879
- const [a, b] = parts;
880
- if (a === 0 || a === 10 || a === 127 || a >= 240) {
1009
+ const [a, b, c] = parts;
1010
+ if (a === 0 || a === 10 || a === 127 || a >= 224) {
881
1011
  return false;
882
1012
  }
883
1013
  if (a === 100 && b >= 64 && b < 128) {
@@ -889,7 +1019,13 @@ function ipv4IsGlobal(literal) {
889
1019
  if (a === 172 && b >= 16 && b < 32) {
890
1020
  return false;
891
1021
  }
892
- if (a === 192 && b === 168) {
1022
+ if (a === 192 && (b === 168 || b === 0 && (c === 0 || c === 2))) {
1023
+ return false;
1024
+ }
1025
+ if (a === 198 && (b === 18 || b === 19 || b === 51 && c === 100)) {
1026
+ return false;
1027
+ }
1028
+ if (a === 203 && b === 0 && c === 113) {
893
1029
  return false;
894
1030
  }
895
1031
  return true;
@@ -899,29 +1035,59 @@ function ipv6IsGlobal(literal) {
899
1035
  if (groups.length !== 8 || groups.some(Number.isNaN)) {
900
1036
  return false;
901
1037
  }
902
- const leading = groups.slice(0, 5).every((group) => group === 0);
903
- if (leading && groups[5] === 0 && groups[6] === 0 && groups[7] <= 1) {
1038
+ const [first, second, third, fourth, fifth, sixth, high, low] = groups;
1039
+ const embedded = (one, other) => ipv4IsGlobal(`${one >> 8}.${one & 255}.${other >> 8}.${other & 255}`);
1040
+ const zeroBefore = (count) => groups.slice(0, count).every((group) => group === 0);
1041
+ if (zeroBefore(5) && sixth === 65535) {
1042
+ return embedded(high, low);
1043
+ }
1044
+ if (zeroBefore(6) || zeroBefore(4) && fifth === 65535 && sixth === 0) {
1045
+ return false;
1046
+ }
1047
+ if (first === 100 && second === 65435) {
1048
+ return third === 0 && fourth === 0 && fifth === 0 && sixth === 0 && embedded(high, low);
1049
+ }
1050
+ if (first === 8194) {
1051
+ return embedded(second, third);
1052
+ }
1053
+ if (first === 8193 && second === 0) {
904
1054
  return false;
905
1055
  }
906
- if (leading && groups[5] === 65535) {
907
- const [high, low] = [groups[6], groups[7]];
908
- return ipv4IsGlobal(`${high >> 8}.${high & 255}.${low >> 8}.${low & 255}`);
1056
+ if (first === 256 && second === 0 && third === 0 && fourth === 0) {
1057
+ return false;
909
1058
  }
910
- const first = groups[0];
911
- if ((first & 65024) === 64512) {
1059
+ if ((first & 65024) === 64512 || (first & 65280) === 65280) {
912
1060
  return false;
913
1061
  }
914
- if ((first & 65472) === 65152) {
1062
+ if ((first & 65472) === 65152 || (first & 65472) === 65216) {
915
1063
  return false;
916
1064
  }
917
1065
  return true;
918
1066
  }
919
1067
  function ipv6Groups(address) {
920
- const [head = "", tail] = address.split("::");
1068
+ const halves = withDottedTailAsGroups(address).split("::");
1069
+ const [head = "", tail] = halves;
921
1070
  const left = head.split(":").filter((group) => group !== "");
922
1071
  const right = (tail ?? "").split(":").filter((group) => group !== "");
923
- const gap = tail === void 0 ? [] : Array(8 - left.length - right.length).fill("0");
924
- return [...left, ...gap, ...right].map((group) => parseInt(group, 16));
1072
+ const missing = 8 - left.length - right.length;
1073
+ if (halves.length > 2 || missing < 0) {
1074
+ return [Number.NaN];
1075
+ }
1076
+ const gap = tail === void 0 ? [] : Array(missing).fill("0");
1077
+ return [...left, ...gap, ...right].map(
1078
+ (group) => /^[0-9a-f]{1,4}$/.test(group) ? parseInt(group, 16) : Number.NaN
1079
+ );
1080
+ }
1081
+ function withDottedTailAsGroups(address) {
1082
+ const dotted = /^(.*:)(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/.exec(address);
1083
+ if (dotted === null) {
1084
+ return address;
1085
+ }
1086
+ const [a, b, c, d] = dotted[2].split(".").map(Number);
1087
+ if ([a, b, c, d].some((part) => part > 255)) {
1088
+ return `${dotted[1]}x`;
1089
+ }
1090
+ return `${dotted[1]}${(a << 8 | b).toString(16)}:${(c << 8 | d).toString(16)}`;
925
1091
  }
926
1092
 
927
1093
  // ../core/outbound.ts
@@ -949,6 +1115,9 @@ async function ask(send, url, sent = {}) {
949
1115
  }
950
1116
  await response.body?.cancel();
951
1117
  const next = new URL(location, target).toString();
1118
+ if (sent.staysOnOrigin && !sameOrigin(target, next)) {
1119
+ throw new Error(`${target} redirected off its own origin, to ${next}`);
1120
+ }
952
1121
  carried = KEEPS_THE_METHOD.includes(response.status) ? carried : withoutBody(carried);
953
1122
  carried = sameOrigin(target, next) ? carried : withoutCredentials(carried);
954
1123
  target = next;
@@ -966,14 +1135,39 @@ async function addressesToReach(url) {
966
1135
  return found;
967
1136
  }
968
1137
  async function resolved(url) {
969
- const { lookup } = await import("dns/promises");
970
- const { setTimeout: sleep } = await import("timers/promises");
1138
+ const { isIP } = await import("net");
971
1139
  const host = new URL(url).hostname.replace(/^\[|]$/g, "");
1140
+ const literal = isIP(host);
1141
+ if (literal !== 0) {
1142
+ return [{ address: host, family: literal }];
1143
+ }
1144
+ const { Resolver } = await import("dns/promises");
1145
+ const { setTimeout: sleep } = await import("timers/promises");
1146
+ const resolver = new Resolver({ timeout: LOOKUP_TIMEOUT_MS, tries: 1 });
972
1147
  const found = await Promise.race([
973
- lookup(host, { all: true, verbatim: true }).catch(() => []),
1148
+ Promise.all([
1149
+ resolver.resolve4(host).catch(() => []),
1150
+ resolver.resolve6(host).catch(() => [])
1151
+ ]),
974
1152
  sleep(LOOKUP_TIMEOUT_MS, null, { ref: false })
975
1153
  ]);
976
- return found ?? [];
1154
+ cancelled(resolver);
1155
+ if (found === null) {
1156
+ return [];
1157
+ }
1158
+ const [four, six] = found;
1159
+ return [
1160
+ ...four.map((address) => ({ address, family: 4 })),
1161
+ ...six.map((address) => ({ address, family: 6 }))
1162
+ ];
1163
+ }
1164
+ function cancelled(resolver) {
1165
+ try {
1166
+ resolver.cancel();
1167
+ return true;
1168
+ } catch {
1169
+ return false;
1170
+ }
977
1171
  }
978
1172
  async function answerOf(response) {
979
1173
  const { status, ok, headers } = response;
@@ -995,15 +1189,17 @@ async function answerOf(response) {
995
1189
  return { status, ok, body: text(read), truncated: true, headers };
996
1190
  }
997
1191
  function withoutBody(sent) {
998
- const headers = { ...sent.headers };
999
- delete headers["content-type"];
1192
+ const headers = Object.fromEntries(
1193
+ Object.entries(sent.headers ?? {}).filter(([name]) => name.toLowerCase() !== "content-type")
1194
+ );
1000
1195
  return { ...sent, method: "GET", headers, body: void 0 };
1001
1196
  }
1002
1197
  function withoutCredentials(sent) {
1003
- const headers = { ...sent.headers };
1004
- for (const named of CREDENTIALS) {
1005
- delete headers[named];
1006
- }
1198
+ const headers = Object.fromEntries(
1199
+ Object.entries(sent.headers ?? {}).filter(
1200
+ ([name]) => !CREDENTIALS.includes(name.toLowerCase())
1201
+ )
1202
+ );
1007
1203
  return { ...sent, headers };
1008
1204
  }
1009
1205
  function text(chunks) {
@@ -1046,6 +1242,22 @@ async function resolve(send, addresses, amountMsat) {
1046
1242
  );
1047
1243
  return served.won;
1048
1244
  }
1245
+ async function quote(send, addresses, amountMsat) {
1246
+ const served = await firstThatServes(
1247
+ addresses,
1248
+ (address2, deadline) => probe(send, address2, amountMsat, deadline)
1249
+ );
1250
+ const { address, pay } = served.won;
1251
+ return {
1252
+ won: {
1253
+ address,
1254
+ minMsat: pay.minSendable,
1255
+ maxMsat: pay.maxSendable,
1256
+ metadata: pay.metadata
1257
+ },
1258
+ refusals: served.refusals
1259
+ };
1260
+ }
1049
1261
  async function firstThatServes(addresses, attempt) {
1050
1262
  const deadline = AbortSignal.timeout(RESOLVE_TIMEOUT_MS);
1051
1263
  const refusals3 = [];
@@ -1138,7 +1350,7 @@ function decodeIssued(address, bolt11) {
1138
1350
  return { paymentHash, descriptionHash, amountMsat, expiresAt };
1139
1351
  }
1140
1352
  async function checkSettled(send, verifyUrl, paymentHash) {
1141
- const answer = await answeredJson(send, verifyUrl);
1353
+ const answer = await answeredJson(send, verifyUrl, { staysOnOrigin: true });
1142
1354
  const asked = {
1143
1355
  pace: paceAskedFor(answer.headers),
1144
1356
  ceiling: ceilingAskedFor(answer.headers)
@@ -1196,10 +1408,10 @@ function isNothingButAHost(domain) {
1196
1408
  }
1197
1409
  }
1198
1410
  async function fetchJson(send, url, deadline) {
1199
- return (await answeredJson(send, url, deadline)).said;
1411
+ return (await answeredJson(send, url, { deadline })).said;
1200
1412
  }
1201
- async function answeredJson(send, url, deadline) {
1202
- const answer = await ask(send, url, { headers: { accept: "application/json" }, deadline });
1413
+ async function answeredJson(send, url, sent) {
1414
+ const answer = await ask(send, url, { headers: { accept: "application/json" }, ...sent });
1203
1415
  if (!answer.ok) {
1204
1416
  throw new Error(`${url} answered ${answer.status}`);
1205
1417
  }
@@ -1227,12 +1439,17 @@ var pinnedToTheAddressWeVerified = async (url, sent, signal, at) => {
1227
1439
  }
1228
1440
  const call = request(reaching(asked, sent, at, first), (answer) => {
1229
1441
  const status = answer.statusCode ?? 502;
1230
- settle(
1231
- new Response(
1232
- CARRIES_NO_BODY.includes(status) ? null : Readable.toWeb(answer),
1233
- { status, headers: headersOf(answer.headers) }
1234
- )
1235
- );
1442
+ try {
1443
+ settle(
1444
+ new Response(
1445
+ CARRIES_NO_BODY.includes(status) ? null : Readable.toWeb(answer),
1446
+ { status, headers: headersOf(answer.headers) }
1447
+ )
1448
+ );
1449
+ } catch (unreadable) {
1450
+ answer.destroy();
1451
+ fail(new Error(`${url} answered with something HTTP cannot carry`, { cause: unreadable }));
1452
+ }
1236
1453
  });
1237
1454
  const give = () => call.destroy(new Error(`${url} took longer than allowed`));
1238
1455
  signal.addEventListener("abort", give, { once: true });
@@ -1279,6 +1496,182 @@ function headersOf(raw) {
1279
1496
  return headers;
1280
1497
  }
1281
1498
 
1499
+ // ../core/delivery.ts
1500
+ function deliverySigned(url, timestamp, body) {
1501
+ const endpoint = new URL(url);
1502
+ return new TextEncoder().encode(
1503
+ ["v2", `${endpoint.origin}${endpoint.pathname}`, timestamp, body].join("\n")
1504
+ );
1505
+ }
1506
+
1507
+ // src/verify.ts
1508
+ var HTTP_TIMEOUT_MS2 = 15e3;
1509
+ var ISSUED_SKEW_SECS = 300;
1510
+ var NAMES_AN_ADDRESS = ["text/identifier", "text/email"];
1511
+ var DEFAULT_WRAP_PROPORTION = 0.01;
1512
+ var DEFAULT_WRAP_BASE_MSAT = 1e3;
1513
+ async function proveOrigin(payment, asked, askedAt) {
1514
+ const cheat = (code) => new GatewayCheatError(code, payment.id);
1515
+ const listed2 = asked.paidTo.find((address) => equalIgnoringCase(address, payment.lnAddress));
1516
+ if (listed2 === void 0) {
1517
+ throw cheat("address_not_requested");
1518
+ }
1519
+ if (payment.amountMsat !== asked.amountMsat) {
1520
+ throw cheat("amount_mismatch");
1521
+ }
1522
+ const invoice = decodeInvoice(payment.bolt11);
1523
+ if (invoice.paymentHash === null || !equalIgnoringCase(invoice.paymentHash, payment.paymentHash)) {
1524
+ throw cheat("hash_mismatch");
1525
+ }
1526
+ if (invoice.amountMsat !== asked.amountMsat) {
1527
+ throw cheat("amount_mismatch");
1528
+ }
1529
+ if (askedAt !== void 0 && (invoice.issuedAt ?? 0) < askedAt - ISSUED_SKEW_SECS) {
1530
+ throw cheat("invoice_stale");
1531
+ }
1532
+ const payRequest = await payRequestFor(listed2, payment);
1533
+ if (typeof payRequest.metadata !== "string" || typeof payRequest.callback !== "string") {
1534
+ throw new UnverifiedRecipientError(payment.lnAddress, payment.id, "no payRequest served");
1535
+ }
1536
+ if (!namesTheAddress(payRequest.metadata, listed2)) {
1537
+ throw new UnverifiedRecipientError(
1538
+ payment.lnAddress,
1539
+ payment.id,
1540
+ "its metadata names no text/identifier for this address, so no invoice binds to it"
1541
+ );
1542
+ }
1543
+ if (invoice.descriptionHash !== sha256Hex(payRequest.metadata)) {
1544
+ throw cheat("description_hash_mismatch");
1545
+ }
1546
+ if (!sameOrigin(payment.verifyUrl, payRequest.callback)) {
1547
+ throw cheat("verify_url_foreign");
1548
+ }
1549
+ const issued = await reachable(payment.verifyUrl, payment);
1550
+ if (typeof issued.pr !== "string" || !equalIgnoringCase(issued.pr, payment.bolt11)) {
1551
+ throw cheat("invoice_not_issued");
1552
+ }
1553
+ if (askedAt !== void 0 && issued.settled === true) {
1554
+ throw cheat("invoice_settled");
1555
+ }
1556
+ }
1557
+ function namesTheAddress(metadata, address) {
1558
+ try {
1559
+ const entries = JSON.parse(metadata);
1560
+ return Array.isArray(entries) && entries.some(
1561
+ (entry) => Array.isArray(entry) && NAMES_AN_ADDRESS.includes(entry[0]) && typeof entry[1] === "string" && equalIgnoringCase(entry[1], address)
1562
+ );
1563
+ } catch {
1564
+ return false;
1565
+ }
1566
+ }
1567
+ async function proveSettlement(payment, asked) {
1568
+ await proveOrigin(payment, asked);
1569
+ const verified = await reachable(payment.verifyUrl, payment);
1570
+ if (verified.settled !== true || typeof verified.preimage !== "string") {
1571
+ return null;
1572
+ }
1573
+ if (!preimageMatchesHash(verified.preimage, payment.paymentHash)) {
1574
+ throw new GatewayCheatError("preimage_mismatch", payment.id);
1575
+ }
1576
+ return verified.preimage;
1577
+ }
1578
+ function agreesWithItself(report) {
1579
+ if (report.status !== "paid" || report.preimage === null) {
1580
+ return false;
1581
+ }
1582
+ if (report.bolt11 !== void 0 && report.bolt11 !== null) {
1583
+ const invoiceHash = decodeInvoice(report.bolt11).paymentHash;
1584
+ if (invoiceHash === null || !equalIgnoringCase(invoiceHash, report.paymentHash)) {
1585
+ return false;
1586
+ }
1587
+ }
1588
+ return preimageMatchesHash(report.preimage, report.paymentHash);
1589
+ }
1590
+ var carriesProof = agreesWithItself;
1591
+ function wrapFeeCeiling(amountMsat, allowance) {
1592
+ const proportional = amountMsat * (allowance?.proportion ?? DEFAULT_WRAP_PROPORTION);
1593
+ return msat(Math.max(allowance?.baseMsat ?? DEFAULT_WRAP_BASE_MSAT, Math.floor(proportional)));
1594
+ }
1595
+ function proveWrapped(wrapped, recipient, allowance) {
1596
+ const offered = decodeInvoice(wrapped);
1597
+ const owed = decodeInvoice(recipient);
1598
+ const refuse2 = (code, detail) => {
1599
+ throw new WrapRefusedError(code, detail);
1600
+ };
1601
+ if (offered.paymentHash === null || offered.amountMsat === null || offered.expiresAt === null) {
1602
+ refuse2("undecodable", "the wrapped invoice carries no hash, amount or expiry");
1603
+ }
1604
+ if (owed.paymentHash === null || owed.amountMsat === null || owed.expiresAt === null) {
1605
+ refuse2("undecodable", "the recipient's invoice carries no hash, amount or expiry");
1606
+ }
1607
+ if (offered.paymentHash?.toLowerCase() !== owed.paymentHash?.toLowerCase()) {
1608
+ refuse2("hash_mismatch", "settling the wrap would not settle the recipient");
1609
+ }
1610
+ const asked = offered.amountMsat ?? 0;
1611
+ const due = owed.amountMsat ?? 0;
1612
+ if (asked < due) {
1613
+ refuse2("amount_below_recipient", `${asked} msat cannot forward ${due} msat`);
1614
+ }
1615
+ const ceiling = wrapFeeCeiling(due, allowance);
1616
+ if (asked - due > ceiling) {
1617
+ refuse2("fee_above_allowance", `${asked - due} msat over ${due}, and ${ceiling} was allowed`);
1618
+ }
1619
+ if ((owed.expiresAt ?? 0) <= (offered.expiresAt ?? 0)) {
1620
+ refuse2("recipient_expires_first", "the operator could be paid and find it cannot deliver");
1621
+ }
1622
+ }
1623
+ async function payRequestFor(listed2, payment) {
1624
+ const at = listed2.indexOf("@");
1625
+ const name = listed2.slice(0, at);
1626
+ const domain = listed2.slice(at + 1).toLowerCase();
1627
+ if (at < 1 || !publicHttps(`https://${domain}/`)) {
1628
+ throw new UnverifiedRecipientError(payment.lnAddress, payment.id, "not a usable address");
1629
+ }
1630
+ return reachable(`https://${domain}/.well-known/lnurlp/${name}`, payment);
1631
+ }
1632
+ async function reachable(url, payment) {
1633
+ try {
1634
+ if (!publicHttps(url)) {
1635
+ throw new Error(`${url} is not a public https URL`);
1636
+ }
1637
+ const response = await fetch(url, {
1638
+ signal: AbortSignal.timeout(HTTP_TIMEOUT_MS2),
1639
+ headers: { accept: "application/json" }
1640
+ });
1641
+ if (response.redirected && !sameOrigin(response.url, url)) {
1642
+ throw new Error(`redirected off its own origin, to ${response.url}`);
1643
+ }
1644
+ if (!response.ok) {
1645
+ throw new Error(`answered ${response.status}`);
1646
+ }
1647
+ const body = JSON.parse(await boundedText(response));
1648
+ if (body === null || typeof body !== "object") {
1649
+ throw new Error("answered with no JSON object");
1650
+ }
1651
+ return body;
1652
+ } catch (cause) {
1653
+ throw new UnverifiedRecipientError(payment.lnAddress, payment.id, cause);
1654
+ }
1655
+ }
1656
+ async function boundedText(response) {
1657
+ const reader = response.body?.getReader();
1658
+ const decoder = new TextDecoder();
1659
+ let text3 = "";
1660
+ let bytes = 0;
1661
+ for (let chunk = await reader?.read(); chunk && !chunk.done; chunk = await reader?.read()) {
1662
+ bytes += chunk.value.length;
1663
+ if (bytes > BODY_LIMIT_BYTES) {
1664
+ await reader?.cancel();
1665
+ throw new Error(`answered with more than the ${BODY_LIMIT_BYTES} bytes a proof reads`);
1666
+ }
1667
+ text3 += decoder.decode(chunk.value, { stream: true });
1668
+ }
1669
+ return text3 + decoder.decode();
1670
+ }
1671
+ function equalIgnoringCase(one, other) {
1672
+ return one.toLowerCase() === other.toLowerCase();
1673
+ }
1674
+
1282
1675
  // src/wire.ts
1283
1676
  var ASSET_CODE = "BTC";
1284
1677
  var ASSET_SCALE = 11;
@@ -1476,7 +1869,7 @@ function isStatus(value) {
1476
1869
  }
1477
1870
 
1478
1871
  // src/webhook.ts
1479
- var SIGNATURE_HEADER2 = "x-signature";
1872
+ var SIGNATURE_HEADER2 = "x-signature-v2";
1480
1873
  var TIMESTAMP_HEADER2 = "x-timestamp";
1481
1874
  var GATEWAY_KEY_PREFIX = "ed25519=";
1482
1875
  var DEFAULT_TOLERANCE_SECS = 300;
@@ -1484,22 +1877,22 @@ var CHALLENGE = "webhook-challenge";
1484
1877
  var VERIFY_CHALLENGE = "verify-challenge";
1485
1878
  async function readPayment(request, credential, options = {}) {
1486
1879
  const body = await believable(request, credential, options);
1487
- return body === null ? null : decoded(body, paymentFromWire);
1880
+ return body === null ? null : proved(decoded(body, paymentFromWire));
1488
1881
  }
1489
1882
  async function readSettlement(request, credential, options = {}) {
1490
1883
  const body = await believable(request, credential, options);
1491
- return body === null ? null : decoded(body, settlementFromWire);
1884
+ return body === null ? null : proved(decoded(body, settlementFromWire));
1492
1885
  }
1493
1886
  async function answerWebhookChallenge(request, credential, options = {}) {
1494
1887
  const body = await believable(request, credential, options);
1495
- const nonce = body === null ? null : nonceOf(body, CHALLENGE);
1888
+ const nonce = body === null ? null : nonceOf2(body, CHALLENGE);
1496
1889
  return nonce === null ? null : answered2(nonce);
1497
1890
  }
1498
1891
  async function answerVerifyChallenge(request) {
1499
1892
  if (request.method !== "POST") {
1500
1893
  return null;
1501
1894
  }
1502
- const nonce = nonceOf(await request.clone().text(), VERIFY_CHALLENGE);
1895
+ const nonce = nonceOf2(await request.clone().text(), VERIFY_CHALLENGE);
1503
1896
  return nonce === null ? null : answered2(nonce);
1504
1897
  }
1505
1898
  async function believable(request, credential, options) {
@@ -1518,10 +1911,13 @@ async function believable(request, credential, options) {
1518
1911
  const signed = await verifyHex(
1519
1912
  credential.publicKey.toLowerCase(),
1520
1913
  signature.slice(GATEWAY_KEY_PREFIX.length).toLowerCase(),
1521
- stamped(timestamp, body)
1914
+ deliverySigned(options.url ?? request.url, timestamp, body)
1522
1915
  );
1523
1916
  return signed ? body : null;
1524
1917
  }
1918
+ function proved(read) {
1919
+ return read !== null && read.status === "paid" && !agreesWithItself(read) ? null : read;
1920
+ }
1525
1921
  function decoded(body, from) {
1526
1922
  try {
1527
1923
  return from(JSON.parse(body));
@@ -1534,7 +1930,7 @@ function answered2(nonce) {
1534
1930
  headers: { "content-type": "application/json" }
1535
1931
  });
1536
1932
  }
1537
- function nonceOf(text3, type) {
1933
+ function nonceOf2(text3, type) {
1538
1934
  try {
1539
1935
  const said = JSON.parse(text3);
1540
1936
  if (said["type"] !== type || typeof said["nonce"] !== "string") {
@@ -1552,9 +1948,6 @@ function recent(timestamp, toleranceSecs) {
1552
1948
  }
1553
1949
  return Math.abs(Math.floor(Date.now() / 1e3) - sent) <= toleranceSecs;
1554
1950
  }
1555
- function stamped(timestamp, body) {
1556
- return new TextEncoder().encode(`${timestamp}.${body}`);
1557
- }
1558
1951
 
1559
1952
  // src/bank.ts
1560
1953
  var DEFAULT_CURRENCY = "CZK";
@@ -1562,30 +1955,39 @@ var DEFAULT_LOOK_BACK_SECS = 7 * 24 * 60 * 60;
1562
1955
  var DEFAULT_POLL_EVERY_SECS = 30;
1563
1956
  var IBAN = /^[A-Z]{2}[0-9]{2}[0-9A-Z]{8,30}$/;
1564
1957
  var FORBIDDEN_IN_SPD = "*";
1958
+ var MAX_REFERENCE_CHARS = 60;
1565
1959
  async function bankTransfer(gateway, params) {
1566
1960
  const currency = params.currency ?? DEFAULT_CURRENCY;
1567
- refuseUnusable(params, currency);
1961
+ const iban = accountOf(params.iban);
1962
+ refuseUnusable(params, iban, currency);
1568
1963
  await refuseAnOpenGateway(gateway, params);
1569
- const spd = shortPaymentDescriptor(params, currency);
1570
- const subject = subjectOf(params.reference, params.amountMinor, currency);
1571
- const polling = new URL(params.verifyUrl);
1572
- polling.searchParams.set("ref", params.reference);
1573
- polling.searchParams.set("minor", String(params.amountMinor));
1574
- polling.searchParams.set("cc", currency);
1575
- polling.searchParams.set("sig", await hmacHex(params.secret, `verify|${subject}`));
1576
- const verifyUrl = polling.toString();
1964
+ const spd = shortPaymentDescriptor(params, iban, currency);
1965
+ const subject = subjectOf({
1966
+ iban,
1967
+ amountMinor: params.amountMinor,
1968
+ currency,
1969
+ expiresAt: params.expiresAt,
1970
+ reference: params.reference
1971
+ });
1972
+ const verifyUrl = await answeredAt(params, subject);
1973
+ const paymentHash = hashOf(await hmacHex(params.secret, `preimage|${subject}`));
1577
1974
  const watched = await gateway.watch({
1578
- paymentHash: hashOf(await hmacHex(params.secret, `preimage|${subject}`)),
1975
+ paymentHash,
1579
1976
  verifyUrl,
1580
1977
  expiresAt: params.expiresAt,
1581
1978
  trigger: params.trigger,
1582
1979
  replay: params.replay,
1583
- sealed: params.sealed,
1980
+ sealed: params.sealed ? await seal(params.sealed.secret, JSON.stringify(params.sealed.data), paymentHash) : void 0,
1584
1981
  webhookUrl: params.webhookUrl
1585
1982
  });
1586
- return { id: watched.id, paymentHash: watched.paymentHash, verifyUrl, spd };
1983
+ return { id: watched.id, paymentHash, verifyUrl, spd };
1587
1984
  }
1588
1985
  function bankVerifyEndpoint(config) {
1986
+ refuseAWeakSecret(config.secret);
1987
+ const answersFor = accountOf(config.iban);
1988
+ if (!IBAN.test(answersFor)) {
1989
+ throw new Error(`${config.iban} is not an IBAN, so this endpoint answers for no account`);
1990
+ }
1589
1991
  const paced = {
1590
1992
  "cache-control": `max-age=${config.pollEverySecs ?? DEFAULT_POLL_EVERY_SECS}`
1591
1993
  };
@@ -1594,51 +1996,82 @@ function bankVerifyEndpoint(config) {
1594
1996
  if (consented !== null) {
1595
1997
  return consented;
1596
1998
  }
1597
- const asked = readQuery(new URL(request.url));
1598
- if (asked === null) {
1999
+ const sealed = new URL(request.url).searchParams.get("q");
2000
+ if (sealed === null) {
1599
2001
  return Response.json({ settled: false }, { status: 400 });
1600
2002
  }
1601
- const subject = subjectOf(asked.reference, asked.amountMinor, asked.currency);
1602
- const expected = await hmacHex(config.secret, `verify|${subject}`);
1603
- if (!equalInConstantTime(asked.signature, expected)) {
2003
+ const subject = await unsealFor("bank-verify", config.secret, sealed);
2004
+ const asked = subject === null ? null : askedFrom(subject);
2005
+ if (asked === null || asked.iban !== answersFor) {
1604
2006
  return Response.json({ settled: false }, { status: 403 });
1605
2007
  }
1606
- const since = unixNow() - (config.lookBackSecs ?? DEFAULT_LOOK_BACK_SECS);
1607
- const landed = (await config.statement(since)).some((credit) => pays(credit, asked));
1608
- if (!landed) {
1609
- return Response.json({ settled: false }, { headers: paced });
1610
- }
1611
- return Response.json(
1612
- { settled: true, preimage: await hmacHex(config.secret, `preimage|${subject}`) },
1613
- { headers: paced }
1614
- );
2008
+ const preimage = await creditedPreimage({
2009
+ secret: config.secret,
2010
+ asked,
2011
+ statement: config.statement,
2012
+ lookBackSecs: config.lookBackSecs
2013
+ });
2014
+ return preimage === null ? Response.json({ settled: false }, { headers: paced }) : Response.json({ settled: true, preimage }, { headers: paced });
1615
2015
  };
1616
2016
  }
1617
- function readQuery(url) {
1618
- const reference = url.searchParams.get("ref");
1619
- const minor = Number(url.searchParams.get("minor"));
1620
- const currency = url.searchParams.get("cc");
1621
- const signature = url.searchParams.get("sig");
1622
- if (!reference || !currency || !signature) {
2017
+ async function answeredAt(params, subject) {
2018
+ if (params.answerBy === "agent") {
2019
+ return `agent:${(await callerKey(params.secret)).publicKeyHex}`;
2020
+ }
2021
+ const polling = new URL(params.verifyUrl ?? "");
2022
+ polling.searchParams.set(
2023
+ "q",
2024
+ await sealFor("bank-verify", params.secret, subject, { stable: true })
2025
+ );
2026
+ return polling.toString();
2027
+ }
2028
+ async function creditedPreimage(params) {
2029
+ const since = unixNow() - (params.lookBackSecs ?? DEFAULT_LOOK_BACK_SECS);
2030
+ const landed = (await params.statement(since)).some((credit) => pays(credit, params.asked));
2031
+ if (!landed) {
1623
2032
  return null;
1624
2033
  }
1625
- if (!Number.isInteger(minor) || minor <= 0) {
2034
+ return await hmacHex(params.secret, `preimage|${subjectOf(params.asked)}`);
2035
+ }
2036
+ function askedFrom(subject) {
2037
+ const [iban, minorText, currency, expiresText, ...rest] = subject.split("|");
2038
+ const reference = rest.join("|");
2039
+ const amountMinor = Number(minorText);
2040
+ const expiresAt = Number(expiresText);
2041
+ if (!iban || !currency || reference.length === 0) {
1626
2042
  return null;
1627
2043
  }
1628
- return { reference, amountMinor: minor, currency, signature };
2044
+ if (!Number.isInteger(amountMinor) || amountMinor <= 0 || !Number.isSafeInteger(expiresAt)) {
2045
+ return null;
2046
+ }
2047
+ return { iban, reference, amountMinor, currency, expiresAt };
1629
2048
  }
1630
2049
  function pays(credit, asked) {
1631
- return credit.amountMinor === asked.amountMinor && credit.currency.toUpperCase() === asked.currency.toUpperCase() && credit.reference.toUpperCase().includes(asked.reference.toUpperCase());
2050
+ return credit.amountMinor === asked.amountMinor && credit.currency.toUpperCase() === asked.currency.toUpperCase() && namesOnly(credit.reference, asked.reference);
2051
+ }
2052
+ function namesOnly(written, reference) {
2053
+ const wanted = reference.toUpperCase();
2054
+ const alike = [...written.toUpperCase().matchAll(wordsShapedLike(wanted))].map(([word]) => word);
2055
+ return alike.includes(wanted) && (!/\d/.test(wanted) || alike.every((word) => word === wanted));
2056
+ }
2057
+ function wordsShapedLike(reference) {
2058
+ const shape = [...reference].map((char) => {
2059
+ if (/\d/.test(char)) {
2060
+ return "\\d";
2061
+ }
2062
+ return /\p{L}/u.test(char) ? "\\p{L}" : char.replace(/[\^$\\.*+?()[\]{}|/]/g, "\\$&");
2063
+ }).join("");
2064
+ return new RegExp(`(?<![\\p{L}\\p{N}])${shape}(?![\\p{L}\\p{N}])`, "gu");
1632
2065
  }
1633
- function subjectOf(reference, amountMinor, currency) {
1634
- return `${reference}|${amountMinor}|${currency.toUpperCase()}`;
2066
+ function subjectOf({ iban, amountMinor, currency, expiresAt, reference }) {
2067
+ return `${iban}|${amountMinor}|${currency.toUpperCase()}|${expiresAt}|${reference}`;
1635
2068
  }
1636
2069
  function hashOf(preimage) {
1637
2070
  return bytesToHex(sha256(hexToBytes(preimage)));
1638
2071
  }
1639
- function shortPaymentDescriptor(params, currency) {
2072
+ function shortPaymentDescriptor(params, iban, currency) {
1640
2073
  const fields = [
1641
- `ACC:${params.iban}`,
2074
+ `ACC:${iban}`,
1642
2075
  `AM:${major(params.amountMinor, currency)}`,
1643
2076
  `CC:${currency.toUpperCase()}`,
1644
2077
  `MSG:${params.reference}`
@@ -1659,19 +2092,28 @@ async function refuseAnOpenGateway(gateway, params) {
1659
2092
  return;
1660
2093
  }
1661
2094
  throw new Error(
1662
- "this gateway serves callers with no token, so it is not yours, and its operator would read the amount and the reference off every verify URL. Point at one that answers 401 to a stranger, or say allowPublicGateway"
2095
+ "this gateway serves callers with no token, so it is not yours, and its operator would learn every watch you place. Point at one that answers 401 to a stranger, or say allowPublicGateway"
1663
2096
  );
1664
2097
  }
1665
- function refuseUnusable(params, currency) {
1666
- if (!IBAN.test(params.iban)) {
2098
+ function refuseUnusable(params, iban, currency) {
2099
+ refuseAWeakSecret(params.secret);
2100
+ if (params.answerBy !== "agent" && params.verifyUrl === void 0) {
2101
+ throw new Error("a transfer the gateway polls needs the verify url it is polled at");
2102
+ }
2103
+ if (!IBAN.test(iban)) {
1667
2104
  throw new Error(`${params.iban} is not an IBAN`);
1668
2105
  }
1669
- if (!Number.isInteger(params.amountMinor) || params.amountMinor <= 0) {
2106
+ if (!Number.isSafeInteger(params.amountMinor) || params.amountMinor <= 0) {
1670
2107
  throw new Error("amountMinor must be a whole number of minor units above zero");
1671
2108
  }
1672
2109
  if (params.reference.length === 0) {
1673
2110
  throw new Error("a transfer with no reference cannot be found");
1674
2111
  }
2112
+ if (params.reference.length > MAX_REFERENCE_CHARS) {
2113
+ throw new Error(
2114
+ `a reference is at most ${MAX_REFERENCE_CHARS} characters, as many as a QR payment's message carries and its sealed query hides`
2115
+ );
2116
+ }
1675
2117
  if (params.reference.includes(FORBIDDEN_IN_SPD)) {
1676
2118
  throw new Error("a reference cannot contain an asterisk, it separates the QR fields");
1677
2119
  }
@@ -1679,10 +2121,168 @@ function refuseUnusable(params, currency) {
1679
2121
  throw new Error("a variable symbol is up to ten digits");
1680
2122
  }
1681
2123
  }
2124
+ function accountOf(iban) {
2125
+ return iban.replace(/\s+/g, "").toUpperCase();
2126
+ }
1682
2127
  function unixNow() {
1683
2128
  return Math.floor(Date.now() / 1e3);
1684
2129
  }
1685
2130
 
2131
+ // src/nostr.ts
2132
+ import { chacha20 } from "@noble/ciphers/chacha.js";
2133
+ import { schnorr, secp256k1 } from "@noble/curves/secp256k1.js";
2134
+ import { expand, extract } from "@noble/hashes/hkdf.js";
2135
+ import { hmac } from "@noble/hashes/hmac.js";
2136
+ import { sha256 as sha2562 } from "@noble/hashes/sha2.js";
2137
+ var NIP44_SALT = new TextEncoder().encode("nip44-v2");
2138
+ var NIP44_VERSION = 2;
2139
+ var NONCE_BYTES2 = 32;
2140
+ var MAC_BYTES = 32;
2141
+ var MESSAGE_KEYS_BYTES = 76;
2142
+ var CHACHA_KEY_END = 32;
2143
+ var CHACHA_NONCE_END = 44;
2144
+ var LENGTH_PREFIX_BYTES = 2;
2145
+ var SMALLEST_PAD = 32;
2146
+ var CHUNKS_STAY_SMALL_TO = 256;
2147
+ var MAX_PLAINTEXT_BYTES = 65535;
2148
+ function publicKeyFor(privateKeyHex) {
2149
+ return bytesToHex(schnorr.getPublicKey(hexToBytes(privateKeyHex)));
2150
+ }
2151
+ function conversationKeyFor(privateKeyHex, publicKeyHex) {
2152
+ const shared = secp256k1.getSharedSecret(
2153
+ hexToBytes(privateKeyHex),
2154
+ hexToBytes(`02${publicKeyHex}`)
2155
+ );
2156
+ return extract(sha2562, shared.subarray(1, 33), NIP44_SALT);
2157
+ }
2158
+ function signEvent(privateKeyHex, draft) {
2159
+ const pubkey = publicKeyFor(privateKeyHex);
2160
+ const id = idOf(pubkey, draft);
2161
+ return {
2162
+ id,
2163
+ pubkey,
2164
+ created_at: draft.created_at,
2165
+ kind: draft.kind,
2166
+ tags: draft.tags,
2167
+ content: draft.content,
2168
+ sig: bytesToHex(schnorr.sign(hexToBytes(id), hexToBytes(privateKeyHex)))
2169
+ };
2170
+ }
2171
+ function verifyEvent(event) {
2172
+ try {
2173
+ return idOf(event.pubkey, event) === event.id && schnorr.verify(hexToBytes(event.sig), hexToBytes(event.id), hexToBytes(event.pubkey));
2174
+ } catch {
2175
+ return false;
2176
+ }
2177
+ }
2178
+ function idOf(pubkey, draft) {
2179
+ return bytesToHex(
2180
+ sha2562(
2181
+ new TextEncoder().encode(
2182
+ JSON.stringify([0, pubkey, draft.created_at, draft.kind, draft.tags, draft.content])
2183
+ )
2184
+ )
2185
+ );
2186
+ }
2187
+ function encryptNip44(conversationKey, plaintext) {
2188
+ return sealNip44(conversationKey, plaintext, crypto.getRandomValues(new Uint8Array(NONCE_BYTES2)));
2189
+ }
2190
+ function sealNip44(conversationKey, plaintext, nonce) {
2191
+ const keys = messageKeysFrom(conversationKey, nonce);
2192
+ const ciphertext = chacha20(keys.chachaKey, keys.chachaNonce, padded(plaintext));
2193
+ const mac = hmac(sha2562, keys.hmacKey, joined(nonce, ciphertext));
2194
+ return toBase64(joined(Uint8Array.of(NIP44_VERSION), nonce, ciphertext, mac));
2195
+ }
2196
+ function decryptNip44(conversationKey, payload) {
2197
+ if (payload.startsWith("#")) {
2198
+ throw new Error("nip44 payload announces an encryption version we do not speak");
2199
+ }
2200
+ const raw = fromBase64(payload);
2201
+ if (raw.length < 1 + NONCE_BYTES2 + MAC_BYTES) {
2202
+ throw new Error(`nip44 payload is ${raw.length} bytes, too short to hold a nonce and a mac`);
2203
+ }
2204
+ if (raw[0] !== NIP44_VERSION) {
2205
+ throw new Error(`nip44 payload is version ${raw[0]}, not ${NIP44_VERSION}`);
2206
+ }
2207
+ const nonce = raw.subarray(1, 1 + NONCE_BYTES2);
2208
+ const ciphertext = raw.subarray(1 + NONCE_BYTES2, raw.length - MAC_BYTES);
2209
+ const mac = raw.subarray(raw.length - MAC_BYTES);
2210
+ const keys = messageKeysFrom(conversationKey, nonce);
2211
+ if (!sameBytes(hmac(sha2562, keys.hmacKey, joined(nonce, ciphertext)), mac)) {
2212
+ throw new Error("nip44 payload failed its mac, so it was not written with this key");
2213
+ }
2214
+ return unpadded(chacha20(keys.chachaKey, keys.chachaNonce, ciphertext));
2215
+ }
2216
+ function messageKeysFrom(conversationKey, nonce) {
2217
+ if (conversationKey.length !== NONCE_BYTES2 || nonce.length !== NONCE_BYTES2) {
2218
+ throw new Error("a nip44 conversation key and nonce are both 32 bytes");
2219
+ }
2220
+ const keys = expand(sha2562, conversationKey, nonce, MESSAGE_KEYS_BYTES);
2221
+ return {
2222
+ chachaKey: keys.subarray(0, CHACHA_KEY_END),
2223
+ chachaNonce: keys.subarray(CHACHA_KEY_END, CHACHA_NONCE_END),
2224
+ hmacKey: keys.subarray(CHACHA_NONCE_END)
2225
+ };
2226
+ }
2227
+ function paddedLengthFor(unpadded2) {
2228
+ if (unpadded2 <= SMALLEST_PAD) {
2229
+ return SMALLEST_PAD;
2230
+ }
2231
+ const nextPower = 1 << Math.floor(Math.log2(unpadded2 - 1)) + 1;
2232
+ const chunk = nextPower <= CHUNKS_STAY_SMALL_TO ? SMALLEST_PAD : nextPower / 8;
2233
+ return chunk * (Math.floor((unpadded2 - 1) / chunk) + 1);
2234
+ }
2235
+ function padded(plaintext) {
2236
+ const body = new TextEncoder().encode(plaintext);
2237
+ if (body.length === 0 || body.length > MAX_PLAINTEXT_BYTES) {
2238
+ throw new Error(`nip44 takes 1 to ${MAX_PLAINTEXT_BYTES} bytes, this was ${body.length}`);
2239
+ }
2240
+ const buffer = new Uint8Array(LENGTH_PREFIX_BYTES + paddedLengthFor(body.length));
2241
+ buffer.set(Uint8Array.of(body.length >>> 8, body.length & 255));
2242
+ buffer.set(body, LENGTH_PREFIX_BYTES);
2243
+ return buffer;
2244
+ }
2245
+ function unpadded(buffer) {
2246
+ const length = (buffer[0] ?? 0) << 8 | (buffer[1] ?? 0);
2247
+ if (length === 0 || buffer.length !== LENGTH_PREFIX_BYTES + paddedLengthFor(length)) {
2248
+ throw new Error("nip44 plaintext does not match the length its own padding declares");
2249
+ }
2250
+ return new TextDecoder().decode(
2251
+ buffer.subarray(LENGTH_PREFIX_BYTES, LENGTH_PREFIX_BYTES + length)
2252
+ );
2253
+ }
2254
+ function joined(...parts) {
2255
+ const whole = new Uint8Array(parts.reduce((total, part) => total + part.length, 0));
2256
+ let at = 0;
2257
+ for (const part of parts) {
2258
+ whole.set(part, at);
2259
+ at += part.length;
2260
+ }
2261
+ return whole;
2262
+ }
2263
+ function sameBytes(left, right) {
2264
+ let different = left.length ^ right.length;
2265
+ for (let at = 0; at < left.length && at < right.length; at++) {
2266
+ different |= (left[at] ?? 0) ^ (right[at] ?? 0);
2267
+ }
2268
+ return different === 0;
2269
+ }
2270
+ function toBase64(bytes) {
2271
+ let binary = "";
2272
+ for (const byte of bytes) {
2273
+ binary += String.fromCharCode(byte);
2274
+ }
2275
+ return btoa(binary);
2276
+ }
2277
+ function fromBase64(text3) {
2278
+ const binary = atob(text3);
2279
+ const bytes = new Uint8Array(binary.length);
2280
+ for (let at = 0; at < bytes.length; at++) {
2281
+ bytes[at] = binary.charCodeAt(at);
2282
+ }
2283
+ return bytes;
2284
+ }
2285
+
1686
2286
  // src/qr.ts
1687
2287
  import { encode } from "uqr";
1688
2288
  var SAFE_COLOR = /^#(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$|^rgba?\([\d.,%/\s]+\)$|^[a-z]+$/i;
@@ -1693,44 +2293,276 @@ function toLightningUri(destination) {
1693
2293
  function invoiceToSvg(destination, options) {
1694
2294
  return svgOf(toLightningUri(destination), options);
1695
2295
  }
1696
- function svgOf(scanned, options) {
1697
- const size = options?.size ?? 256;
1698
- const color = options?.color ?? "#000";
1699
- if (!SAFE_COLOR.test(color)) {
1700
- throw new Error(`Invalid color: ${color}`);
2296
+ function svgOf(scanned, options) {
2297
+ const size = options?.size ?? 256;
2298
+ const color = options?.color ?? "#000";
2299
+ if (!SAFE_COLOR.test(color)) {
2300
+ throw new Error(`Invalid color: ${color}`);
2301
+ }
2302
+ return renderSvg(scanned, size, color);
2303
+ }
2304
+ function isLnAddress(destination) {
2305
+ const at = destination.indexOf("@");
2306
+ return at > 0 && destination.slice(at + 1).includes(".");
2307
+ }
2308
+ function renderSvg(content, size, color) {
2309
+ const { data } = encode(content);
2310
+ const modules = data.length;
2311
+ const cellSize = size / modules;
2312
+ let path = "";
2313
+ for (let y = 0; y < modules; y++) {
2314
+ for (let x = 0; x < modules; x++) {
2315
+ if (data[y][x]) {
2316
+ path += `M${x * cellSize},${y * cellSize}h${cellSize}v${cellSize}h-${cellSize}z`;
2317
+ }
2318
+ }
2319
+ }
2320
+ return [
2321
+ `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${size} ${size}" width="${size}" height="${size}">`,
2322
+ `<rect width="100%" height="100%" fill="white"/>`,
2323
+ `<path d="${path}" fill="${color}"/>`,
2324
+ `</svg>`
2325
+ ].join("");
2326
+ }
2327
+
2328
+ // src/nwc.ts
2329
+ var REQUEST_KIND = 23194;
2330
+ var RESPONSE_KIND = 23195;
2331
+ var NIP44_V2 = "nip44_v2";
2332
+ var DEFAULT_ASK_TIMEOUT_MS = 1e4;
2333
+ var DEFAULT_POLL_EVERY_SECS2 = 5;
2334
+ var HASH = "h";
2335
+ var REFUSAL_BY_CODE = {
2336
+ INSUFFICIENT_BALANCE: "amount-not-accepted",
2337
+ QUOTA_EXCEEDED: "amount-not-accepted",
2338
+ RESTRICTED: "address-unusable",
2339
+ UNAUTHORIZED: "address-unusable",
2340
+ UNSUPPORTED_ENCRYPTION: "address-unusable"
2341
+ };
2342
+ async function nwcInvoice(connection, amountMsat, description, timeoutMs = DEFAULT_ASK_TIMEOUT_MS) {
2343
+ const answered3 = await askWallet(
2344
+ connection,
2345
+ "make_invoice",
2346
+ { amount: amountMsat, description },
2347
+ timeoutMs
2348
+ );
2349
+ const bolt11 = String(answered3.invoice ?? "");
2350
+ const issued = decodeInvoice(bolt11);
2351
+ if (issued.paymentHash === null || issued.expiresAt === null) {
2352
+ throw new WalletRefused("invoice-refused", "the nwc wallet issued an invoice we cannot decode");
2353
+ }
2354
+ if (issued.amountMsat !== amountMsat) {
2355
+ throw new WalletRefused(
2356
+ "invoice-refused",
2357
+ `the nwc wallet issued an invoice for ${issued.amountMsat} msat, not ${amountMsat}`
2358
+ );
2359
+ }
2360
+ return { bolt11, paymentHash: issued.paymentHash, expiresAt: issued.expiresAt };
2361
+ }
2362
+ async function nwcSettlement(connection, paymentHash, timeoutMs = DEFAULT_ASK_TIMEOUT_MS) {
2363
+ const answered3 = await askWallet(
2364
+ connection,
2365
+ "lookup_invoice",
2366
+ { payment_hash: paymentHash },
2367
+ timeoutMs
2368
+ );
2369
+ const preimage = answered3.preimage;
2370
+ if (typeof preimage !== "string" || preimage.length === 0) {
2371
+ return null;
2372
+ }
2373
+ if (!preimageMatchesHash(preimage, paymentHash)) {
2374
+ throw new Error(`the nwc wallet returned a preimage that does not hash to ${paymentHash}`);
2375
+ }
2376
+ return preimage;
2377
+ }
2378
+ function nwcVerifyEndpoint(config) {
2379
+ const paced = {
2380
+ "cache-control": `max-age=${config.pollEverySecs ?? DEFAULT_POLL_EVERY_SECS2}`
2381
+ };
2382
+ return async (request) => {
2383
+ const consented = await answerVerifyChallenge(request);
2384
+ if (consented !== null) {
2385
+ return consented;
2386
+ }
2387
+ const sealed = new URL(request.url).searchParams.get(HASH);
2388
+ if (sealed === null) {
2389
+ return Response.json({ settled: false }, { status: 400 });
2390
+ }
2391
+ const paymentHash = await unsealFor("nwc-verify", config.secret, sealed);
2392
+ if (paymentHash === null) {
2393
+ return Response.json({ settled: false }, { status: 403 });
2394
+ }
2395
+ const preimage = await nwcSettlement(config.connection, paymentHash, config.askTimeoutMs).catch(
2396
+ () => void 0
2397
+ );
2398
+ if (preimage === void 0) {
2399
+ return Response.json({ settled: false }, { status: 502 });
2400
+ }
2401
+ return Response.json({ settled: preimage !== null, preimage }, { headers: paced });
2402
+ };
2403
+ }
2404
+ async function nwcVerifyUrl(endpoint, paymentHash, secret) {
2405
+ const sealed = new URL(endpoint);
2406
+ sealed.searchParams.set(HASH, await sealFor("nwc-verify", secret, paymentHash));
2407
+ return sealed.toString();
2408
+ }
2409
+ async function askWallet(connection, method, params, timeoutMs = DEFAULT_ASK_TIMEOUT_MS) {
2410
+ const key = conversationKeyFor(connection.secret, connection.walletPubkey);
2411
+ const request = signEvent(connection.secret, {
2412
+ kind: REQUEST_KIND,
2413
+ tags: [
2414
+ ["p", connection.walletPubkey],
2415
+ ["encryption", NIP44_V2]
2416
+ ],
2417
+ content: encryptNip44(key, JSON.stringify({ method, params })),
2418
+ created_at: Math.floor(Date.now() / 1e3)
2419
+ });
2420
+ const answer = await overFirstRelayThatAnswers(connection, request, timeoutMs);
2421
+ return resultOf(JSON.parse(decryptNip44(key, answer.content)), method);
2422
+ }
2423
+ function resultOf(said, method) {
2424
+ const failure = said.error;
2425
+ if (failure) {
2426
+ throw new WalletRefused(
2427
+ REFUSAL_BY_CODE[failure.code ?? ""] ?? "invoice-refused",
2428
+ `the nwc wallet refused ${method}: ${failure.code ?? "unknown"} ${failure.message ?? ""}`.trim()
2429
+ );
2430
+ }
2431
+ const result = said.result;
2432
+ if (result === null || typeof result !== "object") {
2433
+ throw new WalletRefused("invoice-refused", `the nwc wallet answered ${method} with no result`);
2434
+ }
2435
+ return result;
2436
+ }
2437
+ async function overFirstRelayThatAnswers(connection, request, timeoutMs) {
2438
+ const deadline = AbortSignal.timeout(timeoutMs);
2439
+ let lastFailure = null;
2440
+ for (const relay of connection.relays) {
2441
+ if (deadline.aborted) {
2442
+ break;
2443
+ }
2444
+ try {
2445
+ return await overRelay(relay, connection, request, deadline);
2446
+ } catch (failure) {
2447
+ lastFailure = failure;
2448
+ }
2449
+ }
2450
+ throw new WalletRefused("unreachable", `no nwc relay answered: ${String(lastFailure)}`);
2451
+ }
2452
+ function overRelay(relay, connection, request, deadline) {
2453
+ return new Promise((resolve2, reject) => {
2454
+ const socket = new WebSocket(relay);
2455
+ const subscription = request.id.slice(0, 16);
2456
+ const expired = () => {
2457
+ close();
2458
+ reject(new Error(`${relay} did not answer before the deadline`));
2459
+ };
2460
+ deadline.addEventListener("abort", expired);
2461
+ function close() {
2462
+ deadline.removeEventListener("abort", expired);
2463
+ socket.close();
2464
+ }
2465
+ socket.addEventListener("open", () => {
2466
+ socket.send(
2467
+ JSON.stringify([
2468
+ "REQ",
2469
+ subscription,
2470
+ {
2471
+ kinds: [RESPONSE_KIND],
2472
+ authors: [connection.walletPubkey],
2473
+ "#p": [publicKeyFor(connection.secret)],
2474
+ "#e": [request.id]
2475
+ }
2476
+ ])
2477
+ );
2478
+ socket.send(JSON.stringify(["EVENT", request]));
2479
+ });
2480
+ socket.addEventListener("error", () => {
2481
+ close();
2482
+ reject(new Error(`${relay} could not be reached`));
2483
+ });
2484
+ socket.addEventListener("message", (delivered) => {
2485
+ const frame = String(delivered.data);
2486
+ const rejection = rejectionOf(frame, request.id);
2487
+ if (rejection !== null) {
2488
+ close();
2489
+ reject(new Error(`${relay} refused the request: ${rejection}`));
2490
+ return;
2491
+ }
2492
+ const answer = answeredEvent(frame, subscription, connection, request.id);
2493
+ if (answer === null) {
2494
+ return;
2495
+ }
2496
+ close();
2497
+ resolve2(answer);
2498
+ });
2499
+ });
2500
+ }
2501
+ function rejectionOf(frame, requestId) {
2502
+ const parsed = parsedFrame(frame);
2503
+ if (parsed === null || parsed[0] !== "OK" || parsed[1] !== requestId || parsed[2] !== false) {
2504
+ return null;
1701
2505
  }
1702
- return renderSvg(scanned, size, color);
2506
+ return typeof parsed[3] === "string" && parsed[3].length > 0 ? parsed[3] : "no reason given";
1703
2507
  }
1704
- function isLnAddress(destination) {
1705
- const at = destination.indexOf("@");
1706
- return at > 0 && destination.slice(at + 1).includes(".");
2508
+ function answeredEvent(frame, subscription, connection, requestId) {
2509
+ const parsed = parsedFrame(frame);
2510
+ if (parsed === null || parsed[0] !== "EVENT" || parsed[1] !== subscription) {
2511
+ return null;
2512
+ }
2513
+ const event = parsed[2];
2514
+ 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);
2515
+ return answers ? event : null;
1707
2516
  }
1708
- function renderSvg(content, size, color) {
1709
- const { data } = encode(content);
1710
- const modules = data.length;
1711
- const cellSize = size / modules;
1712
- let path = "";
1713
- for (let y = 0; y < modules; y++) {
1714
- for (let x = 0; x < modules; x++) {
1715
- if (data[y][x]) {
1716
- path += `M${x * cellSize},${y * cellSize}h${cellSize}v${cellSize}h-${cellSize}z`;
1717
- }
1718
- }
2517
+ function parsedFrame(frame) {
2518
+ try {
2519
+ const parsed = JSON.parse(frame);
2520
+ return Array.isArray(parsed) ? parsed : null;
2521
+ } catch {
2522
+ return null;
1719
2523
  }
1720
- return [
1721
- `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${size} ${size}" width="${size}" height="${size}">`,
1722
- `<rect width="100%" height="100%" fill="white"/>`,
1723
- `<path d="${path}" fill="${color}"/>`,
1724
- `</svg>`
1725
- ].join("");
2524
+ }
2525
+ function nwcRail(gateway, config) {
2526
+ return async (order) => {
2527
+ const invoice = await nwcInvoice(
2528
+ config.connection,
2529
+ await msatForOrder(order, config.amount, config.rate),
2530
+ config.description?.(order) ?? order.reference
2531
+ );
2532
+ const watched = await gateway.watch({
2533
+ paymentHash: invoice.paymentHash,
2534
+ verifyUrl: await nwcVerifyUrl(
2535
+ config.verifyThrough.endpoint,
2536
+ invoice.paymentHash,
2537
+ config.verifyThrough.secret
2538
+ ),
2539
+ expiresAt: invoice.expiresAt,
2540
+ trigger: config.trigger,
2541
+ replay: config.replay,
2542
+ sealed: config.sealed ? await seal(
2543
+ config.sealed.secret,
2544
+ JSON.stringify(config.sealed.data(order)),
2545
+ invoice.paymentHash
2546
+ ) : void 0,
2547
+ webhookUrl: config.webhookUrl
2548
+ });
2549
+ return {
2550
+ id: watched.id,
2551
+ paymentHash: invoice.paymentHash,
2552
+ rail: config.name ?? "lightning",
2553
+ scan: invoice.bolt11,
2554
+ qr: toLightningUri(invoice.bolt11),
2555
+ expiresAt: invoice.expiresAt
2556
+ };
2557
+ };
1726
2558
  }
1727
2559
 
1728
2560
  // src/relay.ts
1729
- var DEFAULT_POLL_EVERY_SECS2 = 5;
2561
+ var DEFAULT_POLL_EVERY_SECS3 = 5;
1730
2562
  var WALLET = "w";
1731
2563
  function lightningVerifyEndpoint(config) {
1732
2564
  const paced = {
1733
- "cache-control": `max-age=${config.pollEverySecs ?? DEFAULT_POLL_EVERY_SECS2}`
2565
+ "cache-control": `max-age=${config.pollEverySecs ?? DEFAULT_POLL_EVERY_SECS3}`
1734
2566
  };
1735
2567
  return async (request) => {
1736
2568
  const consented = await answerVerifyChallenge(request);
@@ -1741,11 +2573,11 @@ function lightningVerifyEndpoint(config) {
1741
2573
  if (sealed === null) {
1742
2574
  return Response.json({ settled: false }, { status: 400 });
1743
2575
  }
1744
- const opened = await unseal(config.secret, sealed);
1745
- if (opened === null) {
2576
+ const opened2 = await unsealFor("relay", config.secret, sealed);
2577
+ const wallet = opened2 === null ? null : relayedFrom(opened2);
2578
+ if (wallet === null) {
1746
2579
  return Response.json({ settled: false }, { status: 403 });
1747
2580
  }
1748
- const wallet = JSON.parse(opened);
1749
2581
  const asked = await checkSettled(
1750
2582
  config.send ?? pinnedToTheAddressWeVerified,
1751
2583
  wallet.url,
@@ -1760,9 +2592,17 @@ function lightningVerifyEndpoint(config) {
1760
2592
  );
1761
2593
  };
1762
2594
  }
2595
+ function relayedFrom(opened2) {
2596
+ try {
2597
+ const said = JSON.parse(opened2);
2598
+ return typeof said?.url === "string" && typeof said.hash === "string" ? { url: said.url, hash: said.hash } : null;
2599
+ } catch {
2600
+ return null;
2601
+ }
2602
+ }
1763
2603
  async function relayedVerifyUrl(endpoint, wallet, secret) {
1764
2604
  const relayed = new URL(endpoint);
1765
- relayed.searchParams.set(WALLET, await seal(secret, JSON.stringify(wallet)));
2605
+ relayed.searchParams.set(WALLET, await sealFor("relay", secret, JSON.stringify(wallet)));
1766
2606
  return relayed.toString();
1767
2607
  }
1768
2608
 
@@ -1782,13 +2622,14 @@ function bankRail(gateway, config) {
1782
2622
  expiresAt,
1783
2623
  trigger: config.trigger,
1784
2624
  replay: config.replay,
1785
- sealed: await config.sealed?.(order),
2625
+ sealed: config.sealed && { secret: config.sealed.secret, data: config.sealed.data(order) },
1786
2626
  variableSymbol: config.variableSymbol?.(order),
1787
2627
  webhookUrl: config.webhookUrl,
1788
2628
  allowPublicGateway: config.allowPublicGateway
1789
2629
  });
1790
2630
  return {
1791
2631
  id: transfer.id,
2632
+ paymentHash: transfer.paymentHash,
1792
2633
  rail: config.name ?? BANK,
1793
2634
  scan: transfer.spd,
1794
2635
  qr: transfer.spd,
@@ -1812,6 +2653,7 @@ function lightningRail(gateway, config) {
1812
2653
  );
1813
2654
  return {
1814
2655
  id: payment.id,
2656
+ paymentHash: payment.paymentHash,
1815
2657
  rail: config.name ?? LIGHTNING,
1816
2658
  scan: payment.bolt11,
1817
2659
  qr: toLightningUri(payment.bolt11),
@@ -1837,11 +2679,16 @@ function blindLightningRail(gateway, config) {
1837
2679
  expiresAt: resolved2.expiresAt,
1838
2680
  trigger: config.trigger,
1839
2681
  replay: config.replay,
1840
- sealed: await config.sealed?.(order),
2682
+ sealed: config.sealed ? await seal(
2683
+ config.sealed.secret,
2684
+ JSON.stringify(config.sealed.data(order)),
2685
+ resolved2.paymentHash
2686
+ ) : void 0,
1841
2687
  webhookUrl: config.webhookUrl
1842
2688
  });
1843
2689
  return {
1844
2690
  id: watched.id,
2691
+ paymentHash: resolved2.paymentHash,
1845
2692
  rail: config.name ?? LIGHTNING,
1846
2693
  scan: resolved2.bolt11,
1847
2694
  qr: toLightningUri(resolved2.bolt11),
@@ -1886,6 +2733,15 @@ var Rails = class {
1886
2733
  bank(config) {
1887
2734
  return bankRail(this.gateway, config);
1888
2735
  }
2736
+ /**
2737
+ * Lightning against a wallet of your own over NIP-47, for a wallet that has no
2738
+ * LUD-21 address to be watched at. Your node mints the invoice and releases the
2739
+ * preimage, so the proof comes from one hop nearer than any hosted address can
2740
+ * manage, and the gateway sees a hash and a URL of yours
2741
+ */
2742
+ nwc(config) {
2743
+ return nwcRail(this.gateway, config);
2744
+ }
1889
2745
  /**
1890
2746
  * One bank transfer without building a rail first, for a shop that asks for
1891
2747
  * them one at a time rather than beside another payment method
@@ -1895,130 +2751,6 @@ var Rails = class {
1895
2751
  }
1896
2752
  };
1897
2753
 
1898
- // src/verify.ts
1899
- var HTTP_TIMEOUT_MS2 = 15e3;
1900
- var DEFAULT_WRAP_PROPORTION = 0.01;
1901
- var DEFAULT_WRAP_BASE_MSAT = 1e3;
1902
- async function proveOrigin(payment, asked) {
1903
- const cheat = (code) => new GatewayCheatError(code, payment.id);
1904
- const listed = asked.paidTo.find((address) => equalIgnoringCase(address, payment.lnAddress));
1905
- if (listed === void 0) {
1906
- throw cheat("address_not_requested");
1907
- }
1908
- if (payment.amountMsat !== asked.amountMsat) {
1909
- throw cheat("amount_mismatch");
1910
- }
1911
- const invoice = decodeInvoice(payment.bolt11);
1912
- if (invoice.paymentHash === null || !equalIgnoringCase(invoice.paymentHash, payment.paymentHash)) {
1913
- throw cheat("hash_mismatch");
1914
- }
1915
- if (invoice.amountMsat !== asked.amountMsat) {
1916
- throw cheat("amount_mismatch");
1917
- }
1918
- const payRequest = await payRequestFor(listed, payment);
1919
- if (typeof payRequest.metadata !== "string" || typeof payRequest.callback !== "string") {
1920
- throw new UnverifiedRecipientError(payment.lnAddress, payment.id, "no payRequest served");
1921
- }
1922
- if (invoice.descriptionHash !== sha256Hex(payRequest.metadata)) {
1923
- throw cheat("description_hash_mismatch");
1924
- }
1925
- if (!sameOrigin(payment.verifyUrl, payRequest.callback)) {
1926
- throw cheat("verify_url_foreign");
1927
- }
1928
- const issued = await reachable(payment.verifyUrl, payment);
1929
- if (typeof issued.pr !== "string" || !equalIgnoringCase(issued.pr, payment.bolt11)) {
1930
- throw cheat("invoice_not_issued");
1931
- }
1932
- }
1933
- async function proveSettlement(payment, asked) {
1934
- await proveOrigin(payment, asked);
1935
- const verified = await reachable(payment.verifyUrl, payment);
1936
- if (verified.settled !== true || typeof verified.preimage !== "string") {
1937
- return null;
1938
- }
1939
- if (!preimageMatchesHash(verified.preimage, payment.paymentHash)) {
1940
- throw new GatewayCheatError("preimage_mismatch", payment.id);
1941
- }
1942
- return verified.preimage;
1943
- }
1944
- function carriesProof(report) {
1945
- if (report.status !== "paid" || report.preimage === null) {
1946
- return false;
1947
- }
1948
- if (report.bolt11 !== void 0 && report.bolt11 !== null) {
1949
- const invoiceHash = decodeInvoice(report.bolt11).paymentHash;
1950
- if (invoiceHash === null || !equalIgnoringCase(invoiceHash, report.paymentHash)) {
1951
- return false;
1952
- }
1953
- }
1954
- return preimageMatchesHash(report.preimage, report.paymentHash);
1955
- }
1956
- function wrapFeeCeiling(amountMsat, allowance) {
1957
- const proportional = amountMsat * (allowance?.proportion ?? DEFAULT_WRAP_PROPORTION);
1958
- return msat(Math.max(allowance?.baseMsat ?? DEFAULT_WRAP_BASE_MSAT, Math.floor(proportional)));
1959
- }
1960
- function proveWrapped(wrapped, recipient, allowance) {
1961
- const offered = decodeInvoice(wrapped);
1962
- const owed = decodeInvoice(recipient);
1963
- const refuse2 = (code, detail) => {
1964
- throw new WrapRefusedError(code, detail);
1965
- };
1966
- if (offered.paymentHash === null || offered.amountMsat === null || offered.expiresAt === null) {
1967
- refuse2("undecodable", "the wrapped invoice carries no hash, amount or expiry");
1968
- }
1969
- if (owed.paymentHash === null || owed.amountMsat === null || owed.expiresAt === null) {
1970
- refuse2("undecodable", "the recipient's invoice carries no hash, amount or expiry");
1971
- }
1972
- if (offered.paymentHash?.toLowerCase() !== owed.paymentHash?.toLowerCase()) {
1973
- refuse2("hash_mismatch", "settling the wrap would not settle the recipient");
1974
- }
1975
- const asked = offered.amountMsat ?? 0;
1976
- const due = owed.amountMsat ?? 0;
1977
- if (asked < due) {
1978
- refuse2("amount_below_recipient", `${asked} msat cannot forward ${due} msat`);
1979
- }
1980
- const ceiling = wrapFeeCeiling(due, allowance);
1981
- if (asked - due > ceiling) {
1982
- refuse2("fee_above_allowance", `${asked - due} msat over ${due}, and ${ceiling} was allowed`);
1983
- }
1984
- if ((owed.expiresAt ?? 0) <= (offered.expiresAt ?? 0)) {
1985
- refuse2("recipient_expires_first", "the operator could be paid and find it cannot deliver");
1986
- }
1987
- }
1988
- async function payRequestFor(listed, payment) {
1989
- const at = listed.indexOf("@");
1990
- const name = listed.slice(0, at);
1991
- const domain = listed.slice(at + 1).toLowerCase();
1992
- if (at < 1 || !publicHttps(`https://${domain}/`)) {
1993
- throw new UnverifiedRecipientError(payment.lnAddress, payment.id, "not a usable address");
1994
- }
1995
- return reachable(`https://${domain}/.well-known/lnurlp/${name}`, payment);
1996
- }
1997
- async function reachable(url, payment) {
1998
- try {
1999
- if (!publicHttps(url)) {
2000
- throw new Error(`${url} is not a public https URL`);
2001
- }
2002
- const response = await fetch(url, {
2003
- signal: AbortSignal.timeout(HTTP_TIMEOUT_MS2),
2004
- headers: { accept: "application/json" }
2005
- });
2006
- if (!response.ok) {
2007
- throw new Error(`answered ${response.status}`);
2008
- }
2009
- const body = await response.json();
2010
- if (body === null || typeof body !== "object") {
2011
- throw new Error("answered with no JSON object");
2012
- }
2013
- return body;
2014
- } catch (cause) {
2015
- throw new UnverifiedRecipientError(payment.lnAddress, payment.id, cause);
2016
- }
2017
- }
2018
- function equalIgnoringCase(one, other) {
2019
- return one.toLowerCase() === other.toLowerCase();
2020
- }
2021
-
2022
2754
  // src/request.ts
2023
2755
  function paymentRequestOf(gateway, payment, options) {
2024
2756
  return {
@@ -2030,13 +2762,13 @@ function paymentRequestOf(gateway, payment, options) {
2030
2762
  expiresAt: payment.expiresAt,
2031
2763
  qr: invoiceToSvg(payment.bolt11, options?.qr),
2032
2764
  payment,
2033
- paid: async (waiting) => paidOnly(await gateway.settled(payment.id, { ...waitingOf(options), ...waiting })),
2765
+ paid: async (waiting) => paidOnly(await gateway.settled(payment, { ...waitingOf(options), ...waiting })),
2034
2766
  prove: () => proveSettlement(payment, { paidTo: [payment.lnAddress], amountMsat: payment.amountMsat }),
2035
2767
  onPaid: (arrived, failed) => {
2036
2768
  const stop = new AbortController();
2037
2769
  const waiting = waitingOf(options);
2038
2770
  const signal = waiting.signal ? AbortSignal.any([stop.signal, waiting.signal]) : stop.signal;
2039
- gateway.settled(payment.id, { ...waiting, signal }).then((ended) => arrived(paidOnly(ended))).catch((reason) => {
2771
+ gateway.settled(payment, { ...waiting, signal }).then((ended) => arrived(paidOnly(ended))).catch((reason) => {
2040
2772
  if (!stop.signal.aborted) {
2041
2773
  failed?.(reason);
2042
2774
  }
@@ -2059,7 +2791,7 @@ function waitingOf(options) {
2059
2791
  }
2060
2792
 
2061
2793
  // src/trigger.ts
2062
- var NONCE_BYTES = 16;
2794
+ var NONCE_BYTES3 = 16;
2063
2795
  function lnurlPayEndpoint(gateway, config) {
2064
2796
  return async (request) => {
2065
2797
  const url = new URL(request.url);
@@ -2085,22 +2817,37 @@ function publicWatchTicketEndpoint(gateway, config) {
2085
2817
  }
2086
2818
  async function offer(gateway, config, url) {
2087
2819
  const { least, most } = await spread(config.amount);
2088
- const quote = await gateway.quote({ paidTo: config.paidTo, amount: msat(least) });
2820
+ const winner = await quoted(gateway, config, least);
2089
2821
  const nonce = randomNonce();
2090
2822
  const callback = new URL(config.baseUrl ?? `${url.origin}${url.pathname}`);
2091
- callback.searchParams.set("to", quote.lnAddress);
2823
+ callback.searchParams.set("to", winner.address);
2092
2824
  callback.searchParams.set("least", String(least));
2093
2825
  callback.searchParams.set("most", String(most));
2094
2826
  callback.searchParams.set("n", nonce);
2095
- callback.searchParams.set("sig", await sign(config.secret, quote.lnAddress, least, most, nonce));
2827
+ callback.searchParams.set("sig", await sign(config.secret, winner.address, least, most, nonce));
2096
2828
  return Response.json({
2097
2829
  tag: "payRequest",
2098
2830
  callback: callback.toString(),
2099
- metadata: quote.metadata,
2831
+ metadata: winner.metadata,
2100
2832
  minSendable: least,
2101
2833
  maxSendable: most
2102
2834
  });
2103
2835
  }
2836
+ async function quoted(gateway, config, amountMsat) {
2837
+ if (config.blind) {
2838
+ const { won } = await quote(
2839
+ config.send ?? pinnedToTheAddressWeVerified,
2840
+ listed(config.paidTo),
2841
+ amountMsat
2842
+ );
2843
+ return won;
2844
+ }
2845
+ const { lnAddress, metadata } = await gateway.quote({
2846
+ paidTo: config.paidTo,
2847
+ amount: msat(amountMsat)
2848
+ });
2849
+ return { address: lnAddress, metadata };
2850
+ }
2104
2851
  async function spread(amount) {
2105
2852
  if (typeof amount === "number" || typeof amount === "function") {
2106
2853
  const pinned = await amountNow(amount);
@@ -2122,6 +2869,9 @@ async function mint(gateway, config, url, address) {
2122
2869
  if (!equalInConstantTime(signature, expected)) {
2123
2870
  return refuse("this callback was not signed here");
2124
2871
  }
2872
+ if (!listed(config.paidTo).some((one) => one.toLowerCase() === address.toLowerCase())) {
2873
+ return refuse(`${address} is not paid through this trigger`);
2874
+ }
2125
2875
  const asked = url.searchParams.get("amount");
2126
2876
  const amountMsat = asked === null ? least : Number(asked);
2127
2877
  if (!Number.isSafeInteger(amountMsat) || amountMsat < least || amountMsat > most) {
@@ -2164,7 +2914,7 @@ async function mintBlind(gateway, config, address, amountMsat) {
2164
2914
  expiresAt: resolved2.expiresAt,
2165
2915
  trigger: config.watchSecret,
2166
2916
  replay: config.replay,
2167
- sealed: locked ? await seal(locked.secret, JSON.stringify(locked.data(minted))) : void 0
2917
+ sealed: locked ? await seal(locked.secret, JSON.stringify(locked.data(minted)), minted.paymentHash) : void 0
2168
2918
  });
2169
2919
  } catch (refused) {
2170
2920
  if (!alreadyWatched(refused)) {
@@ -2173,14 +2923,17 @@ async function mintBlind(gateway, config, address, amountMsat) {
2173
2923
  }
2174
2924
  return { bolt11: resolved2.bolt11, verifyUrl: resolved2.verifyUrl };
2175
2925
  }
2926
+ function listed(paidTo) {
2927
+ return typeof paidTo === "string" ? [paidTo] : paidTo;
2928
+ }
2176
2929
  function alreadyWatched(refused) {
2177
2930
  return refused instanceof ProblemError && isProblemType(refused, PAYMENT_ALREADY_WATCHED);
2178
2931
  }
2179
2932
  function sign(secret, address, least, most, nonce) {
2180
- return hmacHex(secret, `${address}|${least}|${most}|${nonce}`);
2933
+ return hmacHex(secret, `lnurl-callback|${address}|${least}|${most}|${nonce}`);
2181
2934
  }
2182
2935
  function randomNonce() {
2183
- const bytes = crypto.getRandomValues(new Uint8Array(NONCE_BYTES));
2936
+ const bytes = crypto.getRandomValues(new Uint8Array(NONCE_BYTES3));
2184
2937
  let hex = "";
2185
2938
  for (const byte of bytes) {
2186
2939
  hex += byte.toString(16).padStart(2, "0");
@@ -2230,23 +2983,39 @@ var Serve = class {
2230
2983
  * A verify endpoint of your own that asks the recipient's wallet for you, so
2231
2984
  * the gateway polls you and never the wallet
2232
2985
  */
2233
- verify(config) {
2986
+ lightningVerify(config) {
2234
2987
  return lightningVerifyEndpoint(config);
2235
2988
  }
2989
+ /**
2990
+ * What `lightningVerify` was called before 2.2.0
2991
+ *
2992
+ * @deprecated Use `lightningVerify`, beside `bankVerify` and `nwcVerify`
2993
+ */
2994
+ verify(config) {
2995
+ return this.lightningVerify(config);
2996
+ }
2236
2997
  /** The verify endpoint a bank rail is polled at, answering off your own statement */
2237
2998
  bankVerify(config) {
2238
2999
  return bankVerifyEndpoint(config);
2239
3000
  }
3001
+ /**
3002
+ * The verify endpoint an NWC rail is polled at, asking your own wallet over
3003
+ * NIP-47, so the gateway never learns the connection, the relay or the wallet
3004
+ */
3005
+ nwcVerify(config) {
3006
+ return nwcVerifyEndpoint(config);
3007
+ }
2240
3008
  /**
2241
3009
  * The whole webhook route: it answers the gateway's challenge, checks the
2242
3010
  * signature against the key the gateway publishes, refuses a settlement that
2243
- * proves nothing, and calls you for the one that does.
3011
+ * proves nothing, and calls you once for the one that does.
2244
3012
  *
2245
3013
  * `export const POST = gateway.serve.webhook({ onSettled: fulfil })` is the
2246
3014
  * entire integration
2247
3015
  */
2248
3016
  webhook(handlers) {
2249
- const options = { toleranceSecs: handlers.toleranceSecs };
3017
+ const options = { toleranceSecs: handlers.toleranceSecs, url: handlers.url };
3018
+ const once = onceWhileReplayable(handlers.toleranceSecs ?? DEFAULT_TOLERANCE_SECS);
2250
3019
  return async (request) => {
2251
3020
  const credential = await this.credential(handlers);
2252
3021
  const challenge = await answerWebhookChallenge(request, credential, options);
@@ -2255,12 +3024,17 @@ var Serve = class {
2255
3024
  }
2256
3025
  const settlement = await readSettlement(request, credential, options);
2257
3026
  if (settlement !== null) {
2258
- return await acted(settlement, handlers);
3027
+ return await once(
3028
+ `${settlement.id}:${settlement.status}`,
3029
+ () => acted(settlement, handlers)
3030
+ );
2259
3031
  }
2260
3032
  const payment = await readPayment(request, credential, options);
2261
3033
  if (payment !== null) {
2262
- await handlers.onPayment?.(payment);
2263
- return new Response("ok");
3034
+ return await once(`${payment.id}:${payment.status}`, async () => {
3035
+ await handlers.onPayment?.(payment);
3036
+ return new Response("ok");
3037
+ });
2264
3038
  }
2265
3039
  return new Response("bad signature", { status: 401 });
2266
3040
  };
@@ -2281,8 +3055,34 @@ var Serve = class {
2281
3055
  return handlers.credential ?? { publicKey: await this.gateway.webhookKey() };
2282
3056
  }
2283
3057
  };
3058
+ function onceWhileReplayable(toleranceSecs) {
3059
+ const acting = /* @__PURE__ */ new Map();
3060
+ const replayableForMs = 2 * toleranceSecs * 1e3;
3061
+ return async (delivery, act) => {
3062
+ const now = Date.now();
3063
+ for (const [earlier2, { forgetAt }] of acting) {
3064
+ if (forgetAt > now) {
3065
+ break;
3066
+ }
3067
+ acting.delete(earlier2);
3068
+ }
3069
+ const earlier = acting.get(delivery);
3070
+ if (earlier !== void 0) {
3071
+ await earlier.done;
3072
+ return new Response("ok");
3073
+ }
3074
+ const done = act();
3075
+ acting.set(delivery, { forgetAt: now + replayableForMs, done });
3076
+ try {
3077
+ return await done;
3078
+ } catch (failure) {
3079
+ acting.delete(delivery);
3080
+ throw failure;
3081
+ }
3082
+ };
3083
+ }
2284
3084
  async function acted(settlement, handlers) {
2285
- if (carriesProof(settlement)) {
3085
+ if (agreesWithItself(settlement)) {
2286
3086
  await handlers.onSettled?.(settlement);
2287
3087
  return new Response("ok");
2288
3088
  }
@@ -2312,6 +3112,21 @@ function backoffMs(firstDelay, attempt) {
2312
3112
  const grown = Math.min(firstDelay * 2 ** (attempt - 1), RECONNECT_CAP_MS);
2313
3113
  return Math.round(grown * (0.5 + Math.random() / 2));
2314
3114
  }
3115
+ function askFromWire(raw) {
3116
+ let said;
3117
+ try {
3118
+ said = JSON.parse(raw);
3119
+ } catch {
3120
+ return null;
3121
+ }
3122
+ if (typeof said !== "object" || said === null) {
3123
+ return null;
3124
+ }
3125
+ const fields = said;
3126
+ const ask2 = fields["ask"];
3127
+ const paymentHash = fields["payment_hash"];
3128
+ return typeof ask2 === "string" && typeof paymentHash === "string" ? { ask: ask2, paymentHash } : null;
3129
+ }
2315
3130
  function gatewayAt(baseUrl) {
2316
3131
  const at = URL.canParse(baseUrl) ? new URL(baseUrl) : null;
2317
3132
  if (at === null || at.protocol !== "http:" && at.protocol !== "https:") {
@@ -2321,8 +3136,8 @@ function gatewayAt(baseUrl) {
2321
3136
  }
2322
3137
  var ThunderBridge = class {
2323
3138
  baseUrl;
2324
- verify;
2325
3139
  token;
3140
+ handedOut = /* @__PURE__ */ new Map();
2326
3141
  secret;
2327
3142
  strangers = null;
2328
3143
  speaks = null;
@@ -2333,7 +3148,6 @@ var ThunderBridge = class {
2333
3148
  rails;
2334
3149
  constructor(baseUrl, options) {
2335
3150
  this.baseUrl = gatewayAt(baseUrl);
2336
- this.verify = options?.verify ?? true;
2337
3151
  this.token = options?.token ?? null;
2338
3152
  this.secret = options?.secret ?? null;
2339
3153
  this.serve = new Serve(this);
@@ -2375,6 +3189,7 @@ var ThunderBridge = class {
2375
3189
  * and `GatewayCheatError` when what comes back is not what you asked for
2376
3190
  */
2377
3191
  async mint(charge, options) {
3192
+ const askedAt = Math.floor(Date.now() / 1e3);
2378
3193
  const asked = await priced(charge);
2379
3194
  const sent = createRequestBody(
2380
3195
  asked,
@@ -2395,9 +3210,8 @@ var ThunderBridge = class {
2395
3210
  throw await problemFrom(response);
2396
3211
  }
2397
3212
  const payment = await mintedFrom(response);
2398
- if (this.verify) {
2399
- await proveOrigin(payment, asked);
2400
- }
3213
+ await proveOrigin(payment, asked, askedAt);
3214
+ this.handOut(payment, options?.idempotencyKey ?? null);
2401
3215
  return payment;
2402
3216
  }
2403
3217
  /**
@@ -2408,7 +3222,8 @@ var ThunderBridge = class {
2408
3222
  * it for one, and asking mints it
2409
3223
  */
2410
3224
  async quote(charge) {
2411
- const sent = quoteRequestBody(await priced(charge));
3225
+ const asked = await priced(charge);
3226
+ const sent = quoteRequestBody(asked);
2412
3227
  const response = await fetch(`${this.baseUrl}/quotes`, {
2413
3228
  method: "POST",
2414
3229
  headers: await this.sending("/quotes", sent),
@@ -2417,14 +3232,17 @@ var ThunderBridge = class {
2417
3232
  if (!response.ok) {
2418
3233
  throw await problemFrom(response);
2419
3234
  }
2420
- const quote = quoteFromWire(await response.json().catch(() => null));
2421
- if (quote === null) {
3235
+ const quote2 = quoteFromWire(await response.json().catch(() => null));
3236
+ if (quote2 === null) {
2422
3237
  throw new ProblemError({
2423
3238
  status: response.status,
2424
3239
  title: "The gateway answered with something that is not a quote"
2425
3240
  });
2426
3241
  }
2427
- return quote;
3242
+ if (!asked.paidTo.some((address) => address.toLowerCase() === quote2.lnAddress.toLowerCase())) {
3243
+ throw new GatewayCheatError("address_not_requested", "");
3244
+ }
3245
+ return quote2;
2428
3246
  }
2429
3247
  /**
2430
3248
  * The key this gateway signs webhooks with when you registered none of your own.
@@ -2457,8 +3275,8 @@ var ThunderBridge = class {
2457
3275
  * for both sorts: `kind` says whether the gateway minted it or was handed it,
2458
3276
  * and the address, amount and invoice are null on one it was never told
2459
3277
  */
2460
- async payment(id) {
2461
- const path = `/incoming-payments/${encodeURIComponent(id)}`;
3278
+ async payment(held) {
3279
+ const path = `/incoming-payments/${encodeURIComponent(held.id)}`;
2462
3280
  const response = await fetch(`${this.baseUrl}${path}`, {
2463
3281
  headers: await this.reading("GET", path)
2464
3282
  });
@@ -2468,7 +3286,7 @@ var ThunderBridge = class {
2468
3286
  if (!response.ok) {
2469
3287
  throw await problemFrom(response);
2470
3288
  }
2471
- return this.proven(await paymentFrom(response));
3289
+ return this.proven(await paymentFrom(response), held);
2472
3290
  }
2473
3291
  /**
2474
3292
  * List what this gateway is watching, newest first. Only a gateway started
@@ -2488,14 +3306,14 @@ var ThunderBridge = class {
2488
3306
  throw await problemFrom(response);
2489
3307
  }
2490
3308
  const body = await response.json().catch(() => null);
2491
- const listed = Array.isArray(body?.payments) ? body.payments.map(paymentFromWire).filter((one) => one !== null) : null;
2492
- if (listed === null || typeof body?.settled_scanned !== "number") {
3309
+ const listed2 = Array.isArray(body?.payments) ? body.payments.map(paymentFromWire).filter((one) => one !== null).filter((one) => one.status !== "paid" || agreesWithItself(one)) : null;
3310
+ if (listed2 === null || typeof body?.settled_scanned !== "number") {
2493
3311
  throw new ProblemError({
2494
3312
  status: response.status,
2495
3313
  title: "The gateway answered with something that is not a payment list"
2496
3314
  });
2497
3315
  }
2498
- return { payments: listed, scanned: body.settled_scanned };
3316
+ return { payments: listed2, scanned: body.settled_scanned };
2499
3317
  }
2500
3318
  /**
2501
3319
  * Follow a payment over WebSocket until it is paid or expired, reconnecting
@@ -2503,15 +3321,15 @@ var ThunderBridge = class {
2503
3321
  * one that has answered is followed until its own expiry, so the wait always
2504
3322
  * ends by itself
2505
3323
  */
2506
- async settled(id, options) {
2507
- const ended = paymentFromWire(await this.followed(id, options));
3324
+ async settled(held, options) {
3325
+ const ended = paymentFromWire(await this.followed(held.id, options));
2508
3326
  if (ended === null) {
2509
3327
  throw new ProblemError({
2510
3328
  status: 200,
2511
3329
  title: "The gateway answered with something that is not a payment"
2512
3330
  });
2513
3331
  }
2514
- return this.proven(ended);
3332
+ return this.proven(ended, held);
2515
3333
  }
2516
3334
  followed(id, options) {
2517
3335
  const base = this.baseUrl.replace(/^http/, "ws");
@@ -2573,9 +3391,9 @@ var ThunderBridge = class {
2573
3391
  if (settled) {
2574
3392
  return;
2575
3393
  }
2576
- const opened = new WebSocket(url);
2577
- socket = opened;
2578
- opened.onmessage = (event) => {
3394
+ const opened2 = new WebSocket(url);
3395
+ socket = opened2;
3396
+ opened2.onmessage = (event) => {
2579
3397
  try {
2580
3398
  const frame = JSON.parse(String(event.data));
2581
3399
  const watched = paymentFromWire(frame);
@@ -2592,7 +3410,7 @@ var ThunderBridge = class {
2592
3410
  settle(() => reject(refused));
2593
3411
  }
2594
3412
  };
2595
- opened.onclose = again;
3413
+ opened2.onclose = again;
2596
3414
  };
2597
3415
  options?.signal?.addEventListener("abort", abort, { once: true });
2598
3416
  void connect();
@@ -2615,25 +3433,33 @@ var ThunderBridge = class {
2615
3433
  * the recipient's own wallet minted it, so a payer who pays the loser afterwards
2616
3434
  * really does pay twice and that shows up on `follow` as a second settlement to
2617
3435
  * refund.
3436
+ *
3437
+ * A leg the gateway is caught lying about before any leg is paid ends the wait
3438
+ * with its `GatewayCheatError`, even when another leg is paid after it, so a
3439
+ * detected cheat is never traded for a later win.
2618
3440
  */
2619
- async firstSettled(ids, options) {
2620
- if (ids.length === 0) {
3441
+ async firstSettled(held, options) {
3442
+ if (held.length === 0) {
2621
3443
  return null;
2622
3444
  }
2623
3445
  const stopLosers = new AbortController();
2624
3446
  const signal = options?.signal ? AbortSignal.any([stopLosers.signal, options.signal]) : stopLosers.signal;
2625
3447
  let refused = null;
2626
3448
  try {
2627
- const winner = await new Promise((resolve2) => {
2628
- let waiting = ids.length;
3449
+ const winner = await new Promise((resolve2, caught) => {
3450
+ let waiting = held.length;
2629
3451
  const lost = () => {
2630
3452
  waiting -= 1;
2631
3453
  if (waiting === 0) {
2632
3454
  resolve2(null);
2633
3455
  }
2634
3456
  };
2635
- for (const id of ids) {
2636
- this.settled(id, { ...options, signal }).then((watched) => watched.status === "paid" ? resolve2(watched) : lost()).catch((failure) => {
3457
+ for (const leg of held) {
3458
+ this.settled(leg, { ...options, signal }).then((watched) => watched.status === "paid" ? resolve2(watched) : lost()).catch((failure) => {
3459
+ if (failure instanceof GatewayCheatError) {
3460
+ caught(failure);
3461
+ return;
3462
+ }
2637
3463
  refused ??= failure;
2638
3464
  lost();
2639
3465
  });
@@ -2677,6 +3503,9 @@ var ThunderBridge = class {
2677
3503
  if (named !== null && watched.id !== named) {
2678
3504
  throw new GatewayCheatError("id_not_mine", watched.id);
2679
3505
  }
3506
+ if (watched.paymentHash.toLowerCase() !== handover.paymentHash.toLowerCase()) {
3507
+ throw new GatewayCheatError("hash_mismatch", watched.id);
3508
+ }
2680
3509
  return watched;
2681
3510
  }
2682
3511
  /**
@@ -2689,6 +3518,69 @@ var ThunderBridge = class {
2689
3518
  async nameFor(paymentHash) {
2690
3519
  return this.secret === null ? null : paymentNamedBy((await this.speaking()).publicKeyHex, paymentHash);
2691
3520
  }
3521
+ /**
3522
+ * Hold a socket open and answer what the gateway asks about this caller's own
3523
+ * payments, so a watch addressed to this caller settles without anybody
3524
+ * hosting a URL. Reconnects on its own until the returned function is called
3525
+ */
3526
+ attend(options) {
3527
+ const base = this.baseUrl.replace(/^http/, "ws");
3528
+ const firstDelay = options.reconnectDelayMs ?? RECONNECT_DELAY_MS;
3529
+ let socket = null;
3530
+ let retry;
3531
+ let stopped = false;
3532
+ let attempt = 0;
3533
+ const again = () => {
3534
+ if (stopped || options.reconnect === false) {
3535
+ return;
3536
+ }
3537
+ retry = setTimeout(() => void connect(), backoffMs(firstDelay, attempt));
3538
+ };
3539
+ const answered3 = async (opened2, raw) => {
3540
+ const asked = askFromWire(raw);
3541
+ if (asked === null) {
3542
+ return;
3543
+ }
3544
+ const preimage = await options.answer(asked.paymentHash);
3545
+ opened2.send(
3546
+ JSON.stringify(
3547
+ preimage === null ? { ask: asked.ask, settled: false } : { ask: asked.ask, settled: true, preimage }
3548
+ )
3549
+ );
3550
+ };
3551
+ const connect = async () => {
3552
+ attempt += 1;
3553
+ let url;
3554
+ try {
3555
+ url = `${base}/ws/tickets/${await this.wsTicket({ agent: true })}`;
3556
+ } catch (refused) {
3557
+ options.onError?.(refused);
3558
+ again();
3559
+ return;
3560
+ }
3561
+ if (stopped) {
3562
+ return;
3563
+ }
3564
+ const opened2 = new WebSocket(url);
3565
+ socket = opened2;
3566
+ opened2.onopen = () => {
3567
+ attempt = 1;
3568
+ };
3569
+ opened2.onmessage = (event) => {
3570
+ void answered3(opened2, String(event.data)).catch(
3571
+ (refused) => options.onError?.(refused)
3572
+ );
3573
+ };
3574
+ opened2.onerror = () => options.onError?.(new Error(`could not attend ${base}`));
3575
+ opened2.onclose = again;
3576
+ };
3577
+ void connect();
3578
+ return () => {
3579
+ stopped = true;
3580
+ clearTimeout(retry);
3581
+ socket?.close();
3582
+ };
3583
+ }
2692
3584
  /**
2693
3585
  * Follow every payment made to one trigger, replayed from the recent ones on
2694
3586
  * connect and then live, reconnecting on its own until the returned function
@@ -2765,7 +3657,7 @@ var ThunderBridge = class {
2765
3657
  });
2766
3658
  }
2767
3659
  needsTicket(asked) {
2768
- return asked === true || this.token !== null;
3660
+ return asked === true || this.token !== null || this.secret !== null;
2769
3661
  }
2770
3662
  async wsTicket(body) {
2771
3663
  return encodeURIComponent((await this.mintedTicket(body)).ticket);
@@ -2795,15 +3687,33 @@ var ThunderBridge = class {
2795
3687
  async reading(method, path, body = "") {
2796
3688
  return {
2797
3689
  ...this.token === null ? {} : { authorization: `Bearer ${this.token}` },
2798
- ...this.secret === null ? {} : await signedAs(await this.speaking(), method, path, body)
3690
+ ...this.secret === null ? {} : await signedAs(await this.speaking(), method, path, body, new URL(this.baseUrl).host)
2799
3691
  };
2800
3692
  }
2801
3693
  async speaking() {
2802
3694
  this.speaks ??= callerKey(this.secret ?? "");
2803
3695
  return await this.speaks;
2804
3696
  }
2805
- proven(payment) {
2806
- if (this.verify && payment.status === "paid" && !carriesProof(payment)) {
3697
+ handOut(payment, key) {
3698
+ const now = Math.floor(Date.now() / 1e3);
3699
+ for (const [hash2, earlier2] of this.handedOut) {
3700
+ if (earlier2.expiresAt <= now) {
3701
+ this.handedOut.delete(hash2);
3702
+ }
3703
+ }
3704
+ const hash = payment.paymentHash.toLowerCase();
3705
+ const earlier = this.handedOut.get(hash);
3706
+ if (earlier !== void 0 && (earlier.key === null || earlier.key !== key)) {
3707
+ throw new GatewayCheatError("invoice_reused", payment.id);
3708
+ }
3709
+ this.handedOut.set(hash, { key, expiresAt: payment.expiresAt });
3710
+ }
3711
+ proven(payment, held) {
3712
+ const other = held !== void 0 && (payment.id !== held.id || payment.paymentHash.toLowerCase() !== held.paymentHash.toLowerCase());
3713
+ if (other) {
3714
+ throw new GatewayCheatError("hash_mismatch", payment.id);
3715
+ }
3716
+ if (payment.status === "paid" && !agreesWithItself(payment)) {
2807
3717
  throw new GatewayCheatError("preimage_mismatch", payment.id);
2808
3718
  }
2809
3719
  return payment;
@@ -2857,6 +3767,7 @@ var Gateways = class {
2857
3767
  each;
2858
3768
  urls;
2859
3769
  onRefused;
3770
+ onCaught;
2860
3771
  constructor(baseUrls, options) {
2861
3772
  if (baseUrls.length === 0) {
2862
3773
  throw new Error("name at least one gateway to watch at");
@@ -2865,6 +3776,8 @@ var Gateways = class {
2865
3776
  this.each = baseUrls.map((baseUrl) => new ThunderBridge(baseUrl, options));
2866
3777
  this.onRefused = options?.onRefused ?? (() => {
2867
3778
  });
3779
+ this.onCaught = options?.onCaught ?? (() => {
3780
+ });
2868
3781
  }
2869
3782
  /** What this payment is called, which every gateway here will agree on */
2870
3783
  async nameFor(paymentHash) {
@@ -2898,7 +3811,7 @@ var Gateways = class {
2898
3811
  * saying so. When they all end without a payment, the first ending is the answer,
2899
3812
  * and when they all fail, the first failure is thrown
2900
3813
  */
2901
- async settled(id, options) {
3814
+ async settled(held, options) {
2902
3815
  const stopLosers = new AbortController();
2903
3816
  const signal = options?.signal ? AbortSignal.any([stopLosers.signal, options.signal]) : stopLosers.signal;
2904
3817
  const unpaid = [];
@@ -2912,14 +3825,17 @@ var Gateways = class {
2912
3825
  resolve2(null);
2913
3826
  }
2914
3827
  };
2915
- for (const gateway of this.each) {
2916
- gateway.settled(id, { ...options, signal }).then((watched) => {
3828
+ for (const [at, gateway] of this.each.entries()) {
3829
+ gateway.settled(held, { ...options, signal }).then((watched) => {
2917
3830
  if (watched.status === "paid") {
2918
3831
  return resolve2(watched);
2919
3832
  }
2920
3833
  unpaid.push(watched);
2921
3834
  lost();
2922
3835
  }).catch((failure) => {
3836
+ if (failure instanceof GatewayCheatError) {
3837
+ this.onCaught(this.urls[at], failure);
3838
+ }
2923
3839
  refused ??= failure;
2924
3840
  lost();
2925
3841
  });
@@ -2947,6 +3863,7 @@ export {
2947
3863
  ThunderBridge,
2948
3864
  UnverifiedRecipientError,
2949
3865
  WrapRefusedError,
3866
+ agreesWithItself,
2950
3867
  answerVerifyChallenge,
2951
3868
  carriesProof,
2952
3869
  decodeInvoice,