thunder-bridge 0.8.1 → 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 +36 -12
- package/dist/index.cjs +312 -90
- package/dist/index.d.cts +142 -14
- package/dist/index.d.ts +142 -14
- package/dist/index.js +307 -90
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -12,10 +12,11 @@ amount you asked for rather than the amount the gateway echoed back, and its
|
|
|
12
12
|
settlement proof url belongs to the recipient. A gateway that substitutes an
|
|
13
13
|
invoice is caught before a payer sees a QR code.
|
|
14
14
|
|
|
15
|
-
Lightning is the rail it was built for, not the only one it can prove.
|
|
16
|
-
`bankTransfer` derives
|
|
17
|
-
|
|
18
|
-
the
|
|
15
|
+
Lightning is the rail it was built for, not the only one it can prove. A bank
|
|
16
|
+
transfer has no preimage, so `bankTransfer` derives one and hands the gateway its
|
|
17
|
+
hash, which puts money arriving in a bank account behind the same watch, the same
|
|
18
|
+
poll and the same proof. `Statement` is where a bank plugs in and `fioStatement`
|
|
19
|
+
is the first one.
|
|
19
20
|
|
|
20
21
|
It touches only `fetch`, `crypto.subtle`, `URL` and `WebSocket`, so it runs in
|
|
21
22
|
Node, Bun, Deno, Cloudflare Workers and the browser. The gateway it talks to is
|
|
@@ -36,7 +37,7 @@ CORS open, so the proof fetches work from a browser too.
|
|
|
36
37
|
```ts
|
|
37
38
|
import { ThunderBridge, invoiceToSvg, type CreatePaymentParams } from "thunder-bridge";
|
|
38
39
|
|
|
39
|
-
const gateway = new ThunderBridge("https://thunder-bridge-
|
|
40
|
+
const gateway = new ThunderBridge("https://thunder-bridge-production.up.railway.app");
|
|
40
41
|
|
|
41
42
|
const request: CreatePaymentParams = {
|
|
42
43
|
lnAddresses: ["alice@coinos.io", "alice@getalby.com"],
|
|
@@ -106,17 +107,38 @@ them and this table does not repeat them.
|
|
|
106
107
|
| Export | What it does |
|
|
107
108
|
|---|---|
|
|
108
109
|
| `lnurlPayEndpoint(config)` | a whole LNURL-pay endpoint as one Fetch handler, so a static QR points at your domain |
|
|
109
|
-
| `bankTransfer(params)` | register a Czech QR platba as a watched payment. Refuses a gateway with no token |
|
|
110
|
-
| `bankVerifyEndpoint(config)` | the other half, the LUD-21 shape backed by your own statement |
|
|
111
|
-
| `fioStatement(config)` | a `Statement` reading a Fio account, several tokens used strictly in turn |
|
|
112
110
|
| `seal(secret, plaintext)`, `unseal` | the blob the gateway stores and cannot read |
|
|
113
111
|
| `toLnurl(url)` | bech32-encode an endpoint url |
|
|
112
|
+
| `bankTransfer(params)` | register a Czech QR platba as a watched payment. Refuses a gateway that serves strangers |
|
|
113
|
+
| `bankVerifyEndpoint(config)` | the other half, the LUD-21 shape backed by your own statement |
|
|
114
|
+
| `fioStatement(config)` | a `Statement` reading a Fio account, several tokens used strictly in turn |
|
|
115
|
+
|
|
116
|
+
What your service answers once those handlers are mounted is written out in
|
|
117
|
+
[`openapi.yaml`](openapi.yaml), shipped with this package.
|
|
114
118
|
|
|
115
119
|
`Statement` is a plain `(sinceUnix) => Promise<Credit[]>`, so another bank is
|
|
116
120
|
another function of that shape and persistence wraps it from outside rather than
|
|
117
121
|
living inside it. Nothing above it changes, and the package stays ignorant of
|
|
118
122
|
whatever runtime you keep state in.
|
|
119
123
|
|
|
124
|
+
**One shape for every payment method** - [`src/rail.ts`](src/rail.ts)
|
|
125
|
+
|
|
126
|
+
| Export | What it does |
|
|
127
|
+
|---|---|
|
|
128
|
+
| `bankRail(config)` | a `Rail` selling for a bank transfer, reading back through any `Statement` |
|
|
129
|
+
| `lightningRail(config)` | a `Rail` where the gateway mints the invoice, so it learns the address and the amount |
|
|
130
|
+
| `blindLightningRail(config)` | a `Rail` that resolves the address itself and tells the gateway only a hash |
|
|
131
|
+
|
|
132
|
+
`Rail` is `(order: Order) => Promise<Leg>`. Everything that differs between rails
|
|
133
|
+
is bound once when the rail is built, so the only thing passed per sale is which
|
|
134
|
+
sale it is: a reference, an amount in minor units and a currency. A `Leg` reads
|
|
135
|
+
the same whichever rail made it, which is what lets `firstToSettle` take a mixed
|
|
136
|
+
list without being told what is in it.
|
|
137
|
+
|
|
138
|
+
The gateway is already indifferent to all of this. It holds a payment hash, polls
|
|
139
|
+
a verify URL and reports what came back, so a rail is an SDK-side arrangement of
|
|
140
|
+
calls the gateway already answers, not a plugin it has to load.
|
|
141
|
+
|
|
120
142
|
**Pricing a fiat order** - [`src/price.ts`](src/price.ts), [`src/currency.ts`](src/currency.ts)
|
|
121
143
|
|
|
122
144
|
| Export | What it does |
|
|
@@ -129,10 +151,12 @@ whatever runtime you keep state in.
|
|
|
129
151
|
**QR codes** - [`src/qr.ts`](src/qr.ts)
|
|
130
152
|
|
|
131
153
|
Every renderer returns a string, so they work on a server, in a worker and in a
|
|
132
|
-
browser with no canvas involved. `
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
154
|
+
browser with no canvas involved. `qrToSvg` takes any rail's `Leg.qr` and needs to
|
|
155
|
+
know nothing else, because a rail states its own payload. Below it sit the
|
|
156
|
+
format-named ones for calling directly: `invoiceToSvg` takes an invoice or a
|
|
157
|
+
lightning address, `lnurlToSvg` takes your own endpoint url, `spdToSvg` takes a
|
|
158
|
+
Short Payment Descriptor. Each has a `…ToDataUrl` twin for an `<img>` `src`. A
|
|
159
|
+
BOLT12 offer is not handled, because this gateway never returns one.
|
|
136
160
|
|
|
137
161
|
```ts
|
|
138
162
|
import { invoiceToSvg, lnurlToSvg } from "thunder-bridge";
|
package/dist/index.cjs
CHANGED
|
@@ -30,9 +30,11 @@ __export(index_exports, {
|
|
|
30
30
|
REQUEST_IN_FLIGHT: () => REQUEST_IN_FLIGHT,
|
|
31
31
|
ThunderBridge: () => ThunderBridge,
|
|
32
32
|
UnverifiedRecipientError: () => UnverifiedRecipientError,
|
|
33
|
+
bankRail: () => bankRail,
|
|
33
34
|
bankTransfer: () => bankTransfer,
|
|
34
35
|
bankVerifyEndpoint: () => bankVerifyEndpoint,
|
|
35
36
|
bitstamp: () => bitstamp,
|
|
37
|
+
blindLightningRail: () => blindLightningRail,
|
|
36
38
|
coinbase: () => coinbase,
|
|
37
39
|
coinmate: () => coinmate,
|
|
38
40
|
decodeInvoice: () => decodeInvoice,
|
|
@@ -42,6 +44,7 @@ __export(index_exports, {
|
|
|
42
44
|
isProblemType: () => isProblemType,
|
|
43
45
|
isProvablyPaid: () => isProvablyPaid,
|
|
44
46
|
kraken: () => kraken,
|
|
47
|
+
lightningRail: () => lightningRail,
|
|
45
48
|
lnurlPayEndpoint: () => lnurlPayEndpoint,
|
|
46
49
|
lnurlToDataUrl: () => lnurlToDataUrl,
|
|
47
50
|
lnurlToSvg: () => lnurlToSvg,
|
|
@@ -54,6 +57,8 @@ __export(index_exports, {
|
|
|
54
57
|
preimageMatchesHash: () => preimageMatchesHash,
|
|
55
58
|
proveOrigin: () => proveOrigin,
|
|
56
59
|
proveSettlement: () => proveSettlement,
|
|
60
|
+
qrToDataUrl: () => qrToDataUrl,
|
|
61
|
+
qrToSvg: () => qrToSvg,
|
|
57
62
|
seal: () => seal,
|
|
58
63
|
spdToDataUrl: () => spdToDataUrl,
|
|
59
64
|
spdToSvg: () => spdToSvg,
|
|
@@ -99,10 +104,8 @@ var ProblemError = class extends Error {
|
|
|
99
104
|
this.detail = problem.detail ?? null;
|
|
100
105
|
}
|
|
101
106
|
};
|
|
102
|
-
var NAMESPACE = "urn:problem-type:thunder-bridge:";
|
|
103
|
-
var NAMESPACE_BEFORE_THE_RENAME = "urn:problem-type:thunder-bridge-direct:";
|
|
104
107
|
function isProblemType(problem, type) {
|
|
105
|
-
return problem.type === type
|
|
108
|
+
return problem.type === type;
|
|
106
109
|
}
|
|
107
110
|
var NO_WALLET_AVAILABLE = "urn:problem-type:thunder-bridge:no-wallet-available";
|
|
108
111
|
var REQUEST_IN_FLIGHT = "urn:problem-type:thunder-bridge:request-in-flight";
|
|
@@ -138,8 +141,8 @@ function hexToBytes(hex) {
|
|
|
138
141
|
}
|
|
139
142
|
return bytes;
|
|
140
143
|
}
|
|
141
|
-
function isHex(
|
|
142
|
-
return /^[0-9a-f]+$/i.test(
|
|
144
|
+
function isHex(text3) {
|
|
145
|
+
return /^[0-9a-f]+$/i.test(text3) && text3.length % 2 === 0;
|
|
143
146
|
}
|
|
144
147
|
|
|
145
148
|
// ../core/sha256.ts
|
|
@@ -233,8 +236,8 @@ function sha256(bytes) {
|
|
|
233
236
|
}
|
|
234
237
|
return asBytes(state);
|
|
235
238
|
}
|
|
236
|
-
function sha256Hex(
|
|
237
|
-
return bytesToHex(sha256(new TextEncoder().encode(
|
|
239
|
+
function sha256Hex(text3) {
|
|
240
|
+
return bytesToHex(sha256(new TextEncoder().encode(text3)));
|
|
238
241
|
}
|
|
239
242
|
function withPadding(bytes) {
|
|
240
243
|
const padded = new Uint8Array(
|
|
@@ -413,10 +416,13 @@ function sameOrigin(one, other) {
|
|
|
413
416
|
return false;
|
|
414
417
|
}
|
|
415
418
|
}
|
|
419
|
+
function publicAddress(literal) {
|
|
420
|
+
return literal.includes(":") ? ipv6IsGlobal(literal) : ipv4IsGlobal(literal);
|
|
421
|
+
}
|
|
416
422
|
function hostIsPublic(host) {
|
|
417
|
-
if (host.startsWith("[")) return
|
|
423
|
+
if (host.startsWith("[")) return publicAddress(host.slice(1, -1));
|
|
418
424
|
const name = host.replace(/\.$/, "");
|
|
419
|
-
if (/^\d+\.\d+\.\d+\.\d+$/.test(name)) return
|
|
425
|
+
if (/^\d+\.\d+\.\d+\.\d+$/.test(name)) return publicAddress(name);
|
|
420
426
|
const labels = name.split(".");
|
|
421
427
|
if (labels.length < 2 || labels.some((label) => label === "")) return false;
|
|
422
428
|
return !PRIVATE_SUFFIXES.includes(labels[labels.length - 1]);
|
|
@@ -681,6 +687,16 @@ var TERMINAL = /* @__PURE__ */ new Set(["paid", "expired"]);
|
|
|
681
687
|
var RECONNECT_DELAY_MS = 3e3;
|
|
682
688
|
var RECONNECT_CAP_MS = 3e4;
|
|
683
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
|
+
}
|
|
684
700
|
function backoffMs(firstDelay, attempt) {
|
|
685
701
|
const grown = Math.min(firstDelay * 2 ** (attempt - 1), RECONNECT_CAP_MS);
|
|
686
702
|
return Math.round(grown * (0.5 + Math.random() / 2));
|
|
@@ -689,6 +705,7 @@ var ThunderBridge = class {
|
|
|
689
705
|
baseUrl;
|
|
690
706
|
verify;
|
|
691
707
|
token;
|
|
708
|
+
strangers = null;
|
|
692
709
|
constructor(baseUrl, options) {
|
|
693
710
|
this.baseUrl = baseUrl.replace(/\/+$/, "");
|
|
694
711
|
this.verify = options?.verify ?? true;
|
|
@@ -702,6 +719,21 @@ var ThunderBridge = class {
|
|
|
702
719
|
get isPrivate() {
|
|
703
720
|
return this.token !== null;
|
|
704
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
|
+
}
|
|
705
737
|
/**
|
|
706
738
|
* Ask the gateway for an invoice payable to the first address on your list
|
|
707
739
|
* that can issue a provable one, throws `NoWalletAvailableError` when none can
|
|
@@ -713,7 +745,10 @@ var ThunderBridge = class {
|
|
|
713
745
|
const response = await fetch(`${this.baseUrl}/incoming-payments`, {
|
|
714
746
|
method: "POST",
|
|
715
747
|
headers,
|
|
716
|
-
body: createRequestBody(
|
|
748
|
+
body: createRequestBody(
|
|
749
|
+
params,
|
|
750
|
+
options?.trigger ? sha256Hex(unguessable(options.trigger)) : null
|
|
751
|
+
)
|
|
717
752
|
});
|
|
718
753
|
if (!response.ok) throw await problemFrom(response);
|
|
719
754
|
const payment = await paymentFrom(response);
|
|
@@ -955,7 +990,10 @@ var ThunderBridge = class {
|
|
|
955
990
|
const response = await fetch(`${this.baseUrl}/watched-payments`, {
|
|
956
991
|
method: "POST",
|
|
957
992
|
headers: this.sending(),
|
|
958
|
-
body: watchRequestBody(
|
|
993
|
+
body: watchRequestBody(
|
|
994
|
+
params,
|
|
995
|
+
params.trigger ? sha256Hex(unguessable(params.trigger)) : null
|
|
996
|
+
)
|
|
959
997
|
});
|
|
960
998
|
if (!response.ok) throw await problemFrom(response);
|
|
961
999
|
const watched = triggerEventFromWire(await response.json().catch(() => null));
|
|
@@ -974,7 +1012,7 @@ var ThunderBridge = class {
|
|
|
974
1012
|
*/
|
|
975
1013
|
followTrigger(secret, options) {
|
|
976
1014
|
const base = this.baseUrl.replace(/^http/, "ws");
|
|
977
|
-
const direct = `${base}/ws/triggers/${encodeURIComponent(secret)}`;
|
|
1015
|
+
const direct = `${base}/ws/triggers/${encodeURIComponent(unguessable(secret))}`;
|
|
978
1016
|
const firstDelay = options.reconnectDelayMs ?? RECONNECT_DELAY_MS;
|
|
979
1017
|
let socket = null;
|
|
980
1018
|
let retry;
|
|
@@ -1146,10 +1184,10 @@ function toBase64Url(bytes) {
|
|
|
1146
1184
|
for (const byte of bytes) binary += String.fromCharCode(byte);
|
|
1147
1185
|
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
1148
1186
|
}
|
|
1149
|
-
function fromBase64Url(
|
|
1150
|
-
if (
|
|
1187
|
+
function fromBase64Url(text3) {
|
|
1188
|
+
if (text3.length === 0 || !/^[A-Za-z0-9_-]+$/.test(text3)) return null;
|
|
1151
1189
|
try {
|
|
1152
|
-
const binary = atob(
|
|
1190
|
+
const binary = atob(text3.replace(/-/g, "+").replace(/_/g, "/"));
|
|
1153
1191
|
const bytes = new Uint8Array(binary.length);
|
|
1154
1192
|
for (let at = 0; at < bytes.length; at++) bytes[at] = binary.charCodeAt(at);
|
|
1155
1193
|
return bytes;
|
|
@@ -1181,6 +1219,87 @@ function asBuffer(bytes) {
|
|
|
1181
1219
|
return bytes instanceof Uint8Array && bytes.buffer instanceof ArrayBuffer ? bytes : new Uint8Array(bytes);
|
|
1182
1220
|
}
|
|
1183
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
|
+
|
|
1184
1303
|
// ../core/refusal.ts
|
|
1185
1304
|
var WalletRefused = class extends Error {
|
|
1186
1305
|
reason;
|
|
@@ -1199,7 +1318,6 @@ var NoWalletAvailable = class extends Error {
|
|
|
1199
1318
|
|
|
1200
1319
|
// ../core/lnurl.ts
|
|
1201
1320
|
var VERIFY_WITHOUT_PREIMAGE = ["zeuspay.com", "zeusnuts.com", "ecash.love"];
|
|
1202
|
-
var HTTP_TIMEOUT_MS2 = 15e3;
|
|
1203
1321
|
var BECH32_CHARSET2 = "qpzry9x8gf2tvdw0s3jn54khce6mua7l";
|
|
1204
1322
|
var BECH32_GENERATOR = [996825010, 642813549, 513874426, 1027748829, 705979059];
|
|
1205
1323
|
var CHECKSUM_SLOTS = [0, 1, 2, 3, 4, 5];
|
|
@@ -1364,14 +1482,12 @@ function splitAddress(address) {
|
|
|
1364
1482
|
return [user, domain];
|
|
1365
1483
|
}
|
|
1366
1484
|
async function fetchJson(url, deadline) {
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
if (!response.ok) throw new Error(`${url} answered ${response.status}`);
|
|
1374
|
-
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);
|
|
1375
1491
|
}
|
|
1376
1492
|
|
|
1377
1493
|
// src/trigger.ts
|
|
@@ -1515,6 +1631,7 @@ var FORBIDDEN_IN_SPD = "*";
|
|
|
1515
1631
|
async function bankTransfer(params) {
|
|
1516
1632
|
const currency = params.currency ?? DEFAULT_CURRENCY;
|
|
1517
1633
|
refuseUnusable(params, currency);
|
|
1634
|
+
await refuseAnOpenGateway(params);
|
|
1518
1635
|
const spd = shortPaymentDescriptor(params, currency);
|
|
1519
1636
|
const subject = subjectOf(params.reference, params.amountMinor, currency);
|
|
1520
1637
|
const polling = new URL(params.verifyUrl);
|
|
@@ -1581,12 +1698,14 @@ function shortPaymentDescriptor(params, currency) {
|
|
|
1581
1698
|
function major(amountMinor, currency) {
|
|
1582
1699
|
return (amountMinor / minorScaleOf(currency)).toFixed(minorUnitsOf(currency));
|
|
1583
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
|
+
}
|
|
1584
1708
|
function refuseUnusable(params, currency) {
|
|
1585
|
-
if (!params.gateway.isPrivate && params.allowPublicGateway !== true) {
|
|
1586
|
-
throw new Error(
|
|
1587
|
-
"this gateway has no token, so it is not yours, and its operator would read the amount and the reference off every verify URL. Give it a token or say allowPublicGateway"
|
|
1588
|
-
);
|
|
1589
|
-
}
|
|
1590
1709
|
if (!IBAN.test(params.iban)) throw new Error(`${params.iban} is not an IBAN`);
|
|
1591
1710
|
if (!Number.isInteger(params.amountMinor) || params.amountMinor <= 0) {
|
|
1592
1711
|
throw new Error("amountMinor must be a whole number of minor units above zero");
|
|
@@ -1625,6 +1744,7 @@ var WHERE_A_PAYER_WRITES = [
|
|
|
1625
1744
|
USER_IDENTIFICATION,
|
|
1626
1745
|
PAYER_REFERENCE
|
|
1627
1746
|
];
|
|
1747
|
+
var UTC_OFFSET = /^[+-]\d{4}$/;
|
|
1628
1748
|
function fioStatement(config) {
|
|
1629
1749
|
const named = typeof config.token === "string" ? [config.token] : config.token;
|
|
1630
1750
|
if (named.length === 0) throw new Error("fioStatement needs at least one token to read with");
|
|
@@ -1677,9 +1797,21 @@ function asCredit(transaction) {
|
|
|
1677
1797
|
amountMinor: Math.round(numberIn(transaction[AMOUNT]) * MINOR_UNITS),
|
|
1678
1798
|
currency: textIn(transaction[CURRENCY]),
|
|
1679
1799
|
reference: WHERE_A_PAYER_WRITES.map((column) => textIn(transaction[column])).filter((written) => written.length > 0).join(" "),
|
|
1680
|
-
bookedAt:
|
|
1800
|
+
bookedAt: bookedAtIn(transaction[BOOKED_AT])
|
|
1681
1801
|
};
|
|
1682
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
|
+
}
|
|
1683
1815
|
function numberIn(cell) {
|
|
1684
1816
|
const value = Number(cell?.value ?? Number.NaN);
|
|
1685
1817
|
return Number.isFinite(value) ? value : 0;
|
|
@@ -1692,6 +1824,149 @@ function asDate(unix) {
|
|
|
1692
1824
|
return new Date(unix * MILLIS).toISOString().slice(0, DATE_CHARS);
|
|
1693
1825
|
}
|
|
1694
1826
|
|
|
1827
|
+
// src/qr.ts
|
|
1828
|
+
var import_uqr = require("uqr");
|
|
1829
|
+
var SAFE_COLOR = /^#(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$|^rgba?\([\d.,%/\s]+\)$|^[a-z]+$/i;
|
|
1830
|
+
function encodeForQr(destination) {
|
|
1831
|
+
const scanned = isLnAddress(destination) ? destination : destination.toUpperCase();
|
|
1832
|
+
return `LIGHTNING:${scanned}`;
|
|
1833
|
+
}
|
|
1834
|
+
function invoiceToSvg(destination, options) {
|
|
1835
|
+
return svgOf(encodeForQr(destination), options);
|
|
1836
|
+
}
|
|
1837
|
+
function invoiceToDataUrl(destination, options) {
|
|
1838
|
+
return asDataUrl(invoiceToSvg(destination, options));
|
|
1839
|
+
}
|
|
1840
|
+
function lnurlToSvg(endpoint, options) {
|
|
1841
|
+
return svgOf(toLnurl(endpoint), options);
|
|
1842
|
+
}
|
|
1843
|
+
function lnurlToDataUrl(endpoint, options) {
|
|
1844
|
+
return asDataUrl(lnurlToSvg(endpoint, options));
|
|
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
|
+
}
|
|
1858
|
+
function svgOf(scanned, options) {
|
|
1859
|
+
const size = options?.size ?? 256;
|
|
1860
|
+
const color = options?.color ?? "#000";
|
|
1861
|
+
if (!SAFE_COLOR.test(color)) throw new Error(`Invalid color: ${color}`);
|
|
1862
|
+
return renderSvg(scanned, size, color);
|
|
1863
|
+
}
|
|
1864
|
+
function asDataUrl(svg) {
|
|
1865
|
+
return `data:image/svg+xml,${encodeURIComponent(svg)}`;
|
|
1866
|
+
}
|
|
1867
|
+
function isLnAddress(destination) {
|
|
1868
|
+
const at = destination.indexOf("@");
|
|
1869
|
+
return at > 0 && destination.slice(at + 1).includes(".");
|
|
1870
|
+
}
|
|
1871
|
+
function renderSvg(content, size, color) {
|
|
1872
|
+
const { data } = (0, import_uqr.encode)(content);
|
|
1873
|
+
const modules = data.length;
|
|
1874
|
+
const cellSize = size / modules;
|
|
1875
|
+
let path = "";
|
|
1876
|
+
for (let y = 0; y < modules; y++) {
|
|
1877
|
+
for (let x = 0; x < modules; x++) {
|
|
1878
|
+
if (data[y][x]) {
|
|
1879
|
+
path += `M${x * cellSize},${y * cellSize}h${cellSize}v${cellSize}h-${cellSize}z`;
|
|
1880
|
+
}
|
|
1881
|
+
}
|
|
1882
|
+
}
|
|
1883
|
+
return [
|
|
1884
|
+
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${size} ${size}" width="${size}" height="${size}">`,
|
|
1885
|
+
`<rect width="100%" height="100%" fill="white"/>`,
|
|
1886
|
+
`<path d="${path}" fill="${color}"/>`,
|
|
1887
|
+
`</svg>`
|
|
1888
|
+
].join("");
|
|
1889
|
+
}
|
|
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
|
+
|
|
1695
1970
|
// src/price.ts
|
|
1696
1971
|
var MSAT_PER_BTC2 = 1e11;
|
|
1697
1972
|
var BASIS_POINTS = 1e4;
|
|
@@ -1814,64 +2089,6 @@ function refusals2(answers) {
|
|
|
1814
2089
|
).join("; ");
|
|
1815
2090
|
}
|
|
1816
2091
|
|
|
1817
|
-
// src/qr.ts
|
|
1818
|
-
var import_uqr = require("uqr");
|
|
1819
|
-
var SAFE_COLOR = /^#(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$|^rgba?\([\d.,%/\s]+\)$|^[a-z]+$/i;
|
|
1820
|
-
function encodeForQr(destination) {
|
|
1821
|
-
const scanned = isLnAddress(destination) ? destination : destination.toUpperCase();
|
|
1822
|
-
return `LIGHTNING:${scanned}`;
|
|
1823
|
-
}
|
|
1824
|
-
function invoiceToSvg(destination, options) {
|
|
1825
|
-
return svgOf(encodeForQr(destination), options);
|
|
1826
|
-
}
|
|
1827
|
-
function invoiceToDataUrl(destination, options) {
|
|
1828
|
-
return asDataUrl(invoiceToSvg(destination, options));
|
|
1829
|
-
}
|
|
1830
|
-
function lnurlToSvg(endpoint, options) {
|
|
1831
|
-
return svgOf(toLnurl(endpoint), options);
|
|
1832
|
-
}
|
|
1833
|
-
function lnurlToDataUrl(endpoint, options) {
|
|
1834
|
-
return asDataUrl(lnurlToSvg(endpoint, options));
|
|
1835
|
-
}
|
|
1836
|
-
function spdToSvg(spd, options) {
|
|
1837
|
-
return svgOf(spd, options);
|
|
1838
|
-
}
|
|
1839
|
-
function spdToDataUrl(spd, options) {
|
|
1840
|
-
return asDataUrl(spdToSvg(spd, options));
|
|
1841
|
-
}
|
|
1842
|
-
function svgOf(scanned, options) {
|
|
1843
|
-
const size = options?.size ?? 256;
|
|
1844
|
-
const color = options?.color ?? "#000";
|
|
1845
|
-
if (!SAFE_COLOR.test(color)) throw new Error(`Invalid color: ${color}`);
|
|
1846
|
-
return renderSvg(scanned, size, color);
|
|
1847
|
-
}
|
|
1848
|
-
function asDataUrl(svg) {
|
|
1849
|
-
return `data:image/svg+xml,${encodeURIComponent(svg)}`;
|
|
1850
|
-
}
|
|
1851
|
-
function isLnAddress(destination) {
|
|
1852
|
-
const at = destination.indexOf("@");
|
|
1853
|
-
return at > 0 && destination.slice(at + 1).includes(".");
|
|
1854
|
-
}
|
|
1855
|
-
function renderSvg(content, size, color) {
|
|
1856
|
-
const { data } = (0, import_uqr.encode)(content);
|
|
1857
|
-
const modules = data.length;
|
|
1858
|
-
const cellSize = size / modules;
|
|
1859
|
-
let path = "";
|
|
1860
|
-
for (let y = 0; y < modules; y++) {
|
|
1861
|
-
for (let x = 0; x < modules; x++) {
|
|
1862
|
-
if (data[y][x]) {
|
|
1863
|
-
path += `M${x * cellSize},${y * cellSize}h${cellSize}v${cellSize}h-${cellSize}z`;
|
|
1864
|
-
}
|
|
1865
|
-
}
|
|
1866
|
-
}
|
|
1867
|
-
return [
|
|
1868
|
-
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${size} ${size}" width="${size}" height="${size}">`,
|
|
1869
|
-
`<rect width="100%" height="100%" fill="white"/>`,
|
|
1870
|
-
`<path d="${path}" fill="${color}"/>`,
|
|
1871
|
-
`</svg>`
|
|
1872
|
-
].join("");
|
|
1873
|
-
}
|
|
1874
|
-
|
|
1875
2092
|
// src/webhook.ts
|
|
1876
2093
|
var SIGNATURE_HEADER = "x-signature";
|
|
1877
2094
|
var TIMESTAMP_HEADER = "x-timestamp";
|
|
@@ -1884,9 +2101,9 @@ async function verifyWebhookSignature(body, signature, secret, timestamp, option
|
|
|
1884
2101
|
}
|
|
1885
2102
|
async function parseWebhook(body, signature, secret, timestamp, options = {}) {
|
|
1886
2103
|
if (!await verifyWebhookSignature(body, signature, secret, timestamp, options)) return null;
|
|
1887
|
-
const
|
|
2104
|
+
const text3 = typeof body === "string" ? body : new TextDecoder().decode(body);
|
|
1888
2105
|
try {
|
|
1889
|
-
return paymentFromWire(JSON.parse(
|
|
2106
|
+
return paymentFromWire(JSON.parse(text3));
|
|
1890
2107
|
} catch {
|
|
1891
2108
|
return null;
|
|
1892
2109
|
}
|
|
@@ -1926,9 +2143,11 @@ function signed(timestamp, body) {
|
|
|
1926
2143
|
REQUEST_IN_FLIGHT,
|
|
1927
2144
|
ThunderBridge,
|
|
1928
2145
|
UnverifiedRecipientError,
|
|
2146
|
+
bankRail,
|
|
1929
2147
|
bankTransfer,
|
|
1930
2148
|
bankVerifyEndpoint,
|
|
1931
2149
|
bitstamp,
|
|
2150
|
+
blindLightningRail,
|
|
1932
2151
|
coinbase,
|
|
1933
2152
|
coinmate,
|
|
1934
2153
|
decodeInvoice,
|
|
@@ -1938,6 +2157,7 @@ function signed(timestamp, body) {
|
|
|
1938
2157
|
isProblemType,
|
|
1939
2158
|
isProvablyPaid,
|
|
1940
2159
|
kraken,
|
|
2160
|
+
lightningRail,
|
|
1941
2161
|
lnurlPayEndpoint,
|
|
1942
2162
|
lnurlToDataUrl,
|
|
1943
2163
|
lnurlToSvg,
|
|
@@ -1950,6 +2170,8 @@ function signed(timestamp, body) {
|
|
|
1950
2170
|
preimageMatchesHash,
|
|
1951
2171
|
proveOrigin,
|
|
1952
2172
|
proveSettlement,
|
|
2173
|
+
qrToDataUrl,
|
|
2174
|
+
qrToSvg,
|
|
1953
2175
|
seal,
|
|
1954
2176
|
spdToDataUrl,
|
|
1955
2177
|
spdToSvg,
|