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