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