thunder-bridge 0.8.0 → 0.8.2

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
@@ -34,10 +34,13 @@ var ProblemError = class extends Error {
34
34
  this.detail = problem.detail ?? null;
35
35
  }
36
36
  };
37
- var NO_WALLET_AVAILABLE = "urn:problem-type:thunder-bridge-direct:no-wallet-available";
38
- var REQUEST_IN_FLIGHT = "urn:problem-type:thunder-bridge-direct:request-in-flight";
39
- var IDEMPOTENCY_KEY_REUSED = "urn:problem-type:thunder-bridge-direct:idempotency-key-reused";
40
- var PAYMENT_ALREADY_WATCHED = "urn:problem-type:thunder-bridge-direct:payment-already-watched";
37
+ function isProblemType(problem, type) {
38
+ return problem.type === type;
39
+ }
40
+ var NO_WALLET_AVAILABLE = "urn:problem-type:thunder-bridge:no-wallet-available";
41
+ var REQUEST_IN_FLIGHT = "urn:problem-type:thunder-bridge:request-in-flight";
42
+ var IDEMPOTENCY_KEY_REUSED = "urn:problem-type:thunder-bridge:idempotency-key-reused";
43
+ var PAYMENT_ALREADY_WATCHED = "urn:problem-type:thunder-bridge:payment-already-watched";
41
44
  var IdempotencyConflictError = class extends ProblemError {
42
45
  conflict;
43
46
  constructor(problem, conflict) {
@@ -68,8 +71,8 @@ function hexToBytes(hex) {
68
71
  }
69
72
  return bytes;
70
73
  }
71
- function isHex(text2) {
72
- return /^[0-9a-f]+$/i.test(text2) && text2.length % 2 === 0;
74
+ function isHex(text3) {
75
+ return /^[0-9a-f]+$/i.test(text3) && text3.length % 2 === 0;
73
76
  }
74
77
 
75
78
  // ../core/sha256.ts
@@ -163,8 +166,8 @@ function sha256(bytes) {
163
166
  }
164
167
  return asBytes(state);
165
168
  }
166
- function sha256Hex(text2) {
167
- return bytesToHex(sha256(new TextEncoder().encode(text2)));
169
+ function sha256Hex(text3) {
170
+ return bytesToHex(sha256(new TextEncoder().encode(text3)));
168
171
  }
169
172
  function withPadding(bytes) {
170
173
  const padded = new Uint8Array(
@@ -343,10 +346,13 @@ function sameOrigin(one, other) {
343
346
  return false;
344
347
  }
345
348
  }
349
+ function publicAddress(literal) {
350
+ return literal.includes(":") ? ipv6IsGlobal(literal) : ipv4IsGlobal(literal);
351
+ }
346
352
  function hostIsPublic(host) {
347
- if (host.startsWith("[")) return ipv6IsGlobal(host.slice(1, -1));
353
+ if (host.startsWith("[")) return publicAddress(host.slice(1, -1));
348
354
  const name = host.replace(/\.$/, "");
349
- if (/^\d+\.\d+\.\d+\.\d+$/.test(name)) return ipv4IsGlobal(name);
355
+ if (/^\d+\.\d+\.\d+\.\d+$/.test(name)) return publicAddress(name);
350
356
  const labels = name.split(".");
351
357
  if (labels.length < 2 || labels.some((label) => label === "")) return false;
352
358
  return !PRIVATE_SUFFIXES.includes(labels[labels.length - 1]);
@@ -489,10 +495,10 @@ function quoteFromWire(body) {
489
495
  const minMsat = msatFrom(wire["min_amount"], 0);
490
496
  const maxMsat = msatFrom(wire["max_amount"], 0);
491
497
  const metadata = text(wire["metadata"]);
492
- const refusals2 = refusalsFrom(wire["refusals"]);
498
+ const refusals3 = refusalsFrom(wire["refusals"]);
493
499
  if (lnAddress === null || amountMsat === null || feeMsat === null) return null;
494
- if (minMsat === null || maxMsat === null || metadata === null || refusals2 === null) return null;
495
- return { lnAddress, amountMsat, feeMsat, minMsat, maxMsat, metadata, refusals: refusals2 };
500
+ if (minMsat === null || maxMsat === null || metadata === null || refusals3 === null) return null;
501
+ return { lnAddress, amountMsat, feeMsat, minMsat, maxMsat, metadata, refusals: refusals3 };
496
502
  }
497
503
  function paymentFromWire(body) {
498
504
  const wire = asObject(body);
@@ -587,15 +593,15 @@ function msatFrom(value, least) {
587
593
  }
588
594
  function refusalsFrom(value) {
589
595
  if (!Array.isArray(value)) return null;
590
- const refusals2 = [];
596
+ const refusals3 = [];
591
597
  for (const entry of value) {
592
598
  const failure = asObject(entry);
593
599
  const address = failure === null ? null : text(failure["address"]);
594
600
  const reason = failure?.["reason"];
595
601
  if (address === null || typeof reason !== "string" || !REASONS.has(reason)) return null;
596
- refusals2.push({ address, reason });
602
+ refusals3.push({ address, reason });
597
603
  }
598
- return refusals2;
604
+ return refusals3;
599
605
  }
600
606
  function secondsFrom(value) {
601
607
  if (typeof value !== "string") return null;
@@ -611,6 +617,16 @@ var TERMINAL = /* @__PURE__ */ new Set(["paid", "expired"]);
611
617
  var RECONNECT_DELAY_MS = 3e3;
612
618
  var RECONNECT_CAP_MS = 3e4;
613
619
  var UNANSWERED_ATTEMPTS = 5;
620
+ var TRIGGER_MIN_CHARS = 16;
621
+ var STRANGER_PROBE = "is-this-gateway-yours";
622
+ function unguessable(trigger) {
623
+ if (trigger.length < TRIGGER_MIN_CHARS) {
624
+ throw new Error(
625
+ `a trigger secret is the only thing guarding its stream, and nothing rate limits a guess at it, so it has to be at least ${TRIGGER_MIN_CHARS} characters nobody can predict`
626
+ );
627
+ }
628
+ return trigger;
629
+ }
614
630
  function backoffMs(firstDelay, attempt) {
615
631
  const grown = Math.min(firstDelay * 2 ** (attempt - 1), RECONNECT_CAP_MS);
616
632
  return Math.round(grown * (0.5 + Math.random() / 2));
@@ -619,11 +635,35 @@ var ThunderBridge = class {
619
635
  baseUrl;
620
636
  verify;
621
637
  token;
638
+ strangers = null;
622
639
  constructor(baseUrl, options) {
623
640
  this.baseUrl = baseUrl.replace(/\/+$/, "");
624
641
  this.verify = options?.verify ?? true;
625
642
  this.token = options?.token ?? null;
626
643
  }
644
+ /**
645
+ * Whether a token was given, which is what makes an instance yours: a gateway
646
+ * started with `GATEWAY_TOKEN` answers nobody else, so anything you hand it
647
+ * stays between you and it
648
+ */
649
+ get isPrivate() {
650
+ return this.token !== null;
651
+ }
652
+ /**
653
+ * Whether the gateway turns away a caller carrying no token, asked by making
654
+ * one unauthenticated read it would have to refuse. `isPrivate` answers only
655
+ * whether you configured a token, which is your side of the arrangement and
656
+ * says nothing about the gateway's, so a made-up token against a public
657
+ * instance reads as private and is not. Asked once and remembered, because an
658
+ * instance does not change its mind. Anything other than a refusal counts as
659
+ * open, so an unreachable gateway fails closed
660
+ */
661
+ async refusesStrangers() {
662
+ this.strangers ??= fetch(`${this.baseUrl}/incoming-payments/${STRANGER_PROBE}`, {
663
+ headers: { accept: "application/json" }
664
+ }).then((answer) => answer.status === 401).catch(() => false);
665
+ return await this.strangers;
666
+ }
627
667
  /**
628
668
  * Ask the gateway for an invoice payable to the first address on your list
629
669
  * that can issue a provable one, throws `NoWalletAvailableError` when none can
@@ -635,7 +675,10 @@ var ThunderBridge = class {
635
675
  const response = await fetch(`${this.baseUrl}/incoming-payments`, {
636
676
  method: "POST",
637
677
  headers,
638
- body: createRequestBody(params, options?.trigger ? sha256Hex(options.trigger) : null)
678
+ body: createRequestBody(
679
+ params,
680
+ options?.trigger ? sha256Hex(unguessable(options.trigger)) : null
681
+ )
639
682
  });
640
683
  if (!response.ok) throw await problemFrom(response);
641
684
  const payment = await paymentFrom(response);
@@ -674,6 +717,26 @@ var ThunderBridge = class {
674
717
  if (!response.ok) throw await problemFrom(response);
675
718
  return this.checked(await paymentFrom(response));
676
719
  }
720
+ /**
721
+ * Read back a payment the gateway is only watching, null when it has never
722
+ * heard of it. A watched payment carries no address, amount or invoice, so
723
+ * `getPayment` refuses it and this reads the shape both rails share
724
+ */
725
+ async getWatched(id) {
726
+ const response = await fetch(`${this.baseUrl}/incoming-payments/${encodeURIComponent(id)}`, {
727
+ headers: this.reading()
728
+ });
729
+ if (response.status === 404) return null;
730
+ if (!response.ok) throw await problemFrom(response);
731
+ const watched = triggerEventFromWire(await response.json().catch(() => null));
732
+ if (watched === null) {
733
+ throw new ProblemError({
734
+ status: response.status,
735
+ title: "The gateway answered with something that is not a payment"
736
+ });
737
+ }
738
+ return this.proven(watched);
739
+ }
677
740
  /**
678
741
  * List what this gateway is watching, newest first. Only a gateway started
679
742
  * with `GATEWAY_TOKEN` serves this, because on a shared one it would hand
@@ -705,7 +768,36 @@ var ThunderBridge = class {
705
768
  * one that has answered is followed until its own expiry, so the wait always
706
769
  * ends by itself
707
770
  */
708
- waitForPayment(id, options) {
771
+ async waitForPayment(id, options) {
772
+ const payment = paymentFromWire(await this.followed(id, options));
773
+ if (payment === null) {
774
+ throw new ProblemError({
775
+ status: 200,
776
+ title: `payment ${id} is watched rather than minted here, read it with waitForWatched`
777
+ });
778
+ }
779
+ return this.checked(payment);
780
+ }
781
+ /**
782
+ * Follow a payment the gateway is only watching, one it did not mint, until it
783
+ * is paid or expired.
784
+ *
785
+ * A watched payment carries no address, no amount and no invoice, because the
786
+ * gateway was told none of them, so it reads back as the shape a trigger
787
+ * streams rather than as a `Payment`. That is every bank transfer, and every
788
+ * Lightning invoice registered with `watchPayment` instead of `createPayment`.
789
+ */
790
+ async waitForWatched(id, options) {
791
+ const watched = triggerEventFromWire(await this.followed(id, options));
792
+ if (watched === null) {
793
+ throw new ProblemError({
794
+ status: 200,
795
+ title: "The gateway answered with something that is not a payment"
796
+ });
797
+ }
798
+ return this.proven(watched);
799
+ }
800
+ followed(id, options) {
709
801
  const base = this.baseUrl.replace(/^http/, "ws");
710
802
  const direct = `${base}/ws/incoming-payments/${encodeURIComponent(id)}`;
711
803
  return new Promise((resolve2, reject) => {
@@ -758,13 +850,13 @@ var ThunderBridge = class {
758
850
  socket = opened;
759
851
  opened.onmessage = (event) => {
760
852
  try {
761
- const payment = paymentFromWire(JSON.parse(String(event.data)));
762
- if (payment === null) return;
763
- expiresAt = payment.expiresAt;
853
+ const frame = JSON.parse(String(event.data));
854
+ const watched = triggerEventFromWire(frame);
855
+ if (watched === null) return;
856
+ expiresAt = watched.expiresAt;
764
857
  attempt = 1;
765
- if (!TERMINAL.has(payment.status)) return;
766
- const checked = this.checked(payment);
767
- settle(() => resolve2(checked));
858
+ if (!TERMINAL.has(watched.status)) return;
859
+ settle(() => resolve2(frame));
768
860
  } catch (refused) {
769
861
  settle(() => reject(refused));
770
862
  }
@@ -775,6 +867,49 @@ var ThunderBridge = class {
775
867
  void connect();
776
868
  });
777
869
  }
870
+ /**
871
+ * Wait on several payments and keep the first one that is really paid, then stop
872
+ * waiting on the losers, which closes their sockets.
873
+ *
874
+ * This is how one order offers two rails. A Lightning invoice and a bank
875
+ * transfer for the same thing are two payments here, and the payer picks one, so
876
+ * what you want is the one that arrives and nothing further from the other.
877
+ *
878
+ * A leg that expires is a loser, not a winner, which is the whole reason this is
879
+ * not a race: `waitForPayment` ends on `paid` and on `expired` alike, and a
880
+ * Lightning invoice expires in an hour while a bank transfer takes days. `null`
881
+ * means every leg ended without being paid.
882
+ *
883
+ * Stopping the wait is not revoking the invoice. Nobody can revoke one, because
884
+ * the recipient's own wallet minted it, so a payer who pays the loser afterwards
885
+ * really does pay twice and that shows up on `followTrigger` as a second
886
+ * settlement to refund.
887
+ */
888
+ async firstToSettle(ids, options) {
889
+ if (ids.length === 0) return null;
890
+ const stopLosers = new AbortController();
891
+ const signal = options?.signal ? AbortSignal.any([stopLosers.signal, options.signal]) : stopLosers.signal;
892
+ let refused = null;
893
+ try {
894
+ const winner = await new Promise((resolve2) => {
895
+ let waiting = ids.length;
896
+ const lost = () => {
897
+ waiting -= 1;
898
+ if (waiting === 0) resolve2(null);
899
+ };
900
+ for (const id of ids) {
901
+ this.waitForWatched(id, { ...options, signal }).then((watched) => watched.status === "paid" ? resolve2(watched) : lost()).catch((failure) => {
902
+ refused ??= failure;
903
+ lost();
904
+ });
905
+ }
906
+ });
907
+ if (winner === null && refused !== null) throw refused;
908
+ return winner;
909
+ } finally {
910
+ stopLosers.abort();
911
+ }
912
+ }
778
913
  /**
779
914
  * Hand over an invoice you obtained yourself so the gateway watches it without
780
915
  * being told the address or the amount. It can then only refuse everyone
@@ -785,7 +920,10 @@ var ThunderBridge = class {
785
920
  const response = await fetch(`${this.baseUrl}/watched-payments`, {
786
921
  method: "POST",
787
922
  headers: this.sending(),
788
- body: watchRequestBody(params, params.trigger ? sha256Hex(params.trigger) : null)
923
+ body: watchRequestBody(
924
+ params,
925
+ params.trigger ? sha256Hex(unguessable(params.trigger)) : null
926
+ )
789
927
  });
790
928
  if (!response.ok) throw await problemFrom(response);
791
929
  const watched = triggerEventFromWire(await response.json().catch(() => null));
@@ -804,7 +942,7 @@ var ThunderBridge = class {
804
942
  */
805
943
  followTrigger(secret, options) {
806
944
  const base = this.baseUrl.replace(/^http/, "ws");
807
- const direct = `${base}/ws/triggers/${encodeURIComponent(secret)}`;
945
+ const direct = `${base}/ws/triggers/${encodeURIComponent(unguessable(secret))}`;
808
946
  const firstDelay = options.reconnectDelayMs ?? RECONNECT_DELAY_MS;
809
947
  let socket = null;
810
948
  let retry;
@@ -899,13 +1037,13 @@ async function paymentFrom(response) {
899
1037
  async function problemFrom(response) {
900
1038
  const problem = await response.json().catch(() => ({}));
901
1039
  const document = { ...problem, status: response.status };
902
- if (problem.type === NO_WALLET_AVAILABLE) {
1040
+ if (isProblemType(problem, NO_WALLET_AVAILABLE)) {
903
1041
  return new NoWalletAvailableError(document, refusals(problem.wallets));
904
1042
  }
905
- if (problem.type === REQUEST_IN_FLIGHT) {
1043
+ if (isProblemType(problem, REQUEST_IN_FLIGHT)) {
906
1044
  return new IdempotencyConflictError(document, "request-in-flight");
907
1045
  }
908
- if (problem.type === IDEMPOTENCY_KEY_REUSED) {
1046
+ if (isProblemType(problem, IDEMPOTENCY_KEY_REUSED)) {
909
1047
  return new IdempotencyConflictError(document, "key-reused");
910
1048
  }
911
1049
  return new ProblemError(document);
@@ -922,7 +1060,7 @@ var VERSION = "v1";
922
1060
  var IV_BYTES = 12;
923
1061
  var MIN_SECRET_CHARS = 32;
924
1062
  var MAX_PLAIN_BYTES = 3e3;
925
- var INFO = new TextEncoder().encode("thunder-bridge-direct/sealed");
1063
+ var INFO = new TextEncoder().encode("thunder-bridge/sealed");
926
1064
  async function seal(secret, plaintext) {
927
1065
  const body = new TextEncoder().encode(plaintext);
928
1066
  if (body.length > MAX_PLAIN_BYTES) {
@@ -976,10 +1114,10 @@ function toBase64Url(bytes) {
976
1114
  for (const byte of bytes) binary += String.fromCharCode(byte);
977
1115
  return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
978
1116
  }
979
- function fromBase64Url(text2) {
980
- if (text2.length === 0 || !/^[A-Za-z0-9_-]+$/.test(text2)) return null;
1117
+ function fromBase64Url(text3) {
1118
+ if (text3.length === 0 || !/^[A-Za-z0-9_-]+$/.test(text3)) return null;
981
1119
  try {
982
- const binary = atob(text2.replace(/-/g, "+").replace(/_/g, "/"));
1120
+ const binary = atob(text3.replace(/-/g, "+").replace(/_/g, "/"));
983
1121
  const bytes = new Uint8Array(binary.length);
984
1122
  for (let at = 0; at < bytes.length; at++) bytes[at] = binary.charCodeAt(at);
985
1123
  return bytes;
@@ -1011,6 +1149,87 @@ function asBuffer(bytes) {
1011
1149
  return bytes instanceof Uint8Array && bytes.buffer instanceof ArrayBuffer ? bytes : new Uint8Array(bytes);
1012
1150
  }
1013
1151
 
1152
+ // ../core/outbound.ts
1153
+ import { lookup } from "dns/promises";
1154
+ import { setTimeout as sleep } from "timers/promises";
1155
+ var HTTP_TIMEOUT_MS2 = 15e3;
1156
+ var LOOKUP_TIMEOUT_MS = 5e3;
1157
+ var HOPS_FOLLOWED = 2;
1158
+ var REDIRECTS = [301, 302, 303, 307, 308];
1159
+ var KEEPS_THE_METHOD = [307, 308];
1160
+ var CREDENTIALS = ["authorization", "cookie", "proxy-authorization"];
1161
+ var BODY_LIMIT_BYTES = 262144;
1162
+ async function ask(url, sent = {}) {
1163
+ const capped = AbortSignal.timeout(HTTP_TIMEOUT_MS2);
1164
+ const signal = sent.deadline ? AbortSignal.any([sent.deadline, capped]) : capped;
1165
+ let target = url;
1166
+ let carried = sent;
1167
+ for (let hop = 0; hop <= HOPS_FOLLOWED; hop += 1) {
1168
+ await refuseUnlessPublic(target);
1169
+ const response = await fetch(target, {
1170
+ method: carried.method ?? "GET",
1171
+ headers: carried.headers ?? {},
1172
+ body: carried.body,
1173
+ redirect: "manual",
1174
+ signal
1175
+ });
1176
+ if (!REDIRECTS.includes(response.status)) return await answerOf(response);
1177
+ const location = response.headers.get("location");
1178
+ if (location === null) {
1179
+ throw new Error(`${target} answered ${response.status} without saying where to`);
1180
+ }
1181
+ await response.body?.cancel();
1182
+ const next = new URL(location, target).toString();
1183
+ carried = KEEPS_THE_METHOD.includes(response.status) ? carried : withoutBody(carried);
1184
+ carried = sameOrigin(target, next) ? carried : withoutCredentials(carried);
1185
+ target = next;
1186
+ }
1187
+ throw new Error(`${url} redirected more than ${HOPS_FOLLOWED} times`);
1188
+ }
1189
+ async function resolvesNothingPrivate(url) {
1190
+ const host = new URL(url).hostname.replace(/^\[|]$/g, "");
1191
+ const found = await Promise.race([
1192
+ lookup(host, { all: true, verbatim: true }).catch(() => []),
1193
+ sleep(LOOKUP_TIMEOUT_MS, null, { ref: false })
1194
+ ]);
1195
+ return found !== null && found.every((one) => publicAddress(one.address));
1196
+ }
1197
+ async function refuseUnlessPublic(url) {
1198
+ if (!publicHttps(url)) throw new Error(`${url} is not a public https URL`);
1199
+ if (!await resolvesNothingPrivate(url)) throw new Error(`${url} resolves to an address we do not reach`);
1200
+ }
1201
+ async function answerOf(response) {
1202
+ const { status, ok } = response;
1203
+ const reader = response.body?.getReader();
1204
+ if (!reader) return { status, ok, body: "", truncated: false };
1205
+ const read = [];
1206
+ let bytes = 0;
1207
+ while (bytes <= BODY_LIMIT_BYTES) {
1208
+ const { done, value } = await reader.read();
1209
+ if (done) return { status, ok, body: text2(read), truncated: false };
1210
+ read.push(value);
1211
+ bytes += value.length;
1212
+ }
1213
+ await reader.cancel();
1214
+ return { status, ok, body: text2(read), truncated: true };
1215
+ }
1216
+ function withoutBody(sent) {
1217
+ const headers = { ...sent.headers };
1218
+ delete headers["content-type"];
1219
+ return { ...sent, method: "GET", headers, body: void 0 };
1220
+ }
1221
+ function withoutCredentials(sent) {
1222
+ const headers = { ...sent.headers };
1223
+ for (const named of CREDENTIALS) delete headers[named];
1224
+ return { ...sent, headers };
1225
+ }
1226
+ function text2(chunks) {
1227
+ const decoder = new TextDecoder();
1228
+ let decoded = "";
1229
+ for (const chunk of chunks) decoded += decoder.decode(chunk, { stream: true });
1230
+ return decoded + decoder.decode();
1231
+ }
1232
+
1014
1233
  // ../core/refusal.ts
1015
1234
  var WalletRefused = class extends Error {
1016
1235
  reason;
@@ -1029,7 +1248,6 @@ var NoWalletAvailable = class extends Error {
1029
1248
 
1030
1249
  // ../core/lnurl.ts
1031
1250
  var VERIFY_WITHOUT_PREIMAGE = ["zeuspay.com", "zeusnuts.com", "ecash.love"];
1032
- var HTTP_TIMEOUT_MS2 = 15e3;
1033
1251
  var BECH32_CHARSET2 = "qpzry9x8gf2tvdw0s3jn54khce6mua7l";
1034
1252
  var BECH32_GENERATOR = [996825010, 642813549, 513874426, 1027748829, 705979059];
1035
1253
  var CHECKSUM_SLOTS = [0, 1, 2, 3, 4, 5];
@@ -1044,18 +1262,18 @@ async function resolve(addresses, amountMsat) {
1044
1262
  }
1045
1263
  async function firstThatServes(addresses, attempt) {
1046
1264
  const deadline = AbortSignal.timeout(RESOLVE_TIMEOUT_MS);
1047
- const refusals2 = [];
1265
+ const refusals3 = [];
1048
1266
  for (const address of addresses) {
1049
1267
  if (deadline.aborted) break;
1050
1268
  try {
1051
- return { won: await attempt(address, deadline), refusals: refusals2 };
1269
+ return { won: await attempt(address, deadline), refusals: refusals3 };
1052
1270
  } catch (refusal) {
1053
1271
  if (!(refusal instanceof WalletRefused)) throw refusal;
1054
1272
  console.warn(`wallet ${address} refused, ${refusal.reason}: ${refusal.message}`);
1055
- refusals2.push({ address, reason: refusal.reason });
1273
+ refusals3.push({ address, reason: refusal.reason });
1056
1274
  }
1057
1275
  }
1058
- throw new NoWalletAvailable(refusals2);
1276
+ throw new NoWalletAvailable(refusals3);
1059
1277
  }
1060
1278
  async function probe(address, amountMsat, deadline) {
1061
1279
  const [user, domain] = splitAddress(address);
@@ -1194,14 +1412,12 @@ function splitAddress(address) {
1194
1412
  return [user, domain];
1195
1413
  }
1196
1414
  async function fetchJson(url, deadline) {
1197
- if (!publicHttps(url)) throw new Error(`${url} is not a public https URL`);
1198
- const capped = AbortSignal.timeout(HTTP_TIMEOUT_MS2);
1199
- const response = await fetch(url, {
1200
- signal: deadline ? AbortSignal.any([deadline, capped]) : capped,
1201
- headers: { accept: "application/json" }
1202
- });
1203
- if (!response.ok) throw new Error(`${url} answered ${response.status}`);
1204
- return await response.json();
1415
+ const answer = await ask(url, { headers: { accept: "application/json" }, deadline });
1416
+ if (!answer.ok) throw new Error(`${url} answered ${answer.status}`);
1417
+ if (answer.truncated) {
1418
+ throw new Error(`${url} answered with more than the ${BODY_LIMIT_BYTES} bytes we read`);
1419
+ }
1420
+ return JSON.parse(answer.body);
1205
1421
  }
1206
1422
 
1207
1423
  // src/trigger.ts
@@ -1280,7 +1496,7 @@ async function mintBlind(config, address, amountMsat) {
1280
1496
  return { bolt11: resolved.bolt11, verifyUrl: resolved.verifyUrl };
1281
1497
  }
1282
1498
  function alreadyWatched(refused) {
1283
- return refused instanceof ProblemError && refused.type === PAYMENT_ALREADY_WATCHED;
1499
+ return refused instanceof ProblemError && isProblemType(refused, PAYMENT_ALREADY_WATCHED);
1284
1500
  }
1285
1501
  function sign(secret, address, amountMsat, nonce) {
1286
1502
  return hmacHex(secret, `${address}|${amountMsat}|${nonce}`);
@@ -1295,6 +1511,249 @@ function refuse(reason) {
1295
1511
  return Response.json({ status: "ERROR", reason });
1296
1512
  }
1297
1513
 
1514
+ // src/currency.ts
1515
+ var ISO_4217_MINOR_UNITS = {
1516
+ BHD: 3,
1517
+ BIF: 0,
1518
+ CLP: 0,
1519
+ CZK: 2,
1520
+ DJF: 0,
1521
+ EUR: 2,
1522
+ GBP: 2,
1523
+ ISK: 0,
1524
+ IQD: 3,
1525
+ JOD: 3,
1526
+ JPY: 0,
1527
+ KRW: 0,
1528
+ KWD: 3,
1529
+ LYD: 3,
1530
+ OMR: 3,
1531
+ PLN: 2,
1532
+ PYG: 0,
1533
+ RWF: 0,
1534
+ TND: 3,
1535
+ UGX: 0,
1536
+ USD: 2,
1537
+ VND: 0,
1538
+ VUV: 0,
1539
+ XAF: 0,
1540
+ XOF: 0,
1541
+ XPF: 0
1542
+ };
1543
+ function minorUnitsOf(currency) {
1544
+ const digits = ISO_4217_MINOR_UNITS[currency.toUpperCase()];
1545
+ if (digits === void 0) {
1546
+ throw new Error(
1547
+ `${currency.toUpperCase()} is not a currency this knows the ISO 4217 minor unit of`
1548
+ );
1549
+ }
1550
+ return digits;
1551
+ }
1552
+ function minorScaleOf(currency) {
1553
+ return 10 ** minorUnitsOf(currency);
1554
+ }
1555
+
1556
+ // src/bank.ts
1557
+ var DEFAULT_CURRENCY = "CZK";
1558
+ var DEFAULT_LOOK_BACK_SECS = 7 * 24 * 60 * 60;
1559
+ var IBAN = /^[A-Z]{2}[0-9]{2}[0-9A-Z]{8,30}$/;
1560
+ var FORBIDDEN_IN_SPD = "*";
1561
+ async function bankTransfer(params) {
1562
+ const currency = params.currency ?? DEFAULT_CURRENCY;
1563
+ refuseUnusable(params, currency);
1564
+ await refuseAnOpenGateway(params);
1565
+ const spd = shortPaymentDescriptor(params, currency);
1566
+ const subject = subjectOf(params.reference, params.amountMinor, currency);
1567
+ const polling = new URL(params.verifyUrl);
1568
+ polling.searchParams.set("ref", params.reference);
1569
+ polling.searchParams.set("minor", String(params.amountMinor));
1570
+ polling.searchParams.set("cc", currency);
1571
+ polling.searchParams.set("sig", await hmacHex(params.secret, `verify|${subject}`));
1572
+ const verifyUrl = polling.toString();
1573
+ const watched = await params.gateway.watchPayment({
1574
+ paymentHash: hashOf(await hmacHex(params.secret, `preimage|${subject}`)),
1575
+ verifyUrl,
1576
+ expiresAt: params.expiresAt,
1577
+ trigger: params.trigger,
1578
+ sealed: params.sealed
1579
+ });
1580
+ return { id: watched.id, paymentHash: watched.paymentHash, verifyUrl, spd };
1581
+ }
1582
+ function bankVerifyEndpoint(config) {
1583
+ return async (request) => {
1584
+ const asked = readQuery(new URL(request.url));
1585
+ if (asked === null) return Response.json({ settled: false }, { status: 400 });
1586
+ const subject = subjectOf(asked.reference, asked.amountMinor, asked.currency);
1587
+ const expected = await hmacHex(config.secret, `verify|${subject}`);
1588
+ if (!equalInConstantTime(asked.signature, expected)) {
1589
+ return Response.json({ settled: false }, { status: 403 });
1590
+ }
1591
+ const since = unixNow() - (config.lookBackSecs ?? DEFAULT_LOOK_BACK_SECS);
1592
+ const landed = (await config.statement(since)).some((credit) => pays(credit, asked));
1593
+ if (!landed) return Response.json({ settled: false });
1594
+ return Response.json({
1595
+ settled: true,
1596
+ preimage: await hmacHex(config.secret, `preimage|${subject}`)
1597
+ });
1598
+ };
1599
+ }
1600
+ function readQuery(url) {
1601
+ const reference = url.searchParams.get("ref");
1602
+ const minor = Number(url.searchParams.get("minor"));
1603
+ const currency = url.searchParams.get("cc");
1604
+ const signature = url.searchParams.get("sig");
1605
+ if (!reference || !currency || !signature) return null;
1606
+ if (!Number.isInteger(minor) || minor <= 0) return null;
1607
+ return { reference, amountMinor: minor, currency, signature };
1608
+ }
1609
+ function pays(credit, asked) {
1610
+ return credit.amountMinor === asked.amountMinor && credit.currency.toUpperCase() === asked.currency.toUpperCase() && credit.reference.toUpperCase().includes(asked.reference.toUpperCase());
1611
+ }
1612
+ function subjectOf(reference, amountMinor, currency) {
1613
+ return `${reference}|${amountMinor}|${currency.toUpperCase()}`;
1614
+ }
1615
+ function hashOf(preimage) {
1616
+ return bytesToHex(sha256(hexToBytes(preimage)));
1617
+ }
1618
+ function shortPaymentDescriptor(params, currency) {
1619
+ const fields = [
1620
+ `ACC:${params.iban}`,
1621
+ `AM:${major(params.amountMinor, currency)}`,
1622
+ `CC:${currency.toUpperCase()}`,
1623
+ `MSG:${params.reference}`
1624
+ ];
1625
+ if (params.variableSymbol) fields.push(`X-VS:${params.variableSymbol}`);
1626
+ return `SPD*1.0*${fields.join("*")}`;
1627
+ }
1628
+ function major(amountMinor, currency) {
1629
+ return (amountMinor / minorScaleOf(currency)).toFixed(minorUnitsOf(currency));
1630
+ }
1631
+ async function refuseAnOpenGateway(params) {
1632
+ if (params.allowPublicGateway === true) return;
1633
+ if (await params.gateway.refusesStrangers()) return;
1634
+ throw new Error(
1635
+ "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"
1636
+ );
1637
+ }
1638
+ function refuseUnusable(params, currency) {
1639
+ if (!IBAN.test(params.iban)) throw new Error(`${params.iban} is not an IBAN`);
1640
+ if (!Number.isInteger(params.amountMinor) || params.amountMinor <= 0) {
1641
+ throw new Error("amountMinor must be a whole number of minor units above zero");
1642
+ }
1643
+ if (params.reference.length === 0) {
1644
+ throw new Error("a transfer with no reference cannot be found");
1645
+ }
1646
+ if (params.reference.includes(FORBIDDEN_IN_SPD)) {
1647
+ throw new Error("a reference cannot contain an asterisk, it separates the QR fields");
1648
+ }
1649
+ if (params.variableSymbol && !/^[0-9]{1,10}$/.test(params.variableSymbol)) {
1650
+ throw new Error("a variable symbol is up to ten digits");
1651
+ }
1652
+ }
1653
+ function unixNow() {
1654
+ return Math.floor(Date.now() / 1e3);
1655
+ }
1656
+
1657
+ // src/fio.ts
1658
+ var BASE_URL = "https://fioapi.fio.cz/v1/rest";
1659
+ var MINOR_UNITS = 100;
1660
+ var MILLIS = 1e3;
1661
+ var DATE_CHARS = "2026-08-04".length;
1662
+ var DEFAULT_MIN_INTERVAL_SECS = 30;
1663
+ var TOO_SOON = 409;
1664
+ var BOOKED_AT = "column0";
1665
+ var AMOUNT = "column1";
1666
+ var CURRENCY = "column14";
1667
+ var VARIABLE_SYMBOL = "column5";
1668
+ var USER_IDENTIFICATION = "column7";
1669
+ var MESSAGE_FOR_RECIPIENT = "column16";
1670
+ var PAYER_REFERENCE = "column27";
1671
+ var WHERE_A_PAYER_WRITES = [
1672
+ MESSAGE_FOR_RECIPIENT,
1673
+ VARIABLE_SYMBOL,
1674
+ USER_IDENTIFICATION,
1675
+ PAYER_REFERENCE
1676
+ ];
1677
+ var UTC_OFFSET = /^[+-]\d{4}$/;
1678
+ function fioStatement(config) {
1679
+ const named = typeof config.token === "string" ? [config.token] : config.token;
1680
+ if (named.length === 0) throw new Error("fioStatement needs at least one token to read with");
1681
+ const usedAt = new Map(named.map((token) => [token, Number.NEGATIVE_INFINITY]));
1682
+ const tokenWindowMs = (config.minIntervalSecs ?? DEFAULT_MIN_INTERVAL_SECS) * MILLIS;
1683
+ const paceMs = tokenWindowMs / usedAt.size;
1684
+ let lastRead = Number.NEGATIVE_INFINITY;
1685
+ let credits = [];
1686
+ return async (sinceUnix) => {
1687
+ const now = Date.now();
1688
+ if (now - lastRead < paceMs) return credits;
1689
+ const idlest = longestUnused(usedAt);
1690
+ if (now - idlest.usedAt < tokenWindowMs) return credits;
1691
+ usedAt.set(idlest.token, now);
1692
+ lastRead = now;
1693
+ const url = [
1694
+ config.baseUrl ?? BASE_URL,
1695
+ "periods",
1696
+ idlest.token,
1697
+ asDate(sinceUnix),
1698
+ asDate(Math.floor(now / MILLIS)),
1699
+ "transactions.json"
1700
+ ].join("/");
1701
+ const answer = await fetch(url, { headers: { accept: "application/json" } });
1702
+ if (answer.status === TOO_SOON) {
1703
+ throw new Error("fio refuses a second read of this token inside its 30 second window");
1704
+ }
1705
+ if (!answer.ok) throw new Error(`fio answered ${answer.status} reading the statement`);
1706
+ const read = await answer.json();
1707
+ credits = (read.accountStatement?.transactionList?.transaction ?? []).filter(isCredit).map(asCredit);
1708
+ return credits;
1709
+ };
1710
+ }
1711
+ function longestUnused(usedAt) {
1712
+ let token = "";
1713
+ let idleSince = Number.POSITIVE_INFINITY;
1714
+ for (const [candidate, at] of usedAt) {
1715
+ if (at < idleSince) {
1716
+ token = candidate;
1717
+ idleSince = at;
1718
+ }
1719
+ }
1720
+ return { token, usedAt: idleSince };
1721
+ }
1722
+ function isCredit(transaction) {
1723
+ return numberIn(transaction[AMOUNT]) > 0;
1724
+ }
1725
+ function asCredit(transaction) {
1726
+ return {
1727
+ amountMinor: Math.round(numberIn(transaction[AMOUNT]) * MINOR_UNITS),
1728
+ currency: textIn(transaction[CURRENCY]),
1729
+ reference: WHERE_A_PAYER_WRITES.map((column) => textIn(transaction[column])).filter((written) => written.length > 0).join(" "),
1730
+ bookedAt: bookedAtIn(transaction[BOOKED_AT])
1731
+ };
1732
+ }
1733
+ function bookedAtIn(cell) {
1734
+ const value = cell?.value;
1735
+ if (typeof value === "number") return Math.floor(value / MILLIS);
1736
+ const at = Date.parse(asIso8601(textIn(cell)));
1737
+ return Number.isFinite(at) ? Math.floor(at / MILLIS) : 0;
1738
+ }
1739
+ function asIso8601(booked) {
1740
+ const day = booked.slice(0, DATE_CHARS);
1741
+ const zone = booked.slice(DATE_CHARS);
1742
+ if (!UTC_OFFSET.test(zone)) return `${day}T00:00:00Z`;
1743
+ return `${day}T00:00:00${zone.slice(0, 3)}:${zone.slice(3)}`;
1744
+ }
1745
+ function numberIn(cell) {
1746
+ const value = Number(cell?.value ?? Number.NaN);
1747
+ return Number.isFinite(value) ? value : 0;
1748
+ }
1749
+ function textIn(cell) {
1750
+ const value = cell?.value;
1751
+ return value === null || value === void 0 ? "" : String(value).trim();
1752
+ }
1753
+ function asDate(unix) {
1754
+ return new Date(unix * MILLIS).toISOString().slice(0, DATE_CHARS);
1755
+ }
1756
+
1298
1757
  // src/qr.ts
1299
1758
  import { encode } from "uqr";
1300
1759
  var SAFE_COLOR = /^#(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$|^rgba?\([\d.,%/\s]+\)$|^[a-z]+$/i;
@@ -1314,6 +1773,18 @@ function lnurlToSvg(endpoint, options) {
1314
1773
  function lnurlToDataUrl(endpoint, options) {
1315
1774
  return asDataUrl(lnurlToSvg(endpoint, options));
1316
1775
  }
1776
+ function spdToSvg(spd, options) {
1777
+ return svgOf(spd, options);
1778
+ }
1779
+ function spdToDataUrl(spd, options) {
1780
+ return asDataUrl(spdToSvg(spd, options));
1781
+ }
1782
+ function qrToSvg(payload, options) {
1783
+ return svgOf(payload, options);
1784
+ }
1785
+ function qrToDataUrl(payload, options) {
1786
+ return asDataUrl(qrToSvg(payload, options));
1787
+ }
1317
1788
  function svgOf(scanned, options) {
1318
1789
  const size = options?.size ?? 256;
1319
1790
  const color = options?.color ?? "#000";
@@ -1347,6 +1818,207 @@ function renderSvg(content, size, color) {
1347
1818
  ].join("");
1348
1819
  }
1349
1820
 
1821
+ // src/rail.ts
1822
+ var BANK = "bank";
1823
+ var LIGHTNING = "lightning";
1824
+ function bankRail(config) {
1825
+ return async (order) => {
1826
+ const expiresAt = config.expiresAt(order);
1827
+ const transfer = await bankTransfer({
1828
+ gateway: config.gateway,
1829
+ secret: config.secret,
1830
+ iban: config.iban,
1831
+ verifyUrl: config.verifyUrl,
1832
+ reference: order.reference,
1833
+ amountMinor: order.amountMinor,
1834
+ currency: order.currency,
1835
+ expiresAt,
1836
+ trigger: config.trigger,
1837
+ sealed: await config.sealed?.(order),
1838
+ variableSymbol: config.variableSymbol?.(order),
1839
+ allowPublicGateway: config.allowPublicGateway
1840
+ });
1841
+ return {
1842
+ id: transfer.id,
1843
+ rail: config.name ?? BANK,
1844
+ scan: transfer.spd,
1845
+ qr: transfer.spd,
1846
+ expiresAt
1847
+ };
1848
+ };
1849
+ }
1850
+ function lightningRail(config) {
1851
+ return async (order) => {
1852
+ const payment = await config.gateway.createPayment(
1853
+ {
1854
+ lnAddresses: config.lnAddresses,
1855
+ amountMsat: await config.amountMsat(order),
1856
+ webhookUrl: config.webhookUrl,
1857
+ webhookSecret: config.webhookSecret
1858
+ },
1859
+ { idempotencyKey: config.idempotencyKey?.(order), trigger: config.trigger }
1860
+ );
1861
+ return {
1862
+ id: payment.id,
1863
+ rail: config.name ?? LIGHTNING,
1864
+ scan: payment.bolt11,
1865
+ qr: encodeForQr(payment.bolt11),
1866
+ expiresAt: payment.expiresAt
1867
+ };
1868
+ };
1869
+ }
1870
+ function blindLightningRail(config) {
1871
+ return async (order) => {
1872
+ const resolved = await invoiceFrom(config.lnAddresses, await config.amountMsat(order));
1873
+ const watched = await config.gateway.watchPayment({
1874
+ paymentHash: resolved.paymentHash,
1875
+ verifyUrl: resolved.verifyUrl,
1876
+ expiresAt: resolved.expiresAt,
1877
+ trigger: config.trigger,
1878
+ sealed: await config.sealed?.(order)
1879
+ });
1880
+ return {
1881
+ id: watched.id,
1882
+ rail: config.name ?? LIGHTNING,
1883
+ scan: resolved.bolt11,
1884
+ qr: encodeForQr(resolved.bolt11),
1885
+ expiresAt: resolved.expiresAt
1886
+ };
1887
+ };
1888
+ }
1889
+ async function invoiceFrom(lnAddresses, amountMsat) {
1890
+ try {
1891
+ return await resolve(lnAddresses, amountMsat);
1892
+ } catch (refused) {
1893
+ if (refused instanceof NoWalletAvailable) {
1894
+ throw new NoWalletAvailableError({ title: refused.message }, refused.wallets);
1895
+ }
1896
+ throw refused;
1897
+ }
1898
+ }
1899
+
1900
+ // src/price.ts
1901
+ var MSAT_PER_BTC2 = 1e11;
1902
+ var BASIS_POINTS = 1e4;
1903
+ var DEFAULT_HOLD_SECS = 60;
1904
+ var DEFAULT_MIN_VENUES = 2;
1905
+ var DEFAULT_MAX_SPREAD_BPS = 200;
1906
+ var MILLIS2 = 1e3;
1907
+ function coinbase(baseUrl = "https://api.coinbase.com") {
1908
+ return async (currency) => {
1909
+ const asked = currency.toUpperCase();
1910
+ const answer = await asJson(`${baseUrl}/v2/prices/BTC-${asked}/spot`, "coinbase");
1911
+ const quoted = answer.data;
1912
+ if (quoted?.currency !== asked) {
1913
+ throw new Error(`coinbase quoted ${quoted?.currency} when asked for ${asked}`);
1914
+ }
1915
+ return asMinor(quoted.amount, "coinbase", asked);
1916
+ };
1917
+ }
1918
+ function kraken(baseUrl = "https://api.kraken.com") {
1919
+ return async (currency) => {
1920
+ const asked = currency.toUpperCase();
1921
+ const answer = await asJson(`${baseUrl}/0/public/Ticker?pair=XBT${asked}`, "kraken");
1922
+ if (answer.error?.length) throw new Error(`kraken refused XBT${asked}: ${answer.error[0]}`);
1923
+ const pairs = Object.values(answer.result ?? {});
1924
+ if (pairs.length !== 1) {
1925
+ throw new Error(`kraken answered ${pairs.length} pairs for XBT${asked}`);
1926
+ }
1927
+ return asMinor(pairs[0]?.c?.[0], "kraken", asked);
1928
+ };
1929
+ }
1930
+ function bitstamp(baseUrl = "https://www.bitstamp.net") {
1931
+ return async (currency) => {
1932
+ const pair = `btc${currency.toLowerCase()}`;
1933
+ const answer = await asJson(`${baseUrl}/api/v2/ticker/${pair}/`, "bitstamp");
1934
+ if (Array.isArray(answer)) throw new Error(`bitstamp has no ${pair} pair`);
1935
+ return asMinor(answer.last, "bitstamp", currency);
1936
+ };
1937
+ }
1938
+ function coinmate(baseUrl = "https://coinmate.io") {
1939
+ return async (currency) => {
1940
+ const pair = `BTC_${currency.toUpperCase()}`;
1941
+ const answer = await asJson(`${baseUrl}/api/ticker?currencyPair=${pair}`, "coinmate");
1942
+ if (answer.error !== false) {
1943
+ throw new Error(`coinmate refused ${pair}: ${answer.errorMessage ?? "no reason"}`);
1944
+ }
1945
+ return asMinor(answer.data?.last, "coinmate", currency);
1946
+ };
1947
+ }
1948
+ function medianOf(tickers = [coinbase(), kraken(), bitstamp(), coinmate()], options = {}) {
1949
+ const holdFor = (options.holdForSecs ?? DEFAULT_HOLD_SECS) * MILLIS2;
1950
+ const minVenues = options.minVenues ?? DEFAULT_MIN_VENUES;
1951
+ const maxSpread = options.maxSpreadBps ?? DEFAULT_MAX_SPREAD_BPS;
1952
+ const held = /* @__PURE__ */ new Map();
1953
+ return async (currency) => {
1954
+ const asked = currency.toUpperCase();
1955
+ const remembered = held.get(asked);
1956
+ if (remembered && Date.now() - remembered.at < holdFor) return remembered.price;
1957
+ const answers = await Promise.allSettled(tickers.map((ticker) => ticker(asked)));
1958
+ const quoted = answers.filter((answer) => answer.status === "fulfilled").map((answer) => answer.value).sort((one, other) => one - other);
1959
+ if (quoted.length < minVenues) {
1960
+ throw new Error(
1961
+ `${quoted.length} of ${tickers.length} venues quoted ${asked}, ${minVenues} wanted: ${refusals2(answers)}`
1962
+ );
1963
+ }
1964
+ const spread = spreadBpsOf(quoted);
1965
+ if (spread > maxSpread) {
1966
+ throw new Error(
1967
+ `venues disagree on ${asked} by ${Math.round(spread)} bps, more than the ${maxSpread} allowed: ${quoted.join(", ")}`
1968
+ );
1969
+ }
1970
+ const price = middleOf(quoted);
1971
+ held.set(asked, { at: Date.now(), price });
1972
+ return price;
1973
+ };
1974
+ }
1975
+ function msatFor(amountMinor, priceMinorPerBtc, options = {}) {
1976
+ if (!Number.isInteger(amountMinor) || amountMinor <= 0) {
1977
+ throw new Error("amountMinor must be a whole number of minor units above zero");
1978
+ }
1979
+ if (!Number.isFinite(priceMinorPerBtc) || priceMinorPerBtc <= 0) {
1980
+ throw new Error(`${priceMinorPerBtc} is not a usable price`);
1981
+ }
1982
+ const spreadBps = options.spreadBps ?? 0;
1983
+ if (!Number.isInteger(spreadBps) || spreadBps < 0) {
1984
+ throw new Error("spreadBps must be a whole number of basis points, none or more");
1985
+ }
1986
+ const wanted = BigInt(amountMinor) * BigInt(MSAT_PER_BTC2) * BigInt(BASIS_POINTS + spreadBps);
1987
+ const per = BigInt(Math.round(priceMinorPerBtc)) * BigInt(BASIS_POINTS);
1988
+ const msat = (wanted + per - 1n) / per;
1989
+ if (msat > BigInt(Number.MAX_SAFE_INTEGER)) {
1990
+ throw new Error(`${amountMinor} at ${priceMinorPerBtc} is more millisatoshi than exist`);
1991
+ }
1992
+ return Number(msat);
1993
+ }
1994
+ async function asJson(url, venue) {
1995
+ const answer = await fetch(url, { headers: { accept: "application/json" } });
1996
+ if (!answer.ok) throw new Error(`${venue} answered ${answer.status}`);
1997
+ return answer.json();
1998
+ }
1999
+ function asMinor(quoted, venue, currency) {
2000
+ const major2 = Number(quoted);
2001
+ if (!Number.isFinite(major2) || major2 <= 0) {
2002
+ throw new Error(`${venue} quoted ${String(quoted)} for ${currency.toUpperCase()}`);
2003
+ }
2004
+ return Math.round(major2 * minorScaleOf(currency));
2005
+ }
2006
+ function spreadBpsOf(sorted) {
2007
+ const lowest = sorted[0] ?? 0;
2008
+ const highest = sorted[sorted.length - 1] ?? 0;
2009
+ return lowest === 0 ? Number.POSITIVE_INFINITY : (highest - lowest) / lowest * BASIS_POINTS;
2010
+ }
2011
+ function middleOf(sorted) {
2012
+ const middle = Math.floor(sorted.length / 2);
2013
+ if (sorted.length % 2 === 1) return sorted[middle] ?? 0;
2014
+ return Math.round(((sorted[middle - 1] ?? 0) + (sorted[middle] ?? 0)) / 2);
2015
+ }
2016
+ function refusals2(answers) {
2017
+ return answers.filter((answer) => answer.status === "rejected").map(
2018
+ (answer) => answer.reason instanceof Error ? answer.reason.message : String(answer.reason)
2019
+ ).join("; ");
2020
+ }
2021
+
1350
2022
  // src/webhook.ts
1351
2023
  var SIGNATURE_HEADER = "x-signature";
1352
2024
  var TIMESTAMP_HEADER = "x-timestamp";
@@ -1359,9 +2031,9 @@ async function verifyWebhookSignature(body, signature, secret, timestamp, option
1359
2031
  }
1360
2032
  async function parseWebhook(body, signature, secret, timestamp, options = {}) {
1361
2033
  if (!await verifyWebhookSignature(body, signature, secret, timestamp, options)) return null;
1362
- const text2 = typeof body === "string" ? body : new TextDecoder().decode(body);
2034
+ const text3 = typeof body === "string" ? body : new TextDecoder().decode(body);
1363
2035
  try {
1364
- return paymentFromWire(JSON.parse(text2));
2036
+ return paymentFromWire(JSON.parse(text3));
1365
2037
  } catch {
1366
2038
  return null;
1367
2039
  }
@@ -1400,19 +2072,38 @@ export {
1400
2072
  REQUEST_IN_FLIGHT,
1401
2073
  ThunderBridge,
1402
2074
  UnverifiedRecipientError,
2075
+ bankRail,
2076
+ bankTransfer,
2077
+ bankVerifyEndpoint,
2078
+ bitstamp,
2079
+ blindLightningRail,
2080
+ coinbase,
2081
+ coinmate,
1403
2082
  decodeInvoice,
2083
+ fioStatement,
1404
2084
  invoiceToDataUrl,
1405
2085
  invoiceToSvg,
2086
+ isProblemType,
1406
2087
  isProvablyPaid,
2088
+ kraken,
2089
+ lightningRail,
1407
2090
  lnurlPayEndpoint,
1408
2091
  lnurlToDataUrl,
1409
2092
  lnurlToSvg,
2093
+ medianOf,
2094
+ minorScaleOf,
2095
+ minorUnitsOf,
2096
+ msatFor,
1410
2097
  parseWebhook,
1411
2098
  parseWebhookRequest,
1412
2099
  preimageMatchesHash,
1413
2100
  proveOrigin,
1414
2101
  proveSettlement,
2102
+ qrToDataUrl,
2103
+ qrToSvg,
1415
2104
  seal,
2105
+ spdToDataUrl,
2106
+ spdToSvg,
1416
2107
  toLnurl,
1417
2108
  unseal,
1418
2109
  verifyWebhookSignature