thunder-bridge 1.4.1 → 1.5.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 +356 -536
- package/dist/bank-B3mHISn1.d.cts +92 -0
- package/dist/bank-B3mHISn1.d.ts +92 -0
- package/dist/bank.cjs +141 -0
- package/dist/bank.d.cts +44 -0
- package/dist/bank.d.ts +44 -0
- package/dist/bank.js +114 -0
- package/dist/client-Cc5gtjGV.d.ts +733 -0
- package/dist/client-CzGZcByI.d.cts +733 -0
- package/dist/errors-0vbVoISA.d.ts +126 -0
- package/dist/errors-Dmh-Uoh8.d.cts +126 -0
- package/dist/index.cjs +1576 -1094
- package/dist/index.d.cts +11 -503
- package/dist/index.d.ts +11 -503
- package/dist/index.js +1556 -1052
- package/dist/{server.cjs → nwc.cjs} +261 -689
- package/dist/nwc.d.cts +120 -0
- package/dist/nwc.d.ts +120 -0
- package/dist/{server.js → nwc.js} +256 -667
- package/dist/price.cjs +240 -0
- package/dist/price.d.cts +17 -0
- package/dist/price.d.ts +17 -0
- package/dist/price.js +205 -0
- package/dist/qr-CF-YeXU1.d.cts +55 -0
- package/dist/qr-CF-YeXU1.d.ts +55 -0
- package/dist/qr.cjs +262 -0
- package/dist/qr.d.cts +1 -0
- package/dist/qr.d.ts +1 -0
- package/dist/qr.js +227 -0
- package/dist/types-BNPmVnA7.d.cts +252 -0
- package/dist/types-BNPmVnA7.d.ts +252 -0
- package/openapi.yaml +1 -1
- package/package.json +48 -9
- package/dist/rail-CqUfuYXJ.d.cts +0 -615
- package/dist/rail-CqUfuYXJ.d.ts +0 -615
- package/dist/server.d.cts +0 -145
- package/dist/server.d.ts +0 -145
package/dist/index.js
CHANGED
|
@@ -13,8 +13,8 @@ function hexToBytes(hex) {
|
|
|
13
13
|
}
|
|
14
14
|
return bytes;
|
|
15
15
|
}
|
|
16
|
-
function isHex(
|
|
17
|
-
return /^[0-9a-f]+$/i.test(
|
|
16
|
+
function isHex(text3) {
|
|
17
|
+
return /^[0-9a-f]+$/i.test(text3) && text3.length % 2 === 0;
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
// ../core/sha256.ts
|
|
@@ -108,8 +108,8 @@ function sha256(bytes) {
|
|
|
108
108
|
}
|
|
109
109
|
return asBytes(state);
|
|
110
110
|
}
|
|
111
|
-
function sha256Hex(
|
|
112
|
-
return bytesToHex(sha256(new TextEncoder().encode(
|
|
111
|
+
function sha256Hex(text3) {
|
|
112
|
+
return bytesToHex(sha256(new TextEncoder().encode(text3)));
|
|
113
113
|
}
|
|
114
114
|
function withPadding(bytes) {
|
|
115
115
|
const padded = new Uint8Array(
|
|
@@ -296,158 +296,6 @@ function wordsToBytes(words) {
|
|
|
296
296
|
return Uint8Array.from(bytes);
|
|
297
297
|
}
|
|
298
298
|
|
|
299
|
-
// ../core/url.ts
|
|
300
|
-
var PRIVATE_SUFFIXES = ["localhost", "local", "internal", "lan", "arpa", "test", "invalid"];
|
|
301
|
-
function publicHttps(raw) {
|
|
302
|
-
let url;
|
|
303
|
-
try {
|
|
304
|
-
url = new URL(raw);
|
|
305
|
-
} catch {
|
|
306
|
-
return false;
|
|
307
|
-
}
|
|
308
|
-
return url.protocol === "https:" && hostIsPublic(url.hostname.toLowerCase());
|
|
309
|
-
}
|
|
310
|
-
function sameOrigin(one, other) {
|
|
311
|
-
try {
|
|
312
|
-
return new URL(one).origin === new URL(other).origin;
|
|
313
|
-
} catch {
|
|
314
|
-
return false;
|
|
315
|
-
}
|
|
316
|
-
}
|
|
317
|
-
function publicAddress(literal) {
|
|
318
|
-
return literal.includes(":") ? ipv6IsGlobal(literal) : ipv4IsGlobal(literal);
|
|
319
|
-
}
|
|
320
|
-
function hostIsPublic(host) {
|
|
321
|
-
if (host.startsWith("[")) {
|
|
322
|
-
return publicAddress(host.slice(1, -1));
|
|
323
|
-
}
|
|
324
|
-
const name = host.replace(/\.$/, "");
|
|
325
|
-
if (/^\d+\.\d+\.\d+\.\d+$/.test(name)) {
|
|
326
|
-
return publicAddress(name);
|
|
327
|
-
}
|
|
328
|
-
const labels = name.split(".");
|
|
329
|
-
if (labels.length < 2 || labels.some((label) => label === "")) {
|
|
330
|
-
return false;
|
|
331
|
-
}
|
|
332
|
-
return !PRIVATE_SUFFIXES.includes(labels[labels.length - 1]);
|
|
333
|
-
}
|
|
334
|
-
function ipv4IsGlobal(literal) {
|
|
335
|
-
const parts = literal.split(".").map(Number);
|
|
336
|
-
if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) {
|
|
337
|
-
return false;
|
|
338
|
-
}
|
|
339
|
-
const [a, b] = parts;
|
|
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
|
-
}
|
|
355
|
-
return true;
|
|
356
|
-
}
|
|
357
|
-
function ipv6IsGlobal(literal) {
|
|
358
|
-
const groups = ipv6Groups(literal.toLowerCase());
|
|
359
|
-
if (groups.length !== 8 || groups.some(Number.isNaN)) {
|
|
360
|
-
return false;
|
|
361
|
-
}
|
|
362
|
-
const leading = groups.slice(0, 5).every((group) => group === 0);
|
|
363
|
-
if (leading && groups[5] === 0 && groups[6] === 0 && groups[7] <= 1) {
|
|
364
|
-
return false;
|
|
365
|
-
}
|
|
366
|
-
if (leading && groups[5] === 65535) {
|
|
367
|
-
const [high, low] = [groups[6], groups[7]];
|
|
368
|
-
return ipv4IsGlobal(`${high >> 8}.${high & 255}.${low >> 8}.${low & 255}`);
|
|
369
|
-
}
|
|
370
|
-
const first = groups[0];
|
|
371
|
-
if ((first & 65024) === 64512) {
|
|
372
|
-
return false;
|
|
373
|
-
}
|
|
374
|
-
if ((first & 65472) === 65152) {
|
|
375
|
-
return false;
|
|
376
|
-
}
|
|
377
|
-
return true;
|
|
378
|
-
}
|
|
379
|
-
function ipv6Groups(address) {
|
|
380
|
-
const [head = "", tail] = address.split("::");
|
|
381
|
-
const left = head.split(":").filter((group) => group !== "");
|
|
382
|
-
const right = (tail ?? "").split(":").filter((group) => group !== "");
|
|
383
|
-
const gap = tail === void 0 ? [] : Array(8 - left.length - right.length).fill("0");
|
|
384
|
-
return [...left, ...gap, ...right].map((group) => parseInt(group, 16));
|
|
385
|
-
}
|
|
386
|
-
|
|
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`);
|
|
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();
|
|
400
|
-
}
|
|
401
|
-
function parsedScheme(endpoint) {
|
|
402
|
-
try {
|
|
403
|
-
return new URL(endpoint).protocol;
|
|
404
|
-
} catch {
|
|
405
|
-
return null;
|
|
406
|
-
}
|
|
407
|
-
}
|
|
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
|
-
}
|
|
419
|
-
}
|
|
420
|
-
if (bits > 0) {
|
|
421
|
-
words.push(accumulator << 5 - bits & 31);
|
|
422
|
-
}
|
|
423
|
-
return words;
|
|
424
|
-
}
|
|
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
299
|
// ../core/sealed.ts
|
|
452
300
|
var VERSION = "v1";
|
|
453
301
|
var IV_BYTES = 12;
|
|
@@ -513,12 +361,12 @@ function toBase64Url(bytes) {
|
|
|
513
361
|
}
|
|
514
362
|
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
515
363
|
}
|
|
516
|
-
function fromBase64Url(
|
|
517
|
-
if (
|
|
364
|
+
function fromBase64Url(text3) {
|
|
365
|
+
if (text3.length === 0 || !/^[A-Za-z0-9_-]+$/.test(text3)) {
|
|
518
366
|
return null;
|
|
519
367
|
}
|
|
520
368
|
try {
|
|
521
|
-
const binary = atob(
|
|
369
|
+
const binary = atob(text3.replace(/-/g, "+").replace(/_/g, "/"));
|
|
522
370
|
const bytes = new Uint8Array(binary.length);
|
|
523
371
|
for (let at = 0; at < bytes.length; at++) {
|
|
524
372
|
bytes[at] = binary.charCodeAt(at);
|
|
@@ -529,32 +377,109 @@ function fromBase64Url(text2) {
|
|
|
529
377
|
}
|
|
530
378
|
}
|
|
531
379
|
|
|
532
|
-
//
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
)
|
|
542
|
-
|
|
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;
|
|
380
|
+
// src/errors.ts
|
|
381
|
+
var AmountError = class extends Error {
|
|
382
|
+
/**
|
|
383
|
+
* Whether a failure is one of these, without asking whether it is this exact
|
|
384
|
+
* class. Every entry point carries its own copy of the class, so a price
|
|
385
|
+
* refused inside `thunder-bridge/price` is not `instanceof` the `AmountError`
|
|
386
|
+
* imported from `thunder-bridge`. The name and the code are the same in every
|
|
387
|
+
* copy, so this holds where `instanceof` does not
|
|
388
|
+
*/
|
|
389
|
+
static is(failure) {
|
|
390
|
+
return failure instanceof Error && failure.name === "AmountError" && "code" in failure;
|
|
548
391
|
}
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
392
|
+
code;
|
|
393
|
+
constructor(code, detail) {
|
|
394
|
+
super(`${code}: ${detail}`);
|
|
395
|
+
this.name = "AmountError";
|
|
396
|
+
this.code = code;
|
|
552
397
|
}
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
398
|
+
};
|
|
399
|
+
var GatewayCheatError = class extends Error {
|
|
400
|
+
code;
|
|
401
|
+
paymentId;
|
|
402
|
+
constructor(code, paymentId) {
|
|
403
|
+
super(`gateway verification failed: ${code}`);
|
|
404
|
+
this.name = "GatewayCheatError";
|
|
405
|
+
this.code = code;
|
|
406
|
+
this.paymentId = paymentId;
|
|
407
|
+
}
|
|
408
|
+
};
|
|
409
|
+
var WrapRefusedError = class extends Error {
|
|
410
|
+
code;
|
|
411
|
+
constructor(code, detail) {
|
|
412
|
+
super(`wrapped invoice refused: ${code}, ${detail}`);
|
|
413
|
+
this.name = "WrapRefusedError";
|
|
414
|
+
this.code = code;
|
|
415
|
+
}
|
|
416
|
+
};
|
|
417
|
+
var UnverifiedRecipientError = class extends Error {
|
|
418
|
+
lnAddress;
|
|
419
|
+
paymentId;
|
|
420
|
+
constructor(lnAddress, paymentId, cause) {
|
|
421
|
+
super(`could not reach ${lnAddress} to verify the invoice: ${String(cause)}`);
|
|
422
|
+
this.name = "UnverifiedRecipientError";
|
|
423
|
+
this.lnAddress = lnAddress;
|
|
424
|
+
this.paymentId = paymentId;
|
|
425
|
+
}
|
|
426
|
+
};
|
|
427
|
+
var ProblemError = class extends Error {
|
|
428
|
+
static NO_WALLET_AVAILABLE = "urn:problem-type:thunder-bridge:no-wallet-available";
|
|
429
|
+
static REQUEST_IN_FLIGHT = "urn:problem-type:thunder-bridge:request-in-flight";
|
|
430
|
+
static IDEMPOTENCY_KEY_REUSED = "urn:problem-type:thunder-bridge:idempotency-key-reused";
|
|
431
|
+
static PAYMENT_ALREADY_WATCHED = "urn:problem-type:thunder-bridge:payment-already-watched";
|
|
432
|
+
static INVALID_REQUEST = "urn:problem-type:thunder-bridge:invalid-request";
|
|
433
|
+
static CALLER_UNKNOWN = "urn:problem-type:thunder-bridge:caller-unknown";
|
|
434
|
+
static VERIFY_HOST_REFUSED = "urn:problem-type:thunder-bridge:verify-host-refused";
|
|
435
|
+
static VERIFY_UNCONFIRMED = "urn:problem-type:thunder-bridge:verify-unconfirmed";
|
|
436
|
+
static VERIFY_UNCONSENTED = "urn:problem-type:thunder-bridge:verify-unconsented";
|
|
437
|
+
static WEBHOOK_UNCONFIRMED = "urn:problem-type:thunder-bridge:webhook-unconfirmed";
|
|
438
|
+
static TOO_MANY_PENDING = "urn:problem-type:thunder-bridge:too-many-pending";
|
|
439
|
+
/**
|
|
440
|
+
* Whether a problem carries this type. Branch on the type, never on the prose,
|
|
441
|
+
* and reach for `instanceof` first: every type this SDK gives a class to has one
|
|
442
|
+
*/
|
|
443
|
+
static is(problem, type) {
|
|
444
|
+
return problem.type === type;
|
|
445
|
+
}
|
|
446
|
+
type;
|
|
447
|
+
title;
|
|
448
|
+
status;
|
|
449
|
+
detail;
|
|
450
|
+
constructor(problem) {
|
|
451
|
+
const title = problem.title ?? "Request failed";
|
|
452
|
+
super(problem.detail ? `${title}: ${problem.detail}` : title);
|
|
453
|
+
this.name = "ProblemError";
|
|
454
|
+
this.type = problem.type ?? "about:blank";
|
|
455
|
+
this.title = title;
|
|
456
|
+
this.status = problem.status ?? 0;
|
|
457
|
+
this.detail = problem.detail ?? null;
|
|
458
|
+
}
|
|
459
|
+
};
|
|
460
|
+
function isProblemType(problem, type) {
|
|
461
|
+
return problem.type === type;
|
|
557
462
|
}
|
|
463
|
+
var NO_WALLET_AVAILABLE = "urn:problem-type:thunder-bridge:no-wallet-available";
|
|
464
|
+
var REQUEST_IN_FLIGHT = "urn:problem-type:thunder-bridge:request-in-flight";
|
|
465
|
+
var IDEMPOTENCY_KEY_REUSED = "urn:problem-type:thunder-bridge:idempotency-key-reused";
|
|
466
|
+
var PAYMENT_ALREADY_WATCHED = "urn:problem-type:thunder-bridge:payment-already-watched";
|
|
467
|
+
var IdempotencyConflictError = class extends ProblemError {
|
|
468
|
+
conflict;
|
|
469
|
+
constructor(problem, conflict) {
|
|
470
|
+
super(problem);
|
|
471
|
+
this.name = "IdempotencyConflictError";
|
|
472
|
+
this.conflict = conflict;
|
|
473
|
+
}
|
|
474
|
+
};
|
|
475
|
+
var NoWalletAvailableError = class extends ProblemError {
|
|
476
|
+
wallets;
|
|
477
|
+
constructor(problem, wallets) {
|
|
478
|
+
super({ ...problem, type: NO_WALLET_AVAILABLE });
|
|
479
|
+
this.name = "NoWalletAvailableError";
|
|
480
|
+
this.wallets = wallets;
|
|
481
|
+
}
|
|
482
|
+
};
|
|
558
483
|
|
|
559
484
|
// src/currency.ts
|
|
560
485
|
var ISO_4217_MINOR_UNITS = {
|
|
@@ -588,72 +513,770 @@ var ISO_4217_MINOR_UNITS = {
|
|
|
588
513
|
function minorUnitsOf(currency) {
|
|
589
514
|
const digits = ISO_4217_MINOR_UNITS[currency.toUpperCase()];
|
|
590
515
|
if (digits === void 0) {
|
|
591
|
-
throw new
|
|
516
|
+
throw new AmountError(
|
|
517
|
+
"unknown-currency",
|
|
592
518
|
`${currency.toUpperCase()} is not a currency this knows the ISO 4217 minor unit of`
|
|
593
519
|
);
|
|
594
520
|
}
|
|
595
|
-
return digits;
|
|
521
|
+
return digits;
|
|
522
|
+
}
|
|
523
|
+
function minorScaleOf(currency) {
|
|
524
|
+
return 10 ** minorUnitsOf(currency);
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
// src/price.ts
|
|
528
|
+
var MSAT_PER_BTC2 = 1e11;
|
|
529
|
+
var BASIS_POINTS = 1e4;
|
|
530
|
+
var DEFAULT_HOLD_SECS = 60;
|
|
531
|
+
var DEFAULT_MIN_VENUES = 2;
|
|
532
|
+
var DEFAULT_MAX_SPREAD_BPS = 200;
|
|
533
|
+
var MILLIS = 1e3;
|
|
534
|
+
function coinbase(baseUrl = "https://api.coinbase.com") {
|
|
535
|
+
return async (currency) => {
|
|
536
|
+
const asked = currency.toUpperCase();
|
|
537
|
+
const answer = await asJson(`${baseUrl}/v2/prices/BTC-${asked}/spot`, "coinbase");
|
|
538
|
+
const quoted = answer.data;
|
|
539
|
+
if (quoted?.currency !== asked) {
|
|
540
|
+
throw new Error(`coinbase quoted ${quoted?.currency} when asked for ${asked}`);
|
|
541
|
+
}
|
|
542
|
+
return asMinor(quoted.amount, "coinbase", asked);
|
|
543
|
+
};
|
|
544
|
+
}
|
|
545
|
+
function kraken(baseUrl = "https://api.kraken.com") {
|
|
546
|
+
return async (currency) => {
|
|
547
|
+
const asked = currency.toUpperCase();
|
|
548
|
+
const answer = await asJson(`${baseUrl}/0/public/Ticker?pair=XBT${asked}`, "kraken");
|
|
549
|
+
if (answer.error?.length) {
|
|
550
|
+
throw new Error(`kraken refused XBT${asked}: ${answer.error[0]}`);
|
|
551
|
+
}
|
|
552
|
+
const pairs = Object.values(answer.result ?? {});
|
|
553
|
+
if (pairs.length !== 1) {
|
|
554
|
+
throw new Error(`kraken answered ${pairs.length} pairs for XBT${asked}`);
|
|
555
|
+
}
|
|
556
|
+
return asMinor(pairs[0]?.c?.[0], "kraken", asked);
|
|
557
|
+
};
|
|
558
|
+
}
|
|
559
|
+
function bitstamp(baseUrl = "https://www.bitstamp.net") {
|
|
560
|
+
return async (currency) => {
|
|
561
|
+
const pair = `btc${currency.toLowerCase()}`;
|
|
562
|
+
const answer = await asJson(`${baseUrl}/api/v2/ticker/${pair}/`, "bitstamp");
|
|
563
|
+
if (Array.isArray(answer)) {
|
|
564
|
+
throw new Error(`bitstamp has no ${pair} pair`);
|
|
565
|
+
}
|
|
566
|
+
return asMinor(answer.last, "bitstamp", currency);
|
|
567
|
+
};
|
|
568
|
+
}
|
|
569
|
+
function coinmate(baseUrl = "https://coinmate.io") {
|
|
570
|
+
return async (currency) => {
|
|
571
|
+
const pair = `BTC_${currency.toUpperCase()}`;
|
|
572
|
+
const answer = await asJson(`${baseUrl}/api/ticker?currencyPair=${pair}`, "coinmate");
|
|
573
|
+
if (answer.error !== false) {
|
|
574
|
+
throw new Error(`coinmate refused ${pair}: ${answer.errorMessage ?? "no reason"}`);
|
|
575
|
+
}
|
|
576
|
+
return asMinor(answer.data?.last, "coinmate", currency);
|
|
577
|
+
};
|
|
578
|
+
}
|
|
579
|
+
function medianOf(tickers = [coinbase(), kraken(), bitstamp(), coinmate()], options = {}) {
|
|
580
|
+
const holdFor = (options.holdForSecs ?? DEFAULT_HOLD_SECS) * MILLIS;
|
|
581
|
+
const minVenues = options.minVenues ?? DEFAULT_MIN_VENUES;
|
|
582
|
+
const maxSpread = options.maxSpreadBps ?? DEFAULT_MAX_SPREAD_BPS;
|
|
583
|
+
const held = /* @__PURE__ */ new Map();
|
|
584
|
+
return async (currency) => {
|
|
585
|
+
const asked = currency.toUpperCase();
|
|
586
|
+
const remembered = held.get(asked);
|
|
587
|
+
if (remembered && Date.now() - remembered.at < holdFor) {
|
|
588
|
+
return remembered.price;
|
|
589
|
+
}
|
|
590
|
+
const answers = await Promise.allSettled(tickers.map((ticker) => ticker(asked)));
|
|
591
|
+
const quoted = answers.filter((answer) => answer.status === "fulfilled").map((answer) => answer.value).sort((one, other) => one - other);
|
|
592
|
+
if (quoted.length < minVenues) {
|
|
593
|
+
throw new Error(
|
|
594
|
+
`${quoted.length} of ${tickers.length} venues quoted ${asked}, ${minVenues} wanted: ${refusals(answers)}`
|
|
595
|
+
);
|
|
596
|
+
}
|
|
597
|
+
const spread2 = spreadBpsOf(quoted);
|
|
598
|
+
if (spread2 > maxSpread) {
|
|
599
|
+
throw new Error(
|
|
600
|
+
`venues disagree on ${asked} by ${Math.round(spread2)} bps, more than the ${maxSpread} allowed: ${quoted.join(", ")}`
|
|
601
|
+
);
|
|
602
|
+
}
|
|
603
|
+
const price = middleOf(quoted);
|
|
604
|
+
held.set(asked, { at: Date.now(), price });
|
|
605
|
+
return price;
|
|
606
|
+
};
|
|
607
|
+
}
|
|
608
|
+
function msatFor(amountMinor, priceMinorPerBtc, options = {}) {
|
|
609
|
+
if (!Number.isInteger(amountMinor) || amountMinor <= 0) {
|
|
610
|
+
throw new Error("amountMinor must be a whole number of minor units above zero");
|
|
611
|
+
}
|
|
612
|
+
if (!Number.isFinite(priceMinorPerBtc) || priceMinorPerBtc <= 0) {
|
|
613
|
+
throw new Error(`${priceMinorPerBtc} is not a usable price`);
|
|
614
|
+
}
|
|
615
|
+
const spreadBps = options.spreadBps ?? 0;
|
|
616
|
+
if (!Number.isInteger(spreadBps) || spreadBps < 0) {
|
|
617
|
+
throw new Error("spreadBps must be a whole number of basis points, none or more");
|
|
618
|
+
}
|
|
619
|
+
const wanted = BigInt(amountMinor) * BigInt(MSAT_PER_BTC2) * BigInt(BASIS_POINTS + spreadBps);
|
|
620
|
+
const per = BigInt(Math.round(priceMinorPerBtc)) * BigInt(BASIS_POINTS);
|
|
621
|
+
const msat2 = (wanted + per - 1n) / per;
|
|
622
|
+
if (msat2 > BigInt(Number.MAX_SAFE_INTEGER)) {
|
|
623
|
+
throw new Error(`${amountMinor} at ${priceMinorPerBtc} is more millisatoshi than exist`);
|
|
624
|
+
}
|
|
625
|
+
return Number(msat2);
|
|
626
|
+
}
|
|
627
|
+
async function asJson(url, venue) {
|
|
628
|
+
const answer = await fetch(url, { headers: { accept: "application/json" } });
|
|
629
|
+
if (!answer.ok) {
|
|
630
|
+
throw new Error(`${venue} answered ${answer.status}`);
|
|
631
|
+
}
|
|
632
|
+
return answer.json();
|
|
633
|
+
}
|
|
634
|
+
function asMinor(quoted, venue, currency) {
|
|
635
|
+
const major2 = Number(quoted);
|
|
636
|
+
if (!Number.isFinite(major2) || major2 <= 0) {
|
|
637
|
+
throw new Error(`${venue} quoted ${String(quoted)} for ${currency.toUpperCase()}`);
|
|
638
|
+
}
|
|
639
|
+
return Math.round(major2 * minorScaleOf(currency));
|
|
640
|
+
}
|
|
641
|
+
function spreadBpsOf(sorted) {
|
|
642
|
+
const lowest = sorted[0] ?? 0;
|
|
643
|
+
const highest = sorted[sorted.length - 1] ?? 0;
|
|
644
|
+
return lowest === 0 ? Number.POSITIVE_INFINITY : (highest - lowest) / lowest * BASIS_POINTS;
|
|
645
|
+
}
|
|
646
|
+
function middleOf(sorted) {
|
|
647
|
+
const middle = Math.floor(sorted.length / 2);
|
|
648
|
+
if (sorted.length % 2 === 1) {
|
|
649
|
+
return sorted[middle] ?? 0;
|
|
650
|
+
}
|
|
651
|
+
return Math.round(((sorted[middle - 1] ?? 0) + (sorted[middle] ?? 0)) / 2);
|
|
652
|
+
}
|
|
653
|
+
function refusals(answers) {
|
|
654
|
+
return answers.filter((answer) => answer.status === "rejected").map(
|
|
655
|
+
(answer) => answer.reason instanceof Error ? answer.reason.message : String(answer.reason)
|
|
656
|
+
).join("; ");
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
// src/amount.ts
|
|
660
|
+
var MSAT_PER_SAT = 1e3;
|
|
661
|
+
var MAX_SATS = Math.floor(Number.MAX_SAFE_INTEGER / MSAT_PER_SAT);
|
|
662
|
+
function sats(whole) {
|
|
663
|
+
if (!Number.isInteger(whole) || whole < 1 || whole > MAX_SATS) {
|
|
664
|
+
throw new AmountError("not-whole-satoshi", `${whole} is not a payable count of satoshi`);
|
|
665
|
+
}
|
|
666
|
+
return whole * MSAT_PER_SAT;
|
|
667
|
+
}
|
|
668
|
+
function msat(exact) {
|
|
669
|
+
if (!Number.isSafeInteger(exact) || exact < 1) {
|
|
670
|
+
throw new AmountError(
|
|
671
|
+
"not-whole-millisatoshi",
|
|
672
|
+
`${exact} is not a payable count of millisatoshi`
|
|
673
|
+
);
|
|
674
|
+
}
|
|
675
|
+
return exact;
|
|
676
|
+
}
|
|
677
|
+
function fiat(major2, currency, options) {
|
|
678
|
+
const amountMinor = minorFrom(major2, currency);
|
|
679
|
+
const rate = options?.rate ?? medianOf();
|
|
680
|
+
return async () => {
|
|
681
|
+
const wanted = msatFor(amountMinor, await rate(currency), { spreadBps: options?.spreadBps });
|
|
682
|
+
return sats(Math.ceil(wanted / MSAT_PER_SAT));
|
|
683
|
+
};
|
|
684
|
+
}
|
|
685
|
+
async function amountNow(amount) {
|
|
686
|
+
return typeof amount === "function" ? msat(await amount()) : msat(amount);
|
|
687
|
+
}
|
|
688
|
+
function minorFrom(major2, currency) {
|
|
689
|
+
const digits = minorUnitsOf(currency);
|
|
690
|
+
if (typeof major2 === "number") {
|
|
691
|
+
return payable(Math.round(major2 * minorScaleOf(currency)), major2, currency);
|
|
692
|
+
}
|
|
693
|
+
const parsed = /^(\d+)(?:[.,](\d+))?$/.exec(major2.trim());
|
|
694
|
+
if (parsed === null) {
|
|
695
|
+
throw new AmountError(
|
|
696
|
+
"not-a-decimal",
|
|
697
|
+
`${major2} is not a decimal ${currency.toUpperCase()} amount`
|
|
698
|
+
);
|
|
699
|
+
}
|
|
700
|
+
const fraction = parsed[2] ?? "";
|
|
701
|
+
if (fraction.length > digits) {
|
|
702
|
+
throw new AmountError(
|
|
703
|
+
"too-precise",
|
|
704
|
+
`${major2} carries more decimals than ${currency.toUpperCase()} has, which is ${digits}`
|
|
705
|
+
);
|
|
706
|
+
}
|
|
707
|
+
return payable(Number(`${parsed[1]}${fraction.padEnd(digits, "0")}`), major2, currency);
|
|
708
|
+
}
|
|
709
|
+
function payable(minor, major2, currency) {
|
|
710
|
+
if (!Number.isSafeInteger(minor) || minor < 1) {
|
|
711
|
+
throw new AmountError(
|
|
712
|
+
"not-a-decimal",
|
|
713
|
+
`${major2} is not a ${currency.toUpperCase()} price this can charge`
|
|
714
|
+
);
|
|
715
|
+
}
|
|
716
|
+
return minor;
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
// ../core/ed25519.ts
|
|
720
|
+
var ALGORITHM = { name: "Ed25519" };
|
|
721
|
+
var SEED_BYTES = 32;
|
|
722
|
+
var SIGNATURE_BYTES = 64;
|
|
723
|
+
var PUBLIC_KEY_BYTES = 32;
|
|
724
|
+
var PKCS8_HEADER = hexToBytes("302e020100300506032b657004220420");
|
|
725
|
+
async function signingKeyFromSeed(seed) {
|
|
726
|
+
if (seed.length !== SEED_BYTES) {
|
|
727
|
+
throw new Error(`an ed25519 seed is ${SEED_BYTES} bytes`);
|
|
728
|
+
}
|
|
729
|
+
const wrapped = new Uint8Array(PKCS8_HEADER.length + SEED_BYTES);
|
|
730
|
+
wrapped.set(PKCS8_HEADER);
|
|
731
|
+
wrapped.set(seed, PKCS8_HEADER.length);
|
|
732
|
+
const key = await crypto.subtle.importKey("pkcs8", asBuffer(wrapped), ALGORITHM, true, ["sign"]);
|
|
733
|
+
const jwk = await crypto.subtle.exportKey("jwk", key);
|
|
734
|
+
return {
|
|
735
|
+
publicKeyHex: bytesToHex(fromBase64Url2(jwk.x ?? "")),
|
|
736
|
+
sign: async (payload) => bytesToHex(new Uint8Array(await crypto.subtle.sign(ALGORITHM, key, asBuffer(payload))))
|
|
737
|
+
};
|
|
738
|
+
}
|
|
739
|
+
async function verifyHex(publicKeyHex, signatureHex, payload) {
|
|
740
|
+
if (!isHex(publicKeyHex) || publicKeyHex.length !== PUBLIC_KEY_BYTES * 2) {
|
|
741
|
+
return false;
|
|
742
|
+
}
|
|
743
|
+
if (!isHex(signatureHex) || signatureHex.length !== SIGNATURE_BYTES * 2) {
|
|
744
|
+
return false;
|
|
745
|
+
}
|
|
746
|
+
try {
|
|
747
|
+
const key = await crypto.subtle.importKey(
|
|
748
|
+
"raw",
|
|
749
|
+
asBuffer(hexToBytes(publicKeyHex)),
|
|
750
|
+
ALGORITHM,
|
|
751
|
+
false,
|
|
752
|
+
["verify"]
|
|
753
|
+
);
|
|
754
|
+
return await crypto.subtle.verify(
|
|
755
|
+
ALGORITHM,
|
|
756
|
+
key,
|
|
757
|
+
asBuffer(hexToBytes(signatureHex)),
|
|
758
|
+
asBuffer(payload)
|
|
759
|
+
);
|
|
760
|
+
} catch {
|
|
761
|
+
return false;
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
function fromBase64Url2(text3) {
|
|
765
|
+
const padded = text3.replace(/-/g, "+").replace(/_/g, "/");
|
|
766
|
+
const raw = atob(padded + "=".repeat((4 - padded.length % 4) % 4));
|
|
767
|
+
const bytes = new Uint8Array(raw.length);
|
|
768
|
+
for (let at = 0; at < raw.length; at++) {
|
|
769
|
+
bytes[at] = raw.charCodeAt(at);
|
|
770
|
+
}
|
|
771
|
+
return bytes;
|
|
772
|
+
}
|
|
773
|
+
function asBuffer(bytes) {
|
|
774
|
+
return bytes.buffer instanceof ArrayBuffer ? bytes : new Uint8Array(bytes);
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
// ../core/hmac.ts
|
|
778
|
+
async function hmacHex(secret, payload) {
|
|
779
|
+
const raw = typeof secret === "string" ? new TextEncoder().encode(secret) : secret;
|
|
780
|
+
const key = await crypto.subtle.importKey(
|
|
781
|
+
"raw",
|
|
782
|
+
asBuffer2(raw),
|
|
783
|
+
{ name: "HMAC", hash: "SHA-256" },
|
|
784
|
+
false,
|
|
785
|
+
["sign"]
|
|
786
|
+
);
|
|
787
|
+
const message = typeof payload === "string" ? new TextEncoder().encode(payload) : payload;
|
|
788
|
+
return bytesToHex(new Uint8Array(await crypto.subtle.sign("HMAC", key, asBuffer2(message))));
|
|
789
|
+
}
|
|
790
|
+
function equalInConstantTime(one, other) {
|
|
791
|
+
if (one.length !== other.length) {
|
|
792
|
+
return false;
|
|
793
|
+
}
|
|
794
|
+
let difference = 0;
|
|
795
|
+
for (let at = 0; at < one.length; at++) {
|
|
796
|
+
difference |= one.charCodeAt(at) ^ other.charCodeAt(at);
|
|
797
|
+
}
|
|
798
|
+
return difference === 0;
|
|
799
|
+
}
|
|
800
|
+
function asBuffer2(bytes) {
|
|
801
|
+
return bytes instanceof Uint8Array && bytes.buffer instanceof ArrayBuffer ? bytes : new Uint8Array(bytes);
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
// ../core/caller.ts
|
|
805
|
+
var KEY_HEADER = "x-client-key";
|
|
806
|
+
var SIGNATURE_HEADER = "x-signature";
|
|
807
|
+
var TIMESTAMP_HEADER = "x-timestamp";
|
|
808
|
+
var PREFIX = "ed25519=";
|
|
809
|
+
var SIGNING_LABEL = "request-signing";
|
|
810
|
+
async function callerKey(secret) {
|
|
811
|
+
return await signingKeyFromSeed(hexToBytes(await hmacHex(secret, SIGNING_LABEL)));
|
|
812
|
+
}
|
|
813
|
+
function paymentNamedBy(caller, paymentHash) {
|
|
814
|
+
return sha256Hex(`${caller}.payment-id.${paymentHash}`);
|
|
815
|
+
}
|
|
816
|
+
async function signedAs(key, method, path, body) {
|
|
817
|
+
const timestamp = String(Math.floor(Date.now() / 1e3));
|
|
818
|
+
return {
|
|
819
|
+
[KEY_HEADER]: key.publicKeyHex,
|
|
820
|
+
[TIMESTAMP_HEADER]: timestamp,
|
|
821
|
+
[SIGNATURE_HEADER]: `${PREFIX}${await key.sign(spoken(timestamp, method, path, body))}`
|
|
822
|
+
};
|
|
823
|
+
}
|
|
824
|
+
function spoken(timestamp, method, path, body) {
|
|
825
|
+
return new TextEncoder().encode(
|
|
826
|
+
`${timestamp}.${method.toUpperCase()}.${path}.${sha256Hex(body)}`
|
|
827
|
+
);
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
// src/charge.ts
|
|
831
|
+
async function priced(charge) {
|
|
832
|
+
const paidTo = typeof charge.paidTo === "string" ? [charge.paidTo] : [...charge.paidTo];
|
|
833
|
+
if (paidTo.length === 0) {
|
|
834
|
+
throw new Error("name at least one lightning address to pay");
|
|
835
|
+
}
|
|
836
|
+
return { paidTo, amountMsat: await amountNow(charge.amount) };
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
// ../core/url.ts
|
|
840
|
+
var PRIVATE_SUFFIXES = ["localhost", "local", "internal", "lan", "arpa", "test", "invalid"];
|
|
841
|
+
function publicHttps(raw) {
|
|
842
|
+
let url;
|
|
843
|
+
try {
|
|
844
|
+
url = new URL(raw);
|
|
845
|
+
} catch {
|
|
846
|
+
return false;
|
|
847
|
+
}
|
|
848
|
+
return url.protocol === "https:" && hostIsPublic(url.hostname.toLowerCase());
|
|
849
|
+
}
|
|
850
|
+
function sameOrigin(one, other) {
|
|
851
|
+
try {
|
|
852
|
+
return new URL(one).origin === new URL(other).origin;
|
|
853
|
+
} catch {
|
|
854
|
+
return false;
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
function publicAddress(literal) {
|
|
858
|
+
return literal.includes(":") ? ipv6IsGlobal(literal) : ipv4IsGlobal(literal);
|
|
859
|
+
}
|
|
860
|
+
function hostIsPublic(host) {
|
|
861
|
+
if (host.startsWith("[")) {
|
|
862
|
+
return publicAddress(host.slice(1, -1));
|
|
863
|
+
}
|
|
864
|
+
const name = host.replace(/\.$/, "");
|
|
865
|
+
if (/^\d+\.\d+\.\d+\.\d+$/.test(name)) {
|
|
866
|
+
return publicAddress(name);
|
|
867
|
+
}
|
|
868
|
+
const labels = name.split(".");
|
|
869
|
+
if (labels.length < 2 || labels.some((label) => label === "")) {
|
|
870
|
+
return false;
|
|
871
|
+
}
|
|
872
|
+
return !PRIVATE_SUFFIXES.includes(labels[labels.length - 1]);
|
|
873
|
+
}
|
|
874
|
+
function ipv4IsGlobal(literal) {
|
|
875
|
+
const parts = literal.split(".").map(Number);
|
|
876
|
+
if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) {
|
|
877
|
+
return false;
|
|
878
|
+
}
|
|
879
|
+
const [a, b] = parts;
|
|
880
|
+
if (a === 0 || a === 10 || a === 127 || a >= 240) {
|
|
881
|
+
return false;
|
|
882
|
+
}
|
|
883
|
+
if (a === 100 && b >= 64 && b < 128) {
|
|
884
|
+
return false;
|
|
885
|
+
}
|
|
886
|
+
if (a === 169 && b === 254) {
|
|
887
|
+
return false;
|
|
888
|
+
}
|
|
889
|
+
if (a === 172 && b >= 16 && b < 32) {
|
|
890
|
+
return false;
|
|
891
|
+
}
|
|
892
|
+
if (a === 192 && b === 168) {
|
|
893
|
+
return false;
|
|
894
|
+
}
|
|
895
|
+
return true;
|
|
896
|
+
}
|
|
897
|
+
function ipv6IsGlobal(literal) {
|
|
898
|
+
const groups = ipv6Groups(literal.toLowerCase());
|
|
899
|
+
if (groups.length !== 8 || groups.some(Number.isNaN)) {
|
|
900
|
+
return false;
|
|
901
|
+
}
|
|
902
|
+
const leading = groups.slice(0, 5).every((group) => group === 0);
|
|
903
|
+
if (leading && groups[5] === 0 && groups[6] === 0 && groups[7] <= 1) {
|
|
904
|
+
return false;
|
|
905
|
+
}
|
|
906
|
+
if (leading && groups[5] === 65535) {
|
|
907
|
+
const [high, low] = [groups[6], groups[7]];
|
|
908
|
+
return ipv4IsGlobal(`${high >> 8}.${high & 255}.${low >> 8}.${low & 255}`);
|
|
909
|
+
}
|
|
910
|
+
const first = groups[0];
|
|
911
|
+
if ((first & 65024) === 64512) {
|
|
912
|
+
return false;
|
|
913
|
+
}
|
|
914
|
+
if ((first & 65472) === 65152) {
|
|
915
|
+
return false;
|
|
916
|
+
}
|
|
917
|
+
return true;
|
|
918
|
+
}
|
|
919
|
+
function ipv6Groups(address) {
|
|
920
|
+
const [head = "", tail] = address.split("::");
|
|
921
|
+
const left = head.split(":").filter((group) => group !== "");
|
|
922
|
+
const right = (tail ?? "").split(":").filter((group) => group !== "");
|
|
923
|
+
const gap = tail === void 0 ? [] : Array(8 - left.length - right.length).fill("0");
|
|
924
|
+
return [...left, ...gap, ...right].map((group) => parseInt(group, 16));
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
// ../core/outbound.ts
|
|
928
|
+
var HTTP_TIMEOUT_MS = 15e3;
|
|
929
|
+
var LOOKUP_TIMEOUT_MS = 5e3;
|
|
930
|
+
var HOPS_FOLLOWED = 2;
|
|
931
|
+
var REDIRECTS = [301, 302, 303, 307, 308];
|
|
932
|
+
var KEEPS_THE_METHOD = [307, 308];
|
|
933
|
+
var CREDENTIALS = ["authorization", "cookie", "proxy-authorization"];
|
|
934
|
+
var BODY_LIMIT_BYTES = 262144;
|
|
935
|
+
async function ask(send, url, sent = {}) {
|
|
936
|
+
const capped = AbortSignal.timeout(HTTP_TIMEOUT_MS);
|
|
937
|
+
const signal = sent.deadline ? AbortSignal.any([sent.deadline, capped]) : capped;
|
|
938
|
+
let target = url;
|
|
939
|
+
let carried = sent;
|
|
940
|
+
for (let hop = 0; hop <= HOPS_FOLLOWED; hop += 1) {
|
|
941
|
+
const at = await addressesToReach(target);
|
|
942
|
+
const response = await send(target, carried, signal, at);
|
|
943
|
+
if (!REDIRECTS.includes(response.status)) {
|
|
944
|
+
return await answerOf(response);
|
|
945
|
+
}
|
|
946
|
+
const location = response.headers.get("location");
|
|
947
|
+
if (location === null) {
|
|
948
|
+
throw new Error(`${target} answered ${response.status} without saying where to`);
|
|
949
|
+
}
|
|
950
|
+
await response.body?.cancel();
|
|
951
|
+
const next = new URL(location, target).toString();
|
|
952
|
+
carried = KEEPS_THE_METHOD.includes(response.status) ? carried : withoutBody(carried);
|
|
953
|
+
carried = sameOrigin(target, next) ? carried : withoutCredentials(carried);
|
|
954
|
+
target = next;
|
|
955
|
+
}
|
|
956
|
+
throw new Error(`${url} redirected more than ${HOPS_FOLLOWED} times`);
|
|
957
|
+
}
|
|
958
|
+
async function addressesToReach(url) {
|
|
959
|
+
if (!publicHttps(url)) {
|
|
960
|
+
throw new Error(`${url} is not a public https URL`);
|
|
961
|
+
}
|
|
962
|
+
const found = await resolved(url);
|
|
963
|
+
if (found.length === 0 || !found.every((one) => publicAddress(one.address))) {
|
|
964
|
+
throw new Error(`${url} resolves to an address we do not reach`);
|
|
965
|
+
}
|
|
966
|
+
return found;
|
|
967
|
+
}
|
|
968
|
+
async function resolved(url) {
|
|
969
|
+
const { lookup } = await import("dns/promises");
|
|
970
|
+
const { setTimeout: sleep } = await import("timers/promises");
|
|
971
|
+
const host = new URL(url).hostname.replace(/^\[|]$/g, "");
|
|
972
|
+
const found = await Promise.race([
|
|
973
|
+
lookup(host, { all: true, verbatim: true }).catch(() => []),
|
|
974
|
+
sleep(LOOKUP_TIMEOUT_MS, null, { ref: false })
|
|
975
|
+
]);
|
|
976
|
+
return found ?? [];
|
|
977
|
+
}
|
|
978
|
+
async function answerOf(response) {
|
|
979
|
+
const { status, ok, headers } = response;
|
|
980
|
+
const reader = response.body?.getReader();
|
|
981
|
+
if (!reader) {
|
|
982
|
+
return { status, ok, body: "", truncated: false, headers };
|
|
983
|
+
}
|
|
984
|
+
const read = [];
|
|
985
|
+
let bytes = 0;
|
|
986
|
+
while (bytes <= BODY_LIMIT_BYTES) {
|
|
987
|
+
const { done, value } = await reader.read();
|
|
988
|
+
if (done) {
|
|
989
|
+
return { status, ok, body: text(read), truncated: false, headers };
|
|
990
|
+
}
|
|
991
|
+
read.push(value);
|
|
992
|
+
bytes += value.length;
|
|
993
|
+
}
|
|
994
|
+
await reader.cancel();
|
|
995
|
+
return { status, ok, body: text(read), truncated: true, headers };
|
|
996
|
+
}
|
|
997
|
+
function withoutBody(sent) {
|
|
998
|
+
const headers = { ...sent.headers };
|
|
999
|
+
delete headers["content-type"];
|
|
1000
|
+
return { ...sent, method: "GET", headers, body: void 0 };
|
|
1001
|
+
}
|
|
1002
|
+
function withoutCredentials(sent) {
|
|
1003
|
+
const headers = { ...sent.headers };
|
|
1004
|
+
for (const named of CREDENTIALS) {
|
|
1005
|
+
delete headers[named];
|
|
1006
|
+
}
|
|
1007
|
+
return { ...sent, headers };
|
|
1008
|
+
}
|
|
1009
|
+
function text(chunks) {
|
|
1010
|
+
const decoder = new TextDecoder();
|
|
1011
|
+
let decoded2 = "";
|
|
1012
|
+
for (const chunk of chunks) {
|
|
1013
|
+
decoded2 += decoder.decode(chunk, { stream: true });
|
|
1014
|
+
}
|
|
1015
|
+
return decoded2 + decoder.decode();
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
// ../core/refusal.ts
|
|
1019
|
+
var WalletRefused = class extends Error {
|
|
1020
|
+
reason;
|
|
1021
|
+
constructor(reason, message) {
|
|
1022
|
+
super(message);
|
|
1023
|
+
this.reason = reason;
|
|
1024
|
+
}
|
|
1025
|
+
};
|
|
1026
|
+
var NoWalletAvailable = class extends Error {
|
|
1027
|
+
wallets;
|
|
1028
|
+
constructor(wallets) {
|
|
1029
|
+
super(wallets.map((wallet) => `${wallet.address} ${wallet.reason}`).join(", "));
|
|
1030
|
+
this.wallets = wallets;
|
|
1031
|
+
}
|
|
1032
|
+
};
|
|
1033
|
+
|
|
1034
|
+
// ../core/lnurl.ts
|
|
1035
|
+
var VERIFY_WITHOUT_PREIMAGE = ["zeuspay.com", "zeusnuts.com", "ecash.love"];
|
|
1036
|
+
var RESOLVE_TIMEOUT_MS = 3e4;
|
|
1037
|
+
var NAMES_ONE_ACCOUNT = /^[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)*$/;
|
|
1038
|
+
var MIN_PACE_SECS = 1;
|
|
1039
|
+
var MAX_PACE_SECS = 3600;
|
|
1040
|
+
var MIN_PER_SECOND = 0.01;
|
|
1041
|
+
var MAX_PER_SECOND = 100;
|
|
1042
|
+
async function resolve(send, addresses, amountMsat) {
|
|
1043
|
+
const served = await firstThatServes(
|
|
1044
|
+
addresses,
|
|
1045
|
+
(address, deadline) => resolveAddress(send, address, amountMsat, deadline)
|
|
1046
|
+
);
|
|
1047
|
+
return served.won;
|
|
1048
|
+
}
|
|
1049
|
+
async function firstThatServes(addresses, attempt) {
|
|
1050
|
+
const deadline = AbortSignal.timeout(RESOLVE_TIMEOUT_MS);
|
|
1051
|
+
const refusals3 = [];
|
|
1052
|
+
for (const address of addresses) {
|
|
1053
|
+
if (deadline.aborted) {
|
|
1054
|
+
break;
|
|
1055
|
+
}
|
|
1056
|
+
try {
|
|
1057
|
+
return { won: await attempt(address, deadline), refusals: refusals3 };
|
|
1058
|
+
} catch (refusal) {
|
|
1059
|
+
if (!(refusal instanceof WalletRefused)) {
|
|
1060
|
+
throw refusal;
|
|
1061
|
+
}
|
|
1062
|
+
console.warn(`wallet ${address} refused, ${refusal.reason}: ${refusal.message}`);
|
|
1063
|
+
refusals3.push({ address, reason: refusal.reason });
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
throw new NoWalletAvailable(refusals3);
|
|
596
1067
|
}
|
|
597
|
-
function
|
|
598
|
-
|
|
1068
|
+
async function probe(send, address, amountMsat, deadline) {
|
|
1069
|
+
const [user, domain] = splitAddress(address);
|
|
1070
|
+
if (cannotReleaseAPreimage(domain)) {
|
|
1071
|
+
throw new WalletRefused("cannot-prove-delivery", `${address} never releases a preimage`);
|
|
1072
|
+
}
|
|
1073
|
+
const pay = await answered(
|
|
1074
|
+
send,
|
|
1075
|
+
`https://${domain}/.well-known/lnurlp/${user}`,
|
|
1076
|
+
deadline
|
|
1077
|
+
);
|
|
1078
|
+
if (pay.tag !== "payRequest" || !pay.callback) {
|
|
1079
|
+
throw new WalletRefused("unreachable", `${address} answered with no payRequest`);
|
|
1080
|
+
}
|
|
1081
|
+
if (amountMsat < pay.minSendable || amountMsat > pay.maxSendable) {
|
|
1082
|
+
throw new WalletRefused(
|
|
1083
|
+
"amount-not-accepted",
|
|
1084
|
+
`${address} takes [${pay.minSendable}, ${pay.maxSendable}] msat, not ${amountMsat}`
|
|
1085
|
+
);
|
|
1086
|
+
}
|
|
1087
|
+
return { address, pay };
|
|
599
1088
|
}
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
1089
|
+
async function resolveAddress(send, address, amountMsat, deadline) {
|
|
1090
|
+
const { pay } = await probe(send, address, amountMsat, deadline);
|
|
1091
|
+
const invoice = await answered(
|
|
1092
|
+
send,
|
|
1093
|
+
withAmount(pay.callback, amountMsat),
|
|
1094
|
+
deadline
|
|
1095
|
+
);
|
|
1096
|
+
if (!invoice.pr) {
|
|
1097
|
+
throw new WalletRefused("unreachable", `${address} returned no invoice`);
|
|
1098
|
+
}
|
|
1099
|
+
if (!invoice.verify || !publicHttps(invoice.verify)) {
|
|
1100
|
+
throw new WalletRefused(
|
|
1101
|
+
"cannot-prove-delivery",
|
|
1102
|
+
`${address} publishes no usable LUD-21 verify URL`
|
|
1103
|
+
);
|
|
1104
|
+
}
|
|
1105
|
+
const issued = decodeIssued(address, invoice.pr);
|
|
1106
|
+
if (issued.amountMsat !== amountMsat) {
|
|
1107
|
+
throw new WalletRefused(
|
|
1108
|
+
"invoice-refused",
|
|
1109
|
+
`${address} issued an invoice for ${issued.amountMsat} msat, not ${amountMsat}`
|
|
1110
|
+
);
|
|
1111
|
+
}
|
|
1112
|
+
if (issued.descriptionHash !== sha256Hex(pay.metadata)) {
|
|
1113
|
+
throw new WalletRefused(
|
|
1114
|
+
"invoice-refused",
|
|
1115
|
+
`${address} does not bind its invoice to the metadata it serves`
|
|
1116
|
+
);
|
|
610
1117
|
}
|
|
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
1118
|
return {
|
|
617
|
-
|
|
618
|
-
|
|
1119
|
+
address,
|
|
1120
|
+
bolt11: invoice.pr,
|
|
1121
|
+
verifyUrl: invoice.verify,
|
|
1122
|
+
paymentHash: issued.paymentHash,
|
|
1123
|
+
expiresAt: issued.expiresAt
|
|
619
1124
|
};
|
|
620
1125
|
}
|
|
621
|
-
async function
|
|
622
|
-
|
|
623
|
-
return
|
|
1126
|
+
async function answered(send, url, deadline) {
|
|
1127
|
+
try {
|
|
1128
|
+
return await fetchJson(send, url, deadline);
|
|
1129
|
+
} catch (cause) {
|
|
1130
|
+
throw new WalletRefused("unreachable", String(cause));
|
|
624
1131
|
}
|
|
625
|
-
|
|
626
|
-
|
|
1132
|
+
}
|
|
1133
|
+
function decodeIssued(address, bolt11) {
|
|
1134
|
+
const { paymentHash, descriptionHash, amountMsat, expiresAt } = decodeInvoice(bolt11);
|
|
1135
|
+
if (paymentHash === null || expiresAt === null) {
|
|
1136
|
+
throw new WalletRefused("invoice-refused", `${address} issued an invoice we cannot decode`);
|
|
1137
|
+
}
|
|
1138
|
+
return { paymentHash, descriptionHash, amountMsat, expiresAt };
|
|
1139
|
+
}
|
|
1140
|
+
async function checkSettled(send, verifyUrl, paymentHash) {
|
|
1141
|
+
const answer = await answeredJson(send, verifyUrl);
|
|
1142
|
+
const asked = {
|
|
1143
|
+
pace: paceAskedFor(answer.headers),
|
|
1144
|
+
ceiling: ceilingAskedFor(answer.headers)
|
|
1145
|
+
};
|
|
1146
|
+
if (!answer.said.settled || !answer.said.preimage) {
|
|
1147
|
+
return { preimage: null, ...asked };
|
|
1148
|
+
}
|
|
1149
|
+
if (!preimageMatchesHash(answer.said.preimage, paymentHash)) {
|
|
1150
|
+
throw new Error(`verify returned a preimage that does not hash to ${paymentHash}`);
|
|
1151
|
+
}
|
|
1152
|
+
return { preimage: answer.said.preimage, ...asked };
|
|
1153
|
+
}
|
|
1154
|
+
function paceAskedFor(headers) {
|
|
1155
|
+
const asked = /max-age\s*=\s*(\d+)/i.exec(headers.get("cache-control") ?? "");
|
|
1156
|
+
if (!asked) {
|
|
1157
|
+
return null;
|
|
1158
|
+
}
|
|
1159
|
+
return Math.min(Math.max(Number(asked[1]), MIN_PACE_SECS), MAX_PACE_SECS);
|
|
1160
|
+
}
|
|
1161
|
+
function ceilingAskedFor(headers) {
|
|
1162
|
+
const asked = /^\s*(\d+)\s*(?:;\s*w\s*=\s*(\d+))?/i.exec(headers.get("ratelimit-limit") ?? "");
|
|
1163
|
+
if (!asked) {
|
|
1164
|
+
return null;
|
|
1165
|
+
}
|
|
1166
|
+
const perWindow = Number(asked[1]);
|
|
1167
|
+
const window = asked[2] === void 0 ? 1 : Number(asked[2]);
|
|
1168
|
+
if (perWindow === 0 || window === 0) {
|
|
1169
|
+
return null;
|
|
1170
|
+
}
|
|
1171
|
+
return Math.min(Math.max(perWindow / window, MIN_PER_SECOND), MAX_PER_SECOND);
|
|
1172
|
+
}
|
|
1173
|
+
function cannotReleaseAPreimage(host) {
|
|
1174
|
+
const lowered = host.toLowerCase();
|
|
1175
|
+
return VERIFY_WITHOUT_PREIMAGE.some(
|
|
1176
|
+
(known) => lowered === known || lowered.endsWith(`.${known}`)
|
|
1177
|
+
);
|
|
1178
|
+
}
|
|
1179
|
+
function withAmount(callback, amountMsat) {
|
|
1180
|
+
const separator = callback.includes("?") ? "&" : "?";
|
|
1181
|
+
return `${callback}${separator}amount=${amountMsat}`;
|
|
1182
|
+
}
|
|
1183
|
+
function splitAddress(address) {
|
|
1184
|
+
const [user, domain, ...rest] = address.split("@");
|
|
1185
|
+
if (!user || !domain || rest.length > 0 || !NAMES_ONE_ACCOUNT.test(user) || !isNothingButAHost(domain) || !publicHttps(`https://${domain}/`)) {
|
|
1186
|
+
throw new WalletRefused("address-unusable", `${address} is not a usable lightning address`);
|
|
627
1187
|
}
|
|
1188
|
+
return [user, domain];
|
|
1189
|
+
}
|
|
1190
|
+
function isNothingButAHost(domain) {
|
|
628
1191
|
try {
|
|
629
|
-
const
|
|
630
|
-
|
|
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
|
-
);
|
|
1192
|
+
const parsed = new URL(`https://${domain}/`);
|
|
1193
|
+
return parsed.host === domain.toLowerCase() && parsed.pathname === "/" && parsed.search === "" && parsed.hash === "";
|
|
642
1194
|
} catch {
|
|
643
1195
|
return false;
|
|
644
1196
|
}
|
|
645
1197
|
}
|
|
646
|
-
function
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
1198
|
+
async function fetchJson(send, url, deadline) {
|
|
1199
|
+
return (await answeredJson(send, url, deadline)).said;
|
|
1200
|
+
}
|
|
1201
|
+
async function answeredJson(send, url, deadline) {
|
|
1202
|
+
const answer = await ask(send, url, { headers: { accept: "application/json" }, deadline });
|
|
1203
|
+
if (!answer.ok) {
|
|
1204
|
+
throw new Error(`${url} answered ${answer.status}`);
|
|
652
1205
|
}
|
|
653
|
-
|
|
1206
|
+
if (answer.truncated) {
|
|
1207
|
+
throw new Error(`${url} answered with more than the ${BODY_LIMIT_BYTES} bytes we read`);
|
|
1208
|
+
}
|
|
1209
|
+
return { said: JSON.parse(answer.body), headers: answer.headers };
|
|
654
1210
|
}
|
|
655
|
-
|
|
656
|
-
|
|
1211
|
+
|
|
1212
|
+
// ../core/pinned.ts
|
|
1213
|
+
var HTTPS_PORT = 443;
|
|
1214
|
+
var CARRIES_NO_BODY = [204, 205, 304];
|
|
1215
|
+
var pinnedToTheAddressWeVerified = async (url, sent, signal, at) => {
|
|
1216
|
+
const asked = new URL(url);
|
|
1217
|
+
const first = at[0];
|
|
1218
|
+
if (first === void 0) {
|
|
1219
|
+
throw new Error(`${url} resolved to nothing worth connecting to`);
|
|
1220
|
+
}
|
|
1221
|
+
const { request } = await import("https");
|
|
1222
|
+
const { Readable } = await import("stream");
|
|
1223
|
+
return new Promise((settle, fail) => {
|
|
1224
|
+
if (signal.aborted) {
|
|
1225
|
+
fail(signal.reason);
|
|
1226
|
+
return;
|
|
1227
|
+
}
|
|
1228
|
+
const call = request(reaching(asked, sent, at, first), (answer) => {
|
|
1229
|
+
const status = answer.statusCode ?? 502;
|
|
1230
|
+
settle(
|
|
1231
|
+
new Response(
|
|
1232
|
+
CARRIES_NO_BODY.includes(status) ? null : Readable.toWeb(answer),
|
|
1233
|
+
{ status, headers: headersOf(answer.headers) }
|
|
1234
|
+
)
|
|
1235
|
+
);
|
|
1236
|
+
});
|
|
1237
|
+
const give = () => call.destroy(new Error(`${url} took longer than allowed`));
|
|
1238
|
+
signal.addEventListener("abort", give, { once: true });
|
|
1239
|
+
call.on("close", () => signal.removeEventListener("abort", give));
|
|
1240
|
+
call.on("error", fail);
|
|
1241
|
+
if (sent.body !== void 0) {
|
|
1242
|
+
call.write(sent.body);
|
|
1243
|
+
}
|
|
1244
|
+
call.end();
|
|
1245
|
+
});
|
|
1246
|
+
};
|
|
1247
|
+
function reaching(asked, sent, at, first) {
|
|
1248
|
+
return {
|
|
1249
|
+
host: asked.hostname,
|
|
1250
|
+
servername: asked.hostname,
|
|
1251
|
+
port: asked.port === "" ? HTTPS_PORT : Number(asked.port),
|
|
1252
|
+
path: `${asked.pathname}${asked.search}`,
|
|
1253
|
+
method: sent.method ?? "GET",
|
|
1254
|
+
headers: { ...sent.headers, host: asked.host },
|
|
1255
|
+
agent: false,
|
|
1256
|
+
lookup: (_name, options, done) => {
|
|
1257
|
+
if (options.all === true) {
|
|
1258
|
+
done(
|
|
1259
|
+
null,
|
|
1260
|
+
at.map((one) => ({ ...one }))
|
|
1261
|
+
);
|
|
1262
|
+
} else {
|
|
1263
|
+
done(null, first.address, first.family);
|
|
1264
|
+
}
|
|
1265
|
+
}
|
|
1266
|
+
};
|
|
1267
|
+
}
|
|
1268
|
+
function headersOf(raw) {
|
|
1269
|
+
const headers = new Headers();
|
|
1270
|
+
for (const [name, value] of Object.entries(raw)) {
|
|
1271
|
+
if (Array.isArray(value)) {
|
|
1272
|
+
for (const one of value) {
|
|
1273
|
+
headers.append(name, one);
|
|
1274
|
+
}
|
|
1275
|
+
} else if (value !== void 0) {
|
|
1276
|
+
headers.set(name, value);
|
|
1277
|
+
}
|
|
1278
|
+
}
|
|
1279
|
+
return headers;
|
|
657
1280
|
}
|
|
658
1281
|
|
|
659
1282
|
// src/wire.ts
|
|
@@ -666,19 +1289,19 @@ var REASONS = /* @__PURE__ */ new Set([
|
|
|
666
1289
|
"cannot-prove-delivery",
|
|
667
1290
|
"invoice-refused"
|
|
668
1291
|
]);
|
|
669
|
-
function createRequestBody(
|
|
1292
|
+
function createRequestBody(priced2, webhookUrl, trigger, replay) {
|
|
670
1293
|
return JSON.stringify({
|
|
671
|
-
ln_addresses:
|
|
672
|
-
incoming_amount: toAmount(
|
|
673
|
-
webhook:
|
|
1294
|
+
ln_addresses: priced2.paidTo,
|
|
1295
|
+
incoming_amount: toAmount(priced2.amountMsat),
|
|
1296
|
+
webhook: webhookUrl ? { url: webhookUrl } : void 0,
|
|
674
1297
|
trigger: trigger ?? void 0,
|
|
675
1298
|
replay
|
|
676
1299
|
});
|
|
677
1300
|
}
|
|
678
|
-
function quoteRequestBody(
|
|
1301
|
+
function quoteRequestBody(priced2) {
|
|
679
1302
|
return JSON.stringify({
|
|
680
|
-
ln_addresses:
|
|
681
|
-
amount: toAmount(
|
|
1303
|
+
ln_addresses: priced2.paidTo,
|
|
1304
|
+
amount: toAmount(priced2.amountMsat)
|
|
682
1305
|
});
|
|
683
1306
|
}
|
|
684
1307
|
function quoteFromWire(body) {
|
|
@@ -686,12 +1309,12 @@ function quoteFromWire(body) {
|
|
|
686
1309
|
if (wire === null) {
|
|
687
1310
|
return null;
|
|
688
1311
|
}
|
|
689
|
-
const lnAddress =
|
|
1312
|
+
const lnAddress = text2(wire["ln_address"]);
|
|
690
1313
|
const amountMsat = msatFrom(wire["amount"], 1);
|
|
691
1314
|
const feeMsat = msatFrom(wire["fee"], 0);
|
|
692
1315
|
const minMsat = msatFrom(wire["min_amount"], 0);
|
|
693
1316
|
const maxMsat = msatFrom(wire["max_amount"], 0);
|
|
694
|
-
const metadata =
|
|
1317
|
+
const metadata = text2(wire["metadata"]);
|
|
695
1318
|
const refusals3 = refusalsFrom(wire["refusals"]);
|
|
696
1319
|
if (lnAddress === null || amountMsat === null || feeMsat === null) {
|
|
697
1320
|
return null;
|
|
@@ -706,57 +1329,66 @@ function paymentFromWire(body) {
|
|
|
706
1329
|
if (wire === null) {
|
|
707
1330
|
return null;
|
|
708
1331
|
}
|
|
709
|
-
const id =
|
|
710
|
-
const
|
|
711
|
-
const
|
|
712
|
-
const paymentHash = text(wire["payment_hash"]);
|
|
713
|
-
const verifyUrl = text(wire["verify_url"]);
|
|
714
|
-
const amountMsat = msatFrom(wire["incoming_amount"], 1);
|
|
1332
|
+
const id = text2(wire["id"]);
|
|
1333
|
+
const paymentHash = text2(wire["payment_hash"]);
|
|
1334
|
+
const verifyUrl = text2(wire["verify_url"]);
|
|
715
1335
|
const expiresAt = secondsFrom(wire["expires_at"]);
|
|
716
1336
|
const createdAt = secondsFrom(wire["created_at"]);
|
|
717
1337
|
const status = wire["status"];
|
|
718
1338
|
const preimage = wire["preimage"] ?? null;
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
}
|
|
722
|
-
if (paymentHash === null || verifyUrl === null) {
|
|
1339
|
+
const sealed = wire["sealed"] ?? null;
|
|
1340
|
+
if (id === null || paymentHash === null || verifyUrl === null) {
|
|
723
1341
|
return null;
|
|
724
1342
|
}
|
|
725
|
-
if (
|
|
1343
|
+
if (expiresAt === null || createdAt === null || !isStatus(status)) {
|
|
726
1344
|
return null;
|
|
727
1345
|
}
|
|
728
|
-
if (
|
|
1346
|
+
if (preimage !== null && typeof preimage !== "string") {
|
|
729
1347
|
return null;
|
|
730
1348
|
}
|
|
731
|
-
if (
|
|
1349
|
+
if (sealed !== null && typeof sealed !== "string") {
|
|
732
1350
|
return null;
|
|
733
1351
|
}
|
|
734
|
-
|
|
1352
|
+
const reported = {
|
|
735
1353
|
id,
|
|
736
|
-
lnAddress,
|
|
737
|
-
amountMsat,
|
|
738
1354
|
status,
|
|
739
1355
|
paymentHash,
|
|
740
|
-
|
|
1356
|
+
verifyUrl,
|
|
741
1357
|
preimage,
|
|
742
1358
|
expiresAt,
|
|
743
1359
|
createdAt,
|
|
744
|
-
|
|
1360
|
+
sealed
|
|
745
1361
|
};
|
|
1362
|
+
const lnAddress = text2(wire["ln_address"]);
|
|
1363
|
+
const amountMsat = wire["incoming_amount"] === void 0 ? null : msatFrom(wire["incoming_amount"], 1);
|
|
1364
|
+
const bolt11 = text2(wire["bolt11"]);
|
|
1365
|
+
if (lnAddress !== null && amountMsat !== null && bolt11 !== null) {
|
|
1366
|
+
return { ...reported, kind: "minted", lnAddress, amountMsat, bolt11 };
|
|
1367
|
+
}
|
|
1368
|
+
if (lnAddress === null && amountMsat === null && bolt11 === null) {
|
|
1369
|
+
return { ...reported, kind: "watched", lnAddress: null, amountMsat: null, bolt11: null };
|
|
1370
|
+
}
|
|
1371
|
+
return null;
|
|
1372
|
+
}
|
|
1373
|
+
function mintedFromWire(body) {
|
|
1374
|
+
const payment = paymentFromWire(body);
|
|
1375
|
+
return payment !== null && payment.kind === "minted" ? payment : null;
|
|
746
1376
|
}
|
|
747
|
-
function watchRequestBody(
|
|
748
|
-
const expiresAt = new Date(
|
|
1377
|
+
function watchRequestBody(handover, trigger) {
|
|
1378
|
+
const expiresAt = new Date(handover.expiresAt * 1e3);
|
|
749
1379
|
if (Number.isNaN(expiresAt.getTime())) {
|
|
750
|
-
throw new TypeError(
|
|
1380
|
+
throw new TypeError(
|
|
1381
|
+
`expiresAt must be a usable unix time in seconds, got ${handover.expiresAt}`
|
|
1382
|
+
);
|
|
751
1383
|
}
|
|
752
1384
|
return JSON.stringify({
|
|
753
|
-
payment_hash:
|
|
754
|
-
verify_url:
|
|
1385
|
+
payment_hash: handover.paymentHash,
|
|
1386
|
+
verify_url: handover.verifyUrl,
|
|
755
1387
|
expires_at: expiresAt.toISOString(),
|
|
756
1388
|
trigger: trigger ?? void 0,
|
|
757
|
-
replay:
|
|
758
|
-
sealed:
|
|
759
|
-
webhook:
|
|
1389
|
+
replay: handover.replay,
|
|
1390
|
+
sealed: handover.sealed,
|
|
1391
|
+
webhook: handover.webhookUrl ? { url: handover.webhookUrl } : void 0
|
|
760
1392
|
});
|
|
761
1393
|
}
|
|
762
1394
|
function settlementFromWire(body) {
|
|
@@ -764,8 +1396,8 @@ function settlementFromWire(body) {
|
|
|
764
1396
|
if (wire === null) {
|
|
765
1397
|
return null;
|
|
766
1398
|
}
|
|
767
|
-
const id =
|
|
768
|
-
const paymentHash =
|
|
1399
|
+
const id = text2(wire["id"]);
|
|
1400
|
+
const paymentHash = text2(wire["payment_hash"]);
|
|
769
1401
|
const settledAt = secondsFrom(wire["settled_at"]);
|
|
770
1402
|
const status = wire["status"];
|
|
771
1403
|
const preimage = wire["preimage"] ?? null;
|
|
@@ -777,66 +1409,20 @@ function settlementFromWire(body) {
|
|
|
777
1409
|
}
|
|
778
1410
|
return { id, status, paymentHash, preimage, settledAt };
|
|
779
1411
|
}
|
|
780
|
-
function triggerEventFromWire(body) {
|
|
781
|
-
const wire = asObject(body);
|
|
782
|
-
if (wire === null) {
|
|
783
|
-
return null;
|
|
784
|
-
}
|
|
785
|
-
const id = text(wire["id"]);
|
|
786
|
-
const paymentHash = text(wire["payment_hash"]);
|
|
787
|
-
const verifyUrl = text(wire["verify_url"]);
|
|
788
|
-
const expiresAt = secondsFrom(wire["expires_at"]);
|
|
789
|
-
const createdAt = secondsFrom(wire["created_at"]);
|
|
790
|
-
const status = wire["status"];
|
|
791
|
-
const preimage = wire["preimage"] ?? null;
|
|
792
|
-
const sealed = wire["sealed"] ?? null;
|
|
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
|
-
}
|
|
805
|
-
return {
|
|
806
|
-
id,
|
|
807
|
-
kind: kindOf(wire),
|
|
808
|
-
paymentHash,
|
|
809
|
-
verifyUrl,
|
|
810
|
-
status,
|
|
811
|
-
preimage,
|
|
812
|
-
expiresAt,
|
|
813
|
-
createdAt,
|
|
814
|
-
sealed,
|
|
815
|
-
lnAddress: text(wire["ln_address"]),
|
|
816
|
-
amountMsat: wire["incoming_amount"] === void 0 ? null : msatFrom(wire["incoming_amount"], 1)
|
|
817
|
-
};
|
|
818
|
-
}
|
|
819
1412
|
function socketTicketFromWire(body) {
|
|
820
1413
|
const wire = asObject(body);
|
|
821
1414
|
if (wire === null) {
|
|
822
1415
|
return null;
|
|
823
1416
|
}
|
|
824
|
-
const ticket =
|
|
1417
|
+
const ticket = text2(wire["ticket"]);
|
|
825
1418
|
const expiresAt = secondsFrom(wire["expires_at"]);
|
|
826
1419
|
if (ticket === null || expiresAt === null) {
|
|
827
1420
|
return null;
|
|
828
1421
|
}
|
|
829
1422
|
return { ticket, expiresAt };
|
|
830
1423
|
}
|
|
831
|
-
function
|
|
832
|
-
|
|
833
|
-
if (said === "minted" || said === "watched") {
|
|
834
|
-
return said;
|
|
835
|
-
}
|
|
836
|
-
return text(wire["ln_address"]) === null ? "watched" : "minted";
|
|
837
|
-
}
|
|
838
|
-
function toAmount(msat) {
|
|
839
|
-
return { value: String(msat), asset_code: ASSET_CODE, asset_scale: ASSET_SCALE };
|
|
1424
|
+
function toAmount(msat2) {
|
|
1425
|
+
return { value: String(msat2), asset_code: ASSET_CODE, asset_scale: ASSET_SCALE };
|
|
840
1426
|
}
|
|
841
1427
|
function asObject(value) {
|
|
842
1428
|
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
@@ -844,7 +1430,7 @@ function asObject(value) {
|
|
|
844
1430
|
}
|
|
845
1431
|
return value;
|
|
846
1432
|
}
|
|
847
|
-
function
|
|
1433
|
+
function text2(value) {
|
|
848
1434
|
return typeof value === "string" ? value : null;
|
|
849
1435
|
}
|
|
850
1436
|
function msatFrom(value, least) {
|
|
@@ -859,8 +1445,8 @@ function msatFrom(value, least) {
|
|
|
859
1445
|
if (typeof digits !== "string" || !/^[0-9]+$/.test(digits)) {
|
|
860
1446
|
return null;
|
|
861
1447
|
}
|
|
862
|
-
const
|
|
863
|
-
return Number.isSafeInteger(
|
|
1448
|
+
const msat2 = Number(digits);
|
|
1449
|
+
return Number.isSafeInteger(msat2) && msat2 >= least ? msat2 : null;
|
|
864
1450
|
}
|
|
865
1451
|
function refusalsFrom(value) {
|
|
866
1452
|
if (!Array.isArray(value)) {
|
|
@@ -869,7 +1455,7 @@ function refusalsFrom(value) {
|
|
|
869
1455
|
const refusals3 = [];
|
|
870
1456
|
for (const entry of value) {
|
|
871
1457
|
const failure = asObject(entry);
|
|
872
|
-
const address = failure === null ? null :
|
|
1458
|
+
const address = failure === null ? null : text2(failure["address"]);
|
|
873
1459
|
const reason = failure?.["reason"];
|
|
874
1460
|
if (address === null || typeof reason !== "string" || !REASONS.has(reason)) {
|
|
875
1461
|
return null;
|
|
@@ -890,118 +1476,67 @@ function isStatus(value) {
|
|
|
890
1476
|
}
|
|
891
1477
|
|
|
892
1478
|
// src/webhook.ts
|
|
893
|
-
var
|
|
894
|
-
var
|
|
1479
|
+
var SIGNATURE_HEADER2 = "x-signature";
|
|
1480
|
+
var TIMESTAMP_HEADER2 = "x-timestamp";
|
|
895
1481
|
var GATEWAY_KEY_PREFIX = "ed25519=";
|
|
896
1482
|
var DEFAULT_TOLERANCE_SECS = 300;
|
|
897
1483
|
var CHALLENGE = "webhook-challenge";
|
|
898
1484
|
var VERIFY_CHALLENGE = "verify-challenge";
|
|
899
|
-
async function
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
}
|
|
903
|
-
if (!signature.startsWith(GATEWAY_KEY_PREFIX)) {
|
|
904
|
-
return false;
|
|
905
|
-
}
|
|
906
|
-
return await verifyHex(
|
|
907
|
-
credential.publicKey.toLowerCase(),
|
|
908
|
-
signature.slice(GATEWAY_KEY_PREFIX.length).toLowerCase(),
|
|
909
|
-
signed(timestamp, body)
|
|
910
|
-
);
|
|
1485
|
+
async function readPayment(request, credential, options = {}) {
|
|
1486
|
+
const body = await believable(request, credential, options);
|
|
1487
|
+
return body === null ? null : decoded(body, paymentFromWire);
|
|
911
1488
|
}
|
|
912
|
-
async function
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
}
|
|
916
|
-
const text2 = typeof body === "string" ? body : new TextDecoder().decode(body);
|
|
917
|
-
try {
|
|
918
|
-
return paymentFromWire(JSON.parse(text2));
|
|
919
|
-
} catch {
|
|
920
|
-
return null;
|
|
921
|
-
}
|
|
1489
|
+
async function readSettlement(request, credential, options = {}) {
|
|
1490
|
+
const body = await believable(request, credential, options);
|
|
1491
|
+
return body === null ? null : decoded(body, settlementFromWire);
|
|
922
1492
|
}
|
|
923
|
-
async function
|
|
924
|
-
const
|
|
925
|
-
const
|
|
926
|
-
|
|
927
|
-
return null;
|
|
928
|
-
}
|
|
929
|
-
return parseWebhook(await request.text(), signature, credential, timestamp, options);
|
|
1493
|
+
async function answerWebhookChallenge(request, credential, options = {}) {
|
|
1494
|
+
const body = await believable(request, credential, options);
|
|
1495
|
+
const nonce = body === null ? null : nonceOf(body, CHALLENGE);
|
|
1496
|
+
return nonce === null ? null : answered2(nonce);
|
|
930
1497
|
}
|
|
931
|
-
async function
|
|
932
|
-
if (
|
|
933
|
-
return null;
|
|
934
|
-
}
|
|
935
|
-
const text2 = typeof body === "string" ? body : new TextDecoder().decode(body);
|
|
936
|
-
try {
|
|
937
|
-
return triggerEventFromWire(JSON.parse(text2));
|
|
938
|
-
} catch {
|
|
1498
|
+
async function answerVerifyChallenge(request) {
|
|
1499
|
+
if (request.method !== "POST") {
|
|
939
1500
|
return null;
|
|
940
1501
|
}
|
|
1502
|
+
const nonce = nonceOf(await request.clone().text(), VERIFY_CHALLENGE);
|
|
1503
|
+
return nonce === null ? null : answered2(nonce);
|
|
941
1504
|
}
|
|
942
|
-
async function
|
|
943
|
-
const signature = request.headers.get(
|
|
944
|
-
const timestamp = request.headers.get(
|
|
1505
|
+
async function believable(request, credential, options) {
|
|
1506
|
+
const signature = request.headers.get(SIGNATURE_HEADER2);
|
|
1507
|
+
const timestamp = request.headers.get(TIMESTAMP_HEADER2);
|
|
945
1508
|
if (signature === null || timestamp === null) {
|
|
946
1509
|
return null;
|
|
947
1510
|
}
|
|
948
|
-
|
|
949
|
-
}
|
|
950
|
-
async function parseSettlement(body, signature, credential, timestamp, options = {}) {
|
|
951
|
-
if (!await verifyWebhookSignature(body, signature, credential, timestamp, options)) {
|
|
952
|
-
return null;
|
|
953
|
-
}
|
|
954
|
-
try {
|
|
955
|
-
return settlementFromWire(JSON.parse(textOf(body)));
|
|
956
|
-
} catch {
|
|
957
|
-
return null;
|
|
958
|
-
}
|
|
959
|
-
}
|
|
960
|
-
async function parseSettlementRequest(request, credential, options = {}) {
|
|
961
|
-
const signature = request.headers.get(SIGNATURE_HEADER);
|
|
962
|
-
const timestamp = request.headers.get(TIMESTAMP_HEADER);
|
|
963
|
-
if (signature === null || timestamp === null) {
|
|
1511
|
+
if (!recent(timestamp, options.toleranceSecs ?? DEFAULT_TOLERANCE_SECS)) {
|
|
964
1512
|
return null;
|
|
965
1513
|
}
|
|
966
|
-
|
|
967
|
-
}
|
|
968
|
-
function isProvablySettled(settled) {
|
|
969
|
-
if (settled.status !== "paid" || settled.preimage === null) {
|
|
970
|
-
return false;
|
|
971
|
-
}
|
|
972
|
-
return preimageMatchesHash(settled.preimage, settled.paymentHash);
|
|
973
|
-
}
|
|
974
|
-
async function answerWebhookChallenge(body, signature, credential, timestamp, options = {}) {
|
|
975
|
-
if (!await verifyWebhookSignature(body, signature, credential, timestamp, options)) {
|
|
1514
|
+
if (!signature.startsWith(GATEWAY_KEY_PREFIX)) {
|
|
976
1515
|
return null;
|
|
977
1516
|
}
|
|
978
|
-
const
|
|
979
|
-
|
|
1517
|
+
const body = await request.clone().text();
|
|
1518
|
+
const signed = await verifyHex(
|
|
1519
|
+
credential.publicKey.toLowerCase(),
|
|
1520
|
+
signature.slice(GATEWAY_KEY_PREFIX.length).toLowerCase(),
|
|
1521
|
+
stamped(timestamp, body)
|
|
1522
|
+
);
|
|
1523
|
+
return signed ? body : null;
|
|
980
1524
|
}
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
1525
|
+
function decoded(body, from) {
|
|
1526
|
+
try {
|
|
1527
|
+
return from(JSON.parse(body));
|
|
1528
|
+
} catch {
|
|
985
1529
|
return null;
|
|
986
1530
|
}
|
|
987
|
-
const answer = await answerWebhookChallenge(
|
|
988
|
-
await request.clone().text(),
|
|
989
|
-
signature,
|
|
990
|
-
credential,
|
|
991
|
-
timestamp,
|
|
992
|
-
options
|
|
993
|
-
);
|
|
994
|
-
return answer === null ? null : new Response(answer, { headers: { "content-type": "application/json" } });
|
|
995
|
-
}
|
|
996
|
-
function textOf(body) {
|
|
997
|
-
return typeof body === "string" ? body : new TextDecoder().decode(body);
|
|
998
1531
|
}
|
|
999
|
-
function
|
|
1000
|
-
return
|
|
1532
|
+
function answered2(nonce) {
|
|
1533
|
+
return new Response(JSON.stringify({ nonce }), {
|
|
1534
|
+
headers: { "content-type": "application/json" }
|
|
1535
|
+
});
|
|
1001
1536
|
}
|
|
1002
|
-
function nonceOf(
|
|
1537
|
+
function nonceOf(text3, type) {
|
|
1003
1538
|
try {
|
|
1004
|
-
const said = JSON.parse(
|
|
1539
|
+
const said = JSON.parse(text3);
|
|
1005
1540
|
if (said["type"] !== type || typeof said["nonce"] !== "string") {
|
|
1006
1541
|
return null;
|
|
1007
1542
|
}
|
|
@@ -1010,17 +1545,6 @@ function nonceOf(text2, type) {
|
|
|
1010
1545
|
return null;
|
|
1011
1546
|
}
|
|
1012
1547
|
}
|
|
1013
|
-
function answerVerifyChallenge(body) {
|
|
1014
|
-
const nonce = nonceOf(body, VERIFY_CHALLENGE);
|
|
1015
|
-
return nonce === null ? null : JSON.stringify({ nonce });
|
|
1016
|
-
}
|
|
1017
|
-
async function answerVerifyChallengeRequest(request) {
|
|
1018
|
-
if (request.method !== "POST") {
|
|
1019
|
-
return null;
|
|
1020
|
-
}
|
|
1021
|
-
const answer = answerVerifyChallenge(await request.clone().text());
|
|
1022
|
-
return answer === null ? null : new Response(answer, { headers: { "content-type": "application/json" } });
|
|
1023
|
-
}
|
|
1024
1548
|
function recent(timestamp, toleranceSecs) {
|
|
1025
1549
|
const sent = Number(timestamp);
|
|
1026
1550
|
if (!Number.isFinite(sent)) {
|
|
@@ -1028,14 +1552,8 @@ function recent(timestamp, toleranceSecs) {
|
|
|
1028
1552
|
}
|
|
1029
1553
|
return Math.abs(Math.floor(Date.now() / 1e3) - sent) <= toleranceSecs;
|
|
1030
1554
|
}
|
|
1031
|
-
function
|
|
1032
|
-
|
|
1033
|
-
const stamp = encoder.encode(`${timestamp}.`);
|
|
1034
|
-
const rest = typeof body === "string" ? encoder.encode(body) : new Uint8Array(body);
|
|
1035
|
-
const payload = new Uint8Array(stamp.length + rest.length);
|
|
1036
|
-
payload.set(stamp);
|
|
1037
|
-
payload.set(rest, stamp.length);
|
|
1038
|
-
return payload;
|
|
1555
|
+
function stamped(timestamp, body) {
|
|
1556
|
+
return new TextEncoder().encode(`${timestamp}.${body}`);
|
|
1039
1557
|
}
|
|
1040
1558
|
|
|
1041
1559
|
// src/bank.ts
|
|
@@ -1044,10 +1562,10 @@ var DEFAULT_LOOK_BACK_SECS = 7 * 24 * 60 * 60;
|
|
|
1044
1562
|
var DEFAULT_POLL_EVERY_SECS = 30;
|
|
1045
1563
|
var IBAN = /^[A-Z]{2}[0-9]{2}[0-9A-Z]{8,30}$/;
|
|
1046
1564
|
var FORBIDDEN_IN_SPD = "*";
|
|
1047
|
-
async function bankTransfer(params) {
|
|
1565
|
+
async function bankTransfer(gateway, params) {
|
|
1048
1566
|
const currency = params.currency ?? DEFAULT_CURRENCY;
|
|
1049
1567
|
refuseUnusable(params, currency);
|
|
1050
|
-
await refuseAnOpenGateway(params);
|
|
1568
|
+
await refuseAnOpenGateway(gateway, params);
|
|
1051
1569
|
const spd = shortPaymentDescriptor(params, currency);
|
|
1052
1570
|
const subject = subjectOf(params.reference, params.amountMinor, currency);
|
|
1053
1571
|
const polling = new URL(params.verifyUrl);
|
|
@@ -1056,7 +1574,7 @@ async function bankTransfer(params) {
|
|
|
1056
1574
|
polling.searchParams.set("cc", currency);
|
|
1057
1575
|
polling.searchParams.set("sig", await hmacHex(params.secret, `verify|${subject}`));
|
|
1058
1576
|
const verifyUrl = polling.toString();
|
|
1059
|
-
const watched = await
|
|
1577
|
+
const watched = await gateway.watch({
|
|
1060
1578
|
paymentHash: hashOf(await hmacHex(params.secret, `preimage|${subject}`)),
|
|
1061
1579
|
verifyUrl,
|
|
1062
1580
|
expiresAt: params.expiresAt,
|
|
@@ -1072,7 +1590,7 @@ function bankVerifyEndpoint(config) {
|
|
|
1072
1590
|
"cache-control": `max-age=${config.pollEverySecs ?? DEFAULT_POLL_EVERY_SECS}`
|
|
1073
1591
|
};
|
|
1074
1592
|
return async (request) => {
|
|
1075
|
-
const consented = await
|
|
1593
|
+
const consented = await answerVerifyChallenge(request);
|
|
1076
1594
|
if (consented !== null) {
|
|
1077
1595
|
return consented;
|
|
1078
1596
|
}
|
|
@@ -1133,11 +1651,11 @@ function shortPaymentDescriptor(params, currency) {
|
|
|
1133
1651
|
function major(amountMinor, currency) {
|
|
1134
1652
|
return (amountMinor / minorScaleOf(currency)).toFixed(minorUnitsOf(currency));
|
|
1135
1653
|
}
|
|
1136
|
-
async function refuseAnOpenGateway(params) {
|
|
1654
|
+
async function refuseAnOpenGateway(gateway, params) {
|
|
1137
1655
|
if (params.allowPublicGateway === true) {
|
|
1138
1656
|
return;
|
|
1139
1657
|
}
|
|
1140
|
-
if (await
|
|
1658
|
+
if (await gateway.refusesStrangers()) {
|
|
1141
1659
|
return;
|
|
1142
1660
|
}
|
|
1143
1661
|
throw new Error(
|
|
@@ -1161,124 +1679,240 @@ function refuseUnusable(params, currency) {
|
|
|
1161
1679
|
throw new Error("a variable symbol is up to ten digits");
|
|
1162
1680
|
}
|
|
1163
1681
|
}
|
|
1164
|
-
function unixNow() {
|
|
1165
|
-
return Math.floor(Date.now() / 1e3);
|
|
1682
|
+
function unixNow() {
|
|
1683
|
+
return Math.floor(Date.now() / 1e3);
|
|
1684
|
+
}
|
|
1685
|
+
|
|
1686
|
+
// src/qr.ts
|
|
1687
|
+
import { encode } from "uqr";
|
|
1688
|
+
var SAFE_COLOR = /^#(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$|^rgba?\([\d.,%/\s]+\)$|^[a-z]+$/i;
|
|
1689
|
+
function toLightningUri(destination) {
|
|
1690
|
+
const scanned = isLnAddress(destination) ? destination : destination.toUpperCase();
|
|
1691
|
+
return `LIGHTNING:${scanned}`;
|
|
1692
|
+
}
|
|
1693
|
+
function invoiceToSvg(destination, options) {
|
|
1694
|
+
return svgOf(toLightningUri(destination), options);
|
|
1695
|
+
}
|
|
1696
|
+
function svgOf(scanned, options) {
|
|
1697
|
+
const size = options?.size ?? 256;
|
|
1698
|
+
const color = options?.color ?? "#000";
|
|
1699
|
+
if (!SAFE_COLOR.test(color)) {
|
|
1700
|
+
throw new Error(`Invalid color: ${color}`);
|
|
1701
|
+
}
|
|
1702
|
+
return renderSvg(scanned, size, color);
|
|
1703
|
+
}
|
|
1704
|
+
function isLnAddress(destination) {
|
|
1705
|
+
const at = destination.indexOf("@");
|
|
1706
|
+
return at > 0 && destination.slice(at + 1).includes(".");
|
|
1707
|
+
}
|
|
1708
|
+
function renderSvg(content, size, color) {
|
|
1709
|
+
const { data } = encode(content);
|
|
1710
|
+
const modules = data.length;
|
|
1711
|
+
const cellSize = size / modules;
|
|
1712
|
+
let path = "";
|
|
1713
|
+
for (let y = 0; y < modules; y++) {
|
|
1714
|
+
for (let x = 0; x < modules; x++) {
|
|
1715
|
+
if (data[y][x]) {
|
|
1716
|
+
path += `M${x * cellSize},${y * cellSize}h${cellSize}v${cellSize}h-${cellSize}z`;
|
|
1717
|
+
}
|
|
1718
|
+
}
|
|
1719
|
+
}
|
|
1720
|
+
return [
|
|
1721
|
+
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${size} ${size}" width="${size}" height="${size}">`,
|
|
1722
|
+
`<rect width="100%" height="100%" fill="white"/>`,
|
|
1723
|
+
`<path d="${path}" fill="${color}"/>`,
|
|
1724
|
+
`</svg>`
|
|
1725
|
+
].join("");
|
|
1726
|
+
}
|
|
1727
|
+
|
|
1728
|
+
// src/relay.ts
|
|
1729
|
+
var DEFAULT_POLL_EVERY_SECS2 = 5;
|
|
1730
|
+
var WALLET = "w";
|
|
1731
|
+
function lightningVerifyEndpoint(config) {
|
|
1732
|
+
const paced = {
|
|
1733
|
+
"cache-control": `max-age=${config.pollEverySecs ?? DEFAULT_POLL_EVERY_SECS2}`
|
|
1734
|
+
};
|
|
1735
|
+
return async (request) => {
|
|
1736
|
+
const consented = await answerVerifyChallenge(request);
|
|
1737
|
+
if (consented !== null) {
|
|
1738
|
+
return consented;
|
|
1739
|
+
}
|
|
1740
|
+
const sealed = new URL(request.url).searchParams.get(WALLET);
|
|
1741
|
+
if (sealed === null) {
|
|
1742
|
+
return Response.json({ settled: false }, { status: 400 });
|
|
1743
|
+
}
|
|
1744
|
+
const opened = await unseal(config.secret, sealed);
|
|
1745
|
+
if (opened === null) {
|
|
1746
|
+
return Response.json({ settled: false }, { status: 403 });
|
|
1747
|
+
}
|
|
1748
|
+
const wallet = JSON.parse(opened);
|
|
1749
|
+
const asked = await checkSettled(
|
|
1750
|
+
config.send ?? pinnedToTheAddressWeVerified,
|
|
1751
|
+
wallet.url,
|
|
1752
|
+
wallet.hash
|
|
1753
|
+
).catch(() => null);
|
|
1754
|
+
if (asked === null) {
|
|
1755
|
+
return Response.json({ settled: false }, { status: 502 });
|
|
1756
|
+
}
|
|
1757
|
+
return Response.json(
|
|
1758
|
+
{ settled: asked.preimage !== null, preimage: asked.preimage },
|
|
1759
|
+
{ headers: paced }
|
|
1760
|
+
);
|
|
1761
|
+
};
|
|
1762
|
+
}
|
|
1763
|
+
async function relayedVerifyUrl(endpoint, wallet, secret) {
|
|
1764
|
+
const relayed = new URL(endpoint);
|
|
1765
|
+
relayed.searchParams.set(WALLET, await seal(secret, JSON.stringify(wallet)));
|
|
1766
|
+
return relayed.toString();
|
|
1166
1767
|
}
|
|
1167
1768
|
|
|
1168
|
-
//
|
|
1169
|
-
var
|
|
1170
|
-
var
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1769
|
+
// src/rail.ts
|
|
1770
|
+
var BANK = "bank";
|
|
1771
|
+
var LIGHTNING = "lightning";
|
|
1772
|
+
function bankRail(gateway, config) {
|
|
1773
|
+
return async (order) => {
|
|
1774
|
+
const expiresAt = config.expiresAt(order);
|
|
1775
|
+
const transfer = await bankTransfer(gateway, {
|
|
1776
|
+
secret: config.secret,
|
|
1777
|
+
iban: config.iban,
|
|
1778
|
+
verifyUrl: config.verifyUrl,
|
|
1779
|
+
reference: order.reference,
|
|
1780
|
+
amountMinor: order.amountMinor,
|
|
1781
|
+
currency: order.currency,
|
|
1782
|
+
expiresAt,
|
|
1783
|
+
trigger: config.trigger,
|
|
1784
|
+
replay: config.replay,
|
|
1785
|
+
sealed: await config.sealed?.(order),
|
|
1786
|
+
variableSymbol: config.variableSymbol?.(order),
|
|
1787
|
+
webhookUrl: config.webhookUrl,
|
|
1788
|
+
allowPublicGateway: config.allowPublicGateway
|
|
1789
|
+
});
|
|
1790
|
+
return {
|
|
1791
|
+
id: transfer.id,
|
|
1792
|
+
rail: config.name ?? BANK,
|
|
1793
|
+
scan: transfer.spd,
|
|
1794
|
+
qr: transfer.spd,
|
|
1795
|
+
expiresAt
|
|
1796
|
+
};
|
|
1797
|
+
};
|
|
1176
1798
|
}
|
|
1177
|
-
function
|
|
1178
|
-
return
|
|
1799
|
+
function lightningRail(gateway, config) {
|
|
1800
|
+
return async (order) => {
|
|
1801
|
+
const payment = await gateway.mint(
|
|
1802
|
+
{
|
|
1803
|
+
paidTo: config.paidTo,
|
|
1804
|
+
amount: await msatForOrder(order, config.amount, config.rate),
|
|
1805
|
+
webhookUrl: config.webhookUrl
|
|
1806
|
+
},
|
|
1807
|
+
{
|
|
1808
|
+
idempotencyKey: config.idempotencyKey?.(order),
|
|
1809
|
+
trigger: config.trigger,
|
|
1810
|
+
replay: config.replay
|
|
1811
|
+
}
|
|
1812
|
+
);
|
|
1813
|
+
return {
|
|
1814
|
+
id: payment.id,
|
|
1815
|
+
rail: config.name ?? LIGHTNING,
|
|
1816
|
+
scan: payment.bolt11,
|
|
1817
|
+
qr: toLightningUri(payment.bolt11),
|
|
1818
|
+
expiresAt: payment.expiresAt
|
|
1819
|
+
};
|
|
1820
|
+
};
|
|
1179
1821
|
}
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1822
|
+
function blindLightningRail(gateway, config) {
|
|
1823
|
+
return async (order) => {
|
|
1824
|
+
const resolved2 = await invoiceFrom(
|
|
1825
|
+
config.paidTo,
|
|
1826
|
+
await msatForOrder(order, config.amount, config.rate),
|
|
1827
|
+
config.send
|
|
1828
|
+
);
|
|
1829
|
+
const relay = config.relayThrough;
|
|
1830
|
+
const watched = await gateway.watch({
|
|
1831
|
+
paymentHash: resolved2.paymentHash,
|
|
1832
|
+
verifyUrl: relay ? await relayedVerifyUrl(
|
|
1833
|
+
relay.endpoint,
|
|
1834
|
+
{ url: resolved2.verifyUrl, hash: resolved2.paymentHash },
|
|
1835
|
+
relay.secret
|
|
1836
|
+
) : resolved2.verifyUrl,
|
|
1837
|
+
expiresAt: resolved2.expiresAt,
|
|
1838
|
+
trigger: config.trigger,
|
|
1839
|
+
replay: config.replay,
|
|
1840
|
+
sealed: await config.sealed?.(order),
|
|
1841
|
+
webhookUrl: config.webhookUrl
|
|
1842
|
+
});
|
|
1843
|
+
return {
|
|
1844
|
+
id: watched.id,
|
|
1845
|
+
rail: config.name ?? LIGHTNING,
|
|
1846
|
+
scan: resolved2.bolt11,
|
|
1847
|
+
qr: toLightningUri(resolved2.bolt11),
|
|
1848
|
+
expiresAt: resolved2.expiresAt
|
|
1849
|
+
};
|
|
1186
1850
|
};
|
|
1187
1851
|
}
|
|
1188
|
-
function
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1852
|
+
async function invoiceFrom(paidTo, amount, send = pinnedToTheAddressWeVerified) {
|
|
1853
|
+
const addresses = typeof paidTo === "string" ? [paidTo] : paidTo;
|
|
1854
|
+
try {
|
|
1855
|
+
return await resolve(send, addresses, await amountNow(amount));
|
|
1856
|
+
} catch (refused) {
|
|
1857
|
+
if (refused instanceof NoWalletAvailable) {
|
|
1858
|
+
throw new NoWalletAvailableError({ title: refused.message }, refused.wallets);
|
|
1859
|
+
}
|
|
1860
|
+
throw refused;
|
|
1861
|
+
}
|
|
1192
1862
|
}
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
code;
|
|
1197
|
-
paymentId;
|
|
1198
|
-
constructor(code, paymentId) {
|
|
1199
|
-
super(`gateway verification failed: ${code}`);
|
|
1200
|
-
this.name = "GatewayCheatError";
|
|
1201
|
-
this.code = code;
|
|
1202
|
-
this.paymentId = paymentId;
|
|
1863
|
+
async function msatForOrder(order, amount, rate) {
|
|
1864
|
+
if (amount !== void 0) {
|
|
1865
|
+
return await amountNow(amount(order));
|
|
1203
1866
|
}
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
constructor(
|
|
1208
|
-
|
|
1209
|
-
this.name = "WrapRefusedError";
|
|
1210
|
-
this.code = code;
|
|
1867
|
+
return msat(msatFor(order.amountMinor, await (rate ?? medianOf())(order.currency)));
|
|
1868
|
+
}
|
|
1869
|
+
var Rails = class {
|
|
1870
|
+
constructor(gateway) {
|
|
1871
|
+
this.gateway = gateway;
|
|
1211
1872
|
}
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
constructor(lnAddress, paymentId, cause) {
|
|
1217
|
-
super(`could not reach ${lnAddress} to verify the invoice: ${String(cause)}`);
|
|
1218
|
-
this.name = "UnverifiedRecipientError";
|
|
1219
|
-
this.lnAddress = lnAddress;
|
|
1220
|
-
this.paymentId = paymentId;
|
|
1873
|
+
gateway;
|
|
1874
|
+
/** Lightning, with the gateway minting against a priority list of addresses */
|
|
1875
|
+
lightning(config) {
|
|
1876
|
+
return lightningRail(this.gateway, config);
|
|
1221
1877
|
}
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
constructor(problem) {
|
|
1229
|
-
const title = problem.title ?? "Request failed";
|
|
1230
|
-
super(problem.detail ? `${title}: ${problem.detail}` : title);
|
|
1231
|
-
this.name = "ProblemError";
|
|
1232
|
-
this.type = problem.type ?? "about:blank";
|
|
1233
|
-
this.title = title;
|
|
1234
|
-
this.status = problem.status ?? 0;
|
|
1235
|
-
this.detail = problem.detail ?? null;
|
|
1878
|
+
/**
|
|
1879
|
+
* Lightning, with the invoice resolved here so the gateway is told neither the
|
|
1880
|
+
* address nor the amount
|
|
1881
|
+
*/
|
|
1882
|
+
blindLightning(config) {
|
|
1883
|
+
return blindLightningRail(this.gateway, config);
|
|
1236
1884
|
}
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
}
|
|
1241
|
-
var NO_WALLET_AVAILABLE = "urn:problem-type:thunder-bridge:no-wallet-available";
|
|
1242
|
-
var REQUEST_IN_FLIGHT = "urn:problem-type:thunder-bridge:request-in-flight";
|
|
1243
|
-
var IDEMPOTENCY_KEY_REUSED = "urn:problem-type:thunder-bridge:idempotency-key-reused";
|
|
1244
|
-
var PAYMENT_ALREADY_WATCHED = "urn:problem-type:thunder-bridge:payment-already-watched";
|
|
1245
|
-
var IdempotencyConflictError = class extends ProblemError {
|
|
1246
|
-
conflict;
|
|
1247
|
-
constructor(problem, conflict) {
|
|
1248
|
-
super(problem);
|
|
1249
|
-
this.name = "IdempotencyConflictError";
|
|
1250
|
-
this.conflict = conflict;
|
|
1885
|
+
/** A bank transfer, proved the way a Lightning payment is */
|
|
1886
|
+
bank(config) {
|
|
1887
|
+
return bankRail(this.gateway, config);
|
|
1251
1888
|
}
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
this.
|
|
1258
|
-
this.wallets = wallets;
|
|
1889
|
+
/**
|
|
1890
|
+
* One bank transfer without building a rail first, for a shop that asks for
|
|
1891
|
+
* them one at a time rather than beside another payment method
|
|
1892
|
+
*/
|
|
1893
|
+
transfer(params) {
|
|
1894
|
+
return bankTransfer(this.gateway, params);
|
|
1259
1895
|
}
|
|
1260
1896
|
};
|
|
1261
1897
|
|
|
1262
1898
|
// src/verify.ts
|
|
1263
|
-
var
|
|
1899
|
+
var HTTP_TIMEOUT_MS2 = 15e3;
|
|
1264
1900
|
var DEFAULT_WRAP_PROPORTION = 0.01;
|
|
1265
1901
|
var DEFAULT_WRAP_BASE_MSAT = 1e3;
|
|
1266
|
-
async function proveOrigin(payment,
|
|
1902
|
+
async function proveOrigin(payment, asked) {
|
|
1267
1903
|
const cheat = (code) => new GatewayCheatError(code, payment.id);
|
|
1268
|
-
const listed =
|
|
1269
|
-
(address) => equalIgnoringCase(address, payment.lnAddress)
|
|
1270
|
-
);
|
|
1904
|
+
const listed = asked.paidTo.find((address) => equalIgnoringCase(address, payment.lnAddress));
|
|
1271
1905
|
if (listed === void 0) {
|
|
1272
1906
|
throw cheat("address_not_requested");
|
|
1273
1907
|
}
|
|
1274
|
-
if (payment.amountMsat !==
|
|
1908
|
+
if (payment.amountMsat !== asked.amountMsat) {
|
|
1275
1909
|
throw cheat("amount_mismatch");
|
|
1276
1910
|
}
|
|
1277
1911
|
const invoice = decodeInvoice(payment.bolt11);
|
|
1278
1912
|
if (invoice.paymentHash === null || !equalIgnoringCase(invoice.paymentHash, payment.paymentHash)) {
|
|
1279
1913
|
throw cheat("hash_mismatch");
|
|
1280
1914
|
}
|
|
1281
|
-
if (invoice.amountMsat !==
|
|
1915
|
+
if (invoice.amountMsat !== asked.amountMsat) {
|
|
1282
1916
|
throw cheat("amount_mismatch");
|
|
1283
1917
|
}
|
|
1284
1918
|
const payRequest = await payRequestFor(listed, payment);
|
|
@@ -1296,8 +1930,8 @@ async function proveOrigin(payment, request) {
|
|
|
1296
1930
|
throw cheat("invoice_not_issued");
|
|
1297
1931
|
}
|
|
1298
1932
|
}
|
|
1299
|
-
async function proveSettlement(payment,
|
|
1300
|
-
await proveOrigin(payment,
|
|
1933
|
+
async function proveSettlement(payment, asked) {
|
|
1934
|
+
await proveOrigin(payment, asked);
|
|
1301
1935
|
const verified = await reachable(payment.verifyUrl, payment);
|
|
1302
1936
|
if (verified.settled !== true || typeof verified.preimage !== "string") {
|
|
1303
1937
|
return null;
|
|
@@ -1307,46 +1941,48 @@ async function proveSettlement(payment, request) {
|
|
|
1307
1941
|
}
|
|
1308
1942
|
return verified.preimage;
|
|
1309
1943
|
}
|
|
1310
|
-
function
|
|
1311
|
-
if (
|
|
1944
|
+
function carriesProof(report) {
|
|
1945
|
+
if (report.status !== "paid" || report.preimage === null) {
|
|
1312
1946
|
return false;
|
|
1313
1947
|
}
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1948
|
+
if (report.bolt11 !== void 0 && report.bolt11 !== null) {
|
|
1949
|
+
const invoiceHash = decodeInvoice(report.bolt11).paymentHash;
|
|
1950
|
+
if (invoiceHash === null || !equalIgnoringCase(invoiceHash, report.paymentHash)) {
|
|
1951
|
+
return false;
|
|
1952
|
+
}
|
|
1317
1953
|
}
|
|
1318
|
-
return preimageMatchesHash(
|
|
1954
|
+
return preimageMatchesHash(report.preimage, report.paymentHash);
|
|
1319
1955
|
}
|
|
1320
1956
|
function wrapFeeCeiling(amountMsat, allowance) {
|
|
1321
1957
|
const proportional = amountMsat * (allowance?.proportion ?? DEFAULT_WRAP_PROPORTION);
|
|
1322
|
-
return Math.max(allowance?.baseMsat ?? DEFAULT_WRAP_BASE_MSAT, Math.floor(proportional));
|
|
1958
|
+
return msat(Math.max(allowance?.baseMsat ?? DEFAULT_WRAP_BASE_MSAT, Math.floor(proportional)));
|
|
1323
1959
|
}
|
|
1324
1960
|
function proveWrapped(wrapped, recipient, allowance) {
|
|
1325
1961
|
const offered = decodeInvoice(wrapped);
|
|
1326
1962
|
const owed = decodeInvoice(recipient);
|
|
1327
|
-
const
|
|
1963
|
+
const refuse2 = (code, detail) => {
|
|
1328
1964
|
throw new WrapRefusedError(code, detail);
|
|
1329
1965
|
};
|
|
1330
1966
|
if (offered.paymentHash === null || offered.amountMsat === null || offered.expiresAt === null) {
|
|
1331
|
-
|
|
1967
|
+
refuse2("undecodable", "the wrapped invoice carries no hash, amount or expiry");
|
|
1332
1968
|
}
|
|
1333
1969
|
if (owed.paymentHash === null || owed.amountMsat === null || owed.expiresAt === null) {
|
|
1334
|
-
|
|
1970
|
+
refuse2("undecodable", "the recipient's invoice carries no hash, amount or expiry");
|
|
1335
1971
|
}
|
|
1336
1972
|
if (offered.paymentHash?.toLowerCase() !== owed.paymentHash?.toLowerCase()) {
|
|
1337
|
-
|
|
1973
|
+
refuse2("hash_mismatch", "settling the wrap would not settle the recipient");
|
|
1338
1974
|
}
|
|
1339
1975
|
const asked = offered.amountMsat ?? 0;
|
|
1340
1976
|
const due = owed.amountMsat ?? 0;
|
|
1341
1977
|
if (asked < due) {
|
|
1342
|
-
|
|
1978
|
+
refuse2("amount_below_recipient", `${asked} msat cannot forward ${due} msat`);
|
|
1343
1979
|
}
|
|
1344
1980
|
const ceiling = wrapFeeCeiling(due, allowance);
|
|
1345
1981
|
if (asked - due > ceiling) {
|
|
1346
|
-
|
|
1982
|
+
refuse2("fee_above_allowance", `${asked - due} msat over ${due}, and ${ceiling} was allowed`);
|
|
1347
1983
|
}
|
|
1348
1984
|
if ((owed.expiresAt ?? 0) <= (offered.expiresAt ?? 0)) {
|
|
1349
|
-
|
|
1985
|
+
refuse2("recipient_expires_first", "the operator could be paid and find it cannot deliver");
|
|
1350
1986
|
}
|
|
1351
1987
|
}
|
|
1352
1988
|
async function payRequestFor(listed, payment) {
|
|
@@ -1364,7 +2000,7 @@ async function reachable(url, payment) {
|
|
|
1364
2000
|
throw new Error(`${url} is not a public https URL`);
|
|
1365
2001
|
}
|
|
1366
2002
|
const response = await fetch(url, {
|
|
1367
|
-
signal: AbortSignal.timeout(
|
|
2003
|
+
signal: AbortSignal.timeout(HTTP_TIMEOUT_MS2),
|
|
1368
2004
|
headers: { accept: "application/json" }
|
|
1369
2005
|
});
|
|
1370
2006
|
if (!response.ok) {
|
|
@@ -1383,6 +2019,280 @@ function equalIgnoringCase(one, other) {
|
|
|
1383
2019
|
return one.toLowerCase() === other.toLowerCase();
|
|
1384
2020
|
}
|
|
1385
2021
|
|
|
2022
|
+
// src/request.ts
|
|
2023
|
+
function paymentRequestOf(gateway, payment, options) {
|
|
2024
|
+
return {
|
|
2025
|
+
id: payment.id,
|
|
2026
|
+
bolt11: payment.bolt11,
|
|
2027
|
+
paymentHash: payment.paymentHash,
|
|
2028
|
+
lnAddress: payment.lnAddress,
|
|
2029
|
+
amountMsat: payment.amountMsat,
|
|
2030
|
+
expiresAt: payment.expiresAt,
|
|
2031
|
+
qr: invoiceToSvg(payment.bolt11, options?.qr),
|
|
2032
|
+
payment,
|
|
2033
|
+
paid: async (waiting) => paidOnly(await gateway.settled(payment.id, { ...waitingOf(options), ...waiting })),
|
|
2034
|
+
prove: () => proveSettlement(payment, { paidTo: [payment.lnAddress], amountMsat: payment.amountMsat }),
|
|
2035
|
+
onPaid: (arrived, failed) => {
|
|
2036
|
+
const stop = new AbortController();
|
|
2037
|
+
const waiting = waitingOf(options);
|
|
2038
|
+
const signal = waiting.signal ? AbortSignal.any([stop.signal, waiting.signal]) : stop.signal;
|
|
2039
|
+
gateway.settled(payment.id, { ...waiting, signal }).then((ended) => arrived(paidOnly(ended))).catch((reason) => {
|
|
2040
|
+
if (!stop.signal.aborted) {
|
|
2041
|
+
failed?.(reason);
|
|
2042
|
+
}
|
|
2043
|
+
});
|
|
2044
|
+
return () => stop.abort();
|
|
2045
|
+
}
|
|
2046
|
+
};
|
|
2047
|
+
}
|
|
2048
|
+
function paidOnly(ended) {
|
|
2049
|
+
if (ended.status !== "paid") {
|
|
2050
|
+
throw new Error(`payment ${ended.id} ended ${ended.status} rather than paid`);
|
|
2051
|
+
}
|
|
2052
|
+
if (ended.kind !== "minted") {
|
|
2053
|
+
throw new Error(`payment ${ended.id} came back without the invoice it was minted with`);
|
|
2054
|
+
}
|
|
2055
|
+
return ended;
|
|
2056
|
+
}
|
|
2057
|
+
function waitingOf(options) {
|
|
2058
|
+
return { signal: options?.signal, tickets: options?.tickets };
|
|
2059
|
+
}
|
|
2060
|
+
|
|
2061
|
+
// src/trigger.ts
|
|
2062
|
+
var NONCE_BYTES = 16;
|
|
2063
|
+
function lnurlPayEndpoint(gateway, config) {
|
|
2064
|
+
return async (request) => {
|
|
2065
|
+
const url = new URL(request.url);
|
|
2066
|
+
const asked = url.searchParams.get("to");
|
|
2067
|
+
try {
|
|
2068
|
+
return asked === null ? await offer(gateway, config, url) : await mint(gateway, config, url, asked);
|
|
2069
|
+
} catch (failure) {
|
|
2070
|
+
return refuse(failure instanceof Error ? failure.message : "the trigger could not be served");
|
|
2071
|
+
}
|
|
2072
|
+
};
|
|
2073
|
+
}
|
|
2074
|
+
function watchTicketEndpoint(gateway, config) {
|
|
2075
|
+
return async (request) => {
|
|
2076
|
+
const offered = await offeredSecret(request);
|
|
2077
|
+
if (!equalInConstantTime(sha256Hex(offered), sha256Hex(config.watchSecret))) {
|
|
2078
|
+
return Response.json({ reason: "not the watch secret" }, { status: 403 });
|
|
2079
|
+
}
|
|
2080
|
+
return await issue(gateway, config);
|
|
2081
|
+
};
|
|
2082
|
+
}
|
|
2083
|
+
function publicWatchTicketEndpoint(gateway, config) {
|
|
2084
|
+
return () => issue(gateway, config);
|
|
2085
|
+
}
|
|
2086
|
+
async function offer(gateway, config, url) {
|
|
2087
|
+
const { least, most } = await spread(config.amount);
|
|
2088
|
+
const quote = await gateway.quote({ paidTo: config.paidTo, amount: msat(least) });
|
|
2089
|
+
const nonce = randomNonce();
|
|
2090
|
+
const callback = new URL(config.baseUrl ?? `${url.origin}${url.pathname}`);
|
|
2091
|
+
callback.searchParams.set("to", quote.lnAddress);
|
|
2092
|
+
callback.searchParams.set("least", String(least));
|
|
2093
|
+
callback.searchParams.set("most", String(most));
|
|
2094
|
+
callback.searchParams.set("n", nonce);
|
|
2095
|
+
callback.searchParams.set("sig", await sign(config.secret, quote.lnAddress, least, most, nonce));
|
|
2096
|
+
return Response.json({
|
|
2097
|
+
tag: "payRequest",
|
|
2098
|
+
callback: callback.toString(),
|
|
2099
|
+
metadata: quote.metadata,
|
|
2100
|
+
minSendable: least,
|
|
2101
|
+
maxSendable: most
|
|
2102
|
+
});
|
|
2103
|
+
}
|
|
2104
|
+
async function spread(amount) {
|
|
2105
|
+
if (typeof amount === "number" || typeof amount === "function") {
|
|
2106
|
+
const pinned = await amountNow(amount);
|
|
2107
|
+
return { least: pinned, most: pinned };
|
|
2108
|
+
}
|
|
2109
|
+
const least = await amountNow(amount.least);
|
|
2110
|
+
const most = await amountNow(amount.most);
|
|
2111
|
+
if (most < least) {
|
|
2112
|
+
throw new Error(`a range cannot end at ${most} msat when it starts at ${least}`);
|
|
2113
|
+
}
|
|
2114
|
+
return { least, most };
|
|
2115
|
+
}
|
|
2116
|
+
async function mint(gateway, config, url, address) {
|
|
2117
|
+
const least = Number(url.searchParams.get("least"));
|
|
2118
|
+
const most = Number(url.searchParams.get("most"));
|
|
2119
|
+
const nonce = url.searchParams.get("n") ?? "";
|
|
2120
|
+
const signature = url.searchParams.get("sig") ?? "";
|
|
2121
|
+
const expected = await sign(config.secret, address, least, most, nonce);
|
|
2122
|
+
if (!equalInConstantTime(signature, expected)) {
|
|
2123
|
+
return refuse("this callback was not signed here");
|
|
2124
|
+
}
|
|
2125
|
+
const asked = url.searchParams.get("amount");
|
|
2126
|
+
const amountMsat = asked === null ? least : Number(asked);
|
|
2127
|
+
if (!Number.isSafeInteger(amountMsat) || amountMsat < least || amountMsat > most) {
|
|
2128
|
+
return refuse(
|
|
2129
|
+
least === most ? `this trigger costs exactly ${least} msat` : `this trigger takes between ${least} and ${most} msat`
|
|
2130
|
+
);
|
|
2131
|
+
}
|
|
2132
|
+
const minted = config.blind ? await mintBlind(gateway, config, address, amountMsat) : await mintThroughGateway(gateway, config, address, amountMsat, nonce);
|
|
2133
|
+
return Response.json({
|
|
2134
|
+
status: "OK",
|
|
2135
|
+
pr: minted.bolt11,
|
|
2136
|
+
routes: [],
|
|
2137
|
+
verify: minted.verifyUrl
|
|
2138
|
+
});
|
|
2139
|
+
}
|
|
2140
|
+
async function mintThroughGateway(gateway, config, address, amountMsat, nonce) {
|
|
2141
|
+
const payment = await gateway.mint(
|
|
2142
|
+
{ paidTo: address, amount: msat(amountMsat) },
|
|
2143
|
+
{ idempotencyKey: nonce, trigger: config.watchSecret, replay: config.replay }
|
|
2144
|
+
);
|
|
2145
|
+
return { bolt11: payment.bolt11, verifyUrl: payment.verifyUrl };
|
|
2146
|
+
}
|
|
2147
|
+
async function mintBlind(gateway, config, address, amountMsat) {
|
|
2148
|
+
const resolved2 = await resolve(
|
|
2149
|
+
config.send ?? pinnedToTheAddressWeVerified,
|
|
2150
|
+
[address],
|
|
2151
|
+
amountMsat
|
|
2152
|
+
);
|
|
2153
|
+
const minted = { ...resolved2, amountMsat, lnAddress: resolved2.address };
|
|
2154
|
+
const locked = config.sealed;
|
|
2155
|
+
const relay = config.relayThrough;
|
|
2156
|
+
try {
|
|
2157
|
+
await gateway.watch({
|
|
2158
|
+
paymentHash: resolved2.paymentHash,
|
|
2159
|
+
verifyUrl: relay ? await relayedVerifyUrl(
|
|
2160
|
+
relay.endpoint,
|
|
2161
|
+
{ url: resolved2.verifyUrl, hash: resolved2.paymentHash },
|
|
2162
|
+
relay.secret
|
|
2163
|
+
) : resolved2.verifyUrl,
|
|
2164
|
+
expiresAt: resolved2.expiresAt,
|
|
2165
|
+
trigger: config.watchSecret,
|
|
2166
|
+
replay: config.replay,
|
|
2167
|
+
sealed: locked ? await seal(locked.secret, JSON.stringify(locked.data(minted))) : void 0
|
|
2168
|
+
});
|
|
2169
|
+
} catch (refused) {
|
|
2170
|
+
if (!alreadyWatched(refused)) {
|
|
2171
|
+
throw refused;
|
|
2172
|
+
}
|
|
2173
|
+
}
|
|
2174
|
+
return { bolt11: resolved2.bolt11, verifyUrl: resolved2.verifyUrl };
|
|
2175
|
+
}
|
|
2176
|
+
function alreadyWatched(refused) {
|
|
2177
|
+
return refused instanceof ProblemError && isProblemType(refused, PAYMENT_ALREADY_WATCHED);
|
|
2178
|
+
}
|
|
2179
|
+
function sign(secret, address, least, most, nonce) {
|
|
2180
|
+
return hmacHex(secret, `${address}|${least}|${most}|${nonce}`);
|
|
2181
|
+
}
|
|
2182
|
+
function randomNonce() {
|
|
2183
|
+
const bytes = crypto.getRandomValues(new Uint8Array(NONCE_BYTES));
|
|
2184
|
+
let hex = "";
|
|
2185
|
+
for (const byte of bytes) {
|
|
2186
|
+
hex += byte.toString(16).padStart(2, "0");
|
|
2187
|
+
}
|
|
2188
|
+
return hex;
|
|
2189
|
+
}
|
|
2190
|
+
function refuse(reason) {
|
|
2191
|
+
return Response.json({ status: "ERROR", reason });
|
|
2192
|
+
}
|
|
2193
|
+
async function issue(gateway, config) {
|
|
2194
|
+
const issued = await gateway.ticket(config.watchSecret, { replay: config.replay });
|
|
2195
|
+
return Response.json({
|
|
2196
|
+
ticket: issued.ticket,
|
|
2197
|
+
expires_at: new Date(issued.expiresAt * 1e3).toISOString()
|
|
2198
|
+
});
|
|
2199
|
+
}
|
|
2200
|
+
async function offeredSecret(request) {
|
|
2201
|
+
const body = await request.json().catch(() => null);
|
|
2202
|
+
return typeof body?.secret === "string" ? body.secret : "";
|
|
2203
|
+
}
|
|
2204
|
+
|
|
2205
|
+
// src/serving.ts
|
|
2206
|
+
var Serve = class {
|
|
2207
|
+
constructor(gateway) {
|
|
2208
|
+
this.gateway = gateway;
|
|
2209
|
+
}
|
|
2210
|
+
gateway;
|
|
2211
|
+
/**
|
|
2212
|
+
* An LNURL-pay endpoint of your own, standing in front of a priority list of
|
|
2213
|
+
* addresses, so a printed QR points at your domain and never expires
|
|
2214
|
+
*/
|
|
2215
|
+
lnurlPay(config) {
|
|
2216
|
+
return lnurlPayEndpoint(this.gateway, config);
|
|
2217
|
+
}
|
|
2218
|
+
/** Trades the watch secret for a one minute socket ticket, refusing anyone without it */
|
|
2219
|
+
watchTicket(config) {
|
|
2220
|
+
return watchTicketEndpoint(this.gateway, config);
|
|
2221
|
+
}
|
|
2222
|
+
/**
|
|
2223
|
+
* Mints a socket ticket for anybody who asks, which makes the trigger's whole
|
|
2224
|
+
* stream public, preimages included. Only for a board where that is the point
|
|
2225
|
+
*/
|
|
2226
|
+
publicWatchTicket(config) {
|
|
2227
|
+
return publicWatchTicketEndpoint(this.gateway, config);
|
|
2228
|
+
}
|
|
2229
|
+
/**
|
|
2230
|
+
* A verify endpoint of your own that asks the recipient's wallet for you, so
|
|
2231
|
+
* the gateway polls you and never the wallet
|
|
2232
|
+
*/
|
|
2233
|
+
verify(config) {
|
|
2234
|
+
return lightningVerifyEndpoint(config);
|
|
2235
|
+
}
|
|
2236
|
+
/** The verify endpoint a bank rail is polled at, answering off your own statement */
|
|
2237
|
+
bankVerify(config) {
|
|
2238
|
+
return bankVerifyEndpoint(config);
|
|
2239
|
+
}
|
|
2240
|
+
/**
|
|
2241
|
+
* The whole webhook route: it answers the gateway's challenge, checks the
|
|
2242
|
+
* signature against the key the gateway publishes, refuses a settlement that
|
|
2243
|
+
* proves nothing, and calls you for the one that does.
|
|
2244
|
+
*
|
|
2245
|
+
* `export const POST = gateway.serve.webhook({ onSettled: fulfil })` is the
|
|
2246
|
+
* entire integration
|
|
2247
|
+
*/
|
|
2248
|
+
webhook(handlers) {
|
|
2249
|
+
const options = { toleranceSecs: handlers.toleranceSecs };
|
|
2250
|
+
return async (request) => {
|
|
2251
|
+
const credential = await this.credential(handlers);
|
|
2252
|
+
const challenge = await answerWebhookChallenge(request, credential, options);
|
|
2253
|
+
if (challenge !== null) {
|
|
2254
|
+
return challenge;
|
|
2255
|
+
}
|
|
2256
|
+
const settlement = await readSettlement(request, credential, options);
|
|
2257
|
+
if (settlement !== null) {
|
|
2258
|
+
return await acted(settlement, handlers);
|
|
2259
|
+
}
|
|
2260
|
+
const payment = await readPayment(request, credential, options);
|
|
2261
|
+
if (payment !== null) {
|
|
2262
|
+
await handlers.onPayment?.(payment);
|
|
2263
|
+
return new Response("ok");
|
|
2264
|
+
}
|
|
2265
|
+
return new Response("bad signature", { status: 401 });
|
|
2266
|
+
};
|
|
2267
|
+
}
|
|
2268
|
+
/** Verify a delivery and read the settlement out of it, null when it is not believable */
|
|
2269
|
+
async readSettlement(request, options) {
|
|
2270
|
+
return await readSettlement(request, await this.credential({}), options);
|
|
2271
|
+
}
|
|
2272
|
+
/** Verify a delivery and read the payment out of it, null when it is not believable */
|
|
2273
|
+
async readPayment(request, options) {
|
|
2274
|
+
return await readPayment(request, await this.credential({}), options);
|
|
2275
|
+
}
|
|
2276
|
+
/** Answer the challenge the gateway sends before it will post to a webhook of yours */
|
|
2277
|
+
async answerWebhookChallenge(request, options) {
|
|
2278
|
+
return await answerWebhookChallenge(request, await this.credential({}), options);
|
|
2279
|
+
}
|
|
2280
|
+
async credential(handlers) {
|
|
2281
|
+
return handlers.credential ?? { publicKey: await this.gateway.webhookKey() };
|
|
2282
|
+
}
|
|
2283
|
+
};
|
|
2284
|
+
async function acted(settlement, handlers) {
|
|
2285
|
+
if (carriesProof(settlement)) {
|
|
2286
|
+
await handlers.onSettled?.(settlement);
|
|
2287
|
+
return new Response("ok");
|
|
2288
|
+
}
|
|
2289
|
+
if (handlers.onUnproven === void 0) {
|
|
2290
|
+
return new Response("the recipient released no preimage", { status: 202 });
|
|
2291
|
+
}
|
|
2292
|
+
await handlers.onUnproven(settlement);
|
|
2293
|
+
return new Response("ok");
|
|
2294
|
+
}
|
|
2295
|
+
|
|
1386
2296
|
// src/client.ts
|
|
1387
2297
|
var TERMINAL = /* @__PURE__ */ new Set(["paid", "expired"]);
|
|
1388
2298
|
var RECONNECT_DELAY_MS = 3e3;
|
|
@@ -1402,6 +2312,13 @@ function backoffMs(firstDelay, attempt) {
|
|
|
1402
2312
|
const grown = Math.min(firstDelay * 2 ** (attempt - 1), RECONNECT_CAP_MS);
|
|
1403
2313
|
return Math.round(grown * (0.5 + Math.random() / 2));
|
|
1404
2314
|
}
|
|
2315
|
+
function gatewayAt(baseUrl) {
|
|
2316
|
+
const at = URL.canParse(baseUrl) ? new URL(baseUrl) : null;
|
|
2317
|
+
if (at === null || at.protocol !== "http:" && at.protocol !== "https:") {
|
|
2318
|
+
throw new Error(`${baseUrl} is not an http or https gateway url`);
|
|
2319
|
+
}
|
|
2320
|
+
return baseUrl.replace(/\/+$/, "");
|
|
2321
|
+
}
|
|
1405
2322
|
var ThunderBridge = class {
|
|
1406
2323
|
baseUrl;
|
|
1407
2324
|
verify;
|
|
@@ -1409,28 +2326,34 @@ var ThunderBridge = class {
|
|
|
1409
2326
|
secret;
|
|
1410
2327
|
strangers = null;
|
|
1411
2328
|
speaks = null;
|
|
2329
|
+
published = null;
|
|
2330
|
+
/** Everything this gateway lets you mount, from an LNURL endpoint to a webhook route */
|
|
2331
|
+
serve;
|
|
2332
|
+
/** One call per sale, whatever the rail moves */
|
|
2333
|
+
rails;
|
|
1412
2334
|
constructor(baseUrl, options) {
|
|
1413
|
-
this.baseUrl = baseUrl
|
|
2335
|
+
this.baseUrl = gatewayAt(baseUrl);
|
|
1414
2336
|
this.verify = options?.verify ?? true;
|
|
1415
2337
|
this.token = options?.token ?? null;
|
|
1416
2338
|
this.secret = options?.secret ?? null;
|
|
2339
|
+
this.serve = new Serve(this);
|
|
2340
|
+
this.rails = new Rails(this);
|
|
1417
2341
|
}
|
|
1418
2342
|
/**
|
|
1419
|
-
* Whether a token was given, which is
|
|
1420
|
-
*
|
|
1421
|
-
*
|
|
2343
|
+
* Whether a token was given to this instance, which is your side of the
|
|
2344
|
+
* arrangement and says nothing about the gateway's. `refusesStrangers` is the
|
|
2345
|
+
* one that asks the gateway, and it is the one to guard anything with
|
|
1422
2346
|
*/
|
|
1423
|
-
get
|
|
2347
|
+
get hasToken() {
|
|
1424
2348
|
return this.token !== null;
|
|
1425
2349
|
}
|
|
1426
2350
|
/**
|
|
1427
2351
|
* Whether the gateway turns away a caller carrying no token, asked by making
|
|
1428
|
-
* one unauthenticated read it would have to refuse. `
|
|
1429
|
-
* whether you configured
|
|
1430
|
-
*
|
|
1431
|
-
*
|
|
1432
|
-
*
|
|
1433
|
-
* open, so an unreachable gateway fails closed
|
|
2352
|
+
* one unauthenticated read it would have to refuse. `hasToken` answers only
|
|
2353
|
+
* whether you configured one, so a made-up token against a public instance
|
|
2354
|
+
* reads as yours and is not. Asked once and remembered, because an instance
|
|
2355
|
+
* does not change its mind. Anything other than a refusal counts as open, so
|
|
2356
|
+
* an unreachable gateway fails closed
|
|
1434
2357
|
*/
|
|
1435
2358
|
async refusesStrangers() {
|
|
1436
2359
|
this.strangers ??= fetch(`${this.baseUrl}/incoming-payments/${STRANGER_PROBE}`, {
|
|
@@ -1438,14 +2361,24 @@ var ThunderBridge = class {
|
|
|
1438
2361
|
}).then((answer) => answer.status === 401).catch(() => false);
|
|
1439
2362
|
return await this.strangers;
|
|
1440
2363
|
}
|
|
2364
|
+
/**
|
|
2365
|
+
* Ask to be paid for one thing. It mints the invoice, proves it came from the
|
|
2366
|
+
* address you asked for, draws the QR and hands back one object with a way to
|
|
2367
|
+
* wait for the money. This is `mint` plus the two things every caller does next
|
|
2368
|
+
*/
|
|
2369
|
+
async requestPayment(asked) {
|
|
2370
|
+
return paymentRequestOf(this, await this.mint(asked, asked), asked);
|
|
2371
|
+
}
|
|
1441
2372
|
/**
|
|
1442
2373
|
* Ask the gateway for an invoice payable to the first address on your list
|
|
1443
2374
|
* that can issue a provable one, throws `NoWalletAvailableError` when none can
|
|
1444
2375
|
* and `GatewayCheatError` when what comes back is not what you asked for
|
|
1445
2376
|
*/
|
|
1446
|
-
async
|
|
2377
|
+
async mint(charge, options) {
|
|
2378
|
+
const asked = await priced(charge);
|
|
1447
2379
|
const sent = createRequestBody(
|
|
1448
|
-
|
|
2380
|
+
asked,
|
|
2381
|
+
charge.webhookUrl,
|
|
1449
2382
|
options?.trigger ? sha256Hex(unguessable(options.trigger)) : null,
|
|
1450
2383
|
options?.replay
|
|
1451
2384
|
);
|
|
@@ -1461,9 +2394,9 @@ var ThunderBridge = class {
|
|
|
1461
2394
|
if (!response.ok) {
|
|
1462
2395
|
throw await problemFrom(response);
|
|
1463
2396
|
}
|
|
1464
|
-
const payment = await
|
|
2397
|
+
const payment = await mintedFrom(response);
|
|
1465
2398
|
if (this.verify) {
|
|
1466
|
-
await proveOrigin(payment,
|
|
2399
|
+
await proveOrigin(payment, asked);
|
|
1467
2400
|
}
|
|
1468
2401
|
return payment;
|
|
1469
2402
|
}
|
|
@@ -1474,8 +2407,8 @@ var ThunderBridge = class {
|
|
|
1474
2407
|
* whether a wallet returns a provable invoice cannot be known without asking
|
|
1475
2408
|
* it for one, and asking mints it
|
|
1476
2409
|
*/
|
|
1477
|
-
async
|
|
1478
|
-
const sent = quoteRequestBody(
|
|
2410
|
+
async quote(charge) {
|
|
2411
|
+
const sent = quoteRequestBody(await priced(charge));
|
|
1479
2412
|
const response = await fetch(`${this.baseUrl}/quotes`, {
|
|
1480
2413
|
method: "POST",
|
|
1481
2414
|
headers: await this.sending("/quotes", sent),
|
|
@@ -1493,13 +2426,19 @@ var ThunderBridge = class {
|
|
|
1493
2426
|
}
|
|
1494
2427
|
return quote;
|
|
1495
2428
|
}
|
|
1496
|
-
/** Read a payment back, null when the gateway has never heard of it */
|
|
1497
2429
|
/**
|
|
1498
|
-
* The key this gateway signs webhooks with when you registered none of your own
|
|
1499
|
-
*
|
|
1500
|
-
*
|
|
2430
|
+
* The key this gateway signs webhooks with when you registered none of your own.
|
|
2431
|
+
* `serve.webhook` reads it for you. Asked once and kept, because it is the same
|
|
2432
|
+
* for every instance in the cluster
|
|
1501
2433
|
*/
|
|
1502
2434
|
async webhookKey() {
|
|
2435
|
+
this.published ??= this.publishedKey().catch((refused) => {
|
|
2436
|
+
this.published = null;
|
|
2437
|
+
throw refused;
|
|
2438
|
+
});
|
|
2439
|
+
return await this.published;
|
|
2440
|
+
}
|
|
2441
|
+
async publishedKey() {
|
|
1503
2442
|
const response = await fetch(`${this.baseUrl}/webhook-key`);
|
|
1504
2443
|
if (!response.ok) {
|
|
1505
2444
|
throw await problemFrom(response);
|
|
@@ -1513,25 +2452,12 @@ var ThunderBridge = class {
|
|
|
1513
2452
|
}
|
|
1514
2453
|
return body.public_key;
|
|
1515
2454
|
}
|
|
1516
|
-
async getPayment(id) {
|
|
1517
|
-
const path = `/incoming-payments/${encodeURIComponent(id)}`;
|
|
1518
|
-
const response = await fetch(`${this.baseUrl}${path}`, {
|
|
1519
|
-
headers: await this.reading("GET", path)
|
|
1520
|
-
});
|
|
1521
|
-
if (response.status === 404) {
|
|
1522
|
-
return null;
|
|
1523
|
-
}
|
|
1524
|
-
if (!response.ok) {
|
|
1525
|
-
throw await problemFrom(response);
|
|
1526
|
-
}
|
|
1527
|
-
return this.checked(await paymentFrom(response));
|
|
1528
|
-
}
|
|
1529
2455
|
/**
|
|
1530
|
-
* Read
|
|
1531
|
-
*
|
|
1532
|
-
*
|
|
2456
|
+
* Read a payment back, null when the gateway has never heard of it. One method
|
|
2457
|
+
* for both sorts: `kind` says whether the gateway minted it or was handed it,
|
|
2458
|
+
* and the address, amount and invoice are null on one it was never told
|
|
1533
2459
|
*/
|
|
1534
|
-
async
|
|
2460
|
+
async payment(id) {
|
|
1535
2461
|
const path = `/incoming-payments/${encodeURIComponent(id)}`;
|
|
1536
2462
|
const response = await fetch(`${this.baseUrl}${path}`, {
|
|
1537
2463
|
headers: await this.reading("GET", path)
|
|
@@ -1539,17 +2465,10 @@ var ThunderBridge = class {
|
|
|
1539
2465
|
if (response.status === 404) {
|
|
1540
2466
|
return null;
|
|
1541
2467
|
}
|
|
1542
|
-
if (!response.ok) {
|
|
1543
|
-
throw await problemFrom(response);
|
|
1544
|
-
}
|
|
1545
|
-
const watched = triggerEventFromWire(await response.json().catch(() => null));
|
|
1546
|
-
if (watched === null) {
|
|
1547
|
-
throw new ProblemError({
|
|
1548
|
-
status: response.status,
|
|
1549
|
-
title: "The gateway answered with something that is not a payment"
|
|
1550
|
-
});
|
|
2468
|
+
if (!response.ok) {
|
|
2469
|
+
throw await problemFrom(response);
|
|
1551
2470
|
}
|
|
1552
|
-
return this.proven(
|
|
2471
|
+
return this.proven(await paymentFrom(response));
|
|
1553
2472
|
}
|
|
1554
2473
|
/**
|
|
1555
2474
|
* List what this gateway is watching, newest first. Only a gateway started
|
|
@@ -1560,7 +2479,7 @@ var ThunderBridge = class {
|
|
|
1560
2479
|
* Anything older than that window is not in the answer, and the list does not
|
|
1561
2480
|
* pretend otherwise
|
|
1562
2481
|
*/
|
|
1563
|
-
async
|
|
2482
|
+
async payments(limit) {
|
|
1564
2483
|
const path = `/incoming-payments${limit === void 0 ? "" : `?limit=${limit}`}`;
|
|
1565
2484
|
const response = await fetch(`${this.baseUrl}${path}`, {
|
|
1566
2485
|
headers: await this.reading("GET", path)
|
|
@@ -1569,7 +2488,7 @@ var ThunderBridge = class {
|
|
|
1569
2488
|
throw await problemFrom(response);
|
|
1570
2489
|
}
|
|
1571
2490
|
const body = await response.json().catch(() => null);
|
|
1572
|
-
const listed = Array.isArray(body?.payments) ? body.payments.map(
|
|
2491
|
+
const listed = Array.isArray(body?.payments) ? body.payments.map(paymentFromWire).filter((one) => one !== null) : null;
|
|
1573
2492
|
if (listed === null || typeof body?.settled_scanned !== "number") {
|
|
1574
2493
|
throw new ProblemError({
|
|
1575
2494
|
status: response.status,
|
|
@@ -1584,34 +2503,15 @@ var ThunderBridge = class {
|
|
|
1584
2503
|
* one that has answered is followed until its own expiry, so the wait always
|
|
1585
2504
|
* ends by itself
|
|
1586
2505
|
*/
|
|
1587
|
-
async
|
|
1588
|
-
const
|
|
1589
|
-
if (
|
|
1590
|
-
throw new ProblemError({
|
|
1591
|
-
status: 200,
|
|
1592
|
-
title: `payment ${id} is watched rather than minted here, read it with waitForWatched`
|
|
1593
|
-
});
|
|
1594
|
-
}
|
|
1595
|
-
return this.checked(payment);
|
|
1596
|
-
}
|
|
1597
|
-
/**
|
|
1598
|
-
* Follow a payment the gateway is only watching, one it did not mint, until it
|
|
1599
|
-
* is paid or expired.
|
|
1600
|
-
*
|
|
1601
|
-
* A watched payment carries no address, no amount and no invoice, because the
|
|
1602
|
-
* gateway was told none of them, so it reads back as the shape a trigger
|
|
1603
|
-
* streams rather than as a `Payment`. That is every bank transfer, and every
|
|
1604
|
-
* Lightning invoice registered with `watchPayment` instead of `createPayment`.
|
|
1605
|
-
*/
|
|
1606
|
-
async waitForWatched(id, options) {
|
|
1607
|
-
const watched = triggerEventFromWire(await this.followed(id, options));
|
|
1608
|
-
if (watched === null) {
|
|
2506
|
+
async settled(id, options) {
|
|
2507
|
+
const ended = paymentFromWire(await this.followed(id, options));
|
|
2508
|
+
if (ended === null) {
|
|
1609
2509
|
throw new ProblemError({
|
|
1610
2510
|
status: 200,
|
|
1611
2511
|
title: "The gateway answered with something that is not a payment"
|
|
1612
2512
|
});
|
|
1613
2513
|
}
|
|
1614
|
-
return this.proven(
|
|
2514
|
+
return this.proven(ended);
|
|
1615
2515
|
}
|
|
1616
2516
|
followed(id, options) {
|
|
1617
2517
|
const base = this.baseUrl.replace(/^http/, "ws");
|
|
@@ -1678,7 +2578,7 @@ var ThunderBridge = class {
|
|
|
1678
2578
|
opened.onmessage = (event) => {
|
|
1679
2579
|
try {
|
|
1680
2580
|
const frame = JSON.parse(String(event.data));
|
|
1681
|
-
const watched =
|
|
2581
|
+
const watched = paymentFromWire(frame);
|
|
1682
2582
|
if (watched === null) {
|
|
1683
2583
|
return;
|
|
1684
2584
|
}
|
|
@@ -1707,16 +2607,16 @@ var ThunderBridge = class {
|
|
|
1707
2607
|
* what you want is the one that arrives and nothing further from the other.
|
|
1708
2608
|
*
|
|
1709
2609
|
* A leg that expires is a loser, not a winner, which is the whole reason this is
|
|
1710
|
-
* not a race: `
|
|
2610
|
+
* not a race: `settled` ends on `paid` and on `expired` alike, and a
|
|
1711
2611
|
* Lightning invoice expires in an hour while a bank transfer takes days. `null`
|
|
1712
2612
|
* means every leg ended without being paid.
|
|
1713
2613
|
*
|
|
1714
2614
|
* Stopping the wait is not revoking the invoice. Nobody can revoke one, because
|
|
1715
2615
|
* the recipient's own wallet minted it, so a payer who pays the loser afterwards
|
|
1716
|
-
* really does pay twice and that shows up on `
|
|
1717
|
-
*
|
|
2616
|
+
* really does pay twice and that shows up on `follow` as a second settlement to
|
|
2617
|
+
* refund.
|
|
1718
2618
|
*/
|
|
1719
|
-
async
|
|
2619
|
+
async firstSettled(ids, options) {
|
|
1720
2620
|
if (ids.length === 0) {
|
|
1721
2621
|
return null;
|
|
1722
2622
|
}
|
|
@@ -1733,7 +2633,7 @@ var ThunderBridge = class {
|
|
|
1733
2633
|
}
|
|
1734
2634
|
};
|
|
1735
2635
|
for (const id of ids) {
|
|
1736
|
-
this.
|
|
2636
|
+
this.settled(id, { ...options, signal }).then((watched) => watched.status === "paid" ? resolve2(watched) : lost()).catch((failure) => {
|
|
1737
2637
|
refused ??= failure;
|
|
1738
2638
|
lost();
|
|
1739
2639
|
});
|
|
@@ -1753,10 +2653,10 @@ var ThunderBridge = class {
|
|
|
1753
2653
|
* rather than one recipient, which is what makes leaving it cheap. Anything
|
|
1754
2654
|
* the watcher needs goes in `sealed`, which the gateway cannot read
|
|
1755
2655
|
*/
|
|
1756
|
-
async
|
|
2656
|
+
async watch(handover) {
|
|
1757
2657
|
const sent = watchRequestBody(
|
|
1758
|
-
|
|
1759
|
-
|
|
2658
|
+
handover,
|
|
2659
|
+
handover.trigger ? sha256Hex(unguessable(handover.trigger)) : null
|
|
1760
2660
|
);
|
|
1761
2661
|
const response = await fetch(`${this.baseUrl}/watched-payments`, {
|
|
1762
2662
|
method: "POST",
|
|
@@ -1766,14 +2666,14 @@ var ThunderBridge = class {
|
|
|
1766
2666
|
if (!response.ok) {
|
|
1767
2667
|
throw await problemFrom(response);
|
|
1768
2668
|
}
|
|
1769
|
-
const watched =
|
|
2669
|
+
const watched = paymentFromWire(await response.json().catch(() => null));
|
|
1770
2670
|
if (watched === null) {
|
|
1771
2671
|
throw new ProblemError({
|
|
1772
2672
|
status: response.status,
|
|
1773
2673
|
title: "The gateway answered with something that is not a watched payment"
|
|
1774
2674
|
});
|
|
1775
2675
|
}
|
|
1776
|
-
const named = await this.nameFor(
|
|
2676
|
+
const named = await this.nameFor(handover.paymentHash);
|
|
1777
2677
|
if (named !== null && watched.id !== named) {
|
|
1778
2678
|
throw new GatewayCheatError("id_not_mine", watched.id);
|
|
1779
2679
|
}
|
|
@@ -1794,7 +2694,7 @@ var ThunderBridge = class {
|
|
|
1794
2694
|
* connect and then live, reconnecting on its own until the returned function
|
|
1795
2695
|
* is called. A trigger has no terminal state, so this never resolves
|
|
1796
2696
|
*/
|
|
1797
|
-
|
|
2697
|
+
follow(secret, options) {
|
|
1798
2698
|
const base = this.baseUrl.replace(/^http/, "ws");
|
|
1799
2699
|
const asked = options.replay === void 0 ? "" : `?replay=${options.replay}`;
|
|
1800
2700
|
const direct = `${base}/ws/triggers/${encodeURIComponent(unguessable(secret))}${asked}`;
|
|
@@ -1833,7 +2733,7 @@ var ThunderBridge = class {
|
|
|
1833
2733
|
};
|
|
1834
2734
|
socket.onmessage = (event) => {
|
|
1835
2735
|
try {
|
|
1836
|
-
const settled =
|
|
2736
|
+
const settled = paymentFromWire(JSON.parse(String(event.data)));
|
|
1837
2737
|
if (settled !== null) {
|
|
1838
2738
|
options.onPayment(this.proven(settled));
|
|
1839
2739
|
}
|
|
@@ -1855,13 +2755,13 @@ var ThunderBridge = class {
|
|
|
1855
2755
|
* A one minute pass onto one trigger's stream, for something that must hold
|
|
1856
2756
|
* neither the token nor the trigger secret. Mint it in a handler and answer
|
|
1857
2757
|
* with the ticket alone, because that is all a browser needs to connect and
|
|
1858
|
-
* all it can do anything with. `
|
|
2758
|
+
* all it can do anything with. `serve.watchTicket` is this method already
|
|
1859
2759
|
* wrapped in a route
|
|
1860
2760
|
*/
|
|
1861
|
-
async
|
|
2761
|
+
async ticket(trigger, options) {
|
|
1862
2762
|
return await this.mintedTicket({
|
|
1863
|
-
trigger_secret: unguessable(
|
|
1864
|
-
replay:
|
|
2763
|
+
trigger_secret: unguessable(trigger),
|
|
2764
|
+
replay: options?.replay
|
|
1865
2765
|
});
|
|
1866
2766
|
}
|
|
1867
2767
|
needsTicket(asked) {
|
|
@@ -1902,15 +2802,8 @@ var ThunderBridge = class {
|
|
|
1902
2802
|
this.speaks ??= callerKey(this.secret ?? "");
|
|
1903
2803
|
return await this.speaks;
|
|
1904
2804
|
}
|
|
1905
|
-
proven(
|
|
1906
|
-
|
|
1907
|
-
if (this.verify && unproven) {
|
|
1908
|
-
throw new GatewayCheatError("preimage_mismatch", settled.id);
|
|
1909
|
-
}
|
|
1910
|
-
return settled;
|
|
1911
|
-
}
|
|
1912
|
-
checked(payment) {
|
|
1913
|
-
if (this.verify && payment.status === "paid" && !isProvablyPaid(payment)) {
|
|
2805
|
+
proven(payment) {
|
|
2806
|
+
if (this.verify && payment.status === "paid" && !carriesProof(payment)) {
|
|
1914
2807
|
throw new GatewayCheatError("preimage_mismatch", payment.id);
|
|
1915
2808
|
}
|
|
1916
2809
|
return payment;
|
|
@@ -1926,11 +2819,21 @@ async function paymentFrom(response) {
|
|
|
1926
2819
|
}
|
|
1927
2820
|
return payment;
|
|
1928
2821
|
}
|
|
2822
|
+
async function mintedFrom(response) {
|
|
2823
|
+
const payment = mintedFromWire(await response.json().catch(() => null));
|
|
2824
|
+
if (payment === null) {
|
|
2825
|
+
throw new ProblemError({
|
|
2826
|
+
status: response.status,
|
|
2827
|
+
title: "The gateway answered with something that is not a minted payment"
|
|
2828
|
+
});
|
|
2829
|
+
}
|
|
2830
|
+
return payment;
|
|
2831
|
+
}
|
|
1929
2832
|
async function problemFrom(response) {
|
|
1930
2833
|
const problem = await response.json().catch(() => ({}));
|
|
1931
2834
|
const document = { ...problem, status: response.status };
|
|
1932
2835
|
if (isProblemType(problem, NO_WALLET_AVAILABLE)) {
|
|
1933
|
-
return new NoWalletAvailableError(document,
|
|
2836
|
+
return new NoWalletAvailableError(document, refusals2(problem.wallets));
|
|
1934
2837
|
}
|
|
1935
2838
|
if (isProblemType(problem, REQUEST_IN_FLIGHT)) {
|
|
1936
2839
|
return new IdempotencyConflictError(document, "request-in-flight");
|
|
@@ -1940,7 +2843,7 @@ async function problemFrom(response) {
|
|
|
1940
2843
|
}
|
|
1941
2844
|
return new ProblemError(document);
|
|
1942
2845
|
}
|
|
1943
|
-
function
|
|
2846
|
+
function refusals2(wallets) {
|
|
1944
2847
|
if (!Array.isArray(wallets)) {
|
|
1945
2848
|
return [];
|
|
1946
2849
|
}
|
|
@@ -1949,118 +2852,6 @@ function refusals(wallets) {
|
|
|
1949
2852
|
});
|
|
1950
2853
|
}
|
|
1951
2854
|
|
|
1952
|
-
// src/fio.ts
|
|
1953
|
-
var BASE_URL = "https://fioapi.fio.cz/v1/rest";
|
|
1954
|
-
var MINOR_UNITS = 100;
|
|
1955
|
-
var MILLIS = 1e3;
|
|
1956
|
-
var DATE_CHARS = "2026-08-04".length;
|
|
1957
|
-
var DEFAULT_MIN_INTERVAL_SECS = 30;
|
|
1958
|
-
var TOO_SOON = 409;
|
|
1959
|
-
var BOOKED_AT = "column0";
|
|
1960
|
-
var AMOUNT = "column1";
|
|
1961
|
-
var CURRENCY = "column14";
|
|
1962
|
-
var VARIABLE_SYMBOL = "column5";
|
|
1963
|
-
var USER_IDENTIFICATION = "column7";
|
|
1964
|
-
var MESSAGE_FOR_RECIPIENT = "column16";
|
|
1965
|
-
var PAYER_REFERENCE = "column27";
|
|
1966
|
-
var WHERE_A_PAYER_WRITES = [
|
|
1967
|
-
MESSAGE_FOR_RECIPIENT,
|
|
1968
|
-
VARIABLE_SYMBOL,
|
|
1969
|
-
USER_IDENTIFICATION,
|
|
1970
|
-
PAYER_REFERENCE
|
|
1971
|
-
];
|
|
1972
|
-
var UTC_OFFSET = /^[+-]\d{4}$/;
|
|
1973
|
-
function fioStatement(config) {
|
|
1974
|
-
const named = typeof config.token === "string" ? [config.token] : config.token;
|
|
1975
|
-
if (named.length === 0) {
|
|
1976
|
-
throw new Error("fioStatement needs at least one token to read with");
|
|
1977
|
-
}
|
|
1978
|
-
const usedAt = new Map(named.map((token) => [token, Number.NEGATIVE_INFINITY]));
|
|
1979
|
-
const tokenWindowMs = (config.minIntervalSecs ?? DEFAULT_MIN_INTERVAL_SECS) * MILLIS;
|
|
1980
|
-
const paceMs = tokenWindowMs / usedAt.size;
|
|
1981
|
-
let lastRead = Number.NEGATIVE_INFINITY;
|
|
1982
|
-
let credits = [];
|
|
1983
|
-
return async (sinceUnix) => {
|
|
1984
|
-
const now = Date.now();
|
|
1985
|
-
if (now - lastRead < paceMs) {
|
|
1986
|
-
return credits;
|
|
1987
|
-
}
|
|
1988
|
-
const idlest = longestUnused(usedAt);
|
|
1989
|
-
if (now - idlest.usedAt < tokenWindowMs) {
|
|
1990
|
-
return credits;
|
|
1991
|
-
}
|
|
1992
|
-
usedAt.set(idlest.token, now);
|
|
1993
|
-
lastRead = now;
|
|
1994
|
-
const url = [
|
|
1995
|
-
config.baseUrl ?? BASE_URL,
|
|
1996
|
-
"periods",
|
|
1997
|
-
idlest.token,
|
|
1998
|
-
asDate(sinceUnix),
|
|
1999
|
-
asDate(Math.floor(now / MILLIS)),
|
|
2000
|
-
"transactions.json"
|
|
2001
|
-
].join("/");
|
|
2002
|
-
const answer = await fetch(url, { headers: { accept: "application/json" } });
|
|
2003
|
-
if (answer.status === TOO_SOON) {
|
|
2004
|
-
throw new Error("fio refuses a second read of this token inside its 30 second window");
|
|
2005
|
-
}
|
|
2006
|
-
if (!answer.ok) {
|
|
2007
|
-
throw new Error(`fio answered ${answer.status} reading the statement`);
|
|
2008
|
-
}
|
|
2009
|
-
const read = await answer.json();
|
|
2010
|
-
credits = (read.accountStatement?.transactionList?.transaction ?? []).filter(isCredit).map(asCredit);
|
|
2011
|
-
return credits;
|
|
2012
|
-
};
|
|
2013
|
-
}
|
|
2014
|
-
function longestUnused(usedAt) {
|
|
2015
|
-
let token = "";
|
|
2016
|
-
let idleSince = Number.POSITIVE_INFINITY;
|
|
2017
|
-
for (const [candidate, at] of usedAt) {
|
|
2018
|
-
if (at < idleSince) {
|
|
2019
|
-
token = candidate;
|
|
2020
|
-
idleSince = at;
|
|
2021
|
-
}
|
|
2022
|
-
}
|
|
2023
|
-
return { token, usedAt: idleSince };
|
|
2024
|
-
}
|
|
2025
|
-
function isCredit(transaction) {
|
|
2026
|
-
return numberIn(transaction[AMOUNT]) > 0;
|
|
2027
|
-
}
|
|
2028
|
-
function asCredit(transaction) {
|
|
2029
|
-
return {
|
|
2030
|
-
amountMinor: Math.round(numberIn(transaction[AMOUNT]) * MINOR_UNITS),
|
|
2031
|
-
currency: textIn(transaction[CURRENCY]),
|
|
2032
|
-
reference: WHERE_A_PAYER_WRITES.map((column) => textIn(transaction[column])).filter((written) => written.length > 0).join(" "),
|
|
2033
|
-
bookedAt: bookedAtIn(transaction[BOOKED_AT])
|
|
2034
|
-
};
|
|
2035
|
-
}
|
|
2036
|
-
function bookedAtIn(cell) {
|
|
2037
|
-
const value = cell?.value;
|
|
2038
|
-
if (typeof value === "number") {
|
|
2039
|
-
return Math.floor(value / MILLIS);
|
|
2040
|
-
}
|
|
2041
|
-
const at = Date.parse(asIso8601(textIn(cell)));
|
|
2042
|
-
return Number.isFinite(at) ? Math.floor(at / MILLIS) : 0;
|
|
2043
|
-
}
|
|
2044
|
-
function asIso8601(booked) {
|
|
2045
|
-
const day = booked.slice(0, DATE_CHARS);
|
|
2046
|
-
const zone = booked.slice(DATE_CHARS);
|
|
2047
|
-
if (!UTC_OFFSET.test(zone)) {
|
|
2048
|
-
return `${day}T00:00:00Z`;
|
|
2049
|
-
}
|
|
2050
|
-
return `${day}T00:00:00${zone.slice(0, 3)}:${zone.slice(3)}`;
|
|
2051
|
-
}
|
|
2052
|
-
function numberIn(cell) {
|
|
2053
|
-
const value = Number(cell?.value ?? Number.NaN);
|
|
2054
|
-
return Number.isFinite(value) ? value : 0;
|
|
2055
|
-
}
|
|
2056
|
-
function textIn(cell) {
|
|
2057
|
-
const value = cell?.value;
|
|
2058
|
-
return value === null || value === void 0 ? "" : String(value).trim();
|
|
2059
|
-
}
|
|
2060
|
-
function asDate(unix) {
|
|
2061
|
-
return new Date(unix * MILLIS).toISOString().slice(0, DATE_CHARS);
|
|
2062
|
-
}
|
|
2063
|
-
|
|
2064
2855
|
// src/gateways.ts
|
|
2065
2856
|
var Gateways = class {
|
|
2066
2857
|
each;
|
|
@@ -2084,10 +2875,8 @@ var Gateways = class {
|
|
|
2084
2875
|
* carrying the first refusal, because one gateway that agreed is enough to be
|
|
2085
2876
|
* watched
|
|
2086
2877
|
*/
|
|
2087
|
-
async
|
|
2088
|
-
const asked = await Promise.allSettled(
|
|
2089
|
-
this.each.map((gateway) => gateway.watchPayment(params))
|
|
2090
|
-
);
|
|
2878
|
+
async watch(handover) {
|
|
2879
|
+
const asked = await Promise.allSettled(this.each.map((gateway) => gateway.watch(handover)));
|
|
2091
2880
|
const taken = [];
|
|
2092
2881
|
const refusals3 = [];
|
|
2093
2882
|
for (const [at, answer] of asked.entries()) {
|
|
@@ -2109,7 +2898,7 @@ var Gateways = class {
|
|
|
2109
2898
|
* saying so. When they all end without a payment, the first ending is the answer,
|
|
2110
2899
|
* and when they all fail, the first failure is thrown
|
|
2111
2900
|
*/
|
|
2112
|
-
async
|
|
2901
|
+
async settled(id, options) {
|
|
2113
2902
|
const stopLosers = new AbortController();
|
|
2114
2903
|
const signal = options?.signal ? AbortSignal.any([stopLosers.signal, options.signal]) : stopLosers.signal;
|
|
2115
2904
|
const unpaid = [];
|
|
@@ -2124,7 +2913,7 @@ var Gateways = class {
|
|
|
2124
2913
|
}
|
|
2125
2914
|
};
|
|
2126
2915
|
for (const gateway of this.each) {
|
|
2127
|
-
gateway.
|
|
2916
|
+
gateway.settled(id, { ...options, signal }).then((watched) => {
|
|
2128
2917
|
if (watched.status === "paid") {
|
|
2129
2918
|
return resolve2(watched);
|
|
2130
2919
|
}
|
|
@@ -2148,314 +2937,29 @@ var Gateways = class {
|
|
|
2148
2937
|
}
|
|
2149
2938
|
}
|
|
2150
2939
|
};
|
|
2151
|
-
|
|
2152
|
-
// src/price.ts
|
|
2153
|
-
var MSAT_PER_BTC2 = 1e11;
|
|
2154
|
-
var BASIS_POINTS = 1e4;
|
|
2155
|
-
var DEFAULT_HOLD_SECS = 60;
|
|
2156
|
-
var DEFAULT_MIN_VENUES = 2;
|
|
2157
|
-
var DEFAULT_MAX_SPREAD_BPS = 200;
|
|
2158
|
-
var MILLIS2 = 1e3;
|
|
2159
|
-
function coinbase(baseUrl = "https://api.coinbase.com") {
|
|
2160
|
-
return async (currency) => {
|
|
2161
|
-
const asked = currency.toUpperCase();
|
|
2162
|
-
const answer = await asJson(`${baseUrl}/v2/prices/BTC-${asked}/spot`, "coinbase");
|
|
2163
|
-
const quoted = answer.data;
|
|
2164
|
-
if (quoted?.currency !== asked) {
|
|
2165
|
-
throw new Error(`coinbase quoted ${quoted?.currency} when asked for ${asked}`);
|
|
2166
|
-
}
|
|
2167
|
-
return asMinor(quoted.amount, "coinbase", asked);
|
|
2168
|
-
};
|
|
2169
|
-
}
|
|
2170
|
-
function kraken(baseUrl = "https://api.kraken.com") {
|
|
2171
|
-
return async (currency) => {
|
|
2172
|
-
const asked = currency.toUpperCase();
|
|
2173
|
-
const answer = await asJson(`${baseUrl}/0/public/Ticker?pair=XBT${asked}`, "kraken");
|
|
2174
|
-
if (answer.error?.length) {
|
|
2175
|
-
throw new Error(`kraken refused XBT${asked}: ${answer.error[0]}`);
|
|
2176
|
-
}
|
|
2177
|
-
const pairs = Object.values(answer.result ?? {});
|
|
2178
|
-
if (pairs.length !== 1) {
|
|
2179
|
-
throw new Error(`kraken answered ${pairs.length} pairs for XBT${asked}`);
|
|
2180
|
-
}
|
|
2181
|
-
return asMinor(pairs[0]?.c?.[0], "kraken", asked);
|
|
2182
|
-
};
|
|
2183
|
-
}
|
|
2184
|
-
function bitstamp(baseUrl = "https://www.bitstamp.net") {
|
|
2185
|
-
return async (currency) => {
|
|
2186
|
-
const pair = `btc${currency.toLowerCase()}`;
|
|
2187
|
-
const answer = await asJson(`${baseUrl}/api/v2/ticker/${pair}/`, "bitstamp");
|
|
2188
|
-
if (Array.isArray(answer)) {
|
|
2189
|
-
throw new Error(`bitstamp has no ${pair} pair`);
|
|
2190
|
-
}
|
|
2191
|
-
return asMinor(answer.last, "bitstamp", currency);
|
|
2192
|
-
};
|
|
2193
|
-
}
|
|
2194
|
-
function coinmate(baseUrl = "https://coinmate.io") {
|
|
2195
|
-
return async (currency) => {
|
|
2196
|
-
const pair = `BTC_${currency.toUpperCase()}`;
|
|
2197
|
-
const answer = await asJson(`${baseUrl}/api/ticker?currencyPair=${pair}`, "coinmate");
|
|
2198
|
-
if (answer.error !== false) {
|
|
2199
|
-
throw new Error(`coinmate refused ${pair}: ${answer.errorMessage ?? "no reason"}`);
|
|
2200
|
-
}
|
|
2201
|
-
return asMinor(answer.data?.last, "coinmate", currency);
|
|
2202
|
-
};
|
|
2203
|
-
}
|
|
2204
|
-
function medianOf(tickers = [coinbase(), kraken(), bitstamp(), coinmate()], options = {}) {
|
|
2205
|
-
const holdFor = (options.holdForSecs ?? DEFAULT_HOLD_SECS) * MILLIS2;
|
|
2206
|
-
const minVenues = options.minVenues ?? DEFAULT_MIN_VENUES;
|
|
2207
|
-
const maxSpread = options.maxSpreadBps ?? DEFAULT_MAX_SPREAD_BPS;
|
|
2208
|
-
const held = /* @__PURE__ */ new Map();
|
|
2209
|
-
return async (currency) => {
|
|
2210
|
-
const asked = currency.toUpperCase();
|
|
2211
|
-
const remembered = held.get(asked);
|
|
2212
|
-
if (remembered && Date.now() - remembered.at < holdFor) {
|
|
2213
|
-
return remembered.price;
|
|
2214
|
-
}
|
|
2215
|
-
const answers = await Promise.allSettled(tickers.map((ticker) => ticker(asked)));
|
|
2216
|
-
const quoted = answers.filter((answer) => answer.status === "fulfilled").map((answer) => answer.value).sort((one, other) => one - other);
|
|
2217
|
-
if (quoted.length < minVenues) {
|
|
2218
|
-
throw new Error(
|
|
2219
|
-
`${quoted.length} of ${tickers.length} venues quoted ${asked}, ${minVenues} wanted: ${refusals2(answers)}`
|
|
2220
|
-
);
|
|
2221
|
-
}
|
|
2222
|
-
const spread = spreadBpsOf(quoted);
|
|
2223
|
-
if (spread > maxSpread) {
|
|
2224
|
-
throw new Error(
|
|
2225
|
-
`venues disagree on ${asked} by ${Math.round(spread)} bps, more than the ${maxSpread} allowed: ${quoted.join(", ")}`
|
|
2226
|
-
);
|
|
2227
|
-
}
|
|
2228
|
-
const price = middleOf(quoted);
|
|
2229
|
-
held.set(asked, { at: Date.now(), price });
|
|
2230
|
-
return price;
|
|
2231
|
-
};
|
|
2232
|
-
}
|
|
2233
|
-
function msatFor(amountMinor, priceMinorPerBtc, options = {}) {
|
|
2234
|
-
if (!Number.isInteger(amountMinor) || amountMinor <= 0) {
|
|
2235
|
-
throw new Error("amountMinor must be a whole number of minor units above zero");
|
|
2236
|
-
}
|
|
2237
|
-
if (!Number.isFinite(priceMinorPerBtc) || priceMinorPerBtc <= 0) {
|
|
2238
|
-
throw new Error(`${priceMinorPerBtc} is not a usable price`);
|
|
2239
|
-
}
|
|
2240
|
-
const spreadBps = options.spreadBps ?? 0;
|
|
2241
|
-
if (!Number.isInteger(spreadBps) || spreadBps < 0) {
|
|
2242
|
-
throw new Error("spreadBps must be a whole number of basis points, none or more");
|
|
2243
|
-
}
|
|
2244
|
-
const wanted = BigInt(amountMinor) * BigInt(MSAT_PER_BTC2) * BigInt(BASIS_POINTS + spreadBps);
|
|
2245
|
-
const per = BigInt(Math.round(priceMinorPerBtc)) * BigInt(BASIS_POINTS);
|
|
2246
|
-
const msat = (wanted + per - 1n) / per;
|
|
2247
|
-
if (msat > BigInt(Number.MAX_SAFE_INTEGER)) {
|
|
2248
|
-
throw new Error(`${amountMinor} at ${priceMinorPerBtc} is more millisatoshi than exist`);
|
|
2249
|
-
}
|
|
2250
|
-
return Number(msat);
|
|
2251
|
-
}
|
|
2252
|
-
async function asJson(url, venue) {
|
|
2253
|
-
const answer = await fetch(url, { headers: { accept: "application/json" } });
|
|
2254
|
-
if (!answer.ok) {
|
|
2255
|
-
throw new Error(`${venue} answered ${answer.status}`);
|
|
2256
|
-
}
|
|
2257
|
-
return answer.json();
|
|
2258
|
-
}
|
|
2259
|
-
function asMinor(quoted, venue, currency) {
|
|
2260
|
-
const major2 = Number(quoted);
|
|
2261
|
-
if (!Number.isFinite(major2) || major2 <= 0) {
|
|
2262
|
-
throw new Error(`${venue} quoted ${String(quoted)} for ${currency.toUpperCase()}`);
|
|
2263
|
-
}
|
|
2264
|
-
return Math.round(major2 * minorScaleOf(currency));
|
|
2265
|
-
}
|
|
2266
|
-
function spreadBpsOf(sorted) {
|
|
2267
|
-
const lowest = sorted[0] ?? 0;
|
|
2268
|
-
const highest = sorted[sorted.length - 1] ?? 0;
|
|
2269
|
-
return lowest === 0 ? Number.POSITIVE_INFINITY : (highest - lowest) / lowest * BASIS_POINTS;
|
|
2270
|
-
}
|
|
2271
|
-
function middleOf(sorted) {
|
|
2272
|
-
const middle = Math.floor(sorted.length / 2);
|
|
2273
|
-
if (sorted.length % 2 === 1) {
|
|
2274
|
-
return sorted[middle] ?? 0;
|
|
2275
|
-
}
|
|
2276
|
-
return Math.round(((sorted[middle - 1] ?? 0) + (sorted[middle] ?? 0)) / 2);
|
|
2277
|
-
}
|
|
2278
|
-
function refusals2(answers) {
|
|
2279
|
-
return answers.filter((answer) => answer.status === "rejected").map(
|
|
2280
|
-
(answer) => answer.reason instanceof Error ? answer.reason.message : String(answer.reason)
|
|
2281
|
-
).join("; ");
|
|
2282
|
-
}
|
|
2283
|
-
|
|
2284
|
-
// src/qr.ts
|
|
2285
|
-
import { encode } from "uqr";
|
|
2286
|
-
var SAFE_COLOR = /^#(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$|^rgba?\([\d.,%/\s]+\)$|^[a-z]+$/i;
|
|
2287
|
-
function encodeForQr(destination) {
|
|
2288
|
-
const scanned = isLnAddress(destination) ? destination : destination.toUpperCase();
|
|
2289
|
-
return `LIGHTNING:${scanned}`;
|
|
2290
|
-
}
|
|
2291
|
-
function invoiceToSvg(destination, options) {
|
|
2292
|
-
return svgOf(encodeForQr(destination), options);
|
|
2293
|
-
}
|
|
2294
|
-
function invoiceToDataUrl(destination, options) {
|
|
2295
|
-
return asDataUrl(invoiceToSvg(destination, options));
|
|
2296
|
-
}
|
|
2297
|
-
function lnurlToSvg(endpoint, options) {
|
|
2298
|
-
return svgOf(toLnurl(endpoint), options);
|
|
2299
|
-
}
|
|
2300
|
-
function lnurlToDataUrl(endpoint, options) {
|
|
2301
|
-
return asDataUrl(lnurlToSvg(endpoint, options));
|
|
2302
|
-
}
|
|
2303
|
-
function spdToSvg(spd, options) {
|
|
2304
|
-
return svgOf(spd, options);
|
|
2305
|
-
}
|
|
2306
|
-
function spdToDataUrl(spd, options) {
|
|
2307
|
-
return asDataUrl(spdToSvg(spd, options));
|
|
2308
|
-
}
|
|
2309
|
-
function qrToSvg(payload, options) {
|
|
2310
|
-
return svgOf(payload, options);
|
|
2311
|
-
}
|
|
2312
|
-
function qrToDataUrl(payload, options) {
|
|
2313
|
-
return asDataUrl(qrToSvg(payload, options));
|
|
2314
|
-
}
|
|
2315
|
-
function svgOf(scanned, options) {
|
|
2316
|
-
const size = options?.size ?? 256;
|
|
2317
|
-
const color = options?.color ?? "#000";
|
|
2318
|
-
if (!SAFE_COLOR.test(color)) {
|
|
2319
|
-
throw new Error(`Invalid color: ${color}`);
|
|
2320
|
-
}
|
|
2321
|
-
return renderSvg(scanned, size, color);
|
|
2322
|
-
}
|
|
2323
|
-
function asDataUrl(svg) {
|
|
2324
|
-
return `data:image/svg+xml,${encodeURIComponent(svg)}`;
|
|
2325
|
-
}
|
|
2326
|
-
function isLnAddress(destination) {
|
|
2327
|
-
const at = destination.indexOf("@");
|
|
2328
|
-
return at > 0 && destination.slice(at + 1).includes(".");
|
|
2329
|
-
}
|
|
2330
|
-
function renderSvg(content, size, color) {
|
|
2331
|
-
const { data } = encode(content);
|
|
2332
|
-
const modules = data.length;
|
|
2333
|
-
const cellSize = size / modules;
|
|
2334
|
-
let path = "";
|
|
2335
|
-
for (let y = 0; y < modules; y++) {
|
|
2336
|
-
for (let x = 0; x < modules; x++) {
|
|
2337
|
-
if (data[y][x]) {
|
|
2338
|
-
path += `M${x * cellSize},${y * cellSize}h${cellSize}v${cellSize}h-${cellSize}z`;
|
|
2339
|
-
}
|
|
2340
|
-
}
|
|
2341
|
-
}
|
|
2342
|
-
return [
|
|
2343
|
-
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${size} ${size}" width="${size}" height="${size}">`,
|
|
2344
|
-
`<rect width="100%" height="100%" fill="white"/>`,
|
|
2345
|
-
`<path d="${path}" fill="${color}"/>`,
|
|
2346
|
-
`</svg>`
|
|
2347
|
-
].join("");
|
|
2348
|
-
}
|
|
2349
|
-
|
|
2350
|
-
// src/rail.ts
|
|
2351
|
-
var BANK = "bank";
|
|
2352
|
-
var LIGHTNING = "lightning";
|
|
2353
|
-
function bankRail(config) {
|
|
2354
|
-
return async (order) => {
|
|
2355
|
-
const expiresAt = config.expiresAt(order);
|
|
2356
|
-
const transfer = await bankTransfer({
|
|
2357
|
-
gateway: config.gateway,
|
|
2358
|
-
secret: config.secret,
|
|
2359
|
-
iban: config.iban,
|
|
2360
|
-
verifyUrl: config.verifyUrl,
|
|
2361
|
-
reference: order.reference,
|
|
2362
|
-
amountMinor: order.amountMinor,
|
|
2363
|
-
currency: order.currency,
|
|
2364
|
-
expiresAt,
|
|
2365
|
-
trigger: config.trigger,
|
|
2366
|
-
replay: config.replay,
|
|
2367
|
-
sealed: await config.sealed?.(order),
|
|
2368
|
-
variableSymbol: config.variableSymbol?.(order),
|
|
2369
|
-
webhookUrl: config.webhookUrl,
|
|
2370
|
-
allowPublicGateway: config.allowPublicGateway
|
|
2371
|
-
});
|
|
2372
|
-
return {
|
|
2373
|
-
id: transfer.id,
|
|
2374
|
-
rail: config.name ?? BANK,
|
|
2375
|
-
scan: transfer.spd,
|
|
2376
|
-
qr: transfer.spd,
|
|
2377
|
-
expiresAt
|
|
2378
|
-
};
|
|
2379
|
-
};
|
|
2380
|
-
}
|
|
2381
|
-
function lightningRail(config) {
|
|
2382
|
-
return async (order) => {
|
|
2383
|
-
const payment = await config.gateway.createPayment(
|
|
2384
|
-
{
|
|
2385
|
-
lnAddresses: config.lnAddresses,
|
|
2386
|
-
amountMsat: await config.amountMsat(order),
|
|
2387
|
-
webhookUrl: config.webhookUrl
|
|
2388
|
-
},
|
|
2389
|
-
{
|
|
2390
|
-
idempotencyKey: config.idempotencyKey?.(order),
|
|
2391
|
-
trigger: config.trigger,
|
|
2392
|
-
replay: config.replay
|
|
2393
|
-
}
|
|
2394
|
-
);
|
|
2395
|
-
return {
|
|
2396
|
-
id: payment.id,
|
|
2397
|
-
rail: config.name ?? LIGHTNING,
|
|
2398
|
-
scan: payment.bolt11,
|
|
2399
|
-
qr: encodeForQr(payment.bolt11),
|
|
2400
|
-
expiresAt: payment.expiresAt
|
|
2401
|
-
};
|
|
2402
|
-
};
|
|
2403
|
-
}
|
|
2404
2940
|
export {
|
|
2941
|
+
AmountError,
|
|
2405
2942
|
GatewayCheatError,
|
|
2406
2943
|
Gateways,
|
|
2407
|
-
IDEMPOTENCY_KEY_REUSED,
|
|
2408
2944
|
IdempotencyConflictError,
|
|
2409
|
-
NO_WALLET_AVAILABLE,
|
|
2410
2945
|
NoWalletAvailableError,
|
|
2411
|
-
PAYMENT_ALREADY_WATCHED,
|
|
2412
2946
|
ProblemError,
|
|
2413
|
-
REQUEST_IN_FLIGHT,
|
|
2414
2947
|
ThunderBridge,
|
|
2415
2948
|
UnverifiedRecipientError,
|
|
2416
2949
|
WrapRefusedError,
|
|
2417
2950
|
answerVerifyChallenge,
|
|
2418
|
-
|
|
2419
|
-
answerWebhookChallenge,
|
|
2420
|
-
answerWebhookChallengeRequest,
|
|
2421
|
-
bankRail,
|
|
2422
|
-
bankTransfer,
|
|
2423
|
-
bankVerifyEndpoint,
|
|
2424
|
-
bitstamp,
|
|
2425
|
-
coinbase,
|
|
2426
|
-
coinmate,
|
|
2951
|
+
carriesProof,
|
|
2427
2952
|
decodeInvoice,
|
|
2428
|
-
|
|
2429
|
-
|
|
2430
|
-
|
|
2431
|
-
isProblemType,
|
|
2432
|
-
isProvablyPaid,
|
|
2433
|
-
isProvablySettled,
|
|
2434
|
-
kraken,
|
|
2435
|
-
lightningRail,
|
|
2436
|
-
lnurlToDataUrl,
|
|
2437
|
-
lnurlToSvg,
|
|
2438
|
-
medianOf,
|
|
2439
|
-
minorScaleOf,
|
|
2440
|
-
minorUnitsOf,
|
|
2441
|
-
msatFor,
|
|
2442
|
-
parseSettlement,
|
|
2443
|
-
parseSettlementRequest,
|
|
2444
|
-
parseWatchedWebhook,
|
|
2445
|
-
parseWatchedWebhookRequest,
|
|
2446
|
-
parseWebhook,
|
|
2447
|
-
parseWebhookRequest,
|
|
2953
|
+
fiat,
|
|
2954
|
+
invoiceFrom,
|
|
2955
|
+
msat,
|
|
2448
2956
|
preimageMatchesHash,
|
|
2449
2957
|
proveOrigin,
|
|
2450
2958
|
proveSettlement,
|
|
2451
2959
|
proveWrapped,
|
|
2452
|
-
|
|
2453
|
-
|
|
2960
|
+
relayedVerifyUrl,
|
|
2961
|
+
sats,
|
|
2454
2962
|
seal,
|
|
2455
|
-
spdToDataUrl,
|
|
2456
|
-
spdToSvg,
|
|
2457
|
-
toLnurl,
|
|
2458
2963
|
unseal,
|
|
2459
|
-
verifyWebhookSignature,
|
|
2460
2964
|
wrapFeeCeiling
|
|
2461
2965
|
};
|