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.js CHANGED
@@ -34,10 +34,15 @@ 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
+ var NAMESPACE = "urn:problem-type:thunder-bridge:";
38
+ var NAMESPACE_BEFORE_THE_RENAME = "urn:problem-type:thunder-bridge-direct:";
39
+ function isProblemType(problem, type) {
40
+ return problem.type === type || problem.type === type.replace(NAMESPACE, NAMESPACE_BEFORE_THE_RENAME);
41
+ }
42
+ var NO_WALLET_AVAILABLE = "urn:problem-type:thunder-bridge:no-wallet-available";
43
+ var REQUEST_IN_FLIGHT = "urn:problem-type:thunder-bridge:request-in-flight";
44
+ var IDEMPOTENCY_KEY_REUSED = "urn:problem-type:thunder-bridge:idempotency-key-reused";
45
+ var PAYMENT_ALREADY_WATCHED = "urn:problem-type:thunder-bridge:payment-already-watched";
41
46
  var IdempotencyConflictError = class extends ProblemError {
42
47
  conflict;
43
48
  constructor(problem, conflict) {
@@ -489,10 +494,10 @@ function quoteFromWire(body) {
489
494
  const minMsat = msatFrom(wire["min_amount"], 0);
490
495
  const maxMsat = msatFrom(wire["max_amount"], 0);
491
496
  const metadata = text(wire["metadata"]);
492
- const refusals2 = refusalsFrom(wire["refusals"]);
497
+ const refusals3 = refusalsFrom(wire["refusals"]);
493
498
  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 };
499
+ if (minMsat === null || maxMsat === null || metadata === null || refusals3 === null) return null;
500
+ return { lnAddress, amountMsat, feeMsat, minMsat, maxMsat, metadata, refusals: refusals3 };
496
501
  }
497
502
  function paymentFromWire(body) {
498
503
  const wire = asObject(body);
@@ -587,15 +592,15 @@ function msatFrom(value, least) {
587
592
  }
588
593
  function refusalsFrom(value) {
589
594
  if (!Array.isArray(value)) return null;
590
- const refusals2 = [];
595
+ const refusals3 = [];
591
596
  for (const entry of value) {
592
597
  const failure = asObject(entry);
593
598
  const address = failure === null ? null : text(failure["address"]);
594
599
  const reason = failure?.["reason"];
595
600
  if (address === null || typeof reason !== "string" || !REASONS.has(reason)) return null;
596
- refusals2.push({ address, reason });
601
+ refusals3.push({ address, reason });
597
602
  }
598
- return refusals2;
603
+ return refusals3;
599
604
  }
600
605
  function secondsFrom(value) {
601
606
  if (typeof value !== "string") return null;
@@ -624,6 +629,14 @@ var ThunderBridge = class {
624
629
  this.verify = options?.verify ?? true;
625
630
  this.token = options?.token ?? null;
626
631
  }
632
+ /**
633
+ * Whether a token was given, which is what makes an instance yours: a gateway
634
+ * started with `GATEWAY_TOKEN` answers nobody else, so anything you hand it
635
+ * stays between you and it
636
+ */
637
+ get isPrivate() {
638
+ return this.token !== null;
639
+ }
627
640
  /**
628
641
  * Ask the gateway for an invoice payable to the first address on your list
629
642
  * that can issue a provable one, throws `NoWalletAvailableError` when none can
@@ -674,6 +687,26 @@ var ThunderBridge = class {
674
687
  if (!response.ok) throw await problemFrom(response);
675
688
  return this.checked(await paymentFrom(response));
676
689
  }
690
+ /**
691
+ * Read back a payment the gateway is only watching, null when it has never
692
+ * heard of it. A watched payment carries no address, amount or invoice, so
693
+ * `getPayment` refuses it and this reads the shape both rails share
694
+ */
695
+ async getWatched(id) {
696
+ const response = await fetch(`${this.baseUrl}/incoming-payments/${encodeURIComponent(id)}`, {
697
+ headers: this.reading()
698
+ });
699
+ if (response.status === 404) return null;
700
+ if (!response.ok) throw await problemFrom(response);
701
+ const watched = triggerEventFromWire(await response.json().catch(() => null));
702
+ if (watched === null) {
703
+ throw new ProblemError({
704
+ status: response.status,
705
+ title: "The gateway answered with something that is not a payment"
706
+ });
707
+ }
708
+ return this.proven(watched);
709
+ }
677
710
  /**
678
711
  * List what this gateway is watching, newest first. Only a gateway started
679
712
  * with `GATEWAY_TOKEN` serves this, because on a shared one it would hand
@@ -705,7 +738,36 @@ var ThunderBridge = class {
705
738
  * one that has answered is followed until its own expiry, so the wait always
706
739
  * ends by itself
707
740
  */
708
- waitForPayment(id, options) {
741
+ async waitForPayment(id, options) {
742
+ const payment = paymentFromWire(await this.followed(id, options));
743
+ if (payment === null) {
744
+ throw new ProblemError({
745
+ status: 200,
746
+ title: `payment ${id} is watched rather than minted here, read it with waitForWatched`
747
+ });
748
+ }
749
+ return this.checked(payment);
750
+ }
751
+ /**
752
+ * Follow a payment the gateway is only watching, one it did not mint, until it
753
+ * is paid or expired.
754
+ *
755
+ * A watched payment carries no address, no amount and no invoice, because the
756
+ * gateway was told none of them, so it reads back as the shape a trigger
757
+ * streams rather than as a `Payment`. That is every bank transfer, and every
758
+ * Lightning invoice registered with `watchPayment` instead of `createPayment`.
759
+ */
760
+ async waitForWatched(id, options) {
761
+ const watched = triggerEventFromWire(await this.followed(id, options));
762
+ if (watched === null) {
763
+ throw new ProblemError({
764
+ status: 200,
765
+ title: "The gateway answered with something that is not a payment"
766
+ });
767
+ }
768
+ return this.proven(watched);
769
+ }
770
+ followed(id, options) {
709
771
  const base = this.baseUrl.replace(/^http/, "ws");
710
772
  const direct = `${base}/ws/incoming-payments/${encodeURIComponent(id)}`;
711
773
  return new Promise((resolve2, reject) => {
@@ -758,13 +820,13 @@ var ThunderBridge = class {
758
820
  socket = opened;
759
821
  opened.onmessage = (event) => {
760
822
  try {
761
- const payment = paymentFromWire(JSON.parse(String(event.data)));
762
- if (payment === null) return;
763
- expiresAt = payment.expiresAt;
823
+ const frame = JSON.parse(String(event.data));
824
+ const watched = triggerEventFromWire(frame);
825
+ if (watched === null) return;
826
+ expiresAt = watched.expiresAt;
764
827
  attempt = 1;
765
- if (!TERMINAL.has(payment.status)) return;
766
- const checked = this.checked(payment);
767
- settle(() => resolve2(checked));
828
+ if (!TERMINAL.has(watched.status)) return;
829
+ settle(() => resolve2(frame));
768
830
  } catch (refused) {
769
831
  settle(() => reject(refused));
770
832
  }
@@ -775,6 +837,49 @@ var ThunderBridge = class {
775
837
  void connect();
776
838
  });
777
839
  }
840
+ /**
841
+ * Wait on several payments and keep the first one that is really paid, then stop
842
+ * waiting on the losers, which closes their sockets.
843
+ *
844
+ * This is how one order offers two rails. A Lightning invoice and a bank
845
+ * transfer for the same thing are two payments here, and the payer picks one, so
846
+ * what you want is the one that arrives and nothing further from the other.
847
+ *
848
+ * A leg that expires is a loser, not a winner, which is the whole reason this is
849
+ * not a race: `waitForPayment` ends on `paid` and on `expired` alike, and a
850
+ * Lightning invoice expires in an hour while a bank transfer takes days. `null`
851
+ * means every leg ended without being paid.
852
+ *
853
+ * Stopping the wait is not revoking the invoice. Nobody can revoke one, because
854
+ * the recipient's own wallet minted it, so a payer who pays the loser afterwards
855
+ * really does pay twice and that shows up on `followTrigger` as a second
856
+ * settlement to refund.
857
+ */
858
+ async firstToSettle(ids, options) {
859
+ if (ids.length === 0) return null;
860
+ const stopLosers = new AbortController();
861
+ const signal = options?.signal ? AbortSignal.any([stopLosers.signal, options.signal]) : stopLosers.signal;
862
+ let refused = null;
863
+ try {
864
+ const winner = await new Promise((resolve2) => {
865
+ let waiting = ids.length;
866
+ const lost = () => {
867
+ waiting -= 1;
868
+ if (waiting === 0) resolve2(null);
869
+ };
870
+ for (const id of ids) {
871
+ this.waitForWatched(id, { ...options, signal }).then((watched) => watched.status === "paid" ? resolve2(watched) : lost()).catch((failure) => {
872
+ refused ??= failure;
873
+ lost();
874
+ });
875
+ }
876
+ });
877
+ if (winner === null && refused !== null) throw refused;
878
+ return winner;
879
+ } finally {
880
+ stopLosers.abort();
881
+ }
882
+ }
778
883
  /**
779
884
  * Hand over an invoice you obtained yourself so the gateway watches it without
780
885
  * being told the address or the amount. It can then only refuse everyone
@@ -899,13 +1004,13 @@ async function paymentFrom(response) {
899
1004
  async function problemFrom(response) {
900
1005
  const problem = await response.json().catch(() => ({}));
901
1006
  const document = { ...problem, status: response.status };
902
- if (problem.type === NO_WALLET_AVAILABLE) {
1007
+ if (isProblemType(problem, NO_WALLET_AVAILABLE)) {
903
1008
  return new NoWalletAvailableError(document, refusals(problem.wallets));
904
1009
  }
905
- if (problem.type === REQUEST_IN_FLIGHT) {
1010
+ if (isProblemType(problem, REQUEST_IN_FLIGHT)) {
906
1011
  return new IdempotencyConflictError(document, "request-in-flight");
907
1012
  }
908
- if (problem.type === IDEMPOTENCY_KEY_REUSED) {
1013
+ if (isProblemType(problem, IDEMPOTENCY_KEY_REUSED)) {
909
1014
  return new IdempotencyConflictError(document, "key-reused");
910
1015
  }
911
1016
  return new ProblemError(document);
@@ -922,7 +1027,7 @@ var VERSION = "v1";
922
1027
  var IV_BYTES = 12;
923
1028
  var MIN_SECRET_CHARS = 32;
924
1029
  var MAX_PLAIN_BYTES = 3e3;
925
- var INFO = new TextEncoder().encode("thunder-bridge-direct/sealed");
1030
+ var INFO = new TextEncoder().encode("thunder-bridge/sealed");
926
1031
  async function seal(secret, plaintext) {
927
1032
  const body = new TextEncoder().encode(plaintext);
928
1033
  if (body.length > MAX_PLAIN_BYTES) {
@@ -1044,18 +1149,18 @@ async function resolve(addresses, amountMsat) {
1044
1149
  }
1045
1150
  async function firstThatServes(addresses, attempt) {
1046
1151
  const deadline = AbortSignal.timeout(RESOLVE_TIMEOUT_MS);
1047
- const refusals2 = [];
1152
+ const refusals3 = [];
1048
1153
  for (const address of addresses) {
1049
1154
  if (deadline.aborted) break;
1050
1155
  try {
1051
- return { won: await attempt(address, deadline), refusals: refusals2 };
1156
+ return { won: await attempt(address, deadline), refusals: refusals3 };
1052
1157
  } catch (refusal) {
1053
1158
  if (!(refusal instanceof WalletRefused)) throw refusal;
1054
1159
  console.warn(`wallet ${address} refused, ${refusal.reason}: ${refusal.message}`);
1055
- refusals2.push({ address, reason: refusal.reason });
1160
+ refusals3.push({ address, reason: refusal.reason });
1056
1161
  }
1057
1162
  }
1058
- throw new NoWalletAvailable(refusals2);
1163
+ throw new NoWalletAvailable(refusals3);
1059
1164
  }
1060
1165
  async function probe(address, amountMsat, deadline) {
1061
1166
  const [user, domain] = splitAddress(address);
@@ -1280,7 +1385,7 @@ async function mintBlind(config, address, amountMsat) {
1280
1385
  return { bolt11: resolved.bolt11, verifyUrl: resolved.verifyUrl };
1281
1386
  }
1282
1387
  function alreadyWatched(refused) {
1283
- return refused instanceof ProblemError && refused.type === PAYMENT_ALREADY_WATCHED;
1388
+ return refused instanceof ProblemError && isProblemType(refused, PAYMENT_ALREADY_WATCHED);
1284
1389
  }
1285
1390
  function sign(secret, address, amountMsat, nonce) {
1286
1391
  return hmacHex(secret, `${address}|${amountMsat}|${nonce}`);
@@ -1295,6 +1400,355 @@ function refuse(reason) {
1295
1400
  return Response.json({ status: "ERROR", reason });
1296
1401
  }
1297
1402
 
1403
+ // src/currency.ts
1404
+ var ISO_4217_MINOR_UNITS = {
1405
+ BHD: 3,
1406
+ BIF: 0,
1407
+ CLP: 0,
1408
+ CZK: 2,
1409
+ DJF: 0,
1410
+ EUR: 2,
1411
+ GBP: 2,
1412
+ ISK: 0,
1413
+ IQD: 3,
1414
+ JOD: 3,
1415
+ JPY: 0,
1416
+ KRW: 0,
1417
+ KWD: 3,
1418
+ LYD: 3,
1419
+ OMR: 3,
1420
+ PLN: 2,
1421
+ PYG: 0,
1422
+ RWF: 0,
1423
+ TND: 3,
1424
+ UGX: 0,
1425
+ USD: 2,
1426
+ VND: 0,
1427
+ VUV: 0,
1428
+ XAF: 0,
1429
+ XOF: 0,
1430
+ XPF: 0
1431
+ };
1432
+ function minorUnitsOf(currency) {
1433
+ const digits = ISO_4217_MINOR_UNITS[currency.toUpperCase()];
1434
+ if (digits === void 0) {
1435
+ throw new Error(
1436
+ `${currency.toUpperCase()} is not a currency this knows the ISO 4217 minor unit of`
1437
+ );
1438
+ }
1439
+ return digits;
1440
+ }
1441
+ function minorScaleOf(currency) {
1442
+ return 10 ** minorUnitsOf(currency);
1443
+ }
1444
+
1445
+ // src/bank.ts
1446
+ var DEFAULT_CURRENCY = "CZK";
1447
+ var DEFAULT_LOOK_BACK_SECS = 7 * 24 * 60 * 60;
1448
+ var IBAN = /^[A-Z]{2}[0-9]{2}[0-9A-Z]{8,30}$/;
1449
+ var FORBIDDEN_IN_SPD = "*";
1450
+ async function bankTransfer(params) {
1451
+ const currency = params.currency ?? DEFAULT_CURRENCY;
1452
+ refuseUnusable(params, currency);
1453
+ const spd = shortPaymentDescriptor(params, currency);
1454
+ const subject = subjectOf(params.reference, params.amountMinor, currency);
1455
+ const polling = new URL(params.verifyUrl);
1456
+ polling.searchParams.set("ref", params.reference);
1457
+ polling.searchParams.set("minor", String(params.amountMinor));
1458
+ polling.searchParams.set("cc", currency);
1459
+ polling.searchParams.set("sig", await hmacHex(params.secret, `verify|${subject}`));
1460
+ const verifyUrl = polling.toString();
1461
+ const watched = await params.gateway.watchPayment({
1462
+ paymentHash: hashOf(await hmacHex(params.secret, `preimage|${subject}`)),
1463
+ verifyUrl,
1464
+ expiresAt: params.expiresAt,
1465
+ trigger: params.trigger,
1466
+ sealed: params.sealed
1467
+ });
1468
+ return { id: watched.id, paymentHash: watched.paymentHash, verifyUrl, spd };
1469
+ }
1470
+ function bankVerifyEndpoint(config) {
1471
+ return async (request) => {
1472
+ const asked = readQuery(new URL(request.url));
1473
+ if (asked === null) return Response.json({ settled: false }, { status: 400 });
1474
+ const subject = subjectOf(asked.reference, asked.amountMinor, asked.currency);
1475
+ const expected = await hmacHex(config.secret, `verify|${subject}`);
1476
+ if (!equalInConstantTime(asked.signature, expected)) {
1477
+ return Response.json({ settled: false }, { status: 403 });
1478
+ }
1479
+ const since = unixNow() - (config.lookBackSecs ?? DEFAULT_LOOK_BACK_SECS);
1480
+ const landed = (await config.statement(since)).some((credit) => pays(credit, asked));
1481
+ if (!landed) return Response.json({ settled: false });
1482
+ return Response.json({
1483
+ settled: true,
1484
+ preimage: await hmacHex(config.secret, `preimage|${subject}`)
1485
+ });
1486
+ };
1487
+ }
1488
+ function readQuery(url) {
1489
+ const reference = url.searchParams.get("ref");
1490
+ const minor = Number(url.searchParams.get("minor"));
1491
+ const currency = url.searchParams.get("cc");
1492
+ const signature = url.searchParams.get("sig");
1493
+ if (!reference || !currency || !signature) return null;
1494
+ if (!Number.isInteger(minor) || minor <= 0) return null;
1495
+ return { reference, amountMinor: minor, currency, signature };
1496
+ }
1497
+ function pays(credit, asked) {
1498
+ return credit.amountMinor === asked.amountMinor && credit.currency.toUpperCase() === asked.currency.toUpperCase() && credit.reference.toUpperCase().includes(asked.reference.toUpperCase());
1499
+ }
1500
+ function subjectOf(reference, amountMinor, currency) {
1501
+ return `${reference}|${amountMinor}|${currency.toUpperCase()}`;
1502
+ }
1503
+ function hashOf(preimage) {
1504
+ return bytesToHex(sha256(hexToBytes(preimage)));
1505
+ }
1506
+ function shortPaymentDescriptor(params, currency) {
1507
+ const fields = [
1508
+ `ACC:${params.iban}`,
1509
+ `AM:${major(params.amountMinor, currency)}`,
1510
+ `CC:${currency.toUpperCase()}`,
1511
+ `MSG:${params.reference}`
1512
+ ];
1513
+ if (params.variableSymbol) fields.push(`X-VS:${params.variableSymbol}`);
1514
+ return `SPD*1.0*${fields.join("*")}`;
1515
+ }
1516
+ function major(amountMinor, currency) {
1517
+ return (amountMinor / minorScaleOf(currency)).toFixed(minorUnitsOf(currency));
1518
+ }
1519
+ function refuseUnusable(params, currency) {
1520
+ if (!params.gateway.isPrivate && params.allowPublicGateway !== true) {
1521
+ throw new Error(
1522
+ "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"
1523
+ );
1524
+ }
1525
+ if (!IBAN.test(params.iban)) throw new Error(`${params.iban} is not an IBAN`);
1526
+ if (!Number.isInteger(params.amountMinor) || params.amountMinor <= 0) {
1527
+ throw new Error("amountMinor must be a whole number of minor units above zero");
1528
+ }
1529
+ if (params.reference.length === 0) {
1530
+ throw new Error("a transfer with no reference cannot be found");
1531
+ }
1532
+ if (params.reference.includes(FORBIDDEN_IN_SPD)) {
1533
+ throw new Error("a reference cannot contain an asterisk, it separates the QR fields");
1534
+ }
1535
+ if (params.variableSymbol && !/^[0-9]{1,10}$/.test(params.variableSymbol)) {
1536
+ throw new Error("a variable symbol is up to ten digits");
1537
+ }
1538
+ }
1539
+ function unixNow() {
1540
+ return Math.floor(Date.now() / 1e3);
1541
+ }
1542
+
1543
+ // src/fio.ts
1544
+ var BASE_URL = "https://fioapi.fio.cz/v1/rest";
1545
+ var MINOR_UNITS = 100;
1546
+ var MILLIS = 1e3;
1547
+ var DATE_CHARS = "2026-08-04".length;
1548
+ var DEFAULT_MIN_INTERVAL_SECS = 30;
1549
+ var TOO_SOON = 409;
1550
+ var BOOKED_AT = "column0";
1551
+ var AMOUNT = "column1";
1552
+ var CURRENCY = "column14";
1553
+ var VARIABLE_SYMBOL = "column5";
1554
+ var USER_IDENTIFICATION = "column7";
1555
+ var MESSAGE_FOR_RECIPIENT = "column16";
1556
+ var PAYER_REFERENCE = "column27";
1557
+ var WHERE_A_PAYER_WRITES = [
1558
+ MESSAGE_FOR_RECIPIENT,
1559
+ VARIABLE_SYMBOL,
1560
+ USER_IDENTIFICATION,
1561
+ PAYER_REFERENCE
1562
+ ];
1563
+ function fioStatement(config) {
1564
+ const named = typeof config.token === "string" ? [config.token] : config.token;
1565
+ if (named.length === 0) throw new Error("fioStatement needs at least one token to read with");
1566
+ const usedAt = new Map(named.map((token) => [token, Number.NEGATIVE_INFINITY]));
1567
+ const tokenWindowMs = (config.minIntervalSecs ?? DEFAULT_MIN_INTERVAL_SECS) * MILLIS;
1568
+ const paceMs = tokenWindowMs / usedAt.size;
1569
+ let lastRead = Number.NEGATIVE_INFINITY;
1570
+ let credits = [];
1571
+ return async (sinceUnix) => {
1572
+ const now = Date.now();
1573
+ if (now - lastRead < paceMs) return credits;
1574
+ const idlest = longestUnused(usedAt);
1575
+ if (now - idlest.usedAt < tokenWindowMs) return credits;
1576
+ usedAt.set(idlest.token, now);
1577
+ lastRead = now;
1578
+ const url = [
1579
+ config.baseUrl ?? BASE_URL,
1580
+ "periods",
1581
+ idlest.token,
1582
+ asDate(sinceUnix),
1583
+ asDate(Math.floor(now / MILLIS)),
1584
+ "transactions.json"
1585
+ ].join("/");
1586
+ const answer = await fetch(url, { headers: { accept: "application/json" } });
1587
+ if (answer.status === TOO_SOON) {
1588
+ throw new Error("fio refuses a second read of this token inside its 30 second window");
1589
+ }
1590
+ if (!answer.ok) throw new Error(`fio answered ${answer.status} reading the statement`);
1591
+ const read = await answer.json();
1592
+ credits = (read.accountStatement?.transactionList?.transaction ?? []).filter(isCredit).map(asCredit);
1593
+ return credits;
1594
+ };
1595
+ }
1596
+ function longestUnused(usedAt) {
1597
+ let token = "";
1598
+ let idleSince = Number.POSITIVE_INFINITY;
1599
+ for (const [candidate, at] of usedAt) {
1600
+ if (at < idleSince) {
1601
+ token = candidate;
1602
+ idleSince = at;
1603
+ }
1604
+ }
1605
+ return { token, usedAt: idleSince };
1606
+ }
1607
+ function isCredit(transaction) {
1608
+ return numberIn(transaction[AMOUNT]) > 0;
1609
+ }
1610
+ function asCredit(transaction) {
1611
+ return {
1612
+ amountMinor: Math.round(numberIn(transaction[AMOUNT]) * MINOR_UNITS),
1613
+ currency: textIn(transaction[CURRENCY]),
1614
+ reference: WHERE_A_PAYER_WRITES.map((column) => textIn(transaction[column])).filter((written) => written.length > 0).join(" "),
1615
+ bookedAt: Math.floor(numberIn(transaction[BOOKED_AT]) / MILLIS)
1616
+ };
1617
+ }
1618
+ function numberIn(cell) {
1619
+ const value = Number(cell?.value ?? Number.NaN);
1620
+ return Number.isFinite(value) ? value : 0;
1621
+ }
1622
+ function textIn(cell) {
1623
+ const value = cell?.value;
1624
+ return value === null || value === void 0 ? "" : String(value).trim();
1625
+ }
1626
+ function asDate(unix) {
1627
+ return new Date(unix * MILLIS).toISOString().slice(0, DATE_CHARS);
1628
+ }
1629
+
1630
+ // src/price.ts
1631
+ var MSAT_PER_BTC2 = 1e11;
1632
+ var BASIS_POINTS = 1e4;
1633
+ var DEFAULT_HOLD_SECS = 60;
1634
+ var DEFAULT_MIN_VENUES = 2;
1635
+ var DEFAULT_MAX_SPREAD_BPS = 200;
1636
+ var MILLIS2 = 1e3;
1637
+ function coinbase(baseUrl = "https://api.coinbase.com") {
1638
+ return async (currency) => {
1639
+ const asked = currency.toUpperCase();
1640
+ const answer = await asJson(`${baseUrl}/v2/prices/BTC-${asked}/spot`, "coinbase");
1641
+ const quoted = answer.data;
1642
+ if (quoted?.currency !== asked) {
1643
+ throw new Error(`coinbase quoted ${quoted?.currency} when asked for ${asked}`);
1644
+ }
1645
+ return asMinor(quoted.amount, "coinbase", asked);
1646
+ };
1647
+ }
1648
+ function kraken(baseUrl = "https://api.kraken.com") {
1649
+ return async (currency) => {
1650
+ const asked = currency.toUpperCase();
1651
+ const answer = await asJson(`${baseUrl}/0/public/Ticker?pair=XBT${asked}`, "kraken");
1652
+ if (answer.error?.length) throw new Error(`kraken refused XBT${asked}: ${answer.error[0]}`);
1653
+ const pairs = Object.values(answer.result ?? {});
1654
+ if (pairs.length !== 1) {
1655
+ throw new Error(`kraken answered ${pairs.length} pairs for XBT${asked}`);
1656
+ }
1657
+ return asMinor(pairs[0]?.c?.[0], "kraken", asked);
1658
+ };
1659
+ }
1660
+ function bitstamp(baseUrl = "https://www.bitstamp.net") {
1661
+ return async (currency) => {
1662
+ const pair = `btc${currency.toLowerCase()}`;
1663
+ const answer = await asJson(`${baseUrl}/api/v2/ticker/${pair}/`, "bitstamp");
1664
+ if (Array.isArray(answer)) throw new Error(`bitstamp has no ${pair} pair`);
1665
+ return asMinor(answer.last, "bitstamp", currency);
1666
+ };
1667
+ }
1668
+ function coinmate(baseUrl = "https://coinmate.io") {
1669
+ return async (currency) => {
1670
+ const pair = `BTC_${currency.toUpperCase()}`;
1671
+ const answer = await asJson(`${baseUrl}/api/ticker?currencyPair=${pair}`, "coinmate");
1672
+ if (answer.error !== false) {
1673
+ throw new Error(`coinmate refused ${pair}: ${answer.errorMessage ?? "no reason"}`);
1674
+ }
1675
+ return asMinor(answer.data?.last, "coinmate", currency);
1676
+ };
1677
+ }
1678
+ function medianOf(tickers = [coinbase(), kraken(), bitstamp(), coinmate()], options = {}) {
1679
+ const holdFor = (options.holdForSecs ?? DEFAULT_HOLD_SECS) * MILLIS2;
1680
+ const minVenues = options.minVenues ?? DEFAULT_MIN_VENUES;
1681
+ const maxSpread = options.maxSpreadBps ?? DEFAULT_MAX_SPREAD_BPS;
1682
+ const held = /* @__PURE__ */ new Map();
1683
+ return async (currency) => {
1684
+ const asked = currency.toUpperCase();
1685
+ const remembered = held.get(asked);
1686
+ if (remembered && Date.now() - remembered.at < holdFor) return remembered.price;
1687
+ const answers = await Promise.allSettled(tickers.map((ticker) => ticker(asked)));
1688
+ const quoted = answers.filter((answer) => answer.status === "fulfilled").map((answer) => answer.value).sort((one, other) => one - other);
1689
+ if (quoted.length < minVenues) {
1690
+ throw new Error(
1691
+ `${quoted.length} of ${tickers.length} venues quoted ${asked}, ${minVenues} wanted: ${refusals2(answers)}`
1692
+ );
1693
+ }
1694
+ const spread = spreadBpsOf(quoted);
1695
+ if (spread > maxSpread) {
1696
+ throw new Error(
1697
+ `venues disagree on ${asked} by ${Math.round(spread)} bps, more than the ${maxSpread} allowed: ${quoted.join(", ")}`
1698
+ );
1699
+ }
1700
+ const price = middleOf(quoted);
1701
+ held.set(asked, { at: Date.now(), price });
1702
+ return price;
1703
+ };
1704
+ }
1705
+ function msatFor(amountMinor, priceMinorPerBtc, options = {}) {
1706
+ if (!Number.isInteger(amountMinor) || amountMinor <= 0) {
1707
+ throw new Error("amountMinor must be a whole number of minor units above zero");
1708
+ }
1709
+ if (!Number.isFinite(priceMinorPerBtc) || priceMinorPerBtc <= 0) {
1710
+ throw new Error(`${priceMinorPerBtc} is not a usable price`);
1711
+ }
1712
+ const spreadBps = options.spreadBps ?? 0;
1713
+ if (!Number.isInteger(spreadBps) || spreadBps < 0) {
1714
+ throw new Error("spreadBps must be a whole number of basis points, none or more");
1715
+ }
1716
+ const wanted = BigInt(amountMinor) * BigInt(MSAT_PER_BTC2) * BigInt(BASIS_POINTS + spreadBps);
1717
+ const per = BigInt(Math.round(priceMinorPerBtc)) * BigInt(BASIS_POINTS);
1718
+ const msat = (wanted + per - 1n) / per;
1719
+ if (msat > BigInt(Number.MAX_SAFE_INTEGER)) {
1720
+ throw new Error(`${amountMinor} at ${priceMinorPerBtc} is more millisatoshi than exist`);
1721
+ }
1722
+ return Number(msat);
1723
+ }
1724
+ async function asJson(url, venue) {
1725
+ const answer = await fetch(url, { headers: { accept: "application/json" } });
1726
+ if (!answer.ok) throw new Error(`${venue} answered ${answer.status}`);
1727
+ return answer.json();
1728
+ }
1729
+ function asMinor(quoted, venue, currency) {
1730
+ const major2 = Number(quoted);
1731
+ if (!Number.isFinite(major2) || major2 <= 0) {
1732
+ throw new Error(`${venue} quoted ${String(quoted)} for ${currency.toUpperCase()}`);
1733
+ }
1734
+ return Math.round(major2 * minorScaleOf(currency));
1735
+ }
1736
+ function spreadBpsOf(sorted) {
1737
+ const lowest = sorted[0] ?? 0;
1738
+ const highest = sorted[sorted.length - 1] ?? 0;
1739
+ return lowest === 0 ? Number.POSITIVE_INFINITY : (highest - lowest) / lowest * BASIS_POINTS;
1740
+ }
1741
+ function middleOf(sorted) {
1742
+ const middle = Math.floor(sorted.length / 2);
1743
+ if (sorted.length % 2 === 1) return sorted[middle] ?? 0;
1744
+ return Math.round(((sorted[middle - 1] ?? 0) + (sorted[middle] ?? 0)) / 2);
1745
+ }
1746
+ function refusals2(answers) {
1747
+ return answers.filter((answer) => answer.status === "rejected").map(
1748
+ (answer) => answer.reason instanceof Error ? answer.reason.message : String(answer.reason)
1749
+ ).join("; ");
1750
+ }
1751
+
1298
1752
  // src/qr.ts
1299
1753
  import { encode } from "uqr";
1300
1754
  var SAFE_COLOR = /^#(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$|^rgba?\([\d.,%/\s]+\)$|^[a-z]+$/i;
@@ -1314,6 +1768,12 @@ function lnurlToSvg(endpoint, options) {
1314
1768
  function lnurlToDataUrl(endpoint, options) {
1315
1769
  return asDataUrl(lnurlToSvg(endpoint, options));
1316
1770
  }
1771
+ function spdToSvg(spd, options) {
1772
+ return svgOf(spd, options);
1773
+ }
1774
+ function spdToDataUrl(spd, options) {
1775
+ return asDataUrl(spdToSvg(spd, options));
1776
+ }
1317
1777
  function svgOf(scanned, options) {
1318
1778
  const size = options?.size ?? 256;
1319
1779
  const color = options?.color ?? "#000";
@@ -1400,19 +1860,33 @@ export {
1400
1860
  REQUEST_IN_FLIGHT,
1401
1861
  ThunderBridge,
1402
1862
  UnverifiedRecipientError,
1863
+ bankTransfer,
1864
+ bankVerifyEndpoint,
1865
+ bitstamp,
1866
+ coinbase,
1867
+ coinmate,
1403
1868
  decodeInvoice,
1869
+ fioStatement,
1404
1870
  invoiceToDataUrl,
1405
1871
  invoiceToSvg,
1872
+ isProblemType,
1406
1873
  isProvablyPaid,
1874
+ kraken,
1407
1875
  lnurlPayEndpoint,
1408
1876
  lnurlToDataUrl,
1409
1877
  lnurlToSvg,
1878
+ medianOf,
1879
+ minorScaleOf,
1880
+ minorUnitsOf,
1881
+ msatFor,
1410
1882
  parseWebhook,
1411
1883
  parseWebhookRequest,
1412
1884
  preimageMatchesHash,
1413
1885
  proveOrigin,
1414
1886
  proveSettlement,
1415
1887
  seal,
1888
+ spdToDataUrl,
1889
+ spdToSvg,
1416
1890
  toLnurl,
1417
1891
  unseal,
1418
1892
  verifyWebhookSignature