thunder-bridge 1.1.0 → 1.3.0
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 +90 -4
- package/dist/index.cjs +1650 -1302
- package/dist/index.d.cts +217 -166
- package/dist/index.d.ts +217 -166
- package/dist/index.js +1646 -1301
- package/dist/{rail-Dp8bs6uZ.d.cts → rail-DZzlN-bi.d.cts} +150 -9
- package/dist/{rail-Dp8bs6uZ.d.ts → rail-DZzlN-bi.d.ts} +150 -9
- package/dist/server.cjs +974 -382
- package/dist/server.d.cts +47 -41
- package/dist/server.d.ts +47 -41
- package/dist/server.js +965 -382
- package/openapi.yaml +1 -1
- package/package.json +33 -13
package/dist/index.js
CHANGED
|
@@ -1,67 +1,9 @@
|
|
|
1
|
-
// src/errors.ts
|
|
2
|
-
var GatewayCheatError = class extends Error {
|
|
3
|
-
code;
|
|
4
|
-
paymentId;
|
|
5
|
-
constructor(code, paymentId) {
|
|
6
|
-
super(`gateway verification failed: ${code}`);
|
|
7
|
-
this.name = "GatewayCheatError";
|
|
8
|
-
this.code = code;
|
|
9
|
-
this.paymentId = paymentId;
|
|
10
|
-
}
|
|
11
|
-
};
|
|
12
|
-
var UnverifiedRecipientError = class extends Error {
|
|
13
|
-
lnAddress;
|
|
14
|
-
paymentId;
|
|
15
|
-
constructor(lnAddress, paymentId, cause) {
|
|
16
|
-
super(`could not reach ${lnAddress} to verify the invoice: ${String(cause)}`);
|
|
17
|
-
this.name = "UnverifiedRecipientError";
|
|
18
|
-
this.lnAddress = lnAddress;
|
|
19
|
-
this.paymentId = paymentId;
|
|
20
|
-
}
|
|
21
|
-
};
|
|
22
|
-
var ProblemError = class extends Error {
|
|
23
|
-
type;
|
|
24
|
-
title;
|
|
25
|
-
status;
|
|
26
|
-
detail;
|
|
27
|
-
constructor(problem) {
|
|
28
|
-
const title = problem.title ?? "Request failed";
|
|
29
|
-
super(problem.detail ? `${title}: ${problem.detail}` : title);
|
|
30
|
-
this.name = "ProblemError";
|
|
31
|
-
this.type = problem.type ?? "about:blank";
|
|
32
|
-
this.title = title;
|
|
33
|
-
this.status = problem.status ?? 0;
|
|
34
|
-
this.detail = problem.detail ?? null;
|
|
35
|
-
}
|
|
36
|
-
};
|
|
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";
|
|
44
|
-
var IdempotencyConflictError = class extends ProblemError {
|
|
45
|
-
conflict;
|
|
46
|
-
constructor(problem, conflict) {
|
|
47
|
-
super(problem);
|
|
48
|
-
this.name = "IdempotencyConflictError";
|
|
49
|
-
this.conflict = conflict;
|
|
50
|
-
}
|
|
51
|
-
};
|
|
52
|
-
var NoWalletAvailableError = class extends ProblemError {
|
|
53
|
-
wallets;
|
|
54
|
-
constructor(problem, wallets) {
|
|
55
|
-
super({ ...problem, type: NO_WALLET_AVAILABLE });
|
|
56
|
-
this.name = "NoWalletAvailableError";
|
|
57
|
-
this.wallets = wallets;
|
|
58
|
-
}
|
|
59
|
-
};
|
|
60
|
-
|
|
61
1
|
// ../core/bytes.ts
|
|
62
2
|
function bytesToHex(bytes) {
|
|
63
3
|
let hex = "";
|
|
64
|
-
for (const byte of bytes)
|
|
4
|
+
for (const byte of bytes) {
|
|
5
|
+
hex += byte.toString(16).padStart(2, "0");
|
|
6
|
+
}
|
|
65
7
|
return hex;
|
|
66
8
|
}
|
|
67
9
|
function hexToBytes(hex) {
|
|
@@ -179,7 +121,9 @@ function withPadding(bytes) {
|
|
|
179
121
|
return padded;
|
|
180
122
|
}
|
|
181
123
|
function fillSchedule(schedule, reader, block) {
|
|
182
|
-
for (let at = 0; at < 16; at++)
|
|
124
|
+
for (let at = 0; at < 16; at++) {
|
|
125
|
+
schedule[at] = reader.getUint32(block + at * 4, false);
|
|
126
|
+
}
|
|
183
127
|
for (let at = 16; at < SCHEDULE_WORDS; at++) {
|
|
184
128
|
const recent2 = schedule[at - 15];
|
|
185
129
|
const distant = schedule[at - 2];
|
|
@@ -213,12 +157,16 @@ function compress(state, schedule) {
|
|
|
213
157
|
a = carried + (mixedA + majority | 0) | 0;
|
|
214
158
|
}
|
|
215
159
|
const rolling = [a, b, c, d, e, f, g, h];
|
|
216
|
-
for (let at = 0; at < state.length; at++)
|
|
160
|
+
for (let at = 0; at < state.length; at++) {
|
|
161
|
+
state[at] = state[at] + rolling[at] | 0;
|
|
162
|
+
}
|
|
217
163
|
}
|
|
218
164
|
function asBytes(state) {
|
|
219
165
|
const digest = new Uint8Array(state.length * 4);
|
|
220
166
|
const writer = new DataView(digest.buffer);
|
|
221
|
-
for (let at = 0; at < state.length; at++)
|
|
167
|
+
for (let at = 0; at < state.length; at++) {
|
|
168
|
+
writer.setUint32(at * 4, state[at], false);
|
|
169
|
+
}
|
|
222
170
|
return digest;
|
|
223
171
|
}
|
|
224
172
|
function rotate(value, by) {
|
|
@@ -259,20 +207,30 @@ function decodeInvoice(bolt11) {
|
|
|
259
207
|
};
|
|
260
208
|
}
|
|
261
209
|
function preimageMatchesHash(preimage, paymentHash) {
|
|
262
|
-
if (!isHex(preimage))
|
|
210
|
+
if (!isHex(preimage)) {
|
|
211
|
+
return false;
|
|
212
|
+
}
|
|
263
213
|
return bytesToHex(sha256(hexToBytes(preimage))) === paymentHash.toLowerCase();
|
|
264
214
|
}
|
|
265
215
|
function splitBech32(bolt11) {
|
|
266
216
|
const lower = bolt11.toLowerCase();
|
|
267
|
-
if (isBolt12Encoding(lower))
|
|
217
|
+
if (isBolt12Encoding(lower)) {
|
|
218
|
+
return null;
|
|
219
|
+
}
|
|
268
220
|
const separator = lower.lastIndexOf("1");
|
|
269
|
-
if (separator < 1)
|
|
221
|
+
if (separator < 1) {
|
|
222
|
+
return null;
|
|
223
|
+
}
|
|
270
224
|
const dataChars = lower.slice(separator + 1);
|
|
271
|
-
if (dataChars.length < CHECKSUM_WORDS)
|
|
225
|
+
if (dataChars.length < CHECKSUM_WORDS) {
|
|
226
|
+
return null;
|
|
227
|
+
}
|
|
272
228
|
const words = [];
|
|
273
229
|
for (const char of dataChars) {
|
|
274
230
|
const value = BECH32_CHARSET.indexOf(char);
|
|
275
|
-
if (value === -1)
|
|
231
|
+
if (value === -1) {
|
|
232
|
+
return null;
|
|
233
|
+
}
|
|
276
234
|
words.push(value);
|
|
277
235
|
}
|
|
278
236
|
return { hrp: lower.slice(0, separator), words: words.slice(0, -CHECKSUM_WORDS) };
|
|
@@ -288,25 +246,35 @@ function taggedFields(words) {
|
|
|
288
246
|
const lengthWords = words[cursor + 1] * 32 + words[cursor + 2];
|
|
289
247
|
const dataStart = cursor + 1 + TAGGED_FIELD_LENGTH_WORDS;
|
|
290
248
|
const dataEnd = dataStart + lengthWords;
|
|
291
|
-
if (dataEnd > signatureStart)
|
|
292
|
-
|
|
249
|
+
if (dataEnd > signatureStart) {
|
|
250
|
+
return fields;
|
|
251
|
+
}
|
|
252
|
+
if (!fields.has(words[cursor])) {
|
|
253
|
+
fields.set(words[cursor], words.slice(dataStart, dataEnd));
|
|
254
|
+
}
|
|
293
255
|
cursor = dataEnd;
|
|
294
256
|
}
|
|
295
257
|
return fields;
|
|
296
258
|
}
|
|
297
259
|
function hexTag(fields, tag, expectedWords) {
|
|
298
260
|
const words = fields.get(tag);
|
|
299
|
-
if (words === void 0 || words.length !== expectedWords)
|
|
261
|
+
if (words === void 0 || words.length !== expectedWords) {
|
|
262
|
+
return null;
|
|
263
|
+
}
|
|
300
264
|
return bytesToHex(wordsToBytes(words));
|
|
301
265
|
}
|
|
302
266
|
function expiryOf(words, fields) {
|
|
303
|
-
if (words.length < TIMESTAMP_WORDS + SIGNATURE_WORDS)
|
|
267
|
+
if (words.length < TIMESTAMP_WORDS + SIGNATURE_WORDS) {
|
|
268
|
+
return null;
|
|
269
|
+
}
|
|
304
270
|
const expiry = fields.get(EXPIRY_TAG);
|
|
305
271
|
return readNumber(words.slice(0, TIMESTAMP_WORDS)) + (expiry ? readNumber(expiry) : DEFAULT_EXPIRY_SECS);
|
|
306
272
|
}
|
|
307
273
|
function amountFromHrp(hrp) {
|
|
308
274
|
const match = HRP_AMOUNT.exec(hrp);
|
|
309
|
-
if (match === null)
|
|
275
|
+
if (match === null) {
|
|
276
|
+
return null;
|
|
277
|
+
}
|
|
310
278
|
const [, digits, multiplier] = match;
|
|
311
279
|
return multiplier ? Number(digits) * HRP_MULTIPLIER_MSAT[multiplier] : Number(digits) * MSAT_PER_BTC;
|
|
312
280
|
}
|
|
@@ -328,105 +296,6 @@ function wordsToBytes(words) {
|
|
|
328
296
|
return Uint8Array.from(bytes);
|
|
329
297
|
}
|
|
330
298
|
|
|
331
|
-
// ../core/ed25519.ts
|
|
332
|
-
var ALGORITHM = { name: "Ed25519" };
|
|
333
|
-
var SEED_BYTES = 32;
|
|
334
|
-
var SIGNATURE_BYTES = 64;
|
|
335
|
-
var PUBLIC_KEY_BYTES = 32;
|
|
336
|
-
var PKCS8_HEADER = hexToBytes("302e020100300506032b657004220420");
|
|
337
|
-
async function signingKeyFromSeed(seed) {
|
|
338
|
-
if (seed.length !== SEED_BYTES) throw new Error(`an ed25519 seed is ${SEED_BYTES} bytes`);
|
|
339
|
-
const wrapped = new Uint8Array(PKCS8_HEADER.length + SEED_BYTES);
|
|
340
|
-
wrapped.set(PKCS8_HEADER);
|
|
341
|
-
wrapped.set(seed, PKCS8_HEADER.length);
|
|
342
|
-
const key = await crypto.subtle.importKey("pkcs8", asBuffer(wrapped), ALGORITHM, true, ["sign"]);
|
|
343
|
-
const jwk = await crypto.subtle.exportKey("jwk", key);
|
|
344
|
-
return {
|
|
345
|
-
publicKeyHex: bytesToHex(fromBase64Url(jwk.x ?? "")),
|
|
346
|
-
sign: async (payload) => bytesToHex(new Uint8Array(await crypto.subtle.sign(ALGORITHM, key, asBuffer(payload))))
|
|
347
|
-
};
|
|
348
|
-
}
|
|
349
|
-
async function verifyHex(publicKeyHex, signatureHex, payload) {
|
|
350
|
-
if (!isHex(publicKeyHex) || publicKeyHex.length !== PUBLIC_KEY_BYTES * 2) return false;
|
|
351
|
-
if (!isHex(signatureHex) || signatureHex.length !== SIGNATURE_BYTES * 2) return false;
|
|
352
|
-
try {
|
|
353
|
-
const key = await crypto.subtle.importKey(
|
|
354
|
-
"raw",
|
|
355
|
-
asBuffer(hexToBytes(publicKeyHex)),
|
|
356
|
-
ALGORITHM,
|
|
357
|
-
false,
|
|
358
|
-
["verify"]
|
|
359
|
-
);
|
|
360
|
-
return await crypto.subtle.verify(
|
|
361
|
-
ALGORITHM,
|
|
362
|
-
key,
|
|
363
|
-
asBuffer(hexToBytes(signatureHex)),
|
|
364
|
-
asBuffer(payload)
|
|
365
|
-
);
|
|
366
|
-
} catch {
|
|
367
|
-
return false;
|
|
368
|
-
}
|
|
369
|
-
}
|
|
370
|
-
function fromBase64Url(text2) {
|
|
371
|
-
const padded = text2.replace(/-/g, "+").replace(/_/g, "/");
|
|
372
|
-
const raw = atob(padded + "=".repeat((4 - padded.length % 4) % 4));
|
|
373
|
-
const bytes = new Uint8Array(raw.length);
|
|
374
|
-
for (let at = 0; at < raw.length; at++) bytes[at] = raw.charCodeAt(at);
|
|
375
|
-
return bytes;
|
|
376
|
-
}
|
|
377
|
-
function asBuffer(bytes) {
|
|
378
|
-
return bytes.buffer instanceof ArrayBuffer ? bytes : new Uint8Array(bytes);
|
|
379
|
-
}
|
|
380
|
-
|
|
381
|
-
// ../core/hmac.ts
|
|
382
|
-
async function hmacHex(secret, payload) {
|
|
383
|
-
const raw = typeof secret === "string" ? new TextEncoder().encode(secret) : secret;
|
|
384
|
-
const key = await crypto.subtle.importKey(
|
|
385
|
-
"raw",
|
|
386
|
-
asBuffer2(raw),
|
|
387
|
-
{ name: "HMAC", hash: "SHA-256" },
|
|
388
|
-
false,
|
|
389
|
-
["sign"]
|
|
390
|
-
);
|
|
391
|
-
const message = typeof payload === "string" ? new TextEncoder().encode(payload) : payload;
|
|
392
|
-
return bytesToHex(new Uint8Array(await crypto.subtle.sign("HMAC", key, asBuffer2(message))));
|
|
393
|
-
}
|
|
394
|
-
function equalInConstantTime(one, other) {
|
|
395
|
-
if (one.length !== other.length) return false;
|
|
396
|
-
let difference = 0;
|
|
397
|
-
for (let at = 0; at < one.length; at++) difference |= one.charCodeAt(at) ^ other.charCodeAt(at);
|
|
398
|
-
return difference === 0;
|
|
399
|
-
}
|
|
400
|
-
function asBuffer2(bytes) {
|
|
401
|
-
return bytes instanceof Uint8Array && bytes.buffer instanceof ArrayBuffer ? bytes : new Uint8Array(bytes);
|
|
402
|
-
}
|
|
403
|
-
|
|
404
|
-
// ../core/caller.ts
|
|
405
|
-
var KEY_HEADER = "x-client-key";
|
|
406
|
-
var SIGNATURE_HEADER = "x-signature";
|
|
407
|
-
var TIMESTAMP_HEADER = "x-timestamp";
|
|
408
|
-
var PREFIX = "ed25519=";
|
|
409
|
-
var SIGNING_LABEL = "request-signing";
|
|
410
|
-
async function callerKey(secret) {
|
|
411
|
-
return await signingKeyFromSeed(hexToBytes(await hmacHex(secret, SIGNING_LABEL)));
|
|
412
|
-
}
|
|
413
|
-
function paymentNamedBy(caller, paymentHash) {
|
|
414
|
-
return sha256Hex(`${caller}.payment-id.${paymentHash}`);
|
|
415
|
-
}
|
|
416
|
-
async function signedAs(key, method, path, body) {
|
|
417
|
-
const timestamp = String(Math.floor(Date.now() / 1e3));
|
|
418
|
-
return {
|
|
419
|
-
[KEY_HEADER]: key.publicKeyHex,
|
|
420
|
-
[TIMESTAMP_HEADER]: timestamp,
|
|
421
|
-
[SIGNATURE_HEADER]: `${PREFIX}${await key.sign(spoken(timestamp, method, path, body))}`
|
|
422
|
-
};
|
|
423
|
-
}
|
|
424
|
-
function spoken(timestamp, method, path, body) {
|
|
425
|
-
return new TextEncoder().encode(
|
|
426
|
-
`${timestamp}.${method.toUpperCase()}.${path}.${sha256Hex(body)}`
|
|
427
|
-
);
|
|
428
|
-
}
|
|
429
|
-
|
|
430
299
|
// ../core/url.ts
|
|
431
300
|
var PRIVATE_SUFFIXES = ["localhost", "local", "internal", "lan", "arpa", "test", "invalid"];
|
|
432
301
|
function publicHttps(raw) {
|
|
@@ -449,11 +318,17 @@ function publicAddress(literal) {
|
|
|
449
318
|
return literal.includes(":") ? ipv6IsGlobal(literal) : ipv4IsGlobal(literal);
|
|
450
319
|
}
|
|
451
320
|
function hostIsPublic(host) {
|
|
452
|
-
if (host.startsWith("["))
|
|
321
|
+
if (host.startsWith("[")) {
|
|
322
|
+
return publicAddress(host.slice(1, -1));
|
|
323
|
+
}
|
|
453
324
|
const name = host.replace(/\.$/, "");
|
|
454
|
-
if (/^\d+\.\d+\.\d+\.\d+$/.test(name))
|
|
325
|
+
if (/^\d+\.\d+\.\d+\.\d+$/.test(name)) {
|
|
326
|
+
return publicAddress(name);
|
|
327
|
+
}
|
|
455
328
|
const labels = name.split(".");
|
|
456
|
-
if (labels.length < 2 || labels.some((label) => label === ""))
|
|
329
|
+
if (labels.length < 2 || labels.some((label) => label === "")) {
|
|
330
|
+
return false;
|
|
331
|
+
}
|
|
457
332
|
return !PRIVATE_SUFFIXES.includes(labels[labels.length - 1]);
|
|
458
333
|
}
|
|
459
334
|
function ipv4IsGlobal(literal) {
|
|
@@ -462,25 +337,43 @@ function ipv4IsGlobal(literal) {
|
|
|
462
337
|
return false;
|
|
463
338
|
}
|
|
464
339
|
const [a, b] = parts;
|
|
465
|
-
if (a === 0 || a === 10 || a === 127 || a >= 240)
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
if (a ===
|
|
469
|
-
|
|
340
|
+
if (a === 0 || a === 10 || a === 127 || a >= 240) {
|
|
341
|
+
return false;
|
|
342
|
+
}
|
|
343
|
+
if (a === 100 && b >= 64 && b < 128) {
|
|
344
|
+
return false;
|
|
345
|
+
}
|
|
346
|
+
if (a === 169 && b === 254) {
|
|
347
|
+
return false;
|
|
348
|
+
}
|
|
349
|
+
if (a === 172 && b >= 16 && b < 32) {
|
|
350
|
+
return false;
|
|
351
|
+
}
|
|
352
|
+
if (a === 192 && b === 168) {
|
|
353
|
+
return false;
|
|
354
|
+
}
|
|
470
355
|
return true;
|
|
471
356
|
}
|
|
472
357
|
function ipv6IsGlobal(literal) {
|
|
473
358
|
const groups = ipv6Groups(literal.toLowerCase());
|
|
474
|
-
if (groups.length !== 8 || groups.some(Number.isNaN))
|
|
359
|
+
if (groups.length !== 8 || groups.some(Number.isNaN)) {
|
|
360
|
+
return false;
|
|
361
|
+
}
|
|
475
362
|
const leading = groups.slice(0, 5).every((group) => group === 0);
|
|
476
|
-
if (leading && groups[5] === 0 && groups[6] === 0 && groups[7] <= 1)
|
|
363
|
+
if (leading && groups[5] === 0 && groups[6] === 0 && groups[7] <= 1) {
|
|
364
|
+
return false;
|
|
365
|
+
}
|
|
477
366
|
if (leading && groups[5] === 65535) {
|
|
478
367
|
const [high, low] = [groups[6], groups[7]];
|
|
479
368
|
return ipv4IsGlobal(`${high >> 8}.${high & 255}.${low >> 8}.${low & 255}`);
|
|
480
369
|
}
|
|
481
370
|
const first = groups[0];
|
|
482
|
-
if ((first & 65024) === 64512)
|
|
483
|
-
|
|
371
|
+
if ((first & 65024) === 64512) {
|
|
372
|
+
return false;
|
|
373
|
+
}
|
|
374
|
+
if ((first & 65472) === 65152) {
|
|
375
|
+
return false;
|
|
376
|
+
}
|
|
484
377
|
return true;
|
|
485
378
|
}
|
|
486
379
|
function ipv6Groups(address) {
|
|
@@ -491,74 +384,276 @@ function ipv6Groups(address) {
|
|
|
491
384
|
return [...left, ...gap, ...right].map((group) => parseInt(group, 16));
|
|
492
385
|
}
|
|
493
386
|
|
|
494
|
-
//
|
|
495
|
-
var
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
);
|
|
501
|
-
if (
|
|
502
|
-
|
|
503
|
-
const invoice = decodeInvoice(payment.bolt11);
|
|
504
|
-
if (invoice.paymentHash === null || !equalIgnoringCase(invoice.paymentHash, payment.paymentHash)) {
|
|
505
|
-
throw cheat("hash_mismatch");
|
|
506
|
-
}
|
|
507
|
-
if (invoice.amountMsat !== request.amountMsat) throw cheat("amount_mismatch");
|
|
508
|
-
const payRequest = await payRequestFor(listed, payment);
|
|
509
|
-
if (typeof payRequest.metadata !== "string" || typeof payRequest.callback !== "string") {
|
|
510
|
-
throw new UnverifiedRecipientError(payment.lnAddress, payment.id, "no payRequest served");
|
|
511
|
-
}
|
|
512
|
-
if (invoice.descriptionHash !== sha256Hex(payRequest.metadata)) {
|
|
513
|
-
throw cheat("description_hash_mismatch");
|
|
514
|
-
}
|
|
515
|
-
if (!sameOrigin(payment.verifyUrl, payRequest.callback)) throw cheat("verify_url_foreign");
|
|
516
|
-
const issued = await reachable(payment.verifyUrl, payment);
|
|
517
|
-
if (typeof issued.pr !== "string" || !equalIgnoringCase(issued.pr, payment.bolt11)) {
|
|
518
|
-
throw cheat("invoice_not_issued");
|
|
387
|
+
// ../core/lnurl.ts
|
|
388
|
+
var BECH32_CHARSET2 = "qpzry9x8gf2tvdw0s3jn54khce6mua7l";
|
|
389
|
+
var BECH32_GENERATOR = [996825010, 642813549, 513874426, 1027748829, 705979059];
|
|
390
|
+
var CHECKSUM_SLOTS = [0, 1, 2, 3, 4, 5];
|
|
391
|
+
var LNURL_HRP = "lnurl";
|
|
392
|
+
function toLnurl(endpoint) {
|
|
393
|
+
const scheme = parsedScheme(endpoint);
|
|
394
|
+
if (scheme !== "https:" && scheme !== "http:") {
|
|
395
|
+
throw new Error(`${endpoint} is not an http or https URL`);
|
|
519
396
|
}
|
|
397
|
+
const words = toWords(new TextEncoder().encode(endpoint));
|
|
398
|
+
const data = [...words, ...checksumWords(LNURL_HRP, words)];
|
|
399
|
+
return `${LNURL_HRP}1${data.map((word) => BECH32_CHARSET2[word]).join("")}`.toUpperCase();
|
|
520
400
|
}
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
throw new GatewayCheatError("preimage_mismatch", payment.id);
|
|
401
|
+
function parsedScheme(endpoint) {
|
|
402
|
+
try {
|
|
403
|
+
return new URL(endpoint).protocol;
|
|
404
|
+
} catch {
|
|
405
|
+
return null;
|
|
527
406
|
}
|
|
528
|
-
return verified.preimage;
|
|
529
|
-
}
|
|
530
|
-
function isProvablyPaid(payment) {
|
|
531
|
-
if (payment.status !== "paid" || payment.preimage === null) return false;
|
|
532
|
-
const invoiceHash = decodeInvoice(payment.bolt11).paymentHash;
|
|
533
|
-
if (invoiceHash === null || !equalIgnoringCase(invoiceHash, payment.paymentHash)) return false;
|
|
534
|
-
return preimageMatchesHash(payment.preimage, invoiceHash);
|
|
535
407
|
}
|
|
536
|
-
|
|
537
|
-
const
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
408
|
+
function toWords(bytes) {
|
|
409
|
+
const words = [];
|
|
410
|
+
let accumulator = 0;
|
|
411
|
+
let bits = 0;
|
|
412
|
+
for (const byte of bytes) {
|
|
413
|
+
accumulator = accumulator << 8 | byte;
|
|
414
|
+
bits += 8;
|
|
415
|
+
while (bits >= 5) {
|
|
416
|
+
bits -= 5;
|
|
417
|
+
words.push(accumulator >> bits & 31);
|
|
418
|
+
}
|
|
542
419
|
}
|
|
543
|
-
|
|
420
|
+
if (bits > 0) {
|
|
421
|
+
words.push(accumulator << 5 - bits & 31);
|
|
422
|
+
}
|
|
423
|
+
return words;
|
|
544
424
|
}
|
|
545
|
-
|
|
425
|
+
function checksumWords(hrp, words) {
|
|
426
|
+
const expanded = [...hrp].map((char) => char.charCodeAt(0));
|
|
427
|
+
const values = [
|
|
428
|
+
...expanded.map((code) => code >> 5),
|
|
429
|
+
0,
|
|
430
|
+
...expanded.map((code) => code & 31),
|
|
431
|
+
...words,
|
|
432
|
+
...CHECKSUM_SLOTS.map(() => 0)
|
|
433
|
+
];
|
|
434
|
+
const polymod = bech32Polymod(values) ^ 1;
|
|
435
|
+
return CHECKSUM_SLOTS.map((slot) => polymod >> 5 * (5 - slot) & 31);
|
|
436
|
+
}
|
|
437
|
+
function bech32Polymod(values) {
|
|
438
|
+
let checksum = 1;
|
|
439
|
+
for (const value of values) {
|
|
440
|
+
const top = checksum >> 25;
|
|
441
|
+
checksum = (checksum & 33554431) << 5 ^ value;
|
|
442
|
+
for (let bit = 0; bit < 5; bit++) {
|
|
443
|
+
if (top >> bit & 1) {
|
|
444
|
+
checksum ^= BECH32_GENERATOR[bit];
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
return checksum;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// ../core/sealed.ts
|
|
452
|
+
var VERSION = "v1";
|
|
453
|
+
var IV_BYTES = 12;
|
|
454
|
+
var MIN_SECRET_CHARS = 32;
|
|
455
|
+
var MAX_PLAIN_BYTES = 3e3;
|
|
456
|
+
var INFO = new TextEncoder().encode("thunder-bridge/sealed");
|
|
457
|
+
async function seal(secret, plaintext) {
|
|
458
|
+
const body = new TextEncoder().encode(plaintext);
|
|
459
|
+
if (body.length > MAX_PLAIN_BYTES) {
|
|
460
|
+
throw new Error(`sealed takes at most ${MAX_PLAIN_BYTES} bytes, this was ${body.length}`);
|
|
461
|
+
}
|
|
462
|
+
const key = await keyFor(secret);
|
|
463
|
+
const iv = crypto.getRandomValues(new Uint8Array(IV_BYTES));
|
|
464
|
+
const cipher = new Uint8Array(await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, body));
|
|
465
|
+
const joined = new Uint8Array(iv.length + cipher.length);
|
|
466
|
+
joined.set(iv);
|
|
467
|
+
joined.set(cipher, iv.length);
|
|
468
|
+
return `${VERSION}.${toBase64Url(joined)}`;
|
|
469
|
+
}
|
|
470
|
+
async function unseal(secret, sealed) {
|
|
471
|
+
const key = await keyFor(secret);
|
|
472
|
+
if (!sealed.startsWith(`${VERSION}.`)) {
|
|
473
|
+
return null;
|
|
474
|
+
}
|
|
475
|
+
const joined = fromBase64Url(sealed.slice(VERSION.length + 1));
|
|
476
|
+
if (joined === null || joined.length <= IV_BYTES) {
|
|
477
|
+
return null;
|
|
478
|
+
}
|
|
546
479
|
try {
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
return body;
|
|
556
|
-
} catch (cause) {
|
|
557
|
-
throw new UnverifiedRecipientError(payment.lnAddress, payment.id, cause);
|
|
480
|
+
const body = await crypto.subtle.decrypt(
|
|
481
|
+
{ name: "AES-GCM", iv: joined.slice(0, IV_BYTES) },
|
|
482
|
+
key,
|
|
483
|
+
joined.slice(IV_BYTES)
|
|
484
|
+
);
|
|
485
|
+
return new TextDecoder().decode(body);
|
|
486
|
+
} catch {
|
|
487
|
+
return null;
|
|
558
488
|
}
|
|
559
489
|
}
|
|
560
|
-
function
|
|
561
|
-
|
|
490
|
+
async function keyFor(secret) {
|
|
491
|
+
if (secret.length < MIN_SECRET_CHARS) {
|
|
492
|
+
throw new Error(`the sealing secret needs ${MIN_SECRET_CHARS} characters of randomness`);
|
|
493
|
+
}
|
|
494
|
+
const material = await crypto.subtle.importKey(
|
|
495
|
+
"raw",
|
|
496
|
+
new TextEncoder().encode(secret),
|
|
497
|
+
"HKDF",
|
|
498
|
+
false,
|
|
499
|
+
["deriveKey"]
|
|
500
|
+
);
|
|
501
|
+
return crypto.subtle.deriveKey(
|
|
502
|
+
{ name: "HKDF", hash: "SHA-256", salt: new Uint8Array(0), info: INFO },
|
|
503
|
+
material,
|
|
504
|
+
{ name: "AES-GCM", length: 256 },
|
|
505
|
+
false,
|
|
506
|
+
["encrypt", "decrypt"]
|
|
507
|
+
);
|
|
508
|
+
}
|
|
509
|
+
function toBase64Url(bytes) {
|
|
510
|
+
let binary = "";
|
|
511
|
+
for (const byte of bytes) {
|
|
512
|
+
binary += String.fromCharCode(byte);
|
|
513
|
+
}
|
|
514
|
+
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
515
|
+
}
|
|
516
|
+
function fromBase64Url(text2) {
|
|
517
|
+
if (text2.length === 0 || !/^[A-Za-z0-9_-]+$/.test(text2)) {
|
|
518
|
+
return null;
|
|
519
|
+
}
|
|
520
|
+
try {
|
|
521
|
+
const binary = atob(text2.replace(/-/g, "+").replace(/_/g, "/"));
|
|
522
|
+
const bytes = new Uint8Array(binary.length);
|
|
523
|
+
for (let at = 0; at < bytes.length; at++) {
|
|
524
|
+
bytes[at] = binary.charCodeAt(at);
|
|
525
|
+
}
|
|
526
|
+
return bytes;
|
|
527
|
+
} catch {
|
|
528
|
+
return null;
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
// ../core/hmac.ts
|
|
533
|
+
async function hmacHex(secret, payload) {
|
|
534
|
+
const raw = typeof secret === "string" ? new TextEncoder().encode(secret) : secret;
|
|
535
|
+
const key = await crypto.subtle.importKey(
|
|
536
|
+
"raw",
|
|
537
|
+
asBuffer(raw),
|
|
538
|
+
{ name: "HMAC", hash: "SHA-256" },
|
|
539
|
+
false,
|
|
540
|
+
["sign"]
|
|
541
|
+
);
|
|
542
|
+
const message = typeof payload === "string" ? new TextEncoder().encode(payload) : payload;
|
|
543
|
+
return bytesToHex(new Uint8Array(await crypto.subtle.sign("HMAC", key, asBuffer(message))));
|
|
544
|
+
}
|
|
545
|
+
function equalInConstantTime(one, other) {
|
|
546
|
+
if (one.length !== other.length) {
|
|
547
|
+
return false;
|
|
548
|
+
}
|
|
549
|
+
let difference = 0;
|
|
550
|
+
for (let at = 0; at < one.length; at++) {
|
|
551
|
+
difference |= one.charCodeAt(at) ^ other.charCodeAt(at);
|
|
552
|
+
}
|
|
553
|
+
return difference === 0;
|
|
554
|
+
}
|
|
555
|
+
function asBuffer(bytes) {
|
|
556
|
+
return bytes instanceof Uint8Array && bytes.buffer instanceof ArrayBuffer ? bytes : new Uint8Array(bytes);
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
// src/currency.ts
|
|
560
|
+
var ISO_4217_MINOR_UNITS = {
|
|
561
|
+
BHD: 3,
|
|
562
|
+
BIF: 0,
|
|
563
|
+
CLP: 0,
|
|
564
|
+
CZK: 2,
|
|
565
|
+
DJF: 0,
|
|
566
|
+
EUR: 2,
|
|
567
|
+
GBP: 2,
|
|
568
|
+
ISK: 0,
|
|
569
|
+
IQD: 3,
|
|
570
|
+
JOD: 3,
|
|
571
|
+
JPY: 0,
|
|
572
|
+
KRW: 0,
|
|
573
|
+
KWD: 3,
|
|
574
|
+
LYD: 3,
|
|
575
|
+
OMR: 3,
|
|
576
|
+
PLN: 2,
|
|
577
|
+
PYG: 0,
|
|
578
|
+
RWF: 0,
|
|
579
|
+
TND: 3,
|
|
580
|
+
UGX: 0,
|
|
581
|
+
USD: 2,
|
|
582
|
+
VND: 0,
|
|
583
|
+
VUV: 0,
|
|
584
|
+
XAF: 0,
|
|
585
|
+
XOF: 0,
|
|
586
|
+
XPF: 0
|
|
587
|
+
};
|
|
588
|
+
function minorUnitsOf(currency) {
|
|
589
|
+
const digits = ISO_4217_MINOR_UNITS[currency.toUpperCase()];
|
|
590
|
+
if (digits === void 0) {
|
|
591
|
+
throw new Error(
|
|
592
|
+
`${currency.toUpperCase()} is not a currency this knows the ISO 4217 minor unit of`
|
|
593
|
+
);
|
|
594
|
+
}
|
|
595
|
+
return digits;
|
|
596
|
+
}
|
|
597
|
+
function minorScaleOf(currency) {
|
|
598
|
+
return 10 ** minorUnitsOf(currency);
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
// ../core/ed25519.ts
|
|
602
|
+
var ALGORITHM = { name: "Ed25519" };
|
|
603
|
+
var SEED_BYTES = 32;
|
|
604
|
+
var SIGNATURE_BYTES = 64;
|
|
605
|
+
var PUBLIC_KEY_BYTES = 32;
|
|
606
|
+
var PKCS8_HEADER = hexToBytes("302e020100300506032b657004220420");
|
|
607
|
+
async function signingKeyFromSeed(seed) {
|
|
608
|
+
if (seed.length !== SEED_BYTES) {
|
|
609
|
+
throw new Error(`an ed25519 seed is ${SEED_BYTES} bytes`);
|
|
610
|
+
}
|
|
611
|
+
const wrapped = new Uint8Array(PKCS8_HEADER.length + SEED_BYTES);
|
|
612
|
+
wrapped.set(PKCS8_HEADER);
|
|
613
|
+
wrapped.set(seed, PKCS8_HEADER.length);
|
|
614
|
+
const key = await crypto.subtle.importKey("pkcs8", asBuffer2(wrapped), ALGORITHM, true, ["sign"]);
|
|
615
|
+
const jwk = await crypto.subtle.exportKey("jwk", key);
|
|
616
|
+
return {
|
|
617
|
+
publicKeyHex: bytesToHex(fromBase64Url2(jwk.x ?? "")),
|
|
618
|
+
sign: async (payload) => bytesToHex(new Uint8Array(await crypto.subtle.sign(ALGORITHM, key, asBuffer2(payload))))
|
|
619
|
+
};
|
|
620
|
+
}
|
|
621
|
+
async function verifyHex(publicKeyHex, signatureHex, payload) {
|
|
622
|
+
if (!isHex(publicKeyHex) || publicKeyHex.length !== PUBLIC_KEY_BYTES * 2) {
|
|
623
|
+
return false;
|
|
624
|
+
}
|
|
625
|
+
if (!isHex(signatureHex) || signatureHex.length !== SIGNATURE_BYTES * 2) {
|
|
626
|
+
return false;
|
|
627
|
+
}
|
|
628
|
+
try {
|
|
629
|
+
const key = await crypto.subtle.importKey(
|
|
630
|
+
"raw",
|
|
631
|
+
asBuffer2(hexToBytes(publicKeyHex)),
|
|
632
|
+
ALGORITHM,
|
|
633
|
+
false,
|
|
634
|
+
["verify"]
|
|
635
|
+
);
|
|
636
|
+
return await crypto.subtle.verify(
|
|
637
|
+
ALGORITHM,
|
|
638
|
+
key,
|
|
639
|
+
asBuffer2(hexToBytes(signatureHex)),
|
|
640
|
+
asBuffer2(payload)
|
|
641
|
+
);
|
|
642
|
+
} catch {
|
|
643
|
+
return false;
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
function fromBase64Url2(text2) {
|
|
647
|
+
const padded = text2.replace(/-/g, "+").replace(/_/g, "/");
|
|
648
|
+
const raw = atob(padded + "=".repeat((4 - padded.length % 4) % 4));
|
|
649
|
+
const bytes = new Uint8Array(raw.length);
|
|
650
|
+
for (let at = 0; at < raw.length; at++) {
|
|
651
|
+
bytes[at] = raw.charCodeAt(at);
|
|
652
|
+
}
|
|
653
|
+
return bytes;
|
|
654
|
+
}
|
|
655
|
+
function asBuffer2(bytes) {
|
|
656
|
+
return bytes.buffer instanceof ArrayBuffer ? bytes : new Uint8Array(bytes);
|
|
562
657
|
}
|
|
563
658
|
|
|
564
659
|
// src/wire.ts
|
|
@@ -571,12 +666,13 @@ var REASONS = /* @__PURE__ */ new Set([
|
|
|
571
666
|
"cannot-prove-delivery",
|
|
572
667
|
"invoice-refused"
|
|
573
668
|
]);
|
|
574
|
-
function createRequestBody(params, trigger) {
|
|
669
|
+
function createRequestBody(params, trigger, replay) {
|
|
575
670
|
return JSON.stringify({
|
|
576
671
|
ln_addresses: params.lnAddresses,
|
|
577
672
|
incoming_amount: toAmount(params.amountMsat),
|
|
578
673
|
webhook: params.webhookUrl ? { url: params.webhookUrl } : void 0,
|
|
579
|
-
trigger: trigger ?? void 0
|
|
674
|
+
trigger: trigger ?? void 0,
|
|
675
|
+
replay
|
|
580
676
|
});
|
|
581
677
|
}
|
|
582
678
|
function quoteRequestBody(params) {
|
|
@@ -587,7 +683,9 @@ function quoteRequestBody(params) {
|
|
|
587
683
|
}
|
|
588
684
|
function quoteFromWire(body) {
|
|
589
685
|
const wire = asObject(body);
|
|
590
|
-
if (wire === null)
|
|
686
|
+
if (wire === null) {
|
|
687
|
+
return null;
|
|
688
|
+
}
|
|
591
689
|
const lnAddress = text(wire["ln_address"]);
|
|
592
690
|
const amountMsat = msatFrom(wire["amount"], 1);
|
|
593
691
|
const feeMsat = msatFrom(wire["fee"], 0);
|
|
@@ -595,13 +693,19 @@ function quoteFromWire(body) {
|
|
|
595
693
|
const maxMsat = msatFrom(wire["max_amount"], 0);
|
|
596
694
|
const metadata = text(wire["metadata"]);
|
|
597
695
|
const refusals3 = refusalsFrom(wire["refusals"]);
|
|
598
|
-
if (lnAddress === null || amountMsat === null || feeMsat === null)
|
|
599
|
-
|
|
696
|
+
if (lnAddress === null || amountMsat === null || feeMsat === null) {
|
|
697
|
+
return null;
|
|
698
|
+
}
|
|
699
|
+
if (minMsat === null || maxMsat === null || metadata === null || refusals3 === null) {
|
|
700
|
+
return null;
|
|
701
|
+
}
|
|
600
702
|
return { lnAddress, amountMsat, feeMsat, minMsat, maxMsat, metadata, refusals: refusals3 };
|
|
601
703
|
}
|
|
602
704
|
function paymentFromWire(body) {
|
|
603
705
|
const wire = asObject(body);
|
|
604
|
-
if (wire === null)
|
|
706
|
+
if (wire === null) {
|
|
707
|
+
return null;
|
|
708
|
+
}
|
|
605
709
|
const id = text(wire["id"]);
|
|
606
710
|
const lnAddress = text(wire["ln_address"]);
|
|
607
711
|
const bolt11 = text(wire["bolt11"]);
|
|
@@ -612,11 +716,21 @@ function paymentFromWire(body) {
|
|
|
612
716
|
const createdAt = secondsFrom(wire["created_at"]);
|
|
613
717
|
const status = wire["status"];
|
|
614
718
|
const preimage = wire["preimage"] ?? null;
|
|
615
|
-
if (id === null || lnAddress === null || bolt11 === null)
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
if (
|
|
619
|
-
|
|
719
|
+
if (id === null || lnAddress === null || bolt11 === null) {
|
|
720
|
+
return null;
|
|
721
|
+
}
|
|
722
|
+
if (paymentHash === null || verifyUrl === null) {
|
|
723
|
+
return null;
|
|
724
|
+
}
|
|
725
|
+
if (amountMsat === null || expiresAt === null || createdAt === null) {
|
|
726
|
+
return null;
|
|
727
|
+
}
|
|
728
|
+
if (!isStatus(status)) {
|
|
729
|
+
return null;
|
|
730
|
+
}
|
|
731
|
+
if (preimage !== null && typeof preimage !== "string") {
|
|
732
|
+
return null;
|
|
733
|
+
}
|
|
620
734
|
return {
|
|
621
735
|
id,
|
|
622
736
|
lnAddress,
|
|
@@ -640,25 +754,34 @@ function watchRequestBody(params, trigger) {
|
|
|
640
754
|
verify_url: params.verifyUrl,
|
|
641
755
|
expires_at: expiresAt.toISOString(),
|
|
642
756
|
trigger: trigger ?? void 0,
|
|
757
|
+
replay: params.replay,
|
|
643
758
|
sealed: params.sealed,
|
|
644
759
|
webhook: params.webhookUrl ? { url: params.webhookUrl } : void 0
|
|
645
760
|
});
|
|
646
761
|
}
|
|
647
762
|
function settlementFromWire(body) {
|
|
648
763
|
const wire = asObject(body);
|
|
649
|
-
if (wire === null)
|
|
764
|
+
if (wire === null) {
|
|
765
|
+
return null;
|
|
766
|
+
}
|
|
650
767
|
const id = text(wire["id"]);
|
|
651
768
|
const paymentHash = text(wire["payment_hash"]);
|
|
652
769
|
const settledAt = secondsFrom(wire["settled_at"]);
|
|
653
770
|
const status = wire["status"];
|
|
654
771
|
const preimage = wire["preimage"] ?? null;
|
|
655
|
-
if (id === null || paymentHash === null || settledAt === null || !isStatus(status))
|
|
656
|
-
|
|
772
|
+
if (id === null || paymentHash === null || settledAt === null || !isStatus(status)) {
|
|
773
|
+
return null;
|
|
774
|
+
}
|
|
775
|
+
if (preimage !== null && typeof preimage !== "string") {
|
|
776
|
+
return null;
|
|
777
|
+
}
|
|
657
778
|
return { id, status, paymentHash, preimage, settledAt };
|
|
658
779
|
}
|
|
659
780
|
function triggerEventFromWire(body) {
|
|
660
781
|
const wire = asObject(body);
|
|
661
|
-
if (wire === null)
|
|
782
|
+
if (wire === null) {
|
|
783
|
+
return null;
|
|
784
|
+
}
|
|
662
785
|
const id = text(wire["id"]);
|
|
663
786
|
const paymentHash = text(wire["payment_hash"]);
|
|
664
787
|
const verifyUrl = text(wire["verify_url"]);
|
|
@@ -667,10 +790,18 @@ function triggerEventFromWire(body) {
|
|
|
667
790
|
const status = wire["status"];
|
|
668
791
|
const preimage = wire["preimage"] ?? null;
|
|
669
792
|
const sealed = wire["sealed"] ?? null;
|
|
670
|
-
if (id === null || paymentHash === null || verifyUrl === null)
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
if (
|
|
793
|
+
if (id === null || paymentHash === null || verifyUrl === null) {
|
|
794
|
+
return null;
|
|
795
|
+
}
|
|
796
|
+
if (expiresAt === null || createdAt === null || !isStatus(status)) {
|
|
797
|
+
return null;
|
|
798
|
+
}
|
|
799
|
+
if (preimage !== null && typeof preimage !== "string") {
|
|
800
|
+
return null;
|
|
801
|
+
}
|
|
802
|
+
if (sealed !== null && typeof sealed !== "string") {
|
|
803
|
+
return null;
|
|
804
|
+
}
|
|
674
805
|
return {
|
|
675
806
|
id,
|
|
676
807
|
kind: kindOf(wire),
|
|
@@ -687,14 +818,18 @@ function triggerEventFromWire(body) {
|
|
|
687
818
|
}
|
|
688
819
|
function kindOf(wire) {
|
|
689
820
|
const said = wire["kind"];
|
|
690
|
-
if (said === "minted" || said === "watched")
|
|
821
|
+
if (said === "minted" || said === "watched") {
|
|
822
|
+
return said;
|
|
823
|
+
}
|
|
691
824
|
return text(wire["ln_address"]) === null ? "watched" : "minted";
|
|
692
825
|
}
|
|
693
826
|
function toAmount(msat) {
|
|
694
827
|
return { value: String(msat), asset_code: ASSET_CODE, asset_scale: ASSET_SCALE };
|
|
695
828
|
}
|
|
696
829
|
function asObject(value) {
|
|
697
|
-
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
830
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
831
|
+
return null;
|
|
832
|
+
}
|
|
698
833
|
return value;
|
|
699
834
|
}
|
|
700
835
|
function text(value) {
|
|
@@ -702,27 +837,39 @@ function text(value) {
|
|
|
702
837
|
}
|
|
703
838
|
function msatFrom(value, least) {
|
|
704
839
|
const amount = asObject(value);
|
|
705
|
-
if (amount === null)
|
|
706
|
-
|
|
840
|
+
if (amount === null) {
|
|
841
|
+
return null;
|
|
842
|
+
}
|
|
843
|
+
if (amount["asset_code"] !== ASSET_CODE || amount["asset_scale"] !== ASSET_SCALE) {
|
|
844
|
+
return null;
|
|
845
|
+
}
|
|
707
846
|
const digits = amount["value"];
|
|
708
|
-
if (typeof digits !== "string" || !/^[0-9]+$/.test(digits))
|
|
709
|
-
|
|
847
|
+
if (typeof digits !== "string" || !/^[0-9]+$/.test(digits)) {
|
|
848
|
+
return null;
|
|
849
|
+
}
|
|
850
|
+
const msat = Number(digits);
|
|
710
851
|
return Number.isSafeInteger(msat) && msat >= least ? msat : null;
|
|
711
852
|
}
|
|
712
853
|
function refusalsFrom(value) {
|
|
713
|
-
if (!Array.isArray(value))
|
|
854
|
+
if (!Array.isArray(value)) {
|
|
855
|
+
return null;
|
|
856
|
+
}
|
|
714
857
|
const refusals3 = [];
|
|
715
858
|
for (const entry of value) {
|
|
716
859
|
const failure = asObject(entry);
|
|
717
860
|
const address = failure === null ? null : text(failure["address"]);
|
|
718
861
|
const reason = failure?.["reason"];
|
|
719
|
-
if (address === null || typeof reason !== "string" || !REASONS.has(reason))
|
|
862
|
+
if (address === null || typeof reason !== "string" || !REASONS.has(reason)) {
|
|
863
|
+
return null;
|
|
864
|
+
}
|
|
720
865
|
refusals3.push({ address, reason });
|
|
721
866
|
}
|
|
722
867
|
return refusals3;
|
|
723
868
|
}
|
|
724
869
|
function secondsFrom(value) {
|
|
725
|
-
if (typeof value !== "string")
|
|
870
|
+
if (typeof value !== "string") {
|
|
871
|
+
return null;
|
|
872
|
+
}
|
|
726
873
|
const milliseconds = Date.parse(value);
|
|
727
874
|
return Number.isNaN(milliseconds) ? null : Math.floor(milliseconds / 1e3);
|
|
728
875
|
}
|
|
@@ -730,910 +877,1048 @@ function isStatus(value) {
|
|
|
730
877
|
return value === "pending" || value === "paid" || value === "expired";
|
|
731
878
|
}
|
|
732
879
|
|
|
733
|
-
// src/
|
|
734
|
-
var
|
|
735
|
-
var
|
|
736
|
-
var
|
|
737
|
-
var
|
|
738
|
-
var
|
|
739
|
-
var
|
|
740
|
-
function
|
|
741
|
-
if (
|
|
742
|
-
|
|
743
|
-
`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`
|
|
744
|
-
);
|
|
880
|
+
// src/webhook.ts
|
|
881
|
+
var SIGNATURE_HEADER = "x-signature";
|
|
882
|
+
var TIMESTAMP_HEADER = "x-timestamp";
|
|
883
|
+
var GATEWAY_KEY_PREFIX = "ed25519=";
|
|
884
|
+
var DEFAULT_TOLERANCE_SECS = 300;
|
|
885
|
+
var CHALLENGE = "webhook-challenge";
|
|
886
|
+
var VERIFY_CHALLENGE = "verify-challenge";
|
|
887
|
+
async function verifyWebhookSignature(body, signature, credential, timestamp, options = {}) {
|
|
888
|
+
if (!recent(timestamp, options.toleranceSecs ?? DEFAULT_TOLERANCE_SECS)) {
|
|
889
|
+
return false;
|
|
745
890
|
}
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
891
|
+
if (!signature.startsWith(GATEWAY_KEY_PREFIX)) {
|
|
892
|
+
return false;
|
|
893
|
+
}
|
|
894
|
+
return await verifyHex(
|
|
895
|
+
credential.publicKey.toLowerCase(),
|
|
896
|
+
signature.slice(GATEWAY_KEY_PREFIX.length).toLowerCase(),
|
|
897
|
+
signed(timestamp, body)
|
|
898
|
+
);
|
|
751
899
|
}
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
token;
|
|
756
|
-
secret;
|
|
757
|
-
strangers = null;
|
|
758
|
-
speaks = null;
|
|
759
|
-
constructor(baseUrl, options) {
|
|
760
|
-
this.baseUrl = baseUrl.replace(/\/+$/, "");
|
|
761
|
-
this.verify = options?.verify ?? true;
|
|
762
|
-
this.token = options?.token ?? null;
|
|
763
|
-
this.secret = options?.secret ?? null;
|
|
900
|
+
async function parseWebhook(body, signature, credential, timestamp, options = {}) {
|
|
901
|
+
if (!await verifyWebhookSignature(body, signature, credential, timestamp, options)) {
|
|
902
|
+
return null;
|
|
764
903
|
}
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
get isPrivate() {
|
|
771
|
-
return this.token !== null;
|
|
904
|
+
const text2 = typeof body === "string" ? body : new TextDecoder().decode(body);
|
|
905
|
+
try {
|
|
906
|
+
return paymentFromWire(JSON.parse(text2));
|
|
907
|
+
} catch {
|
|
908
|
+
return null;
|
|
772
909
|
}
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
* instance does not change its mind. Anything other than a refusal counts as
|
|
780
|
-
* open, so an unreachable gateway fails closed
|
|
781
|
-
*/
|
|
782
|
-
async refusesStrangers() {
|
|
783
|
-
this.strangers ??= fetch(`${this.baseUrl}/incoming-payments/${STRANGER_PROBE}`, {
|
|
784
|
-
headers: { accept: "application/json" }
|
|
785
|
-
}).then((answer) => answer.status === 401).catch(() => false);
|
|
786
|
-
return await this.strangers;
|
|
910
|
+
}
|
|
911
|
+
async function parseWebhookRequest(request, credential, options = {}) {
|
|
912
|
+
const signature = request.headers.get(SIGNATURE_HEADER);
|
|
913
|
+
const timestamp = request.headers.get(TIMESTAMP_HEADER);
|
|
914
|
+
if (signature === null || timestamp === null) {
|
|
915
|
+
return null;
|
|
787
916
|
}
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
async createPayment(params, options) {
|
|
794
|
-
const sent = createRequestBody(
|
|
795
|
-
params,
|
|
796
|
-
options?.trigger ? sha256Hex(unguessable(options.trigger)) : null
|
|
797
|
-
);
|
|
798
|
-
const headers = await this.sending("/incoming-payments", sent);
|
|
799
|
-
if (options?.idempotencyKey) headers["idempotency-key"] = options.idempotencyKey;
|
|
800
|
-
const response = await fetch(`${this.baseUrl}/incoming-payments`, {
|
|
801
|
-
method: "POST",
|
|
802
|
-
headers,
|
|
803
|
-
body: sent
|
|
804
|
-
});
|
|
805
|
-
if (!response.ok) throw await problemFrom(response);
|
|
806
|
-
const payment = await paymentFrom(response);
|
|
807
|
-
if (this.verify) await proveOrigin(payment, params);
|
|
808
|
-
return payment;
|
|
917
|
+
return parseWebhook(await request.text(), signature, credential, timestamp, options);
|
|
918
|
+
}
|
|
919
|
+
async function parseWatchedWebhook(body, signature, credential, timestamp, options = {}) {
|
|
920
|
+
if (!await verifyWebhookSignature(body, signature, credential, timestamp, options)) {
|
|
921
|
+
return null;
|
|
809
922
|
}
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
* it for one, and asking mints it
|
|
816
|
-
*/
|
|
817
|
-
async createQuote(params) {
|
|
818
|
-
const sent = quoteRequestBody(params);
|
|
819
|
-
const response = await fetch(`${this.baseUrl}/quotes`, {
|
|
820
|
-
method: "POST",
|
|
821
|
-
headers: await this.sending("/quotes", sent),
|
|
822
|
-
body: sent
|
|
823
|
-
});
|
|
824
|
-
if (!response.ok) throw await problemFrom(response);
|
|
825
|
-
const quote = quoteFromWire(await response.json().catch(() => null));
|
|
826
|
-
if (quote === null) {
|
|
827
|
-
throw new ProblemError({
|
|
828
|
-
status: response.status,
|
|
829
|
-
title: "The gateway answered with something that is not a quote"
|
|
830
|
-
});
|
|
831
|
-
}
|
|
832
|
-
return quote;
|
|
923
|
+
const text2 = typeof body === "string" ? body : new TextDecoder().decode(body);
|
|
924
|
+
try {
|
|
925
|
+
return triggerEventFromWire(JSON.parse(text2));
|
|
926
|
+
} catch {
|
|
927
|
+
return null;
|
|
833
928
|
}
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
async webhookKey() {
|
|
841
|
-
const response = await fetch(`${this.baseUrl}/webhook-key`);
|
|
842
|
-
if (!response.ok) throw await problemFrom(response);
|
|
843
|
-
const body = await response.json().catch(() => null);
|
|
844
|
-
if (body?.algorithm !== "ed25519" || typeof body.public_key !== "string") {
|
|
845
|
-
throw new ProblemError({
|
|
846
|
-
status: response.status,
|
|
847
|
-
title: "The gateway published no ed25519 webhook key"
|
|
848
|
-
});
|
|
849
|
-
}
|
|
850
|
-
return body.public_key;
|
|
929
|
+
}
|
|
930
|
+
async function parseWatchedWebhookRequest(request, credential, options = {}) {
|
|
931
|
+
const signature = request.headers.get(SIGNATURE_HEADER);
|
|
932
|
+
const timestamp = request.headers.get(TIMESTAMP_HEADER);
|
|
933
|
+
if (signature === null || timestamp === null) {
|
|
934
|
+
return null;
|
|
851
935
|
}
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
if (response.status === 404) return null;
|
|
858
|
-
if (!response.ok) throw await problemFrom(response);
|
|
859
|
-
return this.checked(await paymentFrom(response));
|
|
936
|
+
return parseWatchedWebhook(await request.text(), signature, credential, timestamp, options);
|
|
937
|
+
}
|
|
938
|
+
async function parseSettlement(body, signature, credential, timestamp, options = {}) {
|
|
939
|
+
if (!await verifyWebhookSignature(body, signature, credential, timestamp, options)) {
|
|
940
|
+
return null;
|
|
860
941
|
}
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
*/
|
|
866
|
-
async getWatched(id) {
|
|
867
|
-
const path = `/incoming-payments/${encodeURIComponent(id)}`;
|
|
868
|
-
const response = await fetch(`${this.baseUrl}${path}`, {
|
|
869
|
-
headers: await this.reading("GET", path)
|
|
870
|
-
});
|
|
871
|
-
if (response.status === 404) return null;
|
|
872
|
-
if (!response.ok) throw await problemFrom(response);
|
|
873
|
-
const watched = triggerEventFromWire(await response.json().catch(() => null));
|
|
874
|
-
if (watched === null) {
|
|
875
|
-
throw new ProblemError({
|
|
876
|
-
status: response.status,
|
|
877
|
-
title: "The gateway answered with something that is not a payment"
|
|
878
|
-
});
|
|
879
|
-
}
|
|
880
|
-
return this.proven(watched);
|
|
942
|
+
try {
|
|
943
|
+
return settlementFromWire(JSON.parse(textOf(body)));
|
|
944
|
+
} catch {
|
|
945
|
+
return null;
|
|
881
946
|
}
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
* Anything older than that window is not in the answer, and the list does not
|
|
889
|
-
* pretend otherwise
|
|
890
|
-
*/
|
|
891
|
-
async listPayments(limit) {
|
|
892
|
-
const path = `/incoming-payments${limit === void 0 ? "" : `?limit=${limit}`}`;
|
|
893
|
-
const response = await fetch(`${this.baseUrl}${path}`, {
|
|
894
|
-
headers: await this.reading("GET", path)
|
|
895
|
-
});
|
|
896
|
-
if (!response.ok) throw await problemFrom(response);
|
|
897
|
-
const body = await response.json().catch(() => null);
|
|
898
|
-
const listed = Array.isArray(body?.payments) ? body.payments.map(triggerEventFromWire).filter((one) => one !== null) : null;
|
|
899
|
-
if (listed === null || typeof body?.settled_scanned !== "number") {
|
|
900
|
-
throw new ProblemError({
|
|
901
|
-
status: response.status,
|
|
902
|
-
title: "The gateway answered with something that is not a payment list"
|
|
903
|
-
});
|
|
904
|
-
}
|
|
905
|
-
return { payments: listed, scanned: body.settled_scanned };
|
|
947
|
+
}
|
|
948
|
+
async function parseSettlementRequest(request, credential, options = {}) {
|
|
949
|
+
const signature = request.headers.get(SIGNATURE_HEADER);
|
|
950
|
+
const timestamp = request.headers.get(TIMESTAMP_HEADER);
|
|
951
|
+
if (signature === null || timestamp === null) {
|
|
952
|
+
return null;
|
|
906
953
|
}
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
*/
|
|
913
|
-
async waitForPayment(id, options) {
|
|
914
|
-
const payment = paymentFromWire(await this.followed(id, options));
|
|
915
|
-
if (payment === null) {
|
|
916
|
-
throw new ProblemError({
|
|
917
|
-
status: 200,
|
|
918
|
-
title: `payment ${id} is watched rather than minted here, read it with waitForWatched`
|
|
919
|
-
});
|
|
920
|
-
}
|
|
921
|
-
return this.checked(payment);
|
|
954
|
+
return await parseSettlement(await request.text(), signature, credential, timestamp, options);
|
|
955
|
+
}
|
|
956
|
+
function isProvablySettled(settled) {
|
|
957
|
+
if (settled.status !== "paid" || settled.preimage === null) {
|
|
958
|
+
return false;
|
|
922
959
|
}
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
* gateway was told none of them, so it reads back as the shape a trigger
|
|
929
|
-
* streams rather than as a `Payment`. That is every bank transfer, and every
|
|
930
|
-
* Lightning invoice registered with `watchPayment` instead of `createPayment`.
|
|
931
|
-
*/
|
|
932
|
-
async waitForWatched(id, options) {
|
|
933
|
-
const watched = triggerEventFromWire(await this.followed(id, options));
|
|
934
|
-
if (watched === null) {
|
|
935
|
-
throw new ProblemError({
|
|
936
|
-
status: 200,
|
|
937
|
-
title: "The gateway answered with something that is not a payment"
|
|
938
|
-
});
|
|
939
|
-
}
|
|
940
|
-
return this.proven(watched);
|
|
960
|
+
return preimageMatchesHash(settled.preimage, settled.paymentHash);
|
|
961
|
+
}
|
|
962
|
+
async function answerWebhookChallenge(body, signature, credential, timestamp, options = {}) {
|
|
963
|
+
if (!await verifyWebhookSignature(body, signature, credential, timestamp, options)) {
|
|
964
|
+
return null;
|
|
941
965
|
}
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
}
|
|
951
|
-
let socket = null;
|
|
952
|
-
let settled = false;
|
|
953
|
-
let attempt = 0;
|
|
954
|
-
let expiresAt = null;
|
|
955
|
-
let retry;
|
|
956
|
-
const abort = () => settle(() => reject(aborted()));
|
|
957
|
-
const settle = (finish) => {
|
|
958
|
-
if (settled) return;
|
|
959
|
-
settled = true;
|
|
960
|
-
options?.signal?.removeEventListener("abort", abort);
|
|
961
|
-
clearTimeout(retry);
|
|
962
|
-
finish();
|
|
963
|
-
socket?.close();
|
|
964
|
-
};
|
|
965
|
-
const again = () => {
|
|
966
|
-
if (settled) return;
|
|
967
|
-
if (expiresAt === null && attempt >= UNANSWERED_ATTEMPTS) {
|
|
968
|
-
settle(() => reject(new Error(`no gateway answered for payment ${id}`)));
|
|
969
|
-
return;
|
|
970
|
-
}
|
|
971
|
-
if (expiresAt !== null && Date.now() >= expiresAt * 1e3) {
|
|
972
|
-
settle(() => reject(new Error(`payment ${id} went unreported past its expiry`)));
|
|
973
|
-
return;
|
|
974
|
-
}
|
|
975
|
-
retry = setTimeout(() => void connect(), backoffMs(RECONNECT_DELAY_MS, attempt));
|
|
976
|
-
};
|
|
977
|
-
const connect = async () => {
|
|
978
|
-
if (settled) return;
|
|
979
|
-
attempt += 1;
|
|
980
|
-
let url = direct;
|
|
981
|
-
if (this.needsTicket(options?.tickets)) {
|
|
982
|
-
try {
|
|
983
|
-
url = `${base}/ws/tickets/${await this.wsTicket({ payment_id: id })}`;
|
|
984
|
-
} catch (refused) {
|
|
985
|
-
if (refused instanceof ProblemError) settle(() => reject(refused));
|
|
986
|
-
else again();
|
|
987
|
-
return;
|
|
988
|
-
}
|
|
989
|
-
}
|
|
990
|
-
if (settled) return;
|
|
991
|
-
const opened = new WebSocket(url);
|
|
992
|
-
socket = opened;
|
|
993
|
-
opened.onmessage = (event) => {
|
|
994
|
-
try {
|
|
995
|
-
const frame = JSON.parse(String(event.data));
|
|
996
|
-
const watched = triggerEventFromWire(frame);
|
|
997
|
-
if (watched === null) return;
|
|
998
|
-
expiresAt = watched.expiresAt;
|
|
999
|
-
attempt = 1;
|
|
1000
|
-
if (!TERMINAL.has(watched.status)) return;
|
|
1001
|
-
settle(() => resolve2(frame));
|
|
1002
|
-
} catch (refused) {
|
|
1003
|
-
settle(() => reject(refused));
|
|
1004
|
-
}
|
|
1005
|
-
};
|
|
1006
|
-
opened.onclose = again;
|
|
1007
|
-
};
|
|
1008
|
-
options?.signal?.addEventListener("abort", abort, { once: true });
|
|
1009
|
-
void connect();
|
|
1010
|
-
});
|
|
966
|
+
const nonce = challenged(typeof body === "string" ? body : new TextDecoder().decode(body));
|
|
967
|
+
return nonce === null ? null : JSON.stringify({ nonce });
|
|
968
|
+
}
|
|
969
|
+
async function answerWebhookChallengeRequest(request, credential, options = {}) {
|
|
970
|
+
const signature = request.headers.get(SIGNATURE_HEADER);
|
|
971
|
+
const timestamp = request.headers.get(TIMESTAMP_HEADER);
|
|
972
|
+
if (signature === null || timestamp === null) {
|
|
973
|
+
return null;
|
|
1011
974
|
}
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
const stopLosers = new AbortController();
|
|
1033
|
-
const signal = options?.signal ? AbortSignal.any([stopLosers.signal, options.signal]) : stopLosers.signal;
|
|
1034
|
-
let refused = null;
|
|
1035
|
-
try {
|
|
1036
|
-
const winner = await new Promise((resolve2) => {
|
|
1037
|
-
let waiting = ids.length;
|
|
1038
|
-
const lost = () => {
|
|
1039
|
-
waiting -= 1;
|
|
1040
|
-
if (waiting === 0) resolve2(null);
|
|
1041
|
-
};
|
|
1042
|
-
for (const id of ids) {
|
|
1043
|
-
this.waitForWatched(id, { ...options, signal }).then((watched) => watched.status === "paid" ? resolve2(watched) : lost()).catch((failure) => {
|
|
1044
|
-
refused ??= failure;
|
|
1045
|
-
lost();
|
|
1046
|
-
});
|
|
1047
|
-
}
|
|
1048
|
-
});
|
|
1049
|
-
if (winner === null && refused !== null) throw refused;
|
|
1050
|
-
return winner;
|
|
1051
|
-
} finally {
|
|
1052
|
-
stopLosers.abort();
|
|
975
|
+
const answer = await answerWebhookChallenge(
|
|
976
|
+
await request.clone().text(),
|
|
977
|
+
signature,
|
|
978
|
+
credential,
|
|
979
|
+
timestamp,
|
|
980
|
+
options
|
|
981
|
+
);
|
|
982
|
+
return answer === null ? null : new Response(answer, { headers: { "content-type": "application/json" } });
|
|
983
|
+
}
|
|
984
|
+
function textOf(body) {
|
|
985
|
+
return typeof body === "string" ? body : new TextDecoder().decode(body);
|
|
986
|
+
}
|
|
987
|
+
function challenged(text2) {
|
|
988
|
+
return nonceOf(text2, CHALLENGE);
|
|
989
|
+
}
|
|
990
|
+
function nonceOf(text2, type) {
|
|
991
|
+
try {
|
|
992
|
+
const said = JSON.parse(text2);
|
|
993
|
+
if (said["type"] !== type || typeof said["nonce"] !== "string") {
|
|
994
|
+
return null;
|
|
1053
995
|
}
|
|
996
|
+
return said["nonce"];
|
|
997
|
+
} catch {
|
|
998
|
+
return null;
|
|
1054
999
|
}
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1000
|
+
}
|
|
1001
|
+
function answerVerifyChallenge(body) {
|
|
1002
|
+
const nonce = nonceOf(body, VERIFY_CHALLENGE);
|
|
1003
|
+
return nonce === null ? null : JSON.stringify({ nonce });
|
|
1004
|
+
}
|
|
1005
|
+
async function answerVerifyChallengeRequest(request) {
|
|
1006
|
+
if (request.method !== "POST") {
|
|
1007
|
+
return null;
|
|
1008
|
+
}
|
|
1009
|
+
const answer = answerVerifyChallenge(await request.clone().text());
|
|
1010
|
+
return answer === null ? null : new Response(answer, { headers: { "content-type": "application/json" } });
|
|
1011
|
+
}
|
|
1012
|
+
function recent(timestamp, toleranceSecs) {
|
|
1013
|
+
const sent = Number(timestamp);
|
|
1014
|
+
if (!Number.isFinite(sent)) {
|
|
1015
|
+
return false;
|
|
1016
|
+
}
|
|
1017
|
+
return Math.abs(Math.floor(Date.now() / 1e3) - sent) <= toleranceSecs;
|
|
1018
|
+
}
|
|
1019
|
+
function signed(timestamp, body) {
|
|
1020
|
+
const encoder = new TextEncoder();
|
|
1021
|
+
const stamp = encoder.encode(`${timestamp}.`);
|
|
1022
|
+
const rest = typeof body === "string" ? encoder.encode(body) : new Uint8Array(body);
|
|
1023
|
+
const payload = new Uint8Array(stamp.length + rest.length);
|
|
1024
|
+
payload.set(stamp);
|
|
1025
|
+
payload.set(rest, stamp.length);
|
|
1026
|
+
return payload;
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
// src/bank.ts
|
|
1030
|
+
var DEFAULT_CURRENCY = "CZK";
|
|
1031
|
+
var DEFAULT_LOOK_BACK_SECS = 7 * 24 * 60 * 60;
|
|
1032
|
+
var DEFAULT_POLL_EVERY_SECS = 30;
|
|
1033
|
+
var IBAN = /^[A-Z]{2}[0-9]{2}[0-9A-Z]{8,30}$/;
|
|
1034
|
+
var FORBIDDEN_IN_SPD = "*";
|
|
1035
|
+
async function bankTransfer(params) {
|
|
1036
|
+
const currency = params.currency ?? DEFAULT_CURRENCY;
|
|
1037
|
+
refuseUnusable(params, currency);
|
|
1038
|
+
await refuseAnOpenGateway(params);
|
|
1039
|
+
const spd = shortPaymentDescriptor(params, currency);
|
|
1040
|
+
const subject = subjectOf(params.reference, params.amountMinor, currency);
|
|
1041
|
+
const polling = new URL(params.verifyUrl);
|
|
1042
|
+
polling.searchParams.set("ref", params.reference);
|
|
1043
|
+
polling.searchParams.set("minor", String(params.amountMinor));
|
|
1044
|
+
polling.searchParams.set("cc", currency);
|
|
1045
|
+
polling.searchParams.set("sig", await hmacHex(params.secret, `verify|${subject}`));
|
|
1046
|
+
const verifyUrl = polling.toString();
|
|
1047
|
+
const watched = await params.gateway.watchPayment({
|
|
1048
|
+
paymentHash: hashOf(await hmacHex(params.secret, `preimage|${subject}`)),
|
|
1049
|
+
verifyUrl,
|
|
1050
|
+
expiresAt: params.expiresAt,
|
|
1051
|
+
trigger: params.trigger,
|
|
1052
|
+
replay: params.replay,
|
|
1053
|
+
sealed: params.sealed,
|
|
1054
|
+
webhookUrl: params.webhookUrl
|
|
1055
|
+
});
|
|
1056
|
+
return { id: watched.id, paymentHash: watched.paymentHash, verifyUrl, spd };
|
|
1057
|
+
}
|
|
1058
|
+
function bankVerifyEndpoint(config) {
|
|
1059
|
+
const paced = {
|
|
1060
|
+
"cache-control": `max-age=${config.pollEverySecs ?? DEFAULT_POLL_EVERY_SECS}`
|
|
1061
|
+
};
|
|
1062
|
+
return async (request) => {
|
|
1063
|
+
const consented = await answerVerifyChallengeRequest(request);
|
|
1064
|
+
if (consented !== null) {
|
|
1065
|
+
return consented;
|
|
1078
1066
|
}
|
|
1079
|
-
const
|
|
1080
|
-
if (
|
|
1081
|
-
|
|
1067
|
+
const asked = readQuery(new URL(request.url));
|
|
1068
|
+
if (asked === null) {
|
|
1069
|
+
return Response.json({ settled: false }, { status: 400 });
|
|
1082
1070
|
}
|
|
1083
|
-
|
|
1071
|
+
const subject = subjectOf(asked.reference, asked.amountMinor, asked.currency);
|
|
1072
|
+
const expected = await hmacHex(config.secret, `verify|${subject}`);
|
|
1073
|
+
if (!equalInConstantTime(asked.signature, expected)) {
|
|
1074
|
+
return Response.json({ settled: false }, { status: 403 });
|
|
1075
|
+
}
|
|
1076
|
+
const since = unixNow() - (config.lookBackSecs ?? DEFAULT_LOOK_BACK_SECS);
|
|
1077
|
+
const landed = (await config.statement(since)).some((credit) => pays(credit, asked));
|
|
1078
|
+
if (!landed) {
|
|
1079
|
+
return Response.json({ settled: false }, { headers: paced });
|
|
1080
|
+
}
|
|
1081
|
+
return Response.json(
|
|
1082
|
+
{ settled: true, preimage: await hmacHex(config.secret, `preimage|${subject}`) },
|
|
1083
|
+
{ headers: paced }
|
|
1084
|
+
);
|
|
1085
|
+
};
|
|
1086
|
+
}
|
|
1087
|
+
function readQuery(url) {
|
|
1088
|
+
const reference = url.searchParams.get("ref");
|
|
1089
|
+
const minor = Number(url.searchParams.get("minor"));
|
|
1090
|
+
const currency = url.searchParams.get("cc");
|
|
1091
|
+
const signature = url.searchParams.get("sig");
|
|
1092
|
+
if (!reference || !currency || !signature) {
|
|
1093
|
+
return null;
|
|
1084
1094
|
}
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
* heard of it. Every gateway you hand the same invoice to answers with the same
|
|
1088
|
-
* name, so watching at several of them adds up to one payment rather than
|
|
1089
|
-
* several, and no gateway's key is in the answer. Null when no secret was given,
|
|
1090
|
-
* because then the gateway names the payment and only it can
|
|
1091
|
-
*/
|
|
1092
|
-
async nameFor(paymentHash) {
|
|
1093
|
-
return this.secret === null ? null : paymentNamedBy((await this.speaking()).publicKeyHex, paymentHash);
|
|
1095
|
+
if (!Number.isInteger(minor) || minor <= 0) {
|
|
1096
|
+
return null;
|
|
1094
1097
|
}
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
}
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
if (this.needsTicket(options.tickets)) {
|
|
1116
|
-
try {
|
|
1117
|
-
url = `${base}/ws/tickets/${await this.wsTicket({ trigger_secret: secret })}`;
|
|
1118
|
-
} catch (refused) {
|
|
1119
|
-
options.onError?.(refused);
|
|
1120
|
-
again();
|
|
1121
|
-
return;
|
|
1122
|
-
}
|
|
1123
|
-
}
|
|
1124
|
-
if (stopped) return;
|
|
1125
|
-
socket = new WebSocket(url);
|
|
1126
|
-
socket.onopen = () => {
|
|
1127
|
-
attempt = 1;
|
|
1128
|
-
};
|
|
1129
|
-
socket.onmessage = (event) => {
|
|
1130
|
-
try {
|
|
1131
|
-
const settled = triggerEventFromWire(JSON.parse(String(event.data)));
|
|
1132
|
-
if (settled !== null) options.onPayment(this.proven(settled));
|
|
1133
|
-
} catch (refused) {
|
|
1134
|
-
options.onError?.(refused);
|
|
1135
|
-
}
|
|
1136
|
-
};
|
|
1137
|
-
socket.onerror = () => options.onError?.(new Error(`could not follow trigger at ${base}`));
|
|
1138
|
-
socket.onclose = again;
|
|
1139
|
-
};
|
|
1140
|
-
void connect();
|
|
1141
|
-
return () => {
|
|
1142
|
-
stopped = true;
|
|
1143
|
-
clearTimeout(retry);
|
|
1144
|
-
socket?.close();
|
|
1145
|
-
};
|
|
1146
|
-
}
|
|
1147
|
-
needsTicket(asked) {
|
|
1148
|
-
return asked === true || this.token !== null;
|
|
1098
|
+
return { reference, amountMinor: minor, currency, signature };
|
|
1099
|
+
}
|
|
1100
|
+
function pays(credit, asked) {
|
|
1101
|
+
return credit.amountMinor === asked.amountMinor && credit.currency.toUpperCase() === asked.currency.toUpperCase() && credit.reference.toUpperCase().includes(asked.reference.toUpperCase());
|
|
1102
|
+
}
|
|
1103
|
+
function subjectOf(reference, amountMinor, currency) {
|
|
1104
|
+
return `${reference}|${amountMinor}|${currency.toUpperCase()}`;
|
|
1105
|
+
}
|
|
1106
|
+
function hashOf(preimage) {
|
|
1107
|
+
return bytesToHex(sha256(hexToBytes(preimage)));
|
|
1108
|
+
}
|
|
1109
|
+
function shortPaymentDescriptor(params, currency) {
|
|
1110
|
+
const fields = [
|
|
1111
|
+
`ACC:${params.iban}`,
|
|
1112
|
+
`AM:${major(params.amountMinor, currency)}`,
|
|
1113
|
+
`CC:${currency.toUpperCase()}`,
|
|
1114
|
+
`MSG:${params.reference}`
|
|
1115
|
+
];
|
|
1116
|
+
if (params.variableSymbol) {
|
|
1117
|
+
fields.push(`X-VS:${params.variableSymbol}`);
|
|
1149
1118
|
}
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
const minted = await response.json().catch(() => null);
|
|
1159
|
-
if (typeof minted?.ticket !== "string") {
|
|
1160
|
-
throw new ProblemError({
|
|
1161
|
-
status: response.status,
|
|
1162
|
-
title: "The gateway answered with something that is not a ticket"
|
|
1163
|
-
});
|
|
1164
|
-
}
|
|
1165
|
-
return encodeURIComponent(minted.ticket);
|
|
1119
|
+
return `SPD*1.0*${fields.join("*")}`;
|
|
1120
|
+
}
|
|
1121
|
+
function major(amountMinor, currency) {
|
|
1122
|
+
return (amountMinor / minorScaleOf(currency)).toFixed(minorUnitsOf(currency));
|
|
1123
|
+
}
|
|
1124
|
+
async function refuseAnOpenGateway(params) {
|
|
1125
|
+
if (params.allowPublicGateway === true) {
|
|
1126
|
+
return;
|
|
1166
1127
|
}
|
|
1167
|
-
|
|
1168
|
-
return
|
|
1128
|
+
if (await params.gateway.refusesStrangers()) {
|
|
1129
|
+
return;
|
|
1169
1130
|
}
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1131
|
+
throw new Error(
|
|
1132
|
+
"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"
|
|
1133
|
+
);
|
|
1134
|
+
}
|
|
1135
|
+
function refuseUnusable(params, currency) {
|
|
1136
|
+
if (!IBAN.test(params.iban)) {
|
|
1137
|
+
throw new Error(`${params.iban} is not an IBAN`);
|
|
1175
1138
|
}
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
return await this.speaks;
|
|
1139
|
+
if (!Number.isInteger(params.amountMinor) || params.amountMinor <= 0) {
|
|
1140
|
+
throw new Error("amountMinor must be a whole number of minor units above zero");
|
|
1179
1141
|
}
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
if (this.verify && unproven) throw new GatewayCheatError("preimage_mismatch", settled.id);
|
|
1183
|
-
return settled;
|
|
1142
|
+
if (params.reference.length === 0) {
|
|
1143
|
+
throw new Error("a transfer with no reference cannot be found");
|
|
1184
1144
|
}
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
throw new GatewayCheatError("preimage_mismatch", payment.id);
|
|
1188
|
-
}
|
|
1189
|
-
return payment;
|
|
1145
|
+
if (params.reference.includes(FORBIDDEN_IN_SPD)) {
|
|
1146
|
+
throw new Error("a reference cannot contain an asterisk, it separates the QR fields");
|
|
1190
1147
|
}
|
|
1191
|
-
}
|
|
1192
|
-
|
|
1193
|
-
const payment = paymentFromWire(await response.json().catch(() => null));
|
|
1194
|
-
if (payment === null) {
|
|
1195
|
-
throw new ProblemError({
|
|
1196
|
-
status: response.status,
|
|
1197
|
-
title: "The gateway answered with something that is not a payment"
|
|
1198
|
-
});
|
|
1148
|
+
if (params.variableSymbol && !/^[0-9]{1,10}$/.test(params.variableSymbol)) {
|
|
1149
|
+
throw new Error("a variable symbol is up to ten digits");
|
|
1199
1150
|
}
|
|
1200
|
-
return payment;
|
|
1201
1151
|
}
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
const document = { ...problem, status: response.status };
|
|
1205
|
-
if (isProblemType(problem, NO_WALLET_AVAILABLE)) {
|
|
1206
|
-
return new NoWalletAvailableError(document, refusals(problem.wallets));
|
|
1207
|
-
}
|
|
1208
|
-
if (isProblemType(problem, REQUEST_IN_FLIGHT)) {
|
|
1209
|
-
return new IdempotencyConflictError(document, "request-in-flight");
|
|
1210
|
-
}
|
|
1211
|
-
if (isProblemType(problem, IDEMPOTENCY_KEY_REUSED)) {
|
|
1212
|
-
return new IdempotencyConflictError(document, "key-reused");
|
|
1213
|
-
}
|
|
1214
|
-
return new ProblemError(document);
|
|
1152
|
+
function unixNow() {
|
|
1153
|
+
return Math.floor(Date.now() / 1e3);
|
|
1215
1154
|
}
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1155
|
+
|
|
1156
|
+
// ../core/caller.ts
|
|
1157
|
+
var KEY_HEADER = "x-client-key";
|
|
1158
|
+
var SIGNATURE_HEADER2 = "x-signature";
|
|
1159
|
+
var TIMESTAMP_HEADER2 = "x-timestamp";
|
|
1160
|
+
var PREFIX = "ed25519=";
|
|
1161
|
+
var SIGNING_LABEL = "request-signing";
|
|
1162
|
+
async function callerKey(secret) {
|
|
1163
|
+
return await signingKeyFromSeed(hexToBytes(await hmacHex(secret, SIGNING_LABEL)));
|
|
1164
|
+
}
|
|
1165
|
+
function paymentNamedBy(caller, paymentHash) {
|
|
1166
|
+
return sha256Hex(`${caller}.payment-id.${paymentHash}`);
|
|
1167
|
+
}
|
|
1168
|
+
async function signedAs(key, method, path, body) {
|
|
1169
|
+
const timestamp = String(Math.floor(Date.now() / 1e3));
|
|
1170
|
+
return {
|
|
1171
|
+
[KEY_HEADER]: key.publicKeyHex,
|
|
1172
|
+
[TIMESTAMP_HEADER2]: timestamp,
|
|
1173
|
+
[SIGNATURE_HEADER2]: `${PREFIX}${await key.sign(spoken(timestamp, method, path, body))}`
|
|
1174
|
+
};
|
|
1175
|
+
}
|
|
1176
|
+
function spoken(timestamp, method, path, body) {
|
|
1177
|
+
return new TextEncoder().encode(
|
|
1178
|
+
`${timestamp}.${method.toUpperCase()}.${path}.${sha256Hex(body)}`
|
|
1179
|
+
);
|
|
1221
1180
|
}
|
|
1222
1181
|
|
|
1223
|
-
// src/
|
|
1224
|
-
var
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
this.
|
|
1231
|
-
this.
|
|
1232
|
-
this.onRefused = options?.onRefused ?? (() => {
|
|
1233
|
-
});
|
|
1182
|
+
// src/errors.ts
|
|
1183
|
+
var GatewayCheatError = class extends Error {
|
|
1184
|
+
code;
|
|
1185
|
+
paymentId;
|
|
1186
|
+
constructor(code, paymentId) {
|
|
1187
|
+
super(`gateway verification failed: ${code}`);
|
|
1188
|
+
this.name = "GatewayCheatError";
|
|
1189
|
+
this.code = code;
|
|
1190
|
+
this.paymentId = paymentId;
|
|
1234
1191
|
}
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1192
|
+
};
|
|
1193
|
+
var WrapRefusedError = class extends Error {
|
|
1194
|
+
code;
|
|
1195
|
+
constructor(code, detail) {
|
|
1196
|
+
super(`wrapped invoice refused: ${code}, ${detail}`);
|
|
1197
|
+
this.name = "WrapRefusedError";
|
|
1198
|
+
this.code = code;
|
|
1238
1199
|
}
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
for (const [at, answer] of asked.entries()) {
|
|
1249
|
-
if (answer.status === "fulfilled") {
|
|
1250
|
-
taken.push(answer.value);
|
|
1251
|
-
continue;
|
|
1252
|
-
}
|
|
1253
|
-
refusals3.push(answer.reason);
|
|
1254
|
-
this.onRefused(this.urls[at], answer.reason);
|
|
1255
|
-
}
|
|
1256
|
-
if (taken[0] === void 0) throw refusals3[0];
|
|
1257
|
-
return taken[0];
|
|
1200
|
+
};
|
|
1201
|
+
var UnverifiedRecipientError = class extends Error {
|
|
1202
|
+
lnAddress;
|
|
1203
|
+
paymentId;
|
|
1204
|
+
constructor(lnAddress, paymentId, cause) {
|
|
1205
|
+
super(`could not reach ${lnAddress} to verify the invoice: ${String(cause)}`);
|
|
1206
|
+
this.name = "UnverifiedRecipientError";
|
|
1207
|
+
this.lnAddress = lnAddress;
|
|
1208
|
+
this.paymentId = paymentId;
|
|
1258
1209
|
}
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
const
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
const lost = () => {
|
|
1274
|
-
waiting -= 1;
|
|
1275
|
-
if (waiting === 0) resolve2(null);
|
|
1276
|
-
};
|
|
1277
|
-
for (const gateway of this.each) {
|
|
1278
|
-
gateway.waitForWatched(id, { ...options, signal }).then((watched) => {
|
|
1279
|
-
if (watched.status === "paid") return resolve2(watched);
|
|
1280
|
-
unpaid.push(watched);
|
|
1281
|
-
lost();
|
|
1282
|
-
}).catch((failure) => {
|
|
1283
|
-
refused ??= failure;
|
|
1284
|
-
lost();
|
|
1285
|
-
});
|
|
1286
|
-
}
|
|
1287
|
-
});
|
|
1288
|
-
if (settled !== null) return settled;
|
|
1289
|
-
if (unpaid.length > 0) return unpaid[0];
|
|
1290
|
-
throw refused;
|
|
1291
|
-
} finally {
|
|
1292
|
-
stopLosers.abort();
|
|
1293
|
-
}
|
|
1210
|
+
};
|
|
1211
|
+
var ProblemError = class extends Error {
|
|
1212
|
+
type;
|
|
1213
|
+
title;
|
|
1214
|
+
status;
|
|
1215
|
+
detail;
|
|
1216
|
+
constructor(problem) {
|
|
1217
|
+
const title = problem.title ?? "Request failed";
|
|
1218
|
+
super(problem.detail ? `${title}: ${problem.detail}` : title);
|
|
1219
|
+
this.name = "ProblemError";
|
|
1220
|
+
this.type = problem.type ?? "about:blank";
|
|
1221
|
+
this.title = title;
|
|
1222
|
+
this.status = problem.status ?? 0;
|
|
1223
|
+
this.detail = problem.detail ?? null;
|
|
1294
1224
|
}
|
|
1295
1225
|
};
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
var VERSION = "v1";
|
|
1299
|
-
var IV_BYTES = 12;
|
|
1300
|
-
var MIN_SECRET_CHARS = 32;
|
|
1301
|
-
var MAX_PLAIN_BYTES = 3e3;
|
|
1302
|
-
var INFO = new TextEncoder().encode("thunder-bridge/sealed");
|
|
1303
|
-
async function seal(secret, plaintext) {
|
|
1304
|
-
const body = new TextEncoder().encode(plaintext);
|
|
1305
|
-
if (body.length > MAX_PLAIN_BYTES) {
|
|
1306
|
-
throw new Error(`sealed takes at most ${MAX_PLAIN_BYTES} bytes, this was ${body.length}`);
|
|
1307
|
-
}
|
|
1308
|
-
const key = await keyFor(secret);
|
|
1309
|
-
const iv = crypto.getRandomValues(new Uint8Array(IV_BYTES));
|
|
1310
|
-
const cipher = new Uint8Array(await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, body));
|
|
1311
|
-
const joined = new Uint8Array(iv.length + cipher.length);
|
|
1312
|
-
joined.set(iv);
|
|
1313
|
-
joined.set(cipher, iv.length);
|
|
1314
|
-
return `${VERSION}.${toBase64Url(joined)}`;
|
|
1226
|
+
function isProblemType(problem, type) {
|
|
1227
|
+
return problem.type === type;
|
|
1315
1228
|
}
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
);
|
|
1327
|
-
return new TextDecoder().decode(body);
|
|
1328
|
-
} catch {
|
|
1329
|
-
return null;
|
|
1229
|
+
var NO_WALLET_AVAILABLE = "urn:problem-type:thunder-bridge:no-wallet-available";
|
|
1230
|
+
var REQUEST_IN_FLIGHT = "urn:problem-type:thunder-bridge:request-in-flight";
|
|
1231
|
+
var IDEMPOTENCY_KEY_REUSED = "urn:problem-type:thunder-bridge:idempotency-key-reused";
|
|
1232
|
+
var PAYMENT_ALREADY_WATCHED = "urn:problem-type:thunder-bridge:payment-already-watched";
|
|
1233
|
+
var IdempotencyConflictError = class extends ProblemError {
|
|
1234
|
+
conflict;
|
|
1235
|
+
constructor(problem, conflict) {
|
|
1236
|
+
super(problem);
|
|
1237
|
+
this.name = "IdempotencyConflictError";
|
|
1238
|
+
this.conflict = conflict;
|
|
1330
1239
|
}
|
|
1331
|
-
}
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1240
|
+
};
|
|
1241
|
+
var NoWalletAvailableError = class extends ProblemError {
|
|
1242
|
+
wallets;
|
|
1243
|
+
constructor(problem, wallets) {
|
|
1244
|
+
super({ ...problem, type: NO_WALLET_AVAILABLE });
|
|
1245
|
+
this.name = "NoWalletAvailableError";
|
|
1246
|
+
this.wallets = wallets;
|
|
1335
1247
|
}
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
{ name: "AES-GCM", length: 256 },
|
|
1347
|
-
false,
|
|
1348
|
-
["encrypt", "decrypt"]
|
|
1248
|
+
};
|
|
1249
|
+
|
|
1250
|
+
// src/verify.ts
|
|
1251
|
+
var HTTP_TIMEOUT_MS = 15e3;
|
|
1252
|
+
var DEFAULT_WRAP_PROPORTION = 0.01;
|
|
1253
|
+
var DEFAULT_WRAP_BASE_MSAT = 1e3;
|
|
1254
|
+
async function proveOrigin(payment, request) {
|
|
1255
|
+
const cheat = (code) => new GatewayCheatError(code, payment.id);
|
|
1256
|
+
const listed = request.lnAddresses.find(
|
|
1257
|
+
(address) => equalIgnoringCase(address, payment.lnAddress)
|
|
1349
1258
|
);
|
|
1259
|
+
if (listed === void 0) {
|
|
1260
|
+
throw cheat("address_not_requested");
|
|
1261
|
+
}
|
|
1262
|
+
if (payment.amountMsat !== request.amountMsat) {
|
|
1263
|
+
throw cheat("amount_mismatch");
|
|
1264
|
+
}
|
|
1265
|
+
const invoice = decodeInvoice(payment.bolt11);
|
|
1266
|
+
if (invoice.paymentHash === null || !equalIgnoringCase(invoice.paymentHash, payment.paymentHash)) {
|
|
1267
|
+
throw cheat("hash_mismatch");
|
|
1268
|
+
}
|
|
1269
|
+
if (invoice.amountMsat !== request.amountMsat) {
|
|
1270
|
+
throw cheat("amount_mismatch");
|
|
1271
|
+
}
|
|
1272
|
+
const payRequest = await payRequestFor(listed, payment);
|
|
1273
|
+
if (typeof payRequest.metadata !== "string" || typeof payRequest.callback !== "string") {
|
|
1274
|
+
throw new UnverifiedRecipientError(payment.lnAddress, payment.id, "no payRequest served");
|
|
1275
|
+
}
|
|
1276
|
+
if (invoice.descriptionHash !== sha256Hex(payRequest.metadata)) {
|
|
1277
|
+
throw cheat("description_hash_mismatch");
|
|
1278
|
+
}
|
|
1279
|
+
if (!sameOrigin(payment.verifyUrl, payRequest.callback)) {
|
|
1280
|
+
throw cheat("verify_url_foreign");
|
|
1281
|
+
}
|
|
1282
|
+
const issued = await reachable(payment.verifyUrl, payment);
|
|
1283
|
+
if (typeof issued.pr !== "string" || !equalIgnoringCase(issued.pr, payment.bolt11)) {
|
|
1284
|
+
throw cheat("invoice_not_issued");
|
|
1285
|
+
}
|
|
1350
1286
|
}
|
|
1351
|
-
function
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
}
|
|
1356
|
-
function fromBase64Url2(text2) {
|
|
1357
|
-
if (text2.length === 0 || !/^[A-Za-z0-9_-]+$/.test(text2)) return null;
|
|
1358
|
-
try {
|
|
1359
|
-
const binary = atob(text2.replace(/-/g, "+").replace(/_/g, "/"));
|
|
1360
|
-
const bytes = new Uint8Array(binary.length);
|
|
1361
|
-
for (let at = 0; at < bytes.length; at++) bytes[at] = binary.charCodeAt(at);
|
|
1362
|
-
return bytes;
|
|
1363
|
-
} catch {
|
|
1287
|
+
async function proveSettlement(payment, request) {
|
|
1288
|
+
await proveOrigin(payment, request);
|
|
1289
|
+
const verified = await reachable(payment.verifyUrl, payment);
|
|
1290
|
+
if (verified.settled !== true || typeof verified.preimage !== "string") {
|
|
1364
1291
|
return null;
|
|
1365
1292
|
}
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
// src/currency.ts
|
|
1369
|
-
var ISO_4217_MINOR_UNITS = {
|
|
1370
|
-
BHD: 3,
|
|
1371
|
-
BIF: 0,
|
|
1372
|
-
CLP: 0,
|
|
1373
|
-
CZK: 2,
|
|
1374
|
-
DJF: 0,
|
|
1375
|
-
EUR: 2,
|
|
1376
|
-
GBP: 2,
|
|
1377
|
-
ISK: 0,
|
|
1378
|
-
IQD: 3,
|
|
1379
|
-
JOD: 3,
|
|
1380
|
-
JPY: 0,
|
|
1381
|
-
KRW: 0,
|
|
1382
|
-
KWD: 3,
|
|
1383
|
-
LYD: 3,
|
|
1384
|
-
OMR: 3,
|
|
1385
|
-
PLN: 2,
|
|
1386
|
-
PYG: 0,
|
|
1387
|
-
RWF: 0,
|
|
1388
|
-
TND: 3,
|
|
1389
|
-
UGX: 0,
|
|
1390
|
-
USD: 2,
|
|
1391
|
-
VND: 0,
|
|
1392
|
-
VUV: 0,
|
|
1393
|
-
XAF: 0,
|
|
1394
|
-
XOF: 0,
|
|
1395
|
-
XPF: 0
|
|
1396
|
-
};
|
|
1397
|
-
function minorUnitsOf(currency) {
|
|
1398
|
-
const digits = ISO_4217_MINOR_UNITS[currency.toUpperCase()];
|
|
1399
|
-
if (digits === void 0) {
|
|
1400
|
-
throw new Error(
|
|
1401
|
-
`${currency.toUpperCase()} is not a currency this knows the ISO 4217 minor unit of`
|
|
1402
|
-
);
|
|
1293
|
+
if (!preimageMatchesHash(verified.preimage, payment.paymentHash)) {
|
|
1294
|
+
throw new GatewayCheatError("preimage_mismatch", payment.id);
|
|
1403
1295
|
}
|
|
1404
|
-
return
|
|
1405
|
-
}
|
|
1406
|
-
function minorScaleOf(currency) {
|
|
1407
|
-
return 10 ** minorUnitsOf(currency);
|
|
1408
|
-
}
|
|
1409
|
-
|
|
1410
|
-
// src/webhook.ts
|
|
1411
|
-
var SIGNATURE_HEADER2 = "x-signature";
|
|
1412
|
-
var TIMESTAMP_HEADER2 = "x-timestamp";
|
|
1413
|
-
var GATEWAY_KEY_PREFIX = "ed25519=";
|
|
1414
|
-
var DEFAULT_TOLERANCE_SECS = 300;
|
|
1415
|
-
var CHALLENGE = "webhook-challenge";
|
|
1416
|
-
var VERIFY_CHALLENGE = "verify-challenge";
|
|
1417
|
-
async function verifyWebhookSignature(body, signature, credential, timestamp, options = {}) {
|
|
1418
|
-
if (!recent(timestamp, options.toleranceSecs ?? DEFAULT_TOLERANCE_SECS)) return false;
|
|
1419
|
-
if (!signature.startsWith(GATEWAY_KEY_PREFIX)) return false;
|
|
1420
|
-
return await verifyHex(
|
|
1421
|
-
credential.publicKey.toLowerCase(),
|
|
1422
|
-
signature.slice(GATEWAY_KEY_PREFIX.length).toLowerCase(),
|
|
1423
|
-
signed(timestamp, body)
|
|
1424
|
-
);
|
|
1296
|
+
return verified.preimage;
|
|
1425
1297
|
}
|
|
1426
|
-
|
|
1427
|
-
if (
|
|
1428
|
-
|
|
1429
|
-
try {
|
|
1430
|
-
return paymentFromWire(JSON.parse(text2));
|
|
1431
|
-
} catch {
|
|
1432
|
-
return null;
|
|
1298
|
+
function isProvablyPaid(payment) {
|
|
1299
|
+
if (payment.status !== "paid" || payment.preimage === null) {
|
|
1300
|
+
return false;
|
|
1433
1301
|
}
|
|
1302
|
+
const invoiceHash = decodeInvoice(payment.bolt11).paymentHash;
|
|
1303
|
+
if (invoiceHash === null || !equalIgnoringCase(invoiceHash, payment.paymentHash)) {
|
|
1304
|
+
return false;
|
|
1305
|
+
}
|
|
1306
|
+
return preimageMatchesHash(payment.preimage, invoiceHash);
|
|
1434
1307
|
}
|
|
1435
|
-
|
|
1436
|
-
const
|
|
1437
|
-
|
|
1438
|
-
if (signature === null || timestamp === null) return null;
|
|
1439
|
-
return parseWebhook(await request.text(), signature, credential, timestamp, options);
|
|
1308
|
+
function wrapFeeCeiling(amountMsat, allowance) {
|
|
1309
|
+
const proportional = amountMsat * (allowance?.proportion ?? DEFAULT_WRAP_PROPORTION);
|
|
1310
|
+
return Math.max(allowance?.baseMsat ?? DEFAULT_WRAP_BASE_MSAT, Math.floor(proportional));
|
|
1440
1311
|
}
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
const
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
}
|
|
1447
|
-
|
|
1312
|
+
function proveWrapped(wrapped, recipient, allowance) {
|
|
1313
|
+
const offered = decodeInvoice(wrapped);
|
|
1314
|
+
const owed = decodeInvoice(recipient);
|
|
1315
|
+
const refuse = (code, detail) => {
|
|
1316
|
+
throw new WrapRefusedError(code, detail);
|
|
1317
|
+
};
|
|
1318
|
+
if (offered.paymentHash === null || offered.amountMsat === null || offered.expiresAt === null) {
|
|
1319
|
+
refuse("undecodable", "the wrapped invoice carries no hash, amount or expiry");
|
|
1320
|
+
}
|
|
1321
|
+
if (owed.paymentHash === null || owed.amountMsat === null || owed.expiresAt === null) {
|
|
1322
|
+
refuse("undecodable", "the recipient's invoice carries no hash, amount or expiry");
|
|
1323
|
+
}
|
|
1324
|
+
if (offered.paymentHash?.toLowerCase() !== owed.paymentHash?.toLowerCase()) {
|
|
1325
|
+
refuse("hash_mismatch", "settling the wrap would not settle the recipient");
|
|
1326
|
+
}
|
|
1327
|
+
const asked = offered.amountMsat ?? 0;
|
|
1328
|
+
const due = owed.amountMsat ?? 0;
|
|
1329
|
+
if (asked < due) {
|
|
1330
|
+
refuse("amount_below_recipient", `${asked} msat cannot forward ${due} msat`);
|
|
1331
|
+
}
|
|
1332
|
+
const ceiling = wrapFeeCeiling(due, allowance);
|
|
1333
|
+
if (asked - due > ceiling) {
|
|
1334
|
+
refuse("fee_above_allowance", `${asked - due} msat over ${due}, and ${ceiling} was allowed`);
|
|
1335
|
+
}
|
|
1336
|
+
if ((owed.expiresAt ?? 0) <= (offered.expiresAt ?? 0)) {
|
|
1337
|
+
refuse("recipient_expires_first", "the operator could be paid and find it cannot deliver");
|
|
1448
1338
|
}
|
|
1449
1339
|
}
|
|
1450
|
-
async function
|
|
1451
|
-
const
|
|
1452
|
-
const
|
|
1453
|
-
|
|
1454
|
-
|
|
1340
|
+
async function payRequestFor(listed, payment) {
|
|
1341
|
+
const at = listed.indexOf("@");
|
|
1342
|
+
const name = listed.slice(0, at);
|
|
1343
|
+
const domain = listed.slice(at + 1).toLowerCase();
|
|
1344
|
+
if (at < 1 || !publicHttps(`https://${domain}/`)) {
|
|
1345
|
+
throw new UnverifiedRecipientError(payment.lnAddress, payment.id, "not a usable address");
|
|
1346
|
+
}
|
|
1347
|
+
return reachable(`https://${domain}/.well-known/lnurlp/${name}`, payment);
|
|
1455
1348
|
}
|
|
1456
|
-
async function
|
|
1457
|
-
if (!await verifyWebhookSignature(body, signature, credential, timestamp, options)) return null;
|
|
1349
|
+
async function reachable(url, payment) {
|
|
1458
1350
|
try {
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1351
|
+
if (!publicHttps(url)) {
|
|
1352
|
+
throw new Error(`${url} is not a public https URL`);
|
|
1353
|
+
}
|
|
1354
|
+
const response = await fetch(url, {
|
|
1355
|
+
signal: AbortSignal.timeout(HTTP_TIMEOUT_MS),
|
|
1356
|
+
headers: { accept: "application/json" }
|
|
1357
|
+
});
|
|
1358
|
+
if (!response.ok) {
|
|
1359
|
+
throw new Error(`answered ${response.status}`);
|
|
1360
|
+
}
|
|
1361
|
+
const body = await response.json();
|
|
1362
|
+
if (body === null || typeof body !== "object") {
|
|
1363
|
+
throw new Error("answered with no JSON object");
|
|
1364
|
+
}
|
|
1365
|
+
return body;
|
|
1366
|
+
} catch (cause) {
|
|
1367
|
+
throw new UnverifiedRecipientError(payment.lnAddress, payment.id, cause);
|
|
1462
1368
|
}
|
|
1463
1369
|
}
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
const timestamp = request.headers.get(TIMESTAMP_HEADER2);
|
|
1467
|
-
if (signature === null || timestamp === null) return null;
|
|
1468
|
-
return await parseSettlement(await request.text(), signature, credential, timestamp, options);
|
|
1469
|
-
}
|
|
1470
|
-
function isProvablySettled(settled) {
|
|
1471
|
-
if (settled.status !== "paid" || settled.preimage === null) return false;
|
|
1472
|
-
return preimageMatchesHash(settled.preimage, settled.paymentHash);
|
|
1473
|
-
}
|
|
1474
|
-
async function answerWebhookChallenge(body, signature, credential, timestamp, options = {}) {
|
|
1475
|
-
if (!await verifyWebhookSignature(body, signature, credential, timestamp, options)) return null;
|
|
1476
|
-
const nonce = challenged(typeof body === "string" ? body : new TextDecoder().decode(body));
|
|
1477
|
-
return nonce === null ? null : JSON.stringify({ nonce });
|
|
1478
|
-
}
|
|
1479
|
-
async function answerWebhookChallengeRequest(request, credential, options = {}) {
|
|
1480
|
-
const signature = request.headers.get(SIGNATURE_HEADER2);
|
|
1481
|
-
const timestamp = request.headers.get(TIMESTAMP_HEADER2);
|
|
1482
|
-
if (signature === null || timestamp === null) return null;
|
|
1483
|
-
const answer = await answerWebhookChallenge(
|
|
1484
|
-
await request.clone().text(),
|
|
1485
|
-
signature,
|
|
1486
|
-
credential,
|
|
1487
|
-
timestamp,
|
|
1488
|
-
options
|
|
1489
|
-
);
|
|
1490
|
-
return answer === null ? null : new Response(answer, { headers: { "content-type": "application/json" } });
|
|
1491
|
-
}
|
|
1492
|
-
function textOf(body) {
|
|
1493
|
-
return typeof body === "string" ? body : new TextDecoder().decode(body);
|
|
1494
|
-
}
|
|
1495
|
-
function challenged(text2) {
|
|
1496
|
-
return nonceOf(text2, CHALLENGE);
|
|
1370
|
+
function equalIgnoringCase(one, other) {
|
|
1371
|
+
return one.toLowerCase() === other.toLowerCase();
|
|
1497
1372
|
}
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1373
|
+
|
|
1374
|
+
// src/client.ts
|
|
1375
|
+
var TERMINAL = /* @__PURE__ */ new Set(["paid", "expired"]);
|
|
1376
|
+
var RECONNECT_DELAY_MS = 3e3;
|
|
1377
|
+
var RECONNECT_CAP_MS = 3e4;
|
|
1378
|
+
var UNANSWERED_ATTEMPTS = 5;
|
|
1379
|
+
var TRIGGER_MIN_CHARS = 16;
|
|
1380
|
+
var STRANGER_PROBE = "is-this-gateway-yours";
|
|
1381
|
+
function unguessable(trigger) {
|
|
1382
|
+
if (trigger.length < TRIGGER_MIN_CHARS) {
|
|
1383
|
+
throw new Error(
|
|
1384
|
+
`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`
|
|
1385
|
+
);
|
|
1505
1386
|
}
|
|
1387
|
+
return trigger;
|
|
1506
1388
|
}
|
|
1507
|
-
function
|
|
1508
|
-
const
|
|
1509
|
-
return
|
|
1510
|
-
}
|
|
1511
|
-
async function answerVerifyChallengeRequest(request) {
|
|
1512
|
-
if (request.method !== "POST") return null;
|
|
1513
|
-
const answer = answerVerifyChallenge(await request.clone().text());
|
|
1514
|
-
return answer === null ? null : new Response(answer, { headers: { "content-type": "application/json" } });
|
|
1515
|
-
}
|
|
1516
|
-
function recent(timestamp, toleranceSecs) {
|
|
1517
|
-
const sent = Number(timestamp);
|
|
1518
|
-
if (!Number.isFinite(sent)) return false;
|
|
1519
|
-
return Math.abs(Math.floor(Date.now() / 1e3) - sent) <= toleranceSecs;
|
|
1520
|
-
}
|
|
1521
|
-
function signed(timestamp, body) {
|
|
1522
|
-
const encoder = new TextEncoder();
|
|
1523
|
-
const stamp = encoder.encode(`${timestamp}.`);
|
|
1524
|
-
const rest = typeof body === "string" ? encoder.encode(body) : new Uint8Array(body);
|
|
1525
|
-
const payload = new Uint8Array(stamp.length + rest.length);
|
|
1526
|
-
payload.set(stamp);
|
|
1527
|
-
payload.set(rest, stamp.length);
|
|
1528
|
-
return payload;
|
|
1529
|
-
}
|
|
1530
|
-
|
|
1531
|
-
// src/bank.ts
|
|
1532
|
-
var DEFAULT_CURRENCY = "CZK";
|
|
1533
|
-
var DEFAULT_LOOK_BACK_SECS = 7 * 24 * 60 * 60;
|
|
1534
|
-
var DEFAULT_POLL_EVERY_SECS = 30;
|
|
1535
|
-
var IBAN = /^[A-Z]{2}[0-9]{2}[0-9A-Z]{8,30}$/;
|
|
1536
|
-
var FORBIDDEN_IN_SPD = "*";
|
|
1537
|
-
async function bankTransfer(params) {
|
|
1538
|
-
const currency = params.currency ?? DEFAULT_CURRENCY;
|
|
1539
|
-
refuseUnusable(params, currency);
|
|
1540
|
-
await refuseAnOpenGateway(params);
|
|
1541
|
-
const spd = shortPaymentDescriptor(params, currency);
|
|
1542
|
-
const subject = subjectOf(params.reference, params.amountMinor, currency);
|
|
1543
|
-
const polling = new URL(params.verifyUrl);
|
|
1544
|
-
polling.searchParams.set("ref", params.reference);
|
|
1545
|
-
polling.searchParams.set("minor", String(params.amountMinor));
|
|
1546
|
-
polling.searchParams.set("cc", currency);
|
|
1547
|
-
polling.searchParams.set("sig", await hmacHex(params.secret, `verify|${subject}`));
|
|
1548
|
-
const verifyUrl = polling.toString();
|
|
1549
|
-
const watched = await params.gateway.watchPayment({
|
|
1550
|
-
paymentHash: hashOf(await hmacHex(params.secret, `preimage|${subject}`)),
|
|
1551
|
-
verifyUrl,
|
|
1552
|
-
expiresAt: params.expiresAt,
|
|
1553
|
-
trigger: params.trigger,
|
|
1554
|
-
sealed: params.sealed,
|
|
1555
|
-
webhookUrl: params.webhookUrl
|
|
1556
|
-
});
|
|
1557
|
-
return { id: watched.id, paymentHash: watched.paymentHash, verifyUrl, spd };
|
|
1389
|
+
function backoffMs(firstDelay, attempt) {
|
|
1390
|
+
const grown = Math.min(firstDelay * 2 ** (attempt - 1), RECONNECT_CAP_MS);
|
|
1391
|
+
return Math.round(grown * (0.5 + Math.random() / 2));
|
|
1558
1392
|
}
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1393
|
+
var ThunderBridge = class {
|
|
1394
|
+
baseUrl;
|
|
1395
|
+
verify;
|
|
1396
|
+
token;
|
|
1397
|
+
secret;
|
|
1398
|
+
strangers = null;
|
|
1399
|
+
speaks = null;
|
|
1400
|
+
constructor(baseUrl, options) {
|
|
1401
|
+
this.baseUrl = baseUrl.replace(/\/+$/, "");
|
|
1402
|
+
this.verify = options?.verify ?? true;
|
|
1403
|
+
this.token = options?.token ?? null;
|
|
1404
|
+
this.secret = options?.secret ?? null;
|
|
1405
|
+
}
|
|
1406
|
+
/**
|
|
1407
|
+
* Whether a token was given, which is what makes an instance yours: a gateway
|
|
1408
|
+
* started with `GATEWAY_TOKEN` answers nobody else, so anything you hand it
|
|
1409
|
+
* stays between you and it
|
|
1410
|
+
*/
|
|
1411
|
+
get isPrivate() {
|
|
1412
|
+
return this.token !== null;
|
|
1413
|
+
}
|
|
1414
|
+
/**
|
|
1415
|
+
* Whether the gateway turns away a caller carrying no token, asked by making
|
|
1416
|
+
* one unauthenticated read it would have to refuse. `isPrivate` answers only
|
|
1417
|
+
* whether you configured a token, which is your side of the arrangement and
|
|
1418
|
+
* says nothing about the gateway's, so a made-up token against a public
|
|
1419
|
+
* instance reads as private and is not. Asked once and remembered, because an
|
|
1420
|
+
* instance does not change its mind. Anything other than a refusal counts as
|
|
1421
|
+
* open, so an unreachable gateway fails closed
|
|
1422
|
+
*/
|
|
1423
|
+
async refusesStrangers() {
|
|
1424
|
+
this.strangers ??= fetch(`${this.baseUrl}/incoming-payments/${STRANGER_PROBE}`, {
|
|
1425
|
+
headers: { accept: "application/json" }
|
|
1426
|
+
}).then((answer) => answer.status === 401).catch(() => false);
|
|
1427
|
+
return await this.strangers;
|
|
1428
|
+
}
|
|
1429
|
+
/**
|
|
1430
|
+
* Ask the gateway for an invoice payable to the first address on your list
|
|
1431
|
+
* that can issue a provable one, throws `NoWalletAvailableError` when none can
|
|
1432
|
+
* and `GatewayCheatError` when what comes back is not what you asked for
|
|
1433
|
+
*/
|
|
1434
|
+
async createPayment(params, options) {
|
|
1435
|
+
const sent = createRequestBody(
|
|
1436
|
+
params,
|
|
1437
|
+
options?.trigger ? sha256Hex(unguessable(options.trigger)) : null,
|
|
1438
|
+
options?.replay
|
|
1439
|
+
);
|
|
1440
|
+
const headers = await this.sending("/incoming-payments", sent);
|
|
1441
|
+
if (options?.idempotencyKey) {
|
|
1442
|
+
headers["idempotency-key"] = options.idempotencyKey;
|
|
1443
|
+
}
|
|
1444
|
+
const response = await fetch(`${this.baseUrl}/incoming-payments`, {
|
|
1445
|
+
method: "POST",
|
|
1446
|
+
headers,
|
|
1447
|
+
body: sent
|
|
1448
|
+
});
|
|
1449
|
+
if (!response.ok) {
|
|
1450
|
+
throw await problemFrom(response);
|
|
1451
|
+
}
|
|
1452
|
+
const payment = await paymentFrom(response);
|
|
1453
|
+
if (this.verify) {
|
|
1454
|
+
await proveOrigin(payment, params);
|
|
1455
|
+
}
|
|
1456
|
+
return payment;
|
|
1457
|
+
}
|
|
1458
|
+
/**
|
|
1459
|
+
* Ask which address would serve an amount without minting anything, throws
|
|
1460
|
+
* `NoWalletAvailableError` when none would. A quote is a probe and not a
|
|
1461
|
+
* promise: the address it names can still be refused at create time, because
|
|
1462
|
+
* whether a wallet returns a provable invoice cannot be known without asking
|
|
1463
|
+
* it for one, and asking mints it
|
|
1464
|
+
*/
|
|
1465
|
+
async createQuote(params) {
|
|
1466
|
+
const sent = quoteRequestBody(params);
|
|
1467
|
+
const response = await fetch(`${this.baseUrl}/quotes`, {
|
|
1468
|
+
method: "POST",
|
|
1469
|
+
headers: await this.sending("/quotes", sent),
|
|
1470
|
+
body: sent
|
|
1471
|
+
});
|
|
1472
|
+
if (!response.ok) {
|
|
1473
|
+
throw await problemFrom(response);
|
|
1474
|
+
}
|
|
1475
|
+
const quote = quoteFromWire(await response.json().catch(() => null));
|
|
1476
|
+
if (quote === null) {
|
|
1477
|
+
throw new ProblemError({
|
|
1478
|
+
status: response.status,
|
|
1479
|
+
title: "The gateway answered with something that is not a quote"
|
|
1480
|
+
});
|
|
1481
|
+
}
|
|
1482
|
+
return quote;
|
|
1483
|
+
}
|
|
1484
|
+
/** Read a payment back, null when the gateway has never heard of it */
|
|
1485
|
+
/**
|
|
1486
|
+
* The key this gateway signs webhooks with when you registered none of your own,
|
|
1487
|
+
* ready to hand to `parseWebhookRequest` as `{ publicKey }`. Fetch it once and
|
|
1488
|
+
* keep it: it is the same for every instance in the cluster
|
|
1489
|
+
*/
|
|
1490
|
+
async webhookKey() {
|
|
1491
|
+
const response = await fetch(`${this.baseUrl}/webhook-key`);
|
|
1492
|
+
if (!response.ok) {
|
|
1493
|
+
throw await problemFrom(response);
|
|
1494
|
+
}
|
|
1495
|
+
const body = await response.json().catch(() => null);
|
|
1496
|
+
if (body?.algorithm !== "ed25519" || typeof body.public_key !== "string") {
|
|
1497
|
+
throw new ProblemError({
|
|
1498
|
+
status: response.status,
|
|
1499
|
+
title: "The gateway published no ed25519 webhook key"
|
|
1500
|
+
});
|
|
1501
|
+
}
|
|
1502
|
+
return body.public_key;
|
|
1503
|
+
}
|
|
1504
|
+
async getPayment(id) {
|
|
1505
|
+
const path = `/incoming-payments/${encodeURIComponent(id)}`;
|
|
1506
|
+
const response = await fetch(`${this.baseUrl}${path}`, {
|
|
1507
|
+
headers: await this.reading("GET", path)
|
|
1508
|
+
});
|
|
1509
|
+
if (response.status === 404) {
|
|
1510
|
+
return null;
|
|
1511
|
+
}
|
|
1512
|
+
if (!response.ok) {
|
|
1513
|
+
throw await problemFrom(response);
|
|
1514
|
+
}
|
|
1515
|
+
return this.checked(await paymentFrom(response));
|
|
1516
|
+
}
|
|
1517
|
+
/**
|
|
1518
|
+
* Read back a payment the gateway is only watching, null when it has never
|
|
1519
|
+
* heard of it. A watched payment carries no address, amount or invoice, so
|
|
1520
|
+
* `getPayment` refuses it and this reads the shape both rails share
|
|
1521
|
+
*/
|
|
1522
|
+
async getWatched(id) {
|
|
1523
|
+
const path = `/incoming-payments/${encodeURIComponent(id)}`;
|
|
1524
|
+
const response = await fetch(`${this.baseUrl}${path}`, {
|
|
1525
|
+
headers: await this.reading("GET", path)
|
|
1526
|
+
});
|
|
1527
|
+
if (response.status === 404) {
|
|
1528
|
+
return null;
|
|
1529
|
+
}
|
|
1530
|
+
if (!response.ok) {
|
|
1531
|
+
throw await problemFrom(response);
|
|
1532
|
+
}
|
|
1533
|
+
const watched = triggerEventFromWire(await response.json().catch(() => null));
|
|
1534
|
+
if (watched === null) {
|
|
1535
|
+
throw new ProblemError({
|
|
1536
|
+
status: response.status,
|
|
1537
|
+
title: "The gateway answered with something that is not a payment"
|
|
1538
|
+
});
|
|
1539
|
+
}
|
|
1540
|
+
return this.proven(watched);
|
|
1541
|
+
}
|
|
1542
|
+
/**
|
|
1543
|
+
* List what this gateway is watching, newest first. Only a gateway started
|
|
1544
|
+
* with `GATEWAY_TOKEN` serves this, because on a shared one it would hand
|
|
1545
|
+
* every caller everyone else's payments, so a public gateway answers 404.
|
|
1546
|
+
*
|
|
1547
|
+
* `scanned` says how many settled records were looked at to build the page.
|
|
1548
|
+
* Anything older than that window is not in the answer, and the list does not
|
|
1549
|
+
* pretend otherwise
|
|
1550
|
+
*/
|
|
1551
|
+
async listPayments(limit) {
|
|
1552
|
+
const path = `/incoming-payments${limit === void 0 ? "" : `?limit=${limit}`}`;
|
|
1553
|
+
const response = await fetch(`${this.baseUrl}${path}`, {
|
|
1554
|
+
headers: await this.reading("GET", path)
|
|
1555
|
+
});
|
|
1556
|
+
if (!response.ok) {
|
|
1557
|
+
throw await problemFrom(response);
|
|
1558
|
+
}
|
|
1559
|
+
const body = await response.json().catch(() => null);
|
|
1560
|
+
const listed = Array.isArray(body?.payments) ? body.payments.map(triggerEventFromWire).filter((one) => one !== null) : null;
|
|
1561
|
+
if (listed === null || typeof body?.settled_scanned !== "number") {
|
|
1562
|
+
throw new ProblemError({
|
|
1563
|
+
status: response.status,
|
|
1564
|
+
title: "The gateway answered with something that is not a payment list"
|
|
1565
|
+
});
|
|
1566
|
+
}
|
|
1567
|
+
return { payments: listed, scanned: body.settled_scanned };
|
|
1568
|
+
}
|
|
1569
|
+
/**
|
|
1570
|
+
* Follow a payment over WebSocket until it is paid or expired, reconnecting
|
|
1571
|
+
* through a drop. A payment that never answers gives up after a few tries, and
|
|
1572
|
+
* one that has answered is followed until its own expiry, so the wait always
|
|
1573
|
+
* ends by itself
|
|
1574
|
+
*/
|
|
1575
|
+
async waitForPayment(id, options) {
|
|
1576
|
+
const payment = paymentFromWire(await this.followed(id, options));
|
|
1577
|
+
if (payment === null) {
|
|
1578
|
+
throw new ProblemError({
|
|
1579
|
+
status: 200,
|
|
1580
|
+
title: `payment ${id} is watched rather than minted here, read it with waitForWatched`
|
|
1581
|
+
});
|
|
1582
|
+
}
|
|
1583
|
+
return this.checked(payment);
|
|
1584
|
+
}
|
|
1585
|
+
/**
|
|
1586
|
+
* Follow a payment the gateway is only watching, one it did not mint, until it
|
|
1587
|
+
* is paid or expired.
|
|
1588
|
+
*
|
|
1589
|
+
* A watched payment carries no address, no amount and no invoice, because the
|
|
1590
|
+
* gateway was told none of them, so it reads back as the shape a trigger
|
|
1591
|
+
* streams rather than as a `Payment`. That is every bank transfer, and every
|
|
1592
|
+
* Lightning invoice registered with `watchPayment` instead of `createPayment`.
|
|
1593
|
+
*/
|
|
1594
|
+
async waitForWatched(id, options) {
|
|
1595
|
+
const watched = triggerEventFromWire(await this.followed(id, options));
|
|
1596
|
+
if (watched === null) {
|
|
1597
|
+
throw new ProblemError({
|
|
1598
|
+
status: 200,
|
|
1599
|
+
title: "The gateway answered with something that is not a payment"
|
|
1600
|
+
});
|
|
1601
|
+
}
|
|
1602
|
+
return this.proven(watched);
|
|
1603
|
+
}
|
|
1604
|
+
followed(id, options) {
|
|
1605
|
+
const base = this.baseUrl.replace(/^http/, "ws");
|
|
1606
|
+
const direct = `${base}/ws/incoming-payments/${encodeURIComponent(id)}`;
|
|
1607
|
+
return new Promise((resolve2, reject) => {
|
|
1608
|
+
const aborted = () => new Error(`waiting for payment ${id} was aborted`);
|
|
1609
|
+
if (options?.signal?.aborted) {
|
|
1610
|
+
reject(aborted());
|
|
1611
|
+
return;
|
|
1612
|
+
}
|
|
1613
|
+
let socket = null;
|
|
1614
|
+
let settled = false;
|
|
1615
|
+
let attempt = 0;
|
|
1616
|
+
let expiresAt = null;
|
|
1617
|
+
let retry;
|
|
1618
|
+
const abort = () => settle(() => reject(aborted()));
|
|
1619
|
+
const settle = (finish) => {
|
|
1620
|
+
if (settled) {
|
|
1621
|
+
return;
|
|
1622
|
+
}
|
|
1623
|
+
settled = true;
|
|
1624
|
+
options?.signal?.removeEventListener("abort", abort);
|
|
1625
|
+
clearTimeout(retry);
|
|
1626
|
+
finish();
|
|
1627
|
+
socket?.close();
|
|
1628
|
+
};
|
|
1629
|
+
const again = () => {
|
|
1630
|
+
if (settled) {
|
|
1631
|
+
return;
|
|
1632
|
+
}
|
|
1633
|
+
if (expiresAt === null && attempt >= UNANSWERED_ATTEMPTS) {
|
|
1634
|
+
settle(() => reject(new Error(`no gateway answered for payment ${id}`)));
|
|
1635
|
+
return;
|
|
1636
|
+
}
|
|
1637
|
+
if (expiresAt !== null && Date.now() >= expiresAt * 1e3) {
|
|
1638
|
+
settle(() => reject(new Error(`payment ${id} went unreported past its expiry`)));
|
|
1639
|
+
return;
|
|
1640
|
+
}
|
|
1641
|
+
retry = setTimeout(() => void connect(), backoffMs(RECONNECT_DELAY_MS, attempt));
|
|
1642
|
+
};
|
|
1643
|
+
const connect = async () => {
|
|
1644
|
+
if (settled) {
|
|
1645
|
+
return;
|
|
1646
|
+
}
|
|
1647
|
+
attempt += 1;
|
|
1648
|
+
let url = direct;
|
|
1649
|
+
if (this.needsTicket(options?.tickets)) {
|
|
1650
|
+
try {
|
|
1651
|
+
url = `${base}/ws/tickets/${await this.wsTicket({ payment_id: id })}`;
|
|
1652
|
+
} catch (refused) {
|
|
1653
|
+
if (refused instanceof ProblemError) {
|
|
1654
|
+
settle(() => reject(refused));
|
|
1655
|
+
} else {
|
|
1656
|
+
again();
|
|
1657
|
+
}
|
|
1658
|
+
return;
|
|
1659
|
+
}
|
|
1660
|
+
}
|
|
1661
|
+
if (settled) {
|
|
1662
|
+
return;
|
|
1663
|
+
}
|
|
1664
|
+
const opened = new WebSocket(url);
|
|
1665
|
+
socket = opened;
|
|
1666
|
+
opened.onmessage = (event) => {
|
|
1667
|
+
try {
|
|
1668
|
+
const frame = JSON.parse(String(event.data));
|
|
1669
|
+
const watched = triggerEventFromWire(frame);
|
|
1670
|
+
if (watched === null) {
|
|
1671
|
+
return;
|
|
1672
|
+
}
|
|
1673
|
+
expiresAt = watched.expiresAt;
|
|
1674
|
+
attempt = 1;
|
|
1675
|
+
if (!TERMINAL.has(watched.status)) {
|
|
1676
|
+
return;
|
|
1677
|
+
}
|
|
1678
|
+
settle(() => resolve2(frame));
|
|
1679
|
+
} catch (refused) {
|
|
1680
|
+
settle(() => reject(refused));
|
|
1681
|
+
}
|
|
1682
|
+
};
|
|
1683
|
+
opened.onclose = again;
|
|
1684
|
+
};
|
|
1685
|
+
options?.signal?.addEventListener("abort", abort, { once: true });
|
|
1686
|
+
void connect();
|
|
1687
|
+
});
|
|
1688
|
+
}
|
|
1689
|
+
/**
|
|
1690
|
+
* Wait on several payments and keep the first one that is really paid, then stop
|
|
1691
|
+
* waiting on the losers, which closes their sockets.
|
|
1692
|
+
*
|
|
1693
|
+
* This is how one order offers two rails. A Lightning invoice and a bank
|
|
1694
|
+
* transfer for the same thing are two payments here, and the payer picks one, so
|
|
1695
|
+
* what you want is the one that arrives and nothing further from the other.
|
|
1696
|
+
*
|
|
1697
|
+
* A leg that expires is a loser, not a winner, which is the whole reason this is
|
|
1698
|
+
* not a race: `waitForPayment` ends on `paid` and on `expired` alike, and a
|
|
1699
|
+
* Lightning invoice expires in an hour while a bank transfer takes days. `null`
|
|
1700
|
+
* means every leg ended without being paid.
|
|
1701
|
+
*
|
|
1702
|
+
* Stopping the wait is not revoking the invoice. Nobody can revoke one, because
|
|
1703
|
+
* the recipient's own wallet minted it, so a payer who pays the loser afterwards
|
|
1704
|
+
* really does pay twice and that shows up on `followTrigger` as a second
|
|
1705
|
+
* settlement to refund.
|
|
1706
|
+
*/
|
|
1707
|
+
async firstToSettle(ids, options) {
|
|
1708
|
+
if (ids.length === 0) {
|
|
1709
|
+
return null;
|
|
1710
|
+
}
|
|
1711
|
+
const stopLosers = new AbortController();
|
|
1712
|
+
const signal = options?.signal ? AbortSignal.any([stopLosers.signal, options.signal]) : stopLosers.signal;
|
|
1713
|
+
let refused = null;
|
|
1714
|
+
try {
|
|
1715
|
+
const winner = await new Promise((resolve2) => {
|
|
1716
|
+
let waiting = ids.length;
|
|
1717
|
+
const lost = () => {
|
|
1718
|
+
waiting -= 1;
|
|
1719
|
+
if (waiting === 0) {
|
|
1720
|
+
resolve2(null);
|
|
1721
|
+
}
|
|
1722
|
+
};
|
|
1723
|
+
for (const id of ids) {
|
|
1724
|
+
this.waitForWatched(id, { ...options, signal }).then((watched) => watched.status === "paid" ? resolve2(watched) : lost()).catch((failure) => {
|
|
1725
|
+
refused ??= failure;
|
|
1726
|
+
lost();
|
|
1727
|
+
});
|
|
1728
|
+
}
|
|
1729
|
+
});
|
|
1730
|
+
if (winner === null && refused !== null) {
|
|
1731
|
+
throw refused;
|
|
1732
|
+
}
|
|
1733
|
+
return winner;
|
|
1734
|
+
} finally {
|
|
1735
|
+
stopLosers.abort();
|
|
1736
|
+
}
|
|
1737
|
+
}
|
|
1738
|
+
/**
|
|
1739
|
+
* Hand over an invoice you obtained yourself so the gateway watches it without
|
|
1740
|
+
* being told the address or the amount. It can then only refuse everyone
|
|
1741
|
+
* rather than one recipient, which is what makes leaving it cheap. Anything
|
|
1742
|
+
* the watcher needs goes in `sealed`, which the gateway cannot read
|
|
1743
|
+
*/
|
|
1744
|
+
async watchPayment(params) {
|
|
1745
|
+
const sent = watchRequestBody(
|
|
1746
|
+
params,
|
|
1747
|
+
params.trigger ? sha256Hex(unguessable(params.trigger)) : null
|
|
1748
|
+
);
|
|
1749
|
+
const response = await fetch(`${this.baseUrl}/watched-payments`, {
|
|
1750
|
+
method: "POST",
|
|
1751
|
+
headers: await this.sending("/watched-payments", sent),
|
|
1752
|
+
body: sent
|
|
1753
|
+
});
|
|
1754
|
+
if (!response.ok) {
|
|
1755
|
+
throw await problemFrom(response);
|
|
1756
|
+
}
|
|
1757
|
+
const watched = triggerEventFromWire(await response.json().catch(() => null));
|
|
1758
|
+
if (watched === null) {
|
|
1759
|
+
throw new ProblemError({
|
|
1760
|
+
status: response.status,
|
|
1761
|
+
title: "The gateway answered with something that is not a watched payment"
|
|
1762
|
+
});
|
|
1763
|
+
}
|
|
1764
|
+
const named = await this.nameFor(params.paymentHash);
|
|
1765
|
+
if (named !== null && watched.id !== named) {
|
|
1766
|
+
throw new GatewayCheatError("id_not_mine", watched.id);
|
|
1767
|
+
}
|
|
1768
|
+
return watched;
|
|
1769
|
+
}
|
|
1770
|
+
/**
|
|
1771
|
+
* What this payment is called, which you can work out before any gateway has
|
|
1772
|
+
* heard of it. Every gateway you hand the same invoice to answers with the same
|
|
1773
|
+
* name, so watching at several of them adds up to one payment rather than
|
|
1774
|
+
* several, and no gateway's key is in the answer. Null when no secret was given,
|
|
1775
|
+
* because then the gateway names the payment and only it can
|
|
1776
|
+
*/
|
|
1777
|
+
async nameFor(paymentHash) {
|
|
1778
|
+
return this.secret === null ? null : paymentNamedBy((await this.speaking()).publicKeyHex, paymentHash);
|
|
1779
|
+
}
|
|
1780
|
+
/**
|
|
1781
|
+
* Follow every payment made to one trigger, replayed from the recent ones on
|
|
1782
|
+
* connect and then live, reconnecting on its own until the returned function
|
|
1783
|
+
* is called. A trigger has no terminal state, so this never resolves
|
|
1784
|
+
*/
|
|
1785
|
+
followTrigger(secret, options) {
|
|
1786
|
+
const base = this.baseUrl.replace(/^http/, "ws");
|
|
1787
|
+
const asked = options.replay === void 0 ? "" : `?replay=${options.replay}`;
|
|
1788
|
+
const direct = `${base}/ws/triggers/${encodeURIComponent(unguessable(secret))}${asked}`;
|
|
1789
|
+
const firstDelay = options.reconnectDelayMs ?? RECONNECT_DELAY_MS;
|
|
1790
|
+
let socket = null;
|
|
1791
|
+
let retry;
|
|
1792
|
+
let stopped = false;
|
|
1793
|
+
let attempt = 0;
|
|
1794
|
+
const again = () => {
|
|
1795
|
+
if (stopped || options.reconnect === false) {
|
|
1796
|
+
return;
|
|
1797
|
+
}
|
|
1798
|
+
retry = setTimeout(() => void connect(), backoffMs(firstDelay, attempt));
|
|
1799
|
+
};
|
|
1800
|
+
const connect = async () => {
|
|
1801
|
+
attempt += 1;
|
|
1802
|
+
let url = direct;
|
|
1803
|
+
if (this.needsTicket(options.tickets)) {
|
|
1804
|
+
try {
|
|
1805
|
+
url = `${base}/ws/tickets/${await this.wsTicket({
|
|
1806
|
+
trigger_secret: secret,
|
|
1807
|
+
replay: options.replay
|
|
1808
|
+
})}`;
|
|
1809
|
+
} catch (refused) {
|
|
1810
|
+
options.onError?.(refused);
|
|
1811
|
+
again();
|
|
1812
|
+
return;
|
|
1813
|
+
}
|
|
1814
|
+
}
|
|
1815
|
+
if (stopped) {
|
|
1816
|
+
return;
|
|
1817
|
+
}
|
|
1818
|
+
socket = new WebSocket(url);
|
|
1819
|
+
socket.onopen = () => {
|
|
1820
|
+
attempt = 1;
|
|
1821
|
+
};
|
|
1822
|
+
socket.onmessage = (event) => {
|
|
1823
|
+
try {
|
|
1824
|
+
const settled = triggerEventFromWire(JSON.parse(String(event.data)));
|
|
1825
|
+
if (settled !== null) {
|
|
1826
|
+
options.onPayment(this.proven(settled));
|
|
1827
|
+
}
|
|
1828
|
+
} catch (refused) {
|
|
1829
|
+
options.onError?.(refused);
|
|
1830
|
+
}
|
|
1831
|
+
};
|
|
1832
|
+
socket.onerror = () => options.onError?.(new Error(`could not follow trigger at ${base}`));
|
|
1833
|
+
socket.onclose = again;
|
|
1834
|
+
};
|
|
1835
|
+
void connect();
|
|
1836
|
+
return () => {
|
|
1837
|
+
stopped = true;
|
|
1838
|
+
clearTimeout(retry);
|
|
1839
|
+
socket?.close();
|
|
1840
|
+
};
|
|
1841
|
+
}
|
|
1842
|
+
needsTicket(asked) {
|
|
1843
|
+
return asked === true || this.token !== null;
|
|
1844
|
+
}
|
|
1845
|
+
async wsTicket(body) {
|
|
1846
|
+
const sent = JSON.stringify(body);
|
|
1847
|
+
const response = await fetch(`${this.baseUrl}/ws-tickets`, {
|
|
1848
|
+
method: "POST",
|
|
1849
|
+
headers: await this.sending("/ws-tickets", sent),
|
|
1850
|
+
body: sent
|
|
1851
|
+
});
|
|
1852
|
+
if (!response.ok) {
|
|
1853
|
+
throw await problemFrom(response);
|
|
1572
1854
|
}
|
|
1573
|
-
const
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
}
|
|
1582
|
-
function readQuery(url) {
|
|
1583
|
-
const reference = url.searchParams.get("ref");
|
|
1584
|
-
const minor = Number(url.searchParams.get("minor"));
|
|
1585
|
-
const currency = url.searchParams.get("cc");
|
|
1586
|
-
const signature = url.searchParams.get("sig");
|
|
1587
|
-
if (!reference || !currency || !signature) return null;
|
|
1588
|
-
if (!Number.isInteger(minor) || minor <= 0) return null;
|
|
1589
|
-
return { reference, amountMinor: minor, currency, signature };
|
|
1590
|
-
}
|
|
1591
|
-
function pays(credit, asked) {
|
|
1592
|
-
return credit.amountMinor === asked.amountMinor && credit.currency.toUpperCase() === asked.currency.toUpperCase() && credit.reference.toUpperCase().includes(asked.reference.toUpperCase());
|
|
1593
|
-
}
|
|
1594
|
-
function subjectOf(reference, amountMinor, currency) {
|
|
1595
|
-
return `${reference}|${amountMinor}|${currency.toUpperCase()}`;
|
|
1596
|
-
}
|
|
1597
|
-
function hashOf(preimage) {
|
|
1598
|
-
return bytesToHex(sha256(hexToBytes(preimage)));
|
|
1599
|
-
}
|
|
1600
|
-
function shortPaymentDescriptor(params, currency) {
|
|
1601
|
-
const fields = [
|
|
1602
|
-
`ACC:${params.iban}`,
|
|
1603
|
-
`AM:${major(params.amountMinor, currency)}`,
|
|
1604
|
-
`CC:${currency.toUpperCase()}`,
|
|
1605
|
-
`MSG:${params.reference}`
|
|
1606
|
-
];
|
|
1607
|
-
if (params.variableSymbol) fields.push(`X-VS:${params.variableSymbol}`);
|
|
1608
|
-
return `SPD*1.0*${fields.join("*")}`;
|
|
1609
|
-
}
|
|
1610
|
-
function major(amountMinor, currency) {
|
|
1611
|
-
return (amountMinor / minorScaleOf(currency)).toFixed(minorUnitsOf(currency));
|
|
1612
|
-
}
|
|
1613
|
-
async function refuseAnOpenGateway(params) {
|
|
1614
|
-
if (params.allowPublicGateway === true) return;
|
|
1615
|
-
if (await params.gateway.refusesStrangers()) return;
|
|
1616
|
-
throw new Error(
|
|
1617
|
-
"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"
|
|
1618
|
-
);
|
|
1619
|
-
}
|
|
1620
|
-
function refuseUnusable(params, currency) {
|
|
1621
|
-
if (!IBAN.test(params.iban)) throw new Error(`${params.iban} is not an IBAN`);
|
|
1622
|
-
if (!Number.isInteger(params.amountMinor) || params.amountMinor <= 0) {
|
|
1623
|
-
throw new Error("amountMinor must be a whole number of minor units above zero");
|
|
1855
|
+
const minted = await response.json().catch(() => null);
|
|
1856
|
+
if (typeof minted?.ticket !== "string") {
|
|
1857
|
+
throw new ProblemError({
|
|
1858
|
+
status: response.status,
|
|
1859
|
+
title: "The gateway answered with something that is not a ticket"
|
|
1860
|
+
});
|
|
1861
|
+
}
|
|
1862
|
+
return encodeURIComponent(minted.ticket);
|
|
1624
1863
|
}
|
|
1625
|
-
|
|
1626
|
-
|
|
1864
|
+
async sending(path, body) {
|
|
1865
|
+
return { "content-type": "application/json", ...await this.reading("POST", path, body) };
|
|
1627
1866
|
}
|
|
1628
|
-
|
|
1629
|
-
|
|
1867
|
+
async reading(method, path, body = "") {
|
|
1868
|
+
return {
|
|
1869
|
+
...this.token === null ? {} : { authorization: `Bearer ${this.token}` },
|
|
1870
|
+
...this.secret === null ? {} : await signedAs(await this.speaking(), method, path, body)
|
|
1871
|
+
};
|
|
1630
1872
|
}
|
|
1631
|
-
|
|
1632
|
-
|
|
1873
|
+
async speaking() {
|
|
1874
|
+
this.speaks ??= callerKey(this.secret ?? "");
|
|
1875
|
+
return await this.speaks;
|
|
1876
|
+
}
|
|
1877
|
+
proven(settled) {
|
|
1878
|
+
const unproven = settled.status === "paid" && (settled.preimage === null || !preimageMatchesHash(settled.preimage, settled.paymentHash));
|
|
1879
|
+
if (this.verify && unproven) {
|
|
1880
|
+
throw new GatewayCheatError("preimage_mismatch", settled.id);
|
|
1881
|
+
}
|
|
1882
|
+
return settled;
|
|
1883
|
+
}
|
|
1884
|
+
checked(payment) {
|
|
1885
|
+
if (this.verify && payment.status === "paid" && !isProvablyPaid(payment)) {
|
|
1886
|
+
throw new GatewayCheatError("preimage_mismatch", payment.id);
|
|
1887
|
+
}
|
|
1888
|
+
return payment;
|
|
1889
|
+
}
|
|
1890
|
+
};
|
|
1891
|
+
async function paymentFrom(response) {
|
|
1892
|
+
const payment = paymentFromWire(await response.json().catch(() => null));
|
|
1893
|
+
if (payment === null) {
|
|
1894
|
+
throw new ProblemError({
|
|
1895
|
+
status: response.status,
|
|
1896
|
+
title: "The gateway answered with something that is not a payment"
|
|
1897
|
+
});
|
|
1633
1898
|
}
|
|
1899
|
+
return payment;
|
|
1634
1900
|
}
|
|
1635
|
-
function
|
|
1636
|
-
|
|
1901
|
+
async function problemFrom(response) {
|
|
1902
|
+
const problem = await response.json().catch(() => ({}));
|
|
1903
|
+
const document = { ...problem, status: response.status };
|
|
1904
|
+
if (isProblemType(problem, NO_WALLET_AVAILABLE)) {
|
|
1905
|
+
return new NoWalletAvailableError(document, refusals(problem.wallets));
|
|
1906
|
+
}
|
|
1907
|
+
if (isProblemType(problem, REQUEST_IN_FLIGHT)) {
|
|
1908
|
+
return new IdempotencyConflictError(document, "request-in-flight");
|
|
1909
|
+
}
|
|
1910
|
+
if (isProblemType(problem, IDEMPOTENCY_KEY_REUSED)) {
|
|
1911
|
+
return new IdempotencyConflictError(document, "key-reused");
|
|
1912
|
+
}
|
|
1913
|
+
return new ProblemError(document);
|
|
1914
|
+
}
|
|
1915
|
+
function refusals(wallets) {
|
|
1916
|
+
if (!Array.isArray(wallets)) {
|
|
1917
|
+
return [];
|
|
1918
|
+
}
|
|
1919
|
+
return wallets.filter((wallet) => {
|
|
1920
|
+
return typeof wallet === "object" && wallet !== null && "address" in wallet;
|
|
1921
|
+
});
|
|
1637
1922
|
}
|
|
1638
1923
|
|
|
1639
1924
|
// src/fio.ts
|
|
@@ -1659,7 +1944,9 @@ var WHERE_A_PAYER_WRITES = [
|
|
|
1659
1944
|
var UTC_OFFSET = /^[+-]\d{4}$/;
|
|
1660
1945
|
function fioStatement(config) {
|
|
1661
1946
|
const named = typeof config.token === "string" ? [config.token] : config.token;
|
|
1662
|
-
if (named.length === 0)
|
|
1947
|
+
if (named.length === 0) {
|
|
1948
|
+
throw new Error("fioStatement needs at least one token to read with");
|
|
1949
|
+
}
|
|
1663
1950
|
const usedAt = new Map(named.map((token) => [token, Number.NEGATIVE_INFINITY]));
|
|
1664
1951
|
const tokenWindowMs = (config.minIntervalSecs ?? DEFAULT_MIN_INTERVAL_SECS) * MILLIS;
|
|
1665
1952
|
const paceMs = tokenWindowMs / usedAt.size;
|
|
@@ -1667,9 +1954,13 @@ function fioStatement(config) {
|
|
|
1667
1954
|
let credits = [];
|
|
1668
1955
|
return async (sinceUnix) => {
|
|
1669
1956
|
const now = Date.now();
|
|
1670
|
-
if (now - lastRead < paceMs)
|
|
1957
|
+
if (now - lastRead < paceMs) {
|
|
1958
|
+
return credits;
|
|
1959
|
+
}
|
|
1671
1960
|
const idlest = longestUnused(usedAt);
|
|
1672
|
-
if (now - idlest.usedAt < tokenWindowMs)
|
|
1961
|
+
if (now - idlest.usedAt < tokenWindowMs) {
|
|
1962
|
+
return credits;
|
|
1963
|
+
}
|
|
1673
1964
|
usedAt.set(idlest.token, now);
|
|
1674
1965
|
lastRead = now;
|
|
1675
1966
|
const url = [
|
|
@@ -1684,7 +1975,9 @@ function fioStatement(config) {
|
|
|
1684
1975
|
if (answer.status === TOO_SOON) {
|
|
1685
1976
|
throw new Error("fio refuses a second read of this token inside its 30 second window");
|
|
1686
1977
|
}
|
|
1687
|
-
if (!answer.ok)
|
|
1978
|
+
if (!answer.ok) {
|
|
1979
|
+
throw new Error(`fio answered ${answer.status} reading the statement`);
|
|
1980
|
+
}
|
|
1688
1981
|
const read = await answer.json();
|
|
1689
1982
|
credits = (read.accountStatement?.transactionList?.transaction ?? []).filter(isCredit).map(asCredit);
|
|
1690
1983
|
return credits;
|
|
@@ -1714,86 +2007,250 @@ function asCredit(transaction) {
|
|
|
1714
2007
|
}
|
|
1715
2008
|
function bookedAtIn(cell) {
|
|
1716
2009
|
const value = cell?.value;
|
|
1717
|
-
if (typeof value === "number")
|
|
2010
|
+
if (typeof value === "number") {
|
|
2011
|
+
return Math.floor(value / MILLIS);
|
|
2012
|
+
}
|
|
1718
2013
|
const at = Date.parse(asIso8601(textIn(cell)));
|
|
1719
2014
|
return Number.isFinite(at) ? Math.floor(at / MILLIS) : 0;
|
|
1720
2015
|
}
|
|
1721
2016
|
function asIso8601(booked) {
|
|
1722
2017
|
const day = booked.slice(0, DATE_CHARS);
|
|
1723
2018
|
const zone = booked.slice(DATE_CHARS);
|
|
1724
|
-
if (!UTC_OFFSET.test(zone))
|
|
2019
|
+
if (!UTC_OFFSET.test(zone)) {
|
|
2020
|
+
return `${day}T00:00:00Z`;
|
|
2021
|
+
}
|
|
1725
2022
|
return `${day}T00:00:00${zone.slice(0, 3)}:${zone.slice(3)}`;
|
|
1726
2023
|
}
|
|
1727
|
-
function numberIn(cell) {
|
|
1728
|
-
const value = Number(cell?.value ?? Number.NaN);
|
|
1729
|
-
return Number.isFinite(value) ? value : 0;
|
|
2024
|
+
function numberIn(cell) {
|
|
2025
|
+
const value = Number(cell?.value ?? Number.NaN);
|
|
2026
|
+
return Number.isFinite(value) ? value : 0;
|
|
2027
|
+
}
|
|
2028
|
+
function textIn(cell) {
|
|
2029
|
+
const value = cell?.value;
|
|
2030
|
+
return value === null || value === void 0 ? "" : String(value).trim();
|
|
2031
|
+
}
|
|
2032
|
+
function asDate(unix) {
|
|
2033
|
+
return new Date(unix * MILLIS).toISOString().slice(0, DATE_CHARS);
|
|
2034
|
+
}
|
|
2035
|
+
|
|
2036
|
+
// src/gateways.ts
|
|
2037
|
+
var Gateways = class {
|
|
2038
|
+
each;
|
|
2039
|
+
urls;
|
|
2040
|
+
onRefused;
|
|
2041
|
+
constructor(baseUrls, options) {
|
|
2042
|
+
if (baseUrls.length === 0) {
|
|
2043
|
+
throw new Error("name at least one gateway to watch at");
|
|
2044
|
+
}
|
|
2045
|
+
this.urls = [...baseUrls];
|
|
2046
|
+
this.each = baseUrls.map((baseUrl) => new ThunderBridge(baseUrl, options));
|
|
2047
|
+
this.onRefused = options?.onRefused ?? (() => {
|
|
2048
|
+
});
|
|
2049
|
+
}
|
|
2050
|
+
/** What this payment is called, which every gateway here will agree on */
|
|
2051
|
+
async nameFor(paymentHash) {
|
|
2052
|
+
return await this.each[0].nameFor(paymentHash);
|
|
2053
|
+
}
|
|
2054
|
+
/**
|
|
2055
|
+
* Hand the same invoice to every gateway. Throws only when none of them took it,
|
|
2056
|
+
* carrying the first refusal, because one gateway that agreed is enough to be
|
|
2057
|
+
* watched
|
|
2058
|
+
*/
|
|
2059
|
+
async watchPayment(params) {
|
|
2060
|
+
const asked = await Promise.allSettled(
|
|
2061
|
+
this.each.map((gateway) => gateway.watchPayment(params))
|
|
2062
|
+
);
|
|
2063
|
+
const taken = [];
|
|
2064
|
+
const refusals3 = [];
|
|
2065
|
+
for (const [at, answer] of asked.entries()) {
|
|
2066
|
+
if (answer.status === "fulfilled") {
|
|
2067
|
+
taken.push(answer.value);
|
|
2068
|
+
continue;
|
|
2069
|
+
}
|
|
2070
|
+
refusals3.push(answer.reason);
|
|
2071
|
+
this.onRefused(this.urls[at], answer.reason);
|
|
2072
|
+
}
|
|
2073
|
+
if (taken[0] === void 0) {
|
|
2074
|
+
throw refusals3[0];
|
|
2075
|
+
}
|
|
2076
|
+
return taken[0];
|
|
2077
|
+
}
|
|
2078
|
+
/**
|
|
2079
|
+
* Wait for whichever gateway speaks first. A settlement from any of them is the
|
|
2080
|
+
* settlement, and each has already proved the preimage against the hash before
|
|
2081
|
+
* saying so. When they all end without a payment, the first ending is the answer,
|
|
2082
|
+
* and when they all fail, the first failure is thrown
|
|
2083
|
+
*/
|
|
2084
|
+
async waitForWatched(id, options) {
|
|
2085
|
+
const stopLosers = new AbortController();
|
|
2086
|
+
const signal = options?.signal ? AbortSignal.any([stopLosers.signal, options.signal]) : stopLosers.signal;
|
|
2087
|
+
const unpaid = [];
|
|
2088
|
+
let refused = null;
|
|
2089
|
+
try {
|
|
2090
|
+
const settled = await new Promise((resolve2) => {
|
|
2091
|
+
let waiting = this.each.length;
|
|
2092
|
+
const lost = () => {
|
|
2093
|
+
waiting -= 1;
|
|
2094
|
+
if (waiting === 0) {
|
|
2095
|
+
resolve2(null);
|
|
2096
|
+
}
|
|
2097
|
+
};
|
|
2098
|
+
for (const gateway of this.each) {
|
|
2099
|
+
gateway.waitForWatched(id, { ...options, signal }).then((watched) => {
|
|
2100
|
+
if (watched.status === "paid") {
|
|
2101
|
+
return resolve2(watched);
|
|
2102
|
+
}
|
|
2103
|
+
unpaid.push(watched);
|
|
2104
|
+
lost();
|
|
2105
|
+
}).catch((failure) => {
|
|
2106
|
+
refused ??= failure;
|
|
2107
|
+
lost();
|
|
2108
|
+
});
|
|
2109
|
+
}
|
|
2110
|
+
});
|
|
2111
|
+
if (settled !== null) {
|
|
2112
|
+
return settled;
|
|
2113
|
+
}
|
|
2114
|
+
if (unpaid.length > 0) {
|
|
2115
|
+
return unpaid[0];
|
|
2116
|
+
}
|
|
2117
|
+
throw refused;
|
|
2118
|
+
} finally {
|
|
2119
|
+
stopLosers.abort();
|
|
2120
|
+
}
|
|
2121
|
+
}
|
|
2122
|
+
};
|
|
2123
|
+
|
|
2124
|
+
// src/price.ts
|
|
2125
|
+
var MSAT_PER_BTC2 = 1e11;
|
|
2126
|
+
var BASIS_POINTS = 1e4;
|
|
2127
|
+
var DEFAULT_HOLD_SECS = 60;
|
|
2128
|
+
var DEFAULT_MIN_VENUES = 2;
|
|
2129
|
+
var DEFAULT_MAX_SPREAD_BPS = 200;
|
|
2130
|
+
var MILLIS2 = 1e3;
|
|
2131
|
+
function coinbase(baseUrl = "https://api.coinbase.com") {
|
|
2132
|
+
return async (currency) => {
|
|
2133
|
+
const asked = currency.toUpperCase();
|
|
2134
|
+
const answer = await asJson(`${baseUrl}/v2/prices/BTC-${asked}/spot`, "coinbase");
|
|
2135
|
+
const quoted = answer.data;
|
|
2136
|
+
if (quoted?.currency !== asked) {
|
|
2137
|
+
throw new Error(`coinbase quoted ${quoted?.currency} when asked for ${asked}`);
|
|
2138
|
+
}
|
|
2139
|
+
return asMinor(quoted.amount, "coinbase", asked);
|
|
2140
|
+
};
|
|
2141
|
+
}
|
|
2142
|
+
function kraken(baseUrl = "https://api.kraken.com") {
|
|
2143
|
+
return async (currency) => {
|
|
2144
|
+
const asked = currency.toUpperCase();
|
|
2145
|
+
const answer = await asJson(`${baseUrl}/0/public/Ticker?pair=XBT${asked}`, "kraken");
|
|
2146
|
+
if (answer.error?.length) {
|
|
2147
|
+
throw new Error(`kraken refused XBT${asked}: ${answer.error[0]}`);
|
|
2148
|
+
}
|
|
2149
|
+
const pairs = Object.values(answer.result ?? {});
|
|
2150
|
+
if (pairs.length !== 1) {
|
|
2151
|
+
throw new Error(`kraken answered ${pairs.length} pairs for XBT${asked}`);
|
|
2152
|
+
}
|
|
2153
|
+
return asMinor(pairs[0]?.c?.[0], "kraken", asked);
|
|
2154
|
+
};
|
|
2155
|
+
}
|
|
2156
|
+
function bitstamp(baseUrl = "https://www.bitstamp.net") {
|
|
2157
|
+
return async (currency) => {
|
|
2158
|
+
const pair = `btc${currency.toLowerCase()}`;
|
|
2159
|
+
const answer = await asJson(`${baseUrl}/api/v2/ticker/${pair}/`, "bitstamp");
|
|
2160
|
+
if (Array.isArray(answer)) {
|
|
2161
|
+
throw new Error(`bitstamp has no ${pair} pair`);
|
|
2162
|
+
}
|
|
2163
|
+
return asMinor(answer.last, "bitstamp", currency);
|
|
2164
|
+
};
|
|
1730
2165
|
}
|
|
1731
|
-
function
|
|
1732
|
-
|
|
1733
|
-
|
|
2166
|
+
function coinmate(baseUrl = "https://coinmate.io") {
|
|
2167
|
+
return async (currency) => {
|
|
2168
|
+
const pair = `BTC_${currency.toUpperCase()}`;
|
|
2169
|
+
const answer = await asJson(`${baseUrl}/api/ticker?currencyPair=${pair}`, "coinmate");
|
|
2170
|
+
if (answer.error !== false) {
|
|
2171
|
+
throw new Error(`coinmate refused ${pair}: ${answer.errorMessage ?? "no reason"}`);
|
|
2172
|
+
}
|
|
2173
|
+
return asMinor(answer.data?.last, "coinmate", currency);
|
|
2174
|
+
};
|
|
1734
2175
|
}
|
|
1735
|
-
function
|
|
1736
|
-
|
|
2176
|
+
function medianOf(tickers = [coinbase(), kraken(), bitstamp(), coinmate()], options = {}) {
|
|
2177
|
+
const holdFor = (options.holdForSecs ?? DEFAULT_HOLD_SECS) * MILLIS2;
|
|
2178
|
+
const minVenues = options.minVenues ?? DEFAULT_MIN_VENUES;
|
|
2179
|
+
const maxSpread = options.maxSpreadBps ?? DEFAULT_MAX_SPREAD_BPS;
|
|
2180
|
+
const held = /* @__PURE__ */ new Map();
|
|
2181
|
+
return async (currency) => {
|
|
2182
|
+
const asked = currency.toUpperCase();
|
|
2183
|
+
const remembered = held.get(asked);
|
|
2184
|
+
if (remembered && Date.now() - remembered.at < holdFor) {
|
|
2185
|
+
return remembered.price;
|
|
2186
|
+
}
|
|
2187
|
+
const answers = await Promise.allSettled(tickers.map((ticker) => ticker(asked)));
|
|
2188
|
+
const quoted = answers.filter((answer) => answer.status === "fulfilled").map((answer) => answer.value).sort((one, other) => one - other);
|
|
2189
|
+
if (quoted.length < minVenues) {
|
|
2190
|
+
throw new Error(
|
|
2191
|
+
`${quoted.length} of ${tickers.length} venues quoted ${asked}, ${minVenues} wanted: ${refusals2(answers)}`
|
|
2192
|
+
);
|
|
2193
|
+
}
|
|
2194
|
+
const spread = spreadBpsOf(quoted);
|
|
2195
|
+
if (spread > maxSpread) {
|
|
2196
|
+
throw new Error(
|
|
2197
|
+
`venues disagree on ${asked} by ${Math.round(spread)} bps, more than the ${maxSpread} allowed: ${quoted.join(", ")}`
|
|
2198
|
+
);
|
|
2199
|
+
}
|
|
2200
|
+
const price = middleOf(quoted);
|
|
2201
|
+
held.set(asked, { at: Date.now(), price });
|
|
2202
|
+
return price;
|
|
2203
|
+
};
|
|
1737
2204
|
}
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
var BECH32_GENERATOR = [996825010, 642813549, 513874426, 1027748829, 705979059];
|
|
1742
|
-
var CHECKSUM_SLOTS = [0, 1, 2, 3, 4, 5];
|
|
1743
|
-
var LNURL_HRP = "lnurl";
|
|
1744
|
-
function toLnurl(endpoint) {
|
|
1745
|
-
const scheme = parsedScheme(endpoint);
|
|
1746
|
-
if (scheme !== "https:" && scheme !== "http:") {
|
|
1747
|
-
throw new Error(`${endpoint} is not an http or https URL`);
|
|
2205
|
+
function msatFor(amountMinor, priceMinorPerBtc, options = {}) {
|
|
2206
|
+
if (!Number.isInteger(amountMinor) || amountMinor <= 0) {
|
|
2207
|
+
throw new Error("amountMinor must be a whole number of minor units above zero");
|
|
1748
2208
|
}
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
2209
|
+
if (!Number.isFinite(priceMinorPerBtc) || priceMinorPerBtc <= 0) {
|
|
2210
|
+
throw new Error(`${priceMinorPerBtc} is not a usable price`);
|
|
2211
|
+
}
|
|
2212
|
+
const spreadBps = options.spreadBps ?? 0;
|
|
2213
|
+
if (!Number.isInteger(spreadBps) || spreadBps < 0) {
|
|
2214
|
+
throw new Error("spreadBps must be a whole number of basis points, none or more");
|
|
2215
|
+
}
|
|
2216
|
+
const wanted = BigInt(amountMinor) * BigInt(MSAT_PER_BTC2) * BigInt(BASIS_POINTS + spreadBps);
|
|
2217
|
+
const per = BigInt(Math.round(priceMinorPerBtc)) * BigInt(BASIS_POINTS);
|
|
2218
|
+
const msat = (wanted + per - 1n) / per;
|
|
2219
|
+
if (msat > BigInt(Number.MAX_SAFE_INTEGER)) {
|
|
2220
|
+
throw new Error(`${amountMinor} at ${priceMinorPerBtc} is more millisatoshi than exist`);
|
|
2221
|
+
}
|
|
2222
|
+
return Number(msat);
|
|
1752
2223
|
}
|
|
1753
|
-
function
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
return null;
|
|
2224
|
+
async function asJson(url, venue) {
|
|
2225
|
+
const answer = await fetch(url, { headers: { accept: "application/json" } });
|
|
2226
|
+
if (!answer.ok) {
|
|
2227
|
+
throw new Error(`${venue} answered ${answer.status}`);
|
|
1758
2228
|
}
|
|
2229
|
+
return answer.json();
|
|
1759
2230
|
}
|
|
1760
|
-
function
|
|
1761
|
-
const
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
for (const byte of bytes) {
|
|
1765
|
-
accumulator = accumulator << 8 | byte;
|
|
1766
|
-
bits += 8;
|
|
1767
|
-
while (bits >= 5) {
|
|
1768
|
-
bits -= 5;
|
|
1769
|
-
words.push(accumulator >> bits & 31);
|
|
1770
|
-
}
|
|
2231
|
+
function asMinor(quoted, venue, currency) {
|
|
2232
|
+
const major2 = Number(quoted);
|
|
2233
|
+
if (!Number.isFinite(major2) || major2 <= 0) {
|
|
2234
|
+
throw new Error(`${venue} quoted ${String(quoted)} for ${currency.toUpperCase()}`);
|
|
1771
2235
|
}
|
|
1772
|
-
|
|
1773
|
-
return words;
|
|
2236
|
+
return Math.round(major2 * minorScaleOf(currency));
|
|
1774
2237
|
}
|
|
1775
|
-
function
|
|
1776
|
-
const
|
|
1777
|
-
const
|
|
1778
|
-
|
|
1779
|
-
0,
|
|
1780
|
-
...expanded.map((code) => code & 31),
|
|
1781
|
-
...words,
|
|
1782
|
-
...CHECKSUM_SLOTS.map(() => 0)
|
|
1783
|
-
];
|
|
1784
|
-
const polymod = bech32Polymod(values) ^ 1;
|
|
1785
|
-
return CHECKSUM_SLOTS.map((slot) => polymod >> 5 * (5 - slot) & 31);
|
|
2238
|
+
function spreadBpsOf(sorted) {
|
|
2239
|
+
const lowest = sorted[0] ?? 0;
|
|
2240
|
+
const highest = sorted[sorted.length - 1] ?? 0;
|
|
2241
|
+
return lowest === 0 ? Number.POSITIVE_INFINITY : (highest - lowest) / lowest * BASIS_POINTS;
|
|
1786
2242
|
}
|
|
1787
|
-
function
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
checksum = (checksum & 33554431) << 5 ^ value;
|
|
1792
|
-
for (let bit = 0; bit < 5; bit++) {
|
|
1793
|
-
if (top >> bit & 1) checksum ^= BECH32_GENERATOR[bit];
|
|
1794
|
-
}
|
|
2243
|
+
function middleOf(sorted) {
|
|
2244
|
+
const middle = Math.floor(sorted.length / 2);
|
|
2245
|
+
if (sorted.length % 2 === 1) {
|
|
2246
|
+
return sorted[middle] ?? 0;
|
|
1795
2247
|
}
|
|
1796
|
-
return
|
|
2248
|
+
return Math.round(((sorted[middle - 1] ?? 0) + (sorted[middle] ?? 0)) / 2);
|
|
2249
|
+
}
|
|
2250
|
+
function refusals2(answers) {
|
|
2251
|
+
return answers.filter((answer) => answer.status === "rejected").map(
|
|
2252
|
+
(answer) => answer.reason instanceof Error ? answer.reason.message : String(answer.reason)
|
|
2253
|
+
).join("; ");
|
|
1797
2254
|
}
|
|
1798
2255
|
|
|
1799
2256
|
// src/qr.ts
|
|
@@ -1830,7 +2287,9 @@ function qrToDataUrl(payload, options) {
|
|
|
1830
2287
|
function svgOf(scanned, options) {
|
|
1831
2288
|
const size = options?.size ?? 256;
|
|
1832
2289
|
const color = options?.color ?? "#000";
|
|
1833
|
-
if (!SAFE_COLOR.test(color))
|
|
2290
|
+
if (!SAFE_COLOR.test(color)) {
|
|
2291
|
+
throw new Error(`Invalid color: ${color}`);
|
|
2292
|
+
}
|
|
1834
2293
|
return renderSvg(scanned, size, color);
|
|
1835
2294
|
}
|
|
1836
2295
|
function asDataUrl(svg) {
|
|
@@ -1876,6 +2335,7 @@ function bankRail(config) {
|
|
|
1876
2335
|
currency: order.currency,
|
|
1877
2336
|
expiresAt,
|
|
1878
2337
|
trigger: config.trigger,
|
|
2338
|
+
replay: config.replay,
|
|
1879
2339
|
sealed: await config.sealed?.(order),
|
|
1880
2340
|
variableSymbol: config.variableSymbol?.(order),
|
|
1881
2341
|
webhookUrl: config.webhookUrl,
|
|
@@ -1898,7 +2358,11 @@ function lightningRail(config) {
|
|
|
1898
2358
|
amountMsat: await config.amountMsat(order),
|
|
1899
2359
|
webhookUrl: config.webhookUrl
|
|
1900
2360
|
},
|
|
1901
|
-
{
|
|
2361
|
+
{
|
|
2362
|
+
idempotencyKey: config.idempotencyKey?.(order),
|
|
2363
|
+
trigger: config.trigger,
|
|
2364
|
+
replay: config.replay
|
|
2365
|
+
}
|
|
1902
2366
|
);
|
|
1903
2367
|
return {
|
|
1904
2368
|
id: payment.id,
|
|
@@ -1909,128 +2373,6 @@ function lightningRail(config) {
|
|
|
1909
2373
|
};
|
|
1910
2374
|
};
|
|
1911
2375
|
}
|
|
1912
|
-
|
|
1913
|
-
// src/price.ts
|
|
1914
|
-
var MSAT_PER_BTC2 = 1e11;
|
|
1915
|
-
var BASIS_POINTS = 1e4;
|
|
1916
|
-
var DEFAULT_HOLD_SECS = 60;
|
|
1917
|
-
var DEFAULT_MIN_VENUES = 2;
|
|
1918
|
-
var DEFAULT_MAX_SPREAD_BPS = 200;
|
|
1919
|
-
var MILLIS2 = 1e3;
|
|
1920
|
-
function coinbase(baseUrl = "https://api.coinbase.com") {
|
|
1921
|
-
return async (currency) => {
|
|
1922
|
-
const asked = currency.toUpperCase();
|
|
1923
|
-
const answer = await asJson(`${baseUrl}/v2/prices/BTC-${asked}/spot`, "coinbase");
|
|
1924
|
-
const quoted = answer.data;
|
|
1925
|
-
if (quoted?.currency !== asked) {
|
|
1926
|
-
throw new Error(`coinbase quoted ${quoted?.currency} when asked for ${asked}`);
|
|
1927
|
-
}
|
|
1928
|
-
return asMinor(quoted.amount, "coinbase", asked);
|
|
1929
|
-
};
|
|
1930
|
-
}
|
|
1931
|
-
function kraken(baseUrl = "https://api.kraken.com") {
|
|
1932
|
-
return async (currency) => {
|
|
1933
|
-
const asked = currency.toUpperCase();
|
|
1934
|
-
const answer = await asJson(`${baseUrl}/0/public/Ticker?pair=XBT${asked}`, "kraken");
|
|
1935
|
-
if (answer.error?.length) throw new Error(`kraken refused XBT${asked}: ${answer.error[0]}`);
|
|
1936
|
-
const pairs = Object.values(answer.result ?? {});
|
|
1937
|
-
if (pairs.length !== 1) {
|
|
1938
|
-
throw new Error(`kraken answered ${pairs.length} pairs for XBT${asked}`);
|
|
1939
|
-
}
|
|
1940
|
-
return asMinor(pairs[0]?.c?.[0], "kraken", asked);
|
|
1941
|
-
};
|
|
1942
|
-
}
|
|
1943
|
-
function bitstamp(baseUrl = "https://www.bitstamp.net") {
|
|
1944
|
-
return async (currency) => {
|
|
1945
|
-
const pair = `btc${currency.toLowerCase()}`;
|
|
1946
|
-
const answer = await asJson(`${baseUrl}/api/v2/ticker/${pair}/`, "bitstamp");
|
|
1947
|
-
if (Array.isArray(answer)) throw new Error(`bitstamp has no ${pair} pair`);
|
|
1948
|
-
return asMinor(answer.last, "bitstamp", currency);
|
|
1949
|
-
};
|
|
1950
|
-
}
|
|
1951
|
-
function coinmate(baseUrl = "https://coinmate.io") {
|
|
1952
|
-
return async (currency) => {
|
|
1953
|
-
const pair = `BTC_${currency.toUpperCase()}`;
|
|
1954
|
-
const answer = await asJson(`${baseUrl}/api/ticker?currencyPair=${pair}`, "coinmate");
|
|
1955
|
-
if (answer.error !== false) {
|
|
1956
|
-
throw new Error(`coinmate refused ${pair}: ${answer.errorMessage ?? "no reason"}`);
|
|
1957
|
-
}
|
|
1958
|
-
return asMinor(answer.data?.last, "coinmate", currency);
|
|
1959
|
-
};
|
|
1960
|
-
}
|
|
1961
|
-
function medianOf(tickers = [coinbase(), kraken(), bitstamp(), coinmate()], options = {}) {
|
|
1962
|
-
const holdFor = (options.holdForSecs ?? DEFAULT_HOLD_SECS) * MILLIS2;
|
|
1963
|
-
const minVenues = options.minVenues ?? DEFAULT_MIN_VENUES;
|
|
1964
|
-
const maxSpread = options.maxSpreadBps ?? DEFAULT_MAX_SPREAD_BPS;
|
|
1965
|
-
const held = /* @__PURE__ */ new Map();
|
|
1966
|
-
return async (currency) => {
|
|
1967
|
-
const asked = currency.toUpperCase();
|
|
1968
|
-
const remembered = held.get(asked);
|
|
1969
|
-
if (remembered && Date.now() - remembered.at < holdFor) return remembered.price;
|
|
1970
|
-
const answers = await Promise.allSettled(tickers.map((ticker) => ticker(asked)));
|
|
1971
|
-
const quoted = answers.filter((answer) => answer.status === "fulfilled").map((answer) => answer.value).sort((one, other) => one - other);
|
|
1972
|
-
if (quoted.length < minVenues) {
|
|
1973
|
-
throw new Error(
|
|
1974
|
-
`${quoted.length} of ${tickers.length} venues quoted ${asked}, ${minVenues} wanted: ${refusals2(answers)}`
|
|
1975
|
-
);
|
|
1976
|
-
}
|
|
1977
|
-
const spread = spreadBpsOf(quoted);
|
|
1978
|
-
if (spread > maxSpread) {
|
|
1979
|
-
throw new Error(
|
|
1980
|
-
`venues disagree on ${asked} by ${Math.round(spread)} bps, more than the ${maxSpread} allowed: ${quoted.join(", ")}`
|
|
1981
|
-
);
|
|
1982
|
-
}
|
|
1983
|
-
const price = middleOf(quoted);
|
|
1984
|
-
held.set(asked, { at: Date.now(), price });
|
|
1985
|
-
return price;
|
|
1986
|
-
};
|
|
1987
|
-
}
|
|
1988
|
-
function msatFor(amountMinor, priceMinorPerBtc, options = {}) {
|
|
1989
|
-
if (!Number.isInteger(amountMinor) || amountMinor <= 0) {
|
|
1990
|
-
throw new Error("amountMinor must be a whole number of minor units above zero");
|
|
1991
|
-
}
|
|
1992
|
-
if (!Number.isFinite(priceMinorPerBtc) || priceMinorPerBtc <= 0) {
|
|
1993
|
-
throw new Error(`${priceMinorPerBtc} is not a usable price`);
|
|
1994
|
-
}
|
|
1995
|
-
const spreadBps = options.spreadBps ?? 0;
|
|
1996
|
-
if (!Number.isInteger(spreadBps) || spreadBps < 0) {
|
|
1997
|
-
throw new Error("spreadBps must be a whole number of basis points, none or more");
|
|
1998
|
-
}
|
|
1999
|
-
const wanted = BigInt(amountMinor) * BigInt(MSAT_PER_BTC2) * BigInt(BASIS_POINTS + spreadBps);
|
|
2000
|
-
const per = BigInt(Math.round(priceMinorPerBtc)) * BigInt(BASIS_POINTS);
|
|
2001
|
-
const msat = (wanted + per - 1n) / per;
|
|
2002
|
-
if (msat > BigInt(Number.MAX_SAFE_INTEGER)) {
|
|
2003
|
-
throw new Error(`${amountMinor} at ${priceMinorPerBtc} is more millisatoshi than exist`);
|
|
2004
|
-
}
|
|
2005
|
-
return Number(msat);
|
|
2006
|
-
}
|
|
2007
|
-
async function asJson(url, venue) {
|
|
2008
|
-
const answer = await fetch(url, { headers: { accept: "application/json" } });
|
|
2009
|
-
if (!answer.ok) throw new Error(`${venue} answered ${answer.status}`);
|
|
2010
|
-
return answer.json();
|
|
2011
|
-
}
|
|
2012
|
-
function asMinor(quoted, venue, currency) {
|
|
2013
|
-
const major2 = Number(quoted);
|
|
2014
|
-
if (!Number.isFinite(major2) || major2 <= 0) {
|
|
2015
|
-
throw new Error(`${venue} quoted ${String(quoted)} for ${currency.toUpperCase()}`);
|
|
2016
|
-
}
|
|
2017
|
-
return Math.round(major2 * minorScaleOf(currency));
|
|
2018
|
-
}
|
|
2019
|
-
function spreadBpsOf(sorted) {
|
|
2020
|
-
const lowest = sorted[0] ?? 0;
|
|
2021
|
-
const highest = sorted[sorted.length - 1] ?? 0;
|
|
2022
|
-
return lowest === 0 ? Number.POSITIVE_INFINITY : (highest - lowest) / lowest * BASIS_POINTS;
|
|
2023
|
-
}
|
|
2024
|
-
function middleOf(sorted) {
|
|
2025
|
-
const middle = Math.floor(sorted.length / 2);
|
|
2026
|
-
if (sorted.length % 2 === 1) return sorted[middle] ?? 0;
|
|
2027
|
-
return Math.round(((sorted[middle - 1] ?? 0) + (sorted[middle] ?? 0)) / 2);
|
|
2028
|
-
}
|
|
2029
|
-
function refusals2(answers) {
|
|
2030
|
-
return answers.filter((answer) => answer.status === "rejected").map(
|
|
2031
|
-
(answer) => answer.reason instanceof Error ? answer.reason.message : String(answer.reason)
|
|
2032
|
-
).join("; ");
|
|
2033
|
-
}
|
|
2034
2376
|
export {
|
|
2035
2377
|
GatewayCheatError,
|
|
2036
2378
|
Gateways,
|
|
@@ -2043,6 +2385,7 @@ export {
|
|
|
2043
2385
|
REQUEST_IN_FLIGHT,
|
|
2044
2386
|
ThunderBridge,
|
|
2045
2387
|
UnverifiedRecipientError,
|
|
2388
|
+
WrapRefusedError,
|
|
2046
2389
|
answerVerifyChallenge,
|
|
2047
2390
|
answerVerifyChallengeRequest,
|
|
2048
2391
|
answerWebhookChallenge,
|
|
@@ -2077,6 +2420,7 @@ export {
|
|
|
2077
2420
|
preimageMatchesHash,
|
|
2078
2421
|
proveOrigin,
|
|
2079
2422
|
proveSettlement,
|
|
2423
|
+
proveWrapped,
|
|
2080
2424
|
qrToDataUrl,
|
|
2081
2425
|
qrToSvg,
|
|
2082
2426
|
seal,
|
|
@@ -2084,5 +2428,6 @@ export {
|
|
|
2084
2428
|
spdToSvg,
|
|
2085
2429
|
toLnurl,
|
|
2086
2430
|
unseal,
|
|
2087
|
-
verifyWebhookSignature
|
|
2431
|
+
verifyWebhookSignature,
|
|
2432
|
+
wrapFeeCeiling
|
|
2088
2433
|
};
|