thunder-bridge 1.5.0 → 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +49 -24
- package/dist/{client-Cc5gtjGV.d.ts → bank-B5MxT_6u.d.ts} +367 -42
- package/dist/{client-CzGZcByI.d.cts → bank-Bg6RjuO-.d.cts} +367 -42
- package/dist/bank.cjs +196 -0
- package/dist/bank.d.cts +4 -2
- package/dist/bank.d.ts +4 -2
- package/dist/bank.js +195 -0
- package/dist/{errors-Dmh-Uoh8.d.cts → errors-DJmsalYZ.d.cts} +3 -3
- package/dist/{errors-0vbVoISA.d.ts → errors-VX0R18oE.d.ts} +3 -3
- package/dist/index.cjs +1313 -395
- package/dist/index.d.cts +19 -10
- package/dist/index.d.ts +19 -10
- package/dist/index.js +1312 -395
- package/dist/nwc.cjs +183 -42
- package/dist/nwc.d.cts +10 -111
- package/dist/nwc.d.ts +10 -111
- package/dist/nwc.js +180 -39
- package/dist/price.d.cts +2 -2
- package/dist/price.d.ts +2 -2
- package/dist/qr.cjs +20 -23
- package/dist/qr.js +20 -23
- package/dist/{types-BNPmVnA7.d.cts → types-DYZ9EkmJ.d.cts} +11 -2
- package/dist/{types-BNPmVnA7.d.ts → types-DYZ9EkmJ.d.ts} +11 -2
- package/openapi.yaml +30 -40
- package/package.json +1 -1
- package/dist/bank-B3mHISn1.d.cts +0 -92
- package/dist/bank-B3mHISn1.d.ts +0 -92
package/dist/index.cjs
CHANGED
|
@@ -39,6 +39,7 @@ __export(src_exports, {
|
|
|
39
39
|
ThunderBridge: () => ThunderBridge,
|
|
40
40
|
UnverifiedRecipientError: () => UnverifiedRecipientError,
|
|
41
41
|
WrapRefusedError: () => WrapRefusedError,
|
|
42
|
+
agreesWithItself: () => agreesWithItself,
|
|
42
43
|
answerVerifyChallenge: () => answerVerifyChallenge,
|
|
43
44
|
carriesProof: () => carriesProof,
|
|
44
45
|
decodeInvoice: () => decodeInvoice,
|
|
@@ -157,11 +158,11 @@ var BLOCK_BYTES = 64;
|
|
|
157
158
|
var LENGTH_BYTES = 8;
|
|
158
159
|
var SCHEDULE_WORDS = 64;
|
|
159
160
|
function sha256(bytes) {
|
|
160
|
-
const
|
|
161
|
-
const reader = new DataView(
|
|
161
|
+
const padded2 = withPadding(bytes);
|
|
162
|
+
const reader = new DataView(padded2.buffer);
|
|
162
163
|
const state = Uint32Array.from(INITIAL_STATE);
|
|
163
164
|
const schedule = new Uint32Array(SCHEDULE_WORDS);
|
|
164
|
-
for (let block = 0; block <
|
|
165
|
+
for (let block = 0; block < padded2.length; block += BLOCK_BYTES) {
|
|
165
166
|
fillSchedule(schedule, reader, block);
|
|
166
167
|
compress(state, schedule);
|
|
167
168
|
}
|
|
@@ -171,13 +172,13 @@ function sha256Hex(text3) {
|
|
|
171
172
|
return bytesToHex(sha256(new TextEncoder().encode(text3)));
|
|
172
173
|
}
|
|
173
174
|
function withPadding(bytes) {
|
|
174
|
-
const
|
|
175
|
+
const padded2 = new Uint8Array(
|
|
175
176
|
bytes.length + 1 + LENGTH_BYTES + BLOCK_BYTES - 1 & ~(BLOCK_BYTES - 1)
|
|
176
177
|
);
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
new DataView(
|
|
180
|
-
return
|
|
178
|
+
padded2.set(bytes);
|
|
179
|
+
padded2[bytes.length] = 128;
|
|
180
|
+
new DataView(padded2.buffer).setUint32(padded2.length - 4, bytes.length * 8, false);
|
|
181
|
+
return padded2;
|
|
181
182
|
}
|
|
182
183
|
function fillSchedule(schedule, reader, block) {
|
|
183
184
|
for (let at = 0; at < 16; at++) {
|
|
@@ -234,6 +235,7 @@ function rotate(value, by) {
|
|
|
234
235
|
|
|
235
236
|
// ../core/bolt11.ts
|
|
236
237
|
var BECH32_CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l";
|
|
238
|
+
var BECH32_GENERATOR = [996825010, 642813549, 513874426, 1027748829, 705979059];
|
|
237
239
|
var CHECKSUM_WORDS = 6;
|
|
238
240
|
var TIMESTAMP_WORDS = 7;
|
|
239
241
|
var SIGNATURE_WORDS = 104;
|
|
@@ -251,18 +253,27 @@ var HRP_MULTIPLIER_MSAT = {
|
|
|
251
253
|
n: MSAT_PER_BTC / 1e9,
|
|
252
254
|
p: MSAT_PER_BTC / 1e12
|
|
253
255
|
};
|
|
254
|
-
var
|
|
256
|
+
var HRP = /^ln(?:bcrt|bc|tbs|tb|sb)(?:(\d+)([munp]?))?$/;
|
|
255
257
|
function decodeInvoice(bolt11) {
|
|
256
258
|
const parts = splitBech32(bolt11);
|
|
257
259
|
if (parts === null) {
|
|
258
|
-
return {
|
|
260
|
+
return {
|
|
261
|
+
paymentHash: null,
|
|
262
|
+
descriptionHash: null,
|
|
263
|
+
amountMsat: null,
|
|
264
|
+
issuedAt: null,
|
|
265
|
+
expiresAt: null
|
|
266
|
+
};
|
|
259
267
|
}
|
|
260
268
|
const tagged = taggedFields(parts.words);
|
|
269
|
+
const issuedAt = issuedAtOf(parts.words);
|
|
270
|
+
const expiry = tagged.get(EXPIRY_TAG);
|
|
261
271
|
return {
|
|
262
272
|
paymentHash: hexTag(tagged, PAYMENT_HASH_TAG, PAYMENT_HASH_WORDS),
|
|
263
273
|
descriptionHash: hexTag(tagged, DESCRIPTION_HASH_TAG, DESCRIPTION_HASH_WORDS),
|
|
264
274
|
amountMsat: amountFromHrp(parts.hrp),
|
|
265
|
-
|
|
275
|
+
issuedAt,
|
|
276
|
+
expiresAt: issuedAt === null ? null : issuedAt + (expiry ? readNumber(expiry) : DEFAULT_EXPIRY_SECS)
|
|
266
277
|
};
|
|
267
278
|
}
|
|
268
279
|
function preimageMatchesHash(preimage, paymentHash) {
|
|
@@ -273,7 +284,7 @@ function preimageMatchesHash(preimage, paymentHash) {
|
|
|
273
284
|
}
|
|
274
285
|
function splitBech32(bolt11) {
|
|
275
286
|
const lower = bolt11.toLowerCase();
|
|
276
|
-
if (isBolt12Encoding(lower)) {
|
|
287
|
+
if (isBolt12Encoding(lower) || bolt11 !== lower && bolt11 !== bolt11.toUpperCase()) {
|
|
277
288
|
return null;
|
|
278
289
|
}
|
|
279
290
|
const separator = lower.lastIndexOf("1");
|
|
@@ -292,7 +303,28 @@ function splitBech32(bolt11) {
|
|
|
292
303
|
}
|
|
293
304
|
words.push(value);
|
|
294
305
|
}
|
|
295
|
-
|
|
306
|
+
const hrp = lower.slice(0, separator);
|
|
307
|
+
if (!HRP.test(hrp) || bech32Polymod([...expandedHrp(hrp), ...words]) !== 1) {
|
|
308
|
+
return null;
|
|
309
|
+
}
|
|
310
|
+
return { hrp, words: words.slice(0, -CHECKSUM_WORDS) };
|
|
311
|
+
}
|
|
312
|
+
function expandedHrp(hrp) {
|
|
313
|
+
const codes = [...hrp].map((char) => char.charCodeAt(0));
|
|
314
|
+
return [...codes.map((code) => code >> 5), 0, ...codes.map((code) => code & 31)];
|
|
315
|
+
}
|
|
316
|
+
function bech32Polymod(values) {
|
|
317
|
+
let checksum = 1;
|
|
318
|
+
for (const value of values) {
|
|
319
|
+
const top = checksum >> 25;
|
|
320
|
+
checksum = (checksum & 33554431) << 5 ^ value;
|
|
321
|
+
for (let bit = 0; bit < 5; bit++) {
|
|
322
|
+
if (top >> bit & 1) {
|
|
323
|
+
checksum ^= BECH32_GENERATOR[bit];
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
return checksum;
|
|
296
328
|
}
|
|
297
329
|
function isBolt12Encoding(lower) {
|
|
298
330
|
return lower.startsWith("lno") || lower.startsWith("lni") || lower.startsWith("lnr");
|
|
@@ -322,19 +354,17 @@ function hexTag(fields, tag, expectedWords) {
|
|
|
322
354
|
}
|
|
323
355
|
return bytesToHex(wordsToBytes(words));
|
|
324
356
|
}
|
|
325
|
-
function
|
|
357
|
+
function issuedAtOf(words) {
|
|
326
358
|
if (words.length < TIMESTAMP_WORDS + SIGNATURE_WORDS) {
|
|
327
359
|
return null;
|
|
328
360
|
}
|
|
329
|
-
|
|
330
|
-
return readNumber(words.slice(0, TIMESTAMP_WORDS)) + (expiry ? readNumber(expiry) : DEFAULT_EXPIRY_SECS);
|
|
361
|
+
return readNumber(words.slice(0, TIMESTAMP_WORDS));
|
|
331
362
|
}
|
|
332
363
|
function amountFromHrp(hrp) {
|
|
333
|
-
const
|
|
334
|
-
if (
|
|
364
|
+
const [, digits, multiplier] = HRP.exec(hrp) ?? [];
|
|
365
|
+
if (digits === void 0 || multiplier === "p" && !digits.endsWith("0")) {
|
|
335
366
|
return null;
|
|
336
367
|
}
|
|
337
|
-
const [, digits, multiplier] = match;
|
|
338
368
|
return multiplier ? Number(digits) * HRP_MULTIPLIER_MSAT[multiplier] : Number(digits) * MSAT_PER_BTC;
|
|
339
369
|
}
|
|
340
370
|
function readNumber(words) {
|
|
@@ -355,49 +385,148 @@ function wordsToBytes(words) {
|
|
|
355
385
|
return Uint8Array.from(bytes);
|
|
356
386
|
}
|
|
357
387
|
|
|
388
|
+
// ../core/hmac.ts
|
|
389
|
+
async function hmacHex(secret, payload) {
|
|
390
|
+
const raw = typeof secret === "string" ? new TextEncoder().encode(secret) : secret;
|
|
391
|
+
const key = await crypto.subtle.importKey(
|
|
392
|
+
"raw",
|
|
393
|
+
asBuffer(raw),
|
|
394
|
+
{ name: "HMAC", hash: "SHA-256" },
|
|
395
|
+
false,
|
|
396
|
+
["sign"]
|
|
397
|
+
);
|
|
398
|
+
const message = typeof payload === "string" ? new TextEncoder().encode(payload) : payload;
|
|
399
|
+
return bytesToHex(new Uint8Array(await crypto.subtle.sign("HMAC", key, asBuffer(message))));
|
|
400
|
+
}
|
|
401
|
+
function equalInConstantTime(one, other) {
|
|
402
|
+
if (one.length !== other.length) {
|
|
403
|
+
return false;
|
|
404
|
+
}
|
|
405
|
+
let difference = 0;
|
|
406
|
+
for (let at = 0; at < one.length; at++) {
|
|
407
|
+
difference |= one.charCodeAt(at) ^ other.charCodeAt(at);
|
|
408
|
+
}
|
|
409
|
+
return difference === 0;
|
|
410
|
+
}
|
|
411
|
+
function asBuffer(bytes) {
|
|
412
|
+
return bytes instanceof Uint8Array && bytes.buffer instanceof ArrayBuffer ? bytes : new Uint8Array(bytes);
|
|
413
|
+
}
|
|
414
|
+
|
|
358
415
|
// ../core/sealed.ts
|
|
359
|
-
var VERSION = "
|
|
416
|
+
var VERSION = "v2";
|
|
417
|
+
var UNPADDED_VERSION = "v1";
|
|
418
|
+
var PURPOSE_VERSION = "v3";
|
|
360
419
|
var IV_BYTES = 12;
|
|
361
420
|
var MIN_SECRET_CHARS = 32;
|
|
362
421
|
var MAX_PLAIN_BYTES = 3e3;
|
|
363
|
-
var
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
422
|
+
var SIZE_CLASSES = [256, 1024, MAX_PLAIN_BYTES];
|
|
423
|
+
var END_OF_TEXT = 128;
|
|
424
|
+
var INFO = "thunder-bridge/sealed";
|
|
425
|
+
async function seal(secret, plaintext, paymentHash) {
|
|
426
|
+
return await sealUnder(
|
|
427
|
+
await keyFor(secret, INFO),
|
|
428
|
+
plaintext,
|
|
429
|
+
crypto.getRandomValues(new Uint8Array(IV_BYTES)),
|
|
430
|
+
VERSION,
|
|
431
|
+
boundTo(paymentHash)
|
|
432
|
+
);
|
|
433
|
+
}
|
|
434
|
+
async function sealFor(purpose, secret, plaintext, { stable = false } = {}) {
|
|
435
|
+
return await sealUnder(
|
|
436
|
+
await keyFor(secret, `${INFO}/${purpose}`),
|
|
437
|
+
plaintext,
|
|
438
|
+
stable ? await nonceOf(secret, plaintext) : crypto.getRandomValues(new Uint8Array(IV_BYTES)),
|
|
439
|
+
PURPOSE_VERSION,
|
|
440
|
+
new TextEncoder().encode(PURPOSE_VERSION)
|
|
441
|
+
);
|
|
442
|
+
}
|
|
443
|
+
async function unsealFor(purpose, secret, sealed) {
|
|
444
|
+
if (!sealed.startsWith(`${PURPOSE_VERSION}.`)) {
|
|
445
|
+
return await unseal(secret, sealed);
|
|
446
|
+
}
|
|
447
|
+
return await opened(
|
|
448
|
+
await keyFor(secret, `${INFO}/${purpose}`),
|
|
449
|
+
sealed.slice(PURPOSE_VERSION.length + 1),
|
|
450
|
+
new TextEncoder().encode(PURPOSE_VERSION)
|
|
451
|
+
);
|
|
452
|
+
}
|
|
453
|
+
async function nonceOf(secret, plaintext) {
|
|
454
|
+
const derived = await hmacHex(secret, `sealed-nonce|${plaintext}`);
|
|
455
|
+
const nonce = new Uint8Array(IV_BYTES);
|
|
456
|
+
for (let at = 0; at < IV_BYTES; at++) {
|
|
457
|
+
nonce[at] = Number.parseInt(derived.slice(at * 2, at * 2 + 2), 16);
|
|
458
|
+
}
|
|
459
|
+
return nonce;
|
|
460
|
+
}
|
|
461
|
+
async function sealUnder(key, plaintext, iv, version, additionalData) {
|
|
462
|
+
const body = padToClass(plaintext);
|
|
463
|
+
const cipher = new Uint8Array(
|
|
464
|
+
await crypto.subtle.encrypt({ name: "AES-GCM", iv, additionalData }, key, body)
|
|
465
|
+
);
|
|
466
|
+
const joined2 = new Uint8Array(iv.length + cipher.length);
|
|
467
|
+
joined2.set(iv);
|
|
468
|
+
joined2.set(cipher, iv.length);
|
|
469
|
+
return `${version}.${toBase64Url(joined2)}`;
|
|
470
|
+
}
|
|
471
|
+
async function unseal(secret, sealed, paymentHash) {
|
|
472
|
+
const key = await keyFor(secret, INFO);
|
|
473
|
+
const padded2 = sealed.startsWith(`${VERSION}.`);
|
|
474
|
+
const unbound = !padded2 && sealed.startsWith(`${UNPADDED_VERSION}.`);
|
|
475
|
+
if (!padded2 && (!unbound || paymentHash !== void 0)) {
|
|
380
476
|
return null;
|
|
381
477
|
}
|
|
382
|
-
|
|
383
|
-
|
|
478
|
+
return await opened(
|
|
479
|
+
key,
|
|
480
|
+
sealed.slice(VERSION.length + 1),
|
|
481
|
+
padded2 ? boundTo(paymentHash) : void 0
|
|
482
|
+
);
|
|
483
|
+
}
|
|
484
|
+
async function opened(key, encoded, additionalData) {
|
|
485
|
+
const joined2 = fromBase64Url(encoded);
|
|
486
|
+
if (joined2 === null || joined2.length <= IV_BYTES) {
|
|
384
487
|
return null;
|
|
385
488
|
}
|
|
386
489
|
try {
|
|
387
|
-
const body =
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
490
|
+
const body = new Uint8Array(
|
|
491
|
+
await crypto.subtle.decrypt(
|
|
492
|
+
{ name: "AES-GCM", iv: joined2.slice(0, IV_BYTES), additionalData },
|
|
493
|
+
key,
|
|
494
|
+
joined2.slice(IV_BYTES)
|
|
495
|
+
)
|
|
391
496
|
);
|
|
392
|
-
|
|
497
|
+
const text3 = additionalData === void 0 ? body : unpad(body);
|
|
498
|
+
return text3 === null ? null : new TextDecoder().decode(text3);
|
|
393
499
|
} catch {
|
|
394
500
|
return null;
|
|
395
501
|
}
|
|
396
502
|
}
|
|
397
|
-
|
|
503
|
+
function refuseAWeakSecret(secret) {
|
|
398
504
|
if (secret.length < MIN_SECRET_CHARS) {
|
|
399
505
|
throw new Error(`the sealing secret needs ${MIN_SECRET_CHARS} characters of randomness`);
|
|
400
506
|
}
|
|
507
|
+
}
|
|
508
|
+
function boundTo(paymentHash) {
|
|
509
|
+
return new TextEncoder().encode(
|
|
510
|
+
paymentHash === void 0 ? VERSION : `${VERSION}|${paymentHash.toLowerCase()}`
|
|
511
|
+
);
|
|
512
|
+
}
|
|
513
|
+
function padToClass(plaintext) {
|
|
514
|
+
const text3 = new TextEncoder().encode(plaintext);
|
|
515
|
+
const size = SIZE_CLASSES.find((limit) => text3.length <= limit);
|
|
516
|
+
if (size === void 0) {
|
|
517
|
+
throw new Error(`sealed takes at most ${MAX_PLAIN_BYTES} bytes, this was ${text3.length}`);
|
|
518
|
+
}
|
|
519
|
+
const body = new Uint8Array(size + 1);
|
|
520
|
+
body.set(text3);
|
|
521
|
+
body[text3.length] = END_OF_TEXT;
|
|
522
|
+
return body;
|
|
523
|
+
}
|
|
524
|
+
function unpad(body) {
|
|
525
|
+
const end = body.lastIndexOf(END_OF_TEXT);
|
|
526
|
+
return end === -1 ? null : body.subarray(0, end);
|
|
527
|
+
}
|
|
528
|
+
async function keyFor(secret, info) {
|
|
529
|
+
refuseAWeakSecret(secret);
|
|
401
530
|
const material = await crypto.subtle.importKey(
|
|
402
531
|
"raw",
|
|
403
532
|
new TextEncoder().encode(secret),
|
|
@@ -406,7 +535,12 @@ async function keyFor(secret) {
|
|
|
406
535
|
["deriveKey"]
|
|
407
536
|
);
|
|
408
537
|
return crypto.subtle.deriveKey(
|
|
409
|
-
{
|
|
538
|
+
{
|
|
539
|
+
name: "HKDF",
|
|
540
|
+
hash: "SHA-256",
|
|
541
|
+
salt: new Uint8Array(0),
|
|
542
|
+
info: new TextEncoder().encode(info)
|
|
543
|
+
},
|
|
410
544
|
material,
|
|
411
545
|
{ name: "AES-GCM", length: 256 },
|
|
412
546
|
false,
|
|
@@ -594,11 +728,11 @@ function coinbase(baseUrl = "https://api.coinbase.com") {
|
|
|
594
728
|
return async (currency) => {
|
|
595
729
|
const asked = currency.toUpperCase();
|
|
596
730
|
const answer = await asJson(`${baseUrl}/v2/prices/BTC-${asked}/spot`, "coinbase");
|
|
597
|
-
const
|
|
598
|
-
if (
|
|
599
|
-
throw new Error(`coinbase quoted ${
|
|
731
|
+
const quoted2 = answer.data;
|
|
732
|
+
if (quoted2?.currency !== asked) {
|
|
733
|
+
throw new Error(`coinbase quoted ${quoted2?.currency} when asked for ${asked}`);
|
|
600
734
|
}
|
|
601
|
-
return asMinor(
|
|
735
|
+
return asMinor(quoted2.amount, "coinbase", asked);
|
|
602
736
|
};
|
|
603
737
|
}
|
|
604
738
|
function kraken(baseUrl = "https://api.kraken.com") {
|
|
@@ -647,19 +781,19 @@ function medianOf(tickers = [coinbase(), kraken(), bitstamp(), coinmate()], opti
|
|
|
647
781
|
return remembered.price;
|
|
648
782
|
}
|
|
649
783
|
const answers = await Promise.allSettled(tickers.map((ticker) => ticker(asked)));
|
|
650
|
-
const
|
|
651
|
-
if (
|
|
784
|
+
const quoted2 = answers.filter((answer) => answer.status === "fulfilled").map((answer) => answer.value).sort((one, other) => one - other);
|
|
785
|
+
if (quoted2.length < minVenues) {
|
|
652
786
|
throw new Error(
|
|
653
|
-
`${
|
|
787
|
+
`${quoted2.length} of ${tickers.length} venues quoted ${asked}, ${minVenues} wanted: ${refusals(answers)}`
|
|
654
788
|
);
|
|
655
789
|
}
|
|
656
|
-
const spread2 = spreadBpsOf(
|
|
790
|
+
const spread2 = spreadBpsOf(quoted2);
|
|
657
791
|
if (spread2 > maxSpread) {
|
|
658
792
|
throw new Error(
|
|
659
|
-
`venues disagree on ${asked} by ${Math.round(spread2)} bps, more than the ${maxSpread} allowed: ${
|
|
793
|
+
`venues disagree on ${asked} by ${Math.round(spread2)} bps, more than the ${maxSpread} allowed: ${quoted2.join(", ")}`
|
|
660
794
|
);
|
|
661
795
|
}
|
|
662
|
-
const price = middleOf(
|
|
796
|
+
const price = middleOf(quoted2);
|
|
663
797
|
held.set(asked, { at: Date.now(), price });
|
|
664
798
|
return price;
|
|
665
799
|
};
|
|
@@ -690,10 +824,10 @@ async function asJson(url, venue) {
|
|
|
690
824
|
}
|
|
691
825
|
return answer.json();
|
|
692
826
|
}
|
|
693
|
-
function asMinor(
|
|
694
|
-
const major2 = Number(
|
|
827
|
+
function asMinor(quoted2, venue, currency) {
|
|
828
|
+
const major2 = Number(quoted2);
|
|
695
829
|
if (!Number.isFinite(major2) || major2 <= 0) {
|
|
696
|
-
throw new Error(`${venue} quoted ${String(
|
|
830
|
+
throw new Error(`${venue} quoted ${String(quoted2)} for ${currency.toUpperCase()}`);
|
|
697
831
|
}
|
|
698
832
|
return Math.round(major2 * minorScaleOf(currency));
|
|
699
833
|
}
|
|
@@ -781,6 +915,16 @@ var SEED_BYTES = 32;
|
|
|
781
915
|
var SIGNATURE_BYTES = 64;
|
|
782
916
|
var PUBLIC_KEY_BYTES = 32;
|
|
783
917
|
var PKCS8_HEADER = hexToBytes("302e020100300506032b657004220420");
|
|
918
|
+
var SIGN_BIT_CLEARED = 127;
|
|
919
|
+
var SMALL_ORDER = [
|
|
920
|
+
"00".repeat(32),
|
|
921
|
+
`01${"00".repeat(31)}`,
|
|
922
|
+
"26e8958fc2b227b045c3f489f2ef98f0d5dfac05d3c63339b13802886d53fc05",
|
|
923
|
+
"c7176a703d4dd84fba3c0b760d10670f2a2053fa2c39ccc64ec7fd7792ac037a",
|
|
924
|
+
`ec${"ff".repeat(30)}7f`,
|
|
925
|
+
`ed${"ff".repeat(30)}7f`,
|
|
926
|
+
`ee${"ff".repeat(30)}7f`
|
|
927
|
+
];
|
|
784
928
|
async function signingKeyFromSeed(seed) {
|
|
785
929
|
if (seed.length !== SEED_BYTES) {
|
|
786
930
|
throw new Error(`an ed25519 seed is ${SEED_BYTES} bytes`);
|
|
@@ -788,24 +932,27 @@ async function signingKeyFromSeed(seed) {
|
|
|
788
932
|
const wrapped = new Uint8Array(PKCS8_HEADER.length + SEED_BYTES);
|
|
789
933
|
wrapped.set(PKCS8_HEADER);
|
|
790
934
|
wrapped.set(seed, PKCS8_HEADER.length);
|
|
791
|
-
const key = await crypto.subtle.importKey("pkcs8",
|
|
935
|
+
const key = await crypto.subtle.importKey("pkcs8", asBuffer2(wrapped), ALGORITHM, true, ["sign"]);
|
|
792
936
|
const jwk = await crypto.subtle.exportKey("jwk", key);
|
|
793
937
|
return {
|
|
794
938
|
publicKeyHex: bytesToHex(fromBase64Url2(jwk.x ?? "")),
|
|
795
|
-
sign: async (payload) => bytesToHex(new Uint8Array(await crypto.subtle.sign(ALGORITHM, key,
|
|
939
|
+
sign: async (payload) => bytesToHex(new Uint8Array(await crypto.subtle.sign(ALGORITHM, key, asBuffer2(payload))))
|
|
796
940
|
};
|
|
797
941
|
}
|
|
798
942
|
async function verifyHex(publicKeyHex, signatureHex, payload) {
|
|
799
943
|
if (!isHex(publicKeyHex) || publicKeyHex.length !== PUBLIC_KEY_BYTES * 2) {
|
|
800
944
|
return false;
|
|
801
945
|
}
|
|
946
|
+
if (hasSmallOrder(publicKeyHex)) {
|
|
947
|
+
return false;
|
|
948
|
+
}
|
|
802
949
|
if (!isHex(signatureHex) || signatureHex.length !== SIGNATURE_BYTES * 2) {
|
|
803
950
|
return false;
|
|
804
951
|
}
|
|
805
952
|
try {
|
|
806
953
|
const key = await crypto.subtle.importKey(
|
|
807
954
|
"raw",
|
|
808
|
-
|
|
955
|
+
asBuffer2(hexToBytes(publicKeyHex)),
|
|
809
956
|
ALGORITHM,
|
|
810
957
|
false,
|
|
811
958
|
["verify"]
|
|
@@ -813,58 +960,38 @@ async function verifyHex(publicKeyHex, signatureHex, payload) {
|
|
|
813
960
|
return await crypto.subtle.verify(
|
|
814
961
|
ALGORITHM,
|
|
815
962
|
key,
|
|
816
|
-
|
|
817
|
-
|
|
963
|
+
asBuffer2(hexToBytes(signatureHex)),
|
|
964
|
+
asBuffer2(payload)
|
|
818
965
|
);
|
|
819
966
|
} catch {
|
|
820
967
|
return false;
|
|
821
968
|
}
|
|
822
969
|
}
|
|
970
|
+
function hasSmallOrder(publicKeyHex) {
|
|
971
|
+
const point = hexToBytes(publicKeyHex);
|
|
972
|
+
point[PUBLIC_KEY_BYTES - 1] = point[PUBLIC_KEY_BYTES - 1] & SIGN_BIT_CLEARED;
|
|
973
|
+
return SMALL_ORDER.includes(bytesToHex(point));
|
|
974
|
+
}
|
|
823
975
|
function fromBase64Url2(text3) {
|
|
824
|
-
const
|
|
825
|
-
const raw = atob(
|
|
976
|
+
const padded2 = text3.replace(/-/g, "+").replace(/_/g, "/");
|
|
977
|
+
const raw = atob(padded2 + "=".repeat((4 - padded2.length % 4) % 4));
|
|
826
978
|
const bytes = new Uint8Array(raw.length);
|
|
827
979
|
for (let at = 0; at < raw.length; at++) {
|
|
828
980
|
bytes[at] = raw.charCodeAt(at);
|
|
829
981
|
}
|
|
830
982
|
return bytes;
|
|
831
983
|
}
|
|
832
|
-
function asBuffer(bytes) {
|
|
833
|
-
return bytes.buffer instanceof ArrayBuffer ? bytes : new Uint8Array(bytes);
|
|
834
|
-
}
|
|
835
|
-
|
|
836
|
-
// ../core/hmac.ts
|
|
837
|
-
async function hmacHex(secret, payload) {
|
|
838
|
-
const raw = typeof secret === "string" ? new TextEncoder().encode(secret) : secret;
|
|
839
|
-
const key = await crypto.subtle.importKey(
|
|
840
|
-
"raw",
|
|
841
|
-
asBuffer2(raw),
|
|
842
|
-
{ name: "HMAC", hash: "SHA-256" },
|
|
843
|
-
false,
|
|
844
|
-
["sign"]
|
|
845
|
-
);
|
|
846
|
-
const message = typeof payload === "string" ? new TextEncoder().encode(payload) : payload;
|
|
847
|
-
return bytesToHex(new Uint8Array(await crypto.subtle.sign("HMAC", key, asBuffer2(message))));
|
|
848
|
-
}
|
|
849
|
-
function equalInConstantTime(one, other) {
|
|
850
|
-
if (one.length !== other.length) {
|
|
851
|
-
return false;
|
|
852
|
-
}
|
|
853
|
-
let difference = 0;
|
|
854
|
-
for (let at = 0; at < one.length; at++) {
|
|
855
|
-
difference |= one.charCodeAt(at) ^ other.charCodeAt(at);
|
|
856
|
-
}
|
|
857
|
-
return difference === 0;
|
|
858
|
-
}
|
|
859
984
|
function asBuffer2(bytes) {
|
|
860
|
-
return bytes
|
|
985
|
+
return bytes.buffer instanceof ArrayBuffer ? bytes : new Uint8Array(bytes);
|
|
861
986
|
}
|
|
862
987
|
|
|
863
988
|
// ../core/caller.ts
|
|
864
989
|
var KEY_HEADER = "x-client-key";
|
|
865
990
|
var SIGNATURE_HEADER = "x-signature";
|
|
866
991
|
var TIMESTAMP_HEADER = "x-timestamp";
|
|
992
|
+
var NONCE_HEADER = "x-nonce";
|
|
867
993
|
var PREFIX = "ed25519=";
|
|
994
|
+
var NONCE_BYTES = 16;
|
|
868
995
|
var SIGNING_LABEL = "request-signing";
|
|
869
996
|
async function callerKey(secret) {
|
|
870
997
|
return await signingKeyFromSeed(hexToBytes(await hmacHex(secret, SIGNING_LABEL)));
|
|
@@ -872,17 +999,21 @@ async function callerKey(secret) {
|
|
|
872
999
|
function paymentNamedBy(caller, paymentHash) {
|
|
873
1000
|
return sha256Hex(`${caller}.payment-id.${paymentHash}`);
|
|
874
1001
|
}
|
|
875
|
-
async function signedAs(key, method, path, body) {
|
|
1002
|
+
async function signedAs(key, method, path, body, host) {
|
|
876
1003
|
const timestamp = String(Math.floor(Date.now() / 1e3));
|
|
1004
|
+
const nonce = bytesToHex(crypto.getRandomValues(new Uint8Array(NONCE_BYTES)));
|
|
877
1005
|
return {
|
|
878
1006
|
[KEY_HEADER]: key.publicKeyHex,
|
|
879
1007
|
[TIMESTAMP_HEADER]: timestamp,
|
|
880
|
-
[
|
|
1008
|
+
[NONCE_HEADER]: nonce,
|
|
1009
|
+
[SIGNATURE_HEADER]: `${PREFIX}${await key.sign(spokenTo(host, timestamp, nonce, method, path, body))}`
|
|
881
1010
|
};
|
|
882
1011
|
}
|
|
883
|
-
function
|
|
1012
|
+
function spokenTo(host, timestamp, nonce, method, path, body) {
|
|
884
1013
|
return new TextEncoder().encode(
|
|
885
|
-
|
|
1014
|
+
["v2", host.toLowerCase(), timestamp, nonce, method.toUpperCase(), path, sha256Hex(body)].join(
|
|
1015
|
+
"\n"
|
|
1016
|
+
)
|
|
886
1017
|
);
|
|
887
1018
|
}
|
|
888
1019
|
|
|
@@ -935,8 +1066,8 @@ function ipv4IsGlobal(literal) {
|
|
|
935
1066
|
if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) {
|
|
936
1067
|
return false;
|
|
937
1068
|
}
|
|
938
|
-
const [a, b] = parts;
|
|
939
|
-
if (a === 0 || a === 10 || a === 127 || a >=
|
|
1069
|
+
const [a, b, c] = parts;
|
|
1070
|
+
if (a === 0 || a === 10 || a === 127 || a >= 224) {
|
|
940
1071
|
return false;
|
|
941
1072
|
}
|
|
942
1073
|
if (a === 100 && b >= 64 && b < 128) {
|
|
@@ -948,7 +1079,13 @@ function ipv4IsGlobal(literal) {
|
|
|
948
1079
|
if (a === 172 && b >= 16 && b < 32) {
|
|
949
1080
|
return false;
|
|
950
1081
|
}
|
|
951
|
-
if (a === 192 && b === 168) {
|
|
1082
|
+
if (a === 192 && (b === 168 || b === 0 && (c === 0 || c === 2))) {
|
|
1083
|
+
return false;
|
|
1084
|
+
}
|
|
1085
|
+
if (a === 198 && (b === 18 || b === 19 || b === 51 && c === 100)) {
|
|
1086
|
+
return false;
|
|
1087
|
+
}
|
|
1088
|
+
if (a === 203 && b === 0 && c === 113) {
|
|
952
1089
|
return false;
|
|
953
1090
|
}
|
|
954
1091
|
return true;
|
|
@@ -958,29 +1095,59 @@ function ipv6IsGlobal(literal) {
|
|
|
958
1095
|
if (groups.length !== 8 || groups.some(Number.isNaN)) {
|
|
959
1096
|
return false;
|
|
960
1097
|
}
|
|
961
|
-
const
|
|
962
|
-
|
|
1098
|
+
const [first, second, third, fourth, fifth, sixth, high, low] = groups;
|
|
1099
|
+
const embedded = (one, other) => ipv4IsGlobal(`${one >> 8}.${one & 255}.${other >> 8}.${other & 255}`);
|
|
1100
|
+
const zeroBefore = (count) => groups.slice(0, count).every((group) => group === 0);
|
|
1101
|
+
if (zeroBefore(5) && sixth === 65535) {
|
|
1102
|
+
return embedded(high, low);
|
|
1103
|
+
}
|
|
1104
|
+
if (zeroBefore(6) || zeroBefore(4) && fifth === 65535 && sixth === 0) {
|
|
1105
|
+
return false;
|
|
1106
|
+
}
|
|
1107
|
+
if (first === 100 && second === 65435) {
|
|
1108
|
+
return third === 0 && fourth === 0 && fifth === 0 && sixth === 0 && embedded(high, low);
|
|
1109
|
+
}
|
|
1110
|
+
if (first === 8194) {
|
|
1111
|
+
return embedded(second, third);
|
|
1112
|
+
}
|
|
1113
|
+
if (first === 8193 && second === 0) {
|
|
963
1114
|
return false;
|
|
964
1115
|
}
|
|
965
|
-
if (
|
|
966
|
-
|
|
967
|
-
return ipv4IsGlobal(`${high >> 8}.${high & 255}.${low >> 8}.${low & 255}`);
|
|
1116
|
+
if (first === 256 && second === 0 && third === 0 && fourth === 0) {
|
|
1117
|
+
return false;
|
|
968
1118
|
}
|
|
969
|
-
|
|
970
|
-
if ((first & 65024) === 64512) {
|
|
1119
|
+
if ((first & 65024) === 64512 || (first & 65280) === 65280) {
|
|
971
1120
|
return false;
|
|
972
1121
|
}
|
|
973
|
-
if ((first & 65472) === 65152) {
|
|
1122
|
+
if ((first & 65472) === 65152 || (first & 65472) === 65216) {
|
|
974
1123
|
return false;
|
|
975
1124
|
}
|
|
976
1125
|
return true;
|
|
977
1126
|
}
|
|
978
1127
|
function ipv6Groups(address) {
|
|
979
|
-
const
|
|
1128
|
+
const halves = withDottedTailAsGroups(address).split("::");
|
|
1129
|
+
const [head = "", tail] = halves;
|
|
980
1130
|
const left = head.split(":").filter((group) => group !== "");
|
|
981
1131
|
const right = (tail ?? "").split(":").filter((group) => group !== "");
|
|
982
|
-
const
|
|
983
|
-
|
|
1132
|
+
const missing = 8 - left.length - right.length;
|
|
1133
|
+
if (halves.length > 2 || missing < 0) {
|
|
1134
|
+
return [Number.NaN];
|
|
1135
|
+
}
|
|
1136
|
+
const gap = tail === void 0 ? [] : Array(missing).fill("0");
|
|
1137
|
+
return [...left, ...gap, ...right].map(
|
|
1138
|
+
(group) => /^[0-9a-f]{1,4}$/.test(group) ? parseInt(group, 16) : Number.NaN
|
|
1139
|
+
);
|
|
1140
|
+
}
|
|
1141
|
+
function withDottedTailAsGroups(address) {
|
|
1142
|
+
const dotted = /^(.*:)(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/.exec(address);
|
|
1143
|
+
if (dotted === null) {
|
|
1144
|
+
return address;
|
|
1145
|
+
}
|
|
1146
|
+
const [a, b, c, d] = dotted[2].split(".").map(Number);
|
|
1147
|
+
if ([a, b, c, d].some((part) => part > 255)) {
|
|
1148
|
+
return `${dotted[1]}x`;
|
|
1149
|
+
}
|
|
1150
|
+
return `${dotted[1]}${(a << 8 | b).toString(16)}:${(c << 8 | d).toString(16)}`;
|
|
984
1151
|
}
|
|
985
1152
|
|
|
986
1153
|
// ../core/outbound.ts
|
|
@@ -1008,6 +1175,9 @@ async function ask(send, url, sent = {}) {
|
|
|
1008
1175
|
}
|
|
1009
1176
|
await response.body?.cancel();
|
|
1010
1177
|
const next = new URL(location, target).toString();
|
|
1178
|
+
if (sent.staysOnOrigin && !sameOrigin(target, next)) {
|
|
1179
|
+
throw new Error(`${target} redirected off its own origin, to ${next}`);
|
|
1180
|
+
}
|
|
1011
1181
|
carried = KEEPS_THE_METHOD.includes(response.status) ? carried : withoutBody(carried);
|
|
1012
1182
|
carried = sameOrigin(target, next) ? carried : withoutCredentials(carried);
|
|
1013
1183
|
target = next;
|
|
@@ -1025,14 +1195,39 @@ async function addressesToReach(url) {
|
|
|
1025
1195
|
return found;
|
|
1026
1196
|
}
|
|
1027
1197
|
async function resolved(url) {
|
|
1028
|
-
const {
|
|
1029
|
-
const { setTimeout: sleep } = await import("timers/promises");
|
|
1198
|
+
const { isIP } = await import("net");
|
|
1030
1199
|
const host = new URL(url).hostname.replace(/^\[|]$/g, "");
|
|
1200
|
+
const literal = isIP(host);
|
|
1201
|
+
if (literal !== 0) {
|
|
1202
|
+
return [{ address: host, family: literal }];
|
|
1203
|
+
}
|
|
1204
|
+
const { Resolver } = await import("dns/promises");
|
|
1205
|
+
const { setTimeout: sleep } = await import("timers/promises");
|
|
1206
|
+
const resolver = new Resolver({ timeout: LOOKUP_TIMEOUT_MS, tries: 1 });
|
|
1031
1207
|
const found = await Promise.race([
|
|
1032
|
-
|
|
1208
|
+
Promise.all([
|
|
1209
|
+
resolver.resolve4(host).catch(() => []),
|
|
1210
|
+
resolver.resolve6(host).catch(() => [])
|
|
1211
|
+
]),
|
|
1033
1212
|
sleep(LOOKUP_TIMEOUT_MS, null, { ref: false })
|
|
1034
1213
|
]);
|
|
1035
|
-
|
|
1214
|
+
cancelled(resolver);
|
|
1215
|
+
if (found === null) {
|
|
1216
|
+
return [];
|
|
1217
|
+
}
|
|
1218
|
+
const [four, six] = found;
|
|
1219
|
+
return [
|
|
1220
|
+
...four.map((address) => ({ address, family: 4 })),
|
|
1221
|
+
...six.map((address) => ({ address, family: 6 }))
|
|
1222
|
+
];
|
|
1223
|
+
}
|
|
1224
|
+
function cancelled(resolver) {
|
|
1225
|
+
try {
|
|
1226
|
+
resolver.cancel();
|
|
1227
|
+
return true;
|
|
1228
|
+
} catch {
|
|
1229
|
+
return false;
|
|
1230
|
+
}
|
|
1036
1231
|
}
|
|
1037
1232
|
async function answerOf(response) {
|
|
1038
1233
|
const { status, ok, headers } = response;
|
|
@@ -1054,15 +1249,17 @@ async function answerOf(response) {
|
|
|
1054
1249
|
return { status, ok, body: text(read), truncated: true, headers };
|
|
1055
1250
|
}
|
|
1056
1251
|
function withoutBody(sent) {
|
|
1057
|
-
const headers =
|
|
1058
|
-
|
|
1252
|
+
const headers = Object.fromEntries(
|
|
1253
|
+
Object.entries(sent.headers ?? {}).filter(([name]) => name.toLowerCase() !== "content-type")
|
|
1254
|
+
);
|
|
1059
1255
|
return { ...sent, method: "GET", headers, body: void 0 };
|
|
1060
1256
|
}
|
|
1061
1257
|
function withoutCredentials(sent) {
|
|
1062
|
-
const headers =
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1258
|
+
const headers = Object.fromEntries(
|
|
1259
|
+
Object.entries(sent.headers ?? {}).filter(
|
|
1260
|
+
([name]) => !CREDENTIALS.includes(name.toLowerCase())
|
|
1261
|
+
)
|
|
1262
|
+
);
|
|
1066
1263
|
return { ...sent, headers };
|
|
1067
1264
|
}
|
|
1068
1265
|
function text(chunks) {
|
|
@@ -1105,6 +1302,22 @@ async function resolve(send, addresses, amountMsat) {
|
|
|
1105
1302
|
);
|
|
1106
1303
|
return served.won;
|
|
1107
1304
|
}
|
|
1305
|
+
async function quote(send, addresses, amountMsat) {
|
|
1306
|
+
const served = await firstThatServes(
|
|
1307
|
+
addresses,
|
|
1308
|
+
(address2, deadline) => probe(send, address2, amountMsat, deadline)
|
|
1309
|
+
);
|
|
1310
|
+
const { address, pay } = served.won;
|
|
1311
|
+
return {
|
|
1312
|
+
won: {
|
|
1313
|
+
address,
|
|
1314
|
+
minMsat: pay.minSendable,
|
|
1315
|
+
maxMsat: pay.maxSendable,
|
|
1316
|
+
metadata: pay.metadata
|
|
1317
|
+
},
|
|
1318
|
+
refusals: served.refusals
|
|
1319
|
+
};
|
|
1320
|
+
}
|
|
1108
1321
|
async function firstThatServes(addresses, attempt) {
|
|
1109
1322
|
const deadline = AbortSignal.timeout(RESOLVE_TIMEOUT_MS);
|
|
1110
1323
|
const refusals3 = [];
|
|
@@ -1197,7 +1410,7 @@ function decodeIssued(address, bolt11) {
|
|
|
1197
1410
|
return { paymentHash, descriptionHash, amountMsat, expiresAt };
|
|
1198
1411
|
}
|
|
1199
1412
|
async function checkSettled(send, verifyUrl, paymentHash) {
|
|
1200
|
-
const answer = await answeredJson(send, verifyUrl);
|
|
1413
|
+
const answer = await answeredJson(send, verifyUrl, { staysOnOrigin: true });
|
|
1201
1414
|
const asked = {
|
|
1202
1415
|
pace: paceAskedFor(answer.headers),
|
|
1203
1416
|
ceiling: ceilingAskedFor(answer.headers)
|
|
@@ -1255,10 +1468,10 @@ function isNothingButAHost(domain) {
|
|
|
1255
1468
|
}
|
|
1256
1469
|
}
|
|
1257
1470
|
async function fetchJson(send, url, deadline) {
|
|
1258
|
-
return (await answeredJson(send, url, deadline)).said;
|
|
1471
|
+
return (await answeredJson(send, url, { deadline })).said;
|
|
1259
1472
|
}
|
|
1260
|
-
async function answeredJson(send, url,
|
|
1261
|
-
const answer = await ask(send, url, { headers: { accept: "application/json" },
|
|
1473
|
+
async function answeredJson(send, url, sent) {
|
|
1474
|
+
const answer = await ask(send, url, { headers: { accept: "application/json" }, ...sent });
|
|
1262
1475
|
if (!answer.ok) {
|
|
1263
1476
|
throw new Error(`${url} answered ${answer.status}`);
|
|
1264
1477
|
}
|
|
@@ -1286,12 +1499,17 @@ var pinnedToTheAddressWeVerified = async (url, sent, signal, at) => {
|
|
|
1286
1499
|
}
|
|
1287
1500
|
const call = request(reaching(asked, sent, at, first), (answer) => {
|
|
1288
1501
|
const status = answer.statusCode ?? 502;
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1502
|
+
try {
|
|
1503
|
+
settle(
|
|
1504
|
+
new Response(
|
|
1505
|
+
CARRIES_NO_BODY.includes(status) ? null : Readable.toWeb(answer),
|
|
1506
|
+
{ status, headers: headersOf(answer.headers) }
|
|
1507
|
+
)
|
|
1508
|
+
);
|
|
1509
|
+
} catch (unreadable) {
|
|
1510
|
+
answer.destroy();
|
|
1511
|
+
fail(new Error(`${url} answered with something HTTP cannot carry`, { cause: unreadable }));
|
|
1512
|
+
}
|
|
1295
1513
|
});
|
|
1296
1514
|
const give = () => call.destroy(new Error(`${url} took longer than allowed`));
|
|
1297
1515
|
signal.addEventListener("abort", give, { once: true });
|
|
@@ -1338,6 +1556,182 @@ function headersOf(raw) {
|
|
|
1338
1556
|
return headers;
|
|
1339
1557
|
}
|
|
1340
1558
|
|
|
1559
|
+
// ../core/delivery.ts
|
|
1560
|
+
function deliverySigned(url, timestamp, body) {
|
|
1561
|
+
const endpoint = new URL(url);
|
|
1562
|
+
return new TextEncoder().encode(
|
|
1563
|
+
["v2", `${endpoint.origin}${endpoint.pathname}`, timestamp, body].join("\n")
|
|
1564
|
+
);
|
|
1565
|
+
}
|
|
1566
|
+
|
|
1567
|
+
// src/verify.ts
|
|
1568
|
+
var HTTP_TIMEOUT_MS2 = 15e3;
|
|
1569
|
+
var ISSUED_SKEW_SECS = 300;
|
|
1570
|
+
var NAMES_AN_ADDRESS = ["text/identifier", "text/email"];
|
|
1571
|
+
var DEFAULT_WRAP_PROPORTION = 0.01;
|
|
1572
|
+
var DEFAULT_WRAP_BASE_MSAT = 1e3;
|
|
1573
|
+
async function proveOrigin(payment, asked, askedAt) {
|
|
1574
|
+
const cheat = (code) => new GatewayCheatError(code, payment.id);
|
|
1575
|
+
const listed2 = asked.paidTo.find((address) => equalIgnoringCase(address, payment.lnAddress));
|
|
1576
|
+
if (listed2 === void 0) {
|
|
1577
|
+
throw cheat("address_not_requested");
|
|
1578
|
+
}
|
|
1579
|
+
if (payment.amountMsat !== asked.amountMsat) {
|
|
1580
|
+
throw cheat("amount_mismatch");
|
|
1581
|
+
}
|
|
1582
|
+
const invoice = decodeInvoice(payment.bolt11);
|
|
1583
|
+
if (invoice.paymentHash === null || !equalIgnoringCase(invoice.paymentHash, payment.paymentHash)) {
|
|
1584
|
+
throw cheat("hash_mismatch");
|
|
1585
|
+
}
|
|
1586
|
+
if (invoice.amountMsat !== asked.amountMsat) {
|
|
1587
|
+
throw cheat("amount_mismatch");
|
|
1588
|
+
}
|
|
1589
|
+
if (askedAt !== void 0 && (invoice.issuedAt ?? 0) < askedAt - ISSUED_SKEW_SECS) {
|
|
1590
|
+
throw cheat("invoice_stale");
|
|
1591
|
+
}
|
|
1592
|
+
const payRequest = await payRequestFor(listed2, payment);
|
|
1593
|
+
if (typeof payRequest.metadata !== "string" || typeof payRequest.callback !== "string") {
|
|
1594
|
+
throw new UnverifiedRecipientError(payment.lnAddress, payment.id, "no payRequest served");
|
|
1595
|
+
}
|
|
1596
|
+
if (!namesTheAddress(payRequest.metadata, listed2)) {
|
|
1597
|
+
throw new UnverifiedRecipientError(
|
|
1598
|
+
payment.lnAddress,
|
|
1599
|
+
payment.id,
|
|
1600
|
+
"its metadata names no text/identifier for this address, so no invoice binds to it"
|
|
1601
|
+
);
|
|
1602
|
+
}
|
|
1603
|
+
if (invoice.descriptionHash !== sha256Hex(payRequest.metadata)) {
|
|
1604
|
+
throw cheat("description_hash_mismatch");
|
|
1605
|
+
}
|
|
1606
|
+
if (!sameOrigin(payment.verifyUrl, payRequest.callback)) {
|
|
1607
|
+
throw cheat("verify_url_foreign");
|
|
1608
|
+
}
|
|
1609
|
+
const issued = await reachable(payment.verifyUrl, payment);
|
|
1610
|
+
if (typeof issued.pr !== "string" || !equalIgnoringCase(issued.pr, payment.bolt11)) {
|
|
1611
|
+
throw cheat("invoice_not_issued");
|
|
1612
|
+
}
|
|
1613
|
+
if (askedAt !== void 0 && issued.settled === true) {
|
|
1614
|
+
throw cheat("invoice_settled");
|
|
1615
|
+
}
|
|
1616
|
+
}
|
|
1617
|
+
function namesTheAddress(metadata, address) {
|
|
1618
|
+
try {
|
|
1619
|
+
const entries = JSON.parse(metadata);
|
|
1620
|
+
return Array.isArray(entries) && entries.some(
|
|
1621
|
+
(entry) => Array.isArray(entry) && NAMES_AN_ADDRESS.includes(entry[0]) && typeof entry[1] === "string" && equalIgnoringCase(entry[1], address)
|
|
1622
|
+
);
|
|
1623
|
+
} catch {
|
|
1624
|
+
return false;
|
|
1625
|
+
}
|
|
1626
|
+
}
|
|
1627
|
+
async function proveSettlement(payment, asked) {
|
|
1628
|
+
await proveOrigin(payment, asked);
|
|
1629
|
+
const verified = await reachable(payment.verifyUrl, payment);
|
|
1630
|
+
if (verified.settled !== true || typeof verified.preimage !== "string") {
|
|
1631
|
+
return null;
|
|
1632
|
+
}
|
|
1633
|
+
if (!preimageMatchesHash(verified.preimage, payment.paymentHash)) {
|
|
1634
|
+
throw new GatewayCheatError("preimage_mismatch", payment.id);
|
|
1635
|
+
}
|
|
1636
|
+
return verified.preimage;
|
|
1637
|
+
}
|
|
1638
|
+
function agreesWithItself(report) {
|
|
1639
|
+
if (report.status !== "paid" || report.preimage === null) {
|
|
1640
|
+
return false;
|
|
1641
|
+
}
|
|
1642
|
+
if (report.bolt11 !== void 0 && report.bolt11 !== null) {
|
|
1643
|
+
const invoiceHash = decodeInvoice(report.bolt11).paymentHash;
|
|
1644
|
+
if (invoiceHash === null || !equalIgnoringCase(invoiceHash, report.paymentHash)) {
|
|
1645
|
+
return false;
|
|
1646
|
+
}
|
|
1647
|
+
}
|
|
1648
|
+
return preimageMatchesHash(report.preimage, report.paymentHash);
|
|
1649
|
+
}
|
|
1650
|
+
var carriesProof = agreesWithItself;
|
|
1651
|
+
function wrapFeeCeiling(amountMsat, allowance) {
|
|
1652
|
+
const proportional = amountMsat * (allowance?.proportion ?? DEFAULT_WRAP_PROPORTION);
|
|
1653
|
+
return msat(Math.max(allowance?.baseMsat ?? DEFAULT_WRAP_BASE_MSAT, Math.floor(proportional)));
|
|
1654
|
+
}
|
|
1655
|
+
function proveWrapped(wrapped, recipient, allowance) {
|
|
1656
|
+
const offered = decodeInvoice(wrapped);
|
|
1657
|
+
const owed = decodeInvoice(recipient);
|
|
1658
|
+
const refuse2 = (code, detail) => {
|
|
1659
|
+
throw new WrapRefusedError(code, detail);
|
|
1660
|
+
};
|
|
1661
|
+
if (offered.paymentHash === null || offered.amountMsat === null || offered.expiresAt === null) {
|
|
1662
|
+
refuse2("undecodable", "the wrapped invoice carries no hash, amount or expiry");
|
|
1663
|
+
}
|
|
1664
|
+
if (owed.paymentHash === null || owed.amountMsat === null || owed.expiresAt === null) {
|
|
1665
|
+
refuse2("undecodable", "the recipient's invoice carries no hash, amount or expiry");
|
|
1666
|
+
}
|
|
1667
|
+
if (offered.paymentHash?.toLowerCase() !== owed.paymentHash?.toLowerCase()) {
|
|
1668
|
+
refuse2("hash_mismatch", "settling the wrap would not settle the recipient");
|
|
1669
|
+
}
|
|
1670
|
+
const asked = offered.amountMsat ?? 0;
|
|
1671
|
+
const due = owed.amountMsat ?? 0;
|
|
1672
|
+
if (asked < due) {
|
|
1673
|
+
refuse2("amount_below_recipient", `${asked} msat cannot forward ${due} msat`);
|
|
1674
|
+
}
|
|
1675
|
+
const ceiling = wrapFeeCeiling(due, allowance);
|
|
1676
|
+
if (asked - due > ceiling) {
|
|
1677
|
+
refuse2("fee_above_allowance", `${asked - due} msat over ${due}, and ${ceiling} was allowed`);
|
|
1678
|
+
}
|
|
1679
|
+
if ((owed.expiresAt ?? 0) <= (offered.expiresAt ?? 0)) {
|
|
1680
|
+
refuse2("recipient_expires_first", "the operator could be paid and find it cannot deliver");
|
|
1681
|
+
}
|
|
1682
|
+
}
|
|
1683
|
+
async function payRequestFor(listed2, payment) {
|
|
1684
|
+
const at = listed2.indexOf("@");
|
|
1685
|
+
const name = listed2.slice(0, at);
|
|
1686
|
+
const domain = listed2.slice(at + 1).toLowerCase();
|
|
1687
|
+
if (at < 1 || !publicHttps(`https://${domain}/`)) {
|
|
1688
|
+
throw new UnverifiedRecipientError(payment.lnAddress, payment.id, "not a usable address");
|
|
1689
|
+
}
|
|
1690
|
+
return reachable(`https://${domain}/.well-known/lnurlp/${name}`, payment);
|
|
1691
|
+
}
|
|
1692
|
+
async function reachable(url, payment) {
|
|
1693
|
+
try {
|
|
1694
|
+
if (!publicHttps(url)) {
|
|
1695
|
+
throw new Error(`${url} is not a public https URL`);
|
|
1696
|
+
}
|
|
1697
|
+
const response = await fetch(url, {
|
|
1698
|
+
signal: AbortSignal.timeout(HTTP_TIMEOUT_MS2),
|
|
1699
|
+
headers: { accept: "application/json" }
|
|
1700
|
+
});
|
|
1701
|
+
if (response.redirected && !sameOrigin(response.url, url)) {
|
|
1702
|
+
throw new Error(`redirected off its own origin, to ${response.url}`);
|
|
1703
|
+
}
|
|
1704
|
+
if (!response.ok) {
|
|
1705
|
+
throw new Error(`answered ${response.status}`);
|
|
1706
|
+
}
|
|
1707
|
+
const body = JSON.parse(await boundedText(response));
|
|
1708
|
+
if (body === null || typeof body !== "object") {
|
|
1709
|
+
throw new Error("answered with no JSON object");
|
|
1710
|
+
}
|
|
1711
|
+
return body;
|
|
1712
|
+
} catch (cause) {
|
|
1713
|
+
throw new UnverifiedRecipientError(payment.lnAddress, payment.id, cause);
|
|
1714
|
+
}
|
|
1715
|
+
}
|
|
1716
|
+
async function boundedText(response) {
|
|
1717
|
+
const reader = response.body?.getReader();
|
|
1718
|
+
const decoder = new TextDecoder();
|
|
1719
|
+
let text3 = "";
|
|
1720
|
+
let bytes = 0;
|
|
1721
|
+
for (let chunk = await reader?.read(); chunk && !chunk.done; chunk = await reader?.read()) {
|
|
1722
|
+
bytes += chunk.value.length;
|
|
1723
|
+
if (bytes > BODY_LIMIT_BYTES) {
|
|
1724
|
+
await reader?.cancel();
|
|
1725
|
+
throw new Error(`answered with more than the ${BODY_LIMIT_BYTES} bytes a proof reads`);
|
|
1726
|
+
}
|
|
1727
|
+
text3 += decoder.decode(chunk.value, { stream: true });
|
|
1728
|
+
}
|
|
1729
|
+
return text3 + decoder.decode();
|
|
1730
|
+
}
|
|
1731
|
+
function equalIgnoringCase(one, other) {
|
|
1732
|
+
return one.toLowerCase() === other.toLowerCase();
|
|
1733
|
+
}
|
|
1734
|
+
|
|
1341
1735
|
// src/wire.ts
|
|
1342
1736
|
var ASSET_CODE = "BTC";
|
|
1343
1737
|
var ASSET_SCALE = 11;
|
|
@@ -1535,7 +1929,7 @@ function isStatus(value) {
|
|
|
1535
1929
|
}
|
|
1536
1930
|
|
|
1537
1931
|
// src/webhook.ts
|
|
1538
|
-
var SIGNATURE_HEADER2 = "x-signature";
|
|
1932
|
+
var SIGNATURE_HEADER2 = "x-signature-v2";
|
|
1539
1933
|
var TIMESTAMP_HEADER2 = "x-timestamp";
|
|
1540
1934
|
var GATEWAY_KEY_PREFIX = "ed25519=";
|
|
1541
1935
|
var DEFAULT_TOLERANCE_SECS = 300;
|
|
@@ -1543,22 +1937,22 @@ var CHALLENGE = "webhook-challenge";
|
|
|
1543
1937
|
var VERIFY_CHALLENGE = "verify-challenge";
|
|
1544
1938
|
async function readPayment(request, credential, options = {}) {
|
|
1545
1939
|
const body = await believable(request, credential, options);
|
|
1546
|
-
return body === null ? null : decoded(body, paymentFromWire);
|
|
1940
|
+
return body === null ? null : proved(decoded(body, paymentFromWire));
|
|
1547
1941
|
}
|
|
1548
1942
|
async function readSettlement(request, credential, options = {}) {
|
|
1549
1943
|
const body = await believable(request, credential, options);
|
|
1550
|
-
return body === null ? null : decoded(body, settlementFromWire);
|
|
1944
|
+
return body === null ? null : proved(decoded(body, settlementFromWire));
|
|
1551
1945
|
}
|
|
1552
1946
|
async function answerWebhookChallenge(request, credential, options = {}) {
|
|
1553
1947
|
const body = await believable(request, credential, options);
|
|
1554
|
-
const nonce = body === null ? null :
|
|
1948
|
+
const nonce = body === null ? null : nonceOf2(body, CHALLENGE);
|
|
1555
1949
|
return nonce === null ? null : answered2(nonce);
|
|
1556
1950
|
}
|
|
1557
1951
|
async function answerVerifyChallenge(request) {
|
|
1558
1952
|
if (request.method !== "POST") {
|
|
1559
1953
|
return null;
|
|
1560
1954
|
}
|
|
1561
|
-
const nonce =
|
|
1955
|
+
const nonce = nonceOf2(await request.clone().text(), VERIFY_CHALLENGE);
|
|
1562
1956
|
return nonce === null ? null : answered2(nonce);
|
|
1563
1957
|
}
|
|
1564
1958
|
async function believable(request, credential, options) {
|
|
@@ -1577,10 +1971,13 @@ async function believable(request, credential, options) {
|
|
|
1577
1971
|
const signed = await verifyHex(
|
|
1578
1972
|
credential.publicKey.toLowerCase(),
|
|
1579
1973
|
signature.slice(GATEWAY_KEY_PREFIX.length).toLowerCase(),
|
|
1580
|
-
|
|
1974
|
+
deliverySigned(options.url ?? request.url, timestamp, body)
|
|
1581
1975
|
);
|
|
1582
1976
|
return signed ? body : null;
|
|
1583
1977
|
}
|
|
1978
|
+
function proved(read) {
|
|
1979
|
+
return read !== null && read.status === "paid" && !agreesWithItself(read) ? null : read;
|
|
1980
|
+
}
|
|
1584
1981
|
function decoded(body, from) {
|
|
1585
1982
|
try {
|
|
1586
1983
|
return from(JSON.parse(body));
|
|
@@ -1593,7 +1990,7 @@ function answered2(nonce) {
|
|
|
1593
1990
|
headers: { "content-type": "application/json" }
|
|
1594
1991
|
});
|
|
1595
1992
|
}
|
|
1596
|
-
function
|
|
1993
|
+
function nonceOf2(text3, type) {
|
|
1597
1994
|
try {
|
|
1598
1995
|
const said = JSON.parse(text3);
|
|
1599
1996
|
if (said["type"] !== type || typeof said["nonce"] !== "string") {
|
|
@@ -1611,9 +2008,6 @@ function recent(timestamp, toleranceSecs) {
|
|
|
1611
2008
|
}
|
|
1612
2009
|
return Math.abs(Math.floor(Date.now() / 1e3) - sent) <= toleranceSecs;
|
|
1613
2010
|
}
|
|
1614
|
-
function stamped(timestamp, body) {
|
|
1615
|
-
return new TextEncoder().encode(`${timestamp}.${body}`);
|
|
1616
|
-
}
|
|
1617
2011
|
|
|
1618
2012
|
// src/bank.ts
|
|
1619
2013
|
var DEFAULT_CURRENCY = "CZK";
|
|
@@ -1621,30 +2015,39 @@ var DEFAULT_LOOK_BACK_SECS = 7 * 24 * 60 * 60;
|
|
|
1621
2015
|
var DEFAULT_POLL_EVERY_SECS = 30;
|
|
1622
2016
|
var IBAN = /^[A-Z]{2}[0-9]{2}[0-9A-Z]{8,30}$/;
|
|
1623
2017
|
var FORBIDDEN_IN_SPD = "*";
|
|
2018
|
+
var MAX_REFERENCE_CHARS = 60;
|
|
1624
2019
|
async function bankTransfer(gateway, params) {
|
|
1625
2020
|
const currency = params.currency ?? DEFAULT_CURRENCY;
|
|
1626
|
-
|
|
2021
|
+
const iban = accountOf(params.iban);
|
|
2022
|
+
refuseUnusable(params, iban, currency);
|
|
1627
2023
|
await refuseAnOpenGateway(gateway, params);
|
|
1628
|
-
const spd = shortPaymentDescriptor(params, currency);
|
|
1629
|
-
const subject = subjectOf(
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
2024
|
+
const spd = shortPaymentDescriptor(params, iban, currency);
|
|
2025
|
+
const subject = subjectOf({
|
|
2026
|
+
iban,
|
|
2027
|
+
amountMinor: params.amountMinor,
|
|
2028
|
+
currency,
|
|
2029
|
+
expiresAt: params.expiresAt,
|
|
2030
|
+
reference: params.reference
|
|
2031
|
+
});
|
|
2032
|
+
const verifyUrl = await answeredAt(params, subject);
|
|
2033
|
+
const paymentHash = hashOf(await hmacHex(params.secret, `preimage|${subject}`));
|
|
1636
2034
|
const watched = await gateway.watch({
|
|
1637
|
-
paymentHash
|
|
2035
|
+
paymentHash,
|
|
1638
2036
|
verifyUrl,
|
|
1639
2037
|
expiresAt: params.expiresAt,
|
|
1640
2038
|
trigger: params.trigger,
|
|
1641
2039
|
replay: params.replay,
|
|
1642
|
-
sealed: params.sealed,
|
|
2040
|
+
sealed: params.sealed ? await seal(params.sealed.secret, JSON.stringify(params.sealed.data), paymentHash) : void 0,
|
|
1643
2041
|
webhookUrl: params.webhookUrl
|
|
1644
2042
|
});
|
|
1645
|
-
return { id: watched.id, paymentHash
|
|
2043
|
+
return { id: watched.id, paymentHash, verifyUrl, spd };
|
|
1646
2044
|
}
|
|
1647
2045
|
function bankVerifyEndpoint(config) {
|
|
2046
|
+
refuseAWeakSecret(config.secret);
|
|
2047
|
+
const answersFor = accountOf(config.iban);
|
|
2048
|
+
if (!IBAN.test(answersFor)) {
|
|
2049
|
+
throw new Error(`${config.iban} is not an IBAN, so this endpoint answers for no account`);
|
|
2050
|
+
}
|
|
1648
2051
|
const paced = {
|
|
1649
2052
|
"cache-control": `max-age=${config.pollEverySecs ?? DEFAULT_POLL_EVERY_SECS}`
|
|
1650
2053
|
};
|
|
@@ -1653,51 +2056,82 @@ function bankVerifyEndpoint(config) {
|
|
|
1653
2056
|
if (consented !== null) {
|
|
1654
2057
|
return consented;
|
|
1655
2058
|
}
|
|
1656
|
-
const
|
|
1657
|
-
if (
|
|
2059
|
+
const sealed = new URL(request.url).searchParams.get("q");
|
|
2060
|
+
if (sealed === null) {
|
|
1658
2061
|
return Response.json({ settled: false }, { status: 400 });
|
|
1659
2062
|
}
|
|
1660
|
-
const subject =
|
|
1661
|
-
const
|
|
1662
|
-
if (
|
|
2063
|
+
const subject = await unsealFor("bank-verify", config.secret, sealed);
|
|
2064
|
+
const asked = subject === null ? null : askedFrom(subject);
|
|
2065
|
+
if (asked === null || asked.iban !== answersFor) {
|
|
1663
2066
|
return Response.json({ settled: false }, { status: 403 });
|
|
1664
2067
|
}
|
|
1665
|
-
const
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
{ headers: paced }
|
|
1673
|
-
);
|
|
2068
|
+
const preimage = await creditedPreimage({
|
|
2069
|
+
secret: config.secret,
|
|
2070
|
+
asked,
|
|
2071
|
+
statement: config.statement,
|
|
2072
|
+
lookBackSecs: config.lookBackSecs
|
|
2073
|
+
});
|
|
2074
|
+
return preimage === null ? Response.json({ settled: false }, { headers: paced }) : Response.json({ settled: true, preimage }, { headers: paced });
|
|
1674
2075
|
};
|
|
1675
2076
|
}
|
|
1676
|
-
function
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
const
|
|
1681
|
-
|
|
2077
|
+
async function answeredAt(params, subject) {
|
|
2078
|
+
if (params.answerBy === "agent") {
|
|
2079
|
+
return `agent:${(await callerKey(params.secret)).publicKeyHex}`;
|
|
2080
|
+
}
|
|
2081
|
+
const polling = new URL(params.verifyUrl ?? "");
|
|
2082
|
+
polling.searchParams.set(
|
|
2083
|
+
"q",
|
|
2084
|
+
await sealFor("bank-verify", params.secret, subject, { stable: true })
|
|
2085
|
+
);
|
|
2086
|
+
return polling.toString();
|
|
2087
|
+
}
|
|
2088
|
+
async function creditedPreimage(params) {
|
|
2089
|
+
const since = unixNow() - (params.lookBackSecs ?? DEFAULT_LOOK_BACK_SECS);
|
|
2090
|
+
const landed = (await params.statement(since)).some((credit) => pays(credit, params.asked));
|
|
2091
|
+
if (!landed) {
|
|
1682
2092
|
return null;
|
|
1683
2093
|
}
|
|
1684
|
-
|
|
2094
|
+
return await hmacHex(params.secret, `preimage|${subjectOf(params.asked)}`);
|
|
2095
|
+
}
|
|
2096
|
+
function askedFrom(subject) {
|
|
2097
|
+
const [iban, minorText, currency, expiresText, ...rest] = subject.split("|");
|
|
2098
|
+
const reference = rest.join("|");
|
|
2099
|
+
const amountMinor = Number(minorText);
|
|
2100
|
+
const expiresAt = Number(expiresText);
|
|
2101
|
+
if (!iban || !currency || reference.length === 0) {
|
|
1685
2102
|
return null;
|
|
1686
2103
|
}
|
|
1687
|
-
|
|
2104
|
+
if (!Number.isInteger(amountMinor) || amountMinor <= 0 || !Number.isSafeInteger(expiresAt)) {
|
|
2105
|
+
return null;
|
|
2106
|
+
}
|
|
2107
|
+
return { iban, reference, amountMinor, currency, expiresAt };
|
|
1688
2108
|
}
|
|
1689
2109
|
function pays(credit, asked) {
|
|
1690
|
-
return credit.amountMinor === asked.amountMinor && credit.currency.toUpperCase() === asked.currency.toUpperCase() && credit.reference
|
|
2110
|
+
return credit.amountMinor === asked.amountMinor && credit.currency.toUpperCase() === asked.currency.toUpperCase() && namesOnly(credit.reference, asked.reference);
|
|
2111
|
+
}
|
|
2112
|
+
function namesOnly(written, reference) {
|
|
2113
|
+
const wanted = reference.toUpperCase();
|
|
2114
|
+
const alike = [...written.toUpperCase().matchAll(wordsShapedLike(wanted))].map(([word]) => word);
|
|
2115
|
+
return alike.includes(wanted) && (!/\d/.test(wanted) || alike.every((word) => word === wanted));
|
|
2116
|
+
}
|
|
2117
|
+
function wordsShapedLike(reference) {
|
|
2118
|
+
const shape = [...reference].map((char) => {
|
|
2119
|
+
if (/\d/.test(char)) {
|
|
2120
|
+
return "\\d";
|
|
2121
|
+
}
|
|
2122
|
+
return /\p{L}/u.test(char) ? "\\p{L}" : char.replace(/[\^$\\.*+?()[\]{}|/]/g, "\\$&");
|
|
2123
|
+
}).join("");
|
|
2124
|
+
return new RegExp(`(?<![\\p{L}\\p{N}])${shape}(?![\\p{L}\\p{N}])`, "gu");
|
|
1691
2125
|
}
|
|
1692
|
-
function subjectOf(
|
|
1693
|
-
return `${
|
|
2126
|
+
function subjectOf({ iban, amountMinor, currency, expiresAt, reference }) {
|
|
2127
|
+
return `${iban}|${amountMinor}|${currency.toUpperCase()}|${expiresAt}|${reference}`;
|
|
1694
2128
|
}
|
|
1695
2129
|
function hashOf(preimage) {
|
|
1696
2130
|
return bytesToHex(sha256(hexToBytes(preimage)));
|
|
1697
2131
|
}
|
|
1698
|
-
function shortPaymentDescriptor(params, currency) {
|
|
2132
|
+
function shortPaymentDescriptor(params, iban, currency) {
|
|
1699
2133
|
const fields = [
|
|
1700
|
-
`ACC:${
|
|
2134
|
+
`ACC:${iban}`,
|
|
1701
2135
|
`AM:${major(params.amountMinor, currency)}`,
|
|
1702
2136
|
`CC:${currency.toUpperCase()}`,
|
|
1703
2137
|
`MSG:${params.reference}`
|
|
@@ -1718,19 +2152,28 @@ async function refuseAnOpenGateway(gateway, params) {
|
|
|
1718
2152
|
return;
|
|
1719
2153
|
}
|
|
1720
2154
|
throw new Error(
|
|
1721
|
-
"this gateway serves callers with no token, so it is not yours, and its operator would
|
|
2155
|
+
"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"
|
|
1722
2156
|
);
|
|
1723
2157
|
}
|
|
1724
|
-
function refuseUnusable(params, currency) {
|
|
1725
|
-
|
|
2158
|
+
function refuseUnusable(params, iban, currency) {
|
|
2159
|
+
refuseAWeakSecret(params.secret);
|
|
2160
|
+
if (params.answerBy !== "agent" && params.verifyUrl === void 0) {
|
|
2161
|
+
throw new Error("a transfer the gateway polls needs the verify url it is polled at");
|
|
2162
|
+
}
|
|
2163
|
+
if (!IBAN.test(iban)) {
|
|
1726
2164
|
throw new Error(`${params.iban} is not an IBAN`);
|
|
1727
2165
|
}
|
|
1728
|
-
if (!Number.
|
|
2166
|
+
if (!Number.isSafeInteger(params.amountMinor) || params.amountMinor <= 0) {
|
|
1729
2167
|
throw new Error("amountMinor must be a whole number of minor units above zero");
|
|
1730
2168
|
}
|
|
1731
2169
|
if (params.reference.length === 0) {
|
|
1732
2170
|
throw new Error("a transfer with no reference cannot be found");
|
|
1733
2171
|
}
|
|
2172
|
+
if (params.reference.length > MAX_REFERENCE_CHARS) {
|
|
2173
|
+
throw new Error(
|
|
2174
|
+
`a reference is at most ${MAX_REFERENCE_CHARS} characters, as many as a QR payment's message carries and its sealed query hides`
|
|
2175
|
+
);
|
|
2176
|
+
}
|
|
1734
2177
|
if (params.reference.includes(FORBIDDEN_IN_SPD)) {
|
|
1735
2178
|
throw new Error("a reference cannot contain an asterisk, it separates the QR fields");
|
|
1736
2179
|
}
|
|
@@ -1738,10 +2181,168 @@ function refuseUnusable(params, currency) {
|
|
|
1738
2181
|
throw new Error("a variable symbol is up to ten digits");
|
|
1739
2182
|
}
|
|
1740
2183
|
}
|
|
2184
|
+
function accountOf(iban) {
|
|
2185
|
+
return iban.replace(/\s+/g, "").toUpperCase();
|
|
2186
|
+
}
|
|
1741
2187
|
function unixNow() {
|
|
1742
2188
|
return Math.floor(Date.now() / 1e3);
|
|
1743
2189
|
}
|
|
1744
2190
|
|
|
2191
|
+
// src/nostr.ts
|
|
2192
|
+
var import_chacha = require("@noble/ciphers/chacha.js");
|
|
2193
|
+
var import_secp256k1 = require("@noble/curves/secp256k1.js");
|
|
2194
|
+
var import_hkdf = require("@noble/hashes/hkdf.js");
|
|
2195
|
+
var import_hmac4 = require("@noble/hashes/hmac.js");
|
|
2196
|
+
var import_sha2 = require("@noble/hashes/sha2.js");
|
|
2197
|
+
var NIP44_SALT = new TextEncoder().encode("nip44-v2");
|
|
2198
|
+
var NIP44_VERSION = 2;
|
|
2199
|
+
var NONCE_BYTES2 = 32;
|
|
2200
|
+
var MAC_BYTES = 32;
|
|
2201
|
+
var MESSAGE_KEYS_BYTES = 76;
|
|
2202
|
+
var CHACHA_KEY_END = 32;
|
|
2203
|
+
var CHACHA_NONCE_END = 44;
|
|
2204
|
+
var LENGTH_PREFIX_BYTES = 2;
|
|
2205
|
+
var SMALLEST_PAD = 32;
|
|
2206
|
+
var CHUNKS_STAY_SMALL_TO = 256;
|
|
2207
|
+
var MAX_PLAINTEXT_BYTES = 65535;
|
|
2208
|
+
function publicKeyFor(privateKeyHex) {
|
|
2209
|
+
return bytesToHex(import_secp256k1.schnorr.getPublicKey(hexToBytes(privateKeyHex)));
|
|
2210
|
+
}
|
|
2211
|
+
function conversationKeyFor(privateKeyHex, publicKeyHex) {
|
|
2212
|
+
const shared = import_secp256k1.secp256k1.getSharedSecret(
|
|
2213
|
+
hexToBytes(privateKeyHex),
|
|
2214
|
+
hexToBytes(`02${publicKeyHex}`)
|
|
2215
|
+
);
|
|
2216
|
+
return (0, import_hkdf.extract)(import_sha2.sha256, shared.subarray(1, 33), NIP44_SALT);
|
|
2217
|
+
}
|
|
2218
|
+
function signEvent(privateKeyHex, draft) {
|
|
2219
|
+
const pubkey = publicKeyFor(privateKeyHex);
|
|
2220
|
+
const id = idOf(pubkey, draft);
|
|
2221
|
+
return {
|
|
2222
|
+
id,
|
|
2223
|
+
pubkey,
|
|
2224
|
+
created_at: draft.created_at,
|
|
2225
|
+
kind: draft.kind,
|
|
2226
|
+
tags: draft.tags,
|
|
2227
|
+
content: draft.content,
|
|
2228
|
+
sig: bytesToHex(import_secp256k1.schnorr.sign(hexToBytes(id), hexToBytes(privateKeyHex)))
|
|
2229
|
+
};
|
|
2230
|
+
}
|
|
2231
|
+
function verifyEvent(event) {
|
|
2232
|
+
try {
|
|
2233
|
+
return idOf(event.pubkey, event) === event.id && import_secp256k1.schnorr.verify(hexToBytes(event.sig), hexToBytes(event.id), hexToBytes(event.pubkey));
|
|
2234
|
+
} catch {
|
|
2235
|
+
return false;
|
|
2236
|
+
}
|
|
2237
|
+
}
|
|
2238
|
+
function idOf(pubkey, draft) {
|
|
2239
|
+
return bytesToHex(
|
|
2240
|
+
(0, import_sha2.sha256)(
|
|
2241
|
+
new TextEncoder().encode(
|
|
2242
|
+
JSON.stringify([0, pubkey, draft.created_at, draft.kind, draft.tags, draft.content])
|
|
2243
|
+
)
|
|
2244
|
+
)
|
|
2245
|
+
);
|
|
2246
|
+
}
|
|
2247
|
+
function encryptNip44(conversationKey, plaintext) {
|
|
2248
|
+
return sealNip44(conversationKey, plaintext, crypto.getRandomValues(new Uint8Array(NONCE_BYTES2)));
|
|
2249
|
+
}
|
|
2250
|
+
function sealNip44(conversationKey, plaintext, nonce) {
|
|
2251
|
+
const keys = messageKeysFrom(conversationKey, nonce);
|
|
2252
|
+
const ciphertext = (0, import_chacha.chacha20)(keys.chachaKey, keys.chachaNonce, padded(plaintext));
|
|
2253
|
+
const mac = (0, import_hmac4.hmac)(import_sha2.sha256, keys.hmacKey, joined(nonce, ciphertext));
|
|
2254
|
+
return toBase64(joined(Uint8Array.of(NIP44_VERSION), nonce, ciphertext, mac));
|
|
2255
|
+
}
|
|
2256
|
+
function decryptNip44(conversationKey, payload) {
|
|
2257
|
+
if (payload.startsWith("#")) {
|
|
2258
|
+
throw new Error("nip44 payload announces an encryption version we do not speak");
|
|
2259
|
+
}
|
|
2260
|
+
const raw = fromBase64(payload);
|
|
2261
|
+
if (raw.length < 1 + NONCE_BYTES2 + MAC_BYTES) {
|
|
2262
|
+
throw new Error(`nip44 payload is ${raw.length} bytes, too short to hold a nonce and a mac`);
|
|
2263
|
+
}
|
|
2264
|
+
if (raw[0] !== NIP44_VERSION) {
|
|
2265
|
+
throw new Error(`nip44 payload is version ${raw[0]}, not ${NIP44_VERSION}`);
|
|
2266
|
+
}
|
|
2267
|
+
const nonce = raw.subarray(1, 1 + NONCE_BYTES2);
|
|
2268
|
+
const ciphertext = raw.subarray(1 + NONCE_BYTES2, raw.length - MAC_BYTES);
|
|
2269
|
+
const mac = raw.subarray(raw.length - MAC_BYTES);
|
|
2270
|
+
const keys = messageKeysFrom(conversationKey, nonce);
|
|
2271
|
+
if (!sameBytes((0, import_hmac4.hmac)(import_sha2.sha256, keys.hmacKey, joined(nonce, ciphertext)), mac)) {
|
|
2272
|
+
throw new Error("nip44 payload failed its mac, so it was not written with this key");
|
|
2273
|
+
}
|
|
2274
|
+
return unpadded((0, import_chacha.chacha20)(keys.chachaKey, keys.chachaNonce, ciphertext));
|
|
2275
|
+
}
|
|
2276
|
+
function messageKeysFrom(conversationKey, nonce) {
|
|
2277
|
+
if (conversationKey.length !== NONCE_BYTES2 || nonce.length !== NONCE_BYTES2) {
|
|
2278
|
+
throw new Error("a nip44 conversation key and nonce are both 32 bytes");
|
|
2279
|
+
}
|
|
2280
|
+
const keys = (0, import_hkdf.expand)(import_sha2.sha256, conversationKey, nonce, MESSAGE_KEYS_BYTES);
|
|
2281
|
+
return {
|
|
2282
|
+
chachaKey: keys.subarray(0, CHACHA_KEY_END),
|
|
2283
|
+
chachaNonce: keys.subarray(CHACHA_KEY_END, CHACHA_NONCE_END),
|
|
2284
|
+
hmacKey: keys.subarray(CHACHA_NONCE_END)
|
|
2285
|
+
};
|
|
2286
|
+
}
|
|
2287
|
+
function paddedLengthFor(unpadded2) {
|
|
2288
|
+
if (unpadded2 <= SMALLEST_PAD) {
|
|
2289
|
+
return SMALLEST_PAD;
|
|
2290
|
+
}
|
|
2291
|
+
const nextPower = 1 << Math.floor(Math.log2(unpadded2 - 1)) + 1;
|
|
2292
|
+
const chunk = nextPower <= CHUNKS_STAY_SMALL_TO ? SMALLEST_PAD : nextPower / 8;
|
|
2293
|
+
return chunk * (Math.floor((unpadded2 - 1) / chunk) + 1);
|
|
2294
|
+
}
|
|
2295
|
+
function padded(plaintext) {
|
|
2296
|
+
const body = new TextEncoder().encode(plaintext);
|
|
2297
|
+
if (body.length === 0 || body.length > MAX_PLAINTEXT_BYTES) {
|
|
2298
|
+
throw new Error(`nip44 takes 1 to ${MAX_PLAINTEXT_BYTES} bytes, this was ${body.length}`);
|
|
2299
|
+
}
|
|
2300
|
+
const buffer = new Uint8Array(LENGTH_PREFIX_BYTES + paddedLengthFor(body.length));
|
|
2301
|
+
buffer.set(Uint8Array.of(body.length >>> 8, body.length & 255));
|
|
2302
|
+
buffer.set(body, LENGTH_PREFIX_BYTES);
|
|
2303
|
+
return buffer;
|
|
2304
|
+
}
|
|
2305
|
+
function unpadded(buffer) {
|
|
2306
|
+
const length = (buffer[0] ?? 0) << 8 | (buffer[1] ?? 0);
|
|
2307
|
+
if (length === 0 || buffer.length !== LENGTH_PREFIX_BYTES + paddedLengthFor(length)) {
|
|
2308
|
+
throw new Error("nip44 plaintext does not match the length its own padding declares");
|
|
2309
|
+
}
|
|
2310
|
+
return new TextDecoder().decode(
|
|
2311
|
+
buffer.subarray(LENGTH_PREFIX_BYTES, LENGTH_PREFIX_BYTES + length)
|
|
2312
|
+
);
|
|
2313
|
+
}
|
|
2314
|
+
function joined(...parts) {
|
|
2315
|
+
const whole = new Uint8Array(parts.reduce((total, part) => total + part.length, 0));
|
|
2316
|
+
let at = 0;
|
|
2317
|
+
for (const part of parts) {
|
|
2318
|
+
whole.set(part, at);
|
|
2319
|
+
at += part.length;
|
|
2320
|
+
}
|
|
2321
|
+
return whole;
|
|
2322
|
+
}
|
|
2323
|
+
function sameBytes(left, right) {
|
|
2324
|
+
let different = left.length ^ right.length;
|
|
2325
|
+
for (let at = 0; at < left.length && at < right.length; at++) {
|
|
2326
|
+
different |= (left[at] ?? 0) ^ (right[at] ?? 0);
|
|
2327
|
+
}
|
|
2328
|
+
return different === 0;
|
|
2329
|
+
}
|
|
2330
|
+
function toBase64(bytes) {
|
|
2331
|
+
let binary = "";
|
|
2332
|
+
for (const byte of bytes) {
|
|
2333
|
+
binary += String.fromCharCode(byte);
|
|
2334
|
+
}
|
|
2335
|
+
return btoa(binary);
|
|
2336
|
+
}
|
|
2337
|
+
function fromBase64(text3) {
|
|
2338
|
+
const binary = atob(text3);
|
|
2339
|
+
const bytes = new Uint8Array(binary.length);
|
|
2340
|
+
for (let at = 0; at < bytes.length; at++) {
|
|
2341
|
+
bytes[at] = binary.charCodeAt(at);
|
|
2342
|
+
}
|
|
2343
|
+
return bytes;
|
|
2344
|
+
}
|
|
2345
|
+
|
|
1745
2346
|
// src/qr.ts
|
|
1746
2347
|
var import_uqr = require("uqr");
|
|
1747
2348
|
var SAFE_COLOR = /^#(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$|^rgba?\([\d.,%/\s]+\)$|^[a-z]+$/i;
|
|
@@ -1752,44 +2353,276 @@ function toLightningUri(destination) {
|
|
|
1752
2353
|
function invoiceToSvg(destination, options) {
|
|
1753
2354
|
return svgOf(toLightningUri(destination), options);
|
|
1754
2355
|
}
|
|
1755
|
-
function svgOf(scanned, options) {
|
|
1756
|
-
const size = options?.size ?? 256;
|
|
1757
|
-
const color = options?.color ?? "#000";
|
|
1758
|
-
if (!SAFE_COLOR.test(color)) {
|
|
1759
|
-
throw new Error(`Invalid color: ${color}`);
|
|
2356
|
+
function svgOf(scanned, options) {
|
|
2357
|
+
const size = options?.size ?? 256;
|
|
2358
|
+
const color = options?.color ?? "#000";
|
|
2359
|
+
if (!SAFE_COLOR.test(color)) {
|
|
2360
|
+
throw new Error(`Invalid color: ${color}`);
|
|
2361
|
+
}
|
|
2362
|
+
return renderSvg(scanned, size, color);
|
|
2363
|
+
}
|
|
2364
|
+
function isLnAddress(destination) {
|
|
2365
|
+
const at = destination.indexOf("@");
|
|
2366
|
+
return at > 0 && destination.slice(at + 1).includes(".");
|
|
2367
|
+
}
|
|
2368
|
+
function renderSvg(content, size, color) {
|
|
2369
|
+
const { data } = (0, import_uqr.encode)(content);
|
|
2370
|
+
const modules = data.length;
|
|
2371
|
+
const cellSize = size / modules;
|
|
2372
|
+
let path = "";
|
|
2373
|
+
for (let y = 0; y < modules; y++) {
|
|
2374
|
+
for (let x = 0; x < modules; x++) {
|
|
2375
|
+
if (data[y][x]) {
|
|
2376
|
+
path += `M${x * cellSize},${y * cellSize}h${cellSize}v${cellSize}h-${cellSize}z`;
|
|
2377
|
+
}
|
|
2378
|
+
}
|
|
2379
|
+
}
|
|
2380
|
+
return [
|
|
2381
|
+
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${size} ${size}" width="${size}" height="${size}">`,
|
|
2382
|
+
`<rect width="100%" height="100%" fill="white"/>`,
|
|
2383
|
+
`<path d="${path}" fill="${color}"/>`,
|
|
2384
|
+
`</svg>`
|
|
2385
|
+
].join("");
|
|
2386
|
+
}
|
|
2387
|
+
|
|
2388
|
+
// src/nwc.ts
|
|
2389
|
+
var REQUEST_KIND = 23194;
|
|
2390
|
+
var RESPONSE_KIND = 23195;
|
|
2391
|
+
var NIP44_V2 = "nip44_v2";
|
|
2392
|
+
var DEFAULT_ASK_TIMEOUT_MS = 1e4;
|
|
2393
|
+
var DEFAULT_POLL_EVERY_SECS2 = 5;
|
|
2394
|
+
var HASH = "h";
|
|
2395
|
+
var REFUSAL_BY_CODE = {
|
|
2396
|
+
INSUFFICIENT_BALANCE: "amount-not-accepted",
|
|
2397
|
+
QUOTA_EXCEEDED: "amount-not-accepted",
|
|
2398
|
+
RESTRICTED: "address-unusable",
|
|
2399
|
+
UNAUTHORIZED: "address-unusable",
|
|
2400
|
+
UNSUPPORTED_ENCRYPTION: "address-unusable"
|
|
2401
|
+
};
|
|
2402
|
+
async function nwcInvoice(connection, amountMsat, description, timeoutMs = DEFAULT_ASK_TIMEOUT_MS) {
|
|
2403
|
+
const answered3 = await askWallet(
|
|
2404
|
+
connection,
|
|
2405
|
+
"make_invoice",
|
|
2406
|
+
{ amount: amountMsat, description },
|
|
2407
|
+
timeoutMs
|
|
2408
|
+
);
|
|
2409
|
+
const bolt11 = String(answered3.invoice ?? "");
|
|
2410
|
+
const issued = decodeInvoice(bolt11);
|
|
2411
|
+
if (issued.paymentHash === null || issued.expiresAt === null) {
|
|
2412
|
+
throw new WalletRefused("invoice-refused", "the nwc wallet issued an invoice we cannot decode");
|
|
2413
|
+
}
|
|
2414
|
+
if (issued.amountMsat !== amountMsat) {
|
|
2415
|
+
throw new WalletRefused(
|
|
2416
|
+
"invoice-refused",
|
|
2417
|
+
`the nwc wallet issued an invoice for ${issued.amountMsat} msat, not ${amountMsat}`
|
|
2418
|
+
);
|
|
2419
|
+
}
|
|
2420
|
+
return { bolt11, paymentHash: issued.paymentHash, expiresAt: issued.expiresAt };
|
|
2421
|
+
}
|
|
2422
|
+
async function nwcSettlement(connection, paymentHash, timeoutMs = DEFAULT_ASK_TIMEOUT_MS) {
|
|
2423
|
+
const answered3 = await askWallet(
|
|
2424
|
+
connection,
|
|
2425
|
+
"lookup_invoice",
|
|
2426
|
+
{ payment_hash: paymentHash },
|
|
2427
|
+
timeoutMs
|
|
2428
|
+
);
|
|
2429
|
+
const preimage = answered3.preimage;
|
|
2430
|
+
if (typeof preimage !== "string" || preimage.length === 0) {
|
|
2431
|
+
return null;
|
|
2432
|
+
}
|
|
2433
|
+
if (!preimageMatchesHash(preimage, paymentHash)) {
|
|
2434
|
+
throw new Error(`the nwc wallet returned a preimage that does not hash to ${paymentHash}`);
|
|
2435
|
+
}
|
|
2436
|
+
return preimage;
|
|
2437
|
+
}
|
|
2438
|
+
function nwcVerifyEndpoint(config) {
|
|
2439
|
+
const paced = {
|
|
2440
|
+
"cache-control": `max-age=${config.pollEverySecs ?? DEFAULT_POLL_EVERY_SECS2}`
|
|
2441
|
+
};
|
|
2442
|
+
return async (request) => {
|
|
2443
|
+
const consented = await answerVerifyChallenge(request);
|
|
2444
|
+
if (consented !== null) {
|
|
2445
|
+
return consented;
|
|
2446
|
+
}
|
|
2447
|
+
const sealed = new URL(request.url).searchParams.get(HASH);
|
|
2448
|
+
if (sealed === null) {
|
|
2449
|
+
return Response.json({ settled: false }, { status: 400 });
|
|
2450
|
+
}
|
|
2451
|
+
const paymentHash = await unsealFor("nwc-verify", config.secret, sealed);
|
|
2452
|
+
if (paymentHash === null) {
|
|
2453
|
+
return Response.json({ settled: false }, { status: 403 });
|
|
2454
|
+
}
|
|
2455
|
+
const preimage = await nwcSettlement(config.connection, paymentHash, config.askTimeoutMs).catch(
|
|
2456
|
+
() => void 0
|
|
2457
|
+
);
|
|
2458
|
+
if (preimage === void 0) {
|
|
2459
|
+
return Response.json({ settled: false }, { status: 502 });
|
|
2460
|
+
}
|
|
2461
|
+
return Response.json({ settled: preimage !== null, preimage }, { headers: paced });
|
|
2462
|
+
};
|
|
2463
|
+
}
|
|
2464
|
+
async function nwcVerifyUrl(endpoint, paymentHash, secret) {
|
|
2465
|
+
const sealed = new URL(endpoint);
|
|
2466
|
+
sealed.searchParams.set(HASH, await sealFor("nwc-verify", secret, paymentHash));
|
|
2467
|
+
return sealed.toString();
|
|
2468
|
+
}
|
|
2469
|
+
async function askWallet(connection, method, params, timeoutMs = DEFAULT_ASK_TIMEOUT_MS) {
|
|
2470
|
+
const key = conversationKeyFor(connection.secret, connection.walletPubkey);
|
|
2471
|
+
const request = signEvent(connection.secret, {
|
|
2472
|
+
kind: REQUEST_KIND,
|
|
2473
|
+
tags: [
|
|
2474
|
+
["p", connection.walletPubkey],
|
|
2475
|
+
["encryption", NIP44_V2]
|
|
2476
|
+
],
|
|
2477
|
+
content: encryptNip44(key, JSON.stringify({ method, params })),
|
|
2478
|
+
created_at: Math.floor(Date.now() / 1e3)
|
|
2479
|
+
});
|
|
2480
|
+
const answer = await overFirstRelayThatAnswers(connection, request, timeoutMs);
|
|
2481
|
+
return resultOf(JSON.parse(decryptNip44(key, answer.content)), method);
|
|
2482
|
+
}
|
|
2483
|
+
function resultOf(said, method) {
|
|
2484
|
+
const failure = said.error;
|
|
2485
|
+
if (failure) {
|
|
2486
|
+
throw new WalletRefused(
|
|
2487
|
+
REFUSAL_BY_CODE[failure.code ?? ""] ?? "invoice-refused",
|
|
2488
|
+
`the nwc wallet refused ${method}: ${failure.code ?? "unknown"} ${failure.message ?? ""}`.trim()
|
|
2489
|
+
);
|
|
2490
|
+
}
|
|
2491
|
+
const result = said.result;
|
|
2492
|
+
if (result === null || typeof result !== "object") {
|
|
2493
|
+
throw new WalletRefused("invoice-refused", `the nwc wallet answered ${method} with no result`);
|
|
2494
|
+
}
|
|
2495
|
+
return result;
|
|
2496
|
+
}
|
|
2497
|
+
async function overFirstRelayThatAnswers(connection, request, timeoutMs) {
|
|
2498
|
+
const deadline = AbortSignal.timeout(timeoutMs);
|
|
2499
|
+
let lastFailure = null;
|
|
2500
|
+
for (const relay of connection.relays) {
|
|
2501
|
+
if (deadline.aborted) {
|
|
2502
|
+
break;
|
|
2503
|
+
}
|
|
2504
|
+
try {
|
|
2505
|
+
return await overRelay(relay, connection, request, deadline);
|
|
2506
|
+
} catch (failure) {
|
|
2507
|
+
lastFailure = failure;
|
|
2508
|
+
}
|
|
2509
|
+
}
|
|
2510
|
+
throw new WalletRefused("unreachable", `no nwc relay answered: ${String(lastFailure)}`);
|
|
2511
|
+
}
|
|
2512
|
+
function overRelay(relay, connection, request, deadline) {
|
|
2513
|
+
return new Promise((resolve2, reject) => {
|
|
2514
|
+
const socket = new WebSocket(relay);
|
|
2515
|
+
const subscription = request.id.slice(0, 16);
|
|
2516
|
+
const expired = () => {
|
|
2517
|
+
close();
|
|
2518
|
+
reject(new Error(`${relay} did not answer before the deadline`));
|
|
2519
|
+
};
|
|
2520
|
+
deadline.addEventListener("abort", expired);
|
|
2521
|
+
function close() {
|
|
2522
|
+
deadline.removeEventListener("abort", expired);
|
|
2523
|
+
socket.close();
|
|
2524
|
+
}
|
|
2525
|
+
socket.addEventListener("open", () => {
|
|
2526
|
+
socket.send(
|
|
2527
|
+
JSON.stringify([
|
|
2528
|
+
"REQ",
|
|
2529
|
+
subscription,
|
|
2530
|
+
{
|
|
2531
|
+
kinds: [RESPONSE_KIND],
|
|
2532
|
+
authors: [connection.walletPubkey],
|
|
2533
|
+
"#p": [publicKeyFor(connection.secret)],
|
|
2534
|
+
"#e": [request.id]
|
|
2535
|
+
}
|
|
2536
|
+
])
|
|
2537
|
+
);
|
|
2538
|
+
socket.send(JSON.stringify(["EVENT", request]));
|
|
2539
|
+
});
|
|
2540
|
+
socket.addEventListener("error", () => {
|
|
2541
|
+
close();
|
|
2542
|
+
reject(new Error(`${relay} could not be reached`));
|
|
2543
|
+
});
|
|
2544
|
+
socket.addEventListener("message", (delivered) => {
|
|
2545
|
+
const frame = String(delivered.data);
|
|
2546
|
+
const rejection = rejectionOf(frame, request.id);
|
|
2547
|
+
if (rejection !== null) {
|
|
2548
|
+
close();
|
|
2549
|
+
reject(new Error(`${relay} refused the request: ${rejection}`));
|
|
2550
|
+
return;
|
|
2551
|
+
}
|
|
2552
|
+
const answer = answeredEvent(frame, subscription, connection, request.id);
|
|
2553
|
+
if (answer === null) {
|
|
2554
|
+
return;
|
|
2555
|
+
}
|
|
2556
|
+
close();
|
|
2557
|
+
resolve2(answer);
|
|
2558
|
+
});
|
|
2559
|
+
});
|
|
2560
|
+
}
|
|
2561
|
+
function rejectionOf(frame, requestId) {
|
|
2562
|
+
const parsed = parsedFrame(frame);
|
|
2563
|
+
if (parsed === null || parsed[0] !== "OK" || parsed[1] !== requestId || parsed[2] !== false) {
|
|
2564
|
+
return null;
|
|
1760
2565
|
}
|
|
1761
|
-
return
|
|
2566
|
+
return typeof parsed[3] === "string" && parsed[3].length > 0 ? parsed[3] : "no reason given";
|
|
1762
2567
|
}
|
|
1763
|
-
function
|
|
1764
|
-
const
|
|
1765
|
-
|
|
2568
|
+
function answeredEvent(frame, subscription, connection, requestId) {
|
|
2569
|
+
const parsed = parsedFrame(frame);
|
|
2570
|
+
if (parsed === null || parsed[0] !== "EVENT" || parsed[1] !== subscription) {
|
|
2571
|
+
return null;
|
|
2572
|
+
}
|
|
2573
|
+
const event = parsed[2];
|
|
2574
|
+
const answers = event?.kind === RESPONSE_KIND && event.pubkey === connection.walletPubkey && Array.isArray(event.tags) && event.tags.some((tag) => tag?.[0] === "e" && tag[1] === requestId) && verifyEvent(event);
|
|
2575
|
+
return answers ? event : null;
|
|
1766
2576
|
}
|
|
1767
|
-
function
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
for (let x = 0; x < modules; x++) {
|
|
1774
|
-
if (data[y][x]) {
|
|
1775
|
-
path += `M${x * cellSize},${y * cellSize}h${cellSize}v${cellSize}h-${cellSize}z`;
|
|
1776
|
-
}
|
|
1777
|
-
}
|
|
2577
|
+
function parsedFrame(frame) {
|
|
2578
|
+
try {
|
|
2579
|
+
const parsed = JSON.parse(frame);
|
|
2580
|
+
return Array.isArray(parsed) ? parsed : null;
|
|
2581
|
+
} catch {
|
|
2582
|
+
return null;
|
|
1778
2583
|
}
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
2584
|
+
}
|
|
2585
|
+
function nwcRail(gateway, config) {
|
|
2586
|
+
return async (order) => {
|
|
2587
|
+
const invoice = await nwcInvoice(
|
|
2588
|
+
config.connection,
|
|
2589
|
+
await msatForOrder(order, config.amount, config.rate),
|
|
2590
|
+
config.description?.(order) ?? order.reference
|
|
2591
|
+
);
|
|
2592
|
+
const watched = await gateway.watch({
|
|
2593
|
+
paymentHash: invoice.paymentHash,
|
|
2594
|
+
verifyUrl: await nwcVerifyUrl(
|
|
2595
|
+
config.verifyThrough.endpoint,
|
|
2596
|
+
invoice.paymentHash,
|
|
2597
|
+
config.verifyThrough.secret
|
|
2598
|
+
),
|
|
2599
|
+
expiresAt: invoice.expiresAt,
|
|
2600
|
+
trigger: config.trigger,
|
|
2601
|
+
replay: config.replay,
|
|
2602
|
+
sealed: config.sealed ? await seal(
|
|
2603
|
+
config.sealed.secret,
|
|
2604
|
+
JSON.stringify(config.sealed.data(order)),
|
|
2605
|
+
invoice.paymentHash
|
|
2606
|
+
) : void 0,
|
|
2607
|
+
webhookUrl: config.webhookUrl
|
|
2608
|
+
});
|
|
2609
|
+
return {
|
|
2610
|
+
id: watched.id,
|
|
2611
|
+
paymentHash: invoice.paymentHash,
|
|
2612
|
+
rail: config.name ?? "lightning",
|
|
2613
|
+
scan: invoice.bolt11,
|
|
2614
|
+
qr: toLightningUri(invoice.bolt11),
|
|
2615
|
+
expiresAt: invoice.expiresAt
|
|
2616
|
+
};
|
|
2617
|
+
};
|
|
1785
2618
|
}
|
|
1786
2619
|
|
|
1787
2620
|
// src/relay.ts
|
|
1788
|
-
var
|
|
2621
|
+
var DEFAULT_POLL_EVERY_SECS3 = 5;
|
|
1789
2622
|
var WALLET = "w";
|
|
1790
2623
|
function lightningVerifyEndpoint(config) {
|
|
1791
2624
|
const paced = {
|
|
1792
|
-
"cache-control": `max-age=${config.pollEverySecs ??
|
|
2625
|
+
"cache-control": `max-age=${config.pollEverySecs ?? DEFAULT_POLL_EVERY_SECS3}`
|
|
1793
2626
|
};
|
|
1794
2627
|
return async (request) => {
|
|
1795
2628
|
const consented = await answerVerifyChallenge(request);
|
|
@@ -1800,11 +2633,11 @@ function lightningVerifyEndpoint(config) {
|
|
|
1800
2633
|
if (sealed === null) {
|
|
1801
2634
|
return Response.json({ settled: false }, { status: 400 });
|
|
1802
2635
|
}
|
|
1803
|
-
const
|
|
1804
|
-
|
|
2636
|
+
const opened2 = await unsealFor("relay", config.secret, sealed);
|
|
2637
|
+
const wallet = opened2 === null ? null : relayedFrom(opened2);
|
|
2638
|
+
if (wallet === null) {
|
|
1805
2639
|
return Response.json({ settled: false }, { status: 403 });
|
|
1806
2640
|
}
|
|
1807
|
-
const wallet = JSON.parse(opened);
|
|
1808
2641
|
const asked = await checkSettled(
|
|
1809
2642
|
config.send ?? pinnedToTheAddressWeVerified,
|
|
1810
2643
|
wallet.url,
|
|
@@ -1819,9 +2652,17 @@ function lightningVerifyEndpoint(config) {
|
|
|
1819
2652
|
);
|
|
1820
2653
|
};
|
|
1821
2654
|
}
|
|
2655
|
+
function relayedFrom(opened2) {
|
|
2656
|
+
try {
|
|
2657
|
+
const said = JSON.parse(opened2);
|
|
2658
|
+
return typeof said?.url === "string" && typeof said.hash === "string" ? { url: said.url, hash: said.hash } : null;
|
|
2659
|
+
} catch {
|
|
2660
|
+
return null;
|
|
2661
|
+
}
|
|
2662
|
+
}
|
|
1822
2663
|
async function relayedVerifyUrl(endpoint, wallet, secret) {
|
|
1823
2664
|
const relayed = new URL(endpoint);
|
|
1824
|
-
relayed.searchParams.set(WALLET, await
|
|
2665
|
+
relayed.searchParams.set(WALLET, await sealFor("relay", secret, JSON.stringify(wallet)));
|
|
1825
2666
|
return relayed.toString();
|
|
1826
2667
|
}
|
|
1827
2668
|
|
|
@@ -1841,13 +2682,14 @@ function bankRail(gateway, config) {
|
|
|
1841
2682
|
expiresAt,
|
|
1842
2683
|
trigger: config.trigger,
|
|
1843
2684
|
replay: config.replay,
|
|
1844
|
-
sealed:
|
|
2685
|
+
sealed: config.sealed && { secret: config.sealed.secret, data: config.sealed.data(order) },
|
|
1845
2686
|
variableSymbol: config.variableSymbol?.(order),
|
|
1846
2687
|
webhookUrl: config.webhookUrl,
|
|
1847
2688
|
allowPublicGateway: config.allowPublicGateway
|
|
1848
2689
|
});
|
|
1849
2690
|
return {
|
|
1850
2691
|
id: transfer.id,
|
|
2692
|
+
paymentHash: transfer.paymentHash,
|
|
1851
2693
|
rail: config.name ?? BANK,
|
|
1852
2694
|
scan: transfer.spd,
|
|
1853
2695
|
qr: transfer.spd,
|
|
@@ -1871,6 +2713,7 @@ function lightningRail(gateway, config) {
|
|
|
1871
2713
|
);
|
|
1872
2714
|
return {
|
|
1873
2715
|
id: payment.id,
|
|
2716
|
+
paymentHash: payment.paymentHash,
|
|
1874
2717
|
rail: config.name ?? LIGHTNING,
|
|
1875
2718
|
scan: payment.bolt11,
|
|
1876
2719
|
qr: toLightningUri(payment.bolt11),
|
|
@@ -1896,11 +2739,16 @@ function blindLightningRail(gateway, config) {
|
|
|
1896
2739
|
expiresAt: resolved2.expiresAt,
|
|
1897
2740
|
trigger: config.trigger,
|
|
1898
2741
|
replay: config.replay,
|
|
1899
|
-
sealed:
|
|
2742
|
+
sealed: config.sealed ? await seal(
|
|
2743
|
+
config.sealed.secret,
|
|
2744
|
+
JSON.stringify(config.sealed.data(order)),
|
|
2745
|
+
resolved2.paymentHash
|
|
2746
|
+
) : void 0,
|
|
1900
2747
|
webhookUrl: config.webhookUrl
|
|
1901
2748
|
});
|
|
1902
2749
|
return {
|
|
1903
2750
|
id: watched.id,
|
|
2751
|
+
paymentHash: resolved2.paymentHash,
|
|
1904
2752
|
rail: config.name ?? LIGHTNING,
|
|
1905
2753
|
scan: resolved2.bolt11,
|
|
1906
2754
|
qr: toLightningUri(resolved2.bolt11),
|
|
@@ -1945,6 +2793,15 @@ var Rails = class {
|
|
|
1945
2793
|
bank(config) {
|
|
1946
2794
|
return bankRail(this.gateway, config);
|
|
1947
2795
|
}
|
|
2796
|
+
/**
|
|
2797
|
+
* Lightning against a wallet of your own over NIP-47, for a wallet that has no
|
|
2798
|
+
* LUD-21 address to be watched at. Your node mints the invoice and releases the
|
|
2799
|
+
* preimage, so the proof comes from one hop nearer than any hosted address can
|
|
2800
|
+
* manage, and the gateway sees a hash and a URL of yours
|
|
2801
|
+
*/
|
|
2802
|
+
nwc(config) {
|
|
2803
|
+
return nwcRail(this.gateway, config);
|
|
2804
|
+
}
|
|
1948
2805
|
/**
|
|
1949
2806
|
* One bank transfer without building a rail first, for a shop that asks for
|
|
1950
2807
|
* them one at a time rather than beside another payment method
|
|
@@ -1954,130 +2811,6 @@ var Rails = class {
|
|
|
1954
2811
|
}
|
|
1955
2812
|
};
|
|
1956
2813
|
|
|
1957
|
-
// src/verify.ts
|
|
1958
|
-
var HTTP_TIMEOUT_MS2 = 15e3;
|
|
1959
|
-
var DEFAULT_WRAP_PROPORTION = 0.01;
|
|
1960
|
-
var DEFAULT_WRAP_BASE_MSAT = 1e3;
|
|
1961
|
-
async function proveOrigin(payment, asked) {
|
|
1962
|
-
const cheat = (code) => new GatewayCheatError(code, payment.id);
|
|
1963
|
-
const listed = asked.paidTo.find((address) => equalIgnoringCase(address, payment.lnAddress));
|
|
1964
|
-
if (listed === void 0) {
|
|
1965
|
-
throw cheat("address_not_requested");
|
|
1966
|
-
}
|
|
1967
|
-
if (payment.amountMsat !== asked.amountMsat) {
|
|
1968
|
-
throw cheat("amount_mismatch");
|
|
1969
|
-
}
|
|
1970
|
-
const invoice = decodeInvoice(payment.bolt11);
|
|
1971
|
-
if (invoice.paymentHash === null || !equalIgnoringCase(invoice.paymentHash, payment.paymentHash)) {
|
|
1972
|
-
throw cheat("hash_mismatch");
|
|
1973
|
-
}
|
|
1974
|
-
if (invoice.amountMsat !== asked.amountMsat) {
|
|
1975
|
-
throw cheat("amount_mismatch");
|
|
1976
|
-
}
|
|
1977
|
-
const payRequest = await payRequestFor(listed, payment);
|
|
1978
|
-
if (typeof payRequest.metadata !== "string" || typeof payRequest.callback !== "string") {
|
|
1979
|
-
throw new UnverifiedRecipientError(payment.lnAddress, payment.id, "no payRequest served");
|
|
1980
|
-
}
|
|
1981
|
-
if (invoice.descriptionHash !== sha256Hex(payRequest.metadata)) {
|
|
1982
|
-
throw cheat("description_hash_mismatch");
|
|
1983
|
-
}
|
|
1984
|
-
if (!sameOrigin(payment.verifyUrl, payRequest.callback)) {
|
|
1985
|
-
throw cheat("verify_url_foreign");
|
|
1986
|
-
}
|
|
1987
|
-
const issued = await reachable(payment.verifyUrl, payment);
|
|
1988
|
-
if (typeof issued.pr !== "string" || !equalIgnoringCase(issued.pr, payment.bolt11)) {
|
|
1989
|
-
throw cheat("invoice_not_issued");
|
|
1990
|
-
}
|
|
1991
|
-
}
|
|
1992
|
-
async function proveSettlement(payment, asked) {
|
|
1993
|
-
await proveOrigin(payment, asked);
|
|
1994
|
-
const verified = await reachable(payment.verifyUrl, payment);
|
|
1995
|
-
if (verified.settled !== true || typeof verified.preimage !== "string") {
|
|
1996
|
-
return null;
|
|
1997
|
-
}
|
|
1998
|
-
if (!preimageMatchesHash(verified.preimage, payment.paymentHash)) {
|
|
1999
|
-
throw new GatewayCheatError("preimage_mismatch", payment.id);
|
|
2000
|
-
}
|
|
2001
|
-
return verified.preimage;
|
|
2002
|
-
}
|
|
2003
|
-
function carriesProof(report) {
|
|
2004
|
-
if (report.status !== "paid" || report.preimage === null) {
|
|
2005
|
-
return false;
|
|
2006
|
-
}
|
|
2007
|
-
if (report.bolt11 !== void 0 && report.bolt11 !== null) {
|
|
2008
|
-
const invoiceHash = decodeInvoice(report.bolt11).paymentHash;
|
|
2009
|
-
if (invoiceHash === null || !equalIgnoringCase(invoiceHash, report.paymentHash)) {
|
|
2010
|
-
return false;
|
|
2011
|
-
}
|
|
2012
|
-
}
|
|
2013
|
-
return preimageMatchesHash(report.preimage, report.paymentHash);
|
|
2014
|
-
}
|
|
2015
|
-
function wrapFeeCeiling(amountMsat, allowance) {
|
|
2016
|
-
const proportional = amountMsat * (allowance?.proportion ?? DEFAULT_WRAP_PROPORTION);
|
|
2017
|
-
return msat(Math.max(allowance?.baseMsat ?? DEFAULT_WRAP_BASE_MSAT, Math.floor(proportional)));
|
|
2018
|
-
}
|
|
2019
|
-
function proveWrapped(wrapped, recipient, allowance) {
|
|
2020
|
-
const offered = decodeInvoice(wrapped);
|
|
2021
|
-
const owed = decodeInvoice(recipient);
|
|
2022
|
-
const refuse2 = (code, detail) => {
|
|
2023
|
-
throw new WrapRefusedError(code, detail);
|
|
2024
|
-
};
|
|
2025
|
-
if (offered.paymentHash === null || offered.amountMsat === null || offered.expiresAt === null) {
|
|
2026
|
-
refuse2("undecodable", "the wrapped invoice carries no hash, amount or expiry");
|
|
2027
|
-
}
|
|
2028
|
-
if (owed.paymentHash === null || owed.amountMsat === null || owed.expiresAt === null) {
|
|
2029
|
-
refuse2("undecodable", "the recipient's invoice carries no hash, amount or expiry");
|
|
2030
|
-
}
|
|
2031
|
-
if (offered.paymentHash?.toLowerCase() !== owed.paymentHash?.toLowerCase()) {
|
|
2032
|
-
refuse2("hash_mismatch", "settling the wrap would not settle the recipient");
|
|
2033
|
-
}
|
|
2034
|
-
const asked = offered.amountMsat ?? 0;
|
|
2035
|
-
const due = owed.amountMsat ?? 0;
|
|
2036
|
-
if (asked < due) {
|
|
2037
|
-
refuse2("amount_below_recipient", `${asked} msat cannot forward ${due} msat`);
|
|
2038
|
-
}
|
|
2039
|
-
const ceiling = wrapFeeCeiling(due, allowance);
|
|
2040
|
-
if (asked - due > ceiling) {
|
|
2041
|
-
refuse2("fee_above_allowance", `${asked - due} msat over ${due}, and ${ceiling} was allowed`);
|
|
2042
|
-
}
|
|
2043
|
-
if ((owed.expiresAt ?? 0) <= (offered.expiresAt ?? 0)) {
|
|
2044
|
-
refuse2("recipient_expires_first", "the operator could be paid and find it cannot deliver");
|
|
2045
|
-
}
|
|
2046
|
-
}
|
|
2047
|
-
async function payRequestFor(listed, payment) {
|
|
2048
|
-
const at = listed.indexOf("@");
|
|
2049
|
-
const name = listed.slice(0, at);
|
|
2050
|
-
const domain = listed.slice(at + 1).toLowerCase();
|
|
2051
|
-
if (at < 1 || !publicHttps(`https://${domain}/`)) {
|
|
2052
|
-
throw new UnverifiedRecipientError(payment.lnAddress, payment.id, "not a usable address");
|
|
2053
|
-
}
|
|
2054
|
-
return reachable(`https://${domain}/.well-known/lnurlp/${name}`, payment);
|
|
2055
|
-
}
|
|
2056
|
-
async function reachable(url, payment) {
|
|
2057
|
-
try {
|
|
2058
|
-
if (!publicHttps(url)) {
|
|
2059
|
-
throw new Error(`${url} is not a public https URL`);
|
|
2060
|
-
}
|
|
2061
|
-
const response = await fetch(url, {
|
|
2062
|
-
signal: AbortSignal.timeout(HTTP_TIMEOUT_MS2),
|
|
2063
|
-
headers: { accept: "application/json" }
|
|
2064
|
-
});
|
|
2065
|
-
if (!response.ok) {
|
|
2066
|
-
throw new Error(`answered ${response.status}`);
|
|
2067
|
-
}
|
|
2068
|
-
const body = await response.json();
|
|
2069
|
-
if (body === null || typeof body !== "object") {
|
|
2070
|
-
throw new Error("answered with no JSON object");
|
|
2071
|
-
}
|
|
2072
|
-
return body;
|
|
2073
|
-
} catch (cause) {
|
|
2074
|
-
throw new UnverifiedRecipientError(payment.lnAddress, payment.id, cause);
|
|
2075
|
-
}
|
|
2076
|
-
}
|
|
2077
|
-
function equalIgnoringCase(one, other) {
|
|
2078
|
-
return one.toLowerCase() === other.toLowerCase();
|
|
2079
|
-
}
|
|
2080
|
-
|
|
2081
2814
|
// src/request.ts
|
|
2082
2815
|
function paymentRequestOf(gateway, payment, options) {
|
|
2083
2816
|
return {
|
|
@@ -2089,13 +2822,13 @@ function paymentRequestOf(gateway, payment, options) {
|
|
|
2089
2822
|
expiresAt: payment.expiresAt,
|
|
2090
2823
|
qr: invoiceToSvg(payment.bolt11, options?.qr),
|
|
2091
2824
|
payment,
|
|
2092
|
-
paid: async (waiting) => paidOnly(await gateway.settled(payment
|
|
2825
|
+
paid: async (waiting) => paidOnly(await gateway.settled(payment, { ...waitingOf(options), ...waiting })),
|
|
2093
2826
|
prove: () => proveSettlement(payment, { paidTo: [payment.lnAddress], amountMsat: payment.amountMsat }),
|
|
2094
2827
|
onPaid: (arrived, failed) => {
|
|
2095
2828
|
const stop = new AbortController();
|
|
2096
2829
|
const waiting = waitingOf(options);
|
|
2097
2830
|
const signal = waiting.signal ? AbortSignal.any([stop.signal, waiting.signal]) : stop.signal;
|
|
2098
|
-
gateway.settled(payment
|
|
2831
|
+
gateway.settled(payment, { ...waiting, signal }).then((ended) => arrived(paidOnly(ended))).catch((reason) => {
|
|
2099
2832
|
if (!stop.signal.aborted) {
|
|
2100
2833
|
failed?.(reason);
|
|
2101
2834
|
}
|
|
@@ -2118,7 +2851,7 @@ function waitingOf(options) {
|
|
|
2118
2851
|
}
|
|
2119
2852
|
|
|
2120
2853
|
// src/trigger.ts
|
|
2121
|
-
var
|
|
2854
|
+
var NONCE_BYTES3 = 16;
|
|
2122
2855
|
function lnurlPayEndpoint(gateway, config) {
|
|
2123
2856
|
return async (request) => {
|
|
2124
2857
|
const url = new URL(request.url);
|
|
@@ -2144,22 +2877,37 @@ function publicWatchTicketEndpoint(gateway, config) {
|
|
|
2144
2877
|
}
|
|
2145
2878
|
async function offer(gateway, config, url) {
|
|
2146
2879
|
const { least, most } = await spread(config.amount);
|
|
2147
|
-
const
|
|
2880
|
+
const winner = await quoted(gateway, config, least);
|
|
2148
2881
|
const nonce = randomNonce();
|
|
2149
2882
|
const callback = new URL(config.baseUrl ?? `${url.origin}${url.pathname}`);
|
|
2150
|
-
callback.searchParams.set("to",
|
|
2883
|
+
callback.searchParams.set("to", winner.address);
|
|
2151
2884
|
callback.searchParams.set("least", String(least));
|
|
2152
2885
|
callback.searchParams.set("most", String(most));
|
|
2153
2886
|
callback.searchParams.set("n", nonce);
|
|
2154
|
-
callback.searchParams.set("sig", await sign(config.secret,
|
|
2887
|
+
callback.searchParams.set("sig", await sign(config.secret, winner.address, least, most, nonce));
|
|
2155
2888
|
return Response.json({
|
|
2156
2889
|
tag: "payRequest",
|
|
2157
2890
|
callback: callback.toString(),
|
|
2158
|
-
metadata:
|
|
2891
|
+
metadata: winner.metadata,
|
|
2159
2892
|
minSendable: least,
|
|
2160
2893
|
maxSendable: most
|
|
2161
2894
|
});
|
|
2162
2895
|
}
|
|
2896
|
+
async function quoted(gateway, config, amountMsat) {
|
|
2897
|
+
if (config.blind) {
|
|
2898
|
+
const { won } = await quote(
|
|
2899
|
+
config.send ?? pinnedToTheAddressWeVerified,
|
|
2900
|
+
listed(config.paidTo),
|
|
2901
|
+
amountMsat
|
|
2902
|
+
);
|
|
2903
|
+
return won;
|
|
2904
|
+
}
|
|
2905
|
+
const { lnAddress, metadata } = await gateway.quote({
|
|
2906
|
+
paidTo: config.paidTo,
|
|
2907
|
+
amount: msat(amountMsat)
|
|
2908
|
+
});
|
|
2909
|
+
return { address: lnAddress, metadata };
|
|
2910
|
+
}
|
|
2163
2911
|
async function spread(amount) {
|
|
2164
2912
|
if (typeof amount === "number" || typeof amount === "function") {
|
|
2165
2913
|
const pinned = await amountNow(amount);
|
|
@@ -2181,6 +2929,9 @@ async function mint(gateway, config, url, address) {
|
|
|
2181
2929
|
if (!equalInConstantTime(signature, expected)) {
|
|
2182
2930
|
return refuse("this callback was not signed here");
|
|
2183
2931
|
}
|
|
2932
|
+
if (!listed(config.paidTo).some((one) => one.toLowerCase() === address.toLowerCase())) {
|
|
2933
|
+
return refuse(`${address} is not paid through this trigger`);
|
|
2934
|
+
}
|
|
2184
2935
|
const asked = url.searchParams.get("amount");
|
|
2185
2936
|
const amountMsat = asked === null ? least : Number(asked);
|
|
2186
2937
|
if (!Number.isSafeInteger(amountMsat) || amountMsat < least || amountMsat > most) {
|
|
@@ -2223,7 +2974,7 @@ async function mintBlind(gateway, config, address, amountMsat) {
|
|
|
2223
2974
|
expiresAt: resolved2.expiresAt,
|
|
2224
2975
|
trigger: config.watchSecret,
|
|
2225
2976
|
replay: config.replay,
|
|
2226
|
-
sealed: locked ? await seal(locked.secret, JSON.stringify(locked.data(minted))) : void 0
|
|
2977
|
+
sealed: locked ? await seal(locked.secret, JSON.stringify(locked.data(minted)), minted.paymentHash) : void 0
|
|
2227
2978
|
});
|
|
2228
2979
|
} catch (refused) {
|
|
2229
2980
|
if (!alreadyWatched(refused)) {
|
|
@@ -2232,14 +2983,17 @@ async function mintBlind(gateway, config, address, amountMsat) {
|
|
|
2232
2983
|
}
|
|
2233
2984
|
return { bolt11: resolved2.bolt11, verifyUrl: resolved2.verifyUrl };
|
|
2234
2985
|
}
|
|
2986
|
+
function listed(paidTo) {
|
|
2987
|
+
return typeof paidTo === "string" ? [paidTo] : paidTo;
|
|
2988
|
+
}
|
|
2235
2989
|
function alreadyWatched(refused) {
|
|
2236
2990
|
return refused instanceof ProblemError && isProblemType(refused, PAYMENT_ALREADY_WATCHED);
|
|
2237
2991
|
}
|
|
2238
2992
|
function sign(secret, address, least, most, nonce) {
|
|
2239
|
-
return hmacHex(secret,
|
|
2993
|
+
return hmacHex(secret, `lnurl-callback|${address}|${least}|${most}|${nonce}`);
|
|
2240
2994
|
}
|
|
2241
2995
|
function randomNonce() {
|
|
2242
|
-
const bytes = crypto.getRandomValues(new Uint8Array(
|
|
2996
|
+
const bytes = crypto.getRandomValues(new Uint8Array(NONCE_BYTES3));
|
|
2243
2997
|
let hex = "";
|
|
2244
2998
|
for (const byte of bytes) {
|
|
2245
2999
|
hex += byte.toString(16).padStart(2, "0");
|
|
@@ -2289,23 +3043,39 @@ var Serve = class {
|
|
|
2289
3043
|
* A verify endpoint of your own that asks the recipient's wallet for you, so
|
|
2290
3044
|
* the gateway polls you and never the wallet
|
|
2291
3045
|
*/
|
|
2292
|
-
|
|
3046
|
+
lightningVerify(config) {
|
|
2293
3047
|
return lightningVerifyEndpoint(config);
|
|
2294
3048
|
}
|
|
3049
|
+
/**
|
|
3050
|
+
* What `lightningVerify` was called before 2.2.0
|
|
3051
|
+
*
|
|
3052
|
+
* @deprecated Use `lightningVerify`, beside `bankVerify` and `nwcVerify`
|
|
3053
|
+
*/
|
|
3054
|
+
verify(config) {
|
|
3055
|
+
return this.lightningVerify(config);
|
|
3056
|
+
}
|
|
2295
3057
|
/** The verify endpoint a bank rail is polled at, answering off your own statement */
|
|
2296
3058
|
bankVerify(config) {
|
|
2297
3059
|
return bankVerifyEndpoint(config);
|
|
2298
3060
|
}
|
|
3061
|
+
/**
|
|
3062
|
+
* The verify endpoint an NWC rail is polled at, asking your own wallet over
|
|
3063
|
+
* NIP-47, so the gateway never learns the connection, the relay or the wallet
|
|
3064
|
+
*/
|
|
3065
|
+
nwcVerify(config) {
|
|
3066
|
+
return nwcVerifyEndpoint(config);
|
|
3067
|
+
}
|
|
2299
3068
|
/**
|
|
2300
3069
|
* The whole webhook route: it answers the gateway's challenge, checks the
|
|
2301
3070
|
* signature against the key the gateway publishes, refuses a settlement that
|
|
2302
|
-
* proves nothing, and calls you for the one that does.
|
|
3071
|
+
* proves nothing, and calls you once for the one that does.
|
|
2303
3072
|
*
|
|
2304
3073
|
* `export const POST = gateway.serve.webhook({ onSettled: fulfil })` is the
|
|
2305
3074
|
* entire integration
|
|
2306
3075
|
*/
|
|
2307
3076
|
webhook(handlers) {
|
|
2308
|
-
const options = { toleranceSecs: handlers.toleranceSecs };
|
|
3077
|
+
const options = { toleranceSecs: handlers.toleranceSecs, url: handlers.url };
|
|
3078
|
+
const once = onceWhileReplayable(handlers.toleranceSecs ?? DEFAULT_TOLERANCE_SECS);
|
|
2309
3079
|
return async (request) => {
|
|
2310
3080
|
const credential = await this.credential(handlers);
|
|
2311
3081
|
const challenge = await answerWebhookChallenge(request, credential, options);
|
|
@@ -2314,12 +3084,17 @@ var Serve = class {
|
|
|
2314
3084
|
}
|
|
2315
3085
|
const settlement = await readSettlement(request, credential, options);
|
|
2316
3086
|
if (settlement !== null) {
|
|
2317
|
-
return await
|
|
3087
|
+
return await once(
|
|
3088
|
+
`${settlement.id}:${settlement.status}`,
|
|
3089
|
+
() => acted(settlement, handlers)
|
|
3090
|
+
);
|
|
2318
3091
|
}
|
|
2319
3092
|
const payment = await readPayment(request, credential, options);
|
|
2320
3093
|
if (payment !== null) {
|
|
2321
|
-
await
|
|
2322
|
-
|
|
3094
|
+
return await once(`${payment.id}:${payment.status}`, async () => {
|
|
3095
|
+
await handlers.onPayment?.(payment);
|
|
3096
|
+
return new Response("ok");
|
|
3097
|
+
});
|
|
2323
3098
|
}
|
|
2324
3099
|
return new Response("bad signature", { status: 401 });
|
|
2325
3100
|
};
|
|
@@ -2340,8 +3115,34 @@ var Serve = class {
|
|
|
2340
3115
|
return handlers.credential ?? { publicKey: await this.gateway.webhookKey() };
|
|
2341
3116
|
}
|
|
2342
3117
|
};
|
|
3118
|
+
function onceWhileReplayable(toleranceSecs) {
|
|
3119
|
+
const acting = /* @__PURE__ */ new Map();
|
|
3120
|
+
const replayableForMs = 2 * toleranceSecs * 1e3;
|
|
3121
|
+
return async (delivery, act) => {
|
|
3122
|
+
const now = Date.now();
|
|
3123
|
+
for (const [earlier2, { forgetAt }] of acting) {
|
|
3124
|
+
if (forgetAt > now) {
|
|
3125
|
+
break;
|
|
3126
|
+
}
|
|
3127
|
+
acting.delete(earlier2);
|
|
3128
|
+
}
|
|
3129
|
+
const earlier = acting.get(delivery);
|
|
3130
|
+
if (earlier !== void 0) {
|
|
3131
|
+
await earlier.done;
|
|
3132
|
+
return new Response("ok");
|
|
3133
|
+
}
|
|
3134
|
+
const done = act();
|
|
3135
|
+
acting.set(delivery, { forgetAt: now + replayableForMs, done });
|
|
3136
|
+
try {
|
|
3137
|
+
return await done;
|
|
3138
|
+
} catch (failure) {
|
|
3139
|
+
acting.delete(delivery);
|
|
3140
|
+
throw failure;
|
|
3141
|
+
}
|
|
3142
|
+
};
|
|
3143
|
+
}
|
|
2343
3144
|
async function acted(settlement, handlers) {
|
|
2344
|
-
if (
|
|
3145
|
+
if (agreesWithItself(settlement)) {
|
|
2345
3146
|
await handlers.onSettled?.(settlement);
|
|
2346
3147
|
return new Response("ok");
|
|
2347
3148
|
}
|
|
@@ -2371,6 +3172,21 @@ function backoffMs(firstDelay, attempt) {
|
|
|
2371
3172
|
const grown = Math.min(firstDelay * 2 ** (attempt - 1), RECONNECT_CAP_MS);
|
|
2372
3173
|
return Math.round(grown * (0.5 + Math.random() / 2));
|
|
2373
3174
|
}
|
|
3175
|
+
function askFromWire(raw) {
|
|
3176
|
+
let said;
|
|
3177
|
+
try {
|
|
3178
|
+
said = JSON.parse(raw);
|
|
3179
|
+
} catch {
|
|
3180
|
+
return null;
|
|
3181
|
+
}
|
|
3182
|
+
if (typeof said !== "object" || said === null) {
|
|
3183
|
+
return null;
|
|
3184
|
+
}
|
|
3185
|
+
const fields = said;
|
|
3186
|
+
const ask2 = fields["ask"];
|
|
3187
|
+
const paymentHash = fields["payment_hash"];
|
|
3188
|
+
return typeof ask2 === "string" && typeof paymentHash === "string" ? { ask: ask2, paymentHash } : null;
|
|
3189
|
+
}
|
|
2374
3190
|
function gatewayAt(baseUrl) {
|
|
2375
3191
|
const at = URL.canParse(baseUrl) ? new URL(baseUrl) : null;
|
|
2376
3192
|
if (at === null || at.protocol !== "http:" && at.protocol !== "https:") {
|
|
@@ -2380,8 +3196,8 @@ function gatewayAt(baseUrl) {
|
|
|
2380
3196
|
}
|
|
2381
3197
|
var ThunderBridge = class {
|
|
2382
3198
|
baseUrl;
|
|
2383
|
-
verify;
|
|
2384
3199
|
token;
|
|
3200
|
+
handedOut = /* @__PURE__ */ new Map();
|
|
2385
3201
|
secret;
|
|
2386
3202
|
strangers = null;
|
|
2387
3203
|
speaks = null;
|
|
@@ -2392,7 +3208,6 @@ var ThunderBridge = class {
|
|
|
2392
3208
|
rails;
|
|
2393
3209
|
constructor(baseUrl, options) {
|
|
2394
3210
|
this.baseUrl = gatewayAt(baseUrl);
|
|
2395
|
-
this.verify = options?.verify ?? true;
|
|
2396
3211
|
this.token = options?.token ?? null;
|
|
2397
3212
|
this.secret = options?.secret ?? null;
|
|
2398
3213
|
this.serve = new Serve(this);
|
|
@@ -2434,6 +3249,7 @@ var ThunderBridge = class {
|
|
|
2434
3249
|
* and `GatewayCheatError` when what comes back is not what you asked for
|
|
2435
3250
|
*/
|
|
2436
3251
|
async mint(charge, options) {
|
|
3252
|
+
const askedAt = Math.floor(Date.now() / 1e3);
|
|
2437
3253
|
const asked = await priced(charge);
|
|
2438
3254
|
const sent = createRequestBody(
|
|
2439
3255
|
asked,
|
|
@@ -2454,9 +3270,8 @@ var ThunderBridge = class {
|
|
|
2454
3270
|
throw await problemFrom(response);
|
|
2455
3271
|
}
|
|
2456
3272
|
const payment = await mintedFrom(response);
|
|
2457
|
-
|
|
2458
|
-
|
|
2459
|
-
}
|
|
3273
|
+
await proveOrigin(payment, asked, askedAt);
|
|
3274
|
+
this.handOut(payment, options?.idempotencyKey ?? null);
|
|
2460
3275
|
return payment;
|
|
2461
3276
|
}
|
|
2462
3277
|
/**
|
|
@@ -2467,7 +3282,8 @@ var ThunderBridge = class {
|
|
|
2467
3282
|
* it for one, and asking mints it
|
|
2468
3283
|
*/
|
|
2469
3284
|
async quote(charge) {
|
|
2470
|
-
const
|
|
3285
|
+
const asked = await priced(charge);
|
|
3286
|
+
const sent = quoteRequestBody(asked);
|
|
2471
3287
|
const response = await fetch(`${this.baseUrl}/quotes`, {
|
|
2472
3288
|
method: "POST",
|
|
2473
3289
|
headers: await this.sending("/quotes", sent),
|
|
@@ -2476,14 +3292,17 @@ var ThunderBridge = class {
|
|
|
2476
3292
|
if (!response.ok) {
|
|
2477
3293
|
throw await problemFrom(response);
|
|
2478
3294
|
}
|
|
2479
|
-
const
|
|
2480
|
-
if (
|
|
3295
|
+
const quote2 = quoteFromWire(await response.json().catch(() => null));
|
|
3296
|
+
if (quote2 === null) {
|
|
2481
3297
|
throw new ProblemError({
|
|
2482
3298
|
status: response.status,
|
|
2483
3299
|
title: "The gateway answered with something that is not a quote"
|
|
2484
3300
|
});
|
|
2485
3301
|
}
|
|
2486
|
-
|
|
3302
|
+
if (!asked.paidTo.some((address) => address.toLowerCase() === quote2.lnAddress.toLowerCase())) {
|
|
3303
|
+
throw new GatewayCheatError("address_not_requested", "");
|
|
3304
|
+
}
|
|
3305
|
+
return quote2;
|
|
2487
3306
|
}
|
|
2488
3307
|
/**
|
|
2489
3308
|
* The key this gateway signs webhooks with when you registered none of your own.
|
|
@@ -2516,8 +3335,8 @@ var ThunderBridge = class {
|
|
|
2516
3335
|
* for both sorts: `kind` says whether the gateway minted it or was handed it,
|
|
2517
3336
|
* and the address, amount and invoice are null on one it was never told
|
|
2518
3337
|
*/
|
|
2519
|
-
async payment(
|
|
2520
|
-
const path = `/incoming-payments/${encodeURIComponent(id)}`;
|
|
3338
|
+
async payment(held) {
|
|
3339
|
+
const path = `/incoming-payments/${encodeURIComponent(held.id)}`;
|
|
2521
3340
|
const response = await fetch(`${this.baseUrl}${path}`, {
|
|
2522
3341
|
headers: await this.reading("GET", path)
|
|
2523
3342
|
});
|
|
@@ -2527,7 +3346,7 @@ var ThunderBridge = class {
|
|
|
2527
3346
|
if (!response.ok) {
|
|
2528
3347
|
throw await problemFrom(response);
|
|
2529
3348
|
}
|
|
2530
|
-
return this.proven(await paymentFrom(response));
|
|
3349
|
+
return this.proven(await paymentFrom(response), held);
|
|
2531
3350
|
}
|
|
2532
3351
|
/**
|
|
2533
3352
|
* List what this gateway is watching, newest first. Only a gateway started
|
|
@@ -2547,14 +3366,14 @@ var ThunderBridge = class {
|
|
|
2547
3366
|
throw await problemFrom(response);
|
|
2548
3367
|
}
|
|
2549
3368
|
const body = await response.json().catch(() => null);
|
|
2550
|
-
const
|
|
2551
|
-
if (
|
|
3369
|
+
const listed2 = Array.isArray(body?.payments) ? body.payments.map(paymentFromWire).filter((one) => one !== null).filter((one) => one.status !== "paid" || agreesWithItself(one)) : null;
|
|
3370
|
+
if (listed2 === null || typeof body?.settled_scanned !== "number") {
|
|
2552
3371
|
throw new ProblemError({
|
|
2553
3372
|
status: response.status,
|
|
2554
3373
|
title: "The gateway answered with something that is not a payment list"
|
|
2555
3374
|
});
|
|
2556
3375
|
}
|
|
2557
|
-
return { payments:
|
|
3376
|
+
return { payments: listed2, scanned: body.settled_scanned };
|
|
2558
3377
|
}
|
|
2559
3378
|
/**
|
|
2560
3379
|
* Follow a payment over WebSocket until it is paid or expired, reconnecting
|
|
@@ -2562,15 +3381,15 @@ var ThunderBridge = class {
|
|
|
2562
3381
|
* one that has answered is followed until its own expiry, so the wait always
|
|
2563
3382
|
* ends by itself
|
|
2564
3383
|
*/
|
|
2565
|
-
async settled(
|
|
2566
|
-
const ended = paymentFromWire(await this.followed(id, options));
|
|
3384
|
+
async settled(held, options) {
|
|
3385
|
+
const ended = paymentFromWire(await this.followed(held.id, options));
|
|
2567
3386
|
if (ended === null) {
|
|
2568
3387
|
throw new ProblemError({
|
|
2569
3388
|
status: 200,
|
|
2570
3389
|
title: "The gateway answered with something that is not a payment"
|
|
2571
3390
|
});
|
|
2572
3391
|
}
|
|
2573
|
-
return this.proven(ended);
|
|
3392
|
+
return this.proven(ended, held);
|
|
2574
3393
|
}
|
|
2575
3394
|
followed(id, options) {
|
|
2576
3395
|
const base = this.baseUrl.replace(/^http/, "ws");
|
|
@@ -2632,9 +3451,9 @@ var ThunderBridge = class {
|
|
|
2632
3451
|
if (settled) {
|
|
2633
3452
|
return;
|
|
2634
3453
|
}
|
|
2635
|
-
const
|
|
2636
|
-
socket =
|
|
2637
|
-
|
|
3454
|
+
const opened2 = new WebSocket(url);
|
|
3455
|
+
socket = opened2;
|
|
3456
|
+
opened2.onmessage = (event) => {
|
|
2638
3457
|
try {
|
|
2639
3458
|
const frame = JSON.parse(String(event.data));
|
|
2640
3459
|
const watched = paymentFromWire(frame);
|
|
@@ -2651,7 +3470,7 @@ var ThunderBridge = class {
|
|
|
2651
3470
|
settle(() => reject(refused));
|
|
2652
3471
|
}
|
|
2653
3472
|
};
|
|
2654
|
-
|
|
3473
|
+
opened2.onclose = again;
|
|
2655
3474
|
};
|
|
2656
3475
|
options?.signal?.addEventListener("abort", abort, { once: true });
|
|
2657
3476
|
void connect();
|
|
@@ -2674,25 +3493,33 @@ var ThunderBridge = class {
|
|
|
2674
3493
|
* the recipient's own wallet minted it, so a payer who pays the loser afterwards
|
|
2675
3494
|
* really does pay twice and that shows up on `follow` as a second settlement to
|
|
2676
3495
|
* refund.
|
|
3496
|
+
*
|
|
3497
|
+
* A leg the gateway is caught lying about before any leg is paid ends the wait
|
|
3498
|
+
* with its `GatewayCheatError`, even when another leg is paid after it, so a
|
|
3499
|
+
* detected cheat is never traded for a later win.
|
|
2677
3500
|
*/
|
|
2678
|
-
async firstSettled(
|
|
2679
|
-
if (
|
|
3501
|
+
async firstSettled(held, options) {
|
|
3502
|
+
if (held.length === 0) {
|
|
2680
3503
|
return null;
|
|
2681
3504
|
}
|
|
2682
3505
|
const stopLosers = new AbortController();
|
|
2683
3506
|
const signal = options?.signal ? AbortSignal.any([stopLosers.signal, options.signal]) : stopLosers.signal;
|
|
2684
3507
|
let refused = null;
|
|
2685
3508
|
try {
|
|
2686
|
-
const winner = await new Promise((resolve2) => {
|
|
2687
|
-
let waiting =
|
|
3509
|
+
const winner = await new Promise((resolve2, caught) => {
|
|
3510
|
+
let waiting = held.length;
|
|
2688
3511
|
const lost = () => {
|
|
2689
3512
|
waiting -= 1;
|
|
2690
3513
|
if (waiting === 0) {
|
|
2691
3514
|
resolve2(null);
|
|
2692
3515
|
}
|
|
2693
3516
|
};
|
|
2694
|
-
for (const
|
|
2695
|
-
this.settled(
|
|
3517
|
+
for (const leg of held) {
|
|
3518
|
+
this.settled(leg, { ...options, signal }).then((watched) => watched.status === "paid" ? resolve2(watched) : lost()).catch((failure) => {
|
|
3519
|
+
if (failure instanceof GatewayCheatError) {
|
|
3520
|
+
caught(failure);
|
|
3521
|
+
return;
|
|
3522
|
+
}
|
|
2696
3523
|
refused ??= failure;
|
|
2697
3524
|
lost();
|
|
2698
3525
|
});
|
|
@@ -2736,6 +3563,9 @@ var ThunderBridge = class {
|
|
|
2736
3563
|
if (named !== null && watched.id !== named) {
|
|
2737
3564
|
throw new GatewayCheatError("id_not_mine", watched.id);
|
|
2738
3565
|
}
|
|
3566
|
+
if (watched.paymentHash.toLowerCase() !== handover.paymentHash.toLowerCase()) {
|
|
3567
|
+
throw new GatewayCheatError("hash_mismatch", watched.id);
|
|
3568
|
+
}
|
|
2739
3569
|
return watched;
|
|
2740
3570
|
}
|
|
2741
3571
|
/**
|
|
@@ -2748,6 +3578,69 @@ var ThunderBridge = class {
|
|
|
2748
3578
|
async nameFor(paymentHash) {
|
|
2749
3579
|
return this.secret === null ? null : paymentNamedBy((await this.speaking()).publicKeyHex, paymentHash);
|
|
2750
3580
|
}
|
|
3581
|
+
/**
|
|
3582
|
+
* Hold a socket open and answer what the gateway asks about this caller's own
|
|
3583
|
+
* payments, so a watch addressed to this caller settles without anybody
|
|
3584
|
+
* hosting a URL. Reconnects on its own until the returned function is called
|
|
3585
|
+
*/
|
|
3586
|
+
attend(options) {
|
|
3587
|
+
const base = this.baseUrl.replace(/^http/, "ws");
|
|
3588
|
+
const firstDelay = options.reconnectDelayMs ?? RECONNECT_DELAY_MS;
|
|
3589
|
+
let socket = null;
|
|
3590
|
+
let retry;
|
|
3591
|
+
let stopped = false;
|
|
3592
|
+
let attempt = 0;
|
|
3593
|
+
const again = () => {
|
|
3594
|
+
if (stopped || options.reconnect === false) {
|
|
3595
|
+
return;
|
|
3596
|
+
}
|
|
3597
|
+
retry = setTimeout(() => void connect(), backoffMs(firstDelay, attempt));
|
|
3598
|
+
};
|
|
3599
|
+
const answered3 = async (opened2, raw) => {
|
|
3600
|
+
const asked = askFromWire(raw);
|
|
3601
|
+
if (asked === null) {
|
|
3602
|
+
return;
|
|
3603
|
+
}
|
|
3604
|
+
const preimage = await options.answer(asked.paymentHash);
|
|
3605
|
+
opened2.send(
|
|
3606
|
+
JSON.stringify(
|
|
3607
|
+
preimage === null ? { ask: asked.ask, settled: false } : { ask: asked.ask, settled: true, preimage }
|
|
3608
|
+
)
|
|
3609
|
+
);
|
|
3610
|
+
};
|
|
3611
|
+
const connect = async () => {
|
|
3612
|
+
attempt += 1;
|
|
3613
|
+
let url;
|
|
3614
|
+
try {
|
|
3615
|
+
url = `${base}/ws/tickets/${await this.wsTicket({ agent: true })}`;
|
|
3616
|
+
} catch (refused) {
|
|
3617
|
+
options.onError?.(refused);
|
|
3618
|
+
again();
|
|
3619
|
+
return;
|
|
3620
|
+
}
|
|
3621
|
+
if (stopped) {
|
|
3622
|
+
return;
|
|
3623
|
+
}
|
|
3624
|
+
const opened2 = new WebSocket(url);
|
|
3625
|
+
socket = opened2;
|
|
3626
|
+
opened2.onopen = () => {
|
|
3627
|
+
attempt = 1;
|
|
3628
|
+
};
|
|
3629
|
+
opened2.onmessage = (event) => {
|
|
3630
|
+
void answered3(opened2, String(event.data)).catch(
|
|
3631
|
+
(refused) => options.onError?.(refused)
|
|
3632
|
+
);
|
|
3633
|
+
};
|
|
3634
|
+
opened2.onerror = () => options.onError?.(new Error(`could not attend ${base}`));
|
|
3635
|
+
opened2.onclose = again;
|
|
3636
|
+
};
|
|
3637
|
+
void connect();
|
|
3638
|
+
return () => {
|
|
3639
|
+
stopped = true;
|
|
3640
|
+
clearTimeout(retry);
|
|
3641
|
+
socket?.close();
|
|
3642
|
+
};
|
|
3643
|
+
}
|
|
2751
3644
|
/**
|
|
2752
3645
|
* Follow every payment made to one trigger, replayed from the recent ones on
|
|
2753
3646
|
* connect and then live, reconnecting on its own until the returned function
|
|
@@ -2824,7 +3717,7 @@ var ThunderBridge = class {
|
|
|
2824
3717
|
});
|
|
2825
3718
|
}
|
|
2826
3719
|
needsTicket(asked) {
|
|
2827
|
-
return asked === true || this.token !== null;
|
|
3720
|
+
return asked === true || this.token !== null || this.secret !== null;
|
|
2828
3721
|
}
|
|
2829
3722
|
async wsTicket(body) {
|
|
2830
3723
|
return encodeURIComponent((await this.mintedTicket(body)).ticket);
|
|
@@ -2854,15 +3747,33 @@ var ThunderBridge = class {
|
|
|
2854
3747
|
async reading(method, path, body = "") {
|
|
2855
3748
|
return {
|
|
2856
3749
|
...this.token === null ? {} : { authorization: `Bearer ${this.token}` },
|
|
2857
|
-
...this.secret === null ? {} : await signedAs(await this.speaking(), method, path, body)
|
|
3750
|
+
...this.secret === null ? {} : await signedAs(await this.speaking(), method, path, body, new URL(this.baseUrl).host)
|
|
2858
3751
|
};
|
|
2859
3752
|
}
|
|
2860
3753
|
async speaking() {
|
|
2861
3754
|
this.speaks ??= callerKey(this.secret ?? "");
|
|
2862
3755
|
return await this.speaks;
|
|
2863
3756
|
}
|
|
2864
|
-
|
|
2865
|
-
|
|
3757
|
+
handOut(payment, key) {
|
|
3758
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
3759
|
+
for (const [hash2, earlier2] of this.handedOut) {
|
|
3760
|
+
if (earlier2.expiresAt <= now) {
|
|
3761
|
+
this.handedOut.delete(hash2);
|
|
3762
|
+
}
|
|
3763
|
+
}
|
|
3764
|
+
const hash = payment.paymentHash.toLowerCase();
|
|
3765
|
+
const earlier = this.handedOut.get(hash);
|
|
3766
|
+
if (earlier !== void 0 && (earlier.key === null || earlier.key !== key)) {
|
|
3767
|
+
throw new GatewayCheatError("invoice_reused", payment.id);
|
|
3768
|
+
}
|
|
3769
|
+
this.handedOut.set(hash, { key, expiresAt: payment.expiresAt });
|
|
3770
|
+
}
|
|
3771
|
+
proven(payment, held) {
|
|
3772
|
+
const other = held !== void 0 && (payment.id !== held.id || payment.paymentHash.toLowerCase() !== held.paymentHash.toLowerCase());
|
|
3773
|
+
if (other) {
|
|
3774
|
+
throw new GatewayCheatError("hash_mismatch", payment.id);
|
|
3775
|
+
}
|
|
3776
|
+
if (payment.status === "paid" && !agreesWithItself(payment)) {
|
|
2866
3777
|
throw new GatewayCheatError("preimage_mismatch", payment.id);
|
|
2867
3778
|
}
|
|
2868
3779
|
return payment;
|
|
@@ -2916,6 +3827,7 @@ var Gateways = class {
|
|
|
2916
3827
|
each;
|
|
2917
3828
|
urls;
|
|
2918
3829
|
onRefused;
|
|
3830
|
+
onCaught;
|
|
2919
3831
|
constructor(baseUrls, options) {
|
|
2920
3832
|
if (baseUrls.length === 0) {
|
|
2921
3833
|
throw new Error("name at least one gateway to watch at");
|
|
@@ -2924,6 +3836,8 @@ var Gateways = class {
|
|
|
2924
3836
|
this.each = baseUrls.map((baseUrl) => new ThunderBridge(baseUrl, options));
|
|
2925
3837
|
this.onRefused = options?.onRefused ?? (() => {
|
|
2926
3838
|
});
|
|
3839
|
+
this.onCaught = options?.onCaught ?? (() => {
|
|
3840
|
+
});
|
|
2927
3841
|
}
|
|
2928
3842
|
/** What this payment is called, which every gateway here will agree on */
|
|
2929
3843
|
async nameFor(paymentHash) {
|
|
@@ -2957,7 +3871,7 @@ var Gateways = class {
|
|
|
2957
3871
|
* saying so. When they all end without a payment, the first ending is the answer,
|
|
2958
3872
|
* and when they all fail, the first failure is thrown
|
|
2959
3873
|
*/
|
|
2960
|
-
async settled(
|
|
3874
|
+
async settled(held, options) {
|
|
2961
3875
|
const stopLosers = new AbortController();
|
|
2962
3876
|
const signal = options?.signal ? AbortSignal.any([stopLosers.signal, options.signal]) : stopLosers.signal;
|
|
2963
3877
|
const unpaid = [];
|
|
@@ -2971,14 +3885,17 @@ var Gateways = class {
|
|
|
2971
3885
|
resolve2(null);
|
|
2972
3886
|
}
|
|
2973
3887
|
};
|
|
2974
|
-
for (const gateway of this.each) {
|
|
2975
|
-
gateway.settled(
|
|
3888
|
+
for (const [at, gateway] of this.each.entries()) {
|
|
3889
|
+
gateway.settled(held, { ...options, signal }).then((watched) => {
|
|
2976
3890
|
if (watched.status === "paid") {
|
|
2977
3891
|
return resolve2(watched);
|
|
2978
3892
|
}
|
|
2979
3893
|
unpaid.push(watched);
|
|
2980
3894
|
lost();
|
|
2981
3895
|
}).catch((failure) => {
|
|
3896
|
+
if (failure instanceof GatewayCheatError) {
|
|
3897
|
+
this.onCaught(this.urls[at], failure);
|
|
3898
|
+
}
|
|
2982
3899
|
refused ??= failure;
|
|
2983
3900
|
lost();
|
|
2984
3901
|
});
|
|
@@ -3007,6 +3924,7 @@ var Gateways = class {
|
|
|
3007
3924
|
ThunderBridge,
|
|
3008
3925
|
UnverifiedRecipientError,
|
|
3009
3926
|
WrapRefusedError,
|
|
3927
|
+
agreesWithItself,
|
|
3010
3928
|
answerVerifyChallenge,
|
|
3011
3929
|
carriesProof,
|
|
3012
3930
|
decodeInvoice,
|