thunder-bridge 1.4.2 → 2.1.1
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 +145 -133
- package/dist/bank-CEIuNhV_.d.cts +896 -0
- package/dist/bank-M8flcMks.d.ts +896 -0
- package/dist/bank.cjs +338 -0
- package/dist/bank.d.cts +46 -0
- package/dist/bank.d.ts +46 -0
- package/dist/bank.js +310 -0
- package/dist/errors-DriqW-lp.d.cts +126 -0
- package/dist/errors-ZEUT-MhO.d.ts +126 -0
- package/dist/index.cjs +2108 -1235
- package/dist/index.d.cts +13 -505
- package/dist/index.d.ts +13 -505
- package/dist/index.js +2088 -1193
- package/dist/{server.cjs → nwc.cjs} +376 -720
- package/dist/nwc.d.cts +122 -0
- package/dist/nwc.d.ts +122 -0
- package/dist/{server.js → nwc.js} +368 -695
- 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 +259 -0
- package/dist/qr.d.cts +1 -0
- package/dist/qr.d.ts +1 -0
- package/dist/qr.js +224 -0
- package/dist/types-DYZ9EkmJ.d.cts +261 -0
- package/dist/types-DYZ9EkmJ.d.ts +261 -0
- package/openapi.yaml +29 -39
- 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.cjs
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
+
var __create = Object.create;
|
|
2
3
|
var __defProp = Object.defineProperty;
|
|
3
4
|
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
5
|
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
5
7
|
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
8
|
var __export = (target, all) => {
|
|
7
9
|
for (var name in all)
|
|
@@ -15,69 +17,45 @@ var __copyProps = (to, from, except, desc) => {
|
|
|
15
17
|
}
|
|
16
18
|
return to;
|
|
17
19
|
};
|
|
20
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
21
|
+
// If the importer is in node compatibility mode or this is not an ESM
|
|
22
|
+
// file that has been converted to a CommonJS file using a Babel-
|
|
23
|
+
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
24
|
+
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
25
|
+
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
26
|
+
mod
|
|
27
|
+
));
|
|
18
28
|
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
29
|
|
|
20
30
|
// src/index.ts
|
|
21
|
-
var
|
|
22
|
-
__export(
|
|
31
|
+
var src_exports = {};
|
|
32
|
+
__export(src_exports, {
|
|
33
|
+
AmountError: () => AmountError,
|
|
23
34
|
GatewayCheatError: () => GatewayCheatError,
|
|
24
35
|
Gateways: () => Gateways,
|
|
25
|
-
IDEMPOTENCY_KEY_REUSED: () => IDEMPOTENCY_KEY_REUSED,
|
|
26
36
|
IdempotencyConflictError: () => IdempotencyConflictError,
|
|
27
|
-
NO_WALLET_AVAILABLE: () => NO_WALLET_AVAILABLE,
|
|
28
37
|
NoWalletAvailableError: () => NoWalletAvailableError,
|
|
29
|
-
PAYMENT_ALREADY_WATCHED: () => PAYMENT_ALREADY_WATCHED,
|
|
30
38
|
ProblemError: () => ProblemError,
|
|
31
|
-
REQUEST_IN_FLIGHT: () => REQUEST_IN_FLIGHT,
|
|
32
39
|
ThunderBridge: () => ThunderBridge,
|
|
33
40
|
UnverifiedRecipientError: () => UnverifiedRecipientError,
|
|
34
41
|
WrapRefusedError: () => WrapRefusedError,
|
|
35
42
|
answerVerifyChallenge: () => answerVerifyChallenge,
|
|
36
|
-
|
|
37
|
-
answerWebhookChallenge: () => answerWebhookChallenge,
|
|
38
|
-
answerWebhookChallengeRequest: () => answerWebhookChallengeRequest,
|
|
39
|
-
bankRail: () => bankRail,
|
|
40
|
-
bankTransfer: () => bankTransfer,
|
|
41
|
-
bankVerifyEndpoint: () => bankVerifyEndpoint,
|
|
42
|
-
bitstamp: () => bitstamp,
|
|
43
|
-
coinbase: () => coinbase,
|
|
44
|
-
coinmate: () => coinmate,
|
|
43
|
+
carriesProof: () => carriesProof,
|
|
45
44
|
decodeInvoice: () => decodeInvoice,
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
isProblemType: () => isProblemType,
|
|
50
|
-
isProvablyPaid: () => isProvablyPaid,
|
|
51
|
-
isProvablySettled: () => isProvablySettled,
|
|
52
|
-
kraken: () => kraken,
|
|
53
|
-
lightningRail: () => lightningRail,
|
|
54
|
-
lnurlToDataUrl: () => lnurlToDataUrl,
|
|
55
|
-
lnurlToSvg: () => lnurlToSvg,
|
|
56
|
-
medianOf: () => medianOf,
|
|
57
|
-
minorScaleOf: () => minorScaleOf,
|
|
58
|
-
minorUnitsOf: () => minorUnitsOf,
|
|
59
|
-
msatFor: () => msatFor,
|
|
60
|
-
parseSettlement: () => parseSettlement,
|
|
61
|
-
parseSettlementRequest: () => parseSettlementRequest,
|
|
62
|
-
parseWatchedWebhook: () => parseWatchedWebhook,
|
|
63
|
-
parseWatchedWebhookRequest: () => parseWatchedWebhookRequest,
|
|
64
|
-
parseWebhook: () => parseWebhook,
|
|
65
|
-
parseWebhookRequest: () => parseWebhookRequest,
|
|
45
|
+
fiat: () => fiat,
|
|
46
|
+
invoiceFrom: () => invoiceFrom,
|
|
47
|
+
msat: () => msat,
|
|
66
48
|
preimageMatchesHash: () => preimageMatchesHash,
|
|
67
49
|
proveOrigin: () => proveOrigin,
|
|
68
50
|
proveSettlement: () => proveSettlement,
|
|
69
51
|
proveWrapped: () => proveWrapped,
|
|
70
|
-
|
|
71
|
-
|
|
52
|
+
relayedVerifyUrl: () => relayedVerifyUrl,
|
|
53
|
+
sats: () => sats,
|
|
72
54
|
seal: () => seal,
|
|
73
|
-
spdToDataUrl: () => spdToDataUrl,
|
|
74
|
-
spdToSvg: () => spdToSvg,
|
|
75
|
-
toLnurl: () => toLnurl,
|
|
76
55
|
unseal: () => unseal,
|
|
77
|
-
verifyWebhookSignature: () => verifyWebhookSignature,
|
|
78
56
|
wrapFeeCeiling: () => wrapFeeCeiling
|
|
79
57
|
});
|
|
80
|
-
module.exports = __toCommonJS(
|
|
58
|
+
module.exports = __toCommonJS(src_exports);
|
|
81
59
|
|
|
82
60
|
// ../core/bytes.ts
|
|
83
61
|
function bytesToHex(bytes) {
|
|
@@ -94,8 +72,8 @@ function hexToBytes(hex) {
|
|
|
94
72
|
}
|
|
95
73
|
return bytes;
|
|
96
74
|
}
|
|
97
|
-
function isHex(
|
|
98
|
-
return /^[0-9a-f]+$/i.test(
|
|
75
|
+
function isHex(text3) {
|
|
76
|
+
return /^[0-9a-f]+$/i.test(text3) && text3.length % 2 === 0;
|
|
99
77
|
}
|
|
100
78
|
|
|
101
79
|
// ../core/sha256.ts
|
|
@@ -189,8 +167,8 @@ function sha256(bytes) {
|
|
|
189
167
|
}
|
|
190
168
|
return asBytes(state);
|
|
191
169
|
}
|
|
192
|
-
function sha256Hex(
|
|
193
|
-
return bytesToHex(sha256(new TextEncoder().encode(
|
|
170
|
+
function sha256Hex(text3) {
|
|
171
|
+
return bytesToHex(sha256(new TextEncoder().encode(text3)));
|
|
194
172
|
}
|
|
195
173
|
function withPadding(bytes) {
|
|
196
174
|
const padded = new Uint8Array(
|
|
@@ -256,6 +234,7 @@ function rotate(value, by) {
|
|
|
256
234
|
|
|
257
235
|
// ../core/bolt11.ts
|
|
258
236
|
var BECH32_CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l";
|
|
237
|
+
var BECH32_GENERATOR = [996825010, 642813549, 513874426, 1027748829, 705979059];
|
|
259
238
|
var CHECKSUM_WORDS = 6;
|
|
260
239
|
var TIMESTAMP_WORDS = 7;
|
|
261
240
|
var SIGNATURE_WORDS = 104;
|
|
@@ -273,18 +252,27 @@ var HRP_MULTIPLIER_MSAT = {
|
|
|
273
252
|
n: MSAT_PER_BTC / 1e9,
|
|
274
253
|
p: MSAT_PER_BTC / 1e12
|
|
275
254
|
};
|
|
276
|
-
var
|
|
255
|
+
var HRP = /^ln(?:bcrt|bc|tbs|tb|sb)(?:(\d+)([munp]?))?$/;
|
|
277
256
|
function decodeInvoice(bolt11) {
|
|
278
257
|
const parts = splitBech32(bolt11);
|
|
279
258
|
if (parts === null) {
|
|
280
|
-
return {
|
|
259
|
+
return {
|
|
260
|
+
paymentHash: null,
|
|
261
|
+
descriptionHash: null,
|
|
262
|
+
amountMsat: null,
|
|
263
|
+
issuedAt: null,
|
|
264
|
+
expiresAt: null
|
|
265
|
+
};
|
|
281
266
|
}
|
|
282
267
|
const tagged = taggedFields(parts.words);
|
|
268
|
+
const issuedAt = issuedAtOf(parts.words);
|
|
269
|
+
const expiry = tagged.get(EXPIRY_TAG);
|
|
283
270
|
return {
|
|
284
271
|
paymentHash: hexTag(tagged, PAYMENT_HASH_TAG, PAYMENT_HASH_WORDS),
|
|
285
272
|
descriptionHash: hexTag(tagged, DESCRIPTION_HASH_TAG, DESCRIPTION_HASH_WORDS),
|
|
286
273
|
amountMsat: amountFromHrp(parts.hrp),
|
|
287
|
-
|
|
274
|
+
issuedAt,
|
|
275
|
+
expiresAt: issuedAt === null ? null : issuedAt + (expiry ? readNumber(expiry) : DEFAULT_EXPIRY_SECS)
|
|
288
276
|
};
|
|
289
277
|
}
|
|
290
278
|
function preimageMatchesHash(preimage, paymentHash) {
|
|
@@ -295,7 +283,7 @@ function preimageMatchesHash(preimage, paymentHash) {
|
|
|
295
283
|
}
|
|
296
284
|
function splitBech32(bolt11) {
|
|
297
285
|
const lower = bolt11.toLowerCase();
|
|
298
|
-
if (isBolt12Encoding(lower)) {
|
|
286
|
+
if (isBolt12Encoding(lower) || bolt11 !== lower && bolt11 !== bolt11.toUpperCase()) {
|
|
299
287
|
return null;
|
|
300
288
|
}
|
|
301
289
|
const separator = lower.lastIndexOf("1");
|
|
@@ -314,7 +302,28 @@ function splitBech32(bolt11) {
|
|
|
314
302
|
}
|
|
315
303
|
words.push(value);
|
|
316
304
|
}
|
|
317
|
-
|
|
305
|
+
const hrp = lower.slice(0, separator);
|
|
306
|
+
if (!HRP.test(hrp) || bech32Polymod([...expandedHrp(hrp), ...words]) !== 1) {
|
|
307
|
+
return null;
|
|
308
|
+
}
|
|
309
|
+
return { hrp, words: words.slice(0, -CHECKSUM_WORDS) };
|
|
310
|
+
}
|
|
311
|
+
function expandedHrp(hrp) {
|
|
312
|
+
const codes = [...hrp].map((char) => char.charCodeAt(0));
|
|
313
|
+
return [...codes.map((code) => code >> 5), 0, ...codes.map((code) => code & 31)];
|
|
314
|
+
}
|
|
315
|
+
function bech32Polymod(values) {
|
|
316
|
+
let checksum = 1;
|
|
317
|
+
for (const value of values) {
|
|
318
|
+
const top = checksum >> 25;
|
|
319
|
+
checksum = (checksum & 33554431) << 5 ^ value;
|
|
320
|
+
for (let bit = 0; bit < 5; bit++) {
|
|
321
|
+
if (top >> bit & 1) {
|
|
322
|
+
checksum ^= BECH32_GENERATOR[bit];
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
return checksum;
|
|
318
327
|
}
|
|
319
328
|
function isBolt12Encoding(lower) {
|
|
320
329
|
return lower.startsWith("lno") || lower.startsWith("lni") || lower.startsWith("lnr");
|
|
@@ -344,19 +353,17 @@ function hexTag(fields, tag, expectedWords) {
|
|
|
344
353
|
}
|
|
345
354
|
return bytesToHex(wordsToBytes(words));
|
|
346
355
|
}
|
|
347
|
-
function
|
|
356
|
+
function issuedAtOf(words) {
|
|
348
357
|
if (words.length < TIMESTAMP_WORDS + SIGNATURE_WORDS) {
|
|
349
358
|
return null;
|
|
350
359
|
}
|
|
351
|
-
|
|
352
|
-
return readNumber(words.slice(0, TIMESTAMP_WORDS)) + (expiry ? readNumber(expiry) : DEFAULT_EXPIRY_SECS);
|
|
360
|
+
return readNumber(words.slice(0, TIMESTAMP_WORDS));
|
|
353
361
|
}
|
|
354
362
|
function amountFromHrp(hrp) {
|
|
355
|
-
const
|
|
356
|
-
if (
|
|
363
|
+
const [, digits, multiplier] = HRP.exec(hrp) ?? [];
|
|
364
|
+
if (digits === void 0 || multiplier === "p" && !digits.endsWith("0")) {
|
|
357
365
|
return null;
|
|
358
366
|
}
|
|
359
|
-
const [, digits, multiplier] = match;
|
|
360
367
|
return multiplier ? Number(digits) * HRP_MULTIPLIER_MSAT[multiplier] : Number(digits) * MSAT_PER_BTC;
|
|
361
368
|
}
|
|
362
369
|
function readNumber(words) {
|
|
@@ -377,180 +384,74 @@ function wordsToBytes(words) {
|
|
|
377
384
|
return Uint8Array.from(bytes);
|
|
378
385
|
}
|
|
379
386
|
|
|
380
|
-
// ../core/
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
try {
|
|
393
|
-
return new URL(one).origin === new URL(other).origin;
|
|
394
|
-
} catch {
|
|
395
|
-
return false;
|
|
396
|
-
}
|
|
397
|
-
}
|
|
398
|
-
function publicAddress(literal) {
|
|
399
|
-
return literal.includes(":") ? ipv6IsGlobal(literal) : ipv4IsGlobal(literal);
|
|
400
|
-
}
|
|
401
|
-
function hostIsPublic(host) {
|
|
402
|
-
if (host.startsWith("[")) {
|
|
403
|
-
return publicAddress(host.slice(1, -1));
|
|
404
|
-
}
|
|
405
|
-
const name = host.replace(/\.$/, "");
|
|
406
|
-
if (/^\d+\.\d+\.\d+\.\d+$/.test(name)) {
|
|
407
|
-
return publicAddress(name);
|
|
408
|
-
}
|
|
409
|
-
const labels = name.split(".");
|
|
410
|
-
if (labels.length < 2 || labels.some((label) => label === "")) {
|
|
411
|
-
return false;
|
|
412
|
-
}
|
|
413
|
-
return !PRIVATE_SUFFIXES.includes(labels[labels.length - 1]);
|
|
414
|
-
}
|
|
415
|
-
function ipv4IsGlobal(literal) {
|
|
416
|
-
const parts = literal.split(".").map(Number);
|
|
417
|
-
if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) {
|
|
418
|
-
return false;
|
|
419
|
-
}
|
|
420
|
-
const [a, b] = parts;
|
|
421
|
-
if (a === 0 || a === 10 || a === 127 || a >= 240) {
|
|
422
|
-
return false;
|
|
423
|
-
}
|
|
424
|
-
if (a === 100 && b >= 64 && b < 128) {
|
|
425
|
-
return false;
|
|
426
|
-
}
|
|
427
|
-
if (a === 169 && b === 254) {
|
|
428
|
-
return false;
|
|
429
|
-
}
|
|
430
|
-
if (a === 172 && b >= 16 && b < 32) {
|
|
431
|
-
return false;
|
|
432
|
-
}
|
|
433
|
-
if (a === 192 && b === 168) {
|
|
434
|
-
return false;
|
|
435
|
-
}
|
|
436
|
-
return true;
|
|
387
|
+
// ../core/hmac.ts
|
|
388
|
+
async function hmacHex(secret, payload) {
|
|
389
|
+
const raw = typeof secret === "string" ? new TextEncoder().encode(secret) : secret;
|
|
390
|
+
const key = await crypto.subtle.importKey(
|
|
391
|
+
"raw",
|
|
392
|
+
asBuffer(raw),
|
|
393
|
+
{ name: "HMAC", hash: "SHA-256" },
|
|
394
|
+
false,
|
|
395
|
+
["sign"]
|
|
396
|
+
);
|
|
397
|
+
const message = typeof payload === "string" ? new TextEncoder().encode(payload) : payload;
|
|
398
|
+
return bytesToHex(new Uint8Array(await crypto.subtle.sign("HMAC", key, asBuffer(message))));
|
|
437
399
|
}
|
|
438
|
-
function
|
|
439
|
-
|
|
440
|
-
if (groups.length !== 8 || groups.some(Number.isNaN)) {
|
|
441
|
-
return false;
|
|
442
|
-
}
|
|
443
|
-
const leading = groups.slice(0, 5).every((group) => group === 0);
|
|
444
|
-
if (leading && groups[5] === 0 && groups[6] === 0 && groups[7] <= 1) {
|
|
445
|
-
return false;
|
|
446
|
-
}
|
|
447
|
-
if (leading && groups[5] === 65535) {
|
|
448
|
-
const [high, low] = [groups[6], groups[7]];
|
|
449
|
-
return ipv4IsGlobal(`${high >> 8}.${high & 255}.${low >> 8}.${low & 255}`);
|
|
450
|
-
}
|
|
451
|
-
const first = groups[0];
|
|
452
|
-
if ((first & 65024) === 64512) {
|
|
453
|
-
return false;
|
|
454
|
-
}
|
|
455
|
-
if ((first & 65472) === 65152) {
|
|
400
|
+
function equalInConstantTime(one, other) {
|
|
401
|
+
if (one.length !== other.length) {
|
|
456
402
|
return false;
|
|
457
403
|
}
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
const [head = "", tail] = address.split("::");
|
|
462
|
-
const left = head.split(":").filter((group) => group !== "");
|
|
463
|
-
const right = (tail ?? "").split(":").filter((group) => group !== "");
|
|
464
|
-
const gap = tail === void 0 ? [] : Array(8 - left.length - right.length).fill("0");
|
|
465
|
-
return [...left, ...gap, ...right].map((group) => parseInt(group, 16));
|
|
466
|
-
}
|
|
467
|
-
|
|
468
|
-
// ../core/lnurl.ts
|
|
469
|
-
var BECH32_CHARSET2 = "qpzry9x8gf2tvdw0s3jn54khce6mua7l";
|
|
470
|
-
var BECH32_GENERATOR = [996825010, 642813549, 513874426, 1027748829, 705979059];
|
|
471
|
-
var CHECKSUM_SLOTS = [0, 1, 2, 3, 4, 5];
|
|
472
|
-
var LNURL_HRP = "lnurl";
|
|
473
|
-
function toLnurl(endpoint) {
|
|
474
|
-
const scheme = parsedScheme(endpoint);
|
|
475
|
-
if (scheme !== "https:" && scheme !== "http:") {
|
|
476
|
-
throw new Error(`${endpoint} is not an http or https URL`);
|
|
477
|
-
}
|
|
478
|
-
const words = toWords(new TextEncoder().encode(endpoint));
|
|
479
|
-
const data = [...words, ...checksumWords(LNURL_HRP, words)];
|
|
480
|
-
return `${LNURL_HRP}1${data.map((word) => BECH32_CHARSET2[word]).join("")}`.toUpperCase();
|
|
481
|
-
}
|
|
482
|
-
function parsedScheme(endpoint) {
|
|
483
|
-
try {
|
|
484
|
-
return new URL(endpoint).protocol;
|
|
485
|
-
} catch {
|
|
486
|
-
return null;
|
|
404
|
+
let difference = 0;
|
|
405
|
+
for (let at = 0; at < one.length; at++) {
|
|
406
|
+
difference |= one.charCodeAt(at) ^ other.charCodeAt(at);
|
|
487
407
|
}
|
|
408
|
+
return difference === 0;
|
|
488
409
|
}
|
|
489
|
-
function
|
|
490
|
-
|
|
491
|
-
let accumulator = 0;
|
|
492
|
-
let bits = 0;
|
|
493
|
-
for (const byte of bytes) {
|
|
494
|
-
accumulator = accumulator << 8 | byte;
|
|
495
|
-
bits += 8;
|
|
496
|
-
while (bits >= 5) {
|
|
497
|
-
bits -= 5;
|
|
498
|
-
words.push(accumulator >> bits & 31);
|
|
499
|
-
}
|
|
500
|
-
}
|
|
501
|
-
if (bits > 0) {
|
|
502
|
-
words.push(accumulator << 5 - bits & 31);
|
|
503
|
-
}
|
|
504
|
-
return words;
|
|
505
|
-
}
|
|
506
|
-
function checksumWords(hrp, words) {
|
|
507
|
-
const expanded = [...hrp].map((char) => char.charCodeAt(0));
|
|
508
|
-
const values = [
|
|
509
|
-
...expanded.map((code) => code >> 5),
|
|
510
|
-
0,
|
|
511
|
-
...expanded.map((code) => code & 31),
|
|
512
|
-
...words,
|
|
513
|
-
...CHECKSUM_SLOTS.map(() => 0)
|
|
514
|
-
];
|
|
515
|
-
const polymod = bech32Polymod(values) ^ 1;
|
|
516
|
-
return CHECKSUM_SLOTS.map((slot) => polymod >> 5 * (5 - slot) & 31);
|
|
517
|
-
}
|
|
518
|
-
function bech32Polymod(values) {
|
|
519
|
-
let checksum = 1;
|
|
520
|
-
for (const value of values) {
|
|
521
|
-
const top = checksum >> 25;
|
|
522
|
-
checksum = (checksum & 33554431) << 5 ^ value;
|
|
523
|
-
for (let bit = 0; bit < 5; bit++) {
|
|
524
|
-
if (top >> bit & 1) {
|
|
525
|
-
checksum ^= BECH32_GENERATOR[bit];
|
|
526
|
-
}
|
|
527
|
-
}
|
|
528
|
-
}
|
|
529
|
-
return checksum;
|
|
410
|
+
function asBuffer(bytes) {
|
|
411
|
+
return bytes instanceof Uint8Array && bytes.buffer instanceof ArrayBuffer ? bytes : new Uint8Array(bytes);
|
|
530
412
|
}
|
|
531
413
|
|
|
532
414
|
// ../core/sealed.ts
|
|
533
|
-
var VERSION = "
|
|
415
|
+
var VERSION = "v2";
|
|
416
|
+
var UNPADDED_VERSION = "v1";
|
|
534
417
|
var IV_BYTES = 12;
|
|
535
418
|
var MIN_SECRET_CHARS = 32;
|
|
536
419
|
var MAX_PLAIN_BYTES = 3e3;
|
|
420
|
+
var SIZE_CLASSES = [256, 1024, MAX_PLAIN_BYTES];
|
|
421
|
+
var END_OF_TEXT = 128;
|
|
537
422
|
var INFO = new TextEncoder().encode("thunder-bridge/sealed");
|
|
538
|
-
async function seal(secret, plaintext) {
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
423
|
+
async function seal(secret, plaintext, paymentHash) {
|
|
424
|
+
return await sealUnder(
|
|
425
|
+
secret,
|
|
426
|
+
plaintext,
|
|
427
|
+
crypto.getRandomValues(new Uint8Array(IV_BYTES)),
|
|
428
|
+
boundTo(paymentHash)
|
|
429
|
+
);
|
|
430
|
+
}
|
|
431
|
+
async function sealStable(secret, plaintext) {
|
|
432
|
+
const derived = await hmacHex(secret, `sealed-nonce|${plaintext}`);
|
|
433
|
+
const nonce = new Uint8Array(IV_BYTES);
|
|
434
|
+
for (let at = 0; at < IV_BYTES; at++) {
|
|
435
|
+
nonce[at] = Number.parseInt(derived.slice(at * 2, at * 2 + 2), 16);
|
|
542
436
|
}
|
|
437
|
+
return await sealUnder(secret, plaintext, nonce, boundTo(void 0));
|
|
438
|
+
}
|
|
439
|
+
async function sealUnder(secret, plaintext, iv, additionalData) {
|
|
440
|
+
const body = padToClass(plaintext);
|
|
543
441
|
const key = await keyFor(secret);
|
|
544
|
-
const
|
|
545
|
-
|
|
442
|
+
const cipher = new Uint8Array(
|
|
443
|
+
await crypto.subtle.encrypt({ name: "AES-GCM", iv, additionalData }, key, body)
|
|
444
|
+
);
|
|
546
445
|
const joined = new Uint8Array(iv.length + cipher.length);
|
|
547
446
|
joined.set(iv);
|
|
548
447
|
joined.set(cipher, iv.length);
|
|
549
448
|
return `${VERSION}.${toBase64Url(joined)}`;
|
|
550
449
|
}
|
|
551
|
-
async function unseal(secret, sealed) {
|
|
450
|
+
async function unseal(secret, sealed, paymentHash) {
|
|
552
451
|
const key = await keyFor(secret);
|
|
553
|
-
|
|
452
|
+
const padded = sealed.startsWith(`${VERSION}.`);
|
|
453
|
+
const unbound = !padded && sealed.startsWith(`${UNPADDED_VERSION}.`);
|
|
454
|
+
if (!padded && (!unbound || paymentHash !== void 0)) {
|
|
554
455
|
return null;
|
|
555
456
|
}
|
|
556
457
|
const joined = fromBase64Url(sealed.slice(VERSION.length + 1));
|
|
@@ -558,20 +459,50 @@ async function unseal(secret, sealed) {
|
|
|
558
459
|
return null;
|
|
559
460
|
}
|
|
560
461
|
try {
|
|
561
|
-
const body =
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
462
|
+
const body = new Uint8Array(
|
|
463
|
+
await crypto.subtle.decrypt(
|
|
464
|
+
{
|
|
465
|
+
name: "AES-GCM",
|
|
466
|
+
iv: joined.slice(0, IV_BYTES),
|
|
467
|
+
additionalData: padded ? boundTo(paymentHash) : void 0
|
|
468
|
+
},
|
|
469
|
+
key,
|
|
470
|
+
joined.slice(IV_BYTES)
|
|
471
|
+
)
|
|
565
472
|
);
|
|
566
|
-
|
|
473
|
+
const text3 = padded ? unpad(body) : body;
|
|
474
|
+
return text3 === null ? null : new TextDecoder().decode(text3);
|
|
567
475
|
} catch {
|
|
568
476
|
return null;
|
|
569
477
|
}
|
|
570
478
|
}
|
|
571
|
-
|
|
479
|
+
function refuseAWeakSecret(secret) {
|
|
572
480
|
if (secret.length < MIN_SECRET_CHARS) {
|
|
573
481
|
throw new Error(`the sealing secret needs ${MIN_SECRET_CHARS} characters of randomness`);
|
|
574
482
|
}
|
|
483
|
+
}
|
|
484
|
+
function boundTo(paymentHash) {
|
|
485
|
+
return new TextEncoder().encode(
|
|
486
|
+
paymentHash === void 0 ? VERSION : `${VERSION}|${paymentHash.toLowerCase()}`
|
|
487
|
+
);
|
|
488
|
+
}
|
|
489
|
+
function padToClass(plaintext) {
|
|
490
|
+
const text3 = new TextEncoder().encode(plaintext);
|
|
491
|
+
const size = SIZE_CLASSES.find((limit) => text3.length <= limit);
|
|
492
|
+
if (size === void 0) {
|
|
493
|
+
throw new Error(`sealed takes at most ${MAX_PLAIN_BYTES} bytes, this was ${text3.length}`);
|
|
494
|
+
}
|
|
495
|
+
const body = new Uint8Array(size + 1);
|
|
496
|
+
body.set(text3);
|
|
497
|
+
body[text3.length] = END_OF_TEXT;
|
|
498
|
+
return body;
|
|
499
|
+
}
|
|
500
|
+
function unpad(body) {
|
|
501
|
+
const end = body.lastIndexOf(END_OF_TEXT);
|
|
502
|
+
return end === -1 ? null : body.subarray(0, end);
|
|
503
|
+
}
|
|
504
|
+
async function keyFor(secret) {
|
|
505
|
+
refuseAWeakSecret(secret);
|
|
575
506
|
const material = await crypto.subtle.importKey(
|
|
576
507
|
"raw",
|
|
577
508
|
new TextEncoder().encode(secret),
|
|
@@ -594,12 +525,12 @@ function toBase64Url(bytes) {
|
|
|
594
525
|
}
|
|
595
526
|
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
596
527
|
}
|
|
597
|
-
function fromBase64Url(
|
|
598
|
-
if (
|
|
528
|
+
function fromBase64Url(text3) {
|
|
529
|
+
if (text3.length === 0 || !/^[A-Za-z0-9_-]+$/.test(text3)) {
|
|
599
530
|
return null;
|
|
600
531
|
}
|
|
601
532
|
try {
|
|
602
|
-
const binary = atob(
|
|
533
|
+
const binary = atob(text3.replace(/-/g, "+").replace(/_/g, "/"));
|
|
603
534
|
const bytes = new Uint8Array(binary.length);
|
|
604
535
|
for (let at = 0; at < bytes.length; at++) {
|
|
605
536
|
bytes[at] = binary.charCodeAt(at);
|
|
@@ -610,32 +541,109 @@ function fromBase64Url(text2) {
|
|
|
610
541
|
}
|
|
611
542
|
}
|
|
612
543
|
|
|
613
|
-
//
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
)
|
|
623
|
-
|
|
624
|
-
return bytesToHex(new Uint8Array(await crypto.subtle.sign("HMAC", key, asBuffer(message))));
|
|
625
|
-
}
|
|
626
|
-
function equalInConstantTime(one, other) {
|
|
627
|
-
if (one.length !== other.length) {
|
|
628
|
-
return false;
|
|
544
|
+
// src/errors.ts
|
|
545
|
+
var AmountError = class extends Error {
|
|
546
|
+
/**
|
|
547
|
+
* Whether a failure is one of these, without asking whether it is this exact
|
|
548
|
+
* class. Every entry point carries its own copy of the class, so a price
|
|
549
|
+
* refused inside `thunder-bridge/price` is not `instanceof` the `AmountError`
|
|
550
|
+
* imported from `thunder-bridge`. The name and the code are the same in every
|
|
551
|
+
* copy, so this holds where `instanceof` does not
|
|
552
|
+
*/
|
|
553
|
+
static is(failure) {
|
|
554
|
+
return failure instanceof Error && failure.name === "AmountError" && "code" in failure;
|
|
629
555
|
}
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
556
|
+
code;
|
|
557
|
+
constructor(code, detail) {
|
|
558
|
+
super(`${code}: ${detail}`);
|
|
559
|
+
this.name = "AmountError";
|
|
560
|
+
this.code = code;
|
|
633
561
|
}
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
562
|
+
};
|
|
563
|
+
var GatewayCheatError = class extends Error {
|
|
564
|
+
code;
|
|
565
|
+
paymentId;
|
|
566
|
+
constructor(code, paymentId) {
|
|
567
|
+
super(`gateway verification failed: ${code}`);
|
|
568
|
+
this.name = "GatewayCheatError";
|
|
569
|
+
this.code = code;
|
|
570
|
+
this.paymentId = paymentId;
|
|
571
|
+
}
|
|
572
|
+
};
|
|
573
|
+
var WrapRefusedError = class extends Error {
|
|
574
|
+
code;
|
|
575
|
+
constructor(code, detail) {
|
|
576
|
+
super(`wrapped invoice refused: ${code}, ${detail}`);
|
|
577
|
+
this.name = "WrapRefusedError";
|
|
578
|
+
this.code = code;
|
|
579
|
+
}
|
|
580
|
+
};
|
|
581
|
+
var UnverifiedRecipientError = class extends Error {
|
|
582
|
+
lnAddress;
|
|
583
|
+
paymentId;
|
|
584
|
+
constructor(lnAddress, paymentId, cause) {
|
|
585
|
+
super(`could not reach ${lnAddress} to verify the invoice: ${String(cause)}`);
|
|
586
|
+
this.name = "UnverifiedRecipientError";
|
|
587
|
+
this.lnAddress = lnAddress;
|
|
588
|
+
this.paymentId = paymentId;
|
|
589
|
+
}
|
|
590
|
+
};
|
|
591
|
+
var ProblemError = class extends Error {
|
|
592
|
+
static NO_WALLET_AVAILABLE = "urn:problem-type:thunder-bridge:no-wallet-available";
|
|
593
|
+
static REQUEST_IN_FLIGHT = "urn:problem-type:thunder-bridge:request-in-flight";
|
|
594
|
+
static IDEMPOTENCY_KEY_REUSED = "urn:problem-type:thunder-bridge:idempotency-key-reused";
|
|
595
|
+
static PAYMENT_ALREADY_WATCHED = "urn:problem-type:thunder-bridge:payment-already-watched";
|
|
596
|
+
static INVALID_REQUEST = "urn:problem-type:thunder-bridge:invalid-request";
|
|
597
|
+
static CALLER_UNKNOWN = "urn:problem-type:thunder-bridge:caller-unknown";
|
|
598
|
+
static VERIFY_HOST_REFUSED = "urn:problem-type:thunder-bridge:verify-host-refused";
|
|
599
|
+
static VERIFY_UNCONFIRMED = "urn:problem-type:thunder-bridge:verify-unconfirmed";
|
|
600
|
+
static VERIFY_UNCONSENTED = "urn:problem-type:thunder-bridge:verify-unconsented";
|
|
601
|
+
static WEBHOOK_UNCONFIRMED = "urn:problem-type:thunder-bridge:webhook-unconfirmed";
|
|
602
|
+
static TOO_MANY_PENDING = "urn:problem-type:thunder-bridge:too-many-pending";
|
|
603
|
+
/**
|
|
604
|
+
* Whether a problem carries this type. Branch on the type, never on the prose,
|
|
605
|
+
* and reach for `instanceof` first: every type this SDK gives a class to has one
|
|
606
|
+
*/
|
|
607
|
+
static is(problem, type) {
|
|
608
|
+
return problem.type === type;
|
|
609
|
+
}
|
|
610
|
+
type;
|
|
611
|
+
title;
|
|
612
|
+
status;
|
|
613
|
+
detail;
|
|
614
|
+
constructor(problem) {
|
|
615
|
+
const title = problem.title ?? "Request failed";
|
|
616
|
+
super(problem.detail ? `${title}: ${problem.detail}` : title);
|
|
617
|
+
this.name = "ProblemError";
|
|
618
|
+
this.type = problem.type ?? "about:blank";
|
|
619
|
+
this.title = title;
|
|
620
|
+
this.status = problem.status ?? 0;
|
|
621
|
+
this.detail = problem.detail ?? null;
|
|
622
|
+
}
|
|
623
|
+
};
|
|
624
|
+
function isProblemType(problem, type) {
|
|
625
|
+
return problem.type === type;
|
|
638
626
|
}
|
|
627
|
+
var NO_WALLET_AVAILABLE = "urn:problem-type:thunder-bridge:no-wallet-available";
|
|
628
|
+
var REQUEST_IN_FLIGHT = "urn:problem-type:thunder-bridge:request-in-flight";
|
|
629
|
+
var IDEMPOTENCY_KEY_REUSED = "urn:problem-type:thunder-bridge:idempotency-key-reused";
|
|
630
|
+
var PAYMENT_ALREADY_WATCHED = "urn:problem-type:thunder-bridge:payment-already-watched";
|
|
631
|
+
var IdempotencyConflictError = class extends ProblemError {
|
|
632
|
+
conflict;
|
|
633
|
+
constructor(problem, conflict) {
|
|
634
|
+
super(problem);
|
|
635
|
+
this.name = "IdempotencyConflictError";
|
|
636
|
+
this.conflict = conflict;
|
|
637
|
+
}
|
|
638
|
+
};
|
|
639
|
+
var NoWalletAvailableError = class extends ProblemError {
|
|
640
|
+
wallets;
|
|
641
|
+
constructor(problem, wallets) {
|
|
642
|
+
super({ ...problem, type: NO_WALLET_AVAILABLE });
|
|
643
|
+
this.name = "NoWalletAvailableError";
|
|
644
|
+
this.wallets = wallets;
|
|
645
|
+
}
|
|
646
|
+
};
|
|
639
647
|
|
|
640
648
|
// src/currency.ts
|
|
641
649
|
var ISO_4217_MINOR_UNITS = {
|
|
@@ -669,7 +677,8 @@ var ISO_4217_MINOR_UNITS = {
|
|
|
669
677
|
function minorUnitsOf(currency) {
|
|
670
678
|
const digits = ISO_4217_MINOR_UNITS[currency.toUpperCase()];
|
|
671
679
|
if (digits === void 0) {
|
|
672
|
-
throw new
|
|
680
|
+
throw new AmountError(
|
|
681
|
+
"unknown-currency",
|
|
673
682
|
`${currency.toUpperCase()} is not a currency this knows the ISO 4217 minor unit of`
|
|
674
683
|
);
|
|
675
684
|
}
|
|
@@ -679,16 +688,218 @@ function minorScaleOf(currency) {
|
|
|
679
688
|
return 10 ** minorUnitsOf(currency);
|
|
680
689
|
}
|
|
681
690
|
|
|
682
|
-
//
|
|
683
|
-
var
|
|
684
|
-
var
|
|
685
|
-
var
|
|
686
|
-
var
|
|
687
|
-
var
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
691
|
+
// src/price.ts
|
|
692
|
+
var MSAT_PER_BTC2 = 1e11;
|
|
693
|
+
var BASIS_POINTS = 1e4;
|
|
694
|
+
var DEFAULT_HOLD_SECS = 60;
|
|
695
|
+
var DEFAULT_MIN_VENUES = 2;
|
|
696
|
+
var DEFAULT_MAX_SPREAD_BPS = 200;
|
|
697
|
+
var MILLIS = 1e3;
|
|
698
|
+
function coinbase(baseUrl = "https://api.coinbase.com") {
|
|
699
|
+
return async (currency) => {
|
|
700
|
+
const asked = currency.toUpperCase();
|
|
701
|
+
const answer = await asJson(`${baseUrl}/v2/prices/BTC-${asked}/spot`, "coinbase");
|
|
702
|
+
const quoted2 = answer.data;
|
|
703
|
+
if (quoted2?.currency !== asked) {
|
|
704
|
+
throw new Error(`coinbase quoted ${quoted2?.currency} when asked for ${asked}`);
|
|
705
|
+
}
|
|
706
|
+
return asMinor(quoted2.amount, "coinbase", asked);
|
|
707
|
+
};
|
|
708
|
+
}
|
|
709
|
+
function kraken(baseUrl = "https://api.kraken.com") {
|
|
710
|
+
return async (currency) => {
|
|
711
|
+
const asked = currency.toUpperCase();
|
|
712
|
+
const answer = await asJson(`${baseUrl}/0/public/Ticker?pair=XBT${asked}`, "kraken");
|
|
713
|
+
if (answer.error?.length) {
|
|
714
|
+
throw new Error(`kraken refused XBT${asked}: ${answer.error[0]}`);
|
|
715
|
+
}
|
|
716
|
+
const pairs = Object.values(answer.result ?? {});
|
|
717
|
+
if (pairs.length !== 1) {
|
|
718
|
+
throw new Error(`kraken answered ${pairs.length} pairs for XBT${asked}`);
|
|
719
|
+
}
|
|
720
|
+
return asMinor(pairs[0]?.c?.[0], "kraken", asked);
|
|
721
|
+
};
|
|
722
|
+
}
|
|
723
|
+
function bitstamp(baseUrl = "https://www.bitstamp.net") {
|
|
724
|
+
return async (currency) => {
|
|
725
|
+
const pair = `btc${currency.toLowerCase()}`;
|
|
726
|
+
const answer = await asJson(`${baseUrl}/api/v2/ticker/${pair}/`, "bitstamp");
|
|
727
|
+
if (Array.isArray(answer)) {
|
|
728
|
+
throw new Error(`bitstamp has no ${pair} pair`);
|
|
729
|
+
}
|
|
730
|
+
return asMinor(answer.last, "bitstamp", currency);
|
|
731
|
+
};
|
|
732
|
+
}
|
|
733
|
+
function coinmate(baseUrl = "https://coinmate.io") {
|
|
734
|
+
return async (currency) => {
|
|
735
|
+
const pair = `BTC_${currency.toUpperCase()}`;
|
|
736
|
+
const answer = await asJson(`${baseUrl}/api/ticker?currencyPair=${pair}`, "coinmate");
|
|
737
|
+
if (answer.error !== false) {
|
|
738
|
+
throw new Error(`coinmate refused ${pair}: ${answer.errorMessage ?? "no reason"}`);
|
|
739
|
+
}
|
|
740
|
+
return asMinor(answer.data?.last, "coinmate", currency);
|
|
741
|
+
};
|
|
742
|
+
}
|
|
743
|
+
function medianOf(tickers = [coinbase(), kraken(), bitstamp(), coinmate()], options = {}) {
|
|
744
|
+
const holdFor = (options.holdForSecs ?? DEFAULT_HOLD_SECS) * MILLIS;
|
|
745
|
+
const minVenues = options.minVenues ?? DEFAULT_MIN_VENUES;
|
|
746
|
+
const maxSpread = options.maxSpreadBps ?? DEFAULT_MAX_SPREAD_BPS;
|
|
747
|
+
const held = /* @__PURE__ */ new Map();
|
|
748
|
+
return async (currency) => {
|
|
749
|
+
const asked = currency.toUpperCase();
|
|
750
|
+
const remembered = held.get(asked);
|
|
751
|
+
if (remembered && Date.now() - remembered.at < holdFor) {
|
|
752
|
+
return remembered.price;
|
|
753
|
+
}
|
|
754
|
+
const answers = await Promise.allSettled(tickers.map((ticker) => ticker(asked)));
|
|
755
|
+
const quoted2 = answers.filter((answer) => answer.status === "fulfilled").map((answer) => answer.value).sort((one, other) => one - other);
|
|
756
|
+
if (quoted2.length < minVenues) {
|
|
757
|
+
throw new Error(
|
|
758
|
+
`${quoted2.length} of ${tickers.length} venues quoted ${asked}, ${minVenues} wanted: ${refusals(answers)}`
|
|
759
|
+
);
|
|
760
|
+
}
|
|
761
|
+
const spread2 = spreadBpsOf(quoted2);
|
|
762
|
+
if (spread2 > maxSpread) {
|
|
763
|
+
throw new Error(
|
|
764
|
+
`venues disagree on ${asked} by ${Math.round(spread2)} bps, more than the ${maxSpread} allowed: ${quoted2.join(", ")}`
|
|
765
|
+
);
|
|
766
|
+
}
|
|
767
|
+
const price = middleOf(quoted2);
|
|
768
|
+
held.set(asked, { at: Date.now(), price });
|
|
769
|
+
return price;
|
|
770
|
+
};
|
|
771
|
+
}
|
|
772
|
+
function msatFor(amountMinor, priceMinorPerBtc, options = {}) {
|
|
773
|
+
if (!Number.isInteger(amountMinor) || amountMinor <= 0) {
|
|
774
|
+
throw new Error("amountMinor must be a whole number of minor units above zero");
|
|
775
|
+
}
|
|
776
|
+
if (!Number.isFinite(priceMinorPerBtc) || priceMinorPerBtc <= 0) {
|
|
777
|
+
throw new Error(`${priceMinorPerBtc} is not a usable price`);
|
|
778
|
+
}
|
|
779
|
+
const spreadBps = options.spreadBps ?? 0;
|
|
780
|
+
if (!Number.isInteger(spreadBps) || spreadBps < 0) {
|
|
781
|
+
throw new Error("spreadBps must be a whole number of basis points, none or more");
|
|
782
|
+
}
|
|
783
|
+
const wanted = BigInt(amountMinor) * BigInt(MSAT_PER_BTC2) * BigInt(BASIS_POINTS + spreadBps);
|
|
784
|
+
const per = BigInt(Math.round(priceMinorPerBtc)) * BigInt(BASIS_POINTS);
|
|
785
|
+
const msat2 = (wanted + per - 1n) / per;
|
|
786
|
+
if (msat2 > BigInt(Number.MAX_SAFE_INTEGER)) {
|
|
787
|
+
throw new Error(`${amountMinor} at ${priceMinorPerBtc} is more millisatoshi than exist`);
|
|
788
|
+
}
|
|
789
|
+
return Number(msat2);
|
|
790
|
+
}
|
|
791
|
+
async function asJson(url, venue) {
|
|
792
|
+
const answer = await fetch(url, { headers: { accept: "application/json" } });
|
|
793
|
+
if (!answer.ok) {
|
|
794
|
+
throw new Error(`${venue} answered ${answer.status}`);
|
|
795
|
+
}
|
|
796
|
+
return answer.json();
|
|
797
|
+
}
|
|
798
|
+
function asMinor(quoted2, venue, currency) {
|
|
799
|
+
const major2 = Number(quoted2);
|
|
800
|
+
if (!Number.isFinite(major2) || major2 <= 0) {
|
|
801
|
+
throw new Error(`${venue} quoted ${String(quoted2)} for ${currency.toUpperCase()}`);
|
|
802
|
+
}
|
|
803
|
+
return Math.round(major2 * minorScaleOf(currency));
|
|
804
|
+
}
|
|
805
|
+
function spreadBpsOf(sorted) {
|
|
806
|
+
const lowest = sorted[0] ?? 0;
|
|
807
|
+
const highest = sorted[sorted.length - 1] ?? 0;
|
|
808
|
+
return lowest === 0 ? Number.POSITIVE_INFINITY : (highest - lowest) / lowest * BASIS_POINTS;
|
|
809
|
+
}
|
|
810
|
+
function middleOf(sorted) {
|
|
811
|
+
const middle = Math.floor(sorted.length / 2);
|
|
812
|
+
if (sorted.length % 2 === 1) {
|
|
813
|
+
return sorted[middle] ?? 0;
|
|
814
|
+
}
|
|
815
|
+
return Math.round(((sorted[middle - 1] ?? 0) + (sorted[middle] ?? 0)) / 2);
|
|
816
|
+
}
|
|
817
|
+
function refusals(answers) {
|
|
818
|
+
return answers.filter((answer) => answer.status === "rejected").map(
|
|
819
|
+
(answer) => answer.reason instanceof Error ? answer.reason.message : String(answer.reason)
|
|
820
|
+
).join("; ");
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
// src/amount.ts
|
|
824
|
+
var MSAT_PER_SAT = 1e3;
|
|
825
|
+
var MAX_SATS = Math.floor(Number.MAX_SAFE_INTEGER / MSAT_PER_SAT);
|
|
826
|
+
function sats(whole) {
|
|
827
|
+
if (!Number.isInteger(whole) || whole < 1 || whole > MAX_SATS) {
|
|
828
|
+
throw new AmountError("not-whole-satoshi", `${whole} is not a payable count of satoshi`);
|
|
829
|
+
}
|
|
830
|
+
return whole * MSAT_PER_SAT;
|
|
831
|
+
}
|
|
832
|
+
function msat(exact) {
|
|
833
|
+
if (!Number.isSafeInteger(exact) || exact < 1) {
|
|
834
|
+
throw new AmountError(
|
|
835
|
+
"not-whole-millisatoshi",
|
|
836
|
+
`${exact} is not a payable count of millisatoshi`
|
|
837
|
+
);
|
|
838
|
+
}
|
|
839
|
+
return exact;
|
|
840
|
+
}
|
|
841
|
+
function fiat(major2, currency, options) {
|
|
842
|
+
const amountMinor = minorFrom(major2, currency);
|
|
843
|
+
const rate = options?.rate ?? medianOf();
|
|
844
|
+
return async () => {
|
|
845
|
+
const wanted = msatFor(amountMinor, await rate(currency), { spreadBps: options?.spreadBps });
|
|
846
|
+
return sats(Math.ceil(wanted / MSAT_PER_SAT));
|
|
847
|
+
};
|
|
848
|
+
}
|
|
849
|
+
async function amountNow(amount) {
|
|
850
|
+
return typeof amount === "function" ? msat(await amount()) : msat(amount);
|
|
851
|
+
}
|
|
852
|
+
function minorFrom(major2, currency) {
|
|
853
|
+
const digits = minorUnitsOf(currency);
|
|
854
|
+
if (typeof major2 === "number") {
|
|
855
|
+
return payable(Math.round(major2 * minorScaleOf(currency)), major2, currency);
|
|
856
|
+
}
|
|
857
|
+
const parsed = /^(\d+)(?:[.,](\d+))?$/.exec(major2.trim());
|
|
858
|
+
if (parsed === null) {
|
|
859
|
+
throw new AmountError(
|
|
860
|
+
"not-a-decimal",
|
|
861
|
+
`${major2} is not a decimal ${currency.toUpperCase()} amount`
|
|
862
|
+
);
|
|
863
|
+
}
|
|
864
|
+
const fraction = parsed[2] ?? "";
|
|
865
|
+
if (fraction.length > digits) {
|
|
866
|
+
throw new AmountError(
|
|
867
|
+
"too-precise",
|
|
868
|
+
`${major2} carries more decimals than ${currency.toUpperCase()} has, which is ${digits}`
|
|
869
|
+
);
|
|
870
|
+
}
|
|
871
|
+
return payable(Number(`${parsed[1]}${fraction.padEnd(digits, "0")}`), major2, currency);
|
|
872
|
+
}
|
|
873
|
+
function payable(minor, major2, currency) {
|
|
874
|
+
if (!Number.isSafeInteger(minor) || minor < 1) {
|
|
875
|
+
throw new AmountError(
|
|
876
|
+
"not-a-decimal",
|
|
877
|
+
`${major2} is not a ${currency.toUpperCase()} price this can charge`
|
|
878
|
+
);
|
|
879
|
+
}
|
|
880
|
+
return minor;
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
// ../core/ed25519.ts
|
|
884
|
+
var ALGORITHM = { name: "Ed25519" };
|
|
885
|
+
var SEED_BYTES = 32;
|
|
886
|
+
var SIGNATURE_BYTES = 64;
|
|
887
|
+
var PUBLIC_KEY_BYTES = 32;
|
|
888
|
+
var PKCS8_HEADER = hexToBytes("302e020100300506032b657004220420");
|
|
889
|
+
var SIGN_BIT_CLEARED = 127;
|
|
890
|
+
var SMALL_ORDER = [
|
|
891
|
+
"00".repeat(32),
|
|
892
|
+
`01${"00".repeat(31)}`,
|
|
893
|
+
"26e8958fc2b227b045c3f489f2ef98f0d5dfac05d3c63339b13802886d53fc05",
|
|
894
|
+
"c7176a703d4dd84fba3c0b760d10670f2a2053fa2c39ccc64ec7fd7792ac037a",
|
|
895
|
+
`ec${"ff".repeat(30)}7f`,
|
|
896
|
+
`ed${"ff".repeat(30)}7f`,
|
|
897
|
+
`ee${"ff".repeat(30)}7f`
|
|
898
|
+
];
|
|
899
|
+
async function signingKeyFromSeed(seed) {
|
|
900
|
+
if (seed.length !== SEED_BYTES) {
|
|
901
|
+
throw new Error(`an ed25519 seed is ${SEED_BYTES} bytes`);
|
|
902
|
+
}
|
|
692
903
|
const wrapped = new Uint8Array(PKCS8_HEADER.length + SEED_BYTES);
|
|
693
904
|
wrapped.set(PKCS8_HEADER);
|
|
694
905
|
wrapped.set(seed, PKCS8_HEADER.length);
|
|
@@ -703,6 +914,9 @@ async function verifyHex(publicKeyHex, signatureHex, payload) {
|
|
|
703
914
|
if (!isHex(publicKeyHex) || publicKeyHex.length !== PUBLIC_KEY_BYTES * 2) {
|
|
704
915
|
return false;
|
|
705
916
|
}
|
|
917
|
+
if (hasSmallOrder(publicKeyHex)) {
|
|
918
|
+
return false;
|
|
919
|
+
}
|
|
706
920
|
if (!isHex(signatureHex) || signatureHex.length !== SIGNATURE_BYTES * 2) {
|
|
707
921
|
return false;
|
|
708
922
|
}
|
|
@@ -724,8 +938,13 @@ async function verifyHex(publicKeyHex, signatureHex, payload) {
|
|
|
724
938
|
return false;
|
|
725
939
|
}
|
|
726
940
|
}
|
|
727
|
-
function
|
|
728
|
-
const
|
|
941
|
+
function hasSmallOrder(publicKeyHex) {
|
|
942
|
+
const point = hexToBytes(publicKeyHex);
|
|
943
|
+
point[PUBLIC_KEY_BYTES - 1] = point[PUBLIC_KEY_BYTES - 1] & SIGN_BIT_CLEARED;
|
|
944
|
+
return SMALL_ORDER.includes(bytesToHex(point));
|
|
945
|
+
}
|
|
946
|
+
function fromBase64Url2(text3) {
|
|
947
|
+
const padded = text3.replace(/-/g, "+").replace(/_/g, "/");
|
|
729
948
|
const raw = atob(padded + "=".repeat((4 - padded.length % 4) % 4));
|
|
730
949
|
const bytes = new Uint8Array(raw.length);
|
|
731
950
|
for (let at = 0; at < raw.length; at++) {
|
|
@@ -737,135 +956,766 @@ function asBuffer2(bytes) {
|
|
|
737
956
|
return bytes.buffer instanceof ArrayBuffer ? bytes : new Uint8Array(bytes);
|
|
738
957
|
}
|
|
739
958
|
|
|
740
|
-
//
|
|
741
|
-
var
|
|
742
|
-
var
|
|
743
|
-
var
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
"invoice-refused"
|
|
749
|
-
]);
|
|
750
|
-
function createRequestBody(params, trigger, replay) {
|
|
751
|
-
return JSON.stringify({
|
|
752
|
-
ln_addresses: params.lnAddresses,
|
|
753
|
-
incoming_amount: toAmount(params.amountMsat),
|
|
754
|
-
webhook: params.webhookUrl ? { url: params.webhookUrl } : void 0,
|
|
755
|
-
trigger: trigger ?? void 0,
|
|
756
|
-
replay
|
|
757
|
-
});
|
|
959
|
+
// ../core/caller.ts
|
|
960
|
+
var KEY_HEADER = "x-client-key";
|
|
961
|
+
var SIGNATURE_HEADER = "x-signature";
|
|
962
|
+
var TIMESTAMP_HEADER = "x-timestamp";
|
|
963
|
+
var PREFIX = "ed25519=";
|
|
964
|
+
var SIGNING_LABEL = "request-signing";
|
|
965
|
+
async function callerKey(secret) {
|
|
966
|
+
return await signingKeyFromSeed(hexToBytes(await hmacHex(secret, SIGNING_LABEL)));
|
|
758
967
|
}
|
|
759
|
-
function
|
|
760
|
-
return
|
|
761
|
-
ln_addresses: params.lnAddresses,
|
|
762
|
-
amount: toAmount(params.amountMsat)
|
|
763
|
-
});
|
|
968
|
+
function paymentNamedBy(caller, paymentHash) {
|
|
969
|
+
return sha256Hex(`${caller}.payment-id.${paymentHash}`);
|
|
764
970
|
}
|
|
765
|
-
function
|
|
766
|
-
const
|
|
767
|
-
|
|
768
|
-
|
|
971
|
+
async function signedAs(key, method, path, body) {
|
|
972
|
+
const timestamp = String(Math.floor(Date.now() / 1e3));
|
|
973
|
+
return {
|
|
974
|
+
[KEY_HEADER]: key.publicKeyHex,
|
|
975
|
+
[TIMESTAMP_HEADER]: timestamp,
|
|
976
|
+
[SIGNATURE_HEADER]: `${PREFIX}${await key.sign(spoken(timestamp, method, path, body))}`
|
|
977
|
+
};
|
|
978
|
+
}
|
|
979
|
+
function spoken(timestamp, method, path, body) {
|
|
980
|
+
return new TextEncoder().encode(
|
|
981
|
+
`${timestamp}.${method.toUpperCase()}.${path}.${sha256Hex(body)}`
|
|
982
|
+
);
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
// src/charge.ts
|
|
986
|
+
async function priced(charge) {
|
|
987
|
+
const paidTo = typeof charge.paidTo === "string" ? [charge.paidTo] : [...charge.paidTo];
|
|
988
|
+
if (paidTo.length === 0) {
|
|
989
|
+
throw new Error("name at least one lightning address to pay");
|
|
769
990
|
}
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
991
|
+
return { paidTo, amountMsat: await amountNow(charge.amount) };
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
// ../core/url.ts
|
|
995
|
+
var PRIVATE_SUFFIXES = ["localhost", "local", "internal", "lan", "arpa", "test", "invalid"];
|
|
996
|
+
function publicHttps(raw) {
|
|
997
|
+
let url;
|
|
998
|
+
try {
|
|
999
|
+
url = new URL(raw);
|
|
1000
|
+
} catch {
|
|
1001
|
+
return false;
|
|
779
1002
|
}
|
|
780
|
-
|
|
781
|
-
|
|
1003
|
+
return url.protocol === "https:" && hostIsPublic(url.hostname.toLowerCase());
|
|
1004
|
+
}
|
|
1005
|
+
function sameOrigin(one, other) {
|
|
1006
|
+
try {
|
|
1007
|
+
return new URL(one).origin === new URL(other).origin;
|
|
1008
|
+
} catch {
|
|
1009
|
+
return false;
|
|
782
1010
|
}
|
|
783
|
-
return { lnAddress, amountMsat, feeMsat, minMsat, maxMsat, metadata, refusals: refusals3 };
|
|
784
1011
|
}
|
|
785
|
-
function
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
1012
|
+
function publicAddress(literal) {
|
|
1013
|
+
return literal.includes(":") ? ipv6IsGlobal(literal) : ipv4IsGlobal(literal);
|
|
1014
|
+
}
|
|
1015
|
+
function hostIsPublic(host) {
|
|
1016
|
+
if (host.startsWith("[")) {
|
|
1017
|
+
return publicAddress(host.slice(1, -1));
|
|
789
1018
|
}
|
|
790
|
-
const
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
const paymentHash = text(wire["payment_hash"]);
|
|
794
|
-
const verifyUrl = text(wire["verify_url"]);
|
|
795
|
-
const amountMsat = msatFrom(wire["incoming_amount"], 1);
|
|
796
|
-
const expiresAt = secondsFrom(wire["expires_at"]);
|
|
797
|
-
const createdAt = secondsFrom(wire["created_at"]);
|
|
798
|
-
const status = wire["status"];
|
|
799
|
-
const preimage = wire["preimage"] ?? null;
|
|
800
|
-
if (id === null || lnAddress === null || bolt11 === null) {
|
|
801
|
-
return null;
|
|
1019
|
+
const name = host.replace(/\.$/, "");
|
|
1020
|
+
if (/^\d+\.\d+\.\d+\.\d+$/.test(name)) {
|
|
1021
|
+
return publicAddress(name);
|
|
802
1022
|
}
|
|
803
|
-
|
|
804
|
-
|
|
1023
|
+
const labels = name.split(".");
|
|
1024
|
+
if (labels.length < 2 || labels.some((label) => label === "")) {
|
|
1025
|
+
return false;
|
|
805
1026
|
}
|
|
806
|
-
|
|
807
|
-
|
|
1027
|
+
return !PRIVATE_SUFFIXES.includes(labels[labels.length - 1]);
|
|
1028
|
+
}
|
|
1029
|
+
function ipv4IsGlobal(literal) {
|
|
1030
|
+
const parts = literal.split(".").map(Number);
|
|
1031
|
+
if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) {
|
|
1032
|
+
return false;
|
|
808
1033
|
}
|
|
809
|
-
|
|
810
|
-
|
|
1034
|
+
const [a, b, c] = parts;
|
|
1035
|
+
if (a === 0 || a === 10 || a === 127 || a >= 224) {
|
|
1036
|
+
return false;
|
|
811
1037
|
}
|
|
812
|
-
if (
|
|
813
|
-
return
|
|
1038
|
+
if (a === 100 && b >= 64 && b < 128) {
|
|
1039
|
+
return false;
|
|
814
1040
|
}
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
}
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
1041
|
+
if (a === 169 && b === 254) {
|
|
1042
|
+
return false;
|
|
1043
|
+
}
|
|
1044
|
+
if (a === 172 && b >= 16 && b < 32) {
|
|
1045
|
+
return false;
|
|
1046
|
+
}
|
|
1047
|
+
if (a === 192 && (b === 168 || b === 0 && (c === 0 || c === 2))) {
|
|
1048
|
+
return false;
|
|
1049
|
+
}
|
|
1050
|
+
if (a === 198 && (b === 18 || b === 19 || b === 51 && c === 100)) {
|
|
1051
|
+
return false;
|
|
1052
|
+
}
|
|
1053
|
+
if (a === 203 && b === 0 && c === 113) {
|
|
1054
|
+
return false;
|
|
1055
|
+
}
|
|
1056
|
+
return true;
|
|
1057
|
+
}
|
|
1058
|
+
function ipv6IsGlobal(literal) {
|
|
1059
|
+
const groups = ipv6Groups(literal.toLowerCase());
|
|
1060
|
+
if (groups.length !== 8 || groups.some(Number.isNaN)) {
|
|
1061
|
+
return false;
|
|
1062
|
+
}
|
|
1063
|
+
const [first, second, third, fourth, fifth, sixth, high, low] = groups;
|
|
1064
|
+
const embedded = (one, other) => ipv4IsGlobal(`${one >> 8}.${one & 255}.${other >> 8}.${other & 255}`);
|
|
1065
|
+
const zeroBefore = (count) => groups.slice(0, count).every((group) => group === 0);
|
|
1066
|
+
if (zeroBefore(5) && sixth === 65535) {
|
|
1067
|
+
return embedded(high, low);
|
|
1068
|
+
}
|
|
1069
|
+
if (zeroBefore(6) || zeroBefore(4) && fifth === 65535 && sixth === 0) {
|
|
1070
|
+
return false;
|
|
1071
|
+
}
|
|
1072
|
+
if (first === 100 && second === 65435) {
|
|
1073
|
+
return third === 0 && fourth === 0 && fifth === 0 && sixth === 0 && embedded(high, low);
|
|
1074
|
+
}
|
|
1075
|
+
if (first === 8194) {
|
|
1076
|
+
return embedded(second, third);
|
|
1077
|
+
}
|
|
1078
|
+
if (first === 8193 && second === 0) {
|
|
1079
|
+
return false;
|
|
1080
|
+
}
|
|
1081
|
+
if (first === 256 && second === 0 && third === 0 && fourth === 0) {
|
|
1082
|
+
return false;
|
|
1083
|
+
}
|
|
1084
|
+
if ((first & 65024) === 64512 || (first & 65280) === 65280) {
|
|
1085
|
+
return false;
|
|
1086
|
+
}
|
|
1087
|
+
if ((first & 65472) === 65152 || (first & 65472) === 65216) {
|
|
1088
|
+
return false;
|
|
832
1089
|
}
|
|
1090
|
+
return true;
|
|
1091
|
+
}
|
|
1092
|
+
function ipv6Groups(address) {
|
|
1093
|
+
const halves = withDottedTailAsGroups(address).split("::");
|
|
1094
|
+
const [head = "", tail] = halves;
|
|
1095
|
+
const left = head.split(":").filter((group) => group !== "");
|
|
1096
|
+
const right = (tail ?? "").split(":").filter((group) => group !== "");
|
|
1097
|
+
const missing = 8 - left.length - right.length;
|
|
1098
|
+
if (halves.length > 2 || missing < 0) {
|
|
1099
|
+
return [Number.NaN];
|
|
1100
|
+
}
|
|
1101
|
+
const gap = tail === void 0 ? [] : Array(missing).fill("0");
|
|
1102
|
+
return [...left, ...gap, ...right].map(
|
|
1103
|
+
(group) => /^[0-9a-f]{1,4}$/.test(group) ? parseInt(group, 16) : Number.NaN
|
|
1104
|
+
);
|
|
1105
|
+
}
|
|
1106
|
+
function withDottedTailAsGroups(address) {
|
|
1107
|
+
const dotted = /^(.*:)(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/.exec(address);
|
|
1108
|
+
if (dotted === null) {
|
|
1109
|
+
return address;
|
|
1110
|
+
}
|
|
1111
|
+
const [a, b, c, d] = dotted[2].split(".").map(Number);
|
|
1112
|
+
if ([a, b, c, d].some((part) => part > 255)) {
|
|
1113
|
+
return `${dotted[1]}x`;
|
|
1114
|
+
}
|
|
1115
|
+
return `${dotted[1]}${(a << 8 | b).toString(16)}:${(c << 8 | d).toString(16)}`;
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
// ../core/outbound.ts
|
|
1119
|
+
var HTTP_TIMEOUT_MS = 15e3;
|
|
1120
|
+
var LOOKUP_TIMEOUT_MS = 5e3;
|
|
1121
|
+
var HOPS_FOLLOWED = 2;
|
|
1122
|
+
var REDIRECTS = [301, 302, 303, 307, 308];
|
|
1123
|
+
var KEEPS_THE_METHOD = [307, 308];
|
|
1124
|
+
var CREDENTIALS = ["authorization", "cookie", "proxy-authorization"];
|
|
1125
|
+
var BODY_LIMIT_BYTES = 262144;
|
|
1126
|
+
async function ask(send, url, sent = {}) {
|
|
1127
|
+
const capped = AbortSignal.timeout(HTTP_TIMEOUT_MS);
|
|
1128
|
+
const signal = sent.deadline ? AbortSignal.any([sent.deadline, capped]) : capped;
|
|
1129
|
+
let target = url;
|
|
1130
|
+
let carried = sent;
|
|
1131
|
+
for (let hop = 0; hop <= HOPS_FOLLOWED; hop += 1) {
|
|
1132
|
+
const at = await addressesToReach(target);
|
|
1133
|
+
const response = await send(target, carried, signal, at);
|
|
1134
|
+
if (!REDIRECTS.includes(response.status)) {
|
|
1135
|
+
return await answerOf(response);
|
|
1136
|
+
}
|
|
1137
|
+
const location = response.headers.get("location");
|
|
1138
|
+
if (location === null) {
|
|
1139
|
+
throw new Error(`${target} answered ${response.status} without saying where to`);
|
|
1140
|
+
}
|
|
1141
|
+
await response.body?.cancel();
|
|
1142
|
+
const next = new URL(location, target).toString();
|
|
1143
|
+
if (sent.staysOnOrigin && !sameOrigin(target, next)) {
|
|
1144
|
+
throw new Error(`${target} redirected off its own origin, to ${next}`);
|
|
1145
|
+
}
|
|
1146
|
+
carried = KEEPS_THE_METHOD.includes(response.status) ? carried : withoutBody(carried);
|
|
1147
|
+
carried = sameOrigin(target, next) ? carried : withoutCredentials(carried);
|
|
1148
|
+
target = next;
|
|
1149
|
+
}
|
|
1150
|
+
throw new Error(`${url} redirected more than ${HOPS_FOLLOWED} times`);
|
|
1151
|
+
}
|
|
1152
|
+
async function addressesToReach(url) {
|
|
1153
|
+
if (!publicHttps(url)) {
|
|
1154
|
+
throw new Error(`${url} is not a public https URL`);
|
|
1155
|
+
}
|
|
1156
|
+
const found = await resolved(url);
|
|
1157
|
+
if (found.length === 0 || !found.every((one) => publicAddress(one.address))) {
|
|
1158
|
+
throw new Error(`${url} resolves to an address we do not reach`);
|
|
1159
|
+
}
|
|
1160
|
+
return found;
|
|
1161
|
+
}
|
|
1162
|
+
async function resolved(url) {
|
|
1163
|
+
const { lookup } = await import("dns/promises");
|
|
1164
|
+
const { setTimeout: sleep } = await import("timers/promises");
|
|
1165
|
+
const host = new URL(url).hostname.replace(/^\[|]$/g, "");
|
|
1166
|
+
const found = await Promise.race([
|
|
1167
|
+
lookup(host, { all: true, verbatim: true }).catch(() => []),
|
|
1168
|
+
sleep(LOOKUP_TIMEOUT_MS, null, { ref: false })
|
|
1169
|
+
]);
|
|
1170
|
+
return found ?? [];
|
|
1171
|
+
}
|
|
1172
|
+
async function answerOf(response) {
|
|
1173
|
+
const { status, ok, headers } = response;
|
|
1174
|
+
const reader = response.body?.getReader();
|
|
1175
|
+
if (!reader) {
|
|
1176
|
+
return { status, ok, body: "", truncated: false, headers };
|
|
1177
|
+
}
|
|
1178
|
+
const read = [];
|
|
1179
|
+
let bytes = 0;
|
|
1180
|
+
while (bytes <= BODY_LIMIT_BYTES) {
|
|
1181
|
+
const { done, value } = await reader.read();
|
|
1182
|
+
if (done) {
|
|
1183
|
+
return { status, ok, body: text(read), truncated: false, headers };
|
|
1184
|
+
}
|
|
1185
|
+
read.push(value);
|
|
1186
|
+
bytes += value.length;
|
|
1187
|
+
}
|
|
1188
|
+
await reader.cancel();
|
|
1189
|
+
return { status, ok, body: text(read), truncated: true, headers };
|
|
1190
|
+
}
|
|
1191
|
+
function withoutBody(sent) {
|
|
1192
|
+
const headers = Object.fromEntries(
|
|
1193
|
+
Object.entries(sent.headers ?? {}).filter(([name]) => name.toLowerCase() !== "content-type")
|
|
1194
|
+
);
|
|
1195
|
+
return { ...sent, method: "GET", headers, body: void 0 };
|
|
1196
|
+
}
|
|
1197
|
+
function withoutCredentials(sent) {
|
|
1198
|
+
const headers = Object.fromEntries(
|
|
1199
|
+
Object.entries(sent.headers ?? {}).filter(
|
|
1200
|
+
([name]) => !CREDENTIALS.includes(name.toLowerCase())
|
|
1201
|
+
)
|
|
1202
|
+
);
|
|
1203
|
+
return { ...sent, headers };
|
|
1204
|
+
}
|
|
1205
|
+
function text(chunks) {
|
|
1206
|
+
const decoder = new TextDecoder();
|
|
1207
|
+
let decoded2 = "";
|
|
1208
|
+
for (const chunk of chunks) {
|
|
1209
|
+
decoded2 += decoder.decode(chunk, { stream: true });
|
|
1210
|
+
}
|
|
1211
|
+
return decoded2 + decoder.decode();
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
// ../core/refusal.ts
|
|
1215
|
+
var WalletRefused = class extends Error {
|
|
1216
|
+
reason;
|
|
1217
|
+
constructor(reason, message) {
|
|
1218
|
+
super(message);
|
|
1219
|
+
this.reason = reason;
|
|
1220
|
+
}
|
|
1221
|
+
};
|
|
1222
|
+
var NoWalletAvailable = class extends Error {
|
|
1223
|
+
wallets;
|
|
1224
|
+
constructor(wallets) {
|
|
1225
|
+
super(wallets.map((wallet) => `${wallet.address} ${wallet.reason}`).join(", "));
|
|
1226
|
+
this.wallets = wallets;
|
|
1227
|
+
}
|
|
1228
|
+
};
|
|
1229
|
+
|
|
1230
|
+
// ../core/lnurl.ts
|
|
1231
|
+
var VERIFY_WITHOUT_PREIMAGE = ["zeuspay.com", "zeusnuts.com", "ecash.love"];
|
|
1232
|
+
var RESOLVE_TIMEOUT_MS = 3e4;
|
|
1233
|
+
var NAMES_ONE_ACCOUNT = /^[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)*$/;
|
|
1234
|
+
var MIN_PACE_SECS = 1;
|
|
1235
|
+
var MAX_PACE_SECS = 3600;
|
|
1236
|
+
var MIN_PER_SECOND = 0.01;
|
|
1237
|
+
var MAX_PER_SECOND = 100;
|
|
1238
|
+
async function resolve(send, addresses, amountMsat) {
|
|
1239
|
+
const served = await firstThatServes(
|
|
1240
|
+
addresses,
|
|
1241
|
+
(address, deadline) => resolveAddress(send, address, amountMsat, deadline)
|
|
1242
|
+
);
|
|
1243
|
+
return served.won;
|
|
1244
|
+
}
|
|
1245
|
+
async function quote(send, addresses, amountMsat) {
|
|
1246
|
+
const served = await firstThatServes(
|
|
1247
|
+
addresses,
|
|
1248
|
+
(address2, deadline) => probe(send, address2, amountMsat, deadline)
|
|
1249
|
+
);
|
|
1250
|
+
const { address, pay } = served.won;
|
|
1251
|
+
return {
|
|
1252
|
+
won: {
|
|
1253
|
+
address,
|
|
1254
|
+
minMsat: pay.minSendable,
|
|
1255
|
+
maxMsat: pay.maxSendable,
|
|
1256
|
+
metadata: pay.metadata
|
|
1257
|
+
},
|
|
1258
|
+
refusals: served.refusals
|
|
1259
|
+
};
|
|
1260
|
+
}
|
|
1261
|
+
async function firstThatServes(addresses, attempt) {
|
|
1262
|
+
const deadline = AbortSignal.timeout(RESOLVE_TIMEOUT_MS);
|
|
1263
|
+
const refusals3 = [];
|
|
1264
|
+
for (const address of addresses) {
|
|
1265
|
+
if (deadline.aborted) {
|
|
1266
|
+
break;
|
|
1267
|
+
}
|
|
1268
|
+
try {
|
|
1269
|
+
return { won: await attempt(address, deadline), refusals: refusals3 };
|
|
1270
|
+
} catch (refusal) {
|
|
1271
|
+
if (!(refusal instanceof WalletRefused)) {
|
|
1272
|
+
throw refusal;
|
|
1273
|
+
}
|
|
1274
|
+
console.warn(`wallet ${address} refused, ${refusal.reason}: ${refusal.message}`);
|
|
1275
|
+
refusals3.push({ address, reason: refusal.reason });
|
|
1276
|
+
}
|
|
1277
|
+
}
|
|
1278
|
+
throw new NoWalletAvailable(refusals3);
|
|
1279
|
+
}
|
|
1280
|
+
async function probe(send, address, amountMsat, deadline) {
|
|
1281
|
+
const [user, domain] = splitAddress(address);
|
|
1282
|
+
if (cannotReleaseAPreimage(domain)) {
|
|
1283
|
+
throw new WalletRefused("cannot-prove-delivery", `${address} never releases a preimage`);
|
|
1284
|
+
}
|
|
1285
|
+
const pay = await answered(
|
|
1286
|
+
send,
|
|
1287
|
+
`https://${domain}/.well-known/lnurlp/${user}`,
|
|
1288
|
+
deadline
|
|
1289
|
+
);
|
|
1290
|
+
if (pay.tag !== "payRequest" || !pay.callback) {
|
|
1291
|
+
throw new WalletRefused("unreachable", `${address} answered with no payRequest`);
|
|
1292
|
+
}
|
|
1293
|
+
if (amountMsat < pay.minSendable || amountMsat > pay.maxSendable) {
|
|
1294
|
+
throw new WalletRefused(
|
|
1295
|
+
"amount-not-accepted",
|
|
1296
|
+
`${address} takes [${pay.minSendable}, ${pay.maxSendable}] msat, not ${amountMsat}`
|
|
1297
|
+
);
|
|
1298
|
+
}
|
|
1299
|
+
return { address, pay };
|
|
1300
|
+
}
|
|
1301
|
+
async function resolveAddress(send, address, amountMsat, deadline) {
|
|
1302
|
+
const { pay } = await probe(send, address, amountMsat, deadline);
|
|
1303
|
+
const invoice = await answered(
|
|
1304
|
+
send,
|
|
1305
|
+
withAmount(pay.callback, amountMsat),
|
|
1306
|
+
deadline
|
|
1307
|
+
);
|
|
1308
|
+
if (!invoice.pr) {
|
|
1309
|
+
throw new WalletRefused("unreachable", `${address} returned no invoice`);
|
|
1310
|
+
}
|
|
1311
|
+
if (!invoice.verify || !publicHttps(invoice.verify)) {
|
|
1312
|
+
throw new WalletRefused(
|
|
1313
|
+
"cannot-prove-delivery",
|
|
1314
|
+
`${address} publishes no usable LUD-21 verify URL`
|
|
1315
|
+
);
|
|
1316
|
+
}
|
|
1317
|
+
const issued = decodeIssued(address, invoice.pr);
|
|
1318
|
+
if (issued.amountMsat !== amountMsat) {
|
|
1319
|
+
throw new WalletRefused(
|
|
1320
|
+
"invoice-refused",
|
|
1321
|
+
`${address} issued an invoice for ${issued.amountMsat} msat, not ${amountMsat}`
|
|
1322
|
+
);
|
|
1323
|
+
}
|
|
1324
|
+
if (issued.descriptionHash !== sha256Hex(pay.metadata)) {
|
|
1325
|
+
throw new WalletRefused(
|
|
1326
|
+
"invoice-refused",
|
|
1327
|
+
`${address} does not bind its invoice to the metadata it serves`
|
|
1328
|
+
);
|
|
1329
|
+
}
|
|
1330
|
+
return {
|
|
1331
|
+
address,
|
|
1332
|
+
bolt11: invoice.pr,
|
|
1333
|
+
verifyUrl: invoice.verify,
|
|
1334
|
+
paymentHash: issued.paymentHash,
|
|
1335
|
+
expiresAt: issued.expiresAt
|
|
1336
|
+
};
|
|
1337
|
+
}
|
|
1338
|
+
async function answered(send, url, deadline) {
|
|
1339
|
+
try {
|
|
1340
|
+
return await fetchJson(send, url, deadline);
|
|
1341
|
+
} catch (cause) {
|
|
1342
|
+
throw new WalletRefused("unreachable", String(cause));
|
|
1343
|
+
}
|
|
1344
|
+
}
|
|
1345
|
+
function decodeIssued(address, bolt11) {
|
|
1346
|
+
const { paymentHash, descriptionHash, amountMsat, expiresAt } = decodeInvoice(bolt11);
|
|
1347
|
+
if (paymentHash === null || expiresAt === null) {
|
|
1348
|
+
throw new WalletRefused("invoice-refused", `${address} issued an invoice we cannot decode`);
|
|
1349
|
+
}
|
|
1350
|
+
return { paymentHash, descriptionHash, amountMsat, expiresAt };
|
|
1351
|
+
}
|
|
1352
|
+
async function checkSettled(send, verifyUrl, paymentHash) {
|
|
1353
|
+
const answer = await answeredJson(send, verifyUrl, { staysOnOrigin: true });
|
|
1354
|
+
const asked = {
|
|
1355
|
+
pace: paceAskedFor(answer.headers),
|
|
1356
|
+
ceiling: ceilingAskedFor(answer.headers)
|
|
1357
|
+
};
|
|
1358
|
+
if (!answer.said.settled || !answer.said.preimage) {
|
|
1359
|
+
return { preimage: null, ...asked };
|
|
1360
|
+
}
|
|
1361
|
+
if (!preimageMatchesHash(answer.said.preimage, paymentHash)) {
|
|
1362
|
+
throw new Error(`verify returned a preimage that does not hash to ${paymentHash}`);
|
|
1363
|
+
}
|
|
1364
|
+
return { preimage: answer.said.preimage, ...asked };
|
|
1365
|
+
}
|
|
1366
|
+
function paceAskedFor(headers) {
|
|
1367
|
+
const asked = /max-age\s*=\s*(\d+)/i.exec(headers.get("cache-control") ?? "");
|
|
1368
|
+
if (!asked) {
|
|
1369
|
+
return null;
|
|
1370
|
+
}
|
|
1371
|
+
return Math.min(Math.max(Number(asked[1]), MIN_PACE_SECS), MAX_PACE_SECS);
|
|
1372
|
+
}
|
|
1373
|
+
function ceilingAskedFor(headers) {
|
|
1374
|
+
const asked = /^\s*(\d+)\s*(?:;\s*w\s*=\s*(\d+))?/i.exec(headers.get("ratelimit-limit") ?? "");
|
|
1375
|
+
if (!asked) {
|
|
1376
|
+
return null;
|
|
1377
|
+
}
|
|
1378
|
+
const perWindow = Number(asked[1]);
|
|
1379
|
+
const window = asked[2] === void 0 ? 1 : Number(asked[2]);
|
|
1380
|
+
if (perWindow === 0 || window === 0) {
|
|
1381
|
+
return null;
|
|
1382
|
+
}
|
|
1383
|
+
return Math.min(Math.max(perWindow / window, MIN_PER_SECOND), MAX_PER_SECOND);
|
|
1384
|
+
}
|
|
1385
|
+
function cannotReleaseAPreimage(host) {
|
|
1386
|
+
const lowered = host.toLowerCase();
|
|
1387
|
+
return VERIFY_WITHOUT_PREIMAGE.some(
|
|
1388
|
+
(known) => lowered === known || lowered.endsWith(`.${known}`)
|
|
1389
|
+
);
|
|
1390
|
+
}
|
|
1391
|
+
function withAmount(callback, amountMsat) {
|
|
1392
|
+
const separator = callback.includes("?") ? "&" : "?";
|
|
1393
|
+
return `${callback}${separator}amount=${amountMsat}`;
|
|
1394
|
+
}
|
|
1395
|
+
function splitAddress(address) {
|
|
1396
|
+
const [user, domain, ...rest] = address.split("@");
|
|
1397
|
+
if (!user || !domain || rest.length > 0 || !NAMES_ONE_ACCOUNT.test(user) || !isNothingButAHost(domain) || !publicHttps(`https://${domain}/`)) {
|
|
1398
|
+
throw new WalletRefused("address-unusable", `${address} is not a usable lightning address`);
|
|
1399
|
+
}
|
|
1400
|
+
return [user, domain];
|
|
1401
|
+
}
|
|
1402
|
+
function isNothingButAHost(domain) {
|
|
1403
|
+
try {
|
|
1404
|
+
const parsed = new URL(`https://${domain}/`);
|
|
1405
|
+
return parsed.host === domain.toLowerCase() && parsed.pathname === "/" && parsed.search === "" && parsed.hash === "";
|
|
1406
|
+
} catch {
|
|
1407
|
+
return false;
|
|
1408
|
+
}
|
|
1409
|
+
}
|
|
1410
|
+
async function fetchJson(send, url, deadline) {
|
|
1411
|
+
return (await answeredJson(send, url, { deadline })).said;
|
|
1412
|
+
}
|
|
1413
|
+
async function answeredJson(send, url, sent) {
|
|
1414
|
+
const answer = await ask(send, url, { headers: { accept: "application/json" }, ...sent });
|
|
1415
|
+
if (!answer.ok) {
|
|
1416
|
+
throw new Error(`${url} answered ${answer.status}`);
|
|
1417
|
+
}
|
|
1418
|
+
if (answer.truncated) {
|
|
1419
|
+
throw new Error(`${url} answered with more than the ${BODY_LIMIT_BYTES} bytes we read`);
|
|
1420
|
+
}
|
|
1421
|
+
return { said: JSON.parse(answer.body), headers: answer.headers };
|
|
1422
|
+
}
|
|
1423
|
+
|
|
1424
|
+
// ../core/pinned.ts
|
|
1425
|
+
var HTTPS_PORT = 443;
|
|
1426
|
+
var CARRIES_NO_BODY = [204, 205, 304];
|
|
1427
|
+
var pinnedToTheAddressWeVerified = async (url, sent, signal, at) => {
|
|
1428
|
+
const asked = new URL(url);
|
|
1429
|
+
const first = at[0];
|
|
1430
|
+
if (first === void 0) {
|
|
1431
|
+
throw new Error(`${url} resolved to nothing worth connecting to`);
|
|
1432
|
+
}
|
|
1433
|
+
const { request } = await import("https");
|
|
1434
|
+
const { Readable } = await import("stream");
|
|
1435
|
+
return new Promise((settle, fail) => {
|
|
1436
|
+
if (signal.aborted) {
|
|
1437
|
+
fail(signal.reason);
|
|
1438
|
+
return;
|
|
1439
|
+
}
|
|
1440
|
+
const call = request(reaching(asked, sent, at, first), (answer) => {
|
|
1441
|
+
const status = answer.statusCode ?? 502;
|
|
1442
|
+
try {
|
|
1443
|
+
settle(
|
|
1444
|
+
new Response(
|
|
1445
|
+
CARRIES_NO_BODY.includes(status) ? null : Readable.toWeb(answer),
|
|
1446
|
+
{ status, headers: headersOf(answer.headers) }
|
|
1447
|
+
)
|
|
1448
|
+
);
|
|
1449
|
+
} catch (unreadable) {
|
|
1450
|
+
answer.destroy();
|
|
1451
|
+
fail(new Error(`${url} answered with something HTTP cannot carry`, { cause: unreadable }));
|
|
1452
|
+
}
|
|
1453
|
+
});
|
|
1454
|
+
const give = () => call.destroy(new Error(`${url} took longer than allowed`));
|
|
1455
|
+
signal.addEventListener("abort", give, { once: true });
|
|
1456
|
+
call.on("close", () => signal.removeEventListener("abort", give));
|
|
1457
|
+
call.on("error", fail);
|
|
1458
|
+
if (sent.body !== void 0) {
|
|
1459
|
+
call.write(sent.body);
|
|
1460
|
+
}
|
|
1461
|
+
call.end();
|
|
1462
|
+
});
|
|
1463
|
+
};
|
|
1464
|
+
function reaching(asked, sent, at, first) {
|
|
1465
|
+
return {
|
|
1466
|
+
host: asked.hostname,
|
|
1467
|
+
servername: asked.hostname,
|
|
1468
|
+
port: asked.port === "" ? HTTPS_PORT : Number(asked.port),
|
|
1469
|
+
path: `${asked.pathname}${asked.search}`,
|
|
1470
|
+
method: sent.method ?? "GET",
|
|
1471
|
+
headers: { ...sent.headers, host: asked.host },
|
|
1472
|
+
agent: false,
|
|
1473
|
+
lookup: (_name, options, done) => {
|
|
1474
|
+
if (options.all === true) {
|
|
1475
|
+
done(
|
|
1476
|
+
null,
|
|
1477
|
+
at.map((one) => ({ ...one }))
|
|
1478
|
+
);
|
|
1479
|
+
} else {
|
|
1480
|
+
done(null, first.address, first.family);
|
|
1481
|
+
}
|
|
1482
|
+
}
|
|
1483
|
+
};
|
|
1484
|
+
}
|
|
1485
|
+
function headersOf(raw) {
|
|
1486
|
+
const headers = new Headers();
|
|
1487
|
+
for (const [name, value] of Object.entries(raw)) {
|
|
1488
|
+
if (Array.isArray(value)) {
|
|
1489
|
+
for (const one of value) {
|
|
1490
|
+
headers.append(name, one);
|
|
1491
|
+
}
|
|
1492
|
+
} else if (value !== void 0) {
|
|
1493
|
+
headers.set(name, value);
|
|
1494
|
+
}
|
|
1495
|
+
}
|
|
1496
|
+
return headers;
|
|
1497
|
+
}
|
|
1498
|
+
|
|
1499
|
+
// src/verify.ts
|
|
1500
|
+
var HTTP_TIMEOUT_MS2 = 15e3;
|
|
1501
|
+
var ISSUED_SKEW_SECS = 300;
|
|
1502
|
+
var NAMES_AN_ADDRESS = ["text/identifier", "text/email"];
|
|
1503
|
+
var DEFAULT_WRAP_PROPORTION = 0.01;
|
|
1504
|
+
var DEFAULT_WRAP_BASE_MSAT = 1e3;
|
|
1505
|
+
async function proveOrigin(payment, asked, askedAt) {
|
|
1506
|
+
const cheat = (code) => new GatewayCheatError(code, payment.id);
|
|
1507
|
+
const listed2 = asked.paidTo.find((address) => equalIgnoringCase(address, payment.lnAddress));
|
|
1508
|
+
if (listed2 === void 0) {
|
|
1509
|
+
throw cheat("address_not_requested");
|
|
1510
|
+
}
|
|
1511
|
+
if (payment.amountMsat !== asked.amountMsat) {
|
|
1512
|
+
throw cheat("amount_mismatch");
|
|
1513
|
+
}
|
|
1514
|
+
const invoice = decodeInvoice(payment.bolt11);
|
|
1515
|
+
if (invoice.paymentHash === null || !equalIgnoringCase(invoice.paymentHash, payment.paymentHash)) {
|
|
1516
|
+
throw cheat("hash_mismatch");
|
|
1517
|
+
}
|
|
1518
|
+
if (invoice.amountMsat !== asked.amountMsat) {
|
|
1519
|
+
throw cheat("amount_mismatch");
|
|
1520
|
+
}
|
|
1521
|
+
if (askedAt !== void 0 && (invoice.issuedAt ?? 0) < askedAt - ISSUED_SKEW_SECS) {
|
|
1522
|
+
throw cheat("invoice_stale");
|
|
1523
|
+
}
|
|
1524
|
+
const payRequest = await payRequestFor(listed2, payment);
|
|
1525
|
+
if (typeof payRequest.metadata !== "string" || typeof payRequest.callback !== "string") {
|
|
1526
|
+
throw new UnverifiedRecipientError(payment.lnAddress, payment.id, "no payRequest served");
|
|
1527
|
+
}
|
|
1528
|
+
if (!namesTheAddress(payRequest.metadata, listed2)) {
|
|
1529
|
+
throw new UnverifiedRecipientError(
|
|
1530
|
+
payment.lnAddress,
|
|
1531
|
+
payment.id,
|
|
1532
|
+
"its metadata names no text/identifier for this address, so no invoice binds to it"
|
|
1533
|
+
);
|
|
1534
|
+
}
|
|
1535
|
+
if (invoice.descriptionHash !== sha256Hex(payRequest.metadata)) {
|
|
1536
|
+
throw cheat("description_hash_mismatch");
|
|
1537
|
+
}
|
|
1538
|
+
if (!sameOrigin(payment.verifyUrl, payRequest.callback)) {
|
|
1539
|
+
throw cheat("verify_url_foreign");
|
|
1540
|
+
}
|
|
1541
|
+
const issued = await reachable(payment.verifyUrl, payment);
|
|
1542
|
+
if (typeof issued.pr !== "string" || !equalIgnoringCase(issued.pr, payment.bolt11)) {
|
|
1543
|
+
throw cheat("invoice_not_issued");
|
|
1544
|
+
}
|
|
1545
|
+
if (askedAt !== void 0 && issued.settled === true) {
|
|
1546
|
+
throw cheat("invoice_settled");
|
|
1547
|
+
}
|
|
1548
|
+
}
|
|
1549
|
+
function namesTheAddress(metadata, address) {
|
|
1550
|
+
try {
|
|
1551
|
+
const entries = JSON.parse(metadata);
|
|
1552
|
+
return Array.isArray(entries) && entries.some(
|
|
1553
|
+
(entry) => Array.isArray(entry) && NAMES_AN_ADDRESS.includes(entry[0]) && typeof entry[1] === "string" && equalIgnoringCase(entry[1], address)
|
|
1554
|
+
);
|
|
1555
|
+
} catch {
|
|
1556
|
+
return false;
|
|
1557
|
+
}
|
|
1558
|
+
}
|
|
1559
|
+
async function proveSettlement(payment, asked) {
|
|
1560
|
+
await proveOrigin(payment, asked);
|
|
1561
|
+
const verified = await reachable(payment.verifyUrl, payment);
|
|
1562
|
+
if (verified.settled !== true || typeof verified.preimage !== "string") {
|
|
1563
|
+
return null;
|
|
1564
|
+
}
|
|
1565
|
+
if (!preimageMatchesHash(verified.preimage, payment.paymentHash)) {
|
|
1566
|
+
throw new GatewayCheatError("preimage_mismatch", payment.id);
|
|
1567
|
+
}
|
|
1568
|
+
return verified.preimage;
|
|
1569
|
+
}
|
|
1570
|
+
function carriesProof(report) {
|
|
1571
|
+
if (report.status !== "paid" || report.preimage === null) {
|
|
1572
|
+
return false;
|
|
1573
|
+
}
|
|
1574
|
+
if (report.bolt11 !== void 0 && report.bolt11 !== null) {
|
|
1575
|
+
const invoiceHash = decodeInvoice(report.bolt11).paymentHash;
|
|
1576
|
+
if (invoiceHash === null || !equalIgnoringCase(invoiceHash, report.paymentHash)) {
|
|
1577
|
+
return false;
|
|
1578
|
+
}
|
|
1579
|
+
}
|
|
1580
|
+
return preimageMatchesHash(report.preimage, report.paymentHash);
|
|
1581
|
+
}
|
|
1582
|
+
function wrapFeeCeiling(amountMsat, allowance) {
|
|
1583
|
+
const proportional = amountMsat * (allowance?.proportion ?? DEFAULT_WRAP_PROPORTION);
|
|
1584
|
+
return msat(Math.max(allowance?.baseMsat ?? DEFAULT_WRAP_BASE_MSAT, Math.floor(proportional)));
|
|
1585
|
+
}
|
|
1586
|
+
function proveWrapped(wrapped, recipient, allowance) {
|
|
1587
|
+
const offered = decodeInvoice(wrapped);
|
|
1588
|
+
const owed = decodeInvoice(recipient);
|
|
1589
|
+
const refuse2 = (code, detail) => {
|
|
1590
|
+
throw new WrapRefusedError(code, detail);
|
|
1591
|
+
};
|
|
1592
|
+
if (offered.paymentHash === null || offered.amountMsat === null || offered.expiresAt === null) {
|
|
1593
|
+
refuse2("undecodable", "the wrapped invoice carries no hash, amount or expiry");
|
|
1594
|
+
}
|
|
1595
|
+
if (owed.paymentHash === null || owed.amountMsat === null || owed.expiresAt === null) {
|
|
1596
|
+
refuse2("undecodable", "the recipient's invoice carries no hash, amount or expiry");
|
|
1597
|
+
}
|
|
1598
|
+
if (offered.paymentHash?.toLowerCase() !== owed.paymentHash?.toLowerCase()) {
|
|
1599
|
+
refuse2("hash_mismatch", "settling the wrap would not settle the recipient");
|
|
1600
|
+
}
|
|
1601
|
+
const asked = offered.amountMsat ?? 0;
|
|
1602
|
+
const due = owed.amountMsat ?? 0;
|
|
1603
|
+
if (asked < due) {
|
|
1604
|
+
refuse2("amount_below_recipient", `${asked} msat cannot forward ${due} msat`);
|
|
1605
|
+
}
|
|
1606
|
+
const ceiling = wrapFeeCeiling(due, allowance);
|
|
1607
|
+
if (asked - due > ceiling) {
|
|
1608
|
+
refuse2("fee_above_allowance", `${asked - due} msat over ${due}, and ${ceiling} was allowed`);
|
|
1609
|
+
}
|
|
1610
|
+
if ((owed.expiresAt ?? 0) <= (offered.expiresAt ?? 0)) {
|
|
1611
|
+
refuse2("recipient_expires_first", "the operator could be paid and find it cannot deliver");
|
|
1612
|
+
}
|
|
1613
|
+
}
|
|
1614
|
+
async function payRequestFor(listed2, payment) {
|
|
1615
|
+
const at = listed2.indexOf("@");
|
|
1616
|
+
const name = listed2.slice(0, at);
|
|
1617
|
+
const domain = listed2.slice(at + 1).toLowerCase();
|
|
1618
|
+
if (at < 1 || !publicHttps(`https://${domain}/`)) {
|
|
1619
|
+
throw new UnverifiedRecipientError(payment.lnAddress, payment.id, "not a usable address");
|
|
1620
|
+
}
|
|
1621
|
+
return reachable(`https://${domain}/.well-known/lnurlp/${name}`, payment);
|
|
1622
|
+
}
|
|
1623
|
+
async function reachable(url, payment) {
|
|
1624
|
+
try {
|
|
1625
|
+
if (!publicHttps(url)) {
|
|
1626
|
+
throw new Error(`${url} is not a public https URL`);
|
|
1627
|
+
}
|
|
1628
|
+
const response = await fetch(url, {
|
|
1629
|
+
signal: AbortSignal.timeout(HTTP_TIMEOUT_MS2),
|
|
1630
|
+
headers: { accept: "application/json" }
|
|
1631
|
+
});
|
|
1632
|
+
if (response.redirected && !sameOrigin(response.url, url)) {
|
|
1633
|
+
throw new Error(`redirected off its own origin, to ${response.url}`);
|
|
1634
|
+
}
|
|
1635
|
+
if (!response.ok) {
|
|
1636
|
+
throw new Error(`answered ${response.status}`);
|
|
1637
|
+
}
|
|
1638
|
+
const body = JSON.parse(await boundedText(response));
|
|
1639
|
+
if (body === null || typeof body !== "object") {
|
|
1640
|
+
throw new Error("answered with no JSON object");
|
|
1641
|
+
}
|
|
1642
|
+
return body;
|
|
1643
|
+
} catch (cause) {
|
|
1644
|
+
throw new UnverifiedRecipientError(payment.lnAddress, payment.id, cause);
|
|
1645
|
+
}
|
|
1646
|
+
}
|
|
1647
|
+
async function boundedText(response) {
|
|
1648
|
+
const reader = response.body?.getReader();
|
|
1649
|
+
const decoder = new TextDecoder();
|
|
1650
|
+
let text3 = "";
|
|
1651
|
+
let bytes = 0;
|
|
1652
|
+
for (let chunk = await reader?.read(); chunk && !chunk.done; chunk = await reader?.read()) {
|
|
1653
|
+
bytes += chunk.value.length;
|
|
1654
|
+
if (bytes > BODY_LIMIT_BYTES) {
|
|
1655
|
+
await reader?.cancel();
|
|
1656
|
+
throw new Error(`answered with more than the ${BODY_LIMIT_BYTES} bytes a proof reads`);
|
|
1657
|
+
}
|
|
1658
|
+
text3 += decoder.decode(chunk.value, { stream: true });
|
|
1659
|
+
}
|
|
1660
|
+
return text3 + decoder.decode();
|
|
1661
|
+
}
|
|
1662
|
+
function equalIgnoringCase(one, other) {
|
|
1663
|
+
return one.toLowerCase() === other.toLowerCase();
|
|
1664
|
+
}
|
|
1665
|
+
|
|
1666
|
+
// src/wire.ts
|
|
1667
|
+
var ASSET_CODE = "BTC";
|
|
1668
|
+
var ASSET_SCALE = 11;
|
|
1669
|
+
var REASONS = /* @__PURE__ */ new Set([
|
|
1670
|
+
"address-unusable",
|
|
1671
|
+
"unreachable",
|
|
1672
|
+
"amount-not-accepted",
|
|
1673
|
+
"cannot-prove-delivery",
|
|
1674
|
+
"invoice-refused"
|
|
1675
|
+
]);
|
|
1676
|
+
function createRequestBody(priced2, webhookUrl, trigger, replay) {
|
|
833
1677
|
return JSON.stringify({
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
1678
|
+
ln_addresses: priced2.paidTo,
|
|
1679
|
+
incoming_amount: toAmount(priced2.amountMsat),
|
|
1680
|
+
webhook: webhookUrl ? { url: webhookUrl } : void 0,
|
|
837
1681
|
trigger: trigger ?? void 0,
|
|
838
|
-
replay
|
|
839
|
-
sealed: params.sealed,
|
|
840
|
-
webhook: params.webhookUrl ? { url: params.webhookUrl } : void 0
|
|
1682
|
+
replay
|
|
841
1683
|
});
|
|
842
1684
|
}
|
|
843
|
-
function
|
|
1685
|
+
function quoteRequestBody(priced2) {
|
|
1686
|
+
return JSON.stringify({
|
|
1687
|
+
ln_addresses: priced2.paidTo,
|
|
1688
|
+
amount: toAmount(priced2.amountMsat)
|
|
1689
|
+
});
|
|
1690
|
+
}
|
|
1691
|
+
function quoteFromWire(body) {
|
|
844
1692
|
const wire = asObject(body);
|
|
845
1693
|
if (wire === null) {
|
|
846
1694
|
return null;
|
|
847
1695
|
}
|
|
848
|
-
const
|
|
849
|
-
const
|
|
850
|
-
const
|
|
851
|
-
const
|
|
852
|
-
const
|
|
853
|
-
|
|
1696
|
+
const lnAddress = text2(wire["ln_address"]);
|
|
1697
|
+
const amountMsat = msatFrom(wire["amount"], 1);
|
|
1698
|
+
const feeMsat = msatFrom(wire["fee"], 0);
|
|
1699
|
+
const minMsat = msatFrom(wire["min_amount"], 0);
|
|
1700
|
+
const maxMsat = msatFrom(wire["max_amount"], 0);
|
|
1701
|
+
const metadata = text2(wire["metadata"]);
|
|
1702
|
+
const refusals3 = refusalsFrom(wire["refusals"]);
|
|
1703
|
+
if (lnAddress === null || amountMsat === null || feeMsat === null) {
|
|
854
1704
|
return null;
|
|
855
1705
|
}
|
|
856
|
-
if (
|
|
1706
|
+
if (minMsat === null || maxMsat === null || metadata === null || refusals3 === null) {
|
|
857
1707
|
return null;
|
|
858
1708
|
}
|
|
859
|
-
return {
|
|
1709
|
+
return { lnAddress, amountMsat, feeMsat, minMsat, maxMsat, metadata, refusals: refusals3 };
|
|
860
1710
|
}
|
|
861
|
-
function
|
|
1711
|
+
function paymentFromWire(body) {
|
|
862
1712
|
const wire = asObject(body);
|
|
863
1713
|
if (wire === null) {
|
|
864
1714
|
return null;
|
|
865
1715
|
}
|
|
866
|
-
const id =
|
|
867
|
-
const paymentHash =
|
|
868
|
-
const verifyUrl =
|
|
1716
|
+
const id = text2(wire["id"]);
|
|
1717
|
+
const paymentHash = text2(wire["payment_hash"]);
|
|
1718
|
+
const verifyUrl = text2(wire["verify_url"]);
|
|
869
1719
|
const expiresAt = secondsFrom(wire["expires_at"]);
|
|
870
1720
|
const createdAt = secondsFrom(wire["created_at"]);
|
|
871
1721
|
const status = wire["status"];
|
|
@@ -883,41 +1733,80 @@ function triggerEventFromWire(body) {
|
|
|
883
1733
|
if (sealed !== null && typeof sealed !== "string") {
|
|
884
1734
|
return null;
|
|
885
1735
|
}
|
|
886
|
-
|
|
1736
|
+
const reported = {
|
|
887
1737
|
id,
|
|
888
|
-
|
|
1738
|
+
status,
|
|
889
1739
|
paymentHash,
|
|
890
1740
|
verifyUrl,
|
|
891
|
-
status,
|
|
892
1741
|
preimage,
|
|
893
1742
|
expiresAt,
|
|
894
1743
|
createdAt,
|
|
895
|
-
sealed
|
|
896
|
-
lnAddress: text(wire["ln_address"]),
|
|
897
|
-
amountMsat: wire["incoming_amount"] === void 0 ? null : msatFrom(wire["incoming_amount"], 1)
|
|
1744
|
+
sealed
|
|
898
1745
|
};
|
|
1746
|
+
const lnAddress = text2(wire["ln_address"]);
|
|
1747
|
+
const amountMsat = wire["incoming_amount"] === void 0 ? null : msatFrom(wire["incoming_amount"], 1);
|
|
1748
|
+
const bolt11 = text2(wire["bolt11"]);
|
|
1749
|
+
if (lnAddress !== null && amountMsat !== null && bolt11 !== null) {
|
|
1750
|
+
return { ...reported, kind: "minted", lnAddress, amountMsat, bolt11 };
|
|
1751
|
+
}
|
|
1752
|
+
if (lnAddress === null && amountMsat === null && bolt11 === null) {
|
|
1753
|
+
return { ...reported, kind: "watched", lnAddress: null, amountMsat: null, bolt11: null };
|
|
1754
|
+
}
|
|
1755
|
+
return null;
|
|
1756
|
+
}
|
|
1757
|
+
function mintedFromWire(body) {
|
|
1758
|
+
const payment = paymentFromWire(body);
|
|
1759
|
+
return payment !== null && payment.kind === "minted" ? payment : null;
|
|
1760
|
+
}
|
|
1761
|
+
function watchRequestBody(handover, trigger) {
|
|
1762
|
+
const expiresAt = new Date(handover.expiresAt * 1e3);
|
|
1763
|
+
if (Number.isNaN(expiresAt.getTime())) {
|
|
1764
|
+
throw new TypeError(
|
|
1765
|
+
`expiresAt must be a usable unix time in seconds, got ${handover.expiresAt}`
|
|
1766
|
+
);
|
|
1767
|
+
}
|
|
1768
|
+
return JSON.stringify({
|
|
1769
|
+
payment_hash: handover.paymentHash,
|
|
1770
|
+
verify_url: handover.verifyUrl,
|
|
1771
|
+
expires_at: expiresAt.toISOString(),
|
|
1772
|
+
trigger: trigger ?? void 0,
|
|
1773
|
+
replay: handover.replay,
|
|
1774
|
+
sealed: handover.sealed,
|
|
1775
|
+
webhook: handover.webhookUrl ? { url: handover.webhookUrl } : void 0
|
|
1776
|
+
});
|
|
1777
|
+
}
|
|
1778
|
+
function settlementFromWire(body) {
|
|
1779
|
+
const wire = asObject(body);
|
|
1780
|
+
if (wire === null) {
|
|
1781
|
+
return null;
|
|
1782
|
+
}
|
|
1783
|
+
const id = text2(wire["id"]);
|
|
1784
|
+
const paymentHash = text2(wire["payment_hash"]);
|
|
1785
|
+
const settledAt = secondsFrom(wire["settled_at"]);
|
|
1786
|
+
const status = wire["status"];
|
|
1787
|
+
const preimage = wire["preimage"] ?? null;
|
|
1788
|
+
if (id === null || paymentHash === null || settledAt === null || !isStatus(status)) {
|
|
1789
|
+
return null;
|
|
1790
|
+
}
|
|
1791
|
+
if (preimage !== null && typeof preimage !== "string") {
|
|
1792
|
+
return null;
|
|
1793
|
+
}
|
|
1794
|
+
return { id, status, paymentHash, preimage, settledAt };
|
|
899
1795
|
}
|
|
900
1796
|
function socketTicketFromWire(body) {
|
|
901
1797
|
const wire = asObject(body);
|
|
902
1798
|
if (wire === null) {
|
|
903
1799
|
return null;
|
|
904
1800
|
}
|
|
905
|
-
const ticket =
|
|
1801
|
+
const ticket = text2(wire["ticket"]);
|
|
906
1802
|
const expiresAt = secondsFrom(wire["expires_at"]);
|
|
907
1803
|
if (ticket === null || expiresAt === null) {
|
|
908
1804
|
return null;
|
|
909
1805
|
}
|
|
910
1806
|
return { ticket, expiresAt };
|
|
911
1807
|
}
|
|
912
|
-
function
|
|
913
|
-
|
|
914
|
-
if (said === "minted" || said === "watched") {
|
|
915
|
-
return said;
|
|
916
|
-
}
|
|
917
|
-
return text(wire["ln_address"]) === null ? "watched" : "minted";
|
|
918
|
-
}
|
|
919
|
-
function toAmount(msat) {
|
|
920
|
-
return { value: String(msat), asset_code: ASSET_CODE, asset_scale: ASSET_SCALE };
|
|
1808
|
+
function toAmount(msat2) {
|
|
1809
|
+
return { value: String(msat2), asset_code: ASSET_CODE, asset_scale: ASSET_SCALE };
|
|
921
1810
|
}
|
|
922
1811
|
function asObject(value) {
|
|
923
1812
|
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
@@ -925,7 +1814,7 @@ function asObject(value) {
|
|
|
925
1814
|
}
|
|
926
1815
|
return value;
|
|
927
1816
|
}
|
|
928
|
-
function
|
|
1817
|
+
function text2(value) {
|
|
929
1818
|
return typeof value === "string" ? value : null;
|
|
930
1819
|
}
|
|
931
1820
|
function msatFrom(value, least) {
|
|
@@ -940,8 +1829,8 @@ function msatFrom(value, least) {
|
|
|
940
1829
|
if (typeof digits !== "string" || !/^[0-9]+$/.test(digits)) {
|
|
941
1830
|
return null;
|
|
942
1831
|
}
|
|
943
|
-
const
|
|
944
|
-
return Number.isSafeInteger(
|
|
1832
|
+
const msat2 = Number(digits);
|
|
1833
|
+
return Number.isSafeInteger(msat2) && msat2 >= least ? msat2 : null;
|
|
945
1834
|
}
|
|
946
1835
|
function refusalsFrom(value) {
|
|
947
1836
|
if (!Array.isArray(value)) {
|
|
@@ -950,7 +1839,7 @@ function refusalsFrom(value) {
|
|
|
950
1839
|
const refusals3 = [];
|
|
951
1840
|
for (const entry of value) {
|
|
952
1841
|
const failure = asObject(entry);
|
|
953
|
-
const address = failure === null ? null :
|
|
1842
|
+
const address = failure === null ? null : text2(failure["address"]);
|
|
954
1843
|
const reason = failure?.["reason"];
|
|
955
1844
|
if (address === null || typeof reason !== "string" || !REASONS.has(reason)) {
|
|
956
1845
|
return null;
|
|
@@ -971,118 +1860,70 @@ function isStatus(value) {
|
|
|
971
1860
|
}
|
|
972
1861
|
|
|
973
1862
|
// src/webhook.ts
|
|
974
|
-
var
|
|
975
|
-
var
|
|
1863
|
+
var SIGNATURE_HEADER2 = "x-signature";
|
|
1864
|
+
var TIMESTAMP_HEADER2 = "x-timestamp";
|
|
976
1865
|
var GATEWAY_KEY_PREFIX = "ed25519=";
|
|
977
1866
|
var DEFAULT_TOLERANCE_SECS = 300;
|
|
978
1867
|
var CHALLENGE = "webhook-challenge";
|
|
979
1868
|
var VERIFY_CHALLENGE = "verify-challenge";
|
|
980
|
-
async function
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
}
|
|
984
|
-
if (!signature.startsWith(GATEWAY_KEY_PREFIX)) {
|
|
985
|
-
return false;
|
|
986
|
-
}
|
|
987
|
-
return await verifyHex(
|
|
988
|
-
credential.publicKey.toLowerCase(),
|
|
989
|
-
signature.slice(GATEWAY_KEY_PREFIX.length).toLowerCase(),
|
|
990
|
-
signed(timestamp, body)
|
|
991
|
-
);
|
|
1869
|
+
async function readPayment(request, credential, options = {}) {
|
|
1870
|
+
const body = await believable(request, credential, options);
|
|
1871
|
+
return body === null ? null : proved(decoded(body, paymentFromWire));
|
|
992
1872
|
}
|
|
993
|
-
async function
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
}
|
|
997
|
-
const text2 = typeof body === "string" ? body : new TextDecoder().decode(body);
|
|
998
|
-
try {
|
|
999
|
-
return paymentFromWire(JSON.parse(text2));
|
|
1000
|
-
} catch {
|
|
1001
|
-
return null;
|
|
1002
|
-
}
|
|
1873
|
+
async function readSettlement(request, credential, options = {}) {
|
|
1874
|
+
const body = await believable(request, credential, options);
|
|
1875
|
+
return body === null ? null : proved(decoded(body, settlementFromWire));
|
|
1003
1876
|
}
|
|
1004
|
-
async function
|
|
1005
|
-
const
|
|
1006
|
-
const
|
|
1007
|
-
|
|
1008
|
-
return null;
|
|
1009
|
-
}
|
|
1010
|
-
return parseWebhook(await request.text(), signature, credential, timestamp, options);
|
|
1877
|
+
async function answerWebhookChallenge(request, credential, options = {}) {
|
|
1878
|
+
const body = await believable(request, credential, options);
|
|
1879
|
+
const nonce = body === null ? null : nonceOf(body, CHALLENGE);
|
|
1880
|
+
return nonce === null ? null : answered2(nonce);
|
|
1011
1881
|
}
|
|
1012
|
-
async function
|
|
1013
|
-
if (
|
|
1014
|
-
return null;
|
|
1015
|
-
}
|
|
1016
|
-
const text2 = typeof body === "string" ? body : new TextDecoder().decode(body);
|
|
1017
|
-
try {
|
|
1018
|
-
return triggerEventFromWire(JSON.parse(text2));
|
|
1019
|
-
} catch {
|
|
1882
|
+
async function answerVerifyChallenge(request) {
|
|
1883
|
+
if (request.method !== "POST") {
|
|
1020
1884
|
return null;
|
|
1021
1885
|
}
|
|
1886
|
+
const nonce = nonceOf(await request.clone().text(), VERIFY_CHALLENGE);
|
|
1887
|
+
return nonce === null ? null : answered2(nonce);
|
|
1022
1888
|
}
|
|
1023
|
-
async function
|
|
1024
|
-
const signature = request.headers.get(
|
|
1025
|
-
const timestamp = request.headers.get(
|
|
1889
|
+
async function believable(request, credential, options) {
|
|
1890
|
+
const signature = request.headers.get(SIGNATURE_HEADER2);
|
|
1891
|
+
const timestamp = request.headers.get(TIMESTAMP_HEADER2);
|
|
1026
1892
|
if (signature === null || timestamp === null) {
|
|
1027
1893
|
return null;
|
|
1028
1894
|
}
|
|
1029
|
-
|
|
1030
|
-
}
|
|
1031
|
-
async function parseSettlement(body, signature, credential, timestamp, options = {}) {
|
|
1032
|
-
if (!await verifyWebhookSignature(body, signature, credential, timestamp, options)) {
|
|
1033
|
-
return null;
|
|
1034
|
-
}
|
|
1035
|
-
try {
|
|
1036
|
-
return settlementFromWire(JSON.parse(textOf(body)));
|
|
1037
|
-
} catch {
|
|
1895
|
+
if (!recent(timestamp, options.toleranceSecs ?? DEFAULT_TOLERANCE_SECS)) {
|
|
1038
1896
|
return null;
|
|
1039
1897
|
}
|
|
1040
|
-
|
|
1041
|
-
async function parseSettlementRequest(request, credential, options = {}) {
|
|
1042
|
-
const signature = request.headers.get(SIGNATURE_HEADER);
|
|
1043
|
-
const timestamp = request.headers.get(TIMESTAMP_HEADER);
|
|
1044
|
-
if (signature === null || timestamp === null) {
|
|
1898
|
+
if (!signature.startsWith(GATEWAY_KEY_PREFIX)) {
|
|
1045
1899
|
return null;
|
|
1046
1900
|
}
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
return
|
|
1901
|
+
const body = await request.clone().text();
|
|
1902
|
+
const signed = await verifyHex(
|
|
1903
|
+
credential.publicKey.toLowerCase(),
|
|
1904
|
+
signature.slice(GATEWAY_KEY_PREFIX.length).toLowerCase(),
|
|
1905
|
+
stamped(timestamp, body)
|
|
1906
|
+
);
|
|
1907
|
+
return signed ? body : null;
|
|
1054
1908
|
}
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
return null;
|
|
1058
|
-
}
|
|
1059
|
-
const nonce = challenged(typeof body === "string" ? body : new TextDecoder().decode(body));
|
|
1060
|
-
return nonce === null ? null : JSON.stringify({ nonce });
|
|
1909
|
+
function proved(read) {
|
|
1910
|
+
return read !== null && read.status === "paid" && !carriesProof(read) ? null : read;
|
|
1061
1911
|
}
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1912
|
+
function decoded(body, from) {
|
|
1913
|
+
try {
|
|
1914
|
+
return from(JSON.parse(body));
|
|
1915
|
+
} catch {
|
|
1066
1916
|
return null;
|
|
1067
1917
|
}
|
|
1068
|
-
const answer = await answerWebhookChallenge(
|
|
1069
|
-
await request.clone().text(),
|
|
1070
|
-
signature,
|
|
1071
|
-
credential,
|
|
1072
|
-
timestamp,
|
|
1073
|
-
options
|
|
1074
|
-
);
|
|
1075
|
-
return answer === null ? null : new Response(answer, { headers: { "content-type": "application/json" } });
|
|
1076
1918
|
}
|
|
1077
|
-
function
|
|
1078
|
-
return
|
|
1079
|
-
}
|
|
1080
|
-
|
|
1081
|
-
return nonceOf(text2, CHALLENGE);
|
|
1919
|
+
function answered2(nonce) {
|
|
1920
|
+
return new Response(JSON.stringify({ nonce }), {
|
|
1921
|
+
headers: { "content-type": "application/json" }
|
|
1922
|
+
});
|
|
1082
1923
|
}
|
|
1083
|
-
function nonceOf(
|
|
1924
|
+
function nonceOf(text3, type) {
|
|
1084
1925
|
try {
|
|
1085
|
-
const said = JSON.parse(
|
|
1926
|
+
const said = JSON.parse(text3);
|
|
1086
1927
|
if (said["type"] !== type || typeof said["nonce"] !== "string") {
|
|
1087
1928
|
return null;
|
|
1088
1929
|
}
|
|
@@ -1091,17 +1932,6 @@ function nonceOf(text2, type) {
|
|
|
1091
1932
|
return null;
|
|
1092
1933
|
}
|
|
1093
1934
|
}
|
|
1094
|
-
function answerVerifyChallenge(body) {
|
|
1095
|
-
const nonce = nonceOf(body, VERIFY_CHALLENGE);
|
|
1096
|
-
return nonce === null ? null : JSON.stringify({ nonce });
|
|
1097
|
-
}
|
|
1098
|
-
async function answerVerifyChallengeRequest(request) {
|
|
1099
|
-
if (request.method !== "POST") {
|
|
1100
|
-
return null;
|
|
1101
|
-
}
|
|
1102
|
-
const answer = answerVerifyChallenge(await request.clone().text());
|
|
1103
|
-
return answer === null ? null : new Response(answer, { headers: { "content-type": "application/json" } });
|
|
1104
|
-
}
|
|
1105
1935
|
function recent(timestamp, toleranceSecs) {
|
|
1106
1936
|
const sent = Number(timestamp);
|
|
1107
1937
|
if (!Number.isFinite(sent)) {
|
|
@@ -1109,14 +1939,8 @@ function recent(timestamp, toleranceSecs) {
|
|
|
1109
1939
|
}
|
|
1110
1940
|
return Math.abs(Math.floor(Date.now() / 1e3) - sent) <= toleranceSecs;
|
|
1111
1941
|
}
|
|
1112
|
-
function
|
|
1113
|
-
|
|
1114
|
-
const stamp = encoder.encode(`${timestamp}.`);
|
|
1115
|
-
const rest = typeof body === "string" ? encoder.encode(body) : new Uint8Array(body);
|
|
1116
|
-
const payload = new Uint8Array(stamp.length + rest.length);
|
|
1117
|
-
payload.set(stamp);
|
|
1118
|
-
payload.set(rest, stamp.length);
|
|
1119
|
-
return payload;
|
|
1942
|
+
function stamped(timestamp, body) {
|
|
1943
|
+
return new TextEncoder().encode(`${timestamp}.${body}`);
|
|
1120
1944
|
}
|
|
1121
1945
|
|
|
1122
1946
|
// src/bank.ts
|
|
@@ -1125,83 +1949,120 @@ var DEFAULT_LOOK_BACK_SECS = 7 * 24 * 60 * 60;
|
|
|
1125
1949
|
var DEFAULT_POLL_EVERY_SECS = 30;
|
|
1126
1950
|
var IBAN = /^[A-Z]{2}[0-9]{2}[0-9A-Z]{8,30}$/;
|
|
1127
1951
|
var FORBIDDEN_IN_SPD = "*";
|
|
1128
|
-
|
|
1952
|
+
var MAX_REFERENCE_CHARS = 60;
|
|
1953
|
+
async function bankTransfer(gateway, params) {
|
|
1129
1954
|
const currency = params.currency ?? DEFAULT_CURRENCY;
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
const
|
|
1134
|
-
const
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1955
|
+
const iban = accountOf(params.iban);
|
|
1956
|
+
refuseUnusable(params, iban, currency);
|
|
1957
|
+
await refuseAnOpenGateway(gateway, params);
|
|
1958
|
+
const spd = shortPaymentDescriptor(params, iban, currency);
|
|
1959
|
+
const subject = subjectOf({
|
|
1960
|
+
iban,
|
|
1961
|
+
amountMinor: params.amountMinor,
|
|
1962
|
+
currency,
|
|
1963
|
+
expiresAt: params.expiresAt,
|
|
1964
|
+
reference: params.reference
|
|
1965
|
+
});
|
|
1966
|
+
const verifyUrl = await answeredAt(params, subject);
|
|
1967
|
+
const paymentHash = hashOf(await hmacHex(params.secret, `preimage|${subject}`));
|
|
1968
|
+
const watched = await gateway.watch({
|
|
1969
|
+
paymentHash,
|
|
1142
1970
|
verifyUrl,
|
|
1143
1971
|
expiresAt: params.expiresAt,
|
|
1144
1972
|
trigger: params.trigger,
|
|
1145
1973
|
replay: params.replay,
|
|
1146
|
-
sealed: params.sealed,
|
|
1974
|
+
sealed: params.sealed ? await seal(params.sealed.secret, JSON.stringify(params.sealed.data), paymentHash) : void 0,
|
|
1147
1975
|
webhookUrl: params.webhookUrl
|
|
1148
1976
|
});
|
|
1149
|
-
return { id: watched.id, paymentHash
|
|
1977
|
+
return { id: watched.id, paymentHash, verifyUrl, spd };
|
|
1150
1978
|
}
|
|
1151
1979
|
function bankVerifyEndpoint(config) {
|
|
1980
|
+
refuseAWeakSecret(config.secret);
|
|
1981
|
+
const answersFor = accountOf(config.iban);
|
|
1982
|
+
if (!IBAN.test(answersFor)) {
|
|
1983
|
+
throw new Error(`${config.iban} is not an IBAN, so this endpoint answers for no account`);
|
|
1984
|
+
}
|
|
1152
1985
|
const paced = {
|
|
1153
1986
|
"cache-control": `max-age=${config.pollEverySecs ?? DEFAULT_POLL_EVERY_SECS}`
|
|
1154
1987
|
};
|
|
1155
1988
|
return async (request) => {
|
|
1156
|
-
const consented = await
|
|
1989
|
+
const consented = await answerVerifyChallenge(request);
|
|
1157
1990
|
if (consented !== null) {
|
|
1158
1991
|
return consented;
|
|
1159
1992
|
}
|
|
1160
|
-
const
|
|
1161
|
-
if (
|
|
1993
|
+
const sealed = new URL(request.url).searchParams.get("q");
|
|
1994
|
+
if (sealed === null) {
|
|
1162
1995
|
return Response.json({ settled: false }, { status: 400 });
|
|
1163
1996
|
}
|
|
1164
|
-
const subject =
|
|
1165
|
-
const
|
|
1166
|
-
if (
|
|
1997
|
+
const subject = await unseal(config.secret, sealed);
|
|
1998
|
+
const asked = subject === null ? null : askedFrom(subject);
|
|
1999
|
+
if (asked === null || asked.iban !== answersFor) {
|
|
1167
2000
|
return Response.json({ settled: false }, { status: 403 });
|
|
1168
2001
|
}
|
|
1169
|
-
const
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
{ headers: paced }
|
|
1177
|
-
);
|
|
2002
|
+
const preimage = await creditedPreimage({
|
|
2003
|
+
secret: config.secret,
|
|
2004
|
+
asked,
|
|
2005
|
+
statement: config.statement,
|
|
2006
|
+
lookBackSecs: config.lookBackSecs
|
|
2007
|
+
});
|
|
2008
|
+
return preimage === null ? Response.json({ settled: false }, { headers: paced }) : Response.json({ settled: true, preimage }, { headers: paced });
|
|
1178
2009
|
};
|
|
1179
2010
|
}
|
|
1180
|
-
function
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
const
|
|
1185
|
-
|
|
2011
|
+
async function answeredAt(params, subject) {
|
|
2012
|
+
if (params.answerBy === "agent") {
|
|
2013
|
+
return `agent:${(await callerKey(params.secret)).publicKeyHex}`;
|
|
2014
|
+
}
|
|
2015
|
+
const polling = new URL(params.verifyUrl ?? "");
|
|
2016
|
+
polling.searchParams.set("q", await sealStable(params.secret, subject));
|
|
2017
|
+
return polling.toString();
|
|
2018
|
+
}
|
|
2019
|
+
async function creditedPreimage(params) {
|
|
2020
|
+
const since = unixNow() - (params.lookBackSecs ?? DEFAULT_LOOK_BACK_SECS);
|
|
2021
|
+
const landed = (await params.statement(since)).some((credit) => pays(credit, params.asked));
|
|
2022
|
+
if (!landed) {
|
|
2023
|
+
return null;
|
|
2024
|
+
}
|
|
2025
|
+
return await hmacHex(params.secret, `preimage|${subjectOf(params.asked)}`);
|
|
2026
|
+
}
|
|
2027
|
+
function askedFrom(subject) {
|
|
2028
|
+
const [iban, minorText, currency, expiresText, ...rest] = subject.split("|");
|
|
2029
|
+
const reference = rest.join("|");
|
|
2030
|
+
const amountMinor = Number(minorText);
|
|
2031
|
+
const expiresAt = Number(expiresText);
|
|
2032
|
+
if (!iban || !currency || reference.length === 0) {
|
|
1186
2033
|
return null;
|
|
1187
2034
|
}
|
|
1188
|
-
if (!Number.isInteger(
|
|
2035
|
+
if (!Number.isInteger(amountMinor) || amountMinor <= 0 || !Number.isSafeInteger(expiresAt)) {
|
|
1189
2036
|
return null;
|
|
1190
2037
|
}
|
|
1191
|
-
return { reference, amountMinor
|
|
2038
|
+
return { iban, reference, amountMinor, currency, expiresAt };
|
|
1192
2039
|
}
|
|
1193
2040
|
function pays(credit, asked) {
|
|
1194
|
-
return credit.amountMinor === asked.amountMinor && credit.currency.toUpperCase() === asked.currency.toUpperCase() && credit.reference
|
|
2041
|
+
return credit.amountMinor === asked.amountMinor && credit.currency.toUpperCase() === asked.currency.toUpperCase() && namesOnly(credit.reference, asked.reference);
|
|
2042
|
+
}
|
|
2043
|
+
function namesOnly(written, reference) {
|
|
2044
|
+
const wanted = reference.toUpperCase();
|
|
2045
|
+
const alike = [...written.toUpperCase().matchAll(wordsShapedLike(wanted))].map(([word]) => word);
|
|
2046
|
+
return alike.includes(wanted) && (!/\d/.test(wanted) || alike.every((word) => word === wanted));
|
|
2047
|
+
}
|
|
2048
|
+
function wordsShapedLike(reference) {
|
|
2049
|
+
const shape = [...reference].map((char) => {
|
|
2050
|
+
if (/\d/.test(char)) {
|
|
2051
|
+
return "\\d";
|
|
2052
|
+
}
|
|
2053
|
+
return /\p{L}/u.test(char) ? "\\p{L}" : char.replace(/[\^$\\.*+?()[\]{}|/]/g, "\\$&");
|
|
2054
|
+
}).join("");
|
|
2055
|
+
return new RegExp(`(?<![\\p{L}\\p{N}])${shape}(?![\\p{L}\\p{N}])`, "gu");
|
|
1195
2056
|
}
|
|
1196
|
-
function subjectOf(
|
|
1197
|
-
return `${
|
|
2057
|
+
function subjectOf({ iban, amountMinor, currency, expiresAt, reference }) {
|
|
2058
|
+
return `${iban}|${amountMinor}|${currency.toUpperCase()}|${expiresAt}|${reference}`;
|
|
1198
2059
|
}
|
|
1199
2060
|
function hashOf(preimage) {
|
|
1200
2061
|
return bytesToHex(sha256(hexToBytes(preimage)));
|
|
1201
2062
|
}
|
|
1202
|
-
function shortPaymentDescriptor(params, currency) {
|
|
2063
|
+
function shortPaymentDescriptor(params, iban, currency) {
|
|
1203
2064
|
const fields = [
|
|
1204
|
-
`ACC:${
|
|
2065
|
+
`ACC:${iban}`,
|
|
1205
2066
|
`AM:${major(params.amountMinor, currency)}`,
|
|
1206
2067
|
`CC:${currency.toUpperCase()}`,
|
|
1207
2068
|
`MSG:${params.reference}`
|
|
@@ -1214,27 +2075,36 @@ function shortPaymentDescriptor(params, currency) {
|
|
|
1214
2075
|
function major(amountMinor, currency) {
|
|
1215
2076
|
return (amountMinor / minorScaleOf(currency)).toFixed(minorUnitsOf(currency));
|
|
1216
2077
|
}
|
|
1217
|
-
async function refuseAnOpenGateway(params) {
|
|
2078
|
+
async function refuseAnOpenGateway(gateway, params) {
|
|
1218
2079
|
if (params.allowPublicGateway === true) {
|
|
1219
2080
|
return;
|
|
1220
2081
|
}
|
|
1221
|
-
if (await
|
|
2082
|
+
if (await gateway.refusesStrangers()) {
|
|
1222
2083
|
return;
|
|
1223
2084
|
}
|
|
1224
2085
|
throw new Error(
|
|
1225
|
-
"this gateway serves callers with no token, so it is not yours, and its operator would
|
|
2086
|
+
"this gateway serves callers with no token, so it is not yours, and its operator would learn every watch you place. Point at one that answers 401 to a stranger, or say allowPublicGateway"
|
|
1226
2087
|
);
|
|
1227
2088
|
}
|
|
1228
|
-
function refuseUnusable(params, currency) {
|
|
1229
|
-
|
|
2089
|
+
function refuseUnusable(params, iban, currency) {
|
|
2090
|
+
refuseAWeakSecret(params.secret);
|
|
2091
|
+
if (params.answerBy !== "agent" && params.verifyUrl === void 0) {
|
|
2092
|
+
throw new Error("a transfer the gateway polls needs the verify url it is polled at");
|
|
2093
|
+
}
|
|
2094
|
+
if (!IBAN.test(iban)) {
|
|
1230
2095
|
throw new Error(`${params.iban} is not an IBAN`);
|
|
1231
2096
|
}
|
|
1232
|
-
if (!Number.
|
|
2097
|
+
if (!Number.isSafeInteger(params.amountMinor) || params.amountMinor <= 0) {
|
|
1233
2098
|
throw new Error("amountMinor must be a whole number of minor units above zero");
|
|
1234
2099
|
}
|
|
1235
2100
|
if (params.reference.length === 0) {
|
|
1236
2101
|
throw new Error("a transfer with no reference cannot be found");
|
|
1237
2102
|
}
|
|
2103
|
+
if (params.reference.length > MAX_REFERENCE_CHARS) {
|
|
2104
|
+
throw new Error(
|
|
2105
|
+
`a reference is at most ${MAX_REFERENCE_CHARS} characters, as many as a QR payment's message carries and its sealed query hides`
|
|
2106
|
+
);
|
|
2107
|
+
}
|
|
1238
2108
|
if (params.reference.includes(FORBIDDEN_IN_SPD)) {
|
|
1239
2109
|
throw new Error("a reference cannot contain an asterisk, it separates the QR fields");
|
|
1240
2110
|
}
|
|
@@ -1242,226 +2112,533 @@ function refuseUnusable(params, currency) {
|
|
|
1242
2112
|
throw new Error("a variable symbol is up to ten digits");
|
|
1243
2113
|
}
|
|
1244
2114
|
}
|
|
2115
|
+
function accountOf(iban) {
|
|
2116
|
+
return iban.replace(/\s+/g, "").toUpperCase();
|
|
2117
|
+
}
|
|
1245
2118
|
function unixNow() {
|
|
1246
2119
|
return Math.floor(Date.now() / 1e3);
|
|
1247
2120
|
}
|
|
1248
2121
|
|
|
1249
|
-
//
|
|
1250
|
-
var
|
|
1251
|
-
var
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
async function callerKey(secret) {
|
|
1256
|
-
return await signingKeyFromSeed(hexToBytes(await hmacHex(secret, SIGNING_LABEL)));
|
|
2122
|
+
// src/qr.ts
|
|
2123
|
+
var import_uqr = require("uqr");
|
|
2124
|
+
var SAFE_COLOR = /^#(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$|^rgba?\([\d.,%/\s]+\)$|^[a-z]+$/i;
|
|
2125
|
+
function toLightningUri(destination) {
|
|
2126
|
+
const scanned = isLnAddress(destination) ? destination : destination.toUpperCase();
|
|
2127
|
+
return `LIGHTNING:${scanned}`;
|
|
1257
2128
|
}
|
|
1258
|
-
function
|
|
1259
|
-
return
|
|
2129
|
+
function invoiceToSvg(destination, options) {
|
|
2130
|
+
return svgOf(toLightningUri(destination), options);
|
|
1260
2131
|
}
|
|
1261
|
-
|
|
1262
|
-
const
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
2132
|
+
function svgOf(scanned, options) {
|
|
2133
|
+
const size = options?.size ?? 256;
|
|
2134
|
+
const color = options?.color ?? "#000";
|
|
2135
|
+
if (!SAFE_COLOR.test(color)) {
|
|
2136
|
+
throw new Error(`Invalid color: ${color}`);
|
|
2137
|
+
}
|
|
2138
|
+
return renderSvg(scanned, size, color);
|
|
2139
|
+
}
|
|
2140
|
+
function isLnAddress(destination) {
|
|
2141
|
+
const at = destination.indexOf("@");
|
|
2142
|
+
return at > 0 && destination.slice(at + 1).includes(".");
|
|
2143
|
+
}
|
|
2144
|
+
function renderSvg(content, size, color) {
|
|
2145
|
+
const { data } = (0, import_uqr.encode)(content);
|
|
2146
|
+
const modules = data.length;
|
|
2147
|
+
const cellSize = size / modules;
|
|
2148
|
+
let path = "";
|
|
2149
|
+
for (let y = 0; y < modules; y++) {
|
|
2150
|
+
for (let x = 0; x < modules; x++) {
|
|
2151
|
+
if (data[y][x]) {
|
|
2152
|
+
path += `M${x * cellSize},${y * cellSize}h${cellSize}v${cellSize}h-${cellSize}z`;
|
|
2153
|
+
}
|
|
2154
|
+
}
|
|
2155
|
+
}
|
|
2156
|
+
return [
|
|
2157
|
+
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${size} ${size}" width="${size}" height="${size}">`,
|
|
2158
|
+
`<rect width="100%" height="100%" fill="white"/>`,
|
|
2159
|
+
`<path d="${path}" fill="${color}"/>`,
|
|
2160
|
+
`</svg>`
|
|
2161
|
+
].join("");
|
|
2162
|
+
}
|
|
2163
|
+
|
|
2164
|
+
// src/relay.ts
|
|
2165
|
+
var DEFAULT_POLL_EVERY_SECS2 = 5;
|
|
2166
|
+
var WALLET = "w";
|
|
2167
|
+
function lightningVerifyEndpoint(config) {
|
|
2168
|
+
const paced = {
|
|
2169
|
+
"cache-control": `max-age=${config.pollEverySecs ?? DEFAULT_POLL_EVERY_SECS2}`
|
|
2170
|
+
};
|
|
2171
|
+
return async (request) => {
|
|
2172
|
+
const consented = await answerVerifyChallenge(request);
|
|
2173
|
+
if (consented !== null) {
|
|
2174
|
+
return consented;
|
|
2175
|
+
}
|
|
2176
|
+
const sealed = new URL(request.url).searchParams.get(WALLET);
|
|
2177
|
+
if (sealed === null) {
|
|
2178
|
+
return Response.json({ settled: false }, { status: 400 });
|
|
2179
|
+
}
|
|
2180
|
+
const opened = await unseal(config.secret, sealed);
|
|
2181
|
+
const wallet = opened === null ? null : relayedFrom(opened);
|
|
2182
|
+
if (wallet === null) {
|
|
2183
|
+
return Response.json({ settled: false }, { status: 403 });
|
|
2184
|
+
}
|
|
2185
|
+
const asked = await checkSettled(
|
|
2186
|
+
config.send ?? pinnedToTheAddressWeVerified,
|
|
2187
|
+
wallet.url,
|
|
2188
|
+
wallet.hash
|
|
2189
|
+
).catch(() => null);
|
|
2190
|
+
if (asked === null) {
|
|
2191
|
+
return Response.json({ settled: false }, { status: 502 });
|
|
2192
|
+
}
|
|
2193
|
+
return Response.json(
|
|
2194
|
+
{ settled: asked.preimage !== null, preimage: asked.preimage },
|
|
2195
|
+
{ headers: paced }
|
|
2196
|
+
);
|
|
1267
2197
|
};
|
|
1268
2198
|
}
|
|
1269
|
-
function
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
2199
|
+
function relayedFrom(opened) {
|
|
2200
|
+
try {
|
|
2201
|
+
const said = JSON.parse(opened);
|
|
2202
|
+
return typeof said?.url === "string" && typeof said.hash === "string" ? { url: said.url, hash: said.hash } : null;
|
|
2203
|
+
} catch {
|
|
2204
|
+
return null;
|
|
2205
|
+
}
|
|
2206
|
+
}
|
|
2207
|
+
async function relayedVerifyUrl(endpoint, wallet, secret) {
|
|
2208
|
+
const relayed = new URL(endpoint);
|
|
2209
|
+
relayed.searchParams.set(WALLET, await seal(secret, JSON.stringify(wallet)));
|
|
2210
|
+
return relayed.toString();
|
|
1273
2211
|
}
|
|
1274
2212
|
|
|
1275
|
-
// src/
|
|
1276
|
-
var
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
2213
|
+
// src/rail.ts
|
|
2214
|
+
var BANK = "bank";
|
|
2215
|
+
var LIGHTNING = "lightning";
|
|
2216
|
+
function bankRail(gateway, config) {
|
|
2217
|
+
return async (order) => {
|
|
2218
|
+
const expiresAt = config.expiresAt(order);
|
|
2219
|
+
const transfer = await bankTransfer(gateway, {
|
|
2220
|
+
secret: config.secret,
|
|
2221
|
+
iban: config.iban,
|
|
2222
|
+
verifyUrl: config.verifyUrl,
|
|
2223
|
+
reference: order.reference,
|
|
2224
|
+
amountMinor: order.amountMinor,
|
|
2225
|
+
currency: order.currency,
|
|
2226
|
+
expiresAt,
|
|
2227
|
+
trigger: config.trigger,
|
|
2228
|
+
replay: config.replay,
|
|
2229
|
+
sealed: config.sealed && { secret: config.sealed.secret, data: config.sealed.data(order) },
|
|
2230
|
+
variableSymbol: config.variableSymbol?.(order),
|
|
2231
|
+
webhookUrl: config.webhookUrl,
|
|
2232
|
+
allowPublicGateway: config.allowPublicGateway
|
|
2233
|
+
});
|
|
2234
|
+
return {
|
|
2235
|
+
id: transfer.id,
|
|
2236
|
+
paymentHash: transfer.paymentHash,
|
|
2237
|
+
rail: config.name ?? BANK,
|
|
2238
|
+
scan: transfer.spd,
|
|
2239
|
+
qr: transfer.spd,
|
|
2240
|
+
expiresAt
|
|
2241
|
+
};
|
|
2242
|
+
};
|
|
2243
|
+
}
|
|
2244
|
+
function lightningRail(gateway, config) {
|
|
2245
|
+
return async (order) => {
|
|
2246
|
+
const payment = await gateway.mint(
|
|
2247
|
+
{
|
|
2248
|
+
paidTo: config.paidTo,
|
|
2249
|
+
amount: await msatForOrder(order, config.amount, config.rate),
|
|
2250
|
+
webhookUrl: config.webhookUrl
|
|
2251
|
+
},
|
|
2252
|
+
{
|
|
2253
|
+
idempotencyKey: config.idempotencyKey?.(order),
|
|
2254
|
+
trigger: config.trigger,
|
|
2255
|
+
replay: config.replay
|
|
2256
|
+
}
|
|
2257
|
+
);
|
|
2258
|
+
return {
|
|
2259
|
+
id: payment.id,
|
|
2260
|
+
paymentHash: payment.paymentHash,
|
|
2261
|
+
rail: config.name ?? LIGHTNING,
|
|
2262
|
+
scan: payment.bolt11,
|
|
2263
|
+
qr: toLightningUri(payment.bolt11),
|
|
2264
|
+
expiresAt: payment.expiresAt
|
|
2265
|
+
};
|
|
2266
|
+
};
|
|
2267
|
+
}
|
|
2268
|
+
function blindLightningRail(gateway, config) {
|
|
2269
|
+
return async (order) => {
|
|
2270
|
+
const resolved2 = await invoiceFrom(
|
|
2271
|
+
config.paidTo,
|
|
2272
|
+
await msatForOrder(order, config.amount, config.rate),
|
|
2273
|
+
config.send
|
|
2274
|
+
);
|
|
2275
|
+
const relay = config.relayThrough;
|
|
2276
|
+
const watched = await gateway.watch({
|
|
2277
|
+
paymentHash: resolved2.paymentHash,
|
|
2278
|
+
verifyUrl: relay ? await relayedVerifyUrl(
|
|
2279
|
+
relay.endpoint,
|
|
2280
|
+
{ url: resolved2.verifyUrl, hash: resolved2.paymentHash },
|
|
2281
|
+
relay.secret
|
|
2282
|
+
) : resolved2.verifyUrl,
|
|
2283
|
+
expiresAt: resolved2.expiresAt,
|
|
2284
|
+
trigger: config.trigger,
|
|
2285
|
+
replay: config.replay,
|
|
2286
|
+
sealed: config.sealed ? await seal(
|
|
2287
|
+
config.sealed.secret,
|
|
2288
|
+
JSON.stringify(config.sealed.data(order)),
|
|
2289
|
+
resolved2.paymentHash
|
|
2290
|
+
) : void 0,
|
|
2291
|
+
webhookUrl: config.webhookUrl
|
|
2292
|
+
});
|
|
2293
|
+
return {
|
|
2294
|
+
id: watched.id,
|
|
2295
|
+
paymentHash: resolved2.paymentHash,
|
|
2296
|
+
rail: config.name ?? LIGHTNING,
|
|
2297
|
+
scan: resolved2.bolt11,
|
|
2298
|
+
qr: toLightningUri(resolved2.bolt11),
|
|
2299
|
+
expiresAt: resolved2.expiresAt
|
|
2300
|
+
};
|
|
2301
|
+
};
|
|
2302
|
+
}
|
|
2303
|
+
async function invoiceFrom(paidTo, amount, send = pinnedToTheAddressWeVerified) {
|
|
2304
|
+
const addresses = typeof paidTo === "string" ? [paidTo] : paidTo;
|
|
2305
|
+
try {
|
|
2306
|
+
return await resolve(send, addresses, await amountNow(amount));
|
|
2307
|
+
} catch (refused) {
|
|
2308
|
+
if (refused instanceof NoWalletAvailable) {
|
|
2309
|
+
throw new NoWalletAvailableError({ title: refused.message }, refused.wallets);
|
|
2310
|
+
}
|
|
2311
|
+
throw refused;
|
|
1284
2312
|
}
|
|
1285
|
-
}
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
super(`wrapped invoice refused: ${code}, ${detail}`);
|
|
1290
|
-
this.name = "WrapRefusedError";
|
|
1291
|
-
this.code = code;
|
|
2313
|
+
}
|
|
2314
|
+
async function msatForOrder(order, amount, rate) {
|
|
2315
|
+
if (amount !== void 0) {
|
|
2316
|
+
return await amountNow(amount(order));
|
|
1292
2317
|
}
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
super(`could not reach ${lnAddress} to verify the invoice: ${String(cause)}`);
|
|
1299
|
-
this.name = "UnverifiedRecipientError";
|
|
1300
|
-
this.lnAddress = lnAddress;
|
|
1301
|
-
this.paymentId = paymentId;
|
|
2318
|
+
return msat(msatFor(order.amountMinor, await (rate ?? medianOf())(order.currency)));
|
|
2319
|
+
}
|
|
2320
|
+
var Rails = class {
|
|
2321
|
+
constructor(gateway) {
|
|
2322
|
+
this.gateway = gateway;
|
|
1302
2323
|
}
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
status;
|
|
1308
|
-
detail;
|
|
1309
|
-
constructor(problem) {
|
|
1310
|
-
const title = problem.title ?? "Request failed";
|
|
1311
|
-
super(problem.detail ? `${title}: ${problem.detail}` : title);
|
|
1312
|
-
this.name = "ProblemError";
|
|
1313
|
-
this.type = problem.type ?? "about:blank";
|
|
1314
|
-
this.title = title;
|
|
1315
|
-
this.status = problem.status ?? 0;
|
|
1316
|
-
this.detail = problem.detail ?? null;
|
|
2324
|
+
gateway;
|
|
2325
|
+
/** Lightning, with the gateway minting against a priority list of addresses */
|
|
2326
|
+
lightning(config) {
|
|
2327
|
+
return lightningRail(this.gateway, config);
|
|
1317
2328
|
}
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
var IDEMPOTENCY_KEY_REUSED = "urn:problem-type:thunder-bridge:idempotency-key-reused";
|
|
1325
|
-
var PAYMENT_ALREADY_WATCHED = "urn:problem-type:thunder-bridge:payment-already-watched";
|
|
1326
|
-
var IdempotencyConflictError = class extends ProblemError {
|
|
1327
|
-
conflict;
|
|
1328
|
-
constructor(problem, conflict) {
|
|
1329
|
-
super(problem);
|
|
1330
|
-
this.name = "IdempotencyConflictError";
|
|
1331
|
-
this.conflict = conflict;
|
|
2329
|
+
/**
|
|
2330
|
+
* Lightning, with the invoice resolved here so the gateway is told neither the
|
|
2331
|
+
* address nor the amount
|
|
2332
|
+
*/
|
|
2333
|
+
blindLightning(config) {
|
|
2334
|
+
return blindLightningRail(this.gateway, config);
|
|
1332
2335
|
}
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
2336
|
+
/** A bank transfer, proved the way a Lightning payment is */
|
|
2337
|
+
bank(config) {
|
|
2338
|
+
return bankRail(this.gateway, config);
|
|
2339
|
+
}
|
|
2340
|
+
/**
|
|
2341
|
+
* One bank transfer without building a rail first, for a shop that asks for
|
|
2342
|
+
* them one at a time rather than beside another payment method
|
|
2343
|
+
*/
|
|
2344
|
+
transfer(params) {
|
|
2345
|
+
return bankTransfer(this.gateway, params);
|
|
1340
2346
|
}
|
|
1341
2347
|
};
|
|
1342
2348
|
|
|
1343
|
-
// src/
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
2349
|
+
// src/request.ts
|
|
2350
|
+
function paymentRequestOf(gateway, payment, options) {
|
|
2351
|
+
return {
|
|
2352
|
+
id: payment.id,
|
|
2353
|
+
bolt11: payment.bolt11,
|
|
2354
|
+
paymentHash: payment.paymentHash,
|
|
2355
|
+
lnAddress: payment.lnAddress,
|
|
2356
|
+
amountMsat: payment.amountMsat,
|
|
2357
|
+
expiresAt: payment.expiresAt,
|
|
2358
|
+
qr: invoiceToSvg(payment.bolt11, options?.qr),
|
|
2359
|
+
payment,
|
|
2360
|
+
paid: async (waiting) => paidOnly(await gateway.settled(payment, { ...waitingOf(options), ...waiting })),
|
|
2361
|
+
prove: () => proveSettlement(payment, { paidTo: [payment.lnAddress], amountMsat: payment.amountMsat }),
|
|
2362
|
+
onPaid: (arrived, failed) => {
|
|
2363
|
+
const stop = new AbortController();
|
|
2364
|
+
const waiting = waitingOf(options);
|
|
2365
|
+
const signal = waiting.signal ? AbortSignal.any([stop.signal, waiting.signal]) : stop.signal;
|
|
2366
|
+
gateway.settled(payment, { ...waiting, signal }).then((ended) => arrived(paidOnly(ended))).catch((reason) => {
|
|
2367
|
+
if (!stop.signal.aborted) {
|
|
2368
|
+
failed?.(reason);
|
|
2369
|
+
}
|
|
2370
|
+
});
|
|
2371
|
+
return () => stop.abort();
|
|
2372
|
+
}
|
|
2373
|
+
};
|
|
2374
|
+
}
|
|
2375
|
+
function paidOnly(ended) {
|
|
2376
|
+
if (ended.status !== "paid") {
|
|
2377
|
+
throw new Error(`payment ${ended.id} ended ${ended.status} rather than paid`);
|
|
1361
2378
|
}
|
|
1362
|
-
if (
|
|
1363
|
-
throw
|
|
2379
|
+
if (ended.kind !== "minted") {
|
|
2380
|
+
throw new Error(`payment ${ended.id} came back without the invoice it was minted with`);
|
|
1364
2381
|
}
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
2382
|
+
return ended;
|
|
2383
|
+
}
|
|
2384
|
+
function waitingOf(options) {
|
|
2385
|
+
return { signal: options?.signal, tickets: options?.tickets };
|
|
2386
|
+
}
|
|
2387
|
+
|
|
2388
|
+
// src/trigger.ts
|
|
2389
|
+
var NONCE_BYTES = 16;
|
|
2390
|
+
function lnurlPayEndpoint(gateway, config) {
|
|
2391
|
+
return async (request) => {
|
|
2392
|
+
const url = new URL(request.url);
|
|
2393
|
+
const asked = url.searchParams.get("to");
|
|
2394
|
+
try {
|
|
2395
|
+
return asked === null ? await offer(gateway, config, url) : await mint(gateway, config, url, asked);
|
|
2396
|
+
} catch (failure) {
|
|
2397
|
+
return refuse(failure instanceof Error ? failure.message : "the trigger could not be served");
|
|
2398
|
+
}
|
|
2399
|
+
};
|
|
2400
|
+
}
|
|
2401
|
+
function watchTicketEndpoint(gateway, config) {
|
|
2402
|
+
return async (request) => {
|
|
2403
|
+
const offered = await offeredSecret(request);
|
|
2404
|
+
if (!equalInConstantTime(sha256Hex(offered), sha256Hex(config.watchSecret))) {
|
|
2405
|
+
return Response.json({ reason: "not the watch secret" }, { status: 403 });
|
|
2406
|
+
}
|
|
2407
|
+
return await issue(gateway, config);
|
|
2408
|
+
};
|
|
2409
|
+
}
|
|
2410
|
+
function publicWatchTicketEndpoint(gateway, config) {
|
|
2411
|
+
return () => issue(gateway, config);
|
|
2412
|
+
}
|
|
2413
|
+
async function offer(gateway, config, url) {
|
|
2414
|
+
const { least, most } = await spread(config.amount);
|
|
2415
|
+
const winner = await quoted(gateway, config, least);
|
|
2416
|
+
const nonce = randomNonce();
|
|
2417
|
+
const callback = new URL(config.baseUrl ?? `${url.origin}${url.pathname}`);
|
|
2418
|
+
callback.searchParams.set("to", winner.address);
|
|
2419
|
+
callback.searchParams.set("least", String(least));
|
|
2420
|
+
callback.searchParams.set("most", String(most));
|
|
2421
|
+
callback.searchParams.set("n", nonce);
|
|
2422
|
+
callback.searchParams.set("sig", await sign(config.secret, winner.address, least, most, nonce));
|
|
2423
|
+
return Response.json({
|
|
2424
|
+
tag: "payRequest",
|
|
2425
|
+
callback: callback.toString(),
|
|
2426
|
+
metadata: winner.metadata,
|
|
2427
|
+
minSendable: least,
|
|
2428
|
+
maxSendable: most
|
|
2429
|
+
});
|
|
2430
|
+
}
|
|
2431
|
+
async function quoted(gateway, config, amountMsat) {
|
|
2432
|
+
if (config.blind) {
|
|
2433
|
+
const { won } = await quote(
|
|
2434
|
+
config.send ?? pinnedToTheAddressWeVerified,
|
|
2435
|
+
listed(config.paidTo),
|
|
2436
|
+
amountMsat
|
|
2437
|
+
);
|
|
2438
|
+
return won;
|
|
1368
2439
|
}
|
|
1369
|
-
|
|
1370
|
-
|
|
2440
|
+
const { lnAddress, metadata } = await gateway.quote({
|
|
2441
|
+
paidTo: config.paidTo,
|
|
2442
|
+
amount: msat(amountMsat)
|
|
2443
|
+
});
|
|
2444
|
+
return { address: lnAddress, metadata };
|
|
2445
|
+
}
|
|
2446
|
+
async function spread(amount) {
|
|
2447
|
+
if (typeof amount === "number" || typeof amount === "function") {
|
|
2448
|
+
const pinned = await amountNow(amount);
|
|
2449
|
+
return { least: pinned, most: pinned };
|
|
2450
|
+
}
|
|
2451
|
+
const least = await amountNow(amount.least);
|
|
2452
|
+
const most = await amountNow(amount.most);
|
|
2453
|
+
if (most < least) {
|
|
2454
|
+
throw new Error(`a range cannot end at ${most} msat when it starts at ${least}`);
|
|
2455
|
+
}
|
|
2456
|
+
return { least, most };
|
|
2457
|
+
}
|
|
2458
|
+
async function mint(gateway, config, url, address) {
|
|
2459
|
+
const least = Number(url.searchParams.get("least"));
|
|
2460
|
+
const most = Number(url.searchParams.get("most"));
|
|
2461
|
+
const nonce = url.searchParams.get("n") ?? "";
|
|
2462
|
+
const signature = url.searchParams.get("sig") ?? "";
|
|
2463
|
+
const expected = await sign(config.secret, address, least, most, nonce);
|
|
2464
|
+
if (!equalInConstantTime(signature, expected)) {
|
|
2465
|
+
return refuse("this callback was not signed here");
|
|
2466
|
+
}
|
|
2467
|
+
if (!listed(config.paidTo).some((one) => one.toLowerCase() === address.toLowerCase())) {
|
|
2468
|
+
return refuse(`${address} is not paid through this trigger`);
|
|
2469
|
+
}
|
|
2470
|
+
const asked = url.searchParams.get("amount");
|
|
2471
|
+
const amountMsat = asked === null ? least : Number(asked);
|
|
2472
|
+
if (!Number.isSafeInteger(amountMsat) || amountMsat < least || amountMsat > most) {
|
|
2473
|
+
return refuse(
|
|
2474
|
+
least === most ? `this trigger costs exactly ${least} msat` : `this trigger takes between ${least} and ${most} msat`
|
|
2475
|
+
);
|
|
1371
2476
|
}
|
|
1372
|
-
|
|
1373
|
-
|
|
2477
|
+
const minted = config.blind ? await mintBlind(gateway, config, address, amountMsat) : await mintThroughGateway(gateway, config, address, amountMsat, nonce);
|
|
2478
|
+
return Response.json({
|
|
2479
|
+
status: "OK",
|
|
2480
|
+
pr: minted.bolt11,
|
|
2481
|
+
routes: [],
|
|
2482
|
+
verify: minted.verifyUrl
|
|
2483
|
+
});
|
|
2484
|
+
}
|
|
2485
|
+
async function mintThroughGateway(gateway, config, address, amountMsat, nonce) {
|
|
2486
|
+
const payment = await gateway.mint(
|
|
2487
|
+
{ paidTo: address, amount: msat(amountMsat) },
|
|
2488
|
+
{ idempotencyKey: nonce, trigger: config.watchSecret, replay: config.replay }
|
|
2489
|
+
);
|
|
2490
|
+
return { bolt11: payment.bolt11, verifyUrl: payment.verifyUrl };
|
|
2491
|
+
}
|
|
2492
|
+
async function mintBlind(gateway, config, address, amountMsat) {
|
|
2493
|
+
const resolved2 = await resolve(
|
|
2494
|
+
config.send ?? pinnedToTheAddressWeVerified,
|
|
2495
|
+
[address],
|
|
2496
|
+
amountMsat
|
|
2497
|
+
);
|
|
2498
|
+
const minted = { ...resolved2, amountMsat, lnAddress: resolved2.address };
|
|
2499
|
+
const locked = config.sealed;
|
|
2500
|
+
const relay = config.relayThrough;
|
|
2501
|
+
try {
|
|
2502
|
+
await gateway.watch({
|
|
2503
|
+
paymentHash: resolved2.paymentHash,
|
|
2504
|
+
verifyUrl: relay ? await relayedVerifyUrl(
|
|
2505
|
+
relay.endpoint,
|
|
2506
|
+
{ url: resolved2.verifyUrl, hash: resolved2.paymentHash },
|
|
2507
|
+
relay.secret
|
|
2508
|
+
) : resolved2.verifyUrl,
|
|
2509
|
+
expiresAt: resolved2.expiresAt,
|
|
2510
|
+
trigger: config.watchSecret,
|
|
2511
|
+
replay: config.replay,
|
|
2512
|
+
sealed: locked ? await seal(locked.secret, JSON.stringify(locked.data(minted)), minted.paymentHash) : void 0
|
|
2513
|
+
});
|
|
2514
|
+
} catch (refused) {
|
|
2515
|
+
if (!alreadyWatched(refused)) {
|
|
2516
|
+
throw refused;
|
|
2517
|
+
}
|
|
1374
2518
|
}
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
2519
|
+
return { bolt11: resolved2.bolt11, verifyUrl: resolved2.verifyUrl };
|
|
2520
|
+
}
|
|
2521
|
+
function listed(paidTo) {
|
|
2522
|
+
return typeof paidTo === "string" ? [paidTo] : paidTo;
|
|
2523
|
+
}
|
|
2524
|
+
function alreadyWatched(refused) {
|
|
2525
|
+
return refused instanceof ProblemError && isProblemType(refused, PAYMENT_ALREADY_WATCHED);
|
|
2526
|
+
}
|
|
2527
|
+
function sign(secret, address, least, most, nonce) {
|
|
2528
|
+
return hmacHex(secret, `${address}|${least}|${most}|${nonce}`);
|
|
2529
|
+
}
|
|
2530
|
+
function randomNonce() {
|
|
2531
|
+
const bytes = crypto.getRandomValues(new Uint8Array(NONCE_BYTES));
|
|
2532
|
+
let hex = "";
|
|
2533
|
+
for (const byte of bytes) {
|
|
2534
|
+
hex += byte.toString(16).padStart(2, "0");
|
|
1378
2535
|
}
|
|
2536
|
+
return hex;
|
|
1379
2537
|
}
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
2538
|
+
function refuse(reason) {
|
|
2539
|
+
return Response.json({ status: "ERROR", reason });
|
|
2540
|
+
}
|
|
2541
|
+
async function issue(gateway, config) {
|
|
2542
|
+
const issued = await gateway.ticket(config.watchSecret, { replay: config.replay });
|
|
2543
|
+
return Response.json({
|
|
2544
|
+
ticket: issued.ticket,
|
|
2545
|
+
expires_at: new Date(issued.expiresAt * 1e3).toISOString()
|
|
2546
|
+
});
|
|
2547
|
+
}
|
|
2548
|
+
async function offeredSecret(request) {
|
|
2549
|
+
const body = await request.json().catch(() => null);
|
|
2550
|
+
return typeof body?.secret === "string" ? body.secret : "";
|
|
2551
|
+
}
|
|
2552
|
+
|
|
2553
|
+
// src/serving.ts
|
|
2554
|
+
var Serve = class {
|
|
2555
|
+
constructor(gateway) {
|
|
2556
|
+
this.gateway = gateway;
|
|
1385
2557
|
}
|
|
1386
|
-
|
|
1387
|
-
|
|
2558
|
+
gateway;
|
|
2559
|
+
/**
|
|
2560
|
+
* An LNURL-pay endpoint of your own, standing in front of a priority list of
|
|
2561
|
+
* addresses, so a printed QR points at your domain and never expires
|
|
2562
|
+
*/
|
|
2563
|
+
lnurlPay(config) {
|
|
2564
|
+
return lnurlPayEndpoint(this.gateway, config);
|
|
1388
2565
|
}
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
if (payment.status !== "paid" || payment.preimage === null) {
|
|
1393
|
-
return false;
|
|
2566
|
+
/** Trades the watch secret for a one minute socket ticket, refusing anyone without it */
|
|
2567
|
+
watchTicket(config) {
|
|
2568
|
+
return watchTicketEndpoint(this.gateway, config);
|
|
1394
2569
|
}
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
2570
|
+
/**
|
|
2571
|
+
* Mints a socket ticket for anybody who asks, which makes the trigger's whole
|
|
2572
|
+
* stream public, preimages included. Only for a board where that is the point
|
|
2573
|
+
*/
|
|
2574
|
+
publicWatchTicket(config) {
|
|
2575
|
+
return publicWatchTicketEndpoint(this.gateway, config);
|
|
1398
2576
|
}
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
function proveWrapped(wrapped, recipient, allowance) {
|
|
1406
|
-
const offered = decodeInvoice(wrapped);
|
|
1407
|
-
const owed = decodeInvoice(recipient);
|
|
1408
|
-
const refuse = (code, detail) => {
|
|
1409
|
-
throw new WrapRefusedError(code, detail);
|
|
1410
|
-
};
|
|
1411
|
-
if (offered.paymentHash === null || offered.amountMsat === null || offered.expiresAt === null) {
|
|
1412
|
-
refuse("undecodable", "the wrapped invoice carries no hash, amount or expiry");
|
|
2577
|
+
/**
|
|
2578
|
+
* A verify endpoint of your own that asks the recipient's wallet for you, so
|
|
2579
|
+
* the gateway polls you and never the wallet
|
|
2580
|
+
*/
|
|
2581
|
+
verify(config) {
|
|
2582
|
+
return lightningVerifyEndpoint(config);
|
|
1413
2583
|
}
|
|
1414
|
-
|
|
1415
|
-
|
|
2584
|
+
/** The verify endpoint a bank rail is polled at, answering off your own statement */
|
|
2585
|
+
bankVerify(config) {
|
|
2586
|
+
return bankVerifyEndpoint(config);
|
|
1416
2587
|
}
|
|
1417
|
-
|
|
1418
|
-
|
|
2588
|
+
/**
|
|
2589
|
+
* The whole webhook route: it answers the gateway's challenge, checks the
|
|
2590
|
+
* signature against the key the gateway publishes, refuses a settlement that
|
|
2591
|
+
* proves nothing, and calls you for the one that does.
|
|
2592
|
+
*
|
|
2593
|
+
* `export const POST = gateway.serve.webhook({ onSettled: fulfil })` is the
|
|
2594
|
+
* entire integration
|
|
2595
|
+
*/
|
|
2596
|
+
webhook(handlers) {
|
|
2597
|
+
const options = { toleranceSecs: handlers.toleranceSecs };
|
|
2598
|
+
return async (request) => {
|
|
2599
|
+
const credential = await this.credential(handlers);
|
|
2600
|
+
const challenge = await answerWebhookChallenge(request, credential, options);
|
|
2601
|
+
if (challenge !== null) {
|
|
2602
|
+
return challenge;
|
|
2603
|
+
}
|
|
2604
|
+
const settlement = await readSettlement(request, credential, options);
|
|
2605
|
+
if (settlement !== null) {
|
|
2606
|
+
return await acted(settlement, handlers);
|
|
2607
|
+
}
|
|
2608
|
+
const payment = await readPayment(request, credential, options);
|
|
2609
|
+
if (payment !== null) {
|
|
2610
|
+
await handlers.onPayment?.(payment);
|
|
2611
|
+
return new Response("ok");
|
|
2612
|
+
}
|
|
2613
|
+
return new Response("bad signature", { status: 401 });
|
|
2614
|
+
};
|
|
1419
2615
|
}
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
refuse("amount_below_recipient", `${asked} msat cannot forward ${due} msat`);
|
|
2616
|
+
/** Verify a delivery and read the settlement out of it, null when it is not believable */
|
|
2617
|
+
async readSettlement(request, options) {
|
|
2618
|
+
return await readSettlement(request, await this.credential({}), options);
|
|
1424
2619
|
}
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
2620
|
+
/** Verify a delivery and read the payment out of it, null when it is not believable */
|
|
2621
|
+
async readPayment(request, options) {
|
|
2622
|
+
return await readPayment(request, await this.credential({}), options);
|
|
1428
2623
|
}
|
|
1429
|
-
|
|
1430
|
-
|
|
2624
|
+
/** Answer the challenge the gateway sends before it will post to a webhook of yours */
|
|
2625
|
+
async answerWebhookChallenge(request, options) {
|
|
2626
|
+
return await answerWebhookChallenge(request, await this.credential({}), options);
|
|
1431
2627
|
}
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
const at = listed.indexOf("@");
|
|
1435
|
-
const name = listed.slice(0, at);
|
|
1436
|
-
const domain = listed.slice(at + 1).toLowerCase();
|
|
1437
|
-
if (at < 1 || !publicHttps(`https://${domain}/`)) {
|
|
1438
|
-
throw new UnverifiedRecipientError(payment.lnAddress, payment.id, "not a usable address");
|
|
2628
|
+
async credential(handlers) {
|
|
2629
|
+
return handlers.credential ?? { publicKey: await this.gateway.webhookKey() };
|
|
1439
2630
|
}
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
throw new Error(`${url} is not a public https URL`);
|
|
1446
|
-
}
|
|
1447
|
-
const response = await fetch(url, {
|
|
1448
|
-
signal: AbortSignal.timeout(HTTP_TIMEOUT_MS),
|
|
1449
|
-
headers: { accept: "application/json" }
|
|
1450
|
-
});
|
|
1451
|
-
if (!response.ok) {
|
|
1452
|
-
throw new Error(`answered ${response.status}`);
|
|
1453
|
-
}
|
|
1454
|
-
const body = await response.json();
|
|
1455
|
-
if (body === null || typeof body !== "object") {
|
|
1456
|
-
throw new Error("answered with no JSON object");
|
|
1457
|
-
}
|
|
1458
|
-
return body;
|
|
1459
|
-
} catch (cause) {
|
|
1460
|
-
throw new UnverifiedRecipientError(payment.lnAddress, payment.id, cause);
|
|
2631
|
+
};
|
|
2632
|
+
async function acted(settlement, handlers) {
|
|
2633
|
+
if (carriesProof(settlement)) {
|
|
2634
|
+
await handlers.onSettled?.(settlement);
|
|
2635
|
+
return new Response("ok");
|
|
1461
2636
|
}
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
2637
|
+
if (handlers.onUnproven === void 0) {
|
|
2638
|
+
return new Response("the recipient released no preimage", { status: 202 });
|
|
2639
|
+
}
|
|
2640
|
+
await handlers.onUnproven(settlement);
|
|
2641
|
+
return new Response("ok");
|
|
1465
2642
|
}
|
|
1466
2643
|
|
|
1467
2644
|
// src/client.ts
|
|
@@ -1483,35 +2660,62 @@ function backoffMs(firstDelay, attempt) {
|
|
|
1483
2660
|
const grown = Math.min(firstDelay * 2 ** (attempt - 1), RECONNECT_CAP_MS);
|
|
1484
2661
|
return Math.round(grown * (0.5 + Math.random() / 2));
|
|
1485
2662
|
}
|
|
2663
|
+
function askFromWire(raw) {
|
|
2664
|
+
let said;
|
|
2665
|
+
try {
|
|
2666
|
+
said = JSON.parse(raw);
|
|
2667
|
+
} catch {
|
|
2668
|
+
return null;
|
|
2669
|
+
}
|
|
2670
|
+
if (typeof said !== "object" || said === null) {
|
|
2671
|
+
return null;
|
|
2672
|
+
}
|
|
2673
|
+
const fields = said;
|
|
2674
|
+
const ask2 = fields["ask"];
|
|
2675
|
+
const paymentHash = fields["payment_hash"];
|
|
2676
|
+
return typeof ask2 === "string" && typeof paymentHash === "string" ? { ask: ask2, paymentHash } : null;
|
|
2677
|
+
}
|
|
2678
|
+
function gatewayAt(baseUrl) {
|
|
2679
|
+
const at = URL.canParse(baseUrl) ? new URL(baseUrl) : null;
|
|
2680
|
+
if (at === null || at.protocol !== "http:" && at.protocol !== "https:") {
|
|
2681
|
+
throw new Error(`${baseUrl} is not an http or https gateway url`);
|
|
2682
|
+
}
|
|
2683
|
+
return baseUrl.replace(/\/+$/, "");
|
|
2684
|
+
}
|
|
1486
2685
|
var ThunderBridge = class {
|
|
1487
2686
|
baseUrl;
|
|
1488
|
-
verify;
|
|
1489
2687
|
token;
|
|
2688
|
+
handedOut = /* @__PURE__ */ new Map();
|
|
1490
2689
|
secret;
|
|
1491
2690
|
strangers = null;
|
|
1492
2691
|
speaks = null;
|
|
2692
|
+
published = null;
|
|
2693
|
+
/** Everything this gateway lets you mount, from an LNURL endpoint to a webhook route */
|
|
2694
|
+
serve;
|
|
2695
|
+
/** One call per sale, whatever the rail moves */
|
|
2696
|
+
rails;
|
|
1493
2697
|
constructor(baseUrl, options) {
|
|
1494
|
-
this.baseUrl = baseUrl
|
|
1495
|
-
this.verify = options?.verify ?? true;
|
|
2698
|
+
this.baseUrl = gatewayAt(baseUrl);
|
|
1496
2699
|
this.token = options?.token ?? null;
|
|
1497
2700
|
this.secret = options?.secret ?? null;
|
|
2701
|
+
this.serve = new Serve(this);
|
|
2702
|
+
this.rails = new Rails(this);
|
|
1498
2703
|
}
|
|
1499
2704
|
/**
|
|
1500
|
-
* Whether a token was given, which is
|
|
1501
|
-
*
|
|
1502
|
-
*
|
|
2705
|
+
* Whether a token was given to this instance, which is your side of the
|
|
2706
|
+
* arrangement and says nothing about the gateway's. `refusesStrangers` is the
|
|
2707
|
+
* one that asks the gateway, and it is the one to guard anything with
|
|
1503
2708
|
*/
|
|
1504
|
-
get
|
|
2709
|
+
get hasToken() {
|
|
1505
2710
|
return this.token !== null;
|
|
1506
2711
|
}
|
|
1507
2712
|
/**
|
|
1508
2713
|
* Whether the gateway turns away a caller carrying no token, asked by making
|
|
1509
|
-
* one unauthenticated read it would have to refuse. `
|
|
1510
|
-
* whether you configured
|
|
1511
|
-
*
|
|
1512
|
-
*
|
|
1513
|
-
*
|
|
1514
|
-
* open, so an unreachable gateway fails closed
|
|
2714
|
+
* one unauthenticated read it would have to refuse. `hasToken` answers only
|
|
2715
|
+
* whether you configured one, so a made-up token against a public instance
|
|
2716
|
+
* reads as yours and is not. Asked once and remembered, because an instance
|
|
2717
|
+
* does not change its mind. Anything other than a refusal counts as open, so
|
|
2718
|
+
* an unreachable gateway fails closed
|
|
1515
2719
|
*/
|
|
1516
2720
|
async refusesStrangers() {
|
|
1517
2721
|
this.strangers ??= fetch(`${this.baseUrl}/incoming-payments/${STRANGER_PROBE}`, {
|
|
@@ -1519,14 +2723,25 @@ var ThunderBridge = class {
|
|
|
1519
2723
|
}).then((answer) => answer.status === 401).catch(() => false);
|
|
1520
2724
|
return await this.strangers;
|
|
1521
2725
|
}
|
|
2726
|
+
/**
|
|
2727
|
+
* Ask to be paid for one thing. It mints the invoice, proves it came from the
|
|
2728
|
+
* address you asked for, draws the QR and hands back one object with a way to
|
|
2729
|
+
* wait for the money. This is `mint` plus the two things every caller does next
|
|
2730
|
+
*/
|
|
2731
|
+
async requestPayment(asked) {
|
|
2732
|
+
return paymentRequestOf(this, await this.mint(asked, asked), asked);
|
|
2733
|
+
}
|
|
1522
2734
|
/**
|
|
1523
2735
|
* Ask the gateway for an invoice payable to the first address on your list
|
|
1524
2736
|
* that can issue a provable one, throws `NoWalletAvailableError` when none can
|
|
1525
2737
|
* and `GatewayCheatError` when what comes back is not what you asked for
|
|
1526
2738
|
*/
|
|
1527
|
-
async
|
|
2739
|
+
async mint(charge, options) {
|
|
2740
|
+
const askedAt = Math.floor(Date.now() / 1e3);
|
|
2741
|
+
const asked = await priced(charge);
|
|
1528
2742
|
const sent = createRequestBody(
|
|
1529
|
-
|
|
2743
|
+
asked,
|
|
2744
|
+
charge.webhookUrl,
|
|
1530
2745
|
options?.trigger ? sha256Hex(unguessable(options.trigger)) : null,
|
|
1531
2746
|
options?.replay
|
|
1532
2747
|
);
|
|
@@ -1542,10 +2757,9 @@ var ThunderBridge = class {
|
|
|
1542
2757
|
if (!response.ok) {
|
|
1543
2758
|
throw await problemFrom(response);
|
|
1544
2759
|
}
|
|
1545
|
-
const payment = await
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
}
|
|
2760
|
+
const payment = await mintedFrom(response);
|
|
2761
|
+
await proveOrigin(payment, asked, askedAt);
|
|
2762
|
+
this.handOut(payment, options?.idempotencyKey ?? null);
|
|
1549
2763
|
return payment;
|
|
1550
2764
|
}
|
|
1551
2765
|
/**
|
|
@@ -1555,8 +2769,9 @@ var ThunderBridge = class {
|
|
|
1555
2769
|
* whether a wallet returns a provable invoice cannot be known without asking
|
|
1556
2770
|
* it for one, and asking mints it
|
|
1557
2771
|
*/
|
|
1558
|
-
async
|
|
1559
|
-
const
|
|
2772
|
+
async quote(charge) {
|
|
2773
|
+
const asked = await priced(charge);
|
|
2774
|
+
const sent = quoteRequestBody(asked);
|
|
1560
2775
|
const response = await fetch(`${this.baseUrl}/quotes`, {
|
|
1561
2776
|
method: "POST",
|
|
1562
2777
|
headers: await this.sending("/quotes", sent),
|
|
@@ -1565,22 +2780,31 @@ var ThunderBridge = class {
|
|
|
1565
2780
|
if (!response.ok) {
|
|
1566
2781
|
throw await problemFrom(response);
|
|
1567
2782
|
}
|
|
1568
|
-
const
|
|
1569
|
-
if (
|
|
2783
|
+
const quote2 = quoteFromWire(await response.json().catch(() => null));
|
|
2784
|
+
if (quote2 === null) {
|
|
1570
2785
|
throw new ProblemError({
|
|
1571
2786
|
status: response.status,
|
|
1572
2787
|
title: "The gateway answered with something that is not a quote"
|
|
1573
2788
|
});
|
|
1574
2789
|
}
|
|
1575
|
-
|
|
2790
|
+
if (!asked.paidTo.some((address) => address.toLowerCase() === quote2.lnAddress.toLowerCase())) {
|
|
2791
|
+
throw new GatewayCheatError("address_not_requested", "");
|
|
2792
|
+
}
|
|
2793
|
+
return quote2;
|
|
1576
2794
|
}
|
|
1577
|
-
/** Read a payment back, null when the gateway has never heard of it */
|
|
1578
2795
|
/**
|
|
1579
|
-
* The key this gateway signs webhooks with when you registered none of your own
|
|
1580
|
-
*
|
|
1581
|
-
*
|
|
2796
|
+
* The key this gateway signs webhooks with when you registered none of your own.
|
|
2797
|
+
* `serve.webhook` reads it for you. Asked once and kept, because it is the same
|
|
2798
|
+
* for every instance in the cluster
|
|
1582
2799
|
*/
|
|
1583
2800
|
async webhookKey() {
|
|
2801
|
+
this.published ??= this.publishedKey().catch((refused) => {
|
|
2802
|
+
this.published = null;
|
|
2803
|
+
throw refused;
|
|
2804
|
+
});
|
|
2805
|
+
return await this.published;
|
|
2806
|
+
}
|
|
2807
|
+
async publishedKey() {
|
|
1584
2808
|
const response = await fetch(`${this.baseUrl}/webhook-key`);
|
|
1585
2809
|
if (!response.ok) {
|
|
1586
2810
|
throw await problemFrom(response);
|
|
@@ -1594,26 +2818,13 @@ var ThunderBridge = class {
|
|
|
1594
2818
|
}
|
|
1595
2819
|
return body.public_key;
|
|
1596
2820
|
}
|
|
1597
|
-
async getPayment(id) {
|
|
1598
|
-
const path = `/incoming-payments/${encodeURIComponent(id)}`;
|
|
1599
|
-
const response = await fetch(`${this.baseUrl}${path}`, {
|
|
1600
|
-
headers: await this.reading("GET", path)
|
|
1601
|
-
});
|
|
1602
|
-
if (response.status === 404) {
|
|
1603
|
-
return null;
|
|
1604
|
-
}
|
|
1605
|
-
if (!response.ok) {
|
|
1606
|
-
throw await problemFrom(response);
|
|
1607
|
-
}
|
|
1608
|
-
return this.checked(await paymentFrom(response));
|
|
1609
|
-
}
|
|
1610
2821
|
/**
|
|
1611
|
-
* Read
|
|
1612
|
-
*
|
|
1613
|
-
*
|
|
2822
|
+
* Read a payment back, null when the gateway has never heard of it. One method
|
|
2823
|
+
* for both sorts: `kind` says whether the gateway minted it or was handed it,
|
|
2824
|
+
* and the address, amount and invoice are null on one it was never told
|
|
1614
2825
|
*/
|
|
1615
|
-
async
|
|
1616
|
-
const path = `/incoming-payments/${encodeURIComponent(id)}`;
|
|
2826
|
+
async payment(held) {
|
|
2827
|
+
const path = `/incoming-payments/${encodeURIComponent(held.id)}`;
|
|
1617
2828
|
const response = await fetch(`${this.baseUrl}${path}`, {
|
|
1618
2829
|
headers: await this.reading("GET", path)
|
|
1619
2830
|
});
|
|
@@ -1623,14 +2834,7 @@ var ThunderBridge = class {
|
|
|
1623
2834
|
if (!response.ok) {
|
|
1624
2835
|
throw await problemFrom(response);
|
|
1625
2836
|
}
|
|
1626
|
-
|
|
1627
|
-
if (watched === null) {
|
|
1628
|
-
throw new ProblemError({
|
|
1629
|
-
status: response.status,
|
|
1630
|
-
title: "The gateway answered with something that is not a payment"
|
|
1631
|
-
});
|
|
1632
|
-
}
|
|
1633
|
-
return this.proven(watched);
|
|
2837
|
+
return this.proven(await paymentFrom(response), held);
|
|
1634
2838
|
}
|
|
1635
2839
|
/**
|
|
1636
2840
|
* List what this gateway is watching, newest first. Only a gateway started
|
|
@@ -1641,7 +2845,7 @@ var ThunderBridge = class {
|
|
|
1641
2845
|
* Anything older than that window is not in the answer, and the list does not
|
|
1642
2846
|
* pretend otherwise
|
|
1643
2847
|
*/
|
|
1644
|
-
async
|
|
2848
|
+
async payments(limit) {
|
|
1645
2849
|
const path = `/incoming-payments${limit === void 0 ? "" : `?limit=${limit}`}`;
|
|
1646
2850
|
const response = await fetch(`${this.baseUrl}${path}`, {
|
|
1647
2851
|
headers: await this.reading("GET", path)
|
|
@@ -1650,14 +2854,14 @@ var ThunderBridge = class {
|
|
|
1650
2854
|
throw await problemFrom(response);
|
|
1651
2855
|
}
|
|
1652
2856
|
const body = await response.json().catch(() => null);
|
|
1653
|
-
const
|
|
1654
|
-
if (
|
|
2857
|
+
const listed2 = Array.isArray(body?.payments) ? body.payments.map(paymentFromWire).filter((one) => one !== null).filter((one) => one.status !== "paid" || carriesProof(one)) : null;
|
|
2858
|
+
if (listed2 === null || typeof body?.settled_scanned !== "number") {
|
|
1655
2859
|
throw new ProblemError({
|
|
1656
2860
|
status: response.status,
|
|
1657
2861
|
title: "The gateway answered with something that is not a payment list"
|
|
1658
2862
|
});
|
|
1659
2863
|
}
|
|
1660
|
-
return { payments:
|
|
2864
|
+
return { payments: listed2, scanned: body.settled_scanned };
|
|
1661
2865
|
}
|
|
1662
2866
|
/**
|
|
1663
2867
|
* Follow a payment over WebSocket until it is paid or expired, reconnecting
|
|
@@ -1665,34 +2869,15 @@ var ThunderBridge = class {
|
|
|
1665
2869
|
* one that has answered is followed until its own expiry, so the wait always
|
|
1666
2870
|
* ends by itself
|
|
1667
2871
|
*/
|
|
1668
|
-
async
|
|
1669
|
-
const
|
|
1670
|
-
if (
|
|
1671
|
-
throw new ProblemError({
|
|
1672
|
-
status: 200,
|
|
1673
|
-
title: `payment ${id} is watched rather than minted here, read it with waitForWatched`
|
|
1674
|
-
});
|
|
1675
|
-
}
|
|
1676
|
-
return this.checked(payment);
|
|
1677
|
-
}
|
|
1678
|
-
/**
|
|
1679
|
-
* Follow a payment the gateway is only watching, one it did not mint, until it
|
|
1680
|
-
* is paid or expired.
|
|
1681
|
-
*
|
|
1682
|
-
* A watched payment carries no address, no amount and no invoice, because the
|
|
1683
|
-
* gateway was told none of them, so it reads back as the shape a trigger
|
|
1684
|
-
* streams rather than as a `Payment`. That is every bank transfer, and every
|
|
1685
|
-
* Lightning invoice registered with `watchPayment` instead of `createPayment`.
|
|
1686
|
-
*/
|
|
1687
|
-
async waitForWatched(id, options) {
|
|
1688
|
-
const watched = triggerEventFromWire(await this.followed(id, options));
|
|
1689
|
-
if (watched === null) {
|
|
2872
|
+
async settled(held, options) {
|
|
2873
|
+
const ended = paymentFromWire(await this.followed(held.id, options));
|
|
2874
|
+
if (ended === null) {
|
|
1690
2875
|
throw new ProblemError({
|
|
1691
2876
|
status: 200,
|
|
1692
2877
|
title: "The gateway answered with something that is not a payment"
|
|
1693
2878
|
});
|
|
1694
2879
|
}
|
|
1695
|
-
return this.proven(
|
|
2880
|
+
return this.proven(ended, held);
|
|
1696
2881
|
}
|
|
1697
2882
|
followed(id, options) {
|
|
1698
2883
|
const base = this.baseUrl.replace(/^http/, "ws");
|
|
@@ -1759,7 +2944,7 @@ var ThunderBridge = class {
|
|
|
1759
2944
|
opened.onmessage = (event) => {
|
|
1760
2945
|
try {
|
|
1761
2946
|
const frame = JSON.parse(String(event.data));
|
|
1762
|
-
const watched =
|
|
2947
|
+
const watched = paymentFromWire(frame);
|
|
1763
2948
|
if (watched === null) {
|
|
1764
2949
|
return;
|
|
1765
2950
|
}
|
|
@@ -1788,17 +2973,17 @@ var ThunderBridge = class {
|
|
|
1788
2973
|
* what you want is the one that arrives and nothing further from the other.
|
|
1789
2974
|
*
|
|
1790
2975
|
* A leg that expires is a loser, not a winner, which is the whole reason this is
|
|
1791
|
-
* not a race: `
|
|
2976
|
+
* not a race: `settled` ends on `paid` and on `expired` alike, and a
|
|
1792
2977
|
* Lightning invoice expires in an hour while a bank transfer takes days. `null`
|
|
1793
2978
|
* means every leg ended without being paid.
|
|
1794
2979
|
*
|
|
1795
2980
|
* Stopping the wait is not revoking the invoice. Nobody can revoke one, because
|
|
1796
2981
|
* the recipient's own wallet minted it, so a payer who pays the loser afterwards
|
|
1797
|
-
* really does pay twice and that shows up on `
|
|
1798
|
-
*
|
|
2982
|
+
* really does pay twice and that shows up on `follow` as a second settlement to
|
|
2983
|
+
* refund.
|
|
1799
2984
|
*/
|
|
1800
|
-
async
|
|
1801
|
-
if (
|
|
2985
|
+
async firstSettled(held, options) {
|
|
2986
|
+
if (held.length === 0) {
|
|
1802
2987
|
return null;
|
|
1803
2988
|
}
|
|
1804
2989
|
const stopLosers = new AbortController();
|
|
@@ -1806,15 +2991,15 @@ var ThunderBridge = class {
|
|
|
1806
2991
|
let refused = null;
|
|
1807
2992
|
try {
|
|
1808
2993
|
const winner = await new Promise((resolve2) => {
|
|
1809
|
-
let waiting =
|
|
2994
|
+
let waiting = held.length;
|
|
1810
2995
|
const lost = () => {
|
|
1811
2996
|
waiting -= 1;
|
|
1812
2997
|
if (waiting === 0) {
|
|
1813
2998
|
resolve2(null);
|
|
1814
2999
|
}
|
|
1815
3000
|
};
|
|
1816
|
-
for (const
|
|
1817
|
-
this.
|
|
3001
|
+
for (const leg of held) {
|
|
3002
|
+
this.settled(leg, { ...options, signal }).then((watched) => watched.status === "paid" ? resolve2(watched) : lost()).catch((failure) => {
|
|
1818
3003
|
refused ??= failure;
|
|
1819
3004
|
lost();
|
|
1820
3005
|
});
|
|
@@ -1834,10 +3019,10 @@ var ThunderBridge = class {
|
|
|
1834
3019
|
* rather than one recipient, which is what makes leaving it cheap. Anything
|
|
1835
3020
|
* the watcher needs goes in `sealed`, which the gateway cannot read
|
|
1836
3021
|
*/
|
|
1837
|
-
async
|
|
3022
|
+
async watch(handover) {
|
|
1838
3023
|
const sent = watchRequestBody(
|
|
1839
|
-
|
|
1840
|
-
|
|
3024
|
+
handover,
|
|
3025
|
+
handover.trigger ? sha256Hex(unguessable(handover.trigger)) : null
|
|
1841
3026
|
);
|
|
1842
3027
|
const response = await fetch(`${this.baseUrl}/watched-payments`, {
|
|
1843
3028
|
method: "POST",
|
|
@@ -1847,17 +3032,20 @@ var ThunderBridge = class {
|
|
|
1847
3032
|
if (!response.ok) {
|
|
1848
3033
|
throw await problemFrom(response);
|
|
1849
3034
|
}
|
|
1850
|
-
const watched =
|
|
3035
|
+
const watched = paymentFromWire(await response.json().catch(() => null));
|
|
1851
3036
|
if (watched === null) {
|
|
1852
3037
|
throw new ProblemError({
|
|
1853
3038
|
status: response.status,
|
|
1854
3039
|
title: "The gateway answered with something that is not a watched payment"
|
|
1855
3040
|
});
|
|
1856
3041
|
}
|
|
1857
|
-
const named = await this.nameFor(
|
|
3042
|
+
const named = await this.nameFor(handover.paymentHash);
|
|
1858
3043
|
if (named !== null && watched.id !== named) {
|
|
1859
3044
|
throw new GatewayCheatError("id_not_mine", watched.id);
|
|
1860
3045
|
}
|
|
3046
|
+
if (watched.paymentHash.toLowerCase() !== handover.paymentHash.toLowerCase()) {
|
|
3047
|
+
throw new GatewayCheatError("hash_mismatch", watched.id);
|
|
3048
|
+
}
|
|
1861
3049
|
return watched;
|
|
1862
3050
|
}
|
|
1863
3051
|
/**
|
|
@@ -1870,12 +3058,75 @@ var ThunderBridge = class {
|
|
|
1870
3058
|
async nameFor(paymentHash) {
|
|
1871
3059
|
return this.secret === null ? null : paymentNamedBy((await this.speaking()).publicKeyHex, paymentHash);
|
|
1872
3060
|
}
|
|
3061
|
+
/**
|
|
3062
|
+
* Hold a socket open and answer what the gateway asks about this caller's own
|
|
3063
|
+
* payments, so a watch addressed to this caller settles without anybody
|
|
3064
|
+
* hosting a URL. Reconnects on its own until the returned function is called
|
|
3065
|
+
*/
|
|
3066
|
+
attend(options) {
|
|
3067
|
+
const base = this.baseUrl.replace(/^http/, "ws");
|
|
3068
|
+
const firstDelay = options.reconnectDelayMs ?? RECONNECT_DELAY_MS;
|
|
3069
|
+
let socket = null;
|
|
3070
|
+
let retry;
|
|
3071
|
+
let stopped = false;
|
|
3072
|
+
let attempt = 0;
|
|
3073
|
+
const again = () => {
|
|
3074
|
+
if (stopped || options.reconnect === false) {
|
|
3075
|
+
return;
|
|
3076
|
+
}
|
|
3077
|
+
retry = setTimeout(() => void connect(), backoffMs(firstDelay, attempt));
|
|
3078
|
+
};
|
|
3079
|
+
const answered3 = async (opened, raw) => {
|
|
3080
|
+
const asked = askFromWire(raw);
|
|
3081
|
+
if (asked === null) {
|
|
3082
|
+
return;
|
|
3083
|
+
}
|
|
3084
|
+
const preimage = await options.answer(asked.paymentHash);
|
|
3085
|
+
opened.send(
|
|
3086
|
+
JSON.stringify(
|
|
3087
|
+
preimage === null ? { ask: asked.ask, settled: false } : { ask: asked.ask, settled: true, preimage }
|
|
3088
|
+
)
|
|
3089
|
+
);
|
|
3090
|
+
};
|
|
3091
|
+
const connect = async () => {
|
|
3092
|
+
attempt += 1;
|
|
3093
|
+
let url;
|
|
3094
|
+
try {
|
|
3095
|
+
url = `${base}/ws/tickets/${await this.wsTicket({ agent: true })}`;
|
|
3096
|
+
} catch (refused) {
|
|
3097
|
+
options.onError?.(refused);
|
|
3098
|
+
again();
|
|
3099
|
+
return;
|
|
3100
|
+
}
|
|
3101
|
+
if (stopped) {
|
|
3102
|
+
return;
|
|
3103
|
+
}
|
|
3104
|
+
const opened = new WebSocket(url);
|
|
3105
|
+
socket = opened;
|
|
3106
|
+
opened.onopen = () => {
|
|
3107
|
+
attempt = 1;
|
|
3108
|
+
};
|
|
3109
|
+
opened.onmessage = (event) => {
|
|
3110
|
+
void answered3(opened, String(event.data)).catch(
|
|
3111
|
+
(refused) => options.onError?.(refused)
|
|
3112
|
+
);
|
|
3113
|
+
};
|
|
3114
|
+
opened.onerror = () => options.onError?.(new Error(`could not attend ${base}`));
|
|
3115
|
+
opened.onclose = again;
|
|
3116
|
+
};
|
|
3117
|
+
void connect();
|
|
3118
|
+
return () => {
|
|
3119
|
+
stopped = true;
|
|
3120
|
+
clearTimeout(retry);
|
|
3121
|
+
socket?.close();
|
|
3122
|
+
};
|
|
3123
|
+
}
|
|
1873
3124
|
/**
|
|
1874
3125
|
* Follow every payment made to one trigger, replayed from the recent ones on
|
|
1875
3126
|
* connect and then live, reconnecting on its own until the returned function
|
|
1876
3127
|
* is called. A trigger has no terminal state, so this never resolves
|
|
1877
3128
|
*/
|
|
1878
|
-
|
|
3129
|
+
follow(secret, options) {
|
|
1879
3130
|
const base = this.baseUrl.replace(/^http/, "ws");
|
|
1880
3131
|
const asked = options.replay === void 0 ? "" : `?replay=${options.replay}`;
|
|
1881
3132
|
const direct = `${base}/ws/triggers/${encodeURIComponent(unguessable(secret))}${asked}`;
|
|
@@ -1914,7 +3165,7 @@ var ThunderBridge = class {
|
|
|
1914
3165
|
};
|
|
1915
3166
|
socket.onmessage = (event) => {
|
|
1916
3167
|
try {
|
|
1917
|
-
const settled =
|
|
3168
|
+
const settled = paymentFromWire(JSON.parse(String(event.data)));
|
|
1918
3169
|
if (settled !== null) {
|
|
1919
3170
|
options.onPayment(this.proven(settled));
|
|
1920
3171
|
}
|
|
@@ -1936,17 +3187,17 @@ var ThunderBridge = class {
|
|
|
1936
3187
|
* A one minute pass onto one trigger's stream, for something that must hold
|
|
1937
3188
|
* neither the token nor the trigger secret. Mint it in a handler and answer
|
|
1938
3189
|
* with the ticket alone, because that is all a browser needs to connect and
|
|
1939
|
-
* all it can do anything with. `
|
|
3190
|
+
* all it can do anything with. `serve.watchTicket` is this method already
|
|
1940
3191
|
* wrapped in a route
|
|
1941
3192
|
*/
|
|
1942
|
-
async
|
|
3193
|
+
async ticket(trigger, options) {
|
|
1943
3194
|
return await this.mintedTicket({
|
|
1944
|
-
trigger_secret: unguessable(
|
|
1945
|
-
replay:
|
|
3195
|
+
trigger_secret: unguessable(trigger),
|
|
3196
|
+
replay: options?.replay
|
|
1946
3197
|
});
|
|
1947
3198
|
}
|
|
1948
3199
|
needsTicket(asked) {
|
|
1949
|
-
return asked === true || this.token !== null;
|
|
3200
|
+
return asked === true || this.token !== null || this.secret !== null;
|
|
1950
3201
|
}
|
|
1951
3202
|
async wsTicket(body) {
|
|
1952
3203
|
return encodeURIComponent((await this.mintedTicket(body)).ticket);
|
|
@@ -1983,15 +3234,26 @@ var ThunderBridge = class {
|
|
|
1983
3234
|
this.speaks ??= callerKey(this.secret ?? "");
|
|
1984
3235
|
return await this.speaks;
|
|
1985
3236
|
}
|
|
1986
|
-
|
|
1987
|
-
const
|
|
1988
|
-
|
|
1989
|
-
|
|
3237
|
+
handOut(payment, key) {
|
|
3238
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
3239
|
+
for (const [hash2, earlier2] of this.handedOut) {
|
|
3240
|
+
if (earlier2.expiresAt <= now) {
|
|
3241
|
+
this.handedOut.delete(hash2);
|
|
3242
|
+
}
|
|
3243
|
+
}
|
|
3244
|
+
const hash = payment.paymentHash.toLowerCase();
|
|
3245
|
+
const earlier = this.handedOut.get(hash);
|
|
3246
|
+
if (earlier !== void 0 && (earlier.key === null || earlier.key !== key)) {
|
|
3247
|
+
throw new GatewayCheatError("invoice_reused", payment.id);
|
|
1990
3248
|
}
|
|
1991
|
-
|
|
3249
|
+
this.handedOut.set(hash, { key, expiresAt: payment.expiresAt });
|
|
1992
3250
|
}
|
|
1993
|
-
|
|
1994
|
-
|
|
3251
|
+
proven(payment, held) {
|
|
3252
|
+
const other = held !== void 0 && (payment.id !== held.id || payment.paymentHash.toLowerCase() !== held.paymentHash.toLowerCase());
|
|
3253
|
+
if (other) {
|
|
3254
|
+
throw new GatewayCheatError("hash_mismatch", payment.id);
|
|
3255
|
+
}
|
|
3256
|
+
if (payment.status === "paid" && !carriesProof(payment)) {
|
|
1995
3257
|
throw new GatewayCheatError("preimage_mismatch", payment.id);
|
|
1996
3258
|
}
|
|
1997
3259
|
return payment;
|
|
@@ -2007,11 +3269,21 @@ async function paymentFrom(response) {
|
|
|
2007
3269
|
}
|
|
2008
3270
|
return payment;
|
|
2009
3271
|
}
|
|
3272
|
+
async function mintedFrom(response) {
|
|
3273
|
+
const payment = mintedFromWire(await response.json().catch(() => null));
|
|
3274
|
+
if (payment === null) {
|
|
3275
|
+
throw new ProblemError({
|
|
3276
|
+
status: response.status,
|
|
3277
|
+
title: "The gateway answered with something that is not a minted payment"
|
|
3278
|
+
});
|
|
3279
|
+
}
|
|
3280
|
+
return payment;
|
|
3281
|
+
}
|
|
2010
3282
|
async function problemFrom(response) {
|
|
2011
3283
|
const problem = await response.json().catch(() => ({}));
|
|
2012
3284
|
const document = { ...problem, status: response.status };
|
|
2013
3285
|
if (isProblemType(problem, NO_WALLET_AVAILABLE)) {
|
|
2014
|
-
return new NoWalletAvailableError(document,
|
|
3286
|
+
return new NoWalletAvailableError(document, refusals2(problem.wallets));
|
|
2015
3287
|
}
|
|
2016
3288
|
if (isProblemType(problem, REQUEST_IN_FLIGHT)) {
|
|
2017
3289
|
return new IdempotencyConflictError(document, "request-in-flight");
|
|
@@ -2021,7 +3293,7 @@ async function problemFrom(response) {
|
|
|
2021
3293
|
}
|
|
2022
3294
|
return new ProblemError(document);
|
|
2023
3295
|
}
|
|
2024
|
-
function
|
|
3296
|
+
function refusals2(wallets) {
|
|
2025
3297
|
if (!Array.isArray(wallets)) {
|
|
2026
3298
|
return [];
|
|
2027
3299
|
}
|
|
@@ -2030,118 +3302,6 @@ function refusals(wallets) {
|
|
|
2030
3302
|
});
|
|
2031
3303
|
}
|
|
2032
3304
|
|
|
2033
|
-
// src/fio.ts
|
|
2034
|
-
var BASE_URL = "https://fioapi.fio.cz/v1/rest";
|
|
2035
|
-
var MINOR_UNITS = 100;
|
|
2036
|
-
var MILLIS = 1e3;
|
|
2037
|
-
var DATE_CHARS = "2026-08-04".length;
|
|
2038
|
-
var DEFAULT_MIN_INTERVAL_SECS = 30;
|
|
2039
|
-
var TOO_SOON = 409;
|
|
2040
|
-
var BOOKED_AT = "column0";
|
|
2041
|
-
var AMOUNT = "column1";
|
|
2042
|
-
var CURRENCY = "column14";
|
|
2043
|
-
var VARIABLE_SYMBOL = "column5";
|
|
2044
|
-
var USER_IDENTIFICATION = "column7";
|
|
2045
|
-
var MESSAGE_FOR_RECIPIENT = "column16";
|
|
2046
|
-
var PAYER_REFERENCE = "column27";
|
|
2047
|
-
var WHERE_A_PAYER_WRITES = [
|
|
2048
|
-
MESSAGE_FOR_RECIPIENT,
|
|
2049
|
-
VARIABLE_SYMBOL,
|
|
2050
|
-
USER_IDENTIFICATION,
|
|
2051
|
-
PAYER_REFERENCE
|
|
2052
|
-
];
|
|
2053
|
-
var UTC_OFFSET = /^[+-]\d{4}$/;
|
|
2054
|
-
function fioStatement(config) {
|
|
2055
|
-
const named = typeof config.token === "string" ? [config.token] : config.token;
|
|
2056
|
-
if (named.length === 0) {
|
|
2057
|
-
throw new Error("fioStatement needs at least one token to read with");
|
|
2058
|
-
}
|
|
2059
|
-
const usedAt = new Map(named.map((token) => [token, Number.NEGATIVE_INFINITY]));
|
|
2060
|
-
const tokenWindowMs = (config.minIntervalSecs ?? DEFAULT_MIN_INTERVAL_SECS) * MILLIS;
|
|
2061
|
-
const paceMs = tokenWindowMs / usedAt.size;
|
|
2062
|
-
let lastRead = Number.NEGATIVE_INFINITY;
|
|
2063
|
-
let credits = [];
|
|
2064
|
-
return async (sinceUnix) => {
|
|
2065
|
-
const now = Date.now();
|
|
2066
|
-
if (now - lastRead < paceMs) {
|
|
2067
|
-
return credits;
|
|
2068
|
-
}
|
|
2069
|
-
const idlest = longestUnused(usedAt);
|
|
2070
|
-
if (now - idlest.usedAt < tokenWindowMs) {
|
|
2071
|
-
return credits;
|
|
2072
|
-
}
|
|
2073
|
-
usedAt.set(idlest.token, now);
|
|
2074
|
-
lastRead = now;
|
|
2075
|
-
const url = [
|
|
2076
|
-
config.baseUrl ?? BASE_URL,
|
|
2077
|
-
"periods",
|
|
2078
|
-
idlest.token,
|
|
2079
|
-
asDate(sinceUnix),
|
|
2080
|
-
asDate(Math.floor(now / MILLIS)),
|
|
2081
|
-
"transactions.json"
|
|
2082
|
-
].join("/");
|
|
2083
|
-
const answer = await fetch(url, { headers: { accept: "application/json" } });
|
|
2084
|
-
if (answer.status === TOO_SOON) {
|
|
2085
|
-
throw new Error("fio refuses a second read of this token inside its 30 second window");
|
|
2086
|
-
}
|
|
2087
|
-
if (!answer.ok) {
|
|
2088
|
-
throw new Error(`fio answered ${answer.status} reading the statement`);
|
|
2089
|
-
}
|
|
2090
|
-
const read = await answer.json();
|
|
2091
|
-
credits = (read.accountStatement?.transactionList?.transaction ?? []).filter(isCredit).map(asCredit);
|
|
2092
|
-
return credits;
|
|
2093
|
-
};
|
|
2094
|
-
}
|
|
2095
|
-
function longestUnused(usedAt) {
|
|
2096
|
-
let token = "";
|
|
2097
|
-
let idleSince = Number.POSITIVE_INFINITY;
|
|
2098
|
-
for (const [candidate, at] of usedAt) {
|
|
2099
|
-
if (at < idleSince) {
|
|
2100
|
-
token = candidate;
|
|
2101
|
-
idleSince = at;
|
|
2102
|
-
}
|
|
2103
|
-
}
|
|
2104
|
-
return { token, usedAt: idleSince };
|
|
2105
|
-
}
|
|
2106
|
-
function isCredit(transaction) {
|
|
2107
|
-
return numberIn(transaction[AMOUNT]) > 0;
|
|
2108
|
-
}
|
|
2109
|
-
function asCredit(transaction) {
|
|
2110
|
-
return {
|
|
2111
|
-
amountMinor: Math.round(numberIn(transaction[AMOUNT]) * MINOR_UNITS),
|
|
2112
|
-
currency: textIn(transaction[CURRENCY]),
|
|
2113
|
-
reference: WHERE_A_PAYER_WRITES.map((column) => textIn(transaction[column])).filter((written) => written.length > 0).join(" "),
|
|
2114
|
-
bookedAt: bookedAtIn(transaction[BOOKED_AT])
|
|
2115
|
-
};
|
|
2116
|
-
}
|
|
2117
|
-
function bookedAtIn(cell) {
|
|
2118
|
-
const value = cell?.value;
|
|
2119
|
-
if (typeof value === "number") {
|
|
2120
|
-
return Math.floor(value / MILLIS);
|
|
2121
|
-
}
|
|
2122
|
-
const at = Date.parse(asIso8601(textIn(cell)));
|
|
2123
|
-
return Number.isFinite(at) ? Math.floor(at / MILLIS) : 0;
|
|
2124
|
-
}
|
|
2125
|
-
function asIso8601(booked) {
|
|
2126
|
-
const day = booked.slice(0, DATE_CHARS);
|
|
2127
|
-
const zone = booked.slice(DATE_CHARS);
|
|
2128
|
-
if (!UTC_OFFSET.test(zone)) {
|
|
2129
|
-
return `${day}T00:00:00Z`;
|
|
2130
|
-
}
|
|
2131
|
-
return `${day}T00:00:00${zone.slice(0, 3)}:${zone.slice(3)}`;
|
|
2132
|
-
}
|
|
2133
|
-
function numberIn(cell) {
|
|
2134
|
-
const value = Number(cell?.value ?? Number.NaN);
|
|
2135
|
-
return Number.isFinite(value) ? value : 0;
|
|
2136
|
-
}
|
|
2137
|
-
function textIn(cell) {
|
|
2138
|
-
const value = cell?.value;
|
|
2139
|
-
return value === null || value === void 0 ? "" : String(value).trim();
|
|
2140
|
-
}
|
|
2141
|
-
function asDate(unix) {
|
|
2142
|
-
return new Date(unix * MILLIS).toISOString().slice(0, DATE_CHARS);
|
|
2143
|
-
}
|
|
2144
|
-
|
|
2145
3305
|
// src/gateways.ts
|
|
2146
3306
|
var Gateways = class {
|
|
2147
3307
|
each;
|
|
@@ -2165,10 +3325,8 @@ var Gateways = class {
|
|
|
2165
3325
|
* carrying the first refusal, because one gateway that agreed is enough to be
|
|
2166
3326
|
* watched
|
|
2167
3327
|
*/
|
|
2168
|
-
async
|
|
2169
|
-
const asked = await Promise.allSettled(
|
|
2170
|
-
this.each.map((gateway) => gateway.watchPayment(params))
|
|
2171
|
-
);
|
|
3328
|
+
async watch(handover) {
|
|
3329
|
+
const asked = await Promise.allSettled(this.each.map((gateway) => gateway.watch(handover)));
|
|
2172
3330
|
const taken = [];
|
|
2173
3331
|
const refusals3 = [];
|
|
2174
3332
|
for (const [at, answer] of asked.entries()) {
|
|
@@ -2190,7 +3348,7 @@ var Gateways = class {
|
|
|
2190
3348
|
* saying so. When they all end without a payment, the first ending is the answer,
|
|
2191
3349
|
* and when they all fail, the first failure is thrown
|
|
2192
3350
|
*/
|
|
2193
|
-
async
|
|
3351
|
+
async settled(held, options) {
|
|
2194
3352
|
const stopLosers = new AbortController();
|
|
2195
3353
|
const signal = options?.signal ? AbortSignal.any([stopLosers.signal, options.signal]) : stopLosers.signal;
|
|
2196
3354
|
const unpaid = [];
|
|
@@ -2205,7 +3363,7 @@ var Gateways = class {
|
|
|
2205
3363
|
}
|
|
2206
3364
|
};
|
|
2207
3365
|
for (const gateway of this.each) {
|
|
2208
|
-
gateway.
|
|
3366
|
+
gateway.settled(held, { ...options, signal }).then((watched) => {
|
|
2209
3367
|
if (watched.status === "paid") {
|
|
2210
3368
|
return resolve2(watched);
|
|
2211
3369
|
}
|
|
@@ -2229,315 +3387,30 @@ var Gateways = class {
|
|
|
2229
3387
|
}
|
|
2230
3388
|
}
|
|
2231
3389
|
};
|
|
2232
|
-
|
|
2233
|
-
// src/price.ts
|
|
2234
|
-
var MSAT_PER_BTC2 = 1e11;
|
|
2235
|
-
var BASIS_POINTS = 1e4;
|
|
2236
|
-
var DEFAULT_HOLD_SECS = 60;
|
|
2237
|
-
var DEFAULT_MIN_VENUES = 2;
|
|
2238
|
-
var DEFAULT_MAX_SPREAD_BPS = 200;
|
|
2239
|
-
var MILLIS2 = 1e3;
|
|
2240
|
-
function coinbase(baseUrl = "https://api.coinbase.com") {
|
|
2241
|
-
return async (currency) => {
|
|
2242
|
-
const asked = currency.toUpperCase();
|
|
2243
|
-
const answer = await asJson(`${baseUrl}/v2/prices/BTC-${asked}/spot`, "coinbase");
|
|
2244
|
-
const quoted = answer.data;
|
|
2245
|
-
if (quoted?.currency !== asked) {
|
|
2246
|
-
throw new Error(`coinbase quoted ${quoted?.currency} when asked for ${asked}`);
|
|
2247
|
-
}
|
|
2248
|
-
return asMinor(quoted.amount, "coinbase", asked);
|
|
2249
|
-
};
|
|
2250
|
-
}
|
|
2251
|
-
function kraken(baseUrl = "https://api.kraken.com") {
|
|
2252
|
-
return async (currency) => {
|
|
2253
|
-
const asked = currency.toUpperCase();
|
|
2254
|
-
const answer = await asJson(`${baseUrl}/0/public/Ticker?pair=XBT${asked}`, "kraken");
|
|
2255
|
-
if (answer.error?.length) {
|
|
2256
|
-
throw new Error(`kraken refused XBT${asked}: ${answer.error[0]}`);
|
|
2257
|
-
}
|
|
2258
|
-
const pairs = Object.values(answer.result ?? {});
|
|
2259
|
-
if (pairs.length !== 1) {
|
|
2260
|
-
throw new Error(`kraken answered ${pairs.length} pairs for XBT${asked}`);
|
|
2261
|
-
}
|
|
2262
|
-
return asMinor(pairs[0]?.c?.[0], "kraken", asked);
|
|
2263
|
-
};
|
|
2264
|
-
}
|
|
2265
|
-
function bitstamp(baseUrl = "https://www.bitstamp.net") {
|
|
2266
|
-
return async (currency) => {
|
|
2267
|
-
const pair = `btc${currency.toLowerCase()}`;
|
|
2268
|
-
const answer = await asJson(`${baseUrl}/api/v2/ticker/${pair}/`, "bitstamp");
|
|
2269
|
-
if (Array.isArray(answer)) {
|
|
2270
|
-
throw new Error(`bitstamp has no ${pair} pair`);
|
|
2271
|
-
}
|
|
2272
|
-
return asMinor(answer.last, "bitstamp", currency);
|
|
2273
|
-
};
|
|
2274
|
-
}
|
|
2275
|
-
function coinmate(baseUrl = "https://coinmate.io") {
|
|
2276
|
-
return async (currency) => {
|
|
2277
|
-
const pair = `BTC_${currency.toUpperCase()}`;
|
|
2278
|
-
const answer = await asJson(`${baseUrl}/api/ticker?currencyPair=${pair}`, "coinmate");
|
|
2279
|
-
if (answer.error !== false) {
|
|
2280
|
-
throw new Error(`coinmate refused ${pair}: ${answer.errorMessage ?? "no reason"}`);
|
|
2281
|
-
}
|
|
2282
|
-
return asMinor(answer.data?.last, "coinmate", currency);
|
|
2283
|
-
};
|
|
2284
|
-
}
|
|
2285
|
-
function medianOf(tickers = [coinbase(), kraken(), bitstamp(), coinmate()], options = {}) {
|
|
2286
|
-
const holdFor = (options.holdForSecs ?? DEFAULT_HOLD_SECS) * MILLIS2;
|
|
2287
|
-
const minVenues = options.minVenues ?? DEFAULT_MIN_VENUES;
|
|
2288
|
-
const maxSpread = options.maxSpreadBps ?? DEFAULT_MAX_SPREAD_BPS;
|
|
2289
|
-
const held = /* @__PURE__ */ new Map();
|
|
2290
|
-
return async (currency) => {
|
|
2291
|
-
const asked = currency.toUpperCase();
|
|
2292
|
-
const remembered = held.get(asked);
|
|
2293
|
-
if (remembered && Date.now() - remembered.at < holdFor) {
|
|
2294
|
-
return remembered.price;
|
|
2295
|
-
}
|
|
2296
|
-
const answers = await Promise.allSettled(tickers.map((ticker) => ticker(asked)));
|
|
2297
|
-
const quoted = answers.filter((answer) => answer.status === "fulfilled").map((answer) => answer.value).sort((one, other) => one - other);
|
|
2298
|
-
if (quoted.length < minVenues) {
|
|
2299
|
-
throw new Error(
|
|
2300
|
-
`${quoted.length} of ${tickers.length} venues quoted ${asked}, ${minVenues} wanted: ${refusals2(answers)}`
|
|
2301
|
-
);
|
|
2302
|
-
}
|
|
2303
|
-
const spread = spreadBpsOf(quoted);
|
|
2304
|
-
if (spread > maxSpread) {
|
|
2305
|
-
throw new Error(
|
|
2306
|
-
`venues disagree on ${asked} by ${Math.round(spread)} bps, more than the ${maxSpread} allowed: ${quoted.join(", ")}`
|
|
2307
|
-
);
|
|
2308
|
-
}
|
|
2309
|
-
const price = middleOf(quoted);
|
|
2310
|
-
held.set(asked, { at: Date.now(), price });
|
|
2311
|
-
return price;
|
|
2312
|
-
};
|
|
2313
|
-
}
|
|
2314
|
-
function msatFor(amountMinor, priceMinorPerBtc, options = {}) {
|
|
2315
|
-
if (!Number.isInteger(amountMinor) || amountMinor <= 0) {
|
|
2316
|
-
throw new Error("amountMinor must be a whole number of minor units above zero");
|
|
2317
|
-
}
|
|
2318
|
-
if (!Number.isFinite(priceMinorPerBtc) || priceMinorPerBtc <= 0) {
|
|
2319
|
-
throw new Error(`${priceMinorPerBtc} is not a usable price`);
|
|
2320
|
-
}
|
|
2321
|
-
const spreadBps = options.spreadBps ?? 0;
|
|
2322
|
-
if (!Number.isInteger(spreadBps) || spreadBps < 0) {
|
|
2323
|
-
throw new Error("spreadBps must be a whole number of basis points, none or more");
|
|
2324
|
-
}
|
|
2325
|
-
const wanted = BigInt(amountMinor) * BigInt(MSAT_PER_BTC2) * BigInt(BASIS_POINTS + spreadBps);
|
|
2326
|
-
const per = BigInt(Math.round(priceMinorPerBtc)) * BigInt(BASIS_POINTS);
|
|
2327
|
-
const msat = (wanted + per - 1n) / per;
|
|
2328
|
-
if (msat > BigInt(Number.MAX_SAFE_INTEGER)) {
|
|
2329
|
-
throw new Error(`${amountMinor} at ${priceMinorPerBtc} is more millisatoshi than exist`);
|
|
2330
|
-
}
|
|
2331
|
-
return Number(msat);
|
|
2332
|
-
}
|
|
2333
|
-
async function asJson(url, venue) {
|
|
2334
|
-
const answer = await fetch(url, { headers: { accept: "application/json" } });
|
|
2335
|
-
if (!answer.ok) {
|
|
2336
|
-
throw new Error(`${venue} answered ${answer.status}`);
|
|
2337
|
-
}
|
|
2338
|
-
return answer.json();
|
|
2339
|
-
}
|
|
2340
|
-
function asMinor(quoted, venue, currency) {
|
|
2341
|
-
const major2 = Number(quoted);
|
|
2342
|
-
if (!Number.isFinite(major2) || major2 <= 0) {
|
|
2343
|
-
throw new Error(`${venue} quoted ${String(quoted)} for ${currency.toUpperCase()}`);
|
|
2344
|
-
}
|
|
2345
|
-
return Math.round(major2 * minorScaleOf(currency));
|
|
2346
|
-
}
|
|
2347
|
-
function spreadBpsOf(sorted) {
|
|
2348
|
-
const lowest = sorted[0] ?? 0;
|
|
2349
|
-
const highest = sorted[sorted.length - 1] ?? 0;
|
|
2350
|
-
return lowest === 0 ? Number.POSITIVE_INFINITY : (highest - lowest) / lowest * BASIS_POINTS;
|
|
2351
|
-
}
|
|
2352
|
-
function middleOf(sorted) {
|
|
2353
|
-
const middle = Math.floor(sorted.length / 2);
|
|
2354
|
-
if (sorted.length % 2 === 1) {
|
|
2355
|
-
return sorted[middle] ?? 0;
|
|
2356
|
-
}
|
|
2357
|
-
return Math.round(((sorted[middle - 1] ?? 0) + (sorted[middle] ?? 0)) / 2);
|
|
2358
|
-
}
|
|
2359
|
-
function refusals2(answers) {
|
|
2360
|
-
return answers.filter((answer) => answer.status === "rejected").map(
|
|
2361
|
-
(answer) => answer.reason instanceof Error ? answer.reason.message : String(answer.reason)
|
|
2362
|
-
).join("; ");
|
|
2363
|
-
}
|
|
2364
|
-
|
|
2365
|
-
// src/qr.ts
|
|
2366
|
-
var import_uqr = require("uqr");
|
|
2367
|
-
var SAFE_COLOR = /^#(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$|^rgba?\([\d.,%/\s]+\)$|^[a-z]+$/i;
|
|
2368
|
-
function encodeForQr(destination) {
|
|
2369
|
-
const scanned = isLnAddress(destination) ? destination : destination.toUpperCase();
|
|
2370
|
-
return `LIGHTNING:${scanned}`;
|
|
2371
|
-
}
|
|
2372
|
-
function invoiceToSvg(destination, options) {
|
|
2373
|
-
return svgOf(encodeForQr(destination), options);
|
|
2374
|
-
}
|
|
2375
|
-
function invoiceToDataUrl(destination, options) {
|
|
2376
|
-
return asDataUrl(invoiceToSvg(destination, options));
|
|
2377
|
-
}
|
|
2378
|
-
function lnurlToSvg(endpoint, options) {
|
|
2379
|
-
return svgOf(toLnurl(endpoint), options);
|
|
2380
|
-
}
|
|
2381
|
-
function lnurlToDataUrl(endpoint, options) {
|
|
2382
|
-
return asDataUrl(lnurlToSvg(endpoint, options));
|
|
2383
|
-
}
|
|
2384
|
-
function spdToSvg(spd, options) {
|
|
2385
|
-
return svgOf(spd, options);
|
|
2386
|
-
}
|
|
2387
|
-
function spdToDataUrl(spd, options) {
|
|
2388
|
-
return asDataUrl(spdToSvg(spd, options));
|
|
2389
|
-
}
|
|
2390
|
-
function qrToSvg(payload, options) {
|
|
2391
|
-
return svgOf(payload, options);
|
|
2392
|
-
}
|
|
2393
|
-
function qrToDataUrl(payload, options) {
|
|
2394
|
-
return asDataUrl(qrToSvg(payload, options));
|
|
2395
|
-
}
|
|
2396
|
-
function svgOf(scanned, options) {
|
|
2397
|
-
const size = options?.size ?? 256;
|
|
2398
|
-
const color = options?.color ?? "#000";
|
|
2399
|
-
if (!SAFE_COLOR.test(color)) {
|
|
2400
|
-
throw new Error(`Invalid color: ${color}`);
|
|
2401
|
-
}
|
|
2402
|
-
return renderSvg(scanned, size, color);
|
|
2403
|
-
}
|
|
2404
|
-
function asDataUrl(svg) {
|
|
2405
|
-
return `data:image/svg+xml,${encodeURIComponent(svg)}`;
|
|
2406
|
-
}
|
|
2407
|
-
function isLnAddress(destination) {
|
|
2408
|
-
const at = destination.indexOf("@");
|
|
2409
|
-
return at > 0 && destination.slice(at + 1).includes(".");
|
|
2410
|
-
}
|
|
2411
|
-
function renderSvg(content, size, color) {
|
|
2412
|
-
const { data } = (0, import_uqr.encode)(content);
|
|
2413
|
-
const modules = data.length;
|
|
2414
|
-
const cellSize = size / modules;
|
|
2415
|
-
let path = "";
|
|
2416
|
-
for (let y = 0; y < modules; y++) {
|
|
2417
|
-
for (let x = 0; x < modules; x++) {
|
|
2418
|
-
if (data[y][x]) {
|
|
2419
|
-
path += `M${x * cellSize},${y * cellSize}h${cellSize}v${cellSize}h-${cellSize}z`;
|
|
2420
|
-
}
|
|
2421
|
-
}
|
|
2422
|
-
}
|
|
2423
|
-
return [
|
|
2424
|
-
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${size} ${size}" width="${size}" height="${size}">`,
|
|
2425
|
-
`<rect width="100%" height="100%" fill="white"/>`,
|
|
2426
|
-
`<path d="${path}" fill="${color}"/>`,
|
|
2427
|
-
`</svg>`
|
|
2428
|
-
].join("");
|
|
2429
|
-
}
|
|
2430
|
-
|
|
2431
|
-
// src/rail.ts
|
|
2432
|
-
var BANK = "bank";
|
|
2433
|
-
var LIGHTNING = "lightning";
|
|
2434
|
-
function bankRail(config) {
|
|
2435
|
-
return async (order) => {
|
|
2436
|
-
const expiresAt = config.expiresAt(order);
|
|
2437
|
-
const transfer = await bankTransfer({
|
|
2438
|
-
gateway: config.gateway,
|
|
2439
|
-
secret: config.secret,
|
|
2440
|
-
iban: config.iban,
|
|
2441
|
-
verifyUrl: config.verifyUrl,
|
|
2442
|
-
reference: order.reference,
|
|
2443
|
-
amountMinor: order.amountMinor,
|
|
2444
|
-
currency: order.currency,
|
|
2445
|
-
expiresAt,
|
|
2446
|
-
trigger: config.trigger,
|
|
2447
|
-
replay: config.replay,
|
|
2448
|
-
sealed: await config.sealed?.(order),
|
|
2449
|
-
variableSymbol: config.variableSymbol?.(order),
|
|
2450
|
-
webhookUrl: config.webhookUrl,
|
|
2451
|
-
allowPublicGateway: config.allowPublicGateway
|
|
2452
|
-
});
|
|
2453
|
-
return {
|
|
2454
|
-
id: transfer.id,
|
|
2455
|
-
rail: config.name ?? BANK,
|
|
2456
|
-
scan: transfer.spd,
|
|
2457
|
-
qr: transfer.spd,
|
|
2458
|
-
expiresAt
|
|
2459
|
-
};
|
|
2460
|
-
};
|
|
2461
|
-
}
|
|
2462
|
-
function lightningRail(config) {
|
|
2463
|
-
return async (order) => {
|
|
2464
|
-
const payment = await config.gateway.createPayment(
|
|
2465
|
-
{
|
|
2466
|
-
lnAddresses: config.lnAddresses,
|
|
2467
|
-
amountMsat: await config.amountMsat(order),
|
|
2468
|
-
webhookUrl: config.webhookUrl
|
|
2469
|
-
},
|
|
2470
|
-
{
|
|
2471
|
-
idempotencyKey: config.idempotencyKey?.(order),
|
|
2472
|
-
trigger: config.trigger,
|
|
2473
|
-
replay: config.replay
|
|
2474
|
-
}
|
|
2475
|
-
);
|
|
2476
|
-
return {
|
|
2477
|
-
id: payment.id,
|
|
2478
|
-
rail: config.name ?? LIGHTNING,
|
|
2479
|
-
scan: payment.bolt11,
|
|
2480
|
-
qr: encodeForQr(payment.bolt11),
|
|
2481
|
-
expiresAt: payment.expiresAt
|
|
2482
|
-
};
|
|
2483
|
-
};
|
|
2484
|
-
}
|
|
2485
3390
|
// Annotate the CommonJS export names for ESM import in node:
|
|
2486
3391
|
0 && (module.exports = {
|
|
3392
|
+
AmountError,
|
|
2487
3393
|
GatewayCheatError,
|
|
2488
3394
|
Gateways,
|
|
2489
|
-
IDEMPOTENCY_KEY_REUSED,
|
|
2490
3395
|
IdempotencyConflictError,
|
|
2491
|
-
NO_WALLET_AVAILABLE,
|
|
2492
3396
|
NoWalletAvailableError,
|
|
2493
|
-
PAYMENT_ALREADY_WATCHED,
|
|
2494
3397
|
ProblemError,
|
|
2495
|
-
REQUEST_IN_FLIGHT,
|
|
2496
3398
|
ThunderBridge,
|
|
2497
3399
|
UnverifiedRecipientError,
|
|
2498
3400
|
WrapRefusedError,
|
|
2499
3401
|
answerVerifyChallenge,
|
|
2500
|
-
|
|
2501
|
-
answerWebhookChallenge,
|
|
2502
|
-
answerWebhookChallengeRequest,
|
|
2503
|
-
bankRail,
|
|
2504
|
-
bankTransfer,
|
|
2505
|
-
bankVerifyEndpoint,
|
|
2506
|
-
bitstamp,
|
|
2507
|
-
coinbase,
|
|
2508
|
-
coinmate,
|
|
3402
|
+
carriesProof,
|
|
2509
3403
|
decodeInvoice,
|
|
2510
|
-
|
|
2511
|
-
|
|
2512
|
-
|
|
2513
|
-
isProblemType,
|
|
2514
|
-
isProvablyPaid,
|
|
2515
|
-
isProvablySettled,
|
|
2516
|
-
kraken,
|
|
2517
|
-
lightningRail,
|
|
2518
|
-
lnurlToDataUrl,
|
|
2519
|
-
lnurlToSvg,
|
|
2520
|
-
medianOf,
|
|
2521
|
-
minorScaleOf,
|
|
2522
|
-
minorUnitsOf,
|
|
2523
|
-
msatFor,
|
|
2524
|
-
parseSettlement,
|
|
2525
|
-
parseSettlementRequest,
|
|
2526
|
-
parseWatchedWebhook,
|
|
2527
|
-
parseWatchedWebhookRequest,
|
|
2528
|
-
parseWebhook,
|
|
2529
|
-
parseWebhookRequest,
|
|
3404
|
+
fiat,
|
|
3405
|
+
invoiceFrom,
|
|
3406
|
+
msat,
|
|
2530
3407
|
preimageMatchesHash,
|
|
2531
3408
|
proveOrigin,
|
|
2532
3409
|
proveSettlement,
|
|
2533
3410
|
proveWrapped,
|
|
2534
|
-
|
|
2535
|
-
|
|
3411
|
+
relayedVerifyUrl,
|
|
3412
|
+
sats,
|
|
2536
3413
|
seal,
|
|
2537
|
-
spdToDataUrl,
|
|
2538
|
-
spdToSvg,
|
|
2539
|
-
toLnurl,
|
|
2540
3414
|
unseal,
|
|
2541
|
-
verifyWebhookSignature,
|
|
2542
3415
|
wrapFeeCeiling
|
|
2543
3416
|
});
|