thunder-bridge 0.8.0 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -30,19 +30,33 @@ __export(index_exports, {
30
30
  REQUEST_IN_FLIGHT: () => REQUEST_IN_FLIGHT,
31
31
  ThunderBridge: () => ThunderBridge,
32
32
  UnverifiedRecipientError: () => UnverifiedRecipientError,
33
+ bankTransfer: () => bankTransfer,
34
+ bankVerifyEndpoint: () => bankVerifyEndpoint,
35
+ bitstamp: () => bitstamp,
36
+ coinbase: () => coinbase,
37
+ coinmate: () => coinmate,
33
38
  decodeInvoice: () => decodeInvoice,
39
+ fioStatement: () => fioStatement,
34
40
  invoiceToDataUrl: () => invoiceToDataUrl,
35
41
  invoiceToSvg: () => invoiceToSvg,
42
+ isProblemType: () => isProblemType,
36
43
  isProvablyPaid: () => isProvablyPaid,
44
+ kraken: () => kraken,
37
45
  lnurlPayEndpoint: () => lnurlPayEndpoint,
38
46
  lnurlToDataUrl: () => lnurlToDataUrl,
39
47
  lnurlToSvg: () => lnurlToSvg,
48
+ medianOf: () => medianOf,
49
+ minorScaleOf: () => minorScaleOf,
50
+ minorUnitsOf: () => minorUnitsOf,
51
+ msatFor: () => msatFor,
40
52
  parseWebhook: () => parseWebhook,
41
53
  parseWebhookRequest: () => parseWebhookRequest,
42
54
  preimageMatchesHash: () => preimageMatchesHash,
43
55
  proveOrigin: () => proveOrigin,
44
56
  proveSettlement: () => proveSettlement,
45
57
  seal: () => seal,
58
+ spdToDataUrl: () => spdToDataUrl,
59
+ spdToSvg: () => spdToSvg,
46
60
  toLnurl: () => toLnurl,
47
61
  unseal: () => unseal,
48
62
  verifyWebhookSignature: () => verifyWebhookSignature
@@ -85,10 +99,15 @@ var ProblemError = class extends Error {
85
99
  this.detail = problem.detail ?? null;
86
100
  }
87
101
  };
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";
102
+ var NAMESPACE = "urn:problem-type:thunder-bridge:";
103
+ var NAMESPACE_BEFORE_THE_RENAME = "urn:problem-type:thunder-bridge-direct:";
104
+ function isProblemType(problem, type) {
105
+ return problem.type === type || problem.type === type.replace(NAMESPACE, NAMESPACE_BEFORE_THE_RENAME);
106
+ }
107
+ var NO_WALLET_AVAILABLE = "urn:problem-type:thunder-bridge:no-wallet-available";
108
+ var REQUEST_IN_FLIGHT = "urn:problem-type:thunder-bridge:request-in-flight";
109
+ var IDEMPOTENCY_KEY_REUSED = "urn:problem-type:thunder-bridge:idempotency-key-reused";
110
+ var PAYMENT_ALREADY_WATCHED = "urn:problem-type:thunder-bridge:payment-already-watched";
92
111
  var IdempotencyConflictError = class extends ProblemError {
93
112
  conflict;
94
113
  constructor(problem, conflict) {
@@ -540,10 +559,10 @@ function quoteFromWire(body) {
540
559
  const minMsat = msatFrom(wire["min_amount"], 0);
541
560
  const maxMsat = msatFrom(wire["max_amount"], 0);
542
561
  const metadata = text(wire["metadata"]);
543
- const refusals2 = refusalsFrom(wire["refusals"]);
562
+ const refusals3 = refusalsFrom(wire["refusals"]);
544
563
  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 };
564
+ if (minMsat === null || maxMsat === null || metadata === null || refusals3 === null) return null;
565
+ return { lnAddress, amountMsat, feeMsat, minMsat, maxMsat, metadata, refusals: refusals3 };
547
566
  }
548
567
  function paymentFromWire(body) {
549
568
  const wire = asObject(body);
@@ -638,15 +657,15 @@ function msatFrom(value, least) {
638
657
  }
639
658
  function refusalsFrom(value) {
640
659
  if (!Array.isArray(value)) return null;
641
- const refusals2 = [];
660
+ const refusals3 = [];
642
661
  for (const entry of value) {
643
662
  const failure = asObject(entry);
644
663
  const address = failure === null ? null : text(failure["address"]);
645
664
  const reason = failure?.["reason"];
646
665
  if (address === null || typeof reason !== "string" || !REASONS.has(reason)) return null;
647
- refusals2.push({ address, reason });
666
+ refusals3.push({ address, reason });
648
667
  }
649
- return refusals2;
668
+ return refusals3;
650
669
  }
651
670
  function secondsFrom(value) {
652
671
  if (typeof value !== "string") return null;
@@ -675,6 +694,14 @@ var ThunderBridge = class {
675
694
  this.verify = options?.verify ?? true;
676
695
  this.token = options?.token ?? null;
677
696
  }
697
+ /**
698
+ * Whether a token was given, which is what makes an instance yours: a gateway
699
+ * started with `GATEWAY_TOKEN` answers nobody else, so anything you hand it
700
+ * stays between you and it
701
+ */
702
+ get isPrivate() {
703
+ return this.token !== null;
704
+ }
678
705
  /**
679
706
  * Ask the gateway for an invoice payable to the first address on your list
680
707
  * that can issue a provable one, throws `NoWalletAvailableError` when none can
@@ -725,6 +752,26 @@ var ThunderBridge = class {
725
752
  if (!response.ok) throw await problemFrom(response);
726
753
  return this.checked(await paymentFrom(response));
727
754
  }
755
+ /**
756
+ * Read back a payment the gateway is only watching, null when it has never
757
+ * heard of it. A watched payment carries no address, amount or invoice, so
758
+ * `getPayment` refuses it and this reads the shape both rails share
759
+ */
760
+ async getWatched(id) {
761
+ const response = await fetch(`${this.baseUrl}/incoming-payments/${encodeURIComponent(id)}`, {
762
+ headers: this.reading()
763
+ });
764
+ if (response.status === 404) return null;
765
+ if (!response.ok) throw await problemFrom(response);
766
+ const watched = triggerEventFromWire(await response.json().catch(() => null));
767
+ if (watched === null) {
768
+ throw new ProblemError({
769
+ status: response.status,
770
+ title: "The gateway answered with something that is not a payment"
771
+ });
772
+ }
773
+ return this.proven(watched);
774
+ }
728
775
  /**
729
776
  * List what this gateway is watching, newest first. Only a gateway started
730
777
  * with `GATEWAY_TOKEN` serves this, because on a shared one it would hand
@@ -756,7 +803,36 @@ var ThunderBridge = class {
756
803
  * one that has answered is followed until its own expiry, so the wait always
757
804
  * ends by itself
758
805
  */
759
- waitForPayment(id, options) {
806
+ async waitForPayment(id, options) {
807
+ const payment = paymentFromWire(await this.followed(id, options));
808
+ if (payment === null) {
809
+ throw new ProblemError({
810
+ status: 200,
811
+ title: `payment ${id} is watched rather than minted here, read it with waitForWatched`
812
+ });
813
+ }
814
+ return this.checked(payment);
815
+ }
816
+ /**
817
+ * Follow a payment the gateway is only watching, one it did not mint, until it
818
+ * is paid or expired.
819
+ *
820
+ * A watched payment carries no address, no amount and no invoice, because the
821
+ * gateway was told none of them, so it reads back as the shape a trigger
822
+ * streams rather than as a `Payment`. That is every bank transfer, and every
823
+ * Lightning invoice registered with `watchPayment` instead of `createPayment`.
824
+ */
825
+ async waitForWatched(id, options) {
826
+ const watched = triggerEventFromWire(await this.followed(id, options));
827
+ if (watched === null) {
828
+ throw new ProblemError({
829
+ status: 200,
830
+ title: "The gateway answered with something that is not a payment"
831
+ });
832
+ }
833
+ return this.proven(watched);
834
+ }
835
+ followed(id, options) {
760
836
  const base = this.baseUrl.replace(/^http/, "ws");
761
837
  const direct = `${base}/ws/incoming-payments/${encodeURIComponent(id)}`;
762
838
  return new Promise((resolve2, reject) => {
@@ -809,13 +885,13 @@ var ThunderBridge = class {
809
885
  socket = opened;
810
886
  opened.onmessage = (event) => {
811
887
  try {
812
- const payment = paymentFromWire(JSON.parse(String(event.data)));
813
- if (payment === null) return;
814
- expiresAt = payment.expiresAt;
888
+ const frame = JSON.parse(String(event.data));
889
+ const watched = triggerEventFromWire(frame);
890
+ if (watched === null) return;
891
+ expiresAt = watched.expiresAt;
815
892
  attempt = 1;
816
- if (!TERMINAL.has(payment.status)) return;
817
- const checked = this.checked(payment);
818
- settle(() => resolve2(checked));
893
+ if (!TERMINAL.has(watched.status)) return;
894
+ settle(() => resolve2(frame));
819
895
  } catch (refused) {
820
896
  settle(() => reject(refused));
821
897
  }
@@ -826,6 +902,49 @@ var ThunderBridge = class {
826
902
  void connect();
827
903
  });
828
904
  }
905
+ /**
906
+ * Wait on several payments and keep the first one that is really paid, then stop
907
+ * waiting on the losers, which closes their sockets.
908
+ *
909
+ * This is how one order offers two rails. A Lightning invoice and a bank
910
+ * transfer for the same thing are two payments here, and the payer picks one, so
911
+ * what you want is the one that arrives and nothing further from the other.
912
+ *
913
+ * A leg that expires is a loser, not a winner, which is the whole reason this is
914
+ * not a race: `waitForPayment` ends on `paid` and on `expired` alike, and a
915
+ * Lightning invoice expires in an hour while a bank transfer takes days. `null`
916
+ * means every leg ended without being paid.
917
+ *
918
+ * Stopping the wait is not revoking the invoice. Nobody can revoke one, because
919
+ * the recipient's own wallet minted it, so a payer who pays the loser afterwards
920
+ * really does pay twice and that shows up on `followTrigger` as a second
921
+ * settlement to refund.
922
+ */
923
+ async firstToSettle(ids, options) {
924
+ if (ids.length === 0) return null;
925
+ const stopLosers = new AbortController();
926
+ const signal = options?.signal ? AbortSignal.any([stopLosers.signal, options.signal]) : stopLosers.signal;
927
+ let refused = null;
928
+ try {
929
+ const winner = await new Promise((resolve2) => {
930
+ let waiting = ids.length;
931
+ const lost = () => {
932
+ waiting -= 1;
933
+ if (waiting === 0) resolve2(null);
934
+ };
935
+ for (const id of ids) {
936
+ this.waitForWatched(id, { ...options, signal }).then((watched) => watched.status === "paid" ? resolve2(watched) : lost()).catch((failure) => {
937
+ refused ??= failure;
938
+ lost();
939
+ });
940
+ }
941
+ });
942
+ if (winner === null && refused !== null) throw refused;
943
+ return winner;
944
+ } finally {
945
+ stopLosers.abort();
946
+ }
947
+ }
829
948
  /**
830
949
  * Hand over an invoice you obtained yourself so the gateway watches it without
831
950
  * being told the address or the amount. It can then only refuse everyone
@@ -950,13 +1069,13 @@ async function paymentFrom(response) {
950
1069
  async function problemFrom(response) {
951
1070
  const problem = await response.json().catch(() => ({}));
952
1071
  const document = { ...problem, status: response.status };
953
- if (problem.type === NO_WALLET_AVAILABLE) {
1072
+ if (isProblemType(problem, NO_WALLET_AVAILABLE)) {
954
1073
  return new NoWalletAvailableError(document, refusals(problem.wallets));
955
1074
  }
956
- if (problem.type === REQUEST_IN_FLIGHT) {
1075
+ if (isProblemType(problem, REQUEST_IN_FLIGHT)) {
957
1076
  return new IdempotencyConflictError(document, "request-in-flight");
958
1077
  }
959
- if (problem.type === IDEMPOTENCY_KEY_REUSED) {
1078
+ if (isProblemType(problem, IDEMPOTENCY_KEY_REUSED)) {
960
1079
  return new IdempotencyConflictError(document, "key-reused");
961
1080
  }
962
1081
  return new ProblemError(document);
@@ -973,7 +1092,7 @@ var VERSION = "v1";
973
1092
  var IV_BYTES = 12;
974
1093
  var MIN_SECRET_CHARS = 32;
975
1094
  var MAX_PLAIN_BYTES = 3e3;
976
- var INFO = new TextEncoder().encode("thunder-bridge-direct/sealed");
1095
+ var INFO = new TextEncoder().encode("thunder-bridge/sealed");
977
1096
  async function seal(secret, plaintext) {
978
1097
  const body = new TextEncoder().encode(plaintext);
979
1098
  if (body.length > MAX_PLAIN_BYTES) {
@@ -1095,18 +1214,18 @@ async function resolve(addresses, amountMsat) {
1095
1214
  }
1096
1215
  async function firstThatServes(addresses, attempt) {
1097
1216
  const deadline = AbortSignal.timeout(RESOLVE_TIMEOUT_MS);
1098
- const refusals2 = [];
1217
+ const refusals3 = [];
1099
1218
  for (const address of addresses) {
1100
1219
  if (deadline.aborted) break;
1101
1220
  try {
1102
- return { won: await attempt(address, deadline), refusals: refusals2 };
1221
+ return { won: await attempt(address, deadline), refusals: refusals3 };
1103
1222
  } catch (refusal) {
1104
1223
  if (!(refusal instanceof WalletRefused)) throw refusal;
1105
1224
  console.warn(`wallet ${address} refused, ${refusal.reason}: ${refusal.message}`);
1106
- refusals2.push({ address, reason: refusal.reason });
1225
+ refusals3.push({ address, reason: refusal.reason });
1107
1226
  }
1108
1227
  }
1109
- throw new NoWalletAvailable(refusals2);
1228
+ throw new NoWalletAvailable(refusals3);
1110
1229
  }
1111
1230
  async function probe(address, amountMsat, deadline) {
1112
1231
  const [user, domain] = splitAddress(address);
@@ -1331,7 +1450,7 @@ async function mintBlind(config, address, amountMsat) {
1331
1450
  return { bolt11: resolved.bolt11, verifyUrl: resolved.verifyUrl };
1332
1451
  }
1333
1452
  function alreadyWatched(refused) {
1334
- return refused instanceof ProblemError && refused.type === PAYMENT_ALREADY_WATCHED;
1453
+ return refused instanceof ProblemError && isProblemType(refused, PAYMENT_ALREADY_WATCHED);
1335
1454
  }
1336
1455
  function sign(secret, address, amountMsat, nonce) {
1337
1456
  return hmacHex(secret, `${address}|${amountMsat}|${nonce}`);
@@ -1346,6 +1465,355 @@ function refuse(reason) {
1346
1465
  return Response.json({ status: "ERROR", reason });
1347
1466
  }
1348
1467
 
1468
+ // src/currency.ts
1469
+ var ISO_4217_MINOR_UNITS = {
1470
+ BHD: 3,
1471
+ BIF: 0,
1472
+ CLP: 0,
1473
+ CZK: 2,
1474
+ DJF: 0,
1475
+ EUR: 2,
1476
+ GBP: 2,
1477
+ ISK: 0,
1478
+ IQD: 3,
1479
+ JOD: 3,
1480
+ JPY: 0,
1481
+ KRW: 0,
1482
+ KWD: 3,
1483
+ LYD: 3,
1484
+ OMR: 3,
1485
+ PLN: 2,
1486
+ PYG: 0,
1487
+ RWF: 0,
1488
+ TND: 3,
1489
+ UGX: 0,
1490
+ USD: 2,
1491
+ VND: 0,
1492
+ VUV: 0,
1493
+ XAF: 0,
1494
+ XOF: 0,
1495
+ XPF: 0
1496
+ };
1497
+ function minorUnitsOf(currency) {
1498
+ const digits = ISO_4217_MINOR_UNITS[currency.toUpperCase()];
1499
+ if (digits === void 0) {
1500
+ throw new Error(
1501
+ `${currency.toUpperCase()} is not a currency this knows the ISO 4217 minor unit of`
1502
+ );
1503
+ }
1504
+ return digits;
1505
+ }
1506
+ function minorScaleOf(currency) {
1507
+ return 10 ** minorUnitsOf(currency);
1508
+ }
1509
+
1510
+ // src/bank.ts
1511
+ var DEFAULT_CURRENCY = "CZK";
1512
+ var DEFAULT_LOOK_BACK_SECS = 7 * 24 * 60 * 60;
1513
+ var IBAN = /^[A-Z]{2}[0-9]{2}[0-9A-Z]{8,30}$/;
1514
+ var FORBIDDEN_IN_SPD = "*";
1515
+ async function bankTransfer(params) {
1516
+ const currency = params.currency ?? DEFAULT_CURRENCY;
1517
+ refuseUnusable(params, currency);
1518
+ const spd = shortPaymentDescriptor(params, currency);
1519
+ const subject = subjectOf(params.reference, params.amountMinor, currency);
1520
+ const polling = new URL(params.verifyUrl);
1521
+ polling.searchParams.set("ref", params.reference);
1522
+ polling.searchParams.set("minor", String(params.amountMinor));
1523
+ polling.searchParams.set("cc", currency);
1524
+ polling.searchParams.set("sig", await hmacHex(params.secret, `verify|${subject}`));
1525
+ const verifyUrl = polling.toString();
1526
+ const watched = await params.gateway.watchPayment({
1527
+ paymentHash: hashOf(await hmacHex(params.secret, `preimage|${subject}`)),
1528
+ verifyUrl,
1529
+ expiresAt: params.expiresAt,
1530
+ trigger: params.trigger,
1531
+ sealed: params.sealed
1532
+ });
1533
+ return { id: watched.id, paymentHash: watched.paymentHash, verifyUrl, spd };
1534
+ }
1535
+ function bankVerifyEndpoint(config) {
1536
+ return async (request) => {
1537
+ const asked = readQuery(new URL(request.url));
1538
+ if (asked === null) return Response.json({ settled: false }, { status: 400 });
1539
+ const subject = subjectOf(asked.reference, asked.amountMinor, asked.currency);
1540
+ const expected = await hmacHex(config.secret, `verify|${subject}`);
1541
+ if (!equalInConstantTime(asked.signature, expected)) {
1542
+ return Response.json({ settled: false }, { status: 403 });
1543
+ }
1544
+ const since = unixNow() - (config.lookBackSecs ?? DEFAULT_LOOK_BACK_SECS);
1545
+ const landed = (await config.statement(since)).some((credit) => pays(credit, asked));
1546
+ if (!landed) return Response.json({ settled: false });
1547
+ return Response.json({
1548
+ settled: true,
1549
+ preimage: await hmacHex(config.secret, `preimage|${subject}`)
1550
+ });
1551
+ };
1552
+ }
1553
+ function readQuery(url) {
1554
+ const reference = url.searchParams.get("ref");
1555
+ const minor = Number(url.searchParams.get("minor"));
1556
+ const currency = url.searchParams.get("cc");
1557
+ const signature = url.searchParams.get("sig");
1558
+ if (!reference || !currency || !signature) return null;
1559
+ if (!Number.isInteger(minor) || minor <= 0) return null;
1560
+ return { reference, amountMinor: minor, currency, signature };
1561
+ }
1562
+ function pays(credit, asked) {
1563
+ return credit.amountMinor === asked.amountMinor && credit.currency.toUpperCase() === asked.currency.toUpperCase() && credit.reference.toUpperCase().includes(asked.reference.toUpperCase());
1564
+ }
1565
+ function subjectOf(reference, amountMinor, currency) {
1566
+ return `${reference}|${amountMinor}|${currency.toUpperCase()}`;
1567
+ }
1568
+ function hashOf(preimage) {
1569
+ return bytesToHex(sha256(hexToBytes(preimage)));
1570
+ }
1571
+ function shortPaymentDescriptor(params, currency) {
1572
+ const fields = [
1573
+ `ACC:${params.iban}`,
1574
+ `AM:${major(params.amountMinor, currency)}`,
1575
+ `CC:${currency.toUpperCase()}`,
1576
+ `MSG:${params.reference}`
1577
+ ];
1578
+ if (params.variableSymbol) fields.push(`X-VS:${params.variableSymbol}`);
1579
+ return `SPD*1.0*${fields.join("*")}`;
1580
+ }
1581
+ function major(amountMinor, currency) {
1582
+ return (amountMinor / minorScaleOf(currency)).toFixed(minorUnitsOf(currency));
1583
+ }
1584
+ function refuseUnusable(params, currency) {
1585
+ if (!params.gateway.isPrivate && params.allowPublicGateway !== true) {
1586
+ throw new Error(
1587
+ "this gateway has no token, so it is not yours, and its operator would read the amount and the reference off every verify URL. Give it a token or say allowPublicGateway"
1588
+ );
1589
+ }
1590
+ if (!IBAN.test(params.iban)) throw new Error(`${params.iban} is not an IBAN`);
1591
+ if (!Number.isInteger(params.amountMinor) || params.amountMinor <= 0) {
1592
+ throw new Error("amountMinor must be a whole number of minor units above zero");
1593
+ }
1594
+ if (params.reference.length === 0) {
1595
+ throw new Error("a transfer with no reference cannot be found");
1596
+ }
1597
+ if (params.reference.includes(FORBIDDEN_IN_SPD)) {
1598
+ throw new Error("a reference cannot contain an asterisk, it separates the QR fields");
1599
+ }
1600
+ if (params.variableSymbol && !/^[0-9]{1,10}$/.test(params.variableSymbol)) {
1601
+ throw new Error("a variable symbol is up to ten digits");
1602
+ }
1603
+ }
1604
+ function unixNow() {
1605
+ return Math.floor(Date.now() / 1e3);
1606
+ }
1607
+
1608
+ // src/fio.ts
1609
+ var BASE_URL = "https://fioapi.fio.cz/v1/rest";
1610
+ var MINOR_UNITS = 100;
1611
+ var MILLIS = 1e3;
1612
+ var DATE_CHARS = "2026-08-04".length;
1613
+ var DEFAULT_MIN_INTERVAL_SECS = 30;
1614
+ var TOO_SOON = 409;
1615
+ var BOOKED_AT = "column0";
1616
+ var AMOUNT = "column1";
1617
+ var CURRENCY = "column14";
1618
+ var VARIABLE_SYMBOL = "column5";
1619
+ var USER_IDENTIFICATION = "column7";
1620
+ var MESSAGE_FOR_RECIPIENT = "column16";
1621
+ var PAYER_REFERENCE = "column27";
1622
+ var WHERE_A_PAYER_WRITES = [
1623
+ MESSAGE_FOR_RECIPIENT,
1624
+ VARIABLE_SYMBOL,
1625
+ USER_IDENTIFICATION,
1626
+ PAYER_REFERENCE
1627
+ ];
1628
+ function fioStatement(config) {
1629
+ const named = typeof config.token === "string" ? [config.token] : config.token;
1630
+ if (named.length === 0) throw new Error("fioStatement needs at least one token to read with");
1631
+ const usedAt = new Map(named.map((token) => [token, Number.NEGATIVE_INFINITY]));
1632
+ const tokenWindowMs = (config.minIntervalSecs ?? DEFAULT_MIN_INTERVAL_SECS) * MILLIS;
1633
+ const paceMs = tokenWindowMs / usedAt.size;
1634
+ let lastRead = Number.NEGATIVE_INFINITY;
1635
+ let credits = [];
1636
+ return async (sinceUnix) => {
1637
+ const now = Date.now();
1638
+ if (now - lastRead < paceMs) return credits;
1639
+ const idlest = longestUnused(usedAt);
1640
+ if (now - idlest.usedAt < tokenWindowMs) return credits;
1641
+ usedAt.set(idlest.token, now);
1642
+ lastRead = now;
1643
+ const url = [
1644
+ config.baseUrl ?? BASE_URL,
1645
+ "periods",
1646
+ idlest.token,
1647
+ asDate(sinceUnix),
1648
+ asDate(Math.floor(now / MILLIS)),
1649
+ "transactions.json"
1650
+ ].join("/");
1651
+ const answer = await fetch(url, { headers: { accept: "application/json" } });
1652
+ if (answer.status === TOO_SOON) {
1653
+ throw new Error("fio refuses a second read of this token inside its 30 second window");
1654
+ }
1655
+ if (!answer.ok) throw new Error(`fio answered ${answer.status} reading the statement`);
1656
+ const read = await answer.json();
1657
+ credits = (read.accountStatement?.transactionList?.transaction ?? []).filter(isCredit).map(asCredit);
1658
+ return credits;
1659
+ };
1660
+ }
1661
+ function longestUnused(usedAt) {
1662
+ let token = "";
1663
+ let idleSince = Number.POSITIVE_INFINITY;
1664
+ for (const [candidate, at] of usedAt) {
1665
+ if (at < idleSince) {
1666
+ token = candidate;
1667
+ idleSince = at;
1668
+ }
1669
+ }
1670
+ return { token, usedAt: idleSince };
1671
+ }
1672
+ function isCredit(transaction) {
1673
+ return numberIn(transaction[AMOUNT]) > 0;
1674
+ }
1675
+ function asCredit(transaction) {
1676
+ return {
1677
+ amountMinor: Math.round(numberIn(transaction[AMOUNT]) * MINOR_UNITS),
1678
+ currency: textIn(transaction[CURRENCY]),
1679
+ reference: WHERE_A_PAYER_WRITES.map((column) => textIn(transaction[column])).filter((written) => written.length > 0).join(" "),
1680
+ bookedAt: Math.floor(numberIn(transaction[BOOKED_AT]) / MILLIS)
1681
+ };
1682
+ }
1683
+ function numberIn(cell) {
1684
+ const value = Number(cell?.value ?? Number.NaN);
1685
+ return Number.isFinite(value) ? value : 0;
1686
+ }
1687
+ function textIn(cell) {
1688
+ const value = cell?.value;
1689
+ return value === null || value === void 0 ? "" : String(value).trim();
1690
+ }
1691
+ function asDate(unix) {
1692
+ return new Date(unix * MILLIS).toISOString().slice(0, DATE_CHARS);
1693
+ }
1694
+
1695
+ // src/price.ts
1696
+ var MSAT_PER_BTC2 = 1e11;
1697
+ var BASIS_POINTS = 1e4;
1698
+ var DEFAULT_HOLD_SECS = 60;
1699
+ var DEFAULT_MIN_VENUES = 2;
1700
+ var DEFAULT_MAX_SPREAD_BPS = 200;
1701
+ var MILLIS2 = 1e3;
1702
+ function coinbase(baseUrl = "https://api.coinbase.com") {
1703
+ return async (currency) => {
1704
+ const asked = currency.toUpperCase();
1705
+ const answer = await asJson(`${baseUrl}/v2/prices/BTC-${asked}/spot`, "coinbase");
1706
+ const quoted = answer.data;
1707
+ if (quoted?.currency !== asked) {
1708
+ throw new Error(`coinbase quoted ${quoted?.currency} when asked for ${asked}`);
1709
+ }
1710
+ return asMinor(quoted.amount, "coinbase", asked);
1711
+ };
1712
+ }
1713
+ function kraken(baseUrl = "https://api.kraken.com") {
1714
+ return async (currency) => {
1715
+ const asked = currency.toUpperCase();
1716
+ const answer = await asJson(`${baseUrl}/0/public/Ticker?pair=XBT${asked}`, "kraken");
1717
+ if (answer.error?.length) throw new Error(`kraken refused XBT${asked}: ${answer.error[0]}`);
1718
+ const pairs = Object.values(answer.result ?? {});
1719
+ if (pairs.length !== 1) {
1720
+ throw new Error(`kraken answered ${pairs.length} pairs for XBT${asked}`);
1721
+ }
1722
+ return asMinor(pairs[0]?.c?.[0], "kraken", asked);
1723
+ };
1724
+ }
1725
+ function bitstamp(baseUrl = "https://www.bitstamp.net") {
1726
+ return async (currency) => {
1727
+ const pair = `btc${currency.toLowerCase()}`;
1728
+ const answer = await asJson(`${baseUrl}/api/v2/ticker/${pair}/`, "bitstamp");
1729
+ if (Array.isArray(answer)) throw new Error(`bitstamp has no ${pair} pair`);
1730
+ return asMinor(answer.last, "bitstamp", currency);
1731
+ };
1732
+ }
1733
+ function coinmate(baseUrl = "https://coinmate.io") {
1734
+ return async (currency) => {
1735
+ const pair = `BTC_${currency.toUpperCase()}`;
1736
+ const answer = await asJson(`${baseUrl}/api/ticker?currencyPair=${pair}`, "coinmate");
1737
+ if (answer.error !== false) {
1738
+ throw new Error(`coinmate refused ${pair}: ${answer.errorMessage ?? "no reason"}`);
1739
+ }
1740
+ return asMinor(answer.data?.last, "coinmate", currency);
1741
+ };
1742
+ }
1743
+ function medianOf(tickers = [coinbase(), kraken(), bitstamp(), coinmate()], options = {}) {
1744
+ const holdFor = (options.holdForSecs ?? DEFAULT_HOLD_SECS) * MILLIS2;
1745
+ const minVenues = options.minVenues ?? DEFAULT_MIN_VENUES;
1746
+ const maxSpread = options.maxSpreadBps ?? DEFAULT_MAX_SPREAD_BPS;
1747
+ const held = /* @__PURE__ */ new Map();
1748
+ return async (currency) => {
1749
+ const asked = currency.toUpperCase();
1750
+ const remembered = held.get(asked);
1751
+ if (remembered && Date.now() - remembered.at < holdFor) return remembered.price;
1752
+ const answers = await Promise.allSettled(tickers.map((ticker) => ticker(asked)));
1753
+ const quoted = answers.filter((answer) => answer.status === "fulfilled").map((answer) => answer.value).sort((one, other) => one - other);
1754
+ if (quoted.length < minVenues) {
1755
+ throw new Error(
1756
+ `${quoted.length} of ${tickers.length} venues quoted ${asked}, ${minVenues} wanted: ${refusals2(answers)}`
1757
+ );
1758
+ }
1759
+ const spread = spreadBpsOf(quoted);
1760
+ if (spread > maxSpread) {
1761
+ throw new Error(
1762
+ `venues disagree on ${asked} by ${Math.round(spread)} bps, more than the ${maxSpread} allowed: ${quoted.join(", ")}`
1763
+ );
1764
+ }
1765
+ const price = middleOf(quoted);
1766
+ held.set(asked, { at: Date.now(), price });
1767
+ return price;
1768
+ };
1769
+ }
1770
+ function msatFor(amountMinor, priceMinorPerBtc, options = {}) {
1771
+ if (!Number.isInteger(amountMinor) || amountMinor <= 0) {
1772
+ throw new Error("amountMinor must be a whole number of minor units above zero");
1773
+ }
1774
+ if (!Number.isFinite(priceMinorPerBtc) || priceMinorPerBtc <= 0) {
1775
+ throw new Error(`${priceMinorPerBtc} is not a usable price`);
1776
+ }
1777
+ const spreadBps = options.spreadBps ?? 0;
1778
+ if (!Number.isInteger(spreadBps) || spreadBps < 0) {
1779
+ throw new Error("spreadBps must be a whole number of basis points, none or more");
1780
+ }
1781
+ const wanted = BigInt(amountMinor) * BigInt(MSAT_PER_BTC2) * BigInt(BASIS_POINTS + spreadBps);
1782
+ const per = BigInt(Math.round(priceMinorPerBtc)) * BigInt(BASIS_POINTS);
1783
+ const msat = (wanted + per - 1n) / per;
1784
+ if (msat > BigInt(Number.MAX_SAFE_INTEGER)) {
1785
+ throw new Error(`${amountMinor} at ${priceMinorPerBtc} is more millisatoshi than exist`);
1786
+ }
1787
+ return Number(msat);
1788
+ }
1789
+ async function asJson(url, venue) {
1790
+ const answer = await fetch(url, { headers: { accept: "application/json" } });
1791
+ if (!answer.ok) throw new Error(`${venue} answered ${answer.status}`);
1792
+ return answer.json();
1793
+ }
1794
+ function asMinor(quoted, venue, currency) {
1795
+ const major2 = Number(quoted);
1796
+ if (!Number.isFinite(major2) || major2 <= 0) {
1797
+ throw new Error(`${venue} quoted ${String(quoted)} for ${currency.toUpperCase()}`);
1798
+ }
1799
+ return Math.round(major2 * minorScaleOf(currency));
1800
+ }
1801
+ function spreadBpsOf(sorted) {
1802
+ const lowest = sorted[0] ?? 0;
1803
+ const highest = sorted[sorted.length - 1] ?? 0;
1804
+ return lowest === 0 ? Number.POSITIVE_INFINITY : (highest - lowest) / lowest * BASIS_POINTS;
1805
+ }
1806
+ function middleOf(sorted) {
1807
+ const middle = Math.floor(sorted.length / 2);
1808
+ if (sorted.length % 2 === 1) return sorted[middle] ?? 0;
1809
+ return Math.round(((sorted[middle - 1] ?? 0) + (sorted[middle] ?? 0)) / 2);
1810
+ }
1811
+ function refusals2(answers) {
1812
+ return answers.filter((answer) => answer.status === "rejected").map(
1813
+ (answer) => answer.reason instanceof Error ? answer.reason.message : String(answer.reason)
1814
+ ).join("; ");
1815
+ }
1816
+
1349
1817
  // src/qr.ts
1350
1818
  var import_uqr = require("uqr");
1351
1819
  var SAFE_COLOR = /^#(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$|^rgba?\([\d.,%/\s]+\)$|^[a-z]+$/i;
@@ -1365,6 +1833,12 @@ function lnurlToSvg(endpoint, options) {
1365
1833
  function lnurlToDataUrl(endpoint, options) {
1366
1834
  return asDataUrl(lnurlToSvg(endpoint, options));
1367
1835
  }
1836
+ function spdToSvg(spd, options) {
1837
+ return svgOf(spd, options);
1838
+ }
1839
+ function spdToDataUrl(spd, options) {
1840
+ return asDataUrl(spdToSvg(spd, options));
1841
+ }
1368
1842
  function svgOf(scanned, options) {
1369
1843
  const size = options?.size ?? 256;
1370
1844
  const color = options?.color ?? "#000";
@@ -1452,19 +1926,33 @@ function signed(timestamp, body) {
1452
1926
  REQUEST_IN_FLIGHT,
1453
1927
  ThunderBridge,
1454
1928
  UnverifiedRecipientError,
1929
+ bankTransfer,
1930
+ bankVerifyEndpoint,
1931
+ bitstamp,
1932
+ coinbase,
1933
+ coinmate,
1455
1934
  decodeInvoice,
1935
+ fioStatement,
1456
1936
  invoiceToDataUrl,
1457
1937
  invoiceToSvg,
1938
+ isProblemType,
1458
1939
  isProvablyPaid,
1940
+ kraken,
1459
1941
  lnurlPayEndpoint,
1460
1942
  lnurlToDataUrl,
1461
1943
  lnurlToSvg,
1944
+ medianOf,
1945
+ minorScaleOf,
1946
+ minorUnitsOf,
1947
+ msatFor,
1462
1948
  parseWebhook,
1463
1949
  parseWebhookRequest,
1464
1950
  preimageMatchesHash,
1465
1951
  proveOrigin,
1466
1952
  proveSettlement,
1467
1953
  seal,
1954
+ spdToDataUrl,
1955
+ spdToSvg,
1468
1956
  toLnurl,
1469
1957
  unseal,
1470
1958
  verifyWebhookSignature