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.js
CHANGED
|
@@ -34,10 +34,15 @@ var ProblemError = class extends Error {
|
|
|
34
34
|
this.detail = problem.detail ?? null;
|
|
35
35
|
}
|
|
36
36
|
};
|
|
37
|
-
var
|
|
38
|
-
var
|
|
39
|
-
|
|
40
|
-
|
|
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
|
|
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 ||
|
|
495
|
-
return { lnAddress, amountMsat, feeMsat, minMsat, maxMsat, metadata, refusals:
|
|
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
|
|
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
|
-
|
|
601
|
+
refusals3.push({ address, reason });
|
|
597
602
|
}
|
|
598
|
-
return
|
|
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
|
|
762
|
-
|
|
763
|
-
|
|
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(
|
|
766
|
-
|
|
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
|
|
1007
|
+
if (isProblemType(problem, NO_WALLET_AVAILABLE)) {
|
|
903
1008
|
return new NoWalletAvailableError(document, refusals(problem.wallets));
|
|
904
1009
|
}
|
|
905
|
-
if (problem
|
|
1010
|
+
if (isProblemType(problem, REQUEST_IN_FLIGHT)) {
|
|
906
1011
|
return new IdempotencyConflictError(document, "request-in-flight");
|
|
907
1012
|
}
|
|
908
|
-
if (problem
|
|
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
|
|
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
|
|
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:
|
|
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
|
-
|
|
1160
|
+
refusals3.push({ address, reason: refusal.reason });
|
|
1056
1161
|
}
|
|
1057
1162
|
}
|
|
1058
|
-
throw new NoWalletAvailable(
|
|
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
|
|
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
|