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.js
CHANGED
|
@@ -98,11 +98,11 @@ var BLOCK_BYTES = 64;
|
|
|
98
98
|
var LENGTH_BYTES = 8;
|
|
99
99
|
var SCHEDULE_WORDS = 64;
|
|
100
100
|
function sha256(bytes) {
|
|
101
|
-
const
|
|
102
|
-
const reader = new DataView(
|
|
101
|
+
const padded2 = withPadding(bytes);
|
|
102
|
+
const reader = new DataView(padded2.buffer);
|
|
103
103
|
const state = Uint32Array.from(INITIAL_STATE);
|
|
104
104
|
const schedule = new Uint32Array(SCHEDULE_WORDS);
|
|
105
|
-
for (let block = 0; block <
|
|
105
|
+
for (let block = 0; block < padded2.length; block += BLOCK_BYTES) {
|
|
106
106
|
fillSchedule(schedule, reader, block);
|
|
107
107
|
compress(state, schedule);
|
|
108
108
|
}
|
|
@@ -112,13 +112,13 @@ function sha256Hex(text3) {
|
|
|
112
112
|
return bytesToHex(sha256(new TextEncoder().encode(text3)));
|
|
113
113
|
}
|
|
114
114
|
function withPadding(bytes) {
|
|
115
|
-
const
|
|
115
|
+
const padded2 = new Uint8Array(
|
|
116
116
|
bytes.length + 1 + LENGTH_BYTES + BLOCK_BYTES - 1 & ~(BLOCK_BYTES - 1)
|
|
117
117
|
);
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
new DataView(
|
|
121
|
-
return
|
|
118
|
+
padded2.set(bytes);
|
|
119
|
+
padded2[bytes.length] = 128;
|
|
120
|
+
new DataView(padded2.buffer).setUint32(padded2.length - 4, bytes.length * 8, false);
|
|
121
|
+
return padded2;
|
|
122
122
|
}
|
|
123
123
|
function fillSchedule(schedule, reader, block) {
|
|
124
124
|
for (let at = 0; at < 16; at++) {
|
|
@@ -175,6 +175,7 @@ function rotate(value, by) {
|
|
|
175
175
|
|
|
176
176
|
// ../core/bolt11.ts
|
|
177
177
|
var BECH32_CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l";
|
|
178
|
+
var BECH32_GENERATOR = [996825010, 642813549, 513874426, 1027748829, 705979059];
|
|
178
179
|
var CHECKSUM_WORDS = 6;
|
|
179
180
|
var TIMESTAMP_WORDS = 7;
|
|
180
181
|
var SIGNATURE_WORDS = 104;
|
|
@@ -192,18 +193,27 @@ var HRP_MULTIPLIER_MSAT = {
|
|
|
192
193
|
n: MSAT_PER_BTC / 1e9,
|
|
193
194
|
p: MSAT_PER_BTC / 1e12
|
|
194
195
|
};
|
|
195
|
-
var
|
|
196
|
+
var HRP = /^ln(?:bcrt|bc|tbs|tb|sb)(?:(\d+)([munp]?))?$/;
|
|
196
197
|
function decodeInvoice(bolt11) {
|
|
197
198
|
const parts = splitBech32(bolt11);
|
|
198
199
|
if (parts === null) {
|
|
199
|
-
return {
|
|
200
|
+
return {
|
|
201
|
+
paymentHash: null,
|
|
202
|
+
descriptionHash: null,
|
|
203
|
+
amountMsat: null,
|
|
204
|
+
issuedAt: null,
|
|
205
|
+
expiresAt: null
|
|
206
|
+
};
|
|
200
207
|
}
|
|
201
208
|
const tagged = taggedFields(parts.words);
|
|
209
|
+
const issuedAt = issuedAtOf(parts.words);
|
|
210
|
+
const expiry = tagged.get(EXPIRY_TAG);
|
|
202
211
|
return {
|
|
203
212
|
paymentHash: hexTag(tagged, PAYMENT_HASH_TAG, PAYMENT_HASH_WORDS),
|
|
204
213
|
descriptionHash: hexTag(tagged, DESCRIPTION_HASH_TAG, DESCRIPTION_HASH_WORDS),
|
|
205
214
|
amountMsat: amountFromHrp(parts.hrp),
|
|
206
|
-
|
|
215
|
+
issuedAt,
|
|
216
|
+
expiresAt: issuedAt === null ? null : issuedAt + (expiry ? readNumber(expiry) : DEFAULT_EXPIRY_SECS)
|
|
207
217
|
};
|
|
208
218
|
}
|
|
209
219
|
function preimageMatchesHash(preimage, paymentHash) {
|
|
@@ -214,7 +224,7 @@ function preimageMatchesHash(preimage, paymentHash) {
|
|
|
214
224
|
}
|
|
215
225
|
function splitBech32(bolt11) {
|
|
216
226
|
const lower = bolt11.toLowerCase();
|
|
217
|
-
if (isBolt12Encoding(lower)) {
|
|
227
|
+
if (isBolt12Encoding(lower) || bolt11 !== lower && bolt11 !== bolt11.toUpperCase()) {
|
|
218
228
|
return null;
|
|
219
229
|
}
|
|
220
230
|
const separator = lower.lastIndexOf("1");
|
|
@@ -233,7 +243,28 @@ function splitBech32(bolt11) {
|
|
|
233
243
|
}
|
|
234
244
|
words.push(value);
|
|
235
245
|
}
|
|
236
|
-
|
|
246
|
+
const hrp = lower.slice(0, separator);
|
|
247
|
+
if (!HRP.test(hrp) || bech32Polymod([...expandedHrp(hrp), ...words]) !== 1) {
|
|
248
|
+
return null;
|
|
249
|
+
}
|
|
250
|
+
return { hrp, words: words.slice(0, -CHECKSUM_WORDS) };
|
|
251
|
+
}
|
|
252
|
+
function expandedHrp(hrp) {
|
|
253
|
+
const codes = [...hrp].map((char) => char.charCodeAt(0));
|
|
254
|
+
return [...codes.map((code) => code >> 5), 0, ...codes.map((code) => code & 31)];
|
|
255
|
+
}
|
|
256
|
+
function bech32Polymod(values) {
|
|
257
|
+
let checksum = 1;
|
|
258
|
+
for (const value of values) {
|
|
259
|
+
const top = checksum >> 25;
|
|
260
|
+
checksum = (checksum & 33554431) << 5 ^ value;
|
|
261
|
+
for (let bit = 0; bit < 5; bit++) {
|
|
262
|
+
if (top >> bit & 1) {
|
|
263
|
+
checksum ^= BECH32_GENERATOR[bit];
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
return checksum;
|
|
237
268
|
}
|
|
238
269
|
function isBolt12Encoding(lower) {
|
|
239
270
|
return lower.startsWith("lno") || lower.startsWith("lni") || lower.startsWith("lnr");
|
|
@@ -263,19 +294,17 @@ function hexTag(fields, tag, expectedWords) {
|
|
|
263
294
|
}
|
|
264
295
|
return bytesToHex(wordsToBytes(words));
|
|
265
296
|
}
|
|
266
|
-
function
|
|
297
|
+
function issuedAtOf(words) {
|
|
267
298
|
if (words.length < TIMESTAMP_WORDS + SIGNATURE_WORDS) {
|
|
268
299
|
return null;
|
|
269
300
|
}
|
|
270
|
-
|
|
271
|
-
return readNumber(words.slice(0, TIMESTAMP_WORDS)) + (expiry ? readNumber(expiry) : DEFAULT_EXPIRY_SECS);
|
|
301
|
+
return readNumber(words.slice(0, TIMESTAMP_WORDS));
|
|
272
302
|
}
|
|
273
303
|
function amountFromHrp(hrp) {
|
|
274
|
-
const
|
|
275
|
-
if (
|
|
304
|
+
const [, digits, multiplier] = HRP.exec(hrp) ?? [];
|
|
305
|
+
if (digits === void 0 || multiplier === "p" && !digits.endsWith("0")) {
|
|
276
306
|
return null;
|
|
277
307
|
}
|
|
278
|
-
const [, digits, multiplier] = match;
|
|
279
308
|
return multiplier ? Number(digits) * HRP_MULTIPLIER_MSAT[multiplier] : Number(digits) * MSAT_PER_BTC;
|
|
280
309
|
}
|
|
281
310
|
function readNumber(words) {
|
|
@@ -296,49 +325,148 @@ function wordsToBytes(words) {
|
|
|
296
325
|
return Uint8Array.from(bytes);
|
|
297
326
|
}
|
|
298
327
|
|
|
328
|
+
// ../core/hmac.ts
|
|
329
|
+
async function hmacHex(secret, payload) {
|
|
330
|
+
const raw = typeof secret === "string" ? new TextEncoder().encode(secret) : secret;
|
|
331
|
+
const key = await crypto.subtle.importKey(
|
|
332
|
+
"raw",
|
|
333
|
+
asBuffer(raw),
|
|
334
|
+
{ name: "HMAC", hash: "SHA-256" },
|
|
335
|
+
false,
|
|
336
|
+
["sign"]
|
|
337
|
+
);
|
|
338
|
+
const message = typeof payload === "string" ? new TextEncoder().encode(payload) : payload;
|
|
339
|
+
return bytesToHex(new Uint8Array(await crypto.subtle.sign("HMAC", key, asBuffer(message))));
|
|
340
|
+
}
|
|
341
|
+
function equalInConstantTime(one, other) {
|
|
342
|
+
if (one.length !== other.length) {
|
|
343
|
+
return false;
|
|
344
|
+
}
|
|
345
|
+
let difference = 0;
|
|
346
|
+
for (let at = 0; at < one.length; at++) {
|
|
347
|
+
difference |= one.charCodeAt(at) ^ other.charCodeAt(at);
|
|
348
|
+
}
|
|
349
|
+
return difference === 0;
|
|
350
|
+
}
|
|
351
|
+
function asBuffer(bytes) {
|
|
352
|
+
return bytes instanceof Uint8Array && bytes.buffer instanceof ArrayBuffer ? bytes : new Uint8Array(bytes);
|
|
353
|
+
}
|
|
354
|
+
|
|
299
355
|
// ../core/sealed.ts
|
|
300
|
-
var VERSION = "
|
|
356
|
+
var VERSION = "v2";
|
|
357
|
+
var UNPADDED_VERSION = "v1";
|
|
358
|
+
var PURPOSE_VERSION = "v3";
|
|
301
359
|
var IV_BYTES = 12;
|
|
302
360
|
var MIN_SECRET_CHARS = 32;
|
|
303
361
|
var MAX_PLAIN_BYTES = 3e3;
|
|
304
|
-
var
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
362
|
+
var SIZE_CLASSES = [256, 1024, MAX_PLAIN_BYTES];
|
|
363
|
+
var END_OF_TEXT = 128;
|
|
364
|
+
var INFO = "thunder-bridge/sealed";
|
|
365
|
+
async function seal(secret, plaintext, paymentHash) {
|
|
366
|
+
return await sealUnder(
|
|
367
|
+
await keyFor(secret, INFO),
|
|
368
|
+
plaintext,
|
|
369
|
+
crypto.getRandomValues(new Uint8Array(IV_BYTES)),
|
|
370
|
+
VERSION,
|
|
371
|
+
boundTo(paymentHash)
|
|
372
|
+
);
|
|
373
|
+
}
|
|
374
|
+
async function sealFor(purpose, secret, plaintext, { stable = false } = {}) {
|
|
375
|
+
return await sealUnder(
|
|
376
|
+
await keyFor(secret, `${INFO}/${purpose}`),
|
|
377
|
+
plaintext,
|
|
378
|
+
stable ? await nonceOf(secret, plaintext) : crypto.getRandomValues(new Uint8Array(IV_BYTES)),
|
|
379
|
+
PURPOSE_VERSION,
|
|
380
|
+
new TextEncoder().encode(PURPOSE_VERSION)
|
|
381
|
+
);
|
|
382
|
+
}
|
|
383
|
+
async function unsealFor(purpose, secret, sealed) {
|
|
384
|
+
if (!sealed.startsWith(`${PURPOSE_VERSION}.`)) {
|
|
385
|
+
return await unseal(secret, sealed);
|
|
386
|
+
}
|
|
387
|
+
return await opened(
|
|
388
|
+
await keyFor(secret, `${INFO}/${purpose}`),
|
|
389
|
+
sealed.slice(PURPOSE_VERSION.length + 1),
|
|
390
|
+
new TextEncoder().encode(PURPOSE_VERSION)
|
|
391
|
+
);
|
|
392
|
+
}
|
|
393
|
+
async function nonceOf(secret, plaintext) {
|
|
394
|
+
const derived = await hmacHex(secret, `sealed-nonce|${plaintext}`);
|
|
395
|
+
const nonce = new Uint8Array(IV_BYTES);
|
|
396
|
+
for (let at = 0; at < IV_BYTES; at++) {
|
|
397
|
+
nonce[at] = Number.parseInt(derived.slice(at * 2, at * 2 + 2), 16);
|
|
398
|
+
}
|
|
399
|
+
return nonce;
|
|
400
|
+
}
|
|
401
|
+
async function sealUnder(key, plaintext, iv, version, additionalData) {
|
|
402
|
+
const body = padToClass(plaintext);
|
|
403
|
+
const cipher = new Uint8Array(
|
|
404
|
+
await crypto.subtle.encrypt({ name: "AES-GCM", iv, additionalData }, key, body)
|
|
405
|
+
);
|
|
406
|
+
const joined2 = new Uint8Array(iv.length + cipher.length);
|
|
407
|
+
joined2.set(iv);
|
|
408
|
+
joined2.set(cipher, iv.length);
|
|
409
|
+
return `${version}.${toBase64Url(joined2)}`;
|
|
410
|
+
}
|
|
411
|
+
async function unseal(secret, sealed, paymentHash) {
|
|
412
|
+
const key = await keyFor(secret, INFO);
|
|
413
|
+
const padded2 = sealed.startsWith(`${VERSION}.`);
|
|
414
|
+
const unbound = !padded2 && sealed.startsWith(`${UNPADDED_VERSION}.`);
|
|
415
|
+
if (!padded2 && (!unbound || paymentHash !== void 0)) {
|
|
321
416
|
return null;
|
|
322
417
|
}
|
|
323
|
-
|
|
324
|
-
|
|
418
|
+
return await opened(
|
|
419
|
+
key,
|
|
420
|
+
sealed.slice(VERSION.length + 1),
|
|
421
|
+
padded2 ? boundTo(paymentHash) : void 0
|
|
422
|
+
);
|
|
423
|
+
}
|
|
424
|
+
async function opened(key, encoded, additionalData) {
|
|
425
|
+
const joined2 = fromBase64Url(encoded);
|
|
426
|
+
if (joined2 === null || joined2.length <= IV_BYTES) {
|
|
325
427
|
return null;
|
|
326
428
|
}
|
|
327
429
|
try {
|
|
328
|
-
const body =
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
430
|
+
const body = new Uint8Array(
|
|
431
|
+
await crypto.subtle.decrypt(
|
|
432
|
+
{ name: "AES-GCM", iv: joined2.slice(0, IV_BYTES), additionalData },
|
|
433
|
+
key,
|
|
434
|
+
joined2.slice(IV_BYTES)
|
|
435
|
+
)
|
|
332
436
|
);
|
|
333
|
-
|
|
437
|
+
const text3 = additionalData === void 0 ? body : unpad(body);
|
|
438
|
+
return text3 === null ? null : new TextDecoder().decode(text3);
|
|
334
439
|
} catch {
|
|
335
440
|
return null;
|
|
336
441
|
}
|
|
337
442
|
}
|
|
338
|
-
|
|
443
|
+
function refuseAWeakSecret(secret) {
|
|
339
444
|
if (secret.length < MIN_SECRET_CHARS) {
|
|
340
445
|
throw new Error(`the sealing secret needs ${MIN_SECRET_CHARS} characters of randomness`);
|
|
341
446
|
}
|
|
447
|
+
}
|
|
448
|
+
function boundTo(paymentHash) {
|
|
449
|
+
return new TextEncoder().encode(
|
|
450
|
+
paymentHash === void 0 ? VERSION : `${VERSION}|${paymentHash.toLowerCase()}`
|
|
451
|
+
);
|
|
452
|
+
}
|
|
453
|
+
function padToClass(plaintext) {
|
|
454
|
+
const text3 = new TextEncoder().encode(plaintext);
|
|
455
|
+
const size = SIZE_CLASSES.find((limit) => text3.length <= limit);
|
|
456
|
+
if (size === void 0) {
|
|
457
|
+
throw new Error(`sealed takes at most ${MAX_PLAIN_BYTES} bytes, this was ${text3.length}`);
|
|
458
|
+
}
|
|
459
|
+
const body = new Uint8Array(size + 1);
|
|
460
|
+
body.set(text3);
|
|
461
|
+
body[text3.length] = END_OF_TEXT;
|
|
462
|
+
return body;
|
|
463
|
+
}
|
|
464
|
+
function unpad(body) {
|
|
465
|
+
const end = body.lastIndexOf(END_OF_TEXT);
|
|
466
|
+
return end === -1 ? null : body.subarray(0, end);
|
|
467
|
+
}
|
|
468
|
+
async function keyFor(secret, info) {
|
|
469
|
+
refuseAWeakSecret(secret);
|
|
342
470
|
const material = await crypto.subtle.importKey(
|
|
343
471
|
"raw",
|
|
344
472
|
new TextEncoder().encode(secret),
|
|
@@ -347,7 +475,12 @@ async function keyFor(secret) {
|
|
|
347
475
|
["deriveKey"]
|
|
348
476
|
);
|
|
349
477
|
return crypto.subtle.deriveKey(
|
|
350
|
-
{
|
|
478
|
+
{
|
|
479
|
+
name: "HKDF",
|
|
480
|
+
hash: "SHA-256",
|
|
481
|
+
salt: new Uint8Array(0),
|
|
482
|
+
info: new TextEncoder().encode(info)
|
|
483
|
+
},
|
|
351
484
|
material,
|
|
352
485
|
{ name: "AES-GCM", length: 256 },
|
|
353
486
|
false,
|
|
@@ -535,11 +668,11 @@ function coinbase(baseUrl = "https://api.coinbase.com") {
|
|
|
535
668
|
return async (currency) => {
|
|
536
669
|
const asked = currency.toUpperCase();
|
|
537
670
|
const answer = await asJson(`${baseUrl}/v2/prices/BTC-${asked}/spot`, "coinbase");
|
|
538
|
-
const
|
|
539
|
-
if (
|
|
540
|
-
throw new Error(`coinbase quoted ${
|
|
671
|
+
const quoted2 = answer.data;
|
|
672
|
+
if (quoted2?.currency !== asked) {
|
|
673
|
+
throw new Error(`coinbase quoted ${quoted2?.currency} when asked for ${asked}`);
|
|
541
674
|
}
|
|
542
|
-
return asMinor(
|
|
675
|
+
return asMinor(quoted2.amount, "coinbase", asked);
|
|
543
676
|
};
|
|
544
677
|
}
|
|
545
678
|
function kraken(baseUrl = "https://api.kraken.com") {
|
|
@@ -588,19 +721,19 @@ function medianOf(tickers = [coinbase(), kraken(), bitstamp(), coinmate()], opti
|
|
|
588
721
|
return remembered.price;
|
|
589
722
|
}
|
|
590
723
|
const answers = await Promise.allSettled(tickers.map((ticker) => ticker(asked)));
|
|
591
|
-
const
|
|
592
|
-
if (
|
|
724
|
+
const quoted2 = answers.filter((answer) => answer.status === "fulfilled").map((answer) => answer.value).sort((one, other) => one - other);
|
|
725
|
+
if (quoted2.length < minVenues) {
|
|
593
726
|
throw new Error(
|
|
594
|
-
`${
|
|
727
|
+
`${quoted2.length} of ${tickers.length} venues quoted ${asked}, ${minVenues} wanted: ${refusals(answers)}`
|
|
595
728
|
);
|
|
596
729
|
}
|
|
597
|
-
const spread2 = spreadBpsOf(
|
|
730
|
+
const spread2 = spreadBpsOf(quoted2);
|
|
598
731
|
if (spread2 > maxSpread) {
|
|
599
732
|
throw new Error(
|
|
600
|
-
`venues disagree on ${asked} by ${Math.round(spread2)} bps, more than the ${maxSpread} allowed: ${
|
|
733
|
+
`venues disagree on ${asked} by ${Math.round(spread2)} bps, more than the ${maxSpread} allowed: ${quoted2.join(", ")}`
|
|
601
734
|
);
|
|
602
735
|
}
|
|
603
|
-
const price = middleOf(
|
|
736
|
+
const price = middleOf(quoted2);
|
|
604
737
|
held.set(asked, { at: Date.now(), price });
|
|
605
738
|
return price;
|
|
606
739
|
};
|
|
@@ -631,10 +764,10 @@ async function asJson(url, venue) {
|
|
|
631
764
|
}
|
|
632
765
|
return answer.json();
|
|
633
766
|
}
|
|
634
|
-
function asMinor(
|
|
635
|
-
const major2 = Number(
|
|
767
|
+
function asMinor(quoted2, venue, currency) {
|
|
768
|
+
const major2 = Number(quoted2);
|
|
636
769
|
if (!Number.isFinite(major2) || major2 <= 0) {
|
|
637
|
-
throw new Error(`${venue} quoted ${String(
|
|
770
|
+
throw new Error(`${venue} quoted ${String(quoted2)} for ${currency.toUpperCase()}`);
|
|
638
771
|
}
|
|
639
772
|
return Math.round(major2 * minorScaleOf(currency));
|
|
640
773
|
}
|
|
@@ -722,6 +855,16 @@ var SEED_BYTES = 32;
|
|
|
722
855
|
var SIGNATURE_BYTES = 64;
|
|
723
856
|
var PUBLIC_KEY_BYTES = 32;
|
|
724
857
|
var PKCS8_HEADER = hexToBytes("302e020100300506032b657004220420");
|
|
858
|
+
var SIGN_BIT_CLEARED = 127;
|
|
859
|
+
var SMALL_ORDER = [
|
|
860
|
+
"00".repeat(32),
|
|
861
|
+
`01${"00".repeat(31)}`,
|
|
862
|
+
"26e8958fc2b227b045c3f489f2ef98f0d5dfac05d3c63339b13802886d53fc05",
|
|
863
|
+
"c7176a703d4dd84fba3c0b760d10670f2a2053fa2c39ccc64ec7fd7792ac037a",
|
|
864
|
+
`ec${"ff".repeat(30)}7f`,
|
|
865
|
+
`ed${"ff".repeat(30)}7f`,
|
|
866
|
+
`ee${"ff".repeat(30)}7f`
|
|
867
|
+
];
|
|
725
868
|
async function signingKeyFromSeed(seed) {
|
|
726
869
|
if (seed.length !== SEED_BYTES) {
|
|
727
870
|
throw new Error(`an ed25519 seed is ${SEED_BYTES} bytes`);
|
|
@@ -729,24 +872,27 @@ async function signingKeyFromSeed(seed) {
|
|
|
729
872
|
const wrapped = new Uint8Array(PKCS8_HEADER.length + SEED_BYTES);
|
|
730
873
|
wrapped.set(PKCS8_HEADER);
|
|
731
874
|
wrapped.set(seed, PKCS8_HEADER.length);
|
|
732
|
-
const key = await crypto.subtle.importKey("pkcs8",
|
|
875
|
+
const key = await crypto.subtle.importKey("pkcs8", asBuffer2(wrapped), ALGORITHM, true, ["sign"]);
|
|
733
876
|
const jwk = await crypto.subtle.exportKey("jwk", key);
|
|
734
877
|
return {
|
|
735
878
|
publicKeyHex: bytesToHex(fromBase64Url2(jwk.x ?? "")),
|
|
736
|
-
sign: async (payload) => bytesToHex(new Uint8Array(await crypto.subtle.sign(ALGORITHM, key,
|
|
879
|
+
sign: async (payload) => bytesToHex(new Uint8Array(await crypto.subtle.sign(ALGORITHM, key, asBuffer2(payload))))
|
|
737
880
|
};
|
|
738
881
|
}
|
|
739
882
|
async function verifyHex(publicKeyHex, signatureHex, payload) {
|
|
740
883
|
if (!isHex(publicKeyHex) || publicKeyHex.length !== PUBLIC_KEY_BYTES * 2) {
|
|
741
884
|
return false;
|
|
742
885
|
}
|
|
886
|
+
if (hasSmallOrder(publicKeyHex)) {
|
|
887
|
+
return false;
|
|
888
|
+
}
|
|
743
889
|
if (!isHex(signatureHex) || signatureHex.length !== SIGNATURE_BYTES * 2) {
|
|
744
890
|
return false;
|
|
745
891
|
}
|
|
746
892
|
try {
|
|
747
893
|
const key = await crypto.subtle.importKey(
|
|
748
894
|
"raw",
|
|
749
|
-
|
|
895
|
+
asBuffer2(hexToBytes(publicKeyHex)),
|
|
750
896
|
ALGORITHM,
|
|
751
897
|
false,
|
|
752
898
|
["verify"]
|
|
@@ -754,58 +900,38 @@ async function verifyHex(publicKeyHex, signatureHex, payload) {
|
|
|
754
900
|
return await crypto.subtle.verify(
|
|
755
901
|
ALGORITHM,
|
|
756
902
|
key,
|
|
757
|
-
|
|
758
|
-
|
|
903
|
+
asBuffer2(hexToBytes(signatureHex)),
|
|
904
|
+
asBuffer2(payload)
|
|
759
905
|
);
|
|
760
906
|
} catch {
|
|
761
907
|
return false;
|
|
762
908
|
}
|
|
763
909
|
}
|
|
910
|
+
function hasSmallOrder(publicKeyHex) {
|
|
911
|
+
const point = hexToBytes(publicKeyHex);
|
|
912
|
+
point[PUBLIC_KEY_BYTES - 1] = point[PUBLIC_KEY_BYTES - 1] & SIGN_BIT_CLEARED;
|
|
913
|
+
return SMALL_ORDER.includes(bytesToHex(point));
|
|
914
|
+
}
|
|
764
915
|
function fromBase64Url2(text3) {
|
|
765
|
-
const
|
|
766
|
-
const raw = atob(
|
|
916
|
+
const padded2 = text3.replace(/-/g, "+").replace(/_/g, "/");
|
|
917
|
+
const raw = atob(padded2 + "=".repeat((4 - padded2.length % 4) % 4));
|
|
767
918
|
const bytes = new Uint8Array(raw.length);
|
|
768
919
|
for (let at = 0; at < raw.length; at++) {
|
|
769
920
|
bytes[at] = raw.charCodeAt(at);
|
|
770
921
|
}
|
|
771
922
|
return bytes;
|
|
772
923
|
}
|
|
773
|
-
function asBuffer(bytes) {
|
|
774
|
-
return bytes.buffer instanceof ArrayBuffer ? bytes : new Uint8Array(bytes);
|
|
775
|
-
}
|
|
776
|
-
|
|
777
|
-
// ../core/hmac.ts
|
|
778
|
-
async function hmacHex(secret, payload) {
|
|
779
|
-
const raw = typeof secret === "string" ? new TextEncoder().encode(secret) : secret;
|
|
780
|
-
const key = await crypto.subtle.importKey(
|
|
781
|
-
"raw",
|
|
782
|
-
asBuffer2(raw),
|
|
783
|
-
{ name: "HMAC", hash: "SHA-256" },
|
|
784
|
-
false,
|
|
785
|
-
["sign"]
|
|
786
|
-
);
|
|
787
|
-
const message = typeof payload === "string" ? new TextEncoder().encode(payload) : payload;
|
|
788
|
-
return bytesToHex(new Uint8Array(await crypto.subtle.sign("HMAC", key, asBuffer2(message))));
|
|
789
|
-
}
|
|
790
|
-
function equalInConstantTime(one, other) {
|
|
791
|
-
if (one.length !== other.length) {
|
|
792
|
-
return false;
|
|
793
|
-
}
|
|
794
|
-
let difference = 0;
|
|
795
|
-
for (let at = 0; at < one.length; at++) {
|
|
796
|
-
difference |= one.charCodeAt(at) ^ other.charCodeAt(at);
|
|
797
|
-
}
|
|
798
|
-
return difference === 0;
|
|
799
|
-
}
|
|
800
924
|
function asBuffer2(bytes) {
|
|
801
|
-
return bytes
|
|
925
|
+
return bytes.buffer instanceof ArrayBuffer ? bytes : new Uint8Array(bytes);
|
|
802
926
|
}
|
|
803
927
|
|
|
804
928
|
// ../core/caller.ts
|
|
805
929
|
var KEY_HEADER = "x-client-key";
|
|
806
930
|
var SIGNATURE_HEADER = "x-signature";
|
|
807
931
|
var TIMESTAMP_HEADER = "x-timestamp";
|
|
932
|
+
var NONCE_HEADER = "x-nonce";
|
|
808
933
|
var PREFIX = "ed25519=";
|
|
934
|
+
var NONCE_BYTES = 16;
|
|
809
935
|
var SIGNING_LABEL = "request-signing";
|
|
810
936
|
async function callerKey(secret) {
|
|
811
937
|
return await signingKeyFromSeed(hexToBytes(await hmacHex(secret, SIGNING_LABEL)));
|
|
@@ -813,17 +939,21 @@ async function callerKey(secret) {
|
|
|
813
939
|
function paymentNamedBy(caller, paymentHash) {
|
|
814
940
|
return sha256Hex(`${caller}.payment-id.${paymentHash}`);
|
|
815
941
|
}
|
|
816
|
-
async function signedAs(key, method, path, body) {
|
|
942
|
+
async function signedAs(key, method, path, body, host) {
|
|
817
943
|
const timestamp = String(Math.floor(Date.now() / 1e3));
|
|
944
|
+
const nonce = bytesToHex(crypto.getRandomValues(new Uint8Array(NONCE_BYTES)));
|
|
818
945
|
return {
|
|
819
946
|
[KEY_HEADER]: key.publicKeyHex,
|
|
820
947
|
[TIMESTAMP_HEADER]: timestamp,
|
|
821
|
-
[
|
|
948
|
+
[NONCE_HEADER]: nonce,
|
|
949
|
+
[SIGNATURE_HEADER]: `${PREFIX}${await key.sign(spokenTo(host, timestamp, nonce, method, path, body))}`
|
|
822
950
|
};
|
|
823
951
|
}
|
|
824
|
-
function
|
|
952
|
+
function spokenTo(host, timestamp, nonce, method, path, body) {
|
|
825
953
|
return new TextEncoder().encode(
|
|
826
|
-
|
|
954
|
+
["v2", host.toLowerCase(), timestamp, nonce, method.toUpperCase(), path, sha256Hex(body)].join(
|
|
955
|
+
"\n"
|
|
956
|
+
)
|
|
827
957
|
);
|
|
828
958
|
}
|
|
829
959
|
|
|
@@ -876,8 +1006,8 @@ function ipv4IsGlobal(literal) {
|
|
|
876
1006
|
if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) {
|
|
877
1007
|
return false;
|
|
878
1008
|
}
|
|
879
|
-
const [a, b] = parts;
|
|
880
|
-
if (a === 0 || a === 10 || a === 127 || a >=
|
|
1009
|
+
const [a, b, c] = parts;
|
|
1010
|
+
if (a === 0 || a === 10 || a === 127 || a >= 224) {
|
|
881
1011
|
return false;
|
|
882
1012
|
}
|
|
883
1013
|
if (a === 100 && b >= 64 && b < 128) {
|
|
@@ -889,7 +1019,13 @@ function ipv4IsGlobal(literal) {
|
|
|
889
1019
|
if (a === 172 && b >= 16 && b < 32) {
|
|
890
1020
|
return false;
|
|
891
1021
|
}
|
|
892
|
-
if (a === 192 && b === 168) {
|
|
1022
|
+
if (a === 192 && (b === 168 || b === 0 && (c === 0 || c === 2))) {
|
|
1023
|
+
return false;
|
|
1024
|
+
}
|
|
1025
|
+
if (a === 198 && (b === 18 || b === 19 || b === 51 && c === 100)) {
|
|
1026
|
+
return false;
|
|
1027
|
+
}
|
|
1028
|
+
if (a === 203 && b === 0 && c === 113) {
|
|
893
1029
|
return false;
|
|
894
1030
|
}
|
|
895
1031
|
return true;
|
|
@@ -899,29 +1035,59 @@ function ipv6IsGlobal(literal) {
|
|
|
899
1035
|
if (groups.length !== 8 || groups.some(Number.isNaN)) {
|
|
900
1036
|
return false;
|
|
901
1037
|
}
|
|
902
|
-
const
|
|
903
|
-
|
|
1038
|
+
const [first, second, third, fourth, fifth, sixth, high, low] = groups;
|
|
1039
|
+
const embedded = (one, other) => ipv4IsGlobal(`${one >> 8}.${one & 255}.${other >> 8}.${other & 255}`);
|
|
1040
|
+
const zeroBefore = (count) => groups.slice(0, count).every((group) => group === 0);
|
|
1041
|
+
if (zeroBefore(5) && sixth === 65535) {
|
|
1042
|
+
return embedded(high, low);
|
|
1043
|
+
}
|
|
1044
|
+
if (zeroBefore(6) || zeroBefore(4) && fifth === 65535 && sixth === 0) {
|
|
1045
|
+
return false;
|
|
1046
|
+
}
|
|
1047
|
+
if (first === 100 && second === 65435) {
|
|
1048
|
+
return third === 0 && fourth === 0 && fifth === 0 && sixth === 0 && embedded(high, low);
|
|
1049
|
+
}
|
|
1050
|
+
if (first === 8194) {
|
|
1051
|
+
return embedded(second, third);
|
|
1052
|
+
}
|
|
1053
|
+
if (first === 8193 && second === 0) {
|
|
904
1054
|
return false;
|
|
905
1055
|
}
|
|
906
|
-
if (
|
|
907
|
-
|
|
908
|
-
return ipv4IsGlobal(`${high >> 8}.${high & 255}.${low >> 8}.${low & 255}`);
|
|
1056
|
+
if (first === 256 && second === 0 && third === 0 && fourth === 0) {
|
|
1057
|
+
return false;
|
|
909
1058
|
}
|
|
910
|
-
|
|
911
|
-
if ((first & 65024) === 64512) {
|
|
1059
|
+
if ((first & 65024) === 64512 || (first & 65280) === 65280) {
|
|
912
1060
|
return false;
|
|
913
1061
|
}
|
|
914
|
-
if ((first & 65472) === 65152) {
|
|
1062
|
+
if ((first & 65472) === 65152 || (first & 65472) === 65216) {
|
|
915
1063
|
return false;
|
|
916
1064
|
}
|
|
917
1065
|
return true;
|
|
918
1066
|
}
|
|
919
1067
|
function ipv6Groups(address) {
|
|
920
|
-
const
|
|
1068
|
+
const halves = withDottedTailAsGroups(address).split("::");
|
|
1069
|
+
const [head = "", tail] = halves;
|
|
921
1070
|
const left = head.split(":").filter((group) => group !== "");
|
|
922
1071
|
const right = (tail ?? "").split(":").filter((group) => group !== "");
|
|
923
|
-
const
|
|
924
|
-
|
|
1072
|
+
const missing = 8 - left.length - right.length;
|
|
1073
|
+
if (halves.length > 2 || missing < 0) {
|
|
1074
|
+
return [Number.NaN];
|
|
1075
|
+
}
|
|
1076
|
+
const gap = tail === void 0 ? [] : Array(missing).fill("0");
|
|
1077
|
+
return [...left, ...gap, ...right].map(
|
|
1078
|
+
(group) => /^[0-9a-f]{1,4}$/.test(group) ? parseInt(group, 16) : Number.NaN
|
|
1079
|
+
);
|
|
1080
|
+
}
|
|
1081
|
+
function withDottedTailAsGroups(address) {
|
|
1082
|
+
const dotted = /^(.*:)(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/.exec(address);
|
|
1083
|
+
if (dotted === null) {
|
|
1084
|
+
return address;
|
|
1085
|
+
}
|
|
1086
|
+
const [a, b, c, d] = dotted[2].split(".").map(Number);
|
|
1087
|
+
if ([a, b, c, d].some((part) => part > 255)) {
|
|
1088
|
+
return `${dotted[1]}x`;
|
|
1089
|
+
}
|
|
1090
|
+
return `${dotted[1]}${(a << 8 | b).toString(16)}:${(c << 8 | d).toString(16)}`;
|
|
925
1091
|
}
|
|
926
1092
|
|
|
927
1093
|
// ../core/outbound.ts
|
|
@@ -949,6 +1115,9 @@ async function ask(send, url, sent = {}) {
|
|
|
949
1115
|
}
|
|
950
1116
|
await response.body?.cancel();
|
|
951
1117
|
const next = new URL(location, target).toString();
|
|
1118
|
+
if (sent.staysOnOrigin && !sameOrigin(target, next)) {
|
|
1119
|
+
throw new Error(`${target} redirected off its own origin, to ${next}`);
|
|
1120
|
+
}
|
|
952
1121
|
carried = KEEPS_THE_METHOD.includes(response.status) ? carried : withoutBody(carried);
|
|
953
1122
|
carried = sameOrigin(target, next) ? carried : withoutCredentials(carried);
|
|
954
1123
|
target = next;
|
|
@@ -966,14 +1135,39 @@ async function addressesToReach(url) {
|
|
|
966
1135
|
return found;
|
|
967
1136
|
}
|
|
968
1137
|
async function resolved(url) {
|
|
969
|
-
const {
|
|
970
|
-
const { setTimeout: sleep } = await import("timers/promises");
|
|
1138
|
+
const { isIP } = await import("net");
|
|
971
1139
|
const host = new URL(url).hostname.replace(/^\[|]$/g, "");
|
|
1140
|
+
const literal = isIP(host);
|
|
1141
|
+
if (literal !== 0) {
|
|
1142
|
+
return [{ address: host, family: literal }];
|
|
1143
|
+
}
|
|
1144
|
+
const { Resolver } = await import("dns/promises");
|
|
1145
|
+
const { setTimeout: sleep } = await import("timers/promises");
|
|
1146
|
+
const resolver = new Resolver({ timeout: LOOKUP_TIMEOUT_MS, tries: 1 });
|
|
972
1147
|
const found = await Promise.race([
|
|
973
|
-
|
|
1148
|
+
Promise.all([
|
|
1149
|
+
resolver.resolve4(host).catch(() => []),
|
|
1150
|
+
resolver.resolve6(host).catch(() => [])
|
|
1151
|
+
]),
|
|
974
1152
|
sleep(LOOKUP_TIMEOUT_MS, null, { ref: false })
|
|
975
1153
|
]);
|
|
976
|
-
|
|
1154
|
+
cancelled(resolver);
|
|
1155
|
+
if (found === null) {
|
|
1156
|
+
return [];
|
|
1157
|
+
}
|
|
1158
|
+
const [four, six] = found;
|
|
1159
|
+
return [
|
|
1160
|
+
...four.map((address) => ({ address, family: 4 })),
|
|
1161
|
+
...six.map((address) => ({ address, family: 6 }))
|
|
1162
|
+
];
|
|
1163
|
+
}
|
|
1164
|
+
function cancelled(resolver) {
|
|
1165
|
+
try {
|
|
1166
|
+
resolver.cancel();
|
|
1167
|
+
return true;
|
|
1168
|
+
} catch {
|
|
1169
|
+
return false;
|
|
1170
|
+
}
|
|
977
1171
|
}
|
|
978
1172
|
async function answerOf(response) {
|
|
979
1173
|
const { status, ok, headers } = response;
|
|
@@ -995,15 +1189,17 @@ async function answerOf(response) {
|
|
|
995
1189
|
return { status, ok, body: text(read), truncated: true, headers };
|
|
996
1190
|
}
|
|
997
1191
|
function withoutBody(sent) {
|
|
998
|
-
const headers =
|
|
999
|
-
|
|
1192
|
+
const headers = Object.fromEntries(
|
|
1193
|
+
Object.entries(sent.headers ?? {}).filter(([name]) => name.toLowerCase() !== "content-type")
|
|
1194
|
+
);
|
|
1000
1195
|
return { ...sent, method: "GET", headers, body: void 0 };
|
|
1001
1196
|
}
|
|
1002
1197
|
function withoutCredentials(sent) {
|
|
1003
|
-
const headers =
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1198
|
+
const headers = Object.fromEntries(
|
|
1199
|
+
Object.entries(sent.headers ?? {}).filter(
|
|
1200
|
+
([name]) => !CREDENTIALS.includes(name.toLowerCase())
|
|
1201
|
+
)
|
|
1202
|
+
);
|
|
1007
1203
|
return { ...sent, headers };
|
|
1008
1204
|
}
|
|
1009
1205
|
function text(chunks) {
|
|
@@ -1046,6 +1242,22 @@ async function resolve(send, addresses, amountMsat) {
|
|
|
1046
1242
|
);
|
|
1047
1243
|
return served.won;
|
|
1048
1244
|
}
|
|
1245
|
+
async function quote(send, addresses, amountMsat) {
|
|
1246
|
+
const served = await firstThatServes(
|
|
1247
|
+
addresses,
|
|
1248
|
+
(address2, deadline) => probe(send, address2, amountMsat, deadline)
|
|
1249
|
+
);
|
|
1250
|
+
const { address, pay } = served.won;
|
|
1251
|
+
return {
|
|
1252
|
+
won: {
|
|
1253
|
+
address,
|
|
1254
|
+
minMsat: pay.minSendable,
|
|
1255
|
+
maxMsat: pay.maxSendable,
|
|
1256
|
+
metadata: pay.metadata
|
|
1257
|
+
},
|
|
1258
|
+
refusals: served.refusals
|
|
1259
|
+
};
|
|
1260
|
+
}
|
|
1049
1261
|
async function firstThatServes(addresses, attempt) {
|
|
1050
1262
|
const deadline = AbortSignal.timeout(RESOLVE_TIMEOUT_MS);
|
|
1051
1263
|
const refusals3 = [];
|
|
@@ -1138,7 +1350,7 @@ function decodeIssued(address, bolt11) {
|
|
|
1138
1350
|
return { paymentHash, descriptionHash, amountMsat, expiresAt };
|
|
1139
1351
|
}
|
|
1140
1352
|
async function checkSettled(send, verifyUrl, paymentHash) {
|
|
1141
|
-
const answer = await answeredJson(send, verifyUrl);
|
|
1353
|
+
const answer = await answeredJson(send, verifyUrl, { staysOnOrigin: true });
|
|
1142
1354
|
const asked = {
|
|
1143
1355
|
pace: paceAskedFor(answer.headers),
|
|
1144
1356
|
ceiling: ceilingAskedFor(answer.headers)
|
|
@@ -1196,10 +1408,10 @@ function isNothingButAHost(domain) {
|
|
|
1196
1408
|
}
|
|
1197
1409
|
}
|
|
1198
1410
|
async function fetchJson(send, url, deadline) {
|
|
1199
|
-
return (await answeredJson(send, url, deadline)).said;
|
|
1411
|
+
return (await answeredJson(send, url, { deadline })).said;
|
|
1200
1412
|
}
|
|
1201
|
-
async function answeredJson(send, url,
|
|
1202
|
-
const answer = await ask(send, url, { headers: { accept: "application/json" },
|
|
1413
|
+
async function answeredJson(send, url, sent) {
|
|
1414
|
+
const answer = await ask(send, url, { headers: { accept: "application/json" }, ...sent });
|
|
1203
1415
|
if (!answer.ok) {
|
|
1204
1416
|
throw new Error(`${url} answered ${answer.status}`);
|
|
1205
1417
|
}
|
|
@@ -1227,12 +1439,17 @@ var pinnedToTheAddressWeVerified = async (url, sent, signal, at) => {
|
|
|
1227
1439
|
}
|
|
1228
1440
|
const call = request(reaching(asked, sent, at, first), (answer) => {
|
|
1229
1441
|
const status = answer.statusCode ?? 502;
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1442
|
+
try {
|
|
1443
|
+
settle(
|
|
1444
|
+
new Response(
|
|
1445
|
+
CARRIES_NO_BODY.includes(status) ? null : Readable.toWeb(answer),
|
|
1446
|
+
{ status, headers: headersOf(answer.headers) }
|
|
1447
|
+
)
|
|
1448
|
+
);
|
|
1449
|
+
} catch (unreadable) {
|
|
1450
|
+
answer.destroy();
|
|
1451
|
+
fail(new Error(`${url} answered with something HTTP cannot carry`, { cause: unreadable }));
|
|
1452
|
+
}
|
|
1236
1453
|
});
|
|
1237
1454
|
const give = () => call.destroy(new Error(`${url} took longer than allowed`));
|
|
1238
1455
|
signal.addEventListener("abort", give, { once: true });
|
|
@@ -1279,6 +1496,182 @@ function headersOf(raw) {
|
|
|
1279
1496
|
return headers;
|
|
1280
1497
|
}
|
|
1281
1498
|
|
|
1499
|
+
// ../core/delivery.ts
|
|
1500
|
+
function deliverySigned(url, timestamp, body) {
|
|
1501
|
+
const endpoint = new URL(url);
|
|
1502
|
+
return new TextEncoder().encode(
|
|
1503
|
+
["v2", `${endpoint.origin}${endpoint.pathname}`, timestamp, body].join("\n")
|
|
1504
|
+
);
|
|
1505
|
+
}
|
|
1506
|
+
|
|
1507
|
+
// src/verify.ts
|
|
1508
|
+
var HTTP_TIMEOUT_MS2 = 15e3;
|
|
1509
|
+
var ISSUED_SKEW_SECS = 300;
|
|
1510
|
+
var NAMES_AN_ADDRESS = ["text/identifier", "text/email"];
|
|
1511
|
+
var DEFAULT_WRAP_PROPORTION = 0.01;
|
|
1512
|
+
var DEFAULT_WRAP_BASE_MSAT = 1e3;
|
|
1513
|
+
async function proveOrigin(payment, asked, askedAt) {
|
|
1514
|
+
const cheat = (code) => new GatewayCheatError(code, payment.id);
|
|
1515
|
+
const listed2 = asked.paidTo.find((address) => equalIgnoringCase(address, payment.lnAddress));
|
|
1516
|
+
if (listed2 === void 0) {
|
|
1517
|
+
throw cheat("address_not_requested");
|
|
1518
|
+
}
|
|
1519
|
+
if (payment.amountMsat !== asked.amountMsat) {
|
|
1520
|
+
throw cheat("amount_mismatch");
|
|
1521
|
+
}
|
|
1522
|
+
const invoice = decodeInvoice(payment.bolt11);
|
|
1523
|
+
if (invoice.paymentHash === null || !equalIgnoringCase(invoice.paymentHash, payment.paymentHash)) {
|
|
1524
|
+
throw cheat("hash_mismatch");
|
|
1525
|
+
}
|
|
1526
|
+
if (invoice.amountMsat !== asked.amountMsat) {
|
|
1527
|
+
throw cheat("amount_mismatch");
|
|
1528
|
+
}
|
|
1529
|
+
if (askedAt !== void 0 && (invoice.issuedAt ?? 0) < askedAt - ISSUED_SKEW_SECS) {
|
|
1530
|
+
throw cheat("invoice_stale");
|
|
1531
|
+
}
|
|
1532
|
+
const payRequest = await payRequestFor(listed2, payment);
|
|
1533
|
+
if (typeof payRequest.metadata !== "string" || typeof payRequest.callback !== "string") {
|
|
1534
|
+
throw new UnverifiedRecipientError(payment.lnAddress, payment.id, "no payRequest served");
|
|
1535
|
+
}
|
|
1536
|
+
if (!namesTheAddress(payRequest.metadata, listed2)) {
|
|
1537
|
+
throw new UnverifiedRecipientError(
|
|
1538
|
+
payment.lnAddress,
|
|
1539
|
+
payment.id,
|
|
1540
|
+
"its metadata names no text/identifier for this address, so no invoice binds to it"
|
|
1541
|
+
);
|
|
1542
|
+
}
|
|
1543
|
+
if (invoice.descriptionHash !== sha256Hex(payRequest.metadata)) {
|
|
1544
|
+
throw cheat("description_hash_mismatch");
|
|
1545
|
+
}
|
|
1546
|
+
if (!sameOrigin(payment.verifyUrl, payRequest.callback)) {
|
|
1547
|
+
throw cheat("verify_url_foreign");
|
|
1548
|
+
}
|
|
1549
|
+
const issued = await reachable(payment.verifyUrl, payment);
|
|
1550
|
+
if (typeof issued.pr !== "string" || !equalIgnoringCase(issued.pr, payment.bolt11)) {
|
|
1551
|
+
throw cheat("invoice_not_issued");
|
|
1552
|
+
}
|
|
1553
|
+
if (askedAt !== void 0 && issued.settled === true) {
|
|
1554
|
+
throw cheat("invoice_settled");
|
|
1555
|
+
}
|
|
1556
|
+
}
|
|
1557
|
+
function namesTheAddress(metadata, address) {
|
|
1558
|
+
try {
|
|
1559
|
+
const entries = JSON.parse(metadata);
|
|
1560
|
+
return Array.isArray(entries) && entries.some(
|
|
1561
|
+
(entry) => Array.isArray(entry) && NAMES_AN_ADDRESS.includes(entry[0]) && typeof entry[1] === "string" && equalIgnoringCase(entry[1], address)
|
|
1562
|
+
);
|
|
1563
|
+
} catch {
|
|
1564
|
+
return false;
|
|
1565
|
+
}
|
|
1566
|
+
}
|
|
1567
|
+
async function proveSettlement(payment, asked) {
|
|
1568
|
+
await proveOrigin(payment, asked);
|
|
1569
|
+
const verified = await reachable(payment.verifyUrl, payment);
|
|
1570
|
+
if (verified.settled !== true || typeof verified.preimage !== "string") {
|
|
1571
|
+
return null;
|
|
1572
|
+
}
|
|
1573
|
+
if (!preimageMatchesHash(verified.preimage, payment.paymentHash)) {
|
|
1574
|
+
throw new GatewayCheatError("preimage_mismatch", payment.id);
|
|
1575
|
+
}
|
|
1576
|
+
return verified.preimage;
|
|
1577
|
+
}
|
|
1578
|
+
function agreesWithItself(report) {
|
|
1579
|
+
if (report.status !== "paid" || report.preimage === null) {
|
|
1580
|
+
return false;
|
|
1581
|
+
}
|
|
1582
|
+
if (report.bolt11 !== void 0 && report.bolt11 !== null) {
|
|
1583
|
+
const invoiceHash = decodeInvoice(report.bolt11).paymentHash;
|
|
1584
|
+
if (invoiceHash === null || !equalIgnoringCase(invoiceHash, report.paymentHash)) {
|
|
1585
|
+
return false;
|
|
1586
|
+
}
|
|
1587
|
+
}
|
|
1588
|
+
return preimageMatchesHash(report.preimage, report.paymentHash);
|
|
1589
|
+
}
|
|
1590
|
+
var carriesProof = agreesWithItself;
|
|
1591
|
+
function wrapFeeCeiling(amountMsat, allowance) {
|
|
1592
|
+
const proportional = amountMsat * (allowance?.proportion ?? DEFAULT_WRAP_PROPORTION);
|
|
1593
|
+
return msat(Math.max(allowance?.baseMsat ?? DEFAULT_WRAP_BASE_MSAT, Math.floor(proportional)));
|
|
1594
|
+
}
|
|
1595
|
+
function proveWrapped(wrapped, recipient, allowance) {
|
|
1596
|
+
const offered = decodeInvoice(wrapped);
|
|
1597
|
+
const owed = decodeInvoice(recipient);
|
|
1598
|
+
const refuse2 = (code, detail) => {
|
|
1599
|
+
throw new WrapRefusedError(code, detail);
|
|
1600
|
+
};
|
|
1601
|
+
if (offered.paymentHash === null || offered.amountMsat === null || offered.expiresAt === null) {
|
|
1602
|
+
refuse2("undecodable", "the wrapped invoice carries no hash, amount or expiry");
|
|
1603
|
+
}
|
|
1604
|
+
if (owed.paymentHash === null || owed.amountMsat === null || owed.expiresAt === null) {
|
|
1605
|
+
refuse2("undecodable", "the recipient's invoice carries no hash, amount or expiry");
|
|
1606
|
+
}
|
|
1607
|
+
if (offered.paymentHash?.toLowerCase() !== owed.paymentHash?.toLowerCase()) {
|
|
1608
|
+
refuse2("hash_mismatch", "settling the wrap would not settle the recipient");
|
|
1609
|
+
}
|
|
1610
|
+
const asked = offered.amountMsat ?? 0;
|
|
1611
|
+
const due = owed.amountMsat ?? 0;
|
|
1612
|
+
if (asked < due) {
|
|
1613
|
+
refuse2("amount_below_recipient", `${asked} msat cannot forward ${due} msat`);
|
|
1614
|
+
}
|
|
1615
|
+
const ceiling = wrapFeeCeiling(due, allowance);
|
|
1616
|
+
if (asked - due > ceiling) {
|
|
1617
|
+
refuse2("fee_above_allowance", `${asked - due} msat over ${due}, and ${ceiling} was allowed`);
|
|
1618
|
+
}
|
|
1619
|
+
if ((owed.expiresAt ?? 0) <= (offered.expiresAt ?? 0)) {
|
|
1620
|
+
refuse2("recipient_expires_first", "the operator could be paid and find it cannot deliver");
|
|
1621
|
+
}
|
|
1622
|
+
}
|
|
1623
|
+
async function payRequestFor(listed2, payment) {
|
|
1624
|
+
const at = listed2.indexOf("@");
|
|
1625
|
+
const name = listed2.slice(0, at);
|
|
1626
|
+
const domain = listed2.slice(at + 1).toLowerCase();
|
|
1627
|
+
if (at < 1 || !publicHttps(`https://${domain}/`)) {
|
|
1628
|
+
throw new UnverifiedRecipientError(payment.lnAddress, payment.id, "not a usable address");
|
|
1629
|
+
}
|
|
1630
|
+
return reachable(`https://${domain}/.well-known/lnurlp/${name}`, payment);
|
|
1631
|
+
}
|
|
1632
|
+
async function reachable(url, payment) {
|
|
1633
|
+
try {
|
|
1634
|
+
if (!publicHttps(url)) {
|
|
1635
|
+
throw new Error(`${url} is not a public https URL`);
|
|
1636
|
+
}
|
|
1637
|
+
const response = await fetch(url, {
|
|
1638
|
+
signal: AbortSignal.timeout(HTTP_TIMEOUT_MS2),
|
|
1639
|
+
headers: { accept: "application/json" }
|
|
1640
|
+
});
|
|
1641
|
+
if (response.redirected && !sameOrigin(response.url, url)) {
|
|
1642
|
+
throw new Error(`redirected off its own origin, to ${response.url}`);
|
|
1643
|
+
}
|
|
1644
|
+
if (!response.ok) {
|
|
1645
|
+
throw new Error(`answered ${response.status}`);
|
|
1646
|
+
}
|
|
1647
|
+
const body = JSON.parse(await boundedText(response));
|
|
1648
|
+
if (body === null || typeof body !== "object") {
|
|
1649
|
+
throw new Error("answered with no JSON object");
|
|
1650
|
+
}
|
|
1651
|
+
return body;
|
|
1652
|
+
} catch (cause) {
|
|
1653
|
+
throw new UnverifiedRecipientError(payment.lnAddress, payment.id, cause);
|
|
1654
|
+
}
|
|
1655
|
+
}
|
|
1656
|
+
async function boundedText(response) {
|
|
1657
|
+
const reader = response.body?.getReader();
|
|
1658
|
+
const decoder = new TextDecoder();
|
|
1659
|
+
let text3 = "";
|
|
1660
|
+
let bytes = 0;
|
|
1661
|
+
for (let chunk = await reader?.read(); chunk && !chunk.done; chunk = await reader?.read()) {
|
|
1662
|
+
bytes += chunk.value.length;
|
|
1663
|
+
if (bytes > BODY_LIMIT_BYTES) {
|
|
1664
|
+
await reader?.cancel();
|
|
1665
|
+
throw new Error(`answered with more than the ${BODY_LIMIT_BYTES} bytes a proof reads`);
|
|
1666
|
+
}
|
|
1667
|
+
text3 += decoder.decode(chunk.value, { stream: true });
|
|
1668
|
+
}
|
|
1669
|
+
return text3 + decoder.decode();
|
|
1670
|
+
}
|
|
1671
|
+
function equalIgnoringCase(one, other) {
|
|
1672
|
+
return one.toLowerCase() === other.toLowerCase();
|
|
1673
|
+
}
|
|
1674
|
+
|
|
1282
1675
|
// src/wire.ts
|
|
1283
1676
|
var ASSET_CODE = "BTC";
|
|
1284
1677
|
var ASSET_SCALE = 11;
|
|
@@ -1476,7 +1869,7 @@ function isStatus(value) {
|
|
|
1476
1869
|
}
|
|
1477
1870
|
|
|
1478
1871
|
// src/webhook.ts
|
|
1479
|
-
var SIGNATURE_HEADER2 = "x-signature";
|
|
1872
|
+
var SIGNATURE_HEADER2 = "x-signature-v2";
|
|
1480
1873
|
var TIMESTAMP_HEADER2 = "x-timestamp";
|
|
1481
1874
|
var GATEWAY_KEY_PREFIX = "ed25519=";
|
|
1482
1875
|
var DEFAULT_TOLERANCE_SECS = 300;
|
|
@@ -1484,22 +1877,22 @@ var CHALLENGE = "webhook-challenge";
|
|
|
1484
1877
|
var VERIFY_CHALLENGE = "verify-challenge";
|
|
1485
1878
|
async function readPayment(request, credential, options = {}) {
|
|
1486
1879
|
const body = await believable(request, credential, options);
|
|
1487
|
-
return body === null ? null : decoded(body, paymentFromWire);
|
|
1880
|
+
return body === null ? null : proved(decoded(body, paymentFromWire));
|
|
1488
1881
|
}
|
|
1489
1882
|
async function readSettlement(request, credential, options = {}) {
|
|
1490
1883
|
const body = await believable(request, credential, options);
|
|
1491
|
-
return body === null ? null : decoded(body, settlementFromWire);
|
|
1884
|
+
return body === null ? null : proved(decoded(body, settlementFromWire));
|
|
1492
1885
|
}
|
|
1493
1886
|
async function answerWebhookChallenge(request, credential, options = {}) {
|
|
1494
1887
|
const body = await believable(request, credential, options);
|
|
1495
|
-
const nonce = body === null ? null :
|
|
1888
|
+
const nonce = body === null ? null : nonceOf2(body, CHALLENGE);
|
|
1496
1889
|
return nonce === null ? null : answered2(nonce);
|
|
1497
1890
|
}
|
|
1498
1891
|
async function answerVerifyChallenge(request) {
|
|
1499
1892
|
if (request.method !== "POST") {
|
|
1500
1893
|
return null;
|
|
1501
1894
|
}
|
|
1502
|
-
const nonce =
|
|
1895
|
+
const nonce = nonceOf2(await request.clone().text(), VERIFY_CHALLENGE);
|
|
1503
1896
|
return nonce === null ? null : answered2(nonce);
|
|
1504
1897
|
}
|
|
1505
1898
|
async function believable(request, credential, options) {
|
|
@@ -1518,10 +1911,13 @@ async function believable(request, credential, options) {
|
|
|
1518
1911
|
const signed = await verifyHex(
|
|
1519
1912
|
credential.publicKey.toLowerCase(),
|
|
1520
1913
|
signature.slice(GATEWAY_KEY_PREFIX.length).toLowerCase(),
|
|
1521
|
-
|
|
1914
|
+
deliverySigned(options.url ?? request.url, timestamp, body)
|
|
1522
1915
|
);
|
|
1523
1916
|
return signed ? body : null;
|
|
1524
1917
|
}
|
|
1918
|
+
function proved(read) {
|
|
1919
|
+
return read !== null && read.status === "paid" && !agreesWithItself(read) ? null : read;
|
|
1920
|
+
}
|
|
1525
1921
|
function decoded(body, from) {
|
|
1526
1922
|
try {
|
|
1527
1923
|
return from(JSON.parse(body));
|
|
@@ -1534,7 +1930,7 @@ function answered2(nonce) {
|
|
|
1534
1930
|
headers: { "content-type": "application/json" }
|
|
1535
1931
|
});
|
|
1536
1932
|
}
|
|
1537
|
-
function
|
|
1933
|
+
function nonceOf2(text3, type) {
|
|
1538
1934
|
try {
|
|
1539
1935
|
const said = JSON.parse(text3);
|
|
1540
1936
|
if (said["type"] !== type || typeof said["nonce"] !== "string") {
|
|
@@ -1552,9 +1948,6 @@ function recent(timestamp, toleranceSecs) {
|
|
|
1552
1948
|
}
|
|
1553
1949
|
return Math.abs(Math.floor(Date.now() / 1e3) - sent) <= toleranceSecs;
|
|
1554
1950
|
}
|
|
1555
|
-
function stamped(timestamp, body) {
|
|
1556
|
-
return new TextEncoder().encode(`${timestamp}.${body}`);
|
|
1557
|
-
}
|
|
1558
1951
|
|
|
1559
1952
|
// src/bank.ts
|
|
1560
1953
|
var DEFAULT_CURRENCY = "CZK";
|
|
@@ -1562,30 +1955,39 @@ var DEFAULT_LOOK_BACK_SECS = 7 * 24 * 60 * 60;
|
|
|
1562
1955
|
var DEFAULT_POLL_EVERY_SECS = 30;
|
|
1563
1956
|
var IBAN = /^[A-Z]{2}[0-9]{2}[0-9A-Z]{8,30}$/;
|
|
1564
1957
|
var FORBIDDEN_IN_SPD = "*";
|
|
1958
|
+
var MAX_REFERENCE_CHARS = 60;
|
|
1565
1959
|
async function bankTransfer(gateway, params) {
|
|
1566
1960
|
const currency = params.currency ?? DEFAULT_CURRENCY;
|
|
1567
|
-
|
|
1961
|
+
const iban = accountOf(params.iban);
|
|
1962
|
+
refuseUnusable(params, iban, currency);
|
|
1568
1963
|
await refuseAnOpenGateway(gateway, params);
|
|
1569
|
-
const spd = shortPaymentDescriptor(params, currency);
|
|
1570
|
-
const subject = subjectOf(
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1964
|
+
const spd = shortPaymentDescriptor(params, iban, currency);
|
|
1965
|
+
const subject = subjectOf({
|
|
1966
|
+
iban,
|
|
1967
|
+
amountMinor: params.amountMinor,
|
|
1968
|
+
currency,
|
|
1969
|
+
expiresAt: params.expiresAt,
|
|
1970
|
+
reference: params.reference
|
|
1971
|
+
});
|
|
1972
|
+
const verifyUrl = await answeredAt(params, subject);
|
|
1973
|
+
const paymentHash = hashOf(await hmacHex(params.secret, `preimage|${subject}`));
|
|
1577
1974
|
const watched = await gateway.watch({
|
|
1578
|
-
paymentHash
|
|
1975
|
+
paymentHash,
|
|
1579
1976
|
verifyUrl,
|
|
1580
1977
|
expiresAt: params.expiresAt,
|
|
1581
1978
|
trigger: params.trigger,
|
|
1582
1979
|
replay: params.replay,
|
|
1583
|
-
sealed: params.sealed,
|
|
1980
|
+
sealed: params.sealed ? await seal(params.sealed.secret, JSON.stringify(params.sealed.data), paymentHash) : void 0,
|
|
1584
1981
|
webhookUrl: params.webhookUrl
|
|
1585
1982
|
});
|
|
1586
|
-
return { id: watched.id, paymentHash
|
|
1983
|
+
return { id: watched.id, paymentHash, verifyUrl, spd };
|
|
1587
1984
|
}
|
|
1588
1985
|
function bankVerifyEndpoint(config) {
|
|
1986
|
+
refuseAWeakSecret(config.secret);
|
|
1987
|
+
const answersFor = accountOf(config.iban);
|
|
1988
|
+
if (!IBAN.test(answersFor)) {
|
|
1989
|
+
throw new Error(`${config.iban} is not an IBAN, so this endpoint answers for no account`);
|
|
1990
|
+
}
|
|
1589
1991
|
const paced = {
|
|
1590
1992
|
"cache-control": `max-age=${config.pollEverySecs ?? DEFAULT_POLL_EVERY_SECS}`
|
|
1591
1993
|
};
|
|
@@ -1594,51 +1996,82 @@ function bankVerifyEndpoint(config) {
|
|
|
1594
1996
|
if (consented !== null) {
|
|
1595
1997
|
return consented;
|
|
1596
1998
|
}
|
|
1597
|
-
const
|
|
1598
|
-
if (
|
|
1999
|
+
const sealed = new URL(request.url).searchParams.get("q");
|
|
2000
|
+
if (sealed === null) {
|
|
1599
2001
|
return Response.json({ settled: false }, { status: 400 });
|
|
1600
2002
|
}
|
|
1601
|
-
const subject =
|
|
1602
|
-
const
|
|
1603
|
-
if (
|
|
2003
|
+
const subject = await unsealFor("bank-verify", config.secret, sealed);
|
|
2004
|
+
const asked = subject === null ? null : askedFrom(subject);
|
|
2005
|
+
if (asked === null || asked.iban !== answersFor) {
|
|
1604
2006
|
return Response.json({ settled: false }, { status: 403 });
|
|
1605
2007
|
}
|
|
1606
|
-
const
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
{ headers: paced }
|
|
1614
|
-
);
|
|
2008
|
+
const preimage = await creditedPreimage({
|
|
2009
|
+
secret: config.secret,
|
|
2010
|
+
asked,
|
|
2011
|
+
statement: config.statement,
|
|
2012
|
+
lookBackSecs: config.lookBackSecs
|
|
2013
|
+
});
|
|
2014
|
+
return preimage === null ? Response.json({ settled: false }, { headers: paced }) : Response.json({ settled: true, preimage }, { headers: paced });
|
|
1615
2015
|
};
|
|
1616
2016
|
}
|
|
1617
|
-
function
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
const
|
|
1622
|
-
|
|
2017
|
+
async function answeredAt(params, subject) {
|
|
2018
|
+
if (params.answerBy === "agent") {
|
|
2019
|
+
return `agent:${(await callerKey(params.secret)).publicKeyHex}`;
|
|
2020
|
+
}
|
|
2021
|
+
const polling = new URL(params.verifyUrl ?? "");
|
|
2022
|
+
polling.searchParams.set(
|
|
2023
|
+
"q",
|
|
2024
|
+
await sealFor("bank-verify", params.secret, subject, { stable: true })
|
|
2025
|
+
);
|
|
2026
|
+
return polling.toString();
|
|
2027
|
+
}
|
|
2028
|
+
async function creditedPreimage(params) {
|
|
2029
|
+
const since = unixNow() - (params.lookBackSecs ?? DEFAULT_LOOK_BACK_SECS);
|
|
2030
|
+
const landed = (await params.statement(since)).some((credit) => pays(credit, params.asked));
|
|
2031
|
+
if (!landed) {
|
|
1623
2032
|
return null;
|
|
1624
2033
|
}
|
|
1625
|
-
|
|
2034
|
+
return await hmacHex(params.secret, `preimage|${subjectOf(params.asked)}`);
|
|
2035
|
+
}
|
|
2036
|
+
function askedFrom(subject) {
|
|
2037
|
+
const [iban, minorText, currency, expiresText, ...rest] = subject.split("|");
|
|
2038
|
+
const reference = rest.join("|");
|
|
2039
|
+
const amountMinor = Number(minorText);
|
|
2040
|
+
const expiresAt = Number(expiresText);
|
|
2041
|
+
if (!iban || !currency || reference.length === 0) {
|
|
1626
2042
|
return null;
|
|
1627
2043
|
}
|
|
1628
|
-
|
|
2044
|
+
if (!Number.isInteger(amountMinor) || amountMinor <= 0 || !Number.isSafeInteger(expiresAt)) {
|
|
2045
|
+
return null;
|
|
2046
|
+
}
|
|
2047
|
+
return { iban, reference, amountMinor, currency, expiresAt };
|
|
1629
2048
|
}
|
|
1630
2049
|
function pays(credit, asked) {
|
|
1631
|
-
return credit.amountMinor === asked.amountMinor && credit.currency.toUpperCase() === asked.currency.toUpperCase() && credit.reference
|
|
2050
|
+
return credit.amountMinor === asked.amountMinor && credit.currency.toUpperCase() === asked.currency.toUpperCase() && namesOnly(credit.reference, asked.reference);
|
|
2051
|
+
}
|
|
2052
|
+
function namesOnly(written, reference) {
|
|
2053
|
+
const wanted = reference.toUpperCase();
|
|
2054
|
+
const alike = [...written.toUpperCase().matchAll(wordsShapedLike(wanted))].map(([word]) => word);
|
|
2055
|
+
return alike.includes(wanted) && (!/\d/.test(wanted) || alike.every((word) => word === wanted));
|
|
2056
|
+
}
|
|
2057
|
+
function wordsShapedLike(reference) {
|
|
2058
|
+
const shape = [...reference].map((char) => {
|
|
2059
|
+
if (/\d/.test(char)) {
|
|
2060
|
+
return "\\d";
|
|
2061
|
+
}
|
|
2062
|
+
return /\p{L}/u.test(char) ? "\\p{L}" : char.replace(/[\^$\\.*+?()[\]{}|/]/g, "\\$&");
|
|
2063
|
+
}).join("");
|
|
2064
|
+
return new RegExp(`(?<![\\p{L}\\p{N}])${shape}(?![\\p{L}\\p{N}])`, "gu");
|
|
1632
2065
|
}
|
|
1633
|
-
function subjectOf(
|
|
1634
|
-
return `${
|
|
2066
|
+
function subjectOf({ iban, amountMinor, currency, expiresAt, reference }) {
|
|
2067
|
+
return `${iban}|${amountMinor}|${currency.toUpperCase()}|${expiresAt}|${reference}`;
|
|
1635
2068
|
}
|
|
1636
2069
|
function hashOf(preimage) {
|
|
1637
2070
|
return bytesToHex(sha256(hexToBytes(preimage)));
|
|
1638
2071
|
}
|
|
1639
|
-
function shortPaymentDescriptor(params, currency) {
|
|
2072
|
+
function shortPaymentDescriptor(params, iban, currency) {
|
|
1640
2073
|
const fields = [
|
|
1641
|
-
`ACC:${
|
|
2074
|
+
`ACC:${iban}`,
|
|
1642
2075
|
`AM:${major(params.amountMinor, currency)}`,
|
|
1643
2076
|
`CC:${currency.toUpperCase()}`,
|
|
1644
2077
|
`MSG:${params.reference}`
|
|
@@ -1659,19 +2092,28 @@ async function refuseAnOpenGateway(gateway, params) {
|
|
|
1659
2092
|
return;
|
|
1660
2093
|
}
|
|
1661
2094
|
throw new Error(
|
|
1662
|
-
"this gateway serves callers with no token, so it is not yours, and its operator would
|
|
2095
|
+
"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"
|
|
1663
2096
|
);
|
|
1664
2097
|
}
|
|
1665
|
-
function refuseUnusable(params, currency) {
|
|
1666
|
-
|
|
2098
|
+
function refuseUnusable(params, iban, currency) {
|
|
2099
|
+
refuseAWeakSecret(params.secret);
|
|
2100
|
+
if (params.answerBy !== "agent" && params.verifyUrl === void 0) {
|
|
2101
|
+
throw new Error("a transfer the gateway polls needs the verify url it is polled at");
|
|
2102
|
+
}
|
|
2103
|
+
if (!IBAN.test(iban)) {
|
|
1667
2104
|
throw new Error(`${params.iban} is not an IBAN`);
|
|
1668
2105
|
}
|
|
1669
|
-
if (!Number.
|
|
2106
|
+
if (!Number.isSafeInteger(params.amountMinor) || params.amountMinor <= 0) {
|
|
1670
2107
|
throw new Error("amountMinor must be a whole number of minor units above zero");
|
|
1671
2108
|
}
|
|
1672
2109
|
if (params.reference.length === 0) {
|
|
1673
2110
|
throw new Error("a transfer with no reference cannot be found");
|
|
1674
2111
|
}
|
|
2112
|
+
if (params.reference.length > MAX_REFERENCE_CHARS) {
|
|
2113
|
+
throw new Error(
|
|
2114
|
+
`a reference is at most ${MAX_REFERENCE_CHARS} characters, as many as a QR payment's message carries and its sealed query hides`
|
|
2115
|
+
);
|
|
2116
|
+
}
|
|
1675
2117
|
if (params.reference.includes(FORBIDDEN_IN_SPD)) {
|
|
1676
2118
|
throw new Error("a reference cannot contain an asterisk, it separates the QR fields");
|
|
1677
2119
|
}
|
|
@@ -1679,10 +2121,168 @@ function refuseUnusable(params, currency) {
|
|
|
1679
2121
|
throw new Error("a variable symbol is up to ten digits");
|
|
1680
2122
|
}
|
|
1681
2123
|
}
|
|
2124
|
+
function accountOf(iban) {
|
|
2125
|
+
return iban.replace(/\s+/g, "").toUpperCase();
|
|
2126
|
+
}
|
|
1682
2127
|
function unixNow() {
|
|
1683
2128
|
return Math.floor(Date.now() / 1e3);
|
|
1684
2129
|
}
|
|
1685
2130
|
|
|
2131
|
+
// src/nostr.ts
|
|
2132
|
+
import { chacha20 } from "@noble/ciphers/chacha.js";
|
|
2133
|
+
import { schnorr, secp256k1 } from "@noble/curves/secp256k1.js";
|
|
2134
|
+
import { expand, extract } from "@noble/hashes/hkdf.js";
|
|
2135
|
+
import { hmac } from "@noble/hashes/hmac.js";
|
|
2136
|
+
import { sha256 as sha2562 } from "@noble/hashes/sha2.js";
|
|
2137
|
+
var NIP44_SALT = new TextEncoder().encode("nip44-v2");
|
|
2138
|
+
var NIP44_VERSION = 2;
|
|
2139
|
+
var NONCE_BYTES2 = 32;
|
|
2140
|
+
var MAC_BYTES = 32;
|
|
2141
|
+
var MESSAGE_KEYS_BYTES = 76;
|
|
2142
|
+
var CHACHA_KEY_END = 32;
|
|
2143
|
+
var CHACHA_NONCE_END = 44;
|
|
2144
|
+
var LENGTH_PREFIX_BYTES = 2;
|
|
2145
|
+
var SMALLEST_PAD = 32;
|
|
2146
|
+
var CHUNKS_STAY_SMALL_TO = 256;
|
|
2147
|
+
var MAX_PLAINTEXT_BYTES = 65535;
|
|
2148
|
+
function publicKeyFor(privateKeyHex) {
|
|
2149
|
+
return bytesToHex(schnorr.getPublicKey(hexToBytes(privateKeyHex)));
|
|
2150
|
+
}
|
|
2151
|
+
function conversationKeyFor(privateKeyHex, publicKeyHex) {
|
|
2152
|
+
const shared = secp256k1.getSharedSecret(
|
|
2153
|
+
hexToBytes(privateKeyHex),
|
|
2154
|
+
hexToBytes(`02${publicKeyHex}`)
|
|
2155
|
+
);
|
|
2156
|
+
return extract(sha2562, shared.subarray(1, 33), NIP44_SALT);
|
|
2157
|
+
}
|
|
2158
|
+
function signEvent(privateKeyHex, draft) {
|
|
2159
|
+
const pubkey = publicKeyFor(privateKeyHex);
|
|
2160
|
+
const id = idOf(pubkey, draft);
|
|
2161
|
+
return {
|
|
2162
|
+
id,
|
|
2163
|
+
pubkey,
|
|
2164
|
+
created_at: draft.created_at,
|
|
2165
|
+
kind: draft.kind,
|
|
2166
|
+
tags: draft.tags,
|
|
2167
|
+
content: draft.content,
|
|
2168
|
+
sig: bytesToHex(schnorr.sign(hexToBytes(id), hexToBytes(privateKeyHex)))
|
|
2169
|
+
};
|
|
2170
|
+
}
|
|
2171
|
+
function verifyEvent(event) {
|
|
2172
|
+
try {
|
|
2173
|
+
return idOf(event.pubkey, event) === event.id && schnorr.verify(hexToBytes(event.sig), hexToBytes(event.id), hexToBytes(event.pubkey));
|
|
2174
|
+
} catch {
|
|
2175
|
+
return false;
|
|
2176
|
+
}
|
|
2177
|
+
}
|
|
2178
|
+
function idOf(pubkey, draft) {
|
|
2179
|
+
return bytesToHex(
|
|
2180
|
+
sha2562(
|
|
2181
|
+
new TextEncoder().encode(
|
|
2182
|
+
JSON.stringify([0, pubkey, draft.created_at, draft.kind, draft.tags, draft.content])
|
|
2183
|
+
)
|
|
2184
|
+
)
|
|
2185
|
+
);
|
|
2186
|
+
}
|
|
2187
|
+
function encryptNip44(conversationKey, plaintext) {
|
|
2188
|
+
return sealNip44(conversationKey, plaintext, crypto.getRandomValues(new Uint8Array(NONCE_BYTES2)));
|
|
2189
|
+
}
|
|
2190
|
+
function sealNip44(conversationKey, plaintext, nonce) {
|
|
2191
|
+
const keys = messageKeysFrom(conversationKey, nonce);
|
|
2192
|
+
const ciphertext = chacha20(keys.chachaKey, keys.chachaNonce, padded(plaintext));
|
|
2193
|
+
const mac = hmac(sha2562, keys.hmacKey, joined(nonce, ciphertext));
|
|
2194
|
+
return toBase64(joined(Uint8Array.of(NIP44_VERSION), nonce, ciphertext, mac));
|
|
2195
|
+
}
|
|
2196
|
+
function decryptNip44(conversationKey, payload) {
|
|
2197
|
+
if (payload.startsWith("#")) {
|
|
2198
|
+
throw new Error("nip44 payload announces an encryption version we do not speak");
|
|
2199
|
+
}
|
|
2200
|
+
const raw = fromBase64(payload);
|
|
2201
|
+
if (raw.length < 1 + NONCE_BYTES2 + MAC_BYTES) {
|
|
2202
|
+
throw new Error(`nip44 payload is ${raw.length} bytes, too short to hold a nonce and a mac`);
|
|
2203
|
+
}
|
|
2204
|
+
if (raw[0] !== NIP44_VERSION) {
|
|
2205
|
+
throw new Error(`nip44 payload is version ${raw[0]}, not ${NIP44_VERSION}`);
|
|
2206
|
+
}
|
|
2207
|
+
const nonce = raw.subarray(1, 1 + NONCE_BYTES2);
|
|
2208
|
+
const ciphertext = raw.subarray(1 + NONCE_BYTES2, raw.length - MAC_BYTES);
|
|
2209
|
+
const mac = raw.subarray(raw.length - MAC_BYTES);
|
|
2210
|
+
const keys = messageKeysFrom(conversationKey, nonce);
|
|
2211
|
+
if (!sameBytes(hmac(sha2562, keys.hmacKey, joined(nonce, ciphertext)), mac)) {
|
|
2212
|
+
throw new Error("nip44 payload failed its mac, so it was not written with this key");
|
|
2213
|
+
}
|
|
2214
|
+
return unpadded(chacha20(keys.chachaKey, keys.chachaNonce, ciphertext));
|
|
2215
|
+
}
|
|
2216
|
+
function messageKeysFrom(conversationKey, nonce) {
|
|
2217
|
+
if (conversationKey.length !== NONCE_BYTES2 || nonce.length !== NONCE_BYTES2) {
|
|
2218
|
+
throw new Error("a nip44 conversation key and nonce are both 32 bytes");
|
|
2219
|
+
}
|
|
2220
|
+
const keys = expand(sha2562, conversationKey, nonce, MESSAGE_KEYS_BYTES);
|
|
2221
|
+
return {
|
|
2222
|
+
chachaKey: keys.subarray(0, CHACHA_KEY_END),
|
|
2223
|
+
chachaNonce: keys.subarray(CHACHA_KEY_END, CHACHA_NONCE_END),
|
|
2224
|
+
hmacKey: keys.subarray(CHACHA_NONCE_END)
|
|
2225
|
+
};
|
|
2226
|
+
}
|
|
2227
|
+
function paddedLengthFor(unpadded2) {
|
|
2228
|
+
if (unpadded2 <= SMALLEST_PAD) {
|
|
2229
|
+
return SMALLEST_PAD;
|
|
2230
|
+
}
|
|
2231
|
+
const nextPower = 1 << Math.floor(Math.log2(unpadded2 - 1)) + 1;
|
|
2232
|
+
const chunk = nextPower <= CHUNKS_STAY_SMALL_TO ? SMALLEST_PAD : nextPower / 8;
|
|
2233
|
+
return chunk * (Math.floor((unpadded2 - 1) / chunk) + 1);
|
|
2234
|
+
}
|
|
2235
|
+
function padded(plaintext) {
|
|
2236
|
+
const body = new TextEncoder().encode(plaintext);
|
|
2237
|
+
if (body.length === 0 || body.length > MAX_PLAINTEXT_BYTES) {
|
|
2238
|
+
throw new Error(`nip44 takes 1 to ${MAX_PLAINTEXT_BYTES} bytes, this was ${body.length}`);
|
|
2239
|
+
}
|
|
2240
|
+
const buffer = new Uint8Array(LENGTH_PREFIX_BYTES + paddedLengthFor(body.length));
|
|
2241
|
+
buffer.set(Uint8Array.of(body.length >>> 8, body.length & 255));
|
|
2242
|
+
buffer.set(body, LENGTH_PREFIX_BYTES);
|
|
2243
|
+
return buffer;
|
|
2244
|
+
}
|
|
2245
|
+
function unpadded(buffer) {
|
|
2246
|
+
const length = (buffer[0] ?? 0) << 8 | (buffer[1] ?? 0);
|
|
2247
|
+
if (length === 0 || buffer.length !== LENGTH_PREFIX_BYTES + paddedLengthFor(length)) {
|
|
2248
|
+
throw new Error("nip44 plaintext does not match the length its own padding declares");
|
|
2249
|
+
}
|
|
2250
|
+
return new TextDecoder().decode(
|
|
2251
|
+
buffer.subarray(LENGTH_PREFIX_BYTES, LENGTH_PREFIX_BYTES + length)
|
|
2252
|
+
);
|
|
2253
|
+
}
|
|
2254
|
+
function joined(...parts) {
|
|
2255
|
+
const whole = new Uint8Array(parts.reduce((total, part) => total + part.length, 0));
|
|
2256
|
+
let at = 0;
|
|
2257
|
+
for (const part of parts) {
|
|
2258
|
+
whole.set(part, at);
|
|
2259
|
+
at += part.length;
|
|
2260
|
+
}
|
|
2261
|
+
return whole;
|
|
2262
|
+
}
|
|
2263
|
+
function sameBytes(left, right) {
|
|
2264
|
+
let different = left.length ^ right.length;
|
|
2265
|
+
for (let at = 0; at < left.length && at < right.length; at++) {
|
|
2266
|
+
different |= (left[at] ?? 0) ^ (right[at] ?? 0);
|
|
2267
|
+
}
|
|
2268
|
+
return different === 0;
|
|
2269
|
+
}
|
|
2270
|
+
function toBase64(bytes) {
|
|
2271
|
+
let binary = "";
|
|
2272
|
+
for (const byte of bytes) {
|
|
2273
|
+
binary += String.fromCharCode(byte);
|
|
2274
|
+
}
|
|
2275
|
+
return btoa(binary);
|
|
2276
|
+
}
|
|
2277
|
+
function fromBase64(text3) {
|
|
2278
|
+
const binary = atob(text3);
|
|
2279
|
+
const bytes = new Uint8Array(binary.length);
|
|
2280
|
+
for (let at = 0; at < bytes.length; at++) {
|
|
2281
|
+
bytes[at] = binary.charCodeAt(at);
|
|
2282
|
+
}
|
|
2283
|
+
return bytes;
|
|
2284
|
+
}
|
|
2285
|
+
|
|
1686
2286
|
// src/qr.ts
|
|
1687
2287
|
import { encode } from "uqr";
|
|
1688
2288
|
var SAFE_COLOR = /^#(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$|^rgba?\([\d.,%/\s]+\)$|^[a-z]+$/i;
|
|
@@ -1693,44 +2293,276 @@ function toLightningUri(destination) {
|
|
|
1693
2293
|
function invoiceToSvg(destination, options) {
|
|
1694
2294
|
return svgOf(toLightningUri(destination), options);
|
|
1695
2295
|
}
|
|
1696
|
-
function svgOf(scanned, options) {
|
|
1697
|
-
const size = options?.size ?? 256;
|
|
1698
|
-
const color = options?.color ?? "#000";
|
|
1699
|
-
if (!SAFE_COLOR.test(color)) {
|
|
1700
|
-
throw new Error(`Invalid color: ${color}`);
|
|
2296
|
+
function svgOf(scanned, options) {
|
|
2297
|
+
const size = options?.size ?? 256;
|
|
2298
|
+
const color = options?.color ?? "#000";
|
|
2299
|
+
if (!SAFE_COLOR.test(color)) {
|
|
2300
|
+
throw new Error(`Invalid color: ${color}`);
|
|
2301
|
+
}
|
|
2302
|
+
return renderSvg(scanned, size, color);
|
|
2303
|
+
}
|
|
2304
|
+
function isLnAddress(destination) {
|
|
2305
|
+
const at = destination.indexOf("@");
|
|
2306
|
+
return at > 0 && destination.slice(at + 1).includes(".");
|
|
2307
|
+
}
|
|
2308
|
+
function renderSvg(content, size, color) {
|
|
2309
|
+
const { data } = encode(content);
|
|
2310
|
+
const modules = data.length;
|
|
2311
|
+
const cellSize = size / modules;
|
|
2312
|
+
let path = "";
|
|
2313
|
+
for (let y = 0; y < modules; y++) {
|
|
2314
|
+
for (let x = 0; x < modules; x++) {
|
|
2315
|
+
if (data[y][x]) {
|
|
2316
|
+
path += `M${x * cellSize},${y * cellSize}h${cellSize}v${cellSize}h-${cellSize}z`;
|
|
2317
|
+
}
|
|
2318
|
+
}
|
|
2319
|
+
}
|
|
2320
|
+
return [
|
|
2321
|
+
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${size} ${size}" width="${size}" height="${size}">`,
|
|
2322
|
+
`<rect width="100%" height="100%" fill="white"/>`,
|
|
2323
|
+
`<path d="${path}" fill="${color}"/>`,
|
|
2324
|
+
`</svg>`
|
|
2325
|
+
].join("");
|
|
2326
|
+
}
|
|
2327
|
+
|
|
2328
|
+
// src/nwc.ts
|
|
2329
|
+
var REQUEST_KIND = 23194;
|
|
2330
|
+
var RESPONSE_KIND = 23195;
|
|
2331
|
+
var NIP44_V2 = "nip44_v2";
|
|
2332
|
+
var DEFAULT_ASK_TIMEOUT_MS = 1e4;
|
|
2333
|
+
var DEFAULT_POLL_EVERY_SECS2 = 5;
|
|
2334
|
+
var HASH = "h";
|
|
2335
|
+
var REFUSAL_BY_CODE = {
|
|
2336
|
+
INSUFFICIENT_BALANCE: "amount-not-accepted",
|
|
2337
|
+
QUOTA_EXCEEDED: "amount-not-accepted",
|
|
2338
|
+
RESTRICTED: "address-unusable",
|
|
2339
|
+
UNAUTHORIZED: "address-unusable",
|
|
2340
|
+
UNSUPPORTED_ENCRYPTION: "address-unusable"
|
|
2341
|
+
};
|
|
2342
|
+
async function nwcInvoice(connection, amountMsat, description, timeoutMs = DEFAULT_ASK_TIMEOUT_MS) {
|
|
2343
|
+
const answered3 = await askWallet(
|
|
2344
|
+
connection,
|
|
2345
|
+
"make_invoice",
|
|
2346
|
+
{ amount: amountMsat, description },
|
|
2347
|
+
timeoutMs
|
|
2348
|
+
);
|
|
2349
|
+
const bolt11 = String(answered3.invoice ?? "");
|
|
2350
|
+
const issued = decodeInvoice(bolt11);
|
|
2351
|
+
if (issued.paymentHash === null || issued.expiresAt === null) {
|
|
2352
|
+
throw new WalletRefused("invoice-refused", "the nwc wallet issued an invoice we cannot decode");
|
|
2353
|
+
}
|
|
2354
|
+
if (issued.amountMsat !== amountMsat) {
|
|
2355
|
+
throw new WalletRefused(
|
|
2356
|
+
"invoice-refused",
|
|
2357
|
+
`the nwc wallet issued an invoice for ${issued.amountMsat} msat, not ${amountMsat}`
|
|
2358
|
+
);
|
|
2359
|
+
}
|
|
2360
|
+
return { bolt11, paymentHash: issued.paymentHash, expiresAt: issued.expiresAt };
|
|
2361
|
+
}
|
|
2362
|
+
async function nwcSettlement(connection, paymentHash, timeoutMs = DEFAULT_ASK_TIMEOUT_MS) {
|
|
2363
|
+
const answered3 = await askWallet(
|
|
2364
|
+
connection,
|
|
2365
|
+
"lookup_invoice",
|
|
2366
|
+
{ payment_hash: paymentHash },
|
|
2367
|
+
timeoutMs
|
|
2368
|
+
);
|
|
2369
|
+
const preimage = answered3.preimage;
|
|
2370
|
+
if (typeof preimage !== "string" || preimage.length === 0) {
|
|
2371
|
+
return null;
|
|
2372
|
+
}
|
|
2373
|
+
if (!preimageMatchesHash(preimage, paymentHash)) {
|
|
2374
|
+
throw new Error(`the nwc wallet returned a preimage that does not hash to ${paymentHash}`);
|
|
2375
|
+
}
|
|
2376
|
+
return preimage;
|
|
2377
|
+
}
|
|
2378
|
+
function nwcVerifyEndpoint(config) {
|
|
2379
|
+
const paced = {
|
|
2380
|
+
"cache-control": `max-age=${config.pollEverySecs ?? DEFAULT_POLL_EVERY_SECS2}`
|
|
2381
|
+
};
|
|
2382
|
+
return async (request) => {
|
|
2383
|
+
const consented = await answerVerifyChallenge(request);
|
|
2384
|
+
if (consented !== null) {
|
|
2385
|
+
return consented;
|
|
2386
|
+
}
|
|
2387
|
+
const sealed = new URL(request.url).searchParams.get(HASH);
|
|
2388
|
+
if (sealed === null) {
|
|
2389
|
+
return Response.json({ settled: false }, { status: 400 });
|
|
2390
|
+
}
|
|
2391
|
+
const paymentHash = await unsealFor("nwc-verify", config.secret, sealed);
|
|
2392
|
+
if (paymentHash === null) {
|
|
2393
|
+
return Response.json({ settled: false }, { status: 403 });
|
|
2394
|
+
}
|
|
2395
|
+
const preimage = await nwcSettlement(config.connection, paymentHash, config.askTimeoutMs).catch(
|
|
2396
|
+
() => void 0
|
|
2397
|
+
);
|
|
2398
|
+
if (preimage === void 0) {
|
|
2399
|
+
return Response.json({ settled: false }, { status: 502 });
|
|
2400
|
+
}
|
|
2401
|
+
return Response.json({ settled: preimage !== null, preimage }, { headers: paced });
|
|
2402
|
+
};
|
|
2403
|
+
}
|
|
2404
|
+
async function nwcVerifyUrl(endpoint, paymentHash, secret) {
|
|
2405
|
+
const sealed = new URL(endpoint);
|
|
2406
|
+
sealed.searchParams.set(HASH, await sealFor("nwc-verify", secret, paymentHash));
|
|
2407
|
+
return sealed.toString();
|
|
2408
|
+
}
|
|
2409
|
+
async function askWallet(connection, method, params, timeoutMs = DEFAULT_ASK_TIMEOUT_MS) {
|
|
2410
|
+
const key = conversationKeyFor(connection.secret, connection.walletPubkey);
|
|
2411
|
+
const request = signEvent(connection.secret, {
|
|
2412
|
+
kind: REQUEST_KIND,
|
|
2413
|
+
tags: [
|
|
2414
|
+
["p", connection.walletPubkey],
|
|
2415
|
+
["encryption", NIP44_V2]
|
|
2416
|
+
],
|
|
2417
|
+
content: encryptNip44(key, JSON.stringify({ method, params })),
|
|
2418
|
+
created_at: Math.floor(Date.now() / 1e3)
|
|
2419
|
+
});
|
|
2420
|
+
const answer = await overFirstRelayThatAnswers(connection, request, timeoutMs);
|
|
2421
|
+
return resultOf(JSON.parse(decryptNip44(key, answer.content)), method);
|
|
2422
|
+
}
|
|
2423
|
+
function resultOf(said, method) {
|
|
2424
|
+
const failure = said.error;
|
|
2425
|
+
if (failure) {
|
|
2426
|
+
throw new WalletRefused(
|
|
2427
|
+
REFUSAL_BY_CODE[failure.code ?? ""] ?? "invoice-refused",
|
|
2428
|
+
`the nwc wallet refused ${method}: ${failure.code ?? "unknown"} ${failure.message ?? ""}`.trim()
|
|
2429
|
+
);
|
|
2430
|
+
}
|
|
2431
|
+
const result = said.result;
|
|
2432
|
+
if (result === null || typeof result !== "object") {
|
|
2433
|
+
throw new WalletRefused("invoice-refused", `the nwc wallet answered ${method} with no result`);
|
|
2434
|
+
}
|
|
2435
|
+
return result;
|
|
2436
|
+
}
|
|
2437
|
+
async function overFirstRelayThatAnswers(connection, request, timeoutMs) {
|
|
2438
|
+
const deadline = AbortSignal.timeout(timeoutMs);
|
|
2439
|
+
let lastFailure = null;
|
|
2440
|
+
for (const relay of connection.relays) {
|
|
2441
|
+
if (deadline.aborted) {
|
|
2442
|
+
break;
|
|
2443
|
+
}
|
|
2444
|
+
try {
|
|
2445
|
+
return await overRelay(relay, connection, request, deadline);
|
|
2446
|
+
} catch (failure) {
|
|
2447
|
+
lastFailure = failure;
|
|
2448
|
+
}
|
|
2449
|
+
}
|
|
2450
|
+
throw new WalletRefused("unreachable", `no nwc relay answered: ${String(lastFailure)}`);
|
|
2451
|
+
}
|
|
2452
|
+
function overRelay(relay, connection, request, deadline) {
|
|
2453
|
+
return new Promise((resolve2, reject) => {
|
|
2454
|
+
const socket = new WebSocket(relay);
|
|
2455
|
+
const subscription = request.id.slice(0, 16);
|
|
2456
|
+
const expired = () => {
|
|
2457
|
+
close();
|
|
2458
|
+
reject(new Error(`${relay} did not answer before the deadline`));
|
|
2459
|
+
};
|
|
2460
|
+
deadline.addEventListener("abort", expired);
|
|
2461
|
+
function close() {
|
|
2462
|
+
deadline.removeEventListener("abort", expired);
|
|
2463
|
+
socket.close();
|
|
2464
|
+
}
|
|
2465
|
+
socket.addEventListener("open", () => {
|
|
2466
|
+
socket.send(
|
|
2467
|
+
JSON.stringify([
|
|
2468
|
+
"REQ",
|
|
2469
|
+
subscription,
|
|
2470
|
+
{
|
|
2471
|
+
kinds: [RESPONSE_KIND],
|
|
2472
|
+
authors: [connection.walletPubkey],
|
|
2473
|
+
"#p": [publicKeyFor(connection.secret)],
|
|
2474
|
+
"#e": [request.id]
|
|
2475
|
+
}
|
|
2476
|
+
])
|
|
2477
|
+
);
|
|
2478
|
+
socket.send(JSON.stringify(["EVENT", request]));
|
|
2479
|
+
});
|
|
2480
|
+
socket.addEventListener("error", () => {
|
|
2481
|
+
close();
|
|
2482
|
+
reject(new Error(`${relay} could not be reached`));
|
|
2483
|
+
});
|
|
2484
|
+
socket.addEventListener("message", (delivered) => {
|
|
2485
|
+
const frame = String(delivered.data);
|
|
2486
|
+
const rejection = rejectionOf(frame, request.id);
|
|
2487
|
+
if (rejection !== null) {
|
|
2488
|
+
close();
|
|
2489
|
+
reject(new Error(`${relay} refused the request: ${rejection}`));
|
|
2490
|
+
return;
|
|
2491
|
+
}
|
|
2492
|
+
const answer = answeredEvent(frame, subscription, connection, request.id);
|
|
2493
|
+
if (answer === null) {
|
|
2494
|
+
return;
|
|
2495
|
+
}
|
|
2496
|
+
close();
|
|
2497
|
+
resolve2(answer);
|
|
2498
|
+
});
|
|
2499
|
+
});
|
|
2500
|
+
}
|
|
2501
|
+
function rejectionOf(frame, requestId) {
|
|
2502
|
+
const parsed = parsedFrame(frame);
|
|
2503
|
+
if (parsed === null || parsed[0] !== "OK" || parsed[1] !== requestId || parsed[2] !== false) {
|
|
2504
|
+
return null;
|
|
1701
2505
|
}
|
|
1702
|
-
return
|
|
2506
|
+
return typeof parsed[3] === "string" && parsed[3].length > 0 ? parsed[3] : "no reason given";
|
|
1703
2507
|
}
|
|
1704
|
-
function
|
|
1705
|
-
const
|
|
1706
|
-
|
|
2508
|
+
function answeredEvent(frame, subscription, connection, requestId) {
|
|
2509
|
+
const parsed = parsedFrame(frame);
|
|
2510
|
+
if (parsed === null || parsed[0] !== "EVENT" || parsed[1] !== subscription) {
|
|
2511
|
+
return null;
|
|
2512
|
+
}
|
|
2513
|
+
const event = parsed[2];
|
|
2514
|
+
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);
|
|
2515
|
+
return answers ? event : null;
|
|
1707
2516
|
}
|
|
1708
|
-
function
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
for (let x = 0; x < modules; x++) {
|
|
1715
|
-
if (data[y][x]) {
|
|
1716
|
-
path += `M${x * cellSize},${y * cellSize}h${cellSize}v${cellSize}h-${cellSize}z`;
|
|
1717
|
-
}
|
|
1718
|
-
}
|
|
2517
|
+
function parsedFrame(frame) {
|
|
2518
|
+
try {
|
|
2519
|
+
const parsed = JSON.parse(frame);
|
|
2520
|
+
return Array.isArray(parsed) ? parsed : null;
|
|
2521
|
+
} catch {
|
|
2522
|
+
return null;
|
|
1719
2523
|
}
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
2524
|
+
}
|
|
2525
|
+
function nwcRail(gateway, config) {
|
|
2526
|
+
return async (order) => {
|
|
2527
|
+
const invoice = await nwcInvoice(
|
|
2528
|
+
config.connection,
|
|
2529
|
+
await msatForOrder(order, config.amount, config.rate),
|
|
2530
|
+
config.description?.(order) ?? order.reference
|
|
2531
|
+
);
|
|
2532
|
+
const watched = await gateway.watch({
|
|
2533
|
+
paymentHash: invoice.paymentHash,
|
|
2534
|
+
verifyUrl: await nwcVerifyUrl(
|
|
2535
|
+
config.verifyThrough.endpoint,
|
|
2536
|
+
invoice.paymentHash,
|
|
2537
|
+
config.verifyThrough.secret
|
|
2538
|
+
),
|
|
2539
|
+
expiresAt: invoice.expiresAt,
|
|
2540
|
+
trigger: config.trigger,
|
|
2541
|
+
replay: config.replay,
|
|
2542
|
+
sealed: config.sealed ? await seal(
|
|
2543
|
+
config.sealed.secret,
|
|
2544
|
+
JSON.stringify(config.sealed.data(order)),
|
|
2545
|
+
invoice.paymentHash
|
|
2546
|
+
) : void 0,
|
|
2547
|
+
webhookUrl: config.webhookUrl
|
|
2548
|
+
});
|
|
2549
|
+
return {
|
|
2550
|
+
id: watched.id,
|
|
2551
|
+
paymentHash: invoice.paymentHash,
|
|
2552
|
+
rail: config.name ?? "lightning",
|
|
2553
|
+
scan: invoice.bolt11,
|
|
2554
|
+
qr: toLightningUri(invoice.bolt11),
|
|
2555
|
+
expiresAt: invoice.expiresAt
|
|
2556
|
+
};
|
|
2557
|
+
};
|
|
1726
2558
|
}
|
|
1727
2559
|
|
|
1728
2560
|
// src/relay.ts
|
|
1729
|
-
var
|
|
2561
|
+
var DEFAULT_POLL_EVERY_SECS3 = 5;
|
|
1730
2562
|
var WALLET = "w";
|
|
1731
2563
|
function lightningVerifyEndpoint(config) {
|
|
1732
2564
|
const paced = {
|
|
1733
|
-
"cache-control": `max-age=${config.pollEverySecs ??
|
|
2565
|
+
"cache-control": `max-age=${config.pollEverySecs ?? DEFAULT_POLL_EVERY_SECS3}`
|
|
1734
2566
|
};
|
|
1735
2567
|
return async (request) => {
|
|
1736
2568
|
const consented = await answerVerifyChallenge(request);
|
|
@@ -1741,11 +2573,11 @@ function lightningVerifyEndpoint(config) {
|
|
|
1741
2573
|
if (sealed === null) {
|
|
1742
2574
|
return Response.json({ settled: false }, { status: 400 });
|
|
1743
2575
|
}
|
|
1744
|
-
const
|
|
1745
|
-
|
|
2576
|
+
const opened2 = await unsealFor("relay", config.secret, sealed);
|
|
2577
|
+
const wallet = opened2 === null ? null : relayedFrom(opened2);
|
|
2578
|
+
if (wallet === null) {
|
|
1746
2579
|
return Response.json({ settled: false }, { status: 403 });
|
|
1747
2580
|
}
|
|
1748
|
-
const wallet = JSON.parse(opened);
|
|
1749
2581
|
const asked = await checkSettled(
|
|
1750
2582
|
config.send ?? pinnedToTheAddressWeVerified,
|
|
1751
2583
|
wallet.url,
|
|
@@ -1760,9 +2592,17 @@ function lightningVerifyEndpoint(config) {
|
|
|
1760
2592
|
);
|
|
1761
2593
|
};
|
|
1762
2594
|
}
|
|
2595
|
+
function relayedFrom(opened2) {
|
|
2596
|
+
try {
|
|
2597
|
+
const said = JSON.parse(opened2);
|
|
2598
|
+
return typeof said?.url === "string" && typeof said.hash === "string" ? { url: said.url, hash: said.hash } : null;
|
|
2599
|
+
} catch {
|
|
2600
|
+
return null;
|
|
2601
|
+
}
|
|
2602
|
+
}
|
|
1763
2603
|
async function relayedVerifyUrl(endpoint, wallet, secret) {
|
|
1764
2604
|
const relayed = new URL(endpoint);
|
|
1765
|
-
relayed.searchParams.set(WALLET, await
|
|
2605
|
+
relayed.searchParams.set(WALLET, await sealFor("relay", secret, JSON.stringify(wallet)));
|
|
1766
2606
|
return relayed.toString();
|
|
1767
2607
|
}
|
|
1768
2608
|
|
|
@@ -1782,13 +2622,14 @@ function bankRail(gateway, config) {
|
|
|
1782
2622
|
expiresAt,
|
|
1783
2623
|
trigger: config.trigger,
|
|
1784
2624
|
replay: config.replay,
|
|
1785
|
-
sealed:
|
|
2625
|
+
sealed: config.sealed && { secret: config.sealed.secret, data: config.sealed.data(order) },
|
|
1786
2626
|
variableSymbol: config.variableSymbol?.(order),
|
|
1787
2627
|
webhookUrl: config.webhookUrl,
|
|
1788
2628
|
allowPublicGateway: config.allowPublicGateway
|
|
1789
2629
|
});
|
|
1790
2630
|
return {
|
|
1791
2631
|
id: transfer.id,
|
|
2632
|
+
paymentHash: transfer.paymentHash,
|
|
1792
2633
|
rail: config.name ?? BANK,
|
|
1793
2634
|
scan: transfer.spd,
|
|
1794
2635
|
qr: transfer.spd,
|
|
@@ -1812,6 +2653,7 @@ function lightningRail(gateway, config) {
|
|
|
1812
2653
|
);
|
|
1813
2654
|
return {
|
|
1814
2655
|
id: payment.id,
|
|
2656
|
+
paymentHash: payment.paymentHash,
|
|
1815
2657
|
rail: config.name ?? LIGHTNING,
|
|
1816
2658
|
scan: payment.bolt11,
|
|
1817
2659
|
qr: toLightningUri(payment.bolt11),
|
|
@@ -1837,11 +2679,16 @@ function blindLightningRail(gateway, config) {
|
|
|
1837
2679
|
expiresAt: resolved2.expiresAt,
|
|
1838
2680
|
trigger: config.trigger,
|
|
1839
2681
|
replay: config.replay,
|
|
1840
|
-
sealed:
|
|
2682
|
+
sealed: config.sealed ? await seal(
|
|
2683
|
+
config.sealed.secret,
|
|
2684
|
+
JSON.stringify(config.sealed.data(order)),
|
|
2685
|
+
resolved2.paymentHash
|
|
2686
|
+
) : void 0,
|
|
1841
2687
|
webhookUrl: config.webhookUrl
|
|
1842
2688
|
});
|
|
1843
2689
|
return {
|
|
1844
2690
|
id: watched.id,
|
|
2691
|
+
paymentHash: resolved2.paymentHash,
|
|
1845
2692
|
rail: config.name ?? LIGHTNING,
|
|
1846
2693
|
scan: resolved2.bolt11,
|
|
1847
2694
|
qr: toLightningUri(resolved2.bolt11),
|
|
@@ -1886,6 +2733,15 @@ var Rails = class {
|
|
|
1886
2733
|
bank(config) {
|
|
1887
2734
|
return bankRail(this.gateway, config);
|
|
1888
2735
|
}
|
|
2736
|
+
/**
|
|
2737
|
+
* Lightning against a wallet of your own over NIP-47, for a wallet that has no
|
|
2738
|
+
* LUD-21 address to be watched at. Your node mints the invoice and releases the
|
|
2739
|
+
* preimage, so the proof comes from one hop nearer than any hosted address can
|
|
2740
|
+
* manage, and the gateway sees a hash and a URL of yours
|
|
2741
|
+
*/
|
|
2742
|
+
nwc(config) {
|
|
2743
|
+
return nwcRail(this.gateway, config);
|
|
2744
|
+
}
|
|
1889
2745
|
/**
|
|
1890
2746
|
* One bank transfer without building a rail first, for a shop that asks for
|
|
1891
2747
|
* them one at a time rather than beside another payment method
|
|
@@ -1895,130 +2751,6 @@ var Rails = class {
|
|
|
1895
2751
|
}
|
|
1896
2752
|
};
|
|
1897
2753
|
|
|
1898
|
-
// src/verify.ts
|
|
1899
|
-
var HTTP_TIMEOUT_MS2 = 15e3;
|
|
1900
|
-
var DEFAULT_WRAP_PROPORTION = 0.01;
|
|
1901
|
-
var DEFAULT_WRAP_BASE_MSAT = 1e3;
|
|
1902
|
-
async function proveOrigin(payment, asked) {
|
|
1903
|
-
const cheat = (code) => new GatewayCheatError(code, payment.id);
|
|
1904
|
-
const listed = asked.paidTo.find((address) => equalIgnoringCase(address, payment.lnAddress));
|
|
1905
|
-
if (listed === void 0) {
|
|
1906
|
-
throw cheat("address_not_requested");
|
|
1907
|
-
}
|
|
1908
|
-
if (payment.amountMsat !== asked.amountMsat) {
|
|
1909
|
-
throw cheat("amount_mismatch");
|
|
1910
|
-
}
|
|
1911
|
-
const invoice = decodeInvoice(payment.bolt11);
|
|
1912
|
-
if (invoice.paymentHash === null || !equalIgnoringCase(invoice.paymentHash, payment.paymentHash)) {
|
|
1913
|
-
throw cheat("hash_mismatch");
|
|
1914
|
-
}
|
|
1915
|
-
if (invoice.amountMsat !== asked.amountMsat) {
|
|
1916
|
-
throw cheat("amount_mismatch");
|
|
1917
|
-
}
|
|
1918
|
-
const payRequest = await payRequestFor(listed, payment);
|
|
1919
|
-
if (typeof payRequest.metadata !== "string" || typeof payRequest.callback !== "string") {
|
|
1920
|
-
throw new UnverifiedRecipientError(payment.lnAddress, payment.id, "no payRequest served");
|
|
1921
|
-
}
|
|
1922
|
-
if (invoice.descriptionHash !== sha256Hex(payRequest.metadata)) {
|
|
1923
|
-
throw cheat("description_hash_mismatch");
|
|
1924
|
-
}
|
|
1925
|
-
if (!sameOrigin(payment.verifyUrl, payRequest.callback)) {
|
|
1926
|
-
throw cheat("verify_url_foreign");
|
|
1927
|
-
}
|
|
1928
|
-
const issued = await reachable(payment.verifyUrl, payment);
|
|
1929
|
-
if (typeof issued.pr !== "string" || !equalIgnoringCase(issued.pr, payment.bolt11)) {
|
|
1930
|
-
throw cheat("invoice_not_issued");
|
|
1931
|
-
}
|
|
1932
|
-
}
|
|
1933
|
-
async function proveSettlement(payment, asked) {
|
|
1934
|
-
await proveOrigin(payment, asked);
|
|
1935
|
-
const verified = await reachable(payment.verifyUrl, payment);
|
|
1936
|
-
if (verified.settled !== true || typeof verified.preimage !== "string") {
|
|
1937
|
-
return null;
|
|
1938
|
-
}
|
|
1939
|
-
if (!preimageMatchesHash(verified.preimage, payment.paymentHash)) {
|
|
1940
|
-
throw new GatewayCheatError("preimage_mismatch", payment.id);
|
|
1941
|
-
}
|
|
1942
|
-
return verified.preimage;
|
|
1943
|
-
}
|
|
1944
|
-
function carriesProof(report) {
|
|
1945
|
-
if (report.status !== "paid" || report.preimage === null) {
|
|
1946
|
-
return false;
|
|
1947
|
-
}
|
|
1948
|
-
if (report.bolt11 !== void 0 && report.bolt11 !== null) {
|
|
1949
|
-
const invoiceHash = decodeInvoice(report.bolt11).paymentHash;
|
|
1950
|
-
if (invoiceHash === null || !equalIgnoringCase(invoiceHash, report.paymentHash)) {
|
|
1951
|
-
return false;
|
|
1952
|
-
}
|
|
1953
|
-
}
|
|
1954
|
-
return preimageMatchesHash(report.preimage, report.paymentHash);
|
|
1955
|
-
}
|
|
1956
|
-
function wrapFeeCeiling(amountMsat, allowance) {
|
|
1957
|
-
const proportional = amountMsat * (allowance?.proportion ?? DEFAULT_WRAP_PROPORTION);
|
|
1958
|
-
return msat(Math.max(allowance?.baseMsat ?? DEFAULT_WRAP_BASE_MSAT, Math.floor(proportional)));
|
|
1959
|
-
}
|
|
1960
|
-
function proveWrapped(wrapped, recipient, allowance) {
|
|
1961
|
-
const offered = decodeInvoice(wrapped);
|
|
1962
|
-
const owed = decodeInvoice(recipient);
|
|
1963
|
-
const refuse2 = (code, detail) => {
|
|
1964
|
-
throw new WrapRefusedError(code, detail);
|
|
1965
|
-
};
|
|
1966
|
-
if (offered.paymentHash === null || offered.amountMsat === null || offered.expiresAt === null) {
|
|
1967
|
-
refuse2("undecodable", "the wrapped invoice carries no hash, amount or expiry");
|
|
1968
|
-
}
|
|
1969
|
-
if (owed.paymentHash === null || owed.amountMsat === null || owed.expiresAt === null) {
|
|
1970
|
-
refuse2("undecodable", "the recipient's invoice carries no hash, amount or expiry");
|
|
1971
|
-
}
|
|
1972
|
-
if (offered.paymentHash?.toLowerCase() !== owed.paymentHash?.toLowerCase()) {
|
|
1973
|
-
refuse2("hash_mismatch", "settling the wrap would not settle the recipient");
|
|
1974
|
-
}
|
|
1975
|
-
const asked = offered.amountMsat ?? 0;
|
|
1976
|
-
const due = owed.amountMsat ?? 0;
|
|
1977
|
-
if (asked < due) {
|
|
1978
|
-
refuse2("amount_below_recipient", `${asked} msat cannot forward ${due} msat`);
|
|
1979
|
-
}
|
|
1980
|
-
const ceiling = wrapFeeCeiling(due, allowance);
|
|
1981
|
-
if (asked - due > ceiling) {
|
|
1982
|
-
refuse2("fee_above_allowance", `${asked - due} msat over ${due}, and ${ceiling} was allowed`);
|
|
1983
|
-
}
|
|
1984
|
-
if ((owed.expiresAt ?? 0) <= (offered.expiresAt ?? 0)) {
|
|
1985
|
-
refuse2("recipient_expires_first", "the operator could be paid and find it cannot deliver");
|
|
1986
|
-
}
|
|
1987
|
-
}
|
|
1988
|
-
async function payRequestFor(listed, payment) {
|
|
1989
|
-
const at = listed.indexOf("@");
|
|
1990
|
-
const name = listed.slice(0, at);
|
|
1991
|
-
const domain = listed.slice(at + 1).toLowerCase();
|
|
1992
|
-
if (at < 1 || !publicHttps(`https://${domain}/`)) {
|
|
1993
|
-
throw new UnverifiedRecipientError(payment.lnAddress, payment.id, "not a usable address");
|
|
1994
|
-
}
|
|
1995
|
-
return reachable(`https://${domain}/.well-known/lnurlp/${name}`, payment);
|
|
1996
|
-
}
|
|
1997
|
-
async function reachable(url, payment) {
|
|
1998
|
-
try {
|
|
1999
|
-
if (!publicHttps(url)) {
|
|
2000
|
-
throw new Error(`${url} is not a public https URL`);
|
|
2001
|
-
}
|
|
2002
|
-
const response = await fetch(url, {
|
|
2003
|
-
signal: AbortSignal.timeout(HTTP_TIMEOUT_MS2),
|
|
2004
|
-
headers: { accept: "application/json" }
|
|
2005
|
-
});
|
|
2006
|
-
if (!response.ok) {
|
|
2007
|
-
throw new Error(`answered ${response.status}`);
|
|
2008
|
-
}
|
|
2009
|
-
const body = await response.json();
|
|
2010
|
-
if (body === null || typeof body !== "object") {
|
|
2011
|
-
throw new Error("answered with no JSON object");
|
|
2012
|
-
}
|
|
2013
|
-
return body;
|
|
2014
|
-
} catch (cause) {
|
|
2015
|
-
throw new UnverifiedRecipientError(payment.lnAddress, payment.id, cause);
|
|
2016
|
-
}
|
|
2017
|
-
}
|
|
2018
|
-
function equalIgnoringCase(one, other) {
|
|
2019
|
-
return one.toLowerCase() === other.toLowerCase();
|
|
2020
|
-
}
|
|
2021
|
-
|
|
2022
2754
|
// src/request.ts
|
|
2023
2755
|
function paymentRequestOf(gateway, payment, options) {
|
|
2024
2756
|
return {
|
|
@@ -2030,13 +2762,13 @@ function paymentRequestOf(gateway, payment, options) {
|
|
|
2030
2762
|
expiresAt: payment.expiresAt,
|
|
2031
2763
|
qr: invoiceToSvg(payment.bolt11, options?.qr),
|
|
2032
2764
|
payment,
|
|
2033
|
-
paid: async (waiting) => paidOnly(await gateway.settled(payment
|
|
2765
|
+
paid: async (waiting) => paidOnly(await gateway.settled(payment, { ...waitingOf(options), ...waiting })),
|
|
2034
2766
|
prove: () => proveSettlement(payment, { paidTo: [payment.lnAddress], amountMsat: payment.amountMsat }),
|
|
2035
2767
|
onPaid: (arrived, failed) => {
|
|
2036
2768
|
const stop = new AbortController();
|
|
2037
2769
|
const waiting = waitingOf(options);
|
|
2038
2770
|
const signal = waiting.signal ? AbortSignal.any([stop.signal, waiting.signal]) : stop.signal;
|
|
2039
|
-
gateway.settled(payment
|
|
2771
|
+
gateway.settled(payment, { ...waiting, signal }).then((ended) => arrived(paidOnly(ended))).catch((reason) => {
|
|
2040
2772
|
if (!stop.signal.aborted) {
|
|
2041
2773
|
failed?.(reason);
|
|
2042
2774
|
}
|
|
@@ -2059,7 +2791,7 @@ function waitingOf(options) {
|
|
|
2059
2791
|
}
|
|
2060
2792
|
|
|
2061
2793
|
// src/trigger.ts
|
|
2062
|
-
var
|
|
2794
|
+
var NONCE_BYTES3 = 16;
|
|
2063
2795
|
function lnurlPayEndpoint(gateway, config) {
|
|
2064
2796
|
return async (request) => {
|
|
2065
2797
|
const url = new URL(request.url);
|
|
@@ -2085,22 +2817,37 @@ function publicWatchTicketEndpoint(gateway, config) {
|
|
|
2085
2817
|
}
|
|
2086
2818
|
async function offer(gateway, config, url) {
|
|
2087
2819
|
const { least, most } = await spread(config.amount);
|
|
2088
|
-
const
|
|
2820
|
+
const winner = await quoted(gateway, config, least);
|
|
2089
2821
|
const nonce = randomNonce();
|
|
2090
2822
|
const callback = new URL(config.baseUrl ?? `${url.origin}${url.pathname}`);
|
|
2091
|
-
callback.searchParams.set("to",
|
|
2823
|
+
callback.searchParams.set("to", winner.address);
|
|
2092
2824
|
callback.searchParams.set("least", String(least));
|
|
2093
2825
|
callback.searchParams.set("most", String(most));
|
|
2094
2826
|
callback.searchParams.set("n", nonce);
|
|
2095
|
-
callback.searchParams.set("sig", await sign(config.secret,
|
|
2827
|
+
callback.searchParams.set("sig", await sign(config.secret, winner.address, least, most, nonce));
|
|
2096
2828
|
return Response.json({
|
|
2097
2829
|
tag: "payRequest",
|
|
2098
2830
|
callback: callback.toString(),
|
|
2099
|
-
metadata:
|
|
2831
|
+
metadata: winner.metadata,
|
|
2100
2832
|
minSendable: least,
|
|
2101
2833
|
maxSendable: most
|
|
2102
2834
|
});
|
|
2103
2835
|
}
|
|
2836
|
+
async function quoted(gateway, config, amountMsat) {
|
|
2837
|
+
if (config.blind) {
|
|
2838
|
+
const { won } = await quote(
|
|
2839
|
+
config.send ?? pinnedToTheAddressWeVerified,
|
|
2840
|
+
listed(config.paidTo),
|
|
2841
|
+
amountMsat
|
|
2842
|
+
);
|
|
2843
|
+
return won;
|
|
2844
|
+
}
|
|
2845
|
+
const { lnAddress, metadata } = await gateway.quote({
|
|
2846
|
+
paidTo: config.paidTo,
|
|
2847
|
+
amount: msat(amountMsat)
|
|
2848
|
+
});
|
|
2849
|
+
return { address: lnAddress, metadata };
|
|
2850
|
+
}
|
|
2104
2851
|
async function spread(amount) {
|
|
2105
2852
|
if (typeof amount === "number" || typeof amount === "function") {
|
|
2106
2853
|
const pinned = await amountNow(amount);
|
|
@@ -2122,6 +2869,9 @@ async function mint(gateway, config, url, address) {
|
|
|
2122
2869
|
if (!equalInConstantTime(signature, expected)) {
|
|
2123
2870
|
return refuse("this callback was not signed here");
|
|
2124
2871
|
}
|
|
2872
|
+
if (!listed(config.paidTo).some((one) => one.toLowerCase() === address.toLowerCase())) {
|
|
2873
|
+
return refuse(`${address} is not paid through this trigger`);
|
|
2874
|
+
}
|
|
2125
2875
|
const asked = url.searchParams.get("amount");
|
|
2126
2876
|
const amountMsat = asked === null ? least : Number(asked);
|
|
2127
2877
|
if (!Number.isSafeInteger(amountMsat) || amountMsat < least || amountMsat > most) {
|
|
@@ -2164,7 +2914,7 @@ async function mintBlind(gateway, config, address, amountMsat) {
|
|
|
2164
2914
|
expiresAt: resolved2.expiresAt,
|
|
2165
2915
|
trigger: config.watchSecret,
|
|
2166
2916
|
replay: config.replay,
|
|
2167
|
-
sealed: locked ? await seal(locked.secret, JSON.stringify(locked.data(minted))) : void 0
|
|
2917
|
+
sealed: locked ? await seal(locked.secret, JSON.stringify(locked.data(minted)), minted.paymentHash) : void 0
|
|
2168
2918
|
});
|
|
2169
2919
|
} catch (refused) {
|
|
2170
2920
|
if (!alreadyWatched(refused)) {
|
|
@@ -2173,14 +2923,17 @@ async function mintBlind(gateway, config, address, amountMsat) {
|
|
|
2173
2923
|
}
|
|
2174
2924
|
return { bolt11: resolved2.bolt11, verifyUrl: resolved2.verifyUrl };
|
|
2175
2925
|
}
|
|
2926
|
+
function listed(paidTo) {
|
|
2927
|
+
return typeof paidTo === "string" ? [paidTo] : paidTo;
|
|
2928
|
+
}
|
|
2176
2929
|
function alreadyWatched(refused) {
|
|
2177
2930
|
return refused instanceof ProblemError && isProblemType(refused, PAYMENT_ALREADY_WATCHED);
|
|
2178
2931
|
}
|
|
2179
2932
|
function sign(secret, address, least, most, nonce) {
|
|
2180
|
-
return hmacHex(secret,
|
|
2933
|
+
return hmacHex(secret, `lnurl-callback|${address}|${least}|${most}|${nonce}`);
|
|
2181
2934
|
}
|
|
2182
2935
|
function randomNonce() {
|
|
2183
|
-
const bytes = crypto.getRandomValues(new Uint8Array(
|
|
2936
|
+
const bytes = crypto.getRandomValues(new Uint8Array(NONCE_BYTES3));
|
|
2184
2937
|
let hex = "";
|
|
2185
2938
|
for (const byte of bytes) {
|
|
2186
2939
|
hex += byte.toString(16).padStart(2, "0");
|
|
@@ -2230,23 +2983,39 @@ var Serve = class {
|
|
|
2230
2983
|
* A verify endpoint of your own that asks the recipient's wallet for you, so
|
|
2231
2984
|
* the gateway polls you and never the wallet
|
|
2232
2985
|
*/
|
|
2233
|
-
|
|
2986
|
+
lightningVerify(config) {
|
|
2234
2987
|
return lightningVerifyEndpoint(config);
|
|
2235
2988
|
}
|
|
2989
|
+
/**
|
|
2990
|
+
* What `lightningVerify` was called before 2.2.0
|
|
2991
|
+
*
|
|
2992
|
+
* @deprecated Use `lightningVerify`, beside `bankVerify` and `nwcVerify`
|
|
2993
|
+
*/
|
|
2994
|
+
verify(config) {
|
|
2995
|
+
return this.lightningVerify(config);
|
|
2996
|
+
}
|
|
2236
2997
|
/** The verify endpoint a bank rail is polled at, answering off your own statement */
|
|
2237
2998
|
bankVerify(config) {
|
|
2238
2999
|
return bankVerifyEndpoint(config);
|
|
2239
3000
|
}
|
|
3001
|
+
/**
|
|
3002
|
+
* The verify endpoint an NWC rail is polled at, asking your own wallet over
|
|
3003
|
+
* NIP-47, so the gateway never learns the connection, the relay or the wallet
|
|
3004
|
+
*/
|
|
3005
|
+
nwcVerify(config) {
|
|
3006
|
+
return nwcVerifyEndpoint(config);
|
|
3007
|
+
}
|
|
2240
3008
|
/**
|
|
2241
3009
|
* The whole webhook route: it answers the gateway's challenge, checks the
|
|
2242
3010
|
* signature against the key the gateway publishes, refuses a settlement that
|
|
2243
|
-
* proves nothing, and calls you for the one that does.
|
|
3011
|
+
* proves nothing, and calls you once for the one that does.
|
|
2244
3012
|
*
|
|
2245
3013
|
* `export const POST = gateway.serve.webhook({ onSettled: fulfil })` is the
|
|
2246
3014
|
* entire integration
|
|
2247
3015
|
*/
|
|
2248
3016
|
webhook(handlers) {
|
|
2249
|
-
const options = { toleranceSecs: handlers.toleranceSecs };
|
|
3017
|
+
const options = { toleranceSecs: handlers.toleranceSecs, url: handlers.url };
|
|
3018
|
+
const once = onceWhileReplayable(handlers.toleranceSecs ?? DEFAULT_TOLERANCE_SECS);
|
|
2250
3019
|
return async (request) => {
|
|
2251
3020
|
const credential = await this.credential(handlers);
|
|
2252
3021
|
const challenge = await answerWebhookChallenge(request, credential, options);
|
|
@@ -2255,12 +3024,17 @@ var Serve = class {
|
|
|
2255
3024
|
}
|
|
2256
3025
|
const settlement = await readSettlement(request, credential, options);
|
|
2257
3026
|
if (settlement !== null) {
|
|
2258
|
-
return await
|
|
3027
|
+
return await once(
|
|
3028
|
+
`${settlement.id}:${settlement.status}`,
|
|
3029
|
+
() => acted(settlement, handlers)
|
|
3030
|
+
);
|
|
2259
3031
|
}
|
|
2260
3032
|
const payment = await readPayment(request, credential, options);
|
|
2261
3033
|
if (payment !== null) {
|
|
2262
|
-
await
|
|
2263
|
-
|
|
3034
|
+
return await once(`${payment.id}:${payment.status}`, async () => {
|
|
3035
|
+
await handlers.onPayment?.(payment);
|
|
3036
|
+
return new Response("ok");
|
|
3037
|
+
});
|
|
2264
3038
|
}
|
|
2265
3039
|
return new Response("bad signature", { status: 401 });
|
|
2266
3040
|
};
|
|
@@ -2281,8 +3055,34 @@ var Serve = class {
|
|
|
2281
3055
|
return handlers.credential ?? { publicKey: await this.gateway.webhookKey() };
|
|
2282
3056
|
}
|
|
2283
3057
|
};
|
|
3058
|
+
function onceWhileReplayable(toleranceSecs) {
|
|
3059
|
+
const acting = /* @__PURE__ */ new Map();
|
|
3060
|
+
const replayableForMs = 2 * toleranceSecs * 1e3;
|
|
3061
|
+
return async (delivery, act) => {
|
|
3062
|
+
const now = Date.now();
|
|
3063
|
+
for (const [earlier2, { forgetAt }] of acting) {
|
|
3064
|
+
if (forgetAt > now) {
|
|
3065
|
+
break;
|
|
3066
|
+
}
|
|
3067
|
+
acting.delete(earlier2);
|
|
3068
|
+
}
|
|
3069
|
+
const earlier = acting.get(delivery);
|
|
3070
|
+
if (earlier !== void 0) {
|
|
3071
|
+
await earlier.done;
|
|
3072
|
+
return new Response("ok");
|
|
3073
|
+
}
|
|
3074
|
+
const done = act();
|
|
3075
|
+
acting.set(delivery, { forgetAt: now + replayableForMs, done });
|
|
3076
|
+
try {
|
|
3077
|
+
return await done;
|
|
3078
|
+
} catch (failure) {
|
|
3079
|
+
acting.delete(delivery);
|
|
3080
|
+
throw failure;
|
|
3081
|
+
}
|
|
3082
|
+
};
|
|
3083
|
+
}
|
|
2284
3084
|
async function acted(settlement, handlers) {
|
|
2285
|
-
if (
|
|
3085
|
+
if (agreesWithItself(settlement)) {
|
|
2286
3086
|
await handlers.onSettled?.(settlement);
|
|
2287
3087
|
return new Response("ok");
|
|
2288
3088
|
}
|
|
@@ -2312,6 +3112,21 @@ function backoffMs(firstDelay, attempt) {
|
|
|
2312
3112
|
const grown = Math.min(firstDelay * 2 ** (attempt - 1), RECONNECT_CAP_MS);
|
|
2313
3113
|
return Math.round(grown * (0.5 + Math.random() / 2));
|
|
2314
3114
|
}
|
|
3115
|
+
function askFromWire(raw) {
|
|
3116
|
+
let said;
|
|
3117
|
+
try {
|
|
3118
|
+
said = JSON.parse(raw);
|
|
3119
|
+
} catch {
|
|
3120
|
+
return null;
|
|
3121
|
+
}
|
|
3122
|
+
if (typeof said !== "object" || said === null) {
|
|
3123
|
+
return null;
|
|
3124
|
+
}
|
|
3125
|
+
const fields = said;
|
|
3126
|
+
const ask2 = fields["ask"];
|
|
3127
|
+
const paymentHash = fields["payment_hash"];
|
|
3128
|
+
return typeof ask2 === "string" && typeof paymentHash === "string" ? { ask: ask2, paymentHash } : null;
|
|
3129
|
+
}
|
|
2315
3130
|
function gatewayAt(baseUrl) {
|
|
2316
3131
|
const at = URL.canParse(baseUrl) ? new URL(baseUrl) : null;
|
|
2317
3132
|
if (at === null || at.protocol !== "http:" && at.protocol !== "https:") {
|
|
@@ -2321,8 +3136,8 @@ function gatewayAt(baseUrl) {
|
|
|
2321
3136
|
}
|
|
2322
3137
|
var ThunderBridge = class {
|
|
2323
3138
|
baseUrl;
|
|
2324
|
-
verify;
|
|
2325
3139
|
token;
|
|
3140
|
+
handedOut = /* @__PURE__ */ new Map();
|
|
2326
3141
|
secret;
|
|
2327
3142
|
strangers = null;
|
|
2328
3143
|
speaks = null;
|
|
@@ -2333,7 +3148,6 @@ var ThunderBridge = class {
|
|
|
2333
3148
|
rails;
|
|
2334
3149
|
constructor(baseUrl, options) {
|
|
2335
3150
|
this.baseUrl = gatewayAt(baseUrl);
|
|
2336
|
-
this.verify = options?.verify ?? true;
|
|
2337
3151
|
this.token = options?.token ?? null;
|
|
2338
3152
|
this.secret = options?.secret ?? null;
|
|
2339
3153
|
this.serve = new Serve(this);
|
|
@@ -2375,6 +3189,7 @@ var ThunderBridge = class {
|
|
|
2375
3189
|
* and `GatewayCheatError` when what comes back is not what you asked for
|
|
2376
3190
|
*/
|
|
2377
3191
|
async mint(charge, options) {
|
|
3192
|
+
const askedAt = Math.floor(Date.now() / 1e3);
|
|
2378
3193
|
const asked = await priced(charge);
|
|
2379
3194
|
const sent = createRequestBody(
|
|
2380
3195
|
asked,
|
|
@@ -2395,9 +3210,8 @@ var ThunderBridge = class {
|
|
|
2395
3210
|
throw await problemFrom(response);
|
|
2396
3211
|
}
|
|
2397
3212
|
const payment = await mintedFrom(response);
|
|
2398
|
-
|
|
2399
|
-
|
|
2400
|
-
}
|
|
3213
|
+
await proveOrigin(payment, asked, askedAt);
|
|
3214
|
+
this.handOut(payment, options?.idempotencyKey ?? null);
|
|
2401
3215
|
return payment;
|
|
2402
3216
|
}
|
|
2403
3217
|
/**
|
|
@@ -2408,7 +3222,8 @@ var ThunderBridge = class {
|
|
|
2408
3222
|
* it for one, and asking mints it
|
|
2409
3223
|
*/
|
|
2410
3224
|
async quote(charge) {
|
|
2411
|
-
const
|
|
3225
|
+
const asked = await priced(charge);
|
|
3226
|
+
const sent = quoteRequestBody(asked);
|
|
2412
3227
|
const response = await fetch(`${this.baseUrl}/quotes`, {
|
|
2413
3228
|
method: "POST",
|
|
2414
3229
|
headers: await this.sending("/quotes", sent),
|
|
@@ -2417,14 +3232,17 @@ var ThunderBridge = class {
|
|
|
2417
3232
|
if (!response.ok) {
|
|
2418
3233
|
throw await problemFrom(response);
|
|
2419
3234
|
}
|
|
2420
|
-
const
|
|
2421
|
-
if (
|
|
3235
|
+
const quote2 = quoteFromWire(await response.json().catch(() => null));
|
|
3236
|
+
if (quote2 === null) {
|
|
2422
3237
|
throw new ProblemError({
|
|
2423
3238
|
status: response.status,
|
|
2424
3239
|
title: "The gateway answered with something that is not a quote"
|
|
2425
3240
|
});
|
|
2426
3241
|
}
|
|
2427
|
-
|
|
3242
|
+
if (!asked.paidTo.some((address) => address.toLowerCase() === quote2.lnAddress.toLowerCase())) {
|
|
3243
|
+
throw new GatewayCheatError("address_not_requested", "");
|
|
3244
|
+
}
|
|
3245
|
+
return quote2;
|
|
2428
3246
|
}
|
|
2429
3247
|
/**
|
|
2430
3248
|
* The key this gateway signs webhooks with when you registered none of your own.
|
|
@@ -2457,8 +3275,8 @@ var ThunderBridge = class {
|
|
|
2457
3275
|
* for both sorts: `kind` says whether the gateway minted it or was handed it,
|
|
2458
3276
|
* and the address, amount and invoice are null on one it was never told
|
|
2459
3277
|
*/
|
|
2460
|
-
async payment(
|
|
2461
|
-
const path = `/incoming-payments/${encodeURIComponent(id)}`;
|
|
3278
|
+
async payment(held) {
|
|
3279
|
+
const path = `/incoming-payments/${encodeURIComponent(held.id)}`;
|
|
2462
3280
|
const response = await fetch(`${this.baseUrl}${path}`, {
|
|
2463
3281
|
headers: await this.reading("GET", path)
|
|
2464
3282
|
});
|
|
@@ -2468,7 +3286,7 @@ var ThunderBridge = class {
|
|
|
2468
3286
|
if (!response.ok) {
|
|
2469
3287
|
throw await problemFrom(response);
|
|
2470
3288
|
}
|
|
2471
|
-
return this.proven(await paymentFrom(response));
|
|
3289
|
+
return this.proven(await paymentFrom(response), held);
|
|
2472
3290
|
}
|
|
2473
3291
|
/**
|
|
2474
3292
|
* List what this gateway is watching, newest first. Only a gateway started
|
|
@@ -2488,14 +3306,14 @@ var ThunderBridge = class {
|
|
|
2488
3306
|
throw await problemFrom(response);
|
|
2489
3307
|
}
|
|
2490
3308
|
const body = await response.json().catch(() => null);
|
|
2491
|
-
const
|
|
2492
|
-
if (
|
|
3309
|
+
const listed2 = Array.isArray(body?.payments) ? body.payments.map(paymentFromWire).filter((one) => one !== null).filter((one) => one.status !== "paid" || agreesWithItself(one)) : null;
|
|
3310
|
+
if (listed2 === null || typeof body?.settled_scanned !== "number") {
|
|
2493
3311
|
throw new ProblemError({
|
|
2494
3312
|
status: response.status,
|
|
2495
3313
|
title: "The gateway answered with something that is not a payment list"
|
|
2496
3314
|
});
|
|
2497
3315
|
}
|
|
2498
|
-
return { payments:
|
|
3316
|
+
return { payments: listed2, scanned: body.settled_scanned };
|
|
2499
3317
|
}
|
|
2500
3318
|
/**
|
|
2501
3319
|
* Follow a payment over WebSocket until it is paid or expired, reconnecting
|
|
@@ -2503,15 +3321,15 @@ var ThunderBridge = class {
|
|
|
2503
3321
|
* one that has answered is followed until its own expiry, so the wait always
|
|
2504
3322
|
* ends by itself
|
|
2505
3323
|
*/
|
|
2506
|
-
async settled(
|
|
2507
|
-
const ended = paymentFromWire(await this.followed(id, options));
|
|
3324
|
+
async settled(held, options) {
|
|
3325
|
+
const ended = paymentFromWire(await this.followed(held.id, options));
|
|
2508
3326
|
if (ended === null) {
|
|
2509
3327
|
throw new ProblemError({
|
|
2510
3328
|
status: 200,
|
|
2511
3329
|
title: "The gateway answered with something that is not a payment"
|
|
2512
3330
|
});
|
|
2513
3331
|
}
|
|
2514
|
-
return this.proven(ended);
|
|
3332
|
+
return this.proven(ended, held);
|
|
2515
3333
|
}
|
|
2516
3334
|
followed(id, options) {
|
|
2517
3335
|
const base = this.baseUrl.replace(/^http/, "ws");
|
|
@@ -2573,9 +3391,9 @@ var ThunderBridge = class {
|
|
|
2573
3391
|
if (settled) {
|
|
2574
3392
|
return;
|
|
2575
3393
|
}
|
|
2576
|
-
const
|
|
2577
|
-
socket =
|
|
2578
|
-
|
|
3394
|
+
const opened2 = new WebSocket(url);
|
|
3395
|
+
socket = opened2;
|
|
3396
|
+
opened2.onmessage = (event) => {
|
|
2579
3397
|
try {
|
|
2580
3398
|
const frame = JSON.parse(String(event.data));
|
|
2581
3399
|
const watched = paymentFromWire(frame);
|
|
@@ -2592,7 +3410,7 @@ var ThunderBridge = class {
|
|
|
2592
3410
|
settle(() => reject(refused));
|
|
2593
3411
|
}
|
|
2594
3412
|
};
|
|
2595
|
-
|
|
3413
|
+
opened2.onclose = again;
|
|
2596
3414
|
};
|
|
2597
3415
|
options?.signal?.addEventListener("abort", abort, { once: true });
|
|
2598
3416
|
void connect();
|
|
@@ -2615,25 +3433,33 @@ var ThunderBridge = class {
|
|
|
2615
3433
|
* the recipient's own wallet minted it, so a payer who pays the loser afterwards
|
|
2616
3434
|
* really does pay twice and that shows up on `follow` as a second settlement to
|
|
2617
3435
|
* refund.
|
|
3436
|
+
*
|
|
3437
|
+
* A leg the gateway is caught lying about before any leg is paid ends the wait
|
|
3438
|
+
* with its `GatewayCheatError`, even when another leg is paid after it, so a
|
|
3439
|
+
* detected cheat is never traded for a later win.
|
|
2618
3440
|
*/
|
|
2619
|
-
async firstSettled(
|
|
2620
|
-
if (
|
|
3441
|
+
async firstSettled(held, options) {
|
|
3442
|
+
if (held.length === 0) {
|
|
2621
3443
|
return null;
|
|
2622
3444
|
}
|
|
2623
3445
|
const stopLosers = new AbortController();
|
|
2624
3446
|
const signal = options?.signal ? AbortSignal.any([stopLosers.signal, options.signal]) : stopLosers.signal;
|
|
2625
3447
|
let refused = null;
|
|
2626
3448
|
try {
|
|
2627
|
-
const winner = await new Promise((resolve2) => {
|
|
2628
|
-
let waiting =
|
|
3449
|
+
const winner = await new Promise((resolve2, caught) => {
|
|
3450
|
+
let waiting = held.length;
|
|
2629
3451
|
const lost = () => {
|
|
2630
3452
|
waiting -= 1;
|
|
2631
3453
|
if (waiting === 0) {
|
|
2632
3454
|
resolve2(null);
|
|
2633
3455
|
}
|
|
2634
3456
|
};
|
|
2635
|
-
for (const
|
|
2636
|
-
this.settled(
|
|
3457
|
+
for (const leg of held) {
|
|
3458
|
+
this.settled(leg, { ...options, signal }).then((watched) => watched.status === "paid" ? resolve2(watched) : lost()).catch((failure) => {
|
|
3459
|
+
if (failure instanceof GatewayCheatError) {
|
|
3460
|
+
caught(failure);
|
|
3461
|
+
return;
|
|
3462
|
+
}
|
|
2637
3463
|
refused ??= failure;
|
|
2638
3464
|
lost();
|
|
2639
3465
|
});
|
|
@@ -2677,6 +3503,9 @@ var ThunderBridge = class {
|
|
|
2677
3503
|
if (named !== null && watched.id !== named) {
|
|
2678
3504
|
throw new GatewayCheatError("id_not_mine", watched.id);
|
|
2679
3505
|
}
|
|
3506
|
+
if (watched.paymentHash.toLowerCase() !== handover.paymentHash.toLowerCase()) {
|
|
3507
|
+
throw new GatewayCheatError("hash_mismatch", watched.id);
|
|
3508
|
+
}
|
|
2680
3509
|
return watched;
|
|
2681
3510
|
}
|
|
2682
3511
|
/**
|
|
@@ -2689,6 +3518,69 @@ var ThunderBridge = class {
|
|
|
2689
3518
|
async nameFor(paymentHash) {
|
|
2690
3519
|
return this.secret === null ? null : paymentNamedBy((await this.speaking()).publicKeyHex, paymentHash);
|
|
2691
3520
|
}
|
|
3521
|
+
/**
|
|
3522
|
+
* Hold a socket open and answer what the gateway asks about this caller's own
|
|
3523
|
+
* payments, so a watch addressed to this caller settles without anybody
|
|
3524
|
+
* hosting a URL. Reconnects on its own until the returned function is called
|
|
3525
|
+
*/
|
|
3526
|
+
attend(options) {
|
|
3527
|
+
const base = this.baseUrl.replace(/^http/, "ws");
|
|
3528
|
+
const firstDelay = options.reconnectDelayMs ?? RECONNECT_DELAY_MS;
|
|
3529
|
+
let socket = null;
|
|
3530
|
+
let retry;
|
|
3531
|
+
let stopped = false;
|
|
3532
|
+
let attempt = 0;
|
|
3533
|
+
const again = () => {
|
|
3534
|
+
if (stopped || options.reconnect === false) {
|
|
3535
|
+
return;
|
|
3536
|
+
}
|
|
3537
|
+
retry = setTimeout(() => void connect(), backoffMs(firstDelay, attempt));
|
|
3538
|
+
};
|
|
3539
|
+
const answered3 = async (opened2, raw) => {
|
|
3540
|
+
const asked = askFromWire(raw);
|
|
3541
|
+
if (asked === null) {
|
|
3542
|
+
return;
|
|
3543
|
+
}
|
|
3544
|
+
const preimage = await options.answer(asked.paymentHash);
|
|
3545
|
+
opened2.send(
|
|
3546
|
+
JSON.stringify(
|
|
3547
|
+
preimage === null ? { ask: asked.ask, settled: false } : { ask: asked.ask, settled: true, preimage }
|
|
3548
|
+
)
|
|
3549
|
+
);
|
|
3550
|
+
};
|
|
3551
|
+
const connect = async () => {
|
|
3552
|
+
attempt += 1;
|
|
3553
|
+
let url;
|
|
3554
|
+
try {
|
|
3555
|
+
url = `${base}/ws/tickets/${await this.wsTicket({ agent: true })}`;
|
|
3556
|
+
} catch (refused) {
|
|
3557
|
+
options.onError?.(refused);
|
|
3558
|
+
again();
|
|
3559
|
+
return;
|
|
3560
|
+
}
|
|
3561
|
+
if (stopped) {
|
|
3562
|
+
return;
|
|
3563
|
+
}
|
|
3564
|
+
const opened2 = new WebSocket(url);
|
|
3565
|
+
socket = opened2;
|
|
3566
|
+
opened2.onopen = () => {
|
|
3567
|
+
attempt = 1;
|
|
3568
|
+
};
|
|
3569
|
+
opened2.onmessage = (event) => {
|
|
3570
|
+
void answered3(opened2, String(event.data)).catch(
|
|
3571
|
+
(refused) => options.onError?.(refused)
|
|
3572
|
+
);
|
|
3573
|
+
};
|
|
3574
|
+
opened2.onerror = () => options.onError?.(new Error(`could not attend ${base}`));
|
|
3575
|
+
opened2.onclose = again;
|
|
3576
|
+
};
|
|
3577
|
+
void connect();
|
|
3578
|
+
return () => {
|
|
3579
|
+
stopped = true;
|
|
3580
|
+
clearTimeout(retry);
|
|
3581
|
+
socket?.close();
|
|
3582
|
+
};
|
|
3583
|
+
}
|
|
2692
3584
|
/**
|
|
2693
3585
|
* Follow every payment made to one trigger, replayed from the recent ones on
|
|
2694
3586
|
* connect and then live, reconnecting on its own until the returned function
|
|
@@ -2765,7 +3657,7 @@ var ThunderBridge = class {
|
|
|
2765
3657
|
});
|
|
2766
3658
|
}
|
|
2767
3659
|
needsTicket(asked) {
|
|
2768
|
-
return asked === true || this.token !== null;
|
|
3660
|
+
return asked === true || this.token !== null || this.secret !== null;
|
|
2769
3661
|
}
|
|
2770
3662
|
async wsTicket(body) {
|
|
2771
3663
|
return encodeURIComponent((await this.mintedTicket(body)).ticket);
|
|
@@ -2795,15 +3687,33 @@ var ThunderBridge = class {
|
|
|
2795
3687
|
async reading(method, path, body = "") {
|
|
2796
3688
|
return {
|
|
2797
3689
|
...this.token === null ? {} : { authorization: `Bearer ${this.token}` },
|
|
2798
|
-
...this.secret === null ? {} : await signedAs(await this.speaking(), method, path, body)
|
|
3690
|
+
...this.secret === null ? {} : await signedAs(await this.speaking(), method, path, body, new URL(this.baseUrl).host)
|
|
2799
3691
|
};
|
|
2800
3692
|
}
|
|
2801
3693
|
async speaking() {
|
|
2802
3694
|
this.speaks ??= callerKey(this.secret ?? "");
|
|
2803
3695
|
return await this.speaks;
|
|
2804
3696
|
}
|
|
2805
|
-
|
|
2806
|
-
|
|
3697
|
+
handOut(payment, key) {
|
|
3698
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
3699
|
+
for (const [hash2, earlier2] of this.handedOut) {
|
|
3700
|
+
if (earlier2.expiresAt <= now) {
|
|
3701
|
+
this.handedOut.delete(hash2);
|
|
3702
|
+
}
|
|
3703
|
+
}
|
|
3704
|
+
const hash = payment.paymentHash.toLowerCase();
|
|
3705
|
+
const earlier = this.handedOut.get(hash);
|
|
3706
|
+
if (earlier !== void 0 && (earlier.key === null || earlier.key !== key)) {
|
|
3707
|
+
throw new GatewayCheatError("invoice_reused", payment.id);
|
|
3708
|
+
}
|
|
3709
|
+
this.handedOut.set(hash, { key, expiresAt: payment.expiresAt });
|
|
3710
|
+
}
|
|
3711
|
+
proven(payment, held) {
|
|
3712
|
+
const other = held !== void 0 && (payment.id !== held.id || payment.paymentHash.toLowerCase() !== held.paymentHash.toLowerCase());
|
|
3713
|
+
if (other) {
|
|
3714
|
+
throw new GatewayCheatError("hash_mismatch", payment.id);
|
|
3715
|
+
}
|
|
3716
|
+
if (payment.status === "paid" && !agreesWithItself(payment)) {
|
|
2807
3717
|
throw new GatewayCheatError("preimage_mismatch", payment.id);
|
|
2808
3718
|
}
|
|
2809
3719
|
return payment;
|
|
@@ -2857,6 +3767,7 @@ var Gateways = class {
|
|
|
2857
3767
|
each;
|
|
2858
3768
|
urls;
|
|
2859
3769
|
onRefused;
|
|
3770
|
+
onCaught;
|
|
2860
3771
|
constructor(baseUrls, options) {
|
|
2861
3772
|
if (baseUrls.length === 0) {
|
|
2862
3773
|
throw new Error("name at least one gateway to watch at");
|
|
@@ -2865,6 +3776,8 @@ var Gateways = class {
|
|
|
2865
3776
|
this.each = baseUrls.map((baseUrl) => new ThunderBridge(baseUrl, options));
|
|
2866
3777
|
this.onRefused = options?.onRefused ?? (() => {
|
|
2867
3778
|
});
|
|
3779
|
+
this.onCaught = options?.onCaught ?? (() => {
|
|
3780
|
+
});
|
|
2868
3781
|
}
|
|
2869
3782
|
/** What this payment is called, which every gateway here will agree on */
|
|
2870
3783
|
async nameFor(paymentHash) {
|
|
@@ -2898,7 +3811,7 @@ var Gateways = class {
|
|
|
2898
3811
|
* saying so. When they all end without a payment, the first ending is the answer,
|
|
2899
3812
|
* and when they all fail, the first failure is thrown
|
|
2900
3813
|
*/
|
|
2901
|
-
async settled(
|
|
3814
|
+
async settled(held, options) {
|
|
2902
3815
|
const stopLosers = new AbortController();
|
|
2903
3816
|
const signal = options?.signal ? AbortSignal.any([stopLosers.signal, options.signal]) : stopLosers.signal;
|
|
2904
3817
|
const unpaid = [];
|
|
@@ -2912,14 +3825,17 @@ var Gateways = class {
|
|
|
2912
3825
|
resolve2(null);
|
|
2913
3826
|
}
|
|
2914
3827
|
};
|
|
2915
|
-
for (const gateway of this.each) {
|
|
2916
|
-
gateway.settled(
|
|
3828
|
+
for (const [at, gateway] of this.each.entries()) {
|
|
3829
|
+
gateway.settled(held, { ...options, signal }).then((watched) => {
|
|
2917
3830
|
if (watched.status === "paid") {
|
|
2918
3831
|
return resolve2(watched);
|
|
2919
3832
|
}
|
|
2920
3833
|
unpaid.push(watched);
|
|
2921
3834
|
lost();
|
|
2922
3835
|
}).catch((failure) => {
|
|
3836
|
+
if (failure instanceof GatewayCheatError) {
|
|
3837
|
+
this.onCaught(this.urls[at], failure);
|
|
3838
|
+
}
|
|
2923
3839
|
refused ??= failure;
|
|
2924
3840
|
lost();
|
|
2925
3841
|
});
|
|
@@ -2947,6 +3863,7 @@ export {
|
|
|
2947
3863
|
ThunderBridge,
|
|
2948
3864
|
UnverifiedRecipientError,
|
|
2949
3865
|
WrapRefusedError,
|
|
3866
|
+
agreesWithItself,
|
|
2950
3867
|
answerVerifyChallenge,
|
|
2951
3868
|
carriesProof,
|
|
2952
3869
|
decodeInvoice,
|