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.js
CHANGED
|
@@ -34,10 +34,13 @@ var ProblemError = class extends Error {
|
|
|
34
34
|
this.detail = problem.detail ?? null;
|
|
35
35
|
}
|
|
36
36
|
};
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
var
|
|
37
|
+
function isProblemType(problem, type) {
|
|
38
|
+
return problem.type === type;
|
|
39
|
+
}
|
|
40
|
+
var NO_WALLET_AVAILABLE = "urn:problem-type:thunder-bridge:no-wallet-available";
|
|
41
|
+
var REQUEST_IN_FLIGHT = "urn:problem-type:thunder-bridge:request-in-flight";
|
|
42
|
+
var IDEMPOTENCY_KEY_REUSED = "urn:problem-type:thunder-bridge:idempotency-key-reused";
|
|
43
|
+
var PAYMENT_ALREADY_WATCHED = "urn:problem-type:thunder-bridge:payment-already-watched";
|
|
41
44
|
var IdempotencyConflictError = class extends ProblemError {
|
|
42
45
|
conflict;
|
|
43
46
|
constructor(problem, conflict) {
|
|
@@ -68,8 +71,8 @@ function hexToBytes(hex) {
|
|
|
68
71
|
}
|
|
69
72
|
return bytes;
|
|
70
73
|
}
|
|
71
|
-
function isHex(
|
|
72
|
-
return /^[0-9a-f]+$/i.test(
|
|
74
|
+
function isHex(text3) {
|
|
75
|
+
return /^[0-9a-f]+$/i.test(text3) && text3.length % 2 === 0;
|
|
73
76
|
}
|
|
74
77
|
|
|
75
78
|
// ../core/sha256.ts
|
|
@@ -163,8 +166,8 @@ function sha256(bytes) {
|
|
|
163
166
|
}
|
|
164
167
|
return asBytes(state);
|
|
165
168
|
}
|
|
166
|
-
function sha256Hex(
|
|
167
|
-
return bytesToHex(sha256(new TextEncoder().encode(
|
|
169
|
+
function sha256Hex(text3) {
|
|
170
|
+
return bytesToHex(sha256(new TextEncoder().encode(text3)));
|
|
168
171
|
}
|
|
169
172
|
function withPadding(bytes) {
|
|
170
173
|
const padded = new Uint8Array(
|
|
@@ -343,10 +346,13 @@ function sameOrigin(one, other) {
|
|
|
343
346
|
return false;
|
|
344
347
|
}
|
|
345
348
|
}
|
|
349
|
+
function publicAddress(literal) {
|
|
350
|
+
return literal.includes(":") ? ipv6IsGlobal(literal) : ipv4IsGlobal(literal);
|
|
351
|
+
}
|
|
346
352
|
function hostIsPublic(host) {
|
|
347
|
-
if (host.startsWith("[")) return
|
|
353
|
+
if (host.startsWith("[")) return publicAddress(host.slice(1, -1));
|
|
348
354
|
const name = host.replace(/\.$/, "");
|
|
349
|
-
if (/^\d+\.\d+\.\d+\.\d+$/.test(name)) return
|
|
355
|
+
if (/^\d+\.\d+\.\d+\.\d+$/.test(name)) return publicAddress(name);
|
|
350
356
|
const labels = name.split(".");
|
|
351
357
|
if (labels.length < 2 || labels.some((label) => label === "")) return false;
|
|
352
358
|
return !PRIVATE_SUFFIXES.includes(labels[labels.length - 1]);
|
|
@@ -489,10 +495,10 @@ function quoteFromWire(body) {
|
|
|
489
495
|
const minMsat = msatFrom(wire["min_amount"], 0);
|
|
490
496
|
const maxMsat = msatFrom(wire["max_amount"], 0);
|
|
491
497
|
const metadata = text(wire["metadata"]);
|
|
492
|
-
const
|
|
498
|
+
const refusals3 = refusalsFrom(wire["refusals"]);
|
|
493
499
|
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:
|
|
500
|
+
if (minMsat === null || maxMsat === null || metadata === null || refusals3 === null) return null;
|
|
501
|
+
return { lnAddress, amountMsat, feeMsat, minMsat, maxMsat, metadata, refusals: refusals3 };
|
|
496
502
|
}
|
|
497
503
|
function paymentFromWire(body) {
|
|
498
504
|
const wire = asObject(body);
|
|
@@ -587,15 +593,15 @@ function msatFrom(value, least) {
|
|
|
587
593
|
}
|
|
588
594
|
function refusalsFrom(value) {
|
|
589
595
|
if (!Array.isArray(value)) return null;
|
|
590
|
-
const
|
|
596
|
+
const refusals3 = [];
|
|
591
597
|
for (const entry of value) {
|
|
592
598
|
const failure = asObject(entry);
|
|
593
599
|
const address = failure === null ? null : text(failure["address"]);
|
|
594
600
|
const reason = failure?.["reason"];
|
|
595
601
|
if (address === null || typeof reason !== "string" || !REASONS.has(reason)) return null;
|
|
596
|
-
|
|
602
|
+
refusals3.push({ address, reason });
|
|
597
603
|
}
|
|
598
|
-
return
|
|
604
|
+
return refusals3;
|
|
599
605
|
}
|
|
600
606
|
function secondsFrom(value) {
|
|
601
607
|
if (typeof value !== "string") return null;
|
|
@@ -611,6 +617,16 @@ var TERMINAL = /* @__PURE__ */ new Set(["paid", "expired"]);
|
|
|
611
617
|
var RECONNECT_DELAY_MS = 3e3;
|
|
612
618
|
var RECONNECT_CAP_MS = 3e4;
|
|
613
619
|
var UNANSWERED_ATTEMPTS = 5;
|
|
620
|
+
var TRIGGER_MIN_CHARS = 16;
|
|
621
|
+
var STRANGER_PROBE = "is-this-gateway-yours";
|
|
622
|
+
function unguessable(trigger) {
|
|
623
|
+
if (trigger.length < TRIGGER_MIN_CHARS) {
|
|
624
|
+
throw new Error(
|
|
625
|
+
`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`
|
|
626
|
+
);
|
|
627
|
+
}
|
|
628
|
+
return trigger;
|
|
629
|
+
}
|
|
614
630
|
function backoffMs(firstDelay, attempt) {
|
|
615
631
|
const grown = Math.min(firstDelay * 2 ** (attempt - 1), RECONNECT_CAP_MS);
|
|
616
632
|
return Math.round(grown * (0.5 + Math.random() / 2));
|
|
@@ -619,11 +635,35 @@ var ThunderBridge = class {
|
|
|
619
635
|
baseUrl;
|
|
620
636
|
verify;
|
|
621
637
|
token;
|
|
638
|
+
strangers = null;
|
|
622
639
|
constructor(baseUrl, options) {
|
|
623
640
|
this.baseUrl = baseUrl.replace(/\/+$/, "");
|
|
624
641
|
this.verify = options?.verify ?? true;
|
|
625
642
|
this.token = options?.token ?? null;
|
|
626
643
|
}
|
|
644
|
+
/**
|
|
645
|
+
* Whether a token was given, which is what makes an instance yours: a gateway
|
|
646
|
+
* started with `GATEWAY_TOKEN` answers nobody else, so anything you hand it
|
|
647
|
+
* stays between you and it
|
|
648
|
+
*/
|
|
649
|
+
get isPrivate() {
|
|
650
|
+
return this.token !== null;
|
|
651
|
+
}
|
|
652
|
+
/**
|
|
653
|
+
* Whether the gateway turns away a caller carrying no token, asked by making
|
|
654
|
+
* one unauthenticated read it would have to refuse. `isPrivate` answers only
|
|
655
|
+
* whether you configured a token, which is your side of the arrangement and
|
|
656
|
+
* says nothing about the gateway's, so a made-up token against a public
|
|
657
|
+
* instance reads as private and is not. Asked once and remembered, because an
|
|
658
|
+
* instance does not change its mind. Anything other than a refusal counts as
|
|
659
|
+
* open, so an unreachable gateway fails closed
|
|
660
|
+
*/
|
|
661
|
+
async refusesStrangers() {
|
|
662
|
+
this.strangers ??= fetch(`${this.baseUrl}/incoming-payments/${STRANGER_PROBE}`, {
|
|
663
|
+
headers: { accept: "application/json" }
|
|
664
|
+
}).then((answer) => answer.status === 401).catch(() => false);
|
|
665
|
+
return await this.strangers;
|
|
666
|
+
}
|
|
627
667
|
/**
|
|
628
668
|
* Ask the gateway for an invoice payable to the first address on your list
|
|
629
669
|
* that can issue a provable one, throws `NoWalletAvailableError` when none can
|
|
@@ -635,7 +675,10 @@ var ThunderBridge = class {
|
|
|
635
675
|
const response = await fetch(`${this.baseUrl}/incoming-payments`, {
|
|
636
676
|
method: "POST",
|
|
637
677
|
headers,
|
|
638
|
-
body: createRequestBody(
|
|
678
|
+
body: createRequestBody(
|
|
679
|
+
params,
|
|
680
|
+
options?.trigger ? sha256Hex(unguessable(options.trigger)) : null
|
|
681
|
+
)
|
|
639
682
|
});
|
|
640
683
|
if (!response.ok) throw await problemFrom(response);
|
|
641
684
|
const payment = await paymentFrom(response);
|
|
@@ -674,6 +717,26 @@ var ThunderBridge = class {
|
|
|
674
717
|
if (!response.ok) throw await problemFrom(response);
|
|
675
718
|
return this.checked(await paymentFrom(response));
|
|
676
719
|
}
|
|
720
|
+
/**
|
|
721
|
+
* Read back a payment the gateway is only watching, null when it has never
|
|
722
|
+
* heard of it. A watched payment carries no address, amount or invoice, so
|
|
723
|
+
* `getPayment` refuses it and this reads the shape both rails share
|
|
724
|
+
*/
|
|
725
|
+
async getWatched(id) {
|
|
726
|
+
const response = await fetch(`${this.baseUrl}/incoming-payments/${encodeURIComponent(id)}`, {
|
|
727
|
+
headers: this.reading()
|
|
728
|
+
});
|
|
729
|
+
if (response.status === 404) return null;
|
|
730
|
+
if (!response.ok) throw await problemFrom(response);
|
|
731
|
+
const watched = triggerEventFromWire(await response.json().catch(() => null));
|
|
732
|
+
if (watched === null) {
|
|
733
|
+
throw new ProblemError({
|
|
734
|
+
status: response.status,
|
|
735
|
+
title: "The gateway answered with something that is not a payment"
|
|
736
|
+
});
|
|
737
|
+
}
|
|
738
|
+
return this.proven(watched);
|
|
739
|
+
}
|
|
677
740
|
/**
|
|
678
741
|
* List what this gateway is watching, newest first. Only a gateway started
|
|
679
742
|
* with `GATEWAY_TOKEN` serves this, because on a shared one it would hand
|
|
@@ -705,7 +768,36 @@ var ThunderBridge = class {
|
|
|
705
768
|
* one that has answered is followed until its own expiry, so the wait always
|
|
706
769
|
* ends by itself
|
|
707
770
|
*/
|
|
708
|
-
waitForPayment(id, options) {
|
|
771
|
+
async waitForPayment(id, options) {
|
|
772
|
+
const payment = paymentFromWire(await this.followed(id, options));
|
|
773
|
+
if (payment === null) {
|
|
774
|
+
throw new ProblemError({
|
|
775
|
+
status: 200,
|
|
776
|
+
title: `payment ${id} is watched rather than minted here, read it with waitForWatched`
|
|
777
|
+
});
|
|
778
|
+
}
|
|
779
|
+
return this.checked(payment);
|
|
780
|
+
}
|
|
781
|
+
/**
|
|
782
|
+
* Follow a payment the gateway is only watching, one it did not mint, until it
|
|
783
|
+
* is paid or expired.
|
|
784
|
+
*
|
|
785
|
+
* A watched payment carries no address, no amount and no invoice, because the
|
|
786
|
+
* gateway was told none of them, so it reads back as the shape a trigger
|
|
787
|
+
* streams rather than as a `Payment`. That is every bank transfer, and every
|
|
788
|
+
* Lightning invoice registered with `watchPayment` instead of `createPayment`.
|
|
789
|
+
*/
|
|
790
|
+
async waitForWatched(id, options) {
|
|
791
|
+
const watched = triggerEventFromWire(await this.followed(id, options));
|
|
792
|
+
if (watched === null) {
|
|
793
|
+
throw new ProblemError({
|
|
794
|
+
status: 200,
|
|
795
|
+
title: "The gateway answered with something that is not a payment"
|
|
796
|
+
});
|
|
797
|
+
}
|
|
798
|
+
return this.proven(watched);
|
|
799
|
+
}
|
|
800
|
+
followed(id, options) {
|
|
709
801
|
const base = this.baseUrl.replace(/^http/, "ws");
|
|
710
802
|
const direct = `${base}/ws/incoming-payments/${encodeURIComponent(id)}`;
|
|
711
803
|
return new Promise((resolve2, reject) => {
|
|
@@ -758,13 +850,13 @@ var ThunderBridge = class {
|
|
|
758
850
|
socket = opened;
|
|
759
851
|
opened.onmessage = (event) => {
|
|
760
852
|
try {
|
|
761
|
-
const
|
|
762
|
-
|
|
763
|
-
|
|
853
|
+
const frame = JSON.parse(String(event.data));
|
|
854
|
+
const watched = triggerEventFromWire(frame);
|
|
855
|
+
if (watched === null) return;
|
|
856
|
+
expiresAt = watched.expiresAt;
|
|
764
857
|
attempt = 1;
|
|
765
|
-
if (!TERMINAL.has(
|
|
766
|
-
|
|
767
|
-
settle(() => resolve2(checked));
|
|
858
|
+
if (!TERMINAL.has(watched.status)) return;
|
|
859
|
+
settle(() => resolve2(frame));
|
|
768
860
|
} catch (refused) {
|
|
769
861
|
settle(() => reject(refused));
|
|
770
862
|
}
|
|
@@ -775,6 +867,49 @@ var ThunderBridge = class {
|
|
|
775
867
|
void connect();
|
|
776
868
|
});
|
|
777
869
|
}
|
|
870
|
+
/**
|
|
871
|
+
* Wait on several payments and keep the first one that is really paid, then stop
|
|
872
|
+
* waiting on the losers, which closes their sockets.
|
|
873
|
+
*
|
|
874
|
+
* This is how one order offers two rails. A Lightning invoice and a bank
|
|
875
|
+
* transfer for the same thing are two payments here, and the payer picks one, so
|
|
876
|
+
* what you want is the one that arrives and nothing further from the other.
|
|
877
|
+
*
|
|
878
|
+
* A leg that expires is a loser, not a winner, which is the whole reason this is
|
|
879
|
+
* not a race: `waitForPayment` ends on `paid` and on `expired` alike, and a
|
|
880
|
+
* Lightning invoice expires in an hour while a bank transfer takes days. `null`
|
|
881
|
+
* means every leg ended without being paid.
|
|
882
|
+
*
|
|
883
|
+
* Stopping the wait is not revoking the invoice. Nobody can revoke one, because
|
|
884
|
+
* the recipient's own wallet minted it, so a payer who pays the loser afterwards
|
|
885
|
+
* really does pay twice and that shows up on `followTrigger` as a second
|
|
886
|
+
* settlement to refund.
|
|
887
|
+
*/
|
|
888
|
+
async firstToSettle(ids, options) {
|
|
889
|
+
if (ids.length === 0) return null;
|
|
890
|
+
const stopLosers = new AbortController();
|
|
891
|
+
const signal = options?.signal ? AbortSignal.any([stopLosers.signal, options.signal]) : stopLosers.signal;
|
|
892
|
+
let refused = null;
|
|
893
|
+
try {
|
|
894
|
+
const winner = await new Promise((resolve2) => {
|
|
895
|
+
let waiting = ids.length;
|
|
896
|
+
const lost = () => {
|
|
897
|
+
waiting -= 1;
|
|
898
|
+
if (waiting === 0) resolve2(null);
|
|
899
|
+
};
|
|
900
|
+
for (const id of ids) {
|
|
901
|
+
this.waitForWatched(id, { ...options, signal }).then((watched) => watched.status === "paid" ? resolve2(watched) : lost()).catch((failure) => {
|
|
902
|
+
refused ??= failure;
|
|
903
|
+
lost();
|
|
904
|
+
});
|
|
905
|
+
}
|
|
906
|
+
});
|
|
907
|
+
if (winner === null && refused !== null) throw refused;
|
|
908
|
+
return winner;
|
|
909
|
+
} finally {
|
|
910
|
+
stopLosers.abort();
|
|
911
|
+
}
|
|
912
|
+
}
|
|
778
913
|
/**
|
|
779
914
|
* Hand over an invoice you obtained yourself so the gateway watches it without
|
|
780
915
|
* being told the address or the amount. It can then only refuse everyone
|
|
@@ -785,7 +920,10 @@ var ThunderBridge = class {
|
|
|
785
920
|
const response = await fetch(`${this.baseUrl}/watched-payments`, {
|
|
786
921
|
method: "POST",
|
|
787
922
|
headers: this.sending(),
|
|
788
|
-
body: watchRequestBody(
|
|
923
|
+
body: watchRequestBody(
|
|
924
|
+
params,
|
|
925
|
+
params.trigger ? sha256Hex(unguessable(params.trigger)) : null
|
|
926
|
+
)
|
|
789
927
|
});
|
|
790
928
|
if (!response.ok) throw await problemFrom(response);
|
|
791
929
|
const watched = triggerEventFromWire(await response.json().catch(() => null));
|
|
@@ -804,7 +942,7 @@ var ThunderBridge = class {
|
|
|
804
942
|
*/
|
|
805
943
|
followTrigger(secret, options) {
|
|
806
944
|
const base = this.baseUrl.replace(/^http/, "ws");
|
|
807
|
-
const direct = `${base}/ws/triggers/${encodeURIComponent(secret)}`;
|
|
945
|
+
const direct = `${base}/ws/triggers/${encodeURIComponent(unguessable(secret))}`;
|
|
808
946
|
const firstDelay = options.reconnectDelayMs ?? RECONNECT_DELAY_MS;
|
|
809
947
|
let socket = null;
|
|
810
948
|
let retry;
|
|
@@ -899,13 +1037,13 @@ async function paymentFrom(response) {
|
|
|
899
1037
|
async function problemFrom(response) {
|
|
900
1038
|
const problem = await response.json().catch(() => ({}));
|
|
901
1039
|
const document = { ...problem, status: response.status };
|
|
902
|
-
if (problem
|
|
1040
|
+
if (isProblemType(problem, NO_WALLET_AVAILABLE)) {
|
|
903
1041
|
return new NoWalletAvailableError(document, refusals(problem.wallets));
|
|
904
1042
|
}
|
|
905
|
-
if (problem
|
|
1043
|
+
if (isProblemType(problem, REQUEST_IN_FLIGHT)) {
|
|
906
1044
|
return new IdempotencyConflictError(document, "request-in-flight");
|
|
907
1045
|
}
|
|
908
|
-
if (problem
|
|
1046
|
+
if (isProblemType(problem, IDEMPOTENCY_KEY_REUSED)) {
|
|
909
1047
|
return new IdempotencyConflictError(document, "key-reused");
|
|
910
1048
|
}
|
|
911
1049
|
return new ProblemError(document);
|
|
@@ -922,7 +1060,7 @@ var VERSION = "v1";
|
|
|
922
1060
|
var IV_BYTES = 12;
|
|
923
1061
|
var MIN_SECRET_CHARS = 32;
|
|
924
1062
|
var MAX_PLAIN_BYTES = 3e3;
|
|
925
|
-
var INFO = new TextEncoder().encode("thunder-bridge
|
|
1063
|
+
var INFO = new TextEncoder().encode("thunder-bridge/sealed");
|
|
926
1064
|
async function seal(secret, plaintext) {
|
|
927
1065
|
const body = new TextEncoder().encode(plaintext);
|
|
928
1066
|
if (body.length > MAX_PLAIN_BYTES) {
|
|
@@ -976,10 +1114,10 @@ function toBase64Url(bytes) {
|
|
|
976
1114
|
for (const byte of bytes) binary += String.fromCharCode(byte);
|
|
977
1115
|
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
978
1116
|
}
|
|
979
|
-
function fromBase64Url(
|
|
980
|
-
if (
|
|
1117
|
+
function fromBase64Url(text3) {
|
|
1118
|
+
if (text3.length === 0 || !/^[A-Za-z0-9_-]+$/.test(text3)) return null;
|
|
981
1119
|
try {
|
|
982
|
-
const binary = atob(
|
|
1120
|
+
const binary = atob(text3.replace(/-/g, "+").replace(/_/g, "/"));
|
|
983
1121
|
const bytes = new Uint8Array(binary.length);
|
|
984
1122
|
for (let at = 0; at < bytes.length; at++) bytes[at] = binary.charCodeAt(at);
|
|
985
1123
|
return bytes;
|
|
@@ -1011,6 +1149,87 @@ function asBuffer(bytes) {
|
|
|
1011
1149
|
return bytes instanceof Uint8Array && bytes.buffer instanceof ArrayBuffer ? bytes : new Uint8Array(bytes);
|
|
1012
1150
|
}
|
|
1013
1151
|
|
|
1152
|
+
// ../core/outbound.ts
|
|
1153
|
+
import { lookup } from "dns/promises";
|
|
1154
|
+
import { setTimeout as sleep } from "timers/promises";
|
|
1155
|
+
var HTTP_TIMEOUT_MS2 = 15e3;
|
|
1156
|
+
var LOOKUP_TIMEOUT_MS = 5e3;
|
|
1157
|
+
var HOPS_FOLLOWED = 2;
|
|
1158
|
+
var REDIRECTS = [301, 302, 303, 307, 308];
|
|
1159
|
+
var KEEPS_THE_METHOD = [307, 308];
|
|
1160
|
+
var CREDENTIALS = ["authorization", "cookie", "proxy-authorization"];
|
|
1161
|
+
var BODY_LIMIT_BYTES = 262144;
|
|
1162
|
+
async function ask(url, sent = {}) {
|
|
1163
|
+
const capped = AbortSignal.timeout(HTTP_TIMEOUT_MS2);
|
|
1164
|
+
const signal = sent.deadline ? AbortSignal.any([sent.deadline, capped]) : capped;
|
|
1165
|
+
let target = url;
|
|
1166
|
+
let carried = sent;
|
|
1167
|
+
for (let hop = 0; hop <= HOPS_FOLLOWED; hop += 1) {
|
|
1168
|
+
await refuseUnlessPublic(target);
|
|
1169
|
+
const response = await fetch(target, {
|
|
1170
|
+
method: carried.method ?? "GET",
|
|
1171
|
+
headers: carried.headers ?? {},
|
|
1172
|
+
body: carried.body,
|
|
1173
|
+
redirect: "manual",
|
|
1174
|
+
signal
|
|
1175
|
+
});
|
|
1176
|
+
if (!REDIRECTS.includes(response.status)) return await answerOf(response);
|
|
1177
|
+
const location = response.headers.get("location");
|
|
1178
|
+
if (location === null) {
|
|
1179
|
+
throw new Error(`${target} answered ${response.status} without saying where to`);
|
|
1180
|
+
}
|
|
1181
|
+
await response.body?.cancel();
|
|
1182
|
+
const next = new URL(location, target).toString();
|
|
1183
|
+
carried = KEEPS_THE_METHOD.includes(response.status) ? carried : withoutBody(carried);
|
|
1184
|
+
carried = sameOrigin(target, next) ? carried : withoutCredentials(carried);
|
|
1185
|
+
target = next;
|
|
1186
|
+
}
|
|
1187
|
+
throw new Error(`${url} redirected more than ${HOPS_FOLLOWED} times`);
|
|
1188
|
+
}
|
|
1189
|
+
async function resolvesNothingPrivate(url) {
|
|
1190
|
+
const host = new URL(url).hostname.replace(/^\[|]$/g, "");
|
|
1191
|
+
const found = await Promise.race([
|
|
1192
|
+
lookup(host, { all: true, verbatim: true }).catch(() => []),
|
|
1193
|
+
sleep(LOOKUP_TIMEOUT_MS, null, { ref: false })
|
|
1194
|
+
]);
|
|
1195
|
+
return found !== null && found.every((one) => publicAddress(one.address));
|
|
1196
|
+
}
|
|
1197
|
+
async function refuseUnlessPublic(url) {
|
|
1198
|
+
if (!publicHttps(url)) throw new Error(`${url} is not a public https URL`);
|
|
1199
|
+
if (!await resolvesNothingPrivate(url)) throw new Error(`${url} resolves to an address we do not reach`);
|
|
1200
|
+
}
|
|
1201
|
+
async function answerOf(response) {
|
|
1202
|
+
const { status, ok } = response;
|
|
1203
|
+
const reader = response.body?.getReader();
|
|
1204
|
+
if (!reader) return { status, ok, body: "", truncated: false };
|
|
1205
|
+
const read = [];
|
|
1206
|
+
let bytes = 0;
|
|
1207
|
+
while (bytes <= BODY_LIMIT_BYTES) {
|
|
1208
|
+
const { done, value } = await reader.read();
|
|
1209
|
+
if (done) return { status, ok, body: text2(read), truncated: false };
|
|
1210
|
+
read.push(value);
|
|
1211
|
+
bytes += value.length;
|
|
1212
|
+
}
|
|
1213
|
+
await reader.cancel();
|
|
1214
|
+
return { status, ok, body: text2(read), truncated: true };
|
|
1215
|
+
}
|
|
1216
|
+
function withoutBody(sent) {
|
|
1217
|
+
const headers = { ...sent.headers };
|
|
1218
|
+
delete headers["content-type"];
|
|
1219
|
+
return { ...sent, method: "GET", headers, body: void 0 };
|
|
1220
|
+
}
|
|
1221
|
+
function withoutCredentials(sent) {
|
|
1222
|
+
const headers = { ...sent.headers };
|
|
1223
|
+
for (const named of CREDENTIALS) delete headers[named];
|
|
1224
|
+
return { ...sent, headers };
|
|
1225
|
+
}
|
|
1226
|
+
function text2(chunks) {
|
|
1227
|
+
const decoder = new TextDecoder();
|
|
1228
|
+
let decoded = "";
|
|
1229
|
+
for (const chunk of chunks) decoded += decoder.decode(chunk, { stream: true });
|
|
1230
|
+
return decoded + decoder.decode();
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1014
1233
|
// ../core/refusal.ts
|
|
1015
1234
|
var WalletRefused = class extends Error {
|
|
1016
1235
|
reason;
|
|
@@ -1029,7 +1248,6 @@ var NoWalletAvailable = class extends Error {
|
|
|
1029
1248
|
|
|
1030
1249
|
// ../core/lnurl.ts
|
|
1031
1250
|
var VERIFY_WITHOUT_PREIMAGE = ["zeuspay.com", "zeusnuts.com", "ecash.love"];
|
|
1032
|
-
var HTTP_TIMEOUT_MS2 = 15e3;
|
|
1033
1251
|
var BECH32_CHARSET2 = "qpzry9x8gf2tvdw0s3jn54khce6mua7l";
|
|
1034
1252
|
var BECH32_GENERATOR = [996825010, 642813549, 513874426, 1027748829, 705979059];
|
|
1035
1253
|
var CHECKSUM_SLOTS = [0, 1, 2, 3, 4, 5];
|
|
@@ -1044,18 +1262,18 @@ async function resolve(addresses, amountMsat) {
|
|
|
1044
1262
|
}
|
|
1045
1263
|
async function firstThatServes(addresses, attempt) {
|
|
1046
1264
|
const deadline = AbortSignal.timeout(RESOLVE_TIMEOUT_MS);
|
|
1047
|
-
const
|
|
1265
|
+
const refusals3 = [];
|
|
1048
1266
|
for (const address of addresses) {
|
|
1049
1267
|
if (deadline.aborted) break;
|
|
1050
1268
|
try {
|
|
1051
|
-
return { won: await attempt(address, deadline), refusals:
|
|
1269
|
+
return { won: await attempt(address, deadline), refusals: refusals3 };
|
|
1052
1270
|
} catch (refusal) {
|
|
1053
1271
|
if (!(refusal instanceof WalletRefused)) throw refusal;
|
|
1054
1272
|
console.warn(`wallet ${address} refused, ${refusal.reason}: ${refusal.message}`);
|
|
1055
|
-
|
|
1273
|
+
refusals3.push({ address, reason: refusal.reason });
|
|
1056
1274
|
}
|
|
1057
1275
|
}
|
|
1058
|
-
throw new NoWalletAvailable(
|
|
1276
|
+
throw new NoWalletAvailable(refusals3);
|
|
1059
1277
|
}
|
|
1060
1278
|
async function probe(address, amountMsat, deadline) {
|
|
1061
1279
|
const [user, domain] = splitAddress(address);
|
|
@@ -1194,14 +1412,12 @@ function splitAddress(address) {
|
|
|
1194
1412
|
return [user, domain];
|
|
1195
1413
|
}
|
|
1196
1414
|
async function fetchJson(url, deadline) {
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
if (!response.ok) throw new Error(`${url} answered ${response.status}`);
|
|
1204
|
-
return await response.json();
|
|
1415
|
+
const answer = await ask(url, { headers: { accept: "application/json" }, deadline });
|
|
1416
|
+
if (!answer.ok) throw new Error(`${url} answered ${answer.status}`);
|
|
1417
|
+
if (answer.truncated) {
|
|
1418
|
+
throw new Error(`${url} answered with more than the ${BODY_LIMIT_BYTES} bytes we read`);
|
|
1419
|
+
}
|
|
1420
|
+
return JSON.parse(answer.body);
|
|
1205
1421
|
}
|
|
1206
1422
|
|
|
1207
1423
|
// src/trigger.ts
|
|
@@ -1280,7 +1496,7 @@ async function mintBlind(config, address, amountMsat) {
|
|
|
1280
1496
|
return { bolt11: resolved.bolt11, verifyUrl: resolved.verifyUrl };
|
|
1281
1497
|
}
|
|
1282
1498
|
function alreadyWatched(refused) {
|
|
1283
|
-
return refused instanceof ProblemError && refused
|
|
1499
|
+
return refused instanceof ProblemError && isProblemType(refused, PAYMENT_ALREADY_WATCHED);
|
|
1284
1500
|
}
|
|
1285
1501
|
function sign(secret, address, amountMsat, nonce) {
|
|
1286
1502
|
return hmacHex(secret, `${address}|${amountMsat}|${nonce}`);
|
|
@@ -1295,6 +1511,249 @@ function refuse(reason) {
|
|
|
1295
1511
|
return Response.json({ status: "ERROR", reason });
|
|
1296
1512
|
}
|
|
1297
1513
|
|
|
1514
|
+
// src/currency.ts
|
|
1515
|
+
var ISO_4217_MINOR_UNITS = {
|
|
1516
|
+
BHD: 3,
|
|
1517
|
+
BIF: 0,
|
|
1518
|
+
CLP: 0,
|
|
1519
|
+
CZK: 2,
|
|
1520
|
+
DJF: 0,
|
|
1521
|
+
EUR: 2,
|
|
1522
|
+
GBP: 2,
|
|
1523
|
+
ISK: 0,
|
|
1524
|
+
IQD: 3,
|
|
1525
|
+
JOD: 3,
|
|
1526
|
+
JPY: 0,
|
|
1527
|
+
KRW: 0,
|
|
1528
|
+
KWD: 3,
|
|
1529
|
+
LYD: 3,
|
|
1530
|
+
OMR: 3,
|
|
1531
|
+
PLN: 2,
|
|
1532
|
+
PYG: 0,
|
|
1533
|
+
RWF: 0,
|
|
1534
|
+
TND: 3,
|
|
1535
|
+
UGX: 0,
|
|
1536
|
+
USD: 2,
|
|
1537
|
+
VND: 0,
|
|
1538
|
+
VUV: 0,
|
|
1539
|
+
XAF: 0,
|
|
1540
|
+
XOF: 0,
|
|
1541
|
+
XPF: 0
|
|
1542
|
+
};
|
|
1543
|
+
function minorUnitsOf(currency) {
|
|
1544
|
+
const digits = ISO_4217_MINOR_UNITS[currency.toUpperCase()];
|
|
1545
|
+
if (digits === void 0) {
|
|
1546
|
+
throw new Error(
|
|
1547
|
+
`${currency.toUpperCase()} is not a currency this knows the ISO 4217 minor unit of`
|
|
1548
|
+
);
|
|
1549
|
+
}
|
|
1550
|
+
return digits;
|
|
1551
|
+
}
|
|
1552
|
+
function minorScaleOf(currency) {
|
|
1553
|
+
return 10 ** minorUnitsOf(currency);
|
|
1554
|
+
}
|
|
1555
|
+
|
|
1556
|
+
// src/bank.ts
|
|
1557
|
+
var DEFAULT_CURRENCY = "CZK";
|
|
1558
|
+
var DEFAULT_LOOK_BACK_SECS = 7 * 24 * 60 * 60;
|
|
1559
|
+
var IBAN = /^[A-Z]{2}[0-9]{2}[0-9A-Z]{8,30}$/;
|
|
1560
|
+
var FORBIDDEN_IN_SPD = "*";
|
|
1561
|
+
async function bankTransfer(params) {
|
|
1562
|
+
const currency = params.currency ?? DEFAULT_CURRENCY;
|
|
1563
|
+
refuseUnusable(params, currency);
|
|
1564
|
+
await refuseAnOpenGateway(params);
|
|
1565
|
+
const spd = shortPaymentDescriptor(params, currency);
|
|
1566
|
+
const subject = subjectOf(params.reference, params.amountMinor, currency);
|
|
1567
|
+
const polling = new URL(params.verifyUrl);
|
|
1568
|
+
polling.searchParams.set("ref", params.reference);
|
|
1569
|
+
polling.searchParams.set("minor", String(params.amountMinor));
|
|
1570
|
+
polling.searchParams.set("cc", currency);
|
|
1571
|
+
polling.searchParams.set("sig", await hmacHex(params.secret, `verify|${subject}`));
|
|
1572
|
+
const verifyUrl = polling.toString();
|
|
1573
|
+
const watched = await params.gateway.watchPayment({
|
|
1574
|
+
paymentHash: hashOf(await hmacHex(params.secret, `preimage|${subject}`)),
|
|
1575
|
+
verifyUrl,
|
|
1576
|
+
expiresAt: params.expiresAt,
|
|
1577
|
+
trigger: params.trigger,
|
|
1578
|
+
sealed: params.sealed
|
|
1579
|
+
});
|
|
1580
|
+
return { id: watched.id, paymentHash: watched.paymentHash, verifyUrl, spd };
|
|
1581
|
+
}
|
|
1582
|
+
function bankVerifyEndpoint(config) {
|
|
1583
|
+
return async (request) => {
|
|
1584
|
+
const asked = readQuery(new URL(request.url));
|
|
1585
|
+
if (asked === null) return Response.json({ settled: false }, { status: 400 });
|
|
1586
|
+
const subject = subjectOf(asked.reference, asked.amountMinor, asked.currency);
|
|
1587
|
+
const expected = await hmacHex(config.secret, `verify|${subject}`);
|
|
1588
|
+
if (!equalInConstantTime(asked.signature, expected)) {
|
|
1589
|
+
return Response.json({ settled: false }, { status: 403 });
|
|
1590
|
+
}
|
|
1591
|
+
const since = unixNow() - (config.lookBackSecs ?? DEFAULT_LOOK_BACK_SECS);
|
|
1592
|
+
const landed = (await config.statement(since)).some((credit) => pays(credit, asked));
|
|
1593
|
+
if (!landed) return Response.json({ settled: false });
|
|
1594
|
+
return Response.json({
|
|
1595
|
+
settled: true,
|
|
1596
|
+
preimage: await hmacHex(config.secret, `preimage|${subject}`)
|
|
1597
|
+
});
|
|
1598
|
+
};
|
|
1599
|
+
}
|
|
1600
|
+
function readQuery(url) {
|
|
1601
|
+
const reference = url.searchParams.get("ref");
|
|
1602
|
+
const minor = Number(url.searchParams.get("minor"));
|
|
1603
|
+
const currency = url.searchParams.get("cc");
|
|
1604
|
+
const signature = url.searchParams.get("sig");
|
|
1605
|
+
if (!reference || !currency || !signature) return null;
|
|
1606
|
+
if (!Number.isInteger(minor) || minor <= 0) return null;
|
|
1607
|
+
return { reference, amountMinor: minor, currency, signature };
|
|
1608
|
+
}
|
|
1609
|
+
function pays(credit, asked) {
|
|
1610
|
+
return credit.amountMinor === asked.amountMinor && credit.currency.toUpperCase() === asked.currency.toUpperCase() && credit.reference.toUpperCase().includes(asked.reference.toUpperCase());
|
|
1611
|
+
}
|
|
1612
|
+
function subjectOf(reference, amountMinor, currency) {
|
|
1613
|
+
return `${reference}|${amountMinor}|${currency.toUpperCase()}`;
|
|
1614
|
+
}
|
|
1615
|
+
function hashOf(preimage) {
|
|
1616
|
+
return bytesToHex(sha256(hexToBytes(preimage)));
|
|
1617
|
+
}
|
|
1618
|
+
function shortPaymentDescriptor(params, currency) {
|
|
1619
|
+
const fields = [
|
|
1620
|
+
`ACC:${params.iban}`,
|
|
1621
|
+
`AM:${major(params.amountMinor, currency)}`,
|
|
1622
|
+
`CC:${currency.toUpperCase()}`,
|
|
1623
|
+
`MSG:${params.reference}`
|
|
1624
|
+
];
|
|
1625
|
+
if (params.variableSymbol) fields.push(`X-VS:${params.variableSymbol}`);
|
|
1626
|
+
return `SPD*1.0*${fields.join("*")}`;
|
|
1627
|
+
}
|
|
1628
|
+
function major(amountMinor, currency) {
|
|
1629
|
+
return (amountMinor / minorScaleOf(currency)).toFixed(minorUnitsOf(currency));
|
|
1630
|
+
}
|
|
1631
|
+
async function refuseAnOpenGateway(params) {
|
|
1632
|
+
if (params.allowPublicGateway === true) return;
|
|
1633
|
+
if (await params.gateway.refusesStrangers()) return;
|
|
1634
|
+
throw new Error(
|
|
1635
|
+
"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"
|
|
1636
|
+
);
|
|
1637
|
+
}
|
|
1638
|
+
function refuseUnusable(params, currency) {
|
|
1639
|
+
if (!IBAN.test(params.iban)) throw new Error(`${params.iban} is not an IBAN`);
|
|
1640
|
+
if (!Number.isInteger(params.amountMinor) || params.amountMinor <= 0) {
|
|
1641
|
+
throw new Error("amountMinor must be a whole number of minor units above zero");
|
|
1642
|
+
}
|
|
1643
|
+
if (params.reference.length === 0) {
|
|
1644
|
+
throw new Error("a transfer with no reference cannot be found");
|
|
1645
|
+
}
|
|
1646
|
+
if (params.reference.includes(FORBIDDEN_IN_SPD)) {
|
|
1647
|
+
throw new Error("a reference cannot contain an asterisk, it separates the QR fields");
|
|
1648
|
+
}
|
|
1649
|
+
if (params.variableSymbol && !/^[0-9]{1,10}$/.test(params.variableSymbol)) {
|
|
1650
|
+
throw new Error("a variable symbol is up to ten digits");
|
|
1651
|
+
}
|
|
1652
|
+
}
|
|
1653
|
+
function unixNow() {
|
|
1654
|
+
return Math.floor(Date.now() / 1e3);
|
|
1655
|
+
}
|
|
1656
|
+
|
|
1657
|
+
// src/fio.ts
|
|
1658
|
+
var BASE_URL = "https://fioapi.fio.cz/v1/rest";
|
|
1659
|
+
var MINOR_UNITS = 100;
|
|
1660
|
+
var MILLIS = 1e3;
|
|
1661
|
+
var DATE_CHARS = "2026-08-04".length;
|
|
1662
|
+
var DEFAULT_MIN_INTERVAL_SECS = 30;
|
|
1663
|
+
var TOO_SOON = 409;
|
|
1664
|
+
var BOOKED_AT = "column0";
|
|
1665
|
+
var AMOUNT = "column1";
|
|
1666
|
+
var CURRENCY = "column14";
|
|
1667
|
+
var VARIABLE_SYMBOL = "column5";
|
|
1668
|
+
var USER_IDENTIFICATION = "column7";
|
|
1669
|
+
var MESSAGE_FOR_RECIPIENT = "column16";
|
|
1670
|
+
var PAYER_REFERENCE = "column27";
|
|
1671
|
+
var WHERE_A_PAYER_WRITES = [
|
|
1672
|
+
MESSAGE_FOR_RECIPIENT,
|
|
1673
|
+
VARIABLE_SYMBOL,
|
|
1674
|
+
USER_IDENTIFICATION,
|
|
1675
|
+
PAYER_REFERENCE
|
|
1676
|
+
];
|
|
1677
|
+
var UTC_OFFSET = /^[+-]\d{4}$/;
|
|
1678
|
+
function fioStatement(config) {
|
|
1679
|
+
const named = typeof config.token === "string" ? [config.token] : config.token;
|
|
1680
|
+
if (named.length === 0) throw new Error("fioStatement needs at least one token to read with");
|
|
1681
|
+
const usedAt = new Map(named.map((token) => [token, Number.NEGATIVE_INFINITY]));
|
|
1682
|
+
const tokenWindowMs = (config.minIntervalSecs ?? DEFAULT_MIN_INTERVAL_SECS) * MILLIS;
|
|
1683
|
+
const paceMs = tokenWindowMs / usedAt.size;
|
|
1684
|
+
let lastRead = Number.NEGATIVE_INFINITY;
|
|
1685
|
+
let credits = [];
|
|
1686
|
+
return async (sinceUnix) => {
|
|
1687
|
+
const now = Date.now();
|
|
1688
|
+
if (now - lastRead < paceMs) return credits;
|
|
1689
|
+
const idlest = longestUnused(usedAt);
|
|
1690
|
+
if (now - idlest.usedAt < tokenWindowMs) return credits;
|
|
1691
|
+
usedAt.set(idlest.token, now);
|
|
1692
|
+
lastRead = now;
|
|
1693
|
+
const url = [
|
|
1694
|
+
config.baseUrl ?? BASE_URL,
|
|
1695
|
+
"periods",
|
|
1696
|
+
idlest.token,
|
|
1697
|
+
asDate(sinceUnix),
|
|
1698
|
+
asDate(Math.floor(now / MILLIS)),
|
|
1699
|
+
"transactions.json"
|
|
1700
|
+
].join("/");
|
|
1701
|
+
const answer = await fetch(url, { headers: { accept: "application/json" } });
|
|
1702
|
+
if (answer.status === TOO_SOON) {
|
|
1703
|
+
throw new Error("fio refuses a second read of this token inside its 30 second window");
|
|
1704
|
+
}
|
|
1705
|
+
if (!answer.ok) throw new Error(`fio answered ${answer.status} reading the statement`);
|
|
1706
|
+
const read = await answer.json();
|
|
1707
|
+
credits = (read.accountStatement?.transactionList?.transaction ?? []).filter(isCredit).map(asCredit);
|
|
1708
|
+
return credits;
|
|
1709
|
+
};
|
|
1710
|
+
}
|
|
1711
|
+
function longestUnused(usedAt) {
|
|
1712
|
+
let token = "";
|
|
1713
|
+
let idleSince = Number.POSITIVE_INFINITY;
|
|
1714
|
+
for (const [candidate, at] of usedAt) {
|
|
1715
|
+
if (at < idleSince) {
|
|
1716
|
+
token = candidate;
|
|
1717
|
+
idleSince = at;
|
|
1718
|
+
}
|
|
1719
|
+
}
|
|
1720
|
+
return { token, usedAt: idleSince };
|
|
1721
|
+
}
|
|
1722
|
+
function isCredit(transaction) {
|
|
1723
|
+
return numberIn(transaction[AMOUNT]) > 0;
|
|
1724
|
+
}
|
|
1725
|
+
function asCredit(transaction) {
|
|
1726
|
+
return {
|
|
1727
|
+
amountMinor: Math.round(numberIn(transaction[AMOUNT]) * MINOR_UNITS),
|
|
1728
|
+
currency: textIn(transaction[CURRENCY]),
|
|
1729
|
+
reference: WHERE_A_PAYER_WRITES.map((column) => textIn(transaction[column])).filter((written) => written.length > 0).join(" "),
|
|
1730
|
+
bookedAt: bookedAtIn(transaction[BOOKED_AT])
|
|
1731
|
+
};
|
|
1732
|
+
}
|
|
1733
|
+
function bookedAtIn(cell) {
|
|
1734
|
+
const value = cell?.value;
|
|
1735
|
+
if (typeof value === "number") return Math.floor(value / MILLIS);
|
|
1736
|
+
const at = Date.parse(asIso8601(textIn(cell)));
|
|
1737
|
+
return Number.isFinite(at) ? Math.floor(at / MILLIS) : 0;
|
|
1738
|
+
}
|
|
1739
|
+
function asIso8601(booked) {
|
|
1740
|
+
const day = booked.slice(0, DATE_CHARS);
|
|
1741
|
+
const zone = booked.slice(DATE_CHARS);
|
|
1742
|
+
if (!UTC_OFFSET.test(zone)) return `${day}T00:00:00Z`;
|
|
1743
|
+
return `${day}T00:00:00${zone.slice(0, 3)}:${zone.slice(3)}`;
|
|
1744
|
+
}
|
|
1745
|
+
function numberIn(cell) {
|
|
1746
|
+
const value = Number(cell?.value ?? Number.NaN);
|
|
1747
|
+
return Number.isFinite(value) ? value : 0;
|
|
1748
|
+
}
|
|
1749
|
+
function textIn(cell) {
|
|
1750
|
+
const value = cell?.value;
|
|
1751
|
+
return value === null || value === void 0 ? "" : String(value).trim();
|
|
1752
|
+
}
|
|
1753
|
+
function asDate(unix) {
|
|
1754
|
+
return new Date(unix * MILLIS).toISOString().slice(0, DATE_CHARS);
|
|
1755
|
+
}
|
|
1756
|
+
|
|
1298
1757
|
// src/qr.ts
|
|
1299
1758
|
import { encode } from "uqr";
|
|
1300
1759
|
var SAFE_COLOR = /^#(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$|^rgba?\([\d.,%/\s]+\)$|^[a-z]+$/i;
|
|
@@ -1314,6 +1773,18 @@ function lnurlToSvg(endpoint, options) {
|
|
|
1314
1773
|
function lnurlToDataUrl(endpoint, options) {
|
|
1315
1774
|
return asDataUrl(lnurlToSvg(endpoint, options));
|
|
1316
1775
|
}
|
|
1776
|
+
function spdToSvg(spd, options) {
|
|
1777
|
+
return svgOf(spd, options);
|
|
1778
|
+
}
|
|
1779
|
+
function spdToDataUrl(spd, options) {
|
|
1780
|
+
return asDataUrl(spdToSvg(spd, options));
|
|
1781
|
+
}
|
|
1782
|
+
function qrToSvg(payload, options) {
|
|
1783
|
+
return svgOf(payload, options);
|
|
1784
|
+
}
|
|
1785
|
+
function qrToDataUrl(payload, options) {
|
|
1786
|
+
return asDataUrl(qrToSvg(payload, options));
|
|
1787
|
+
}
|
|
1317
1788
|
function svgOf(scanned, options) {
|
|
1318
1789
|
const size = options?.size ?? 256;
|
|
1319
1790
|
const color = options?.color ?? "#000";
|
|
@@ -1347,6 +1818,207 @@ function renderSvg(content, size, color) {
|
|
|
1347
1818
|
].join("");
|
|
1348
1819
|
}
|
|
1349
1820
|
|
|
1821
|
+
// src/rail.ts
|
|
1822
|
+
var BANK = "bank";
|
|
1823
|
+
var LIGHTNING = "lightning";
|
|
1824
|
+
function bankRail(config) {
|
|
1825
|
+
return async (order) => {
|
|
1826
|
+
const expiresAt = config.expiresAt(order);
|
|
1827
|
+
const transfer = await bankTransfer({
|
|
1828
|
+
gateway: config.gateway,
|
|
1829
|
+
secret: config.secret,
|
|
1830
|
+
iban: config.iban,
|
|
1831
|
+
verifyUrl: config.verifyUrl,
|
|
1832
|
+
reference: order.reference,
|
|
1833
|
+
amountMinor: order.amountMinor,
|
|
1834
|
+
currency: order.currency,
|
|
1835
|
+
expiresAt,
|
|
1836
|
+
trigger: config.trigger,
|
|
1837
|
+
sealed: await config.sealed?.(order),
|
|
1838
|
+
variableSymbol: config.variableSymbol?.(order),
|
|
1839
|
+
allowPublicGateway: config.allowPublicGateway
|
|
1840
|
+
});
|
|
1841
|
+
return {
|
|
1842
|
+
id: transfer.id,
|
|
1843
|
+
rail: config.name ?? BANK,
|
|
1844
|
+
scan: transfer.spd,
|
|
1845
|
+
qr: transfer.spd,
|
|
1846
|
+
expiresAt
|
|
1847
|
+
};
|
|
1848
|
+
};
|
|
1849
|
+
}
|
|
1850
|
+
function lightningRail(config) {
|
|
1851
|
+
return async (order) => {
|
|
1852
|
+
const payment = await config.gateway.createPayment(
|
|
1853
|
+
{
|
|
1854
|
+
lnAddresses: config.lnAddresses,
|
|
1855
|
+
amountMsat: await config.amountMsat(order),
|
|
1856
|
+
webhookUrl: config.webhookUrl,
|
|
1857
|
+
webhookSecret: config.webhookSecret
|
|
1858
|
+
},
|
|
1859
|
+
{ idempotencyKey: config.idempotencyKey?.(order), trigger: config.trigger }
|
|
1860
|
+
);
|
|
1861
|
+
return {
|
|
1862
|
+
id: payment.id,
|
|
1863
|
+
rail: config.name ?? LIGHTNING,
|
|
1864
|
+
scan: payment.bolt11,
|
|
1865
|
+
qr: encodeForQr(payment.bolt11),
|
|
1866
|
+
expiresAt: payment.expiresAt
|
|
1867
|
+
};
|
|
1868
|
+
};
|
|
1869
|
+
}
|
|
1870
|
+
function blindLightningRail(config) {
|
|
1871
|
+
return async (order) => {
|
|
1872
|
+
const resolved = await invoiceFrom(config.lnAddresses, await config.amountMsat(order));
|
|
1873
|
+
const watched = await config.gateway.watchPayment({
|
|
1874
|
+
paymentHash: resolved.paymentHash,
|
|
1875
|
+
verifyUrl: resolved.verifyUrl,
|
|
1876
|
+
expiresAt: resolved.expiresAt,
|
|
1877
|
+
trigger: config.trigger,
|
|
1878
|
+
sealed: await config.sealed?.(order)
|
|
1879
|
+
});
|
|
1880
|
+
return {
|
|
1881
|
+
id: watched.id,
|
|
1882
|
+
rail: config.name ?? LIGHTNING,
|
|
1883
|
+
scan: resolved.bolt11,
|
|
1884
|
+
qr: encodeForQr(resolved.bolt11),
|
|
1885
|
+
expiresAt: resolved.expiresAt
|
|
1886
|
+
};
|
|
1887
|
+
};
|
|
1888
|
+
}
|
|
1889
|
+
async function invoiceFrom(lnAddresses, amountMsat) {
|
|
1890
|
+
try {
|
|
1891
|
+
return await resolve(lnAddresses, amountMsat);
|
|
1892
|
+
} catch (refused) {
|
|
1893
|
+
if (refused instanceof NoWalletAvailable) {
|
|
1894
|
+
throw new NoWalletAvailableError({ title: refused.message }, refused.wallets);
|
|
1895
|
+
}
|
|
1896
|
+
throw refused;
|
|
1897
|
+
}
|
|
1898
|
+
}
|
|
1899
|
+
|
|
1900
|
+
// src/price.ts
|
|
1901
|
+
var MSAT_PER_BTC2 = 1e11;
|
|
1902
|
+
var BASIS_POINTS = 1e4;
|
|
1903
|
+
var DEFAULT_HOLD_SECS = 60;
|
|
1904
|
+
var DEFAULT_MIN_VENUES = 2;
|
|
1905
|
+
var DEFAULT_MAX_SPREAD_BPS = 200;
|
|
1906
|
+
var MILLIS2 = 1e3;
|
|
1907
|
+
function coinbase(baseUrl = "https://api.coinbase.com") {
|
|
1908
|
+
return async (currency) => {
|
|
1909
|
+
const asked = currency.toUpperCase();
|
|
1910
|
+
const answer = await asJson(`${baseUrl}/v2/prices/BTC-${asked}/spot`, "coinbase");
|
|
1911
|
+
const quoted = answer.data;
|
|
1912
|
+
if (quoted?.currency !== asked) {
|
|
1913
|
+
throw new Error(`coinbase quoted ${quoted?.currency} when asked for ${asked}`);
|
|
1914
|
+
}
|
|
1915
|
+
return asMinor(quoted.amount, "coinbase", asked);
|
|
1916
|
+
};
|
|
1917
|
+
}
|
|
1918
|
+
function kraken(baseUrl = "https://api.kraken.com") {
|
|
1919
|
+
return async (currency) => {
|
|
1920
|
+
const asked = currency.toUpperCase();
|
|
1921
|
+
const answer = await asJson(`${baseUrl}/0/public/Ticker?pair=XBT${asked}`, "kraken");
|
|
1922
|
+
if (answer.error?.length) throw new Error(`kraken refused XBT${asked}: ${answer.error[0]}`);
|
|
1923
|
+
const pairs = Object.values(answer.result ?? {});
|
|
1924
|
+
if (pairs.length !== 1) {
|
|
1925
|
+
throw new Error(`kraken answered ${pairs.length} pairs for XBT${asked}`);
|
|
1926
|
+
}
|
|
1927
|
+
return asMinor(pairs[0]?.c?.[0], "kraken", asked);
|
|
1928
|
+
};
|
|
1929
|
+
}
|
|
1930
|
+
function bitstamp(baseUrl = "https://www.bitstamp.net") {
|
|
1931
|
+
return async (currency) => {
|
|
1932
|
+
const pair = `btc${currency.toLowerCase()}`;
|
|
1933
|
+
const answer = await asJson(`${baseUrl}/api/v2/ticker/${pair}/`, "bitstamp");
|
|
1934
|
+
if (Array.isArray(answer)) throw new Error(`bitstamp has no ${pair} pair`);
|
|
1935
|
+
return asMinor(answer.last, "bitstamp", currency);
|
|
1936
|
+
};
|
|
1937
|
+
}
|
|
1938
|
+
function coinmate(baseUrl = "https://coinmate.io") {
|
|
1939
|
+
return async (currency) => {
|
|
1940
|
+
const pair = `BTC_${currency.toUpperCase()}`;
|
|
1941
|
+
const answer = await asJson(`${baseUrl}/api/ticker?currencyPair=${pair}`, "coinmate");
|
|
1942
|
+
if (answer.error !== false) {
|
|
1943
|
+
throw new Error(`coinmate refused ${pair}: ${answer.errorMessage ?? "no reason"}`);
|
|
1944
|
+
}
|
|
1945
|
+
return asMinor(answer.data?.last, "coinmate", currency);
|
|
1946
|
+
};
|
|
1947
|
+
}
|
|
1948
|
+
function medianOf(tickers = [coinbase(), kraken(), bitstamp(), coinmate()], options = {}) {
|
|
1949
|
+
const holdFor = (options.holdForSecs ?? DEFAULT_HOLD_SECS) * MILLIS2;
|
|
1950
|
+
const minVenues = options.minVenues ?? DEFAULT_MIN_VENUES;
|
|
1951
|
+
const maxSpread = options.maxSpreadBps ?? DEFAULT_MAX_SPREAD_BPS;
|
|
1952
|
+
const held = /* @__PURE__ */ new Map();
|
|
1953
|
+
return async (currency) => {
|
|
1954
|
+
const asked = currency.toUpperCase();
|
|
1955
|
+
const remembered = held.get(asked);
|
|
1956
|
+
if (remembered && Date.now() - remembered.at < holdFor) return remembered.price;
|
|
1957
|
+
const answers = await Promise.allSettled(tickers.map((ticker) => ticker(asked)));
|
|
1958
|
+
const quoted = answers.filter((answer) => answer.status === "fulfilled").map((answer) => answer.value).sort((one, other) => one - other);
|
|
1959
|
+
if (quoted.length < minVenues) {
|
|
1960
|
+
throw new Error(
|
|
1961
|
+
`${quoted.length} of ${tickers.length} venues quoted ${asked}, ${minVenues} wanted: ${refusals2(answers)}`
|
|
1962
|
+
);
|
|
1963
|
+
}
|
|
1964
|
+
const spread = spreadBpsOf(quoted);
|
|
1965
|
+
if (spread > maxSpread) {
|
|
1966
|
+
throw new Error(
|
|
1967
|
+
`venues disagree on ${asked} by ${Math.round(spread)} bps, more than the ${maxSpread} allowed: ${quoted.join(", ")}`
|
|
1968
|
+
);
|
|
1969
|
+
}
|
|
1970
|
+
const price = middleOf(quoted);
|
|
1971
|
+
held.set(asked, { at: Date.now(), price });
|
|
1972
|
+
return price;
|
|
1973
|
+
};
|
|
1974
|
+
}
|
|
1975
|
+
function msatFor(amountMinor, priceMinorPerBtc, options = {}) {
|
|
1976
|
+
if (!Number.isInteger(amountMinor) || amountMinor <= 0) {
|
|
1977
|
+
throw new Error("amountMinor must be a whole number of minor units above zero");
|
|
1978
|
+
}
|
|
1979
|
+
if (!Number.isFinite(priceMinorPerBtc) || priceMinorPerBtc <= 0) {
|
|
1980
|
+
throw new Error(`${priceMinorPerBtc} is not a usable price`);
|
|
1981
|
+
}
|
|
1982
|
+
const spreadBps = options.spreadBps ?? 0;
|
|
1983
|
+
if (!Number.isInteger(spreadBps) || spreadBps < 0) {
|
|
1984
|
+
throw new Error("spreadBps must be a whole number of basis points, none or more");
|
|
1985
|
+
}
|
|
1986
|
+
const wanted = BigInt(amountMinor) * BigInt(MSAT_PER_BTC2) * BigInt(BASIS_POINTS + spreadBps);
|
|
1987
|
+
const per = BigInt(Math.round(priceMinorPerBtc)) * BigInt(BASIS_POINTS);
|
|
1988
|
+
const msat = (wanted + per - 1n) / per;
|
|
1989
|
+
if (msat > BigInt(Number.MAX_SAFE_INTEGER)) {
|
|
1990
|
+
throw new Error(`${amountMinor} at ${priceMinorPerBtc} is more millisatoshi than exist`);
|
|
1991
|
+
}
|
|
1992
|
+
return Number(msat);
|
|
1993
|
+
}
|
|
1994
|
+
async function asJson(url, venue) {
|
|
1995
|
+
const answer = await fetch(url, { headers: { accept: "application/json" } });
|
|
1996
|
+
if (!answer.ok) throw new Error(`${venue} answered ${answer.status}`);
|
|
1997
|
+
return answer.json();
|
|
1998
|
+
}
|
|
1999
|
+
function asMinor(quoted, venue, currency) {
|
|
2000
|
+
const major2 = Number(quoted);
|
|
2001
|
+
if (!Number.isFinite(major2) || major2 <= 0) {
|
|
2002
|
+
throw new Error(`${venue} quoted ${String(quoted)} for ${currency.toUpperCase()}`);
|
|
2003
|
+
}
|
|
2004
|
+
return Math.round(major2 * minorScaleOf(currency));
|
|
2005
|
+
}
|
|
2006
|
+
function spreadBpsOf(sorted) {
|
|
2007
|
+
const lowest = sorted[0] ?? 0;
|
|
2008
|
+
const highest = sorted[sorted.length - 1] ?? 0;
|
|
2009
|
+
return lowest === 0 ? Number.POSITIVE_INFINITY : (highest - lowest) / lowest * BASIS_POINTS;
|
|
2010
|
+
}
|
|
2011
|
+
function middleOf(sorted) {
|
|
2012
|
+
const middle = Math.floor(sorted.length / 2);
|
|
2013
|
+
if (sorted.length % 2 === 1) return sorted[middle] ?? 0;
|
|
2014
|
+
return Math.round(((sorted[middle - 1] ?? 0) + (sorted[middle] ?? 0)) / 2);
|
|
2015
|
+
}
|
|
2016
|
+
function refusals2(answers) {
|
|
2017
|
+
return answers.filter((answer) => answer.status === "rejected").map(
|
|
2018
|
+
(answer) => answer.reason instanceof Error ? answer.reason.message : String(answer.reason)
|
|
2019
|
+
).join("; ");
|
|
2020
|
+
}
|
|
2021
|
+
|
|
1350
2022
|
// src/webhook.ts
|
|
1351
2023
|
var SIGNATURE_HEADER = "x-signature";
|
|
1352
2024
|
var TIMESTAMP_HEADER = "x-timestamp";
|
|
@@ -1359,9 +2031,9 @@ async function verifyWebhookSignature(body, signature, secret, timestamp, option
|
|
|
1359
2031
|
}
|
|
1360
2032
|
async function parseWebhook(body, signature, secret, timestamp, options = {}) {
|
|
1361
2033
|
if (!await verifyWebhookSignature(body, signature, secret, timestamp, options)) return null;
|
|
1362
|
-
const
|
|
2034
|
+
const text3 = typeof body === "string" ? body : new TextDecoder().decode(body);
|
|
1363
2035
|
try {
|
|
1364
|
-
return paymentFromWire(JSON.parse(
|
|
2036
|
+
return paymentFromWire(JSON.parse(text3));
|
|
1365
2037
|
} catch {
|
|
1366
2038
|
return null;
|
|
1367
2039
|
}
|
|
@@ -1400,19 +2072,38 @@ export {
|
|
|
1400
2072
|
REQUEST_IN_FLIGHT,
|
|
1401
2073
|
ThunderBridge,
|
|
1402
2074
|
UnverifiedRecipientError,
|
|
2075
|
+
bankRail,
|
|
2076
|
+
bankTransfer,
|
|
2077
|
+
bankVerifyEndpoint,
|
|
2078
|
+
bitstamp,
|
|
2079
|
+
blindLightningRail,
|
|
2080
|
+
coinbase,
|
|
2081
|
+
coinmate,
|
|
1403
2082
|
decodeInvoice,
|
|
2083
|
+
fioStatement,
|
|
1404
2084
|
invoiceToDataUrl,
|
|
1405
2085
|
invoiceToSvg,
|
|
2086
|
+
isProblemType,
|
|
1406
2087
|
isProvablyPaid,
|
|
2088
|
+
kraken,
|
|
2089
|
+
lightningRail,
|
|
1407
2090
|
lnurlPayEndpoint,
|
|
1408
2091
|
lnurlToDataUrl,
|
|
1409
2092
|
lnurlToSvg,
|
|
2093
|
+
medianOf,
|
|
2094
|
+
minorScaleOf,
|
|
2095
|
+
minorUnitsOf,
|
|
2096
|
+
msatFor,
|
|
1410
2097
|
parseWebhook,
|
|
1411
2098
|
parseWebhookRequest,
|
|
1412
2099
|
preimageMatchesHash,
|
|
1413
2100
|
proveOrigin,
|
|
1414
2101
|
proveSettlement,
|
|
2102
|
+
qrToDataUrl,
|
|
2103
|
+
qrToSvg,
|
|
1415
2104
|
seal,
|
|
2105
|
+
spdToDataUrl,
|
|
2106
|
+
spdToSvg,
|
|
1416
2107
|
toLnurl,
|
|
1417
2108
|
unseal,
|
|
1418
2109
|
verifyWebhookSignature
|