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.cjs CHANGED
@@ -30,19 +30,38 @@ __export(index_exports, {
30
30
  REQUEST_IN_FLIGHT: () => REQUEST_IN_FLIGHT,
31
31
  ThunderBridge: () => ThunderBridge,
32
32
  UnverifiedRecipientError: () => UnverifiedRecipientError,
33
+ bankRail: () => bankRail,
34
+ bankTransfer: () => bankTransfer,
35
+ bankVerifyEndpoint: () => bankVerifyEndpoint,
36
+ bitstamp: () => bitstamp,
37
+ blindLightningRail: () => blindLightningRail,
38
+ coinbase: () => coinbase,
39
+ coinmate: () => coinmate,
33
40
  decodeInvoice: () => decodeInvoice,
41
+ fioStatement: () => fioStatement,
34
42
  invoiceToDataUrl: () => invoiceToDataUrl,
35
43
  invoiceToSvg: () => invoiceToSvg,
44
+ isProblemType: () => isProblemType,
36
45
  isProvablyPaid: () => isProvablyPaid,
46
+ kraken: () => kraken,
47
+ lightningRail: () => lightningRail,
37
48
  lnurlPayEndpoint: () => lnurlPayEndpoint,
38
49
  lnurlToDataUrl: () => lnurlToDataUrl,
39
50
  lnurlToSvg: () => lnurlToSvg,
51
+ medianOf: () => medianOf,
52
+ minorScaleOf: () => minorScaleOf,
53
+ minorUnitsOf: () => minorUnitsOf,
54
+ msatFor: () => msatFor,
40
55
  parseWebhook: () => parseWebhook,
41
56
  parseWebhookRequest: () => parseWebhookRequest,
42
57
  preimageMatchesHash: () => preimageMatchesHash,
43
58
  proveOrigin: () => proveOrigin,
44
59
  proveSettlement: () => proveSettlement,
60
+ qrToDataUrl: () => qrToDataUrl,
61
+ qrToSvg: () => qrToSvg,
45
62
  seal: () => seal,
63
+ spdToDataUrl: () => spdToDataUrl,
64
+ spdToSvg: () => spdToSvg,
46
65
  toLnurl: () => toLnurl,
47
66
  unseal: () => unseal,
48
67
  verifyWebhookSignature: () => verifyWebhookSignature
@@ -85,10 +104,13 @@ var ProblemError = class extends Error {
85
104
  this.detail = problem.detail ?? null;
86
105
  }
87
106
  };
88
- var NO_WALLET_AVAILABLE = "urn:problem-type:thunder-bridge-direct:no-wallet-available";
89
- var REQUEST_IN_FLIGHT = "urn:problem-type:thunder-bridge-direct:request-in-flight";
90
- var IDEMPOTENCY_KEY_REUSED = "urn:problem-type:thunder-bridge-direct:idempotency-key-reused";
91
- var PAYMENT_ALREADY_WATCHED = "urn:problem-type:thunder-bridge-direct:payment-already-watched";
107
+ function isProblemType(problem, type) {
108
+ return problem.type === type;
109
+ }
110
+ var NO_WALLET_AVAILABLE = "urn:problem-type:thunder-bridge:no-wallet-available";
111
+ var REQUEST_IN_FLIGHT = "urn:problem-type:thunder-bridge:request-in-flight";
112
+ var IDEMPOTENCY_KEY_REUSED = "urn:problem-type:thunder-bridge:idempotency-key-reused";
113
+ var PAYMENT_ALREADY_WATCHED = "urn:problem-type:thunder-bridge:payment-already-watched";
92
114
  var IdempotencyConflictError = class extends ProblemError {
93
115
  conflict;
94
116
  constructor(problem, conflict) {
@@ -119,8 +141,8 @@ function hexToBytes(hex) {
119
141
  }
120
142
  return bytes;
121
143
  }
122
- function isHex(text2) {
123
- return /^[0-9a-f]+$/i.test(text2) && text2.length % 2 === 0;
144
+ function isHex(text3) {
145
+ return /^[0-9a-f]+$/i.test(text3) && text3.length % 2 === 0;
124
146
  }
125
147
 
126
148
  // ../core/sha256.ts
@@ -214,8 +236,8 @@ function sha256(bytes) {
214
236
  }
215
237
  return asBytes(state);
216
238
  }
217
- function sha256Hex(text2) {
218
- return bytesToHex(sha256(new TextEncoder().encode(text2)));
239
+ function sha256Hex(text3) {
240
+ return bytesToHex(sha256(new TextEncoder().encode(text3)));
219
241
  }
220
242
  function withPadding(bytes) {
221
243
  const padded = new Uint8Array(
@@ -394,10 +416,13 @@ function sameOrigin(one, other) {
394
416
  return false;
395
417
  }
396
418
  }
419
+ function publicAddress(literal) {
420
+ return literal.includes(":") ? ipv6IsGlobal(literal) : ipv4IsGlobal(literal);
421
+ }
397
422
  function hostIsPublic(host) {
398
- if (host.startsWith("[")) return ipv6IsGlobal(host.slice(1, -1));
423
+ if (host.startsWith("[")) return publicAddress(host.slice(1, -1));
399
424
  const name = host.replace(/\.$/, "");
400
- if (/^\d+\.\d+\.\d+\.\d+$/.test(name)) return ipv4IsGlobal(name);
425
+ if (/^\d+\.\d+\.\d+\.\d+$/.test(name)) return publicAddress(name);
401
426
  const labels = name.split(".");
402
427
  if (labels.length < 2 || labels.some((label) => label === "")) return false;
403
428
  return !PRIVATE_SUFFIXES.includes(labels[labels.length - 1]);
@@ -540,10 +565,10 @@ function quoteFromWire(body) {
540
565
  const minMsat = msatFrom(wire["min_amount"], 0);
541
566
  const maxMsat = msatFrom(wire["max_amount"], 0);
542
567
  const metadata = text(wire["metadata"]);
543
- const refusals2 = refusalsFrom(wire["refusals"]);
568
+ const refusals3 = refusalsFrom(wire["refusals"]);
544
569
  if (lnAddress === null || amountMsat === null || feeMsat === null) return null;
545
- if (minMsat === null || maxMsat === null || metadata === null || refusals2 === null) return null;
546
- return { lnAddress, amountMsat, feeMsat, minMsat, maxMsat, metadata, refusals: refusals2 };
570
+ if (minMsat === null || maxMsat === null || metadata === null || refusals3 === null) return null;
571
+ return { lnAddress, amountMsat, feeMsat, minMsat, maxMsat, metadata, refusals: refusals3 };
547
572
  }
548
573
  function paymentFromWire(body) {
549
574
  const wire = asObject(body);
@@ -638,15 +663,15 @@ function msatFrom(value, least) {
638
663
  }
639
664
  function refusalsFrom(value) {
640
665
  if (!Array.isArray(value)) return null;
641
- const refusals2 = [];
666
+ const refusals3 = [];
642
667
  for (const entry of value) {
643
668
  const failure = asObject(entry);
644
669
  const address = failure === null ? null : text(failure["address"]);
645
670
  const reason = failure?.["reason"];
646
671
  if (address === null || typeof reason !== "string" || !REASONS.has(reason)) return null;
647
- refusals2.push({ address, reason });
672
+ refusals3.push({ address, reason });
648
673
  }
649
- return refusals2;
674
+ return refusals3;
650
675
  }
651
676
  function secondsFrom(value) {
652
677
  if (typeof value !== "string") return null;
@@ -662,6 +687,16 @@ var TERMINAL = /* @__PURE__ */ new Set(["paid", "expired"]);
662
687
  var RECONNECT_DELAY_MS = 3e3;
663
688
  var RECONNECT_CAP_MS = 3e4;
664
689
  var UNANSWERED_ATTEMPTS = 5;
690
+ var TRIGGER_MIN_CHARS = 16;
691
+ var STRANGER_PROBE = "is-this-gateway-yours";
692
+ function unguessable(trigger) {
693
+ if (trigger.length < TRIGGER_MIN_CHARS) {
694
+ throw new Error(
695
+ `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`
696
+ );
697
+ }
698
+ return trigger;
699
+ }
665
700
  function backoffMs(firstDelay, attempt) {
666
701
  const grown = Math.min(firstDelay * 2 ** (attempt - 1), RECONNECT_CAP_MS);
667
702
  return Math.round(grown * (0.5 + Math.random() / 2));
@@ -670,11 +705,35 @@ var ThunderBridge = class {
670
705
  baseUrl;
671
706
  verify;
672
707
  token;
708
+ strangers = null;
673
709
  constructor(baseUrl, options) {
674
710
  this.baseUrl = baseUrl.replace(/\/+$/, "");
675
711
  this.verify = options?.verify ?? true;
676
712
  this.token = options?.token ?? null;
677
713
  }
714
+ /**
715
+ * Whether a token was given, which is what makes an instance yours: a gateway
716
+ * started with `GATEWAY_TOKEN` answers nobody else, so anything you hand it
717
+ * stays between you and it
718
+ */
719
+ get isPrivate() {
720
+ return this.token !== null;
721
+ }
722
+ /**
723
+ * Whether the gateway turns away a caller carrying no token, asked by making
724
+ * one unauthenticated read it would have to refuse. `isPrivate` answers only
725
+ * whether you configured a token, which is your side of the arrangement and
726
+ * says nothing about the gateway's, so a made-up token against a public
727
+ * instance reads as private and is not. Asked once and remembered, because an
728
+ * instance does not change its mind. Anything other than a refusal counts as
729
+ * open, so an unreachable gateway fails closed
730
+ */
731
+ async refusesStrangers() {
732
+ this.strangers ??= fetch(`${this.baseUrl}/incoming-payments/${STRANGER_PROBE}`, {
733
+ headers: { accept: "application/json" }
734
+ }).then((answer) => answer.status === 401).catch(() => false);
735
+ return await this.strangers;
736
+ }
678
737
  /**
679
738
  * Ask the gateway for an invoice payable to the first address on your list
680
739
  * that can issue a provable one, throws `NoWalletAvailableError` when none can
@@ -686,7 +745,10 @@ var ThunderBridge = class {
686
745
  const response = await fetch(`${this.baseUrl}/incoming-payments`, {
687
746
  method: "POST",
688
747
  headers,
689
- body: createRequestBody(params, options?.trigger ? sha256Hex(options.trigger) : null)
748
+ body: createRequestBody(
749
+ params,
750
+ options?.trigger ? sha256Hex(unguessable(options.trigger)) : null
751
+ )
690
752
  });
691
753
  if (!response.ok) throw await problemFrom(response);
692
754
  const payment = await paymentFrom(response);
@@ -725,6 +787,26 @@ var ThunderBridge = class {
725
787
  if (!response.ok) throw await problemFrom(response);
726
788
  return this.checked(await paymentFrom(response));
727
789
  }
790
+ /**
791
+ * Read back a payment the gateway is only watching, null when it has never
792
+ * heard of it. A watched payment carries no address, amount or invoice, so
793
+ * `getPayment` refuses it and this reads the shape both rails share
794
+ */
795
+ async getWatched(id) {
796
+ const response = await fetch(`${this.baseUrl}/incoming-payments/${encodeURIComponent(id)}`, {
797
+ headers: this.reading()
798
+ });
799
+ if (response.status === 404) return null;
800
+ if (!response.ok) throw await problemFrom(response);
801
+ const watched = triggerEventFromWire(await response.json().catch(() => null));
802
+ if (watched === null) {
803
+ throw new ProblemError({
804
+ status: response.status,
805
+ title: "The gateway answered with something that is not a payment"
806
+ });
807
+ }
808
+ return this.proven(watched);
809
+ }
728
810
  /**
729
811
  * List what this gateway is watching, newest first. Only a gateway started
730
812
  * with `GATEWAY_TOKEN` serves this, because on a shared one it would hand
@@ -756,7 +838,36 @@ var ThunderBridge = class {
756
838
  * one that has answered is followed until its own expiry, so the wait always
757
839
  * ends by itself
758
840
  */
759
- waitForPayment(id, options) {
841
+ async waitForPayment(id, options) {
842
+ const payment = paymentFromWire(await this.followed(id, options));
843
+ if (payment === null) {
844
+ throw new ProblemError({
845
+ status: 200,
846
+ title: `payment ${id} is watched rather than minted here, read it with waitForWatched`
847
+ });
848
+ }
849
+ return this.checked(payment);
850
+ }
851
+ /**
852
+ * Follow a payment the gateway is only watching, one it did not mint, until it
853
+ * is paid or expired.
854
+ *
855
+ * A watched payment carries no address, no amount and no invoice, because the
856
+ * gateway was told none of them, so it reads back as the shape a trigger
857
+ * streams rather than as a `Payment`. That is every bank transfer, and every
858
+ * Lightning invoice registered with `watchPayment` instead of `createPayment`.
859
+ */
860
+ async waitForWatched(id, options) {
861
+ const watched = triggerEventFromWire(await this.followed(id, options));
862
+ if (watched === null) {
863
+ throw new ProblemError({
864
+ status: 200,
865
+ title: "The gateway answered with something that is not a payment"
866
+ });
867
+ }
868
+ return this.proven(watched);
869
+ }
870
+ followed(id, options) {
760
871
  const base = this.baseUrl.replace(/^http/, "ws");
761
872
  const direct = `${base}/ws/incoming-payments/${encodeURIComponent(id)}`;
762
873
  return new Promise((resolve2, reject) => {
@@ -809,13 +920,13 @@ var ThunderBridge = class {
809
920
  socket = opened;
810
921
  opened.onmessage = (event) => {
811
922
  try {
812
- const payment = paymentFromWire(JSON.parse(String(event.data)));
813
- if (payment === null) return;
814
- expiresAt = payment.expiresAt;
923
+ const frame = JSON.parse(String(event.data));
924
+ const watched = triggerEventFromWire(frame);
925
+ if (watched === null) return;
926
+ expiresAt = watched.expiresAt;
815
927
  attempt = 1;
816
- if (!TERMINAL.has(payment.status)) return;
817
- const checked = this.checked(payment);
818
- settle(() => resolve2(checked));
928
+ if (!TERMINAL.has(watched.status)) return;
929
+ settle(() => resolve2(frame));
819
930
  } catch (refused) {
820
931
  settle(() => reject(refused));
821
932
  }
@@ -826,6 +937,49 @@ var ThunderBridge = class {
826
937
  void connect();
827
938
  });
828
939
  }
940
+ /**
941
+ * Wait on several payments and keep the first one that is really paid, then stop
942
+ * waiting on the losers, which closes their sockets.
943
+ *
944
+ * This is how one order offers two rails. A Lightning invoice and a bank
945
+ * transfer for the same thing are two payments here, and the payer picks one, so
946
+ * what you want is the one that arrives and nothing further from the other.
947
+ *
948
+ * A leg that expires is a loser, not a winner, which is the whole reason this is
949
+ * not a race: `waitForPayment` ends on `paid` and on `expired` alike, and a
950
+ * Lightning invoice expires in an hour while a bank transfer takes days. `null`
951
+ * means every leg ended without being paid.
952
+ *
953
+ * Stopping the wait is not revoking the invoice. Nobody can revoke one, because
954
+ * the recipient's own wallet minted it, so a payer who pays the loser afterwards
955
+ * really does pay twice and that shows up on `followTrigger` as a second
956
+ * settlement to refund.
957
+ */
958
+ async firstToSettle(ids, options) {
959
+ if (ids.length === 0) return null;
960
+ const stopLosers = new AbortController();
961
+ const signal = options?.signal ? AbortSignal.any([stopLosers.signal, options.signal]) : stopLosers.signal;
962
+ let refused = null;
963
+ try {
964
+ const winner = await new Promise((resolve2) => {
965
+ let waiting = ids.length;
966
+ const lost = () => {
967
+ waiting -= 1;
968
+ if (waiting === 0) resolve2(null);
969
+ };
970
+ for (const id of ids) {
971
+ this.waitForWatched(id, { ...options, signal }).then((watched) => watched.status === "paid" ? resolve2(watched) : lost()).catch((failure) => {
972
+ refused ??= failure;
973
+ lost();
974
+ });
975
+ }
976
+ });
977
+ if (winner === null && refused !== null) throw refused;
978
+ return winner;
979
+ } finally {
980
+ stopLosers.abort();
981
+ }
982
+ }
829
983
  /**
830
984
  * Hand over an invoice you obtained yourself so the gateway watches it without
831
985
  * being told the address or the amount. It can then only refuse everyone
@@ -836,7 +990,10 @@ var ThunderBridge = class {
836
990
  const response = await fetch(`${this.baseUrl}/watched-payments`, {
837
991
  method: "POST",
838
992
  headers: this.sending(),
839
- body: watchRequestBody(params, params.trigger ? sha256Hex(params.trigger) : null)
993
+ body: watchRequestBody(
994
+ params,
995
+ params.trigger ? sha256Hex(unguessable(params.trigger)) : null
996
+ )
840
997
  });
841
998
  if (!response.ok) throw await problemFrom(response);
842
999
  const watched = triggerEventFromWire(await response.json().catch(() => null));
@@ -855,7 +1012,7 @@ var ThunderBridge = class {
855
1012
  */
856
1013
  followTrigger(secret, options) {
857
1014
  const base = this.baseUrl.replace(/^http/, "ws");
858
- const direct = `${base}/ws/triggers/${encodeURIComponent(secret)}`;
1015
+ const direct = `${base}/ws/triggers/${encodeURIComponent(unguessable(secret))}`;
859
1016
  const firstDelay = options.reconnectDelayMs ?? RECONNECT_DELAY_MS;
860
1017
  let socket = null;
861
1018
  let retry;
@@ -950,13 +1107,13 @@ async function paymentFrom(response) {
950
1107
  async function problemFrom(response) {
951
1108
  const problem = await response.json().catch(() => ({}));
952
1109
  const document = { ...problem, status: response.status };
953
- if (problem.type === NO_WALLET_AVAILABLE) {
1110
+ if (isProblemType(problem, NO_WALLET_AVAILABLE)) {
954
1111
  return new NoWalletAvailableError(document, refusals(problem.wallets));
955
1112
  }
956
- if (problem.type === REQUEST_IN_FLIGHT) {
1113
+ if (isProblemType(problem, REQUEST_IN_FLIGHT)) {
957
1114
  return new IdempotencyConflictError(document, "request-in-flight");
958
1115
  }
959
- if (problem.type === IDEMPOTENCY_KEY_REUSED) {
1116
+ if (isProblemType(problem, IDEMPOTENCY_KEY_REUSED)) {
960
1117
  return new IdempotencyConflictError(document, "key-reused");
961
1118
  }
962
1119
  return new ProblemError(document);
@@ -973,7 +1130,7 @@ var VERSION = "v1";
973
1130
  var IV_BYTES = 12;
974
1131
  var MIN_SECRET_CHARS = 32;
975
1132
  var MAX_PLAIN_BYTES = 3e3;
976
- var INFO = new TextEncoder().encode("thunder-bridge-direct/sealed");
1133
+ var INFO = new TextEncoder().encode("thunder-bridge/sealed");
977
1134
  async function seal(secret, plaintext) {
978
1135
  const body = new TextEncoder().encode(plaintext);
979
1136
  if (body.length > MAX_PLAIN_BYTES) {
@@ -1027,10 +1184,10 @@ function toBase64Url(bytes) {
1027
1184
  for (const byte of bytes) binary += String.fromCharCode(byte);
1028
1185
  return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
1029
1186
  }
1030
- function fromBase64Url(text2) {
1031
- if (text2.length === 0 || !/^[A-Za-z0-9_-]+$/.test(text2)) return null;
1187
+ function fromBase64Url(text3) {
1188
+ if (text3.length === 0 || !/^[A-Za-z0-9_-]+$/.test(text3)) return null;
1032
1189
  try {
1033
- const binary = atob(text2.replace(/-/g, "+").replace(/_/g, "/"));
1190
+ const binary = atob(text3.replace(/-/g, "+").replace(/_/g, "/"));
1034
1191
  const bytes = new Uint8Array(binary.length);
1035
1192
  for (let at = 0; at < bytes.length; at++) bytes[at] = binary.charCodeAt(at);
1036
1193
  return bytes;
@@ -1062,6 +1219,87 @@ function asBuffer(bytes) {
1062
1219
  return bytes instanceof Uint8Array && bytes.buffer instanceof ArrayBuffer ? bytes : new Uint8Array(bytes);
1063
1220
  }
1064
1221
 
1222
+ // ../core/outbound.ts
1223
+ var import_promises = require("dns/promises");
1224
+ var import_promises2 = require("timers/promises");
1225
+ var HTTP_TIMEOUT_MS2 = 15e3;
1226
+ var LOOKUP_TIMEOUT_MS = 5e3;
1227
+ var HOPS_FOLLOWED = 2;
1228
+ var REDIRECTS = [301, 302, 303, 307, 308];
1229
+ var KEEPS_THE_METHOD = [307, 308];
1230
+ var CREDENTIALS = ["authorization", "cookie", "proxy-authorization"];
1231
+ var BODY_LIMIT_BYTES = 262144;
1232
+ async function ask(url, sent = {}) {
1233
+ const capped = AbortSignal.timeout(HTTP_TIMEOUT_MS2);
1234
+ const signal = sent.deadline ? AbortSignal.any([sent.deadline, capped]) : capped;
1235
+ let target = url;
1236
+ let carried = sent;
1237
+ for (let hop = 0; hop <= HOPS_FOLLOWED; hop += 1) {
1238
+ await refuseUnlessPublic(target);
1239
+ const response = await fetch(target, {
1240
+ method: carried.method ?? "GET",
1241
+ headers: carried.headers ?? {},
1242
+ body: carried.body,
1243
+ redirect: "manual",
1244
+ signal
1245
+ });
1246
+ if (!REDIRECTS.includes(response.status)) return await answerOf(response);
1247
+ const location = response.headers.get("location");
1248
+ if (location === null) {
1249
+ throw new Error(`${target} answered ${response.status} without saying where to`);
1250
+ }
1251
+ await response.body?.cancel();
1252
+ const next = new URL(location, target).toString();
1253
+ carried = KEEPS_THE_METHOD.includes(response.status) ? carried : withoutBody(carried);
1254
+ carried = sameOrigin(target, next) ? carried : withoutCredentials(carried);
1255
+ target = next;
1256
+ }
1257
+ throw new Error(`${url} redirected more than ${HOPS_FOLLOWED} times`);
1258
+ }
1259
+ async function resolvesNothingPrivate(url) {
1260
+ const host = new URL(url).hostname.replace(/^\[|]$/g, "");
1261
+ const found = await Promise.race([
1262
+ (0, import_promises.lookup)(host, { all: true, verbatim: true }).catch(() => []),
1263
+ (0, import_promises2.setTimeout)(LOOKUP_TIMEOUT_MS, null, { ref: false })
1264
+ ]);
1265
+ return found !== null && found.every((one) => publicAddress(one.address));
1266
+ }
1267
+ async function refuseUnlessPublic(url) {
1268
+ if (!publicHttps(url)) throw new Error(`${url} is not a public https URL`);
1269
+ if (!await resolvesNothingPrivate(url)) throw new Error(`${url} resolves to an address we do not reach`);
1270
+ }
1271
+ async function answerOf(response) {
1272
+ const { status, ok } = response;
1273
+ const reader = response.body?.getReader();
1274
+ if (!reader) return { status, ok, body: "", truncated: false };
1275
+ const read = [];
1276
+ let bytes = 0;
1277
+ while (bytes <= BODY_LIMIT_BYTES) {
1278
+ const { done, value } = await reader.read();
1279
+ if (done) return { status, ok, body: text2(read), truncated: false };
1280
+ read.push(value);
1281
+ bytes += value.length;
1282
+ }
1283
+ await reader.cancel();
1284
+ return { status, ok, body: text2(read), truncated: true };
1285
+ }
1286
+ function withoutBody(sent) {
1287
+ const headers = { ...sent.headers };
1288
+ delete headers["content-type"];
1289
+ return { ...sent, method: "GET", headers, body: void 0 };
1290
+ }
1291
+ function withoutCredentials(sent) {
1292
+ const headers = { ...sent.headers };
1293
+ for (const named of CREDENTIALS) delete headers[named];
1294
+ return { ...sent, headers };
1295
+ }
1296
+ function text2(chunks) {
1297
+ const decoder = new TextDecoder();
1298
+ let decoded = "";
1299
+ for (const chunk of chunks) decoded += decoder.decode(chunk, { stream: true });
1300
+ return decoded + decoder.decode();
1301
+ }
1302
+
1065
1303
  // ../core/refusal.ts
1066
1304
  var WalletRefused = class extends Error {
1067
1305
  reason;
@@ -1080,7 +1318,6 @@ var NoWalletAvailable = class extends Error {
1080
1318
 
1081
1319
  // ../core/lnurl.ts
1082
1320
  var VERIFY_WITHOUT_PREIMAGE = ["zeuspay.com", "zeusnuts.com", "ecash.love"];
1083
- var HTTP_TIMEOUT_MS2 = 15e3;
1084
1321
  var BECH32_CHARSET2 = "qpzry9x8gf2tvdw0s3jn54khce6mua7l";
1085
1322
  var BECH32_GENERATOR = [996825010, 642813549, 513874426, 1027748829, 705979059];
1086
1323
  var CHECKSUM_SLOTS = [0, 1, 2, 3, 4, 5];
@@ -1095,18 +1332,18 @@ async function resolve(addresses, amountMsat) {
1095
1332
  }
1096
1333
  async function firstThatServes(addresses, attempt) {
1097
1334
  const deadline = AbortSignal.timeout(RESOLVE_TIMEOUT_MS);
1098
- const refusals2 = [];
1335
+ const refusals3 = [];
1099
1336
  for (const address of addresses) {
1100
1337
  if (deadline.aborted) break;
1101
1338
  try {
1102
- return { won: await attempt(address, deadline), refusals: refusals2 };
1339
+ return { won: await attempt(address, deadline), refusals: refusals3 };
1103
1340
  } catch (refusal) {
1104
1341
  if (!(refusal instanceof WalletRefused)) throw refusal;
1105
1342
  console.warn(`wallet ${address} refused, ${refusal.reason}: ${refusal.message}`);
1106
- refusals2.push({ address, reason: refusal.reason });
1343
+ refusals3.push({ address, reason: refusal.reason });
1107
1344
  }
1108
1345
  }
1109
- throw new NoWalletAvailable(refusals2);
1346
+ throw new NoWalletAvailable(refusals3);
1110
1347
  }
1111
1348
  async function probe(address, amountMsat, deadline) {
1112
1349
  const [user, domain] = splitAddress(address);
@@ -1245,14 +1482,12 @@ function splitAddress(address) {
1245
1482
  return [user, domain];
1246
1483
  }
1247
1484
  async function fetchJson(url, deadline) {
1248
- if (!publicHttps(url)) throw new Error(`${url} is not a public https URL`);
1249
- const capped = AbortSignal.timeout(HTTP_TIMEOUT_MS2);
1250
- const response = await fetch(url, {
1251
- signal: deadline ? AbortSignal.any([deadline, capped]) : capped,
1252
- headers: { accept: "application/json" }
1253
- });
1254
- if (!response.ok) throw new Error(`${url} answered ${response.status}`);
1255
- return await response.json();
1485
+ const answer = await ask(url, { headers: { accept: "application/json" }, deadline });
1486
+ if (!answer.ok) throw new Error(`${url} answered ${answer.status}`);
1487
+ if (answer.truncated) {
1488
+ throw new Error(`${url} answered with more than the ${BODY_LIMIT_BYTES} bytes we read`);
1489
+ }
1490
+ return JSON.parse(answer.body);
1256
1491
  }
1257
1492
 
1258
1493
  // src/trigger.ts
@@ -1331,7 +1566,7 @@ async function mintBlind(config, address, amountMsat) {
1331
1566
  return { bolt11: resolved.bolt11, verifyUrl: resolved.verifyUrl };
1332
1567
  }
1333
1568
  function alreadyWatched(refused) {
1334
- return refused instanceof ProblemError && refused.type === PAYMENT_ALREADY_WATCHED;
1569
+ return refused instanceof ProblemError && isProblemType(refused, PAYMENT_ALREADY_WATCHED);
1335
1570
  }
1336
1571
  function sign(secret, address, amountMsat, nonce) {
1337
1572
  return hmacHex(secret, `${address}|${amountMsat}|${nonce}`);
@@ -1346,6 +1581,249 @@ function refuse(reason) {
1346
1581
  return Response.json({ status: "ERROR", reason });
1347
1582
  }
1348
1583
 
1584
+ // src/currency.ts
1585
+ var ISO_4217_MINOR_UNITS = {
1586
+ BHD: 3,
1587
+ BIF: 0,
1588
+ CLP: 0,
1589
+ CZK: 2,
1590
+ DJF: 0,
1591
+ EUR: 2,
1592
+ GBP: 2,
1593
+ ISK: 0,
1594
+ IQD: 3,
1595
+ JOD: 3,
1596
+ JPY: 0,
1597
+ KRW: 0,
1598
+ KWD: 3,
1599
+ LYD: 3,
1600
+ OMR: 3,
1601
+ PLN: 2,
1602
+ PYG: 0,
1603
+ RWF: 0,
1604
+ TND: 3,
1605
+ UGX: 0,
1606
+ USD: 2,
1607
+ VND: 0,
1608
+ VUV: 0,
1609
+ XAF: 0,
1610
+ XOF: 0,
1611
+ XPF: 0
1612
+ };
1613
+ function minorUnitsOf(currency) {
1614
+ const digits = ISO_4217_MINOR_UNITS[currency.toUpperCase()];
1615
+ if (digits === void 0) {
1616
+ throw new Error(
1617
+ `${currency.toUpperCase()} is not a currency this knows the ISO 4217 minor unit of`
1618
+ );
1619
+ }
1620
+ return digits;
1621
+ }
1622
+ function minorScaleOf(currency) {
1623
+ return 10 ** minorUnitsOf(currency);
1624
+ }
1625
+
1626
+ // src/bank.ts
1627
+ var DEFAULT_CURRENCY = "CZK";
1628
+ var DEFAULT_LOOK_BACK_SECS = 7 * 24 * 60 * 60;
1629
+ var IBAN = /^[A-Z]{2}[0-9]{2}[0-9A-Z]{8,30}$/;
1630
+ var FORBIDDEN_IN_SPD = "*";
1631
+ async function bankTransfer(params) {
1632
+ const currency = params.currency ?? DEFAULT_CURRENCY;
1633
+ refuseUnusable(params, currency);
1634
+ await refuseAnOpenGateway(params);
1635
+ const spd = shortPaymentDescriptor(params, currency);
1636
+ const subject = subjectOf(params.reference, params.amountMinor, currency);
1637
+ const polling = new URL(params.verifyUrl);
1638
+ polling.searchParams.set("ref", params.reference);
1639
+ polling.searchParams.set("minor", String(params.amountMinor));
1640
+ polling.searchParams.set("cc", currency);
1641
+ polling.searchParams.set("sig", await hmacHex(params.secret, `verify|${subject}`));
1642
+ const verifyUrl = polling.toString();
1643
+ const watched = await params.gateway.watchPayment({
1644
+ paymentHash: hashOf(await hmacHex(params.secret, `preimage|${subject}`)),
1645
+ verifyUrl,
1646
+ expiresAt: params.expiresAt,
1647
+ trigger: params.trigger,
1648
+ sealed: params.sealed
1649
+ });
1650
+ return { id: watched.id, paymentHash: watched.paymentHash, verifyUrl, spd };
1651
+ }
1652
+ function bankVerifyEndpoint(config) {
1653
+ return async (request) => {
1654
+ const asked = readQuery(new URL(request.url));
1655
+ if (asked === null) return Response.json({ settled: false }, { status: 400 });
1656
+ const subject = subjectOf(asked.reference, asked.amountMinor, asked.currency);
1657
+ const expected = await hmacHex(config.secret, `verify|${subject}`);
1658
+ if (!equalInConstantTime(asked.signature, expected)) {
1659
+ return Response.json({ settled: false }, { status: 403 });
1660
+ }
1661
+ const since = unixNow() - (config.lookBackSecs ?? DEFAULT_LOOK_BACK_SECS);
1662
+ const landed = (await config.statement(since)).some((credit) => pays(credit, asked));
1663
+ if (!landed) return Response.json({ settled: false });
1664
+ return Response.json({
1665
+ settled: true,
1666
+ preimage: await hmacHex(config.secret, `preimage|${subject}`)
1667
+ });
1668
+ };
1669
+ }
1670
+ function readQuery(url) {
1671
+ const reference = url.searchParams.get("ref");
1672
+ const minor = Number(url.searchParams.get("minor"));
1673
+ const currency = url.searchParams.get("cc");
1674
+ const signature = url.searchParams.get("sig");
1675
+ if (!reference || !currency || !signature) return null;
1676
+ if (!Number.isInteger(minor) || minor <= 0) return null;
1677
+ return { reference, amountMinor: minor, currency, signature };
1678
+ }
1679
+ function pays(credit, asked) {
1680
+ return credit.amountMinor === asked.amountMinor && credit.currency.toUpperCase() === asked.currency.toUpperCase() && credit.reference.toUpperCase().includes(asked.reference.toUpperCase());
1681
+ }
1682
+ function subjectOf(reference, amountMinor, currency) {
1683
+ return `${reference}|${amountMinor}|${currency.toUpperCase()}`;
1684
+ }
1685
+ function hashOf(preimage) {
1686
+ return bytesToHex(sha256(hexToBytes(preimage)));
1687
+ }
1688
+ function shortPaymentDescriptor(params, currency) {
1689
+ const fields = [
1690
+ `ACC:${params.iban}`,
1691
+ `AM:${major(params.amountMinor, currency)}`,
1692
+ `CC:${currency.toUpperCase()}`,
1693
+ `MSG:${params.reference}`
1694
+ ];
1695
+ if (params.variableSymbol) fields.push(`X-VS:${params.variableSymbol}`);
1696
+ return `SPD*1.0*${fields.join("*")}`;
1697
+ }
1698
+ function major(amountMinor, currency) {
1699
+ return (amountMinor / minorScaleOf(currency)).toFixed(minorUnitsOf(currency));
1700
+ }
1701
+ async function refuseAnOpenGateway(params) {
1702
+ if (params.allowPublicGateway === true) return;
1703
+ if (await params.gateway.refusesStrangers()) return;
1704
+ throw new Error(
1705
+ "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"
1706
+ );
1707
+ }
1708
+ function refuseUnusable(params, currency) {
1709
+ if (!IBAN.test(params.iban)) throw new Error(`${params.iban} is not an IBAN`);
1710
+ if (!Number.isInteger(params.amountMinor) || params.amountMinor <= 0) {
1711
+ throw new Error("amountMinor must be a whole number of minor units above zero");
1712
+ }
1713
+ if (params.reference.length === 0) {
1714
+ throw new Error("a transfer with no reference cannot be found");
1715
+ }
1716
+ if (params.reference.includes(FORBIDDEN_IN_SPD)) {
1717
+ throw new Error("a reference cannot contain an asterisk, it separates the QR fields");
1718
+ }
1719
+ if (params.variableSymbol && !/^[0-9]{1,10}$/.test(params.variableSymbol)) {
1720
+ throw new Error("a variable symbol is up to ten digits");
1721
+ }
1722
+ }
1723
+ function unixNow() {
1724
+ return Math.floor(Date.now() / 1e3);
1725
+ }
1726
+
1727
+ // src/fio.ts
1728
+ var BASE_URL = "https://fioapi.fio.cz/v1/rest";
1729
+ var MINOR_UNITS = 100;
1730
+ var MILLIS = 1e3;
1731
+ var DATE_CHARS = "2026-08-04".length;
1732
+ var DEFAULT_MIN_INTERVAL_SECS = 30;
1733
+ var TOO_SOON = 409;
1734
+ var BOOKED_AT = "column0";
1735
+ var AMOUNT = "column1";
1736
+ var CURRENCY = "column14";
1737
+ var VARIABLE_SYMBOL = "column5";
1738
+ var USER_IDENTIFICATION = "column7";
1739
+ var MESSAGE_FOR_RECIPIENT = "column16";
1740
+ var PAYER_REFERENCE = "column27";
1741
+ var WHERE_A_PAYER_WRITES = [
1742
+ MESSAGE_FOR_RECIPIENT,
1743
+ VARIABLE_SYMBOL,
1744
+ USER_IDENTIFICATION,
1745
+ PAYER_REFERENCE
1746
+ ];
1747
+ var UTC_OFFSET = /^[+-]\d{4}$/;
1748
+ function fioStatement(config) {
1749
+ const named = typeof config.token === "string" ? [config.token] : config.token;
1750
+ if (named.length === 0) throw new Error("fioStatement needs at least one token to read with");
1751
+ const usedAt = new Map(named.map((token) => [token, Number.NEGATIVE_INFINITY]));
1752
+ const tokenWindowMs = (config.minIntervalSecs ?? DEFAULT_MIN_INTERVAL_SECS) * MILLIS;
1753
+ const paceMs = tokenWindowMs / usedAt.size;
1754
+ let lastRead = Number.NEGATIVE_INFINITY;
1755
+ let credits = [];
1756
+ return async (sinceUnix) => {
1757
+ const now = Date.now();
1758
+ if (now - lastRead < paceMs) return credits;
1759
+ const idlest = longestUnused(usedAt);
1760
+ if (now - idlest.usedAt < tokenWindowMs) return credits;
1761
+ usedAt.set(idlest.token, now);
1762
+ lastRead = now;
1763
+ const url = [
1764
+ config.baseUrl ?? BASE_URL,
1765
+ "periods",
1766
+ idlest.token,
1767
+ asDate(sinceUnix),
1768
+ asDate(Math.floor(now / MILLIS)),
1769
+ "transactions.json"
1770
+ ].join("/");
1771
+ const answer = await fetch(url, { headers: { accept: "application/json" } });
1772
+ if (answer.status === TOO_SOON) {
1773
+ throw new Error("fio refuses a second read of this token inside its 30 second window");
1774
+ }
1775
+ if (!answer.ok) throw new Error(`fio answered ${answer.status} reading the statement`);
1776
+ const read = await answer.json();
1777
+ credits = (read.accountStatement?.transactionList?.transaction ?? []).filter(isCredit).map(asCredit);
1778
+ return credits;
1779
+ };
1780
+ }
1781
+ function longestUnused(usedAt) {
1782
+ let token = "";
1783
+ let idleSince = Number.POSITIVE_INFINITY;
1784
+ for (const [candidate, at] of usedAt) {
1785
+ if (at < idleSince) {
1786
+ token = candidate;
1787
+ idleSince = at;
1788
+ }
1789
+ }
1790
+ return { token, usedAt: idleSince };
1791
+ }
1792
+ function isCredit(transaction) {
1793
+ return numberIn(transaction[AMOUNT]) > 0;
1794
+ }
1795
+ function asCredit(transaction) {
1796
+ return {
1797
+ amountMinor: Math.round(numberIn(transaction[AMOUNT]) * MINOR_UNITS),
1798
+ currency: textIn(transaction[CURRENCY]),
1799
+ reference: WHERE_A_PAYER_WRITES.map((column) => textIn(transaction[column])).filter((written) => written.length > 0).join(" "),
1800
+ bookedAt: bookedAtIn(transaction[BOOKED_AT])
1801
+ };
1802
+ }
1803
+ function bookedAtIn(cell) {
1804
+ const value = cell?.value;
1805
+ if (typeof value === "number") return Math.floor(value / MILLIS);
1806
+ const at = Date.parse(asIso8601(textIn(cell)));
1807
+ return Number.isFinite(at) ? Math.floor(at / MILLIS) : 0;
1808
+ }
1809
+ function asIso8601(booked) {
1810
+ const day = booked.slice(0, DATE_CHARS);
1811
+ const zone = booked.slice(DATE_CHARS);
1812
+ if (!UTC_OFFSET.test(zone)) return `${day}T00:00:00Z`;
1813
+ return `${day}T00:00:00${zone.slice(0, 3)}:${zone.slice(3)}`;
1814
+ }
1815
+ function numberIn(cell) {
1816
+ const value = Number(cell?.value ?? Number.NaN);
1817
+ return Number.isFinite(value) ? value : 0;
1818
+ }
1819
+ function textIn(cell) {
1820
+ const value = cell?.value;
1821
+ return value === null || value === void 0 ? "" : String(value).trim();
1822
+ }
1823
+ function asDate(unix) {
1824
+ return new Date(unix * MILLIS).toISOString().slice(0, DATE_CHARS);
1825
+ }
1826
+
1349
1827
  // src/qr.ts
1350
1828
  var import_uqr = require("uqr");
1351
1829
  var SAFE_COLOR = /^#(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$|^rgba?\([\d.,%/\s]+\)$|^[a-z]+$/i;
@@ -1365,6 +1843,18 @@ function lnurlToSvg(endpoint, options) {
1365
1843
  function lnurlToDataUrl(endpoint, options) {
1366
1844
  return asDataUrl(lnurlToSvg(endpoint, options));
1367
1845
  }
1846
+ function spdToSvg(spd, options) {
1847
+ return svgOf(spd, options);
1848
+ }
1849
+ function spdToDataUrl(spd, options) {
1850
+ return asDataUrl(spdToSvg(spd, options));
1851
+ }
1852
+ function qrToSvg(payload, options) {
1853
+ return svgOf(payload, options);
1854
+ }
1855
+ function qrToDataUrl(payload, options) {
1856
+ return asDataUrl(qrToSvg(payload, options));
1857
+ }
1368
1858
  function svgOf(scanned, options) {
1369
1859
  const size = options?.size ?? 256;
1370
1860
  const color = options?.color ?? "#000";
@@ -1398,6 +1888,207 @@ function renderSvg(content, size, color) {
1398
1888
  ].join("");
1399
1889
  }
1400
1890
 
1891
+ // src/rail.ts
1892
+ var BANK = "bank";
1893
+ var LIGHTNING = "lightning";
1894
+ function bankRail(config) {
1895
+ return async (order) => {
1896
+ const expiresAt = config.expiresAt(order);
1897
+ const transfer = await bankTransfer({
1898
+ gateway: config.gateway,
1899
+ secret: config.secret,
1900
+ iban: config.iban,
1901
+ verifyUrl: config.verifyUrl,
1902
+ reference: order.reference,
1903
+ amountMinor: order.amountMinor,
1904
+ currency: order.currency,
1905
+ expiresAt,
1906
+ trigger: config.trigger,
1907
+ sealed: await config.sealed?.(order),
1908
+ variableSymbol: config.variableSymbol?.(order),
1909
+ allowPublicGateway: config.allowPublicGateway
1910
+ });
1911
+ return {
1912
+ id: transfer.id,
1913
+ rail: config.name ?? BANK,
1914
+ scan: transfer.spd,
1915
+ qr: transfer.spd,
1916
+ expiresAt
1917
+ };
1918
+ };
1919
+ }
1920
+ function lightningRail(config) {
1921
+ return async (order) => {
1922
+ const payment = await config.gateway.createPayment(
1923
+ {
1924
+ lnAddresses: config.lnAddresses,
1925
+ amountMsat: await config.amountMsat(order),
1926
+ webhookUrl: config.webhookUrl,
1927
+ webhookSecret: config.webhookSecret
1928
+ },
1929
+ { idempotencyKey: config.idempotencyKey?.(order), trigger: config.trigger }
1930
+ );
1931
+ return {
1932
+ id: payment.id,
1933
+ rail: config.name ?? LIGHTNING,
1934
+ scan: payment.bolt11,
1935
+ qr: encodeForQr(payment.bolt11),
1936
+ expiresAt: payment.expiresAt
1937
+ };
1938
+ };
1939
+ }
1940
+ function blindLightningRail(config) {
1941
+ return async (order) => {
1942
+ const resolved = await invoiceFrom(config.lnAddresses, await config.amountMsat(order));
1943
+ const watched = await config.gateway.watchPayment({
1944
+ paymentHash: resolved.paymentHash,
1945
+ verifyUrl: resolved.verifyUrl,
1946
+ expiresAt: resolved.expiresAt,
1947
+ trigger: config.trigger,
1948
+ sealed: await config.sealed?.(order)
1949
+ });
1950
+ return {
1951
+ id: watched.id,
1952
+ rail: config.name ?? LIGHTNING,
1953
+ scan: resolved.bolt11,
1954
+ qr: encodeForQr(resolved.bolt11),
1955
+ expiresAt: resolved.expiresAt
1956
+ };
1957
+ };
1958
+ }
1959
+ async function invoiceFrom(lnAddresses, amountMsat) {
1960
+ try {
1961
+ return await resolve(lnAddresses, amountMsat);
1962
+ } catch (refused) {
1963
+ if (refused instanceof NoWalletAvailable) {
1964
+ throw new NoWalletAvailableError({ title: refused.message }, refused.wallets);
1965
+ }
1966
+ throw refused;
1967
+ }
1968
+ }
1969
+
1970
+ // src/price.ts
1971
+ var MSAT_PER_BTC2 = 1e11;
1972
+ var BASIS_POINTS = 1e4;
1973
+ var DEFAULT_HOLD_SECS = 60;
1974
+ var DEFAULT_MIN_VENUES = 2;
1975
+ var DEFAULT_MAX_SPREAD_BPS = 200;
1976
+ var MILLIS2 = 1e3;
1977
+ function coinbase(baseUrl = "https://api.coinbase.com") {
1978
+ return async (currency) => {
1979
+ const asked = currency.toUpperCase();
1980
+ const answer = await asJson(`${baseUrl}/v2/prices/BTC-${asked}/spot`, "coinbase");
1981
+ const quoted = answer.data;
1982
+ if (quoted?.currency !== asked) {
1983
+ throw new Error(`coinbase quoted ${quoted?.currency} when asked for ${asked}`);
1984
+ }
1985
+ return asMinor(quoted.amount, "coinbase", asked);
1986
+ };
1987
+ }
1988
+ function kraken(baseUrl = "https://api.kraken.com") {
1989
+ return async (currency) => {
1990
+ const asked = currency.toUpperCase();
1991
+ const answer = await asJson(`${baseUrl}/0/public/Ticker?pair=XBT${asked}`, "kraken");
1992
+ if (answer.error?.length) throw new Error(`kraken refused XBT${asked}: ${answer.error[0]}`);
1993
+ const pairs = Object.values(answer.result ?? {});
1994
+ if (pairs.length !== 1) {
1995
+ throw new Error(`kraken answered ${pairs.length} pairs for XBT${asked}`);
1996
+ }
1997
+ return asMinor(pairs[0]?.c?.[0], "kraken", asked);
1998
+ };
1999
+ }
2000
+ function bitstamp(baseUrl = "https://www.bitstamp.net") {
2001
+ return async (currency) => {
2002
+ const pair = `btc${currency.toLowerCase()}`;
2003
+ const answer = await asJson(`${baseUrl}/api/v2/ticker/${pair}/`, "bitstamp");
2004
+ if (Array.isArray(answer)) throw new Error(`bitstamp has no ${pair} pair`);
2005
+ return asMinor(answer.last, "bitstamp", currency);
2006
+ };
2007
+ }
2008
+ function coinmate(baseUrl = "https://coinmate.io") {
2009
+ return async (currency) => {
2010
+ const pair = `BTC_${currency.toUpperCase()}`;
2011
+ const answer = await asJson(`${baseUrl}/api/ticker?currencyPair=${pair}`, "coinmate");
2012
+ if (answer.error !== false) {
2013
+ throw new Error(`coinmate refused ${pair}: ${answer.errorMessage ?? "no reason"}`);
2014
+ }
2015
+ return asMinor(answer.data?.last, "coinmate", currency);
2016
+ };
2017
+ }
2018
+ function medianOf(tickers = [coinbase(), kraken(), bitstamp(), coinmate()], options = {}) {
2019
+ const holdFor = (options.holdForSecs ?? DEFAULT_HOLD_SECS) * MILLIS2;
2020
+ const minVenues = options.minVenues ?? DEFAULT_MIN_VENUES;
2021
+ const maxSpread = options.maxSpreadBps ?? DEFAULT_MAX_SPREAD_BPS;
2022
+ const held = /* @__PURE__ */ new Map();
2023
+ return async (currency) => {
2024
+ const asked = currency.toUpperCase();
2025
+ const remembered = held.get(asked);
2026
+ if (remembered && Date.now() - remembered.at < holdFor) return remembered.price;
2027
+ const answers = await Promise.allSettled(tickers.map((ticker) => ticker(asked)));
2028
+ const quoted = answers.filter((answer) => answer.status === "fulfilled").map((answer) => answer.value).sort((one, other) => one - other);
2029
+ if (quoted.length < minVenues) {
2030
+ throw new Error(
2031
+ `${quoted.length} of ${tickers.length} venues quoted ${asked}, ${minVenues} wanted: ${refusals2(answers)}`
2032
+ );
2033
+ }
2034
+ const spread = spreadBpsOf(quoted);
2035
+ if (spread > maxSpread) {
2036
+ throw new Error(
2037
+ `venues disagree on ${asked} by ${Math.round(spread)} bps, more than the ${maxSpread} allowed: ${quoted.join(", ")}`
2038
+ );
2039
+ }
2040
+ const price = middleOf(quoted);
2041
+ held.set(asked, { at: Date.now(), price });
2042
+ return price;
2043
+ };
2044
+ }
2045
+ function msatFor(amountMinor, priceMinorPerBtc, options = {}) {
2046
+ if (!Number.isInteger(amountMinor) || amountMinor <= 0) {
2047
+ throw new Error("amountMinor must be a whole number of minor units above zero");
2048
+ }
2049
+ if (!Number.isFinite(priceMinorPerBtc) || priceMinorPerBtc <= 0) {
2050
+ throw new Error(`${priceMinorPerBtc} is not a usable price`);
2051
+ }
2052
+ const spreadBps = options.spreadBps ?? 0;
2053
+ if (!Number.isInteger(spreadBps) || spreadBps < 0) {
2054
+ throw new Error("spreadBps must be a whole number of basis points, none or more");
2055
+ }
2056
+ const wanted = BigInt(amountMinor) * BigInt(MSAT_PER_BTC2) * BigInt(BASIS_POINTS + spreadBps);
2057
+ const per = BigInt(Math.round(priceMinorPerBtc)) * BigInt(BASIS_POINTS);
2058
+ const msat = (wanted + per - 1n) / per;
2059
+ if (msat > BigInt(Number.MAX_SAFE_INTEGER)) {
2060
+ throw new Error(`${amountMinor} at ${priceMinorPerBtc} is more millisatoshi than exist`);
2061
+ }
2062
+ return Number(msat);
2063
+ }
2064
+ async function asJson(url, venue) {
2065
+ const answer = await fetch(url, { headers: { accept: "application/json" } });
2066
+ if (!answer.ok) throw new Error(`${venue} answered ${answer.status}`);
2067
+ return answer.json();
2068
+ }
2069
+ function asMinor(quoted, venue, currency) {
2070
+ const major2 = Number(quoted);
2071
+ if (!Number.isFinite(major2) || major2 <= 0) {
2072
+ throw new Error(`${venue} quoted ${String(quoted)} for ${currency.toUpperCase()}`);
2073
+ }
2074
+ return Math.round(major2 * minorScaleOf(currency));
2075
+ }
2076
+ function spreadBpsOf(sorted) {
2077
+ const lowest = sorted[0] ?? 0;
2078
+ const highest = sorted[sorted.length - 1] ?? 0;
2079
+ return lowest === 0 ? Number.POSITIVE_INFINITY : (highest - lowest) / lowest * BASIS_POINTS;
2080
+ }
2081
+ function middleOf(sorted) {
2082
+ const middle = Math.floor(sorted.length / 2);
2083
+ if (sorted.length % 2 === 1) return sorted[middle] ?? 0;
2084
+ return Math.round(((sorted[middle - 1] ?? 0) + (sorted[middle] ?? 0)) / 2);
2085
+ }
2086
+ function refusals2(answers) {
2087
+ return answers.filter((answer) => answer.status === "rejected").map(
2088
+ (answer) => answer.reason instanceof Error ? answer.reason.message : String(answer.reason)
2089
+ ).join("; ");
2090
+ }
2091
+
1401
2092
  // src/webhook.ts
1402
2093
  var SIGNATURE_HEADER = "x-signature";
1403
2094
  var TIMESTAMP_HEADER = "x-timestamp";
@@ -1410,9 +2101,9 @@ async function verifyWebhookSignature(body, signature, secret, timestamp, option
1410
2101
  }
1411
2102
  async function parseWebhook(body, signature, secret, timestamp, options = {}) {
1412
2103
  if (!await verifyWebhookSignature(body, signature, secret, timestamp, options)) return null;
1413
- const text2 = typeof body === "string" ? body : new TextDecoder().decode(body);
2104
+ const text3 = typeof body === "string" ? body : new TextDecoder().decode(body);
1414
2105
  try {
1415
- return paymentFromWire(JSON.parse(text2));
2106
+ return paymentFromWire(JSON.parse(text3));
1416
2107
  } catch {
1417
2108
  return null;
1418
2109
  }
@@ -1452,19 +2143,38 @@ function signed(timestamp, body) {
1452
2143
  REQUEST_IN_FLIGHT,
1453
2144
  ThunderBridge,
1454
2145
  UnverifiedRecipientError,
2146
+ bankRail,
2147
+ bankTransfer,
2148
+ bankVerifyEndpoint,
2149
+ bitstamp,
2150
+ blindLightningRail,
2151
+ coinbase,
2152
+ coinmate,
1455
2153
  decodeInvoice,
2154
+ fioStatement,
1456
2155
  invoiceToDataUrl,
1457
2156
  invoiceToSvg,
2157
+ isProblemType,
1458
2158
  isProvablyPaid,
2159
+ kraken,
2160
+ lightningRail,
1459
2161
  lnurlPayEndpoint,
1460
2162
  lnurlToDataUrl,
1461
2163
  lnurlToSvg,
2164
+ medianOf,
2165
+ minorScaleOf,
2166
+ minorUnitsOf,
2167
+ msatFor,
1462
2168
  parseWebhook,
1463
2169
  parseWebhookRequest,
1464
2170
  preimageMatchesHash,
1465
2171
  proveOrigin,
1466
2172
  proveSettlement,
2173
+ qrToDataUrl,
2174
+ qrToSvg,
1467
2175
  seal,
2176
+ spdToDataUrl,
2177
+ spdToSvg,
1468
2178
  toLnurl,
1469
2179
  unseal,
1470
2180
  verifyWebhookSignature