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/README.md +199 -785
- package/dist/index.cjs +514 -26
- package/dist/index.d.cts +293 -5
- package/dist/index.d.ts +293 -5
- package/dist/index.js +500 -26
- package/openapi.yaml +307 -0
- package/package.json +3 -2
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
|
|
89
|
-
var
|
|
90
|
-
|
|
91
|
-
|
|
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
|
|
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 ||
|
|
546
|
-
return { lnAddress, amountMsat, feeMsat, minMsat, maxMsat, metadata, refusals:
|
|
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
|
|
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
|
-
|
|
666
|
+
refusals3.push({ address, reason });
|
|
648
667
|
}
|
|
649
|
-
return
|
|
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
|
|
813
|
-
|
|
814
|
-
|
|
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(
|
|
817
|
-
|
|
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
|
|
1072
|
+
if (isProblemType(problem, NO_WALLET_AVAILABLE)) {
|
|
954
1073
|
return new NoWalletAvailableError(document, refusals(problem.wallets));
|
|
955
1074
|
}
|
|
956
|
-
if (problem
|
|
1075
|
+
if (isProblemType(problem, REQUEST_IN_FLIGHT)) {
|
|
957
1076
|
return new IdempotencyConflictError(document, "request-in-flight");
|
|
958
1077
|
}
|
|
959
|
-
if (problem
|
|
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
|
|
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
|
|
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:
|
|
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
|
-
|
|
1225
|
+
refusals3.push({ address, reason: refusal.reason });
|
|
1107
1226
|
}
|
|
1108
1227
|
}
|
|
1109
|
-
throw new NoWalletAvailable(
|
|
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
|
|
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
|