thunder-bridge 1.1.0 → 1.4.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 +115 -6
- package/dist/index.cjs +1677 -1301
- package/dist/index.d.cts +217 -166
- package/dist/index.d.ts +217 -166
- package/dist/index.js +1673 -1300
- package/dist/{rail-Dp8bs6uZ.d.cts → rail-CqUfuYXJ.d.cts} +176 -9
- package/dist/{rail-Dp8bs6uZ.d.ts → rail-CqUfuYXJ.d.ts} +176 -9
- package/dist/server.cjs +1003 -381
- package/dist/server.d.cts +79 -37
- package/dist/server.d.ts +79 -37
- package/dist/server.js +991 -380
- package/openapi.yaml +1 -1
- package/package.json +33 -13
package/dist/server.cjs
CHANGED
|
@@ -30,18 +30,31 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
|
|
|
30
30
|
// src/server.ts
|
|
31
31
|
var server_exports = {};
|
|
32
32
|
__export(server_exports, {
|
|
33
|
+
askWallet: () => askWallet,
|
|
33
34
|
blindLightningRail: () => blindLightningRail,
|
|
34
35
|
invoiceFrom: () => invoiceFrom,
|
|
35
36
|
lightningVerifyEndpoint: () => lightningVerifyEndpoint,
|
|
36
37
|
lnurlPayEndpoint: () => lnurlPayEndpoint,
|
|
37
|
-
|
|
38
|
+
nwcConnection: () => nwcConnection,
|
|
39
|
+
nwcHoldInvoice: () => nwcHoldInvoice,
|
|
40
|
+
nwcInvoice: () => nwcInvoice,
|
|
41
|
+
nwcPay: () => nwcPay,
|
|
42
|
+
nwcRail: () => nwcRail,
|
|
43
|
+
nwcSettlement: () => nwcSettlement,
|
|
44
|
+
nwcVerifyEndpoint: () => nwcVerifyEndpoint,
|
|
45
|
+
nwcVerifyUrl: () => nwcVerifyUrl,
|
|
46
|
+
publicWatchTicketEndpoint: () => publicWatchTicketEndpoint,
|
|
47
|
+
relayedVerifyUrl: () => relayedVerifyUrl,
|
|
48
|
+
watchTicketEndpoint: () => watchTicketEndpoint
|
|
38
49
|
});
|
|
39
50
|
module.exports = __toCommonJS(server_exports);
|
|
40
51
|
|
|
41
52
|
// ../core/bytes.ts
|
|
42
53
|
function bytesToHex(bytes) {
|
|
43
54
|
let hex = "";
|
|
44
|
-
for (const byte of bytes)
|
|
55
|
+
for (const byte of bytes) {
|
|
56
|
+
hex += byte.toString(16).padStart(2, "0");
|
|
57
|
+
}
|
|
45
58
|
return hex;
|
|
46
59
|
}
|
|
47
60
|
function hexToBytes(hex) {
|
|
@@ -55,29 +68,6 @@ function isHex(text2) {
|
|
|
55
68
|
return /^[0-9a-f]+$/i.test(text2) && text2.length % 2 === 0;
|
|
56
69
|
}
|
|
57
70
|
|
|
58
|
-
// ../core/hmac.ts
|
|
59
|
-
async function hmacHex(secret, payload) {
|
|
60
|
-
const raw = typeof secret === "string" ? new TextEncoder().encode(secret) : secret;
|
|
61
|
-
const key = await crypto.subtle.importKey(
|
|
62
|
-
"raw",
|
|
63
|
-
asBuffer(raw),
|
|
64
|
-
{ name: "HMAC", hash: "SHA-256" },
|
|
65
|
-
false,
|
|
66
|
-
["sign"]
|
|
67
|
-
);
|
|
68
|
-
const message = typeof payload === "string" ? new TextEncoder().encode(payload) : payload;
|
|
69
|
-
return bytesToHex(new Uint8Array(await crypto.subtle.sign("HMAC", key, asBuffer(message))));
|
|
70
|
-
}
|
|
71
|
-
function equalInConstantTime(one, other) {
|
|
72
|
-
if (one.length !== other.length) return false;
|
|
73
|
-
let difference = 0;
|
|
74
|
-
for (let at = 0; at < one.length; at++) difference |= one.charCodeAt(at) ^ other.charCodeAt(at);
|
|
75
|
-
return difference === 0;
|
|
76
|
-
}
|
|
77
|
-
function asBuffer(bytes) {
|
|
78
|
-
return bytes instanceof Uint8Array && bytes.buffer instanceof ArrayBuffer ? bytes : new Uint8Array(bytes);
|
|
79
|
-
}
|
|
80
|
-
|
|
81
71
|
// ../core/sha256.ts
|
|
82
72
|
var ROUND_CONSTANTS = new Uint32Array([
|
|
83
73
|
1116352408,
|
|
@@ -159,11 +149,11 @@ var BLOCK_BYTES = 64;
|
|
|
159
149
|
var LENGTH_BYTES = 8;
|
|
160
150
|
var SCHEDULE_WORDS = 64;
|
|
161
151
|
function sha256(bytes) {
|
|
162
|
-
const
|
|
163
|
-
const reader = new DataView(
|
|
152
|
+
const padded2 = withPadding(bytes);
|
|
153
|
+
const reader = new DataView(padded2.buffer);
|
|
164
154
|
const state = Uint32Array.from(INITIAL_STATE);
|
|
165
155
|
const schedule = new Uint32Array(SCHEDULE_WORDS);
|
|
166
|
-
for (let block = 0; block <
|
|
156
|
+
for (let block = 0; block < padded2.length; block += BLOCK_BYTES) {
|
|
167
157
|
fillSchedule(schedule, reader, block);
|
|
168
158
|
compress(state, schedule);
|
|
169
159
|
}
|
|
@@ -173,16 +163,18 @@ function sha256Hex(text2) {
|
|
|
173
163
|
return bytesToHex(sha256(new TextEncoder().encode(text2)));
|
|
174
164
|
}
|
|
175
165
|
function withPadding(bytes) {
|
|
176
|
-
const
|
|
166
|
+
const padded2 = new Uint8Array(
|
|
177
167
|
bytes.length + 1 + LENGTH_BYTES + BLOCK_BYTES - 1 & ~(BLOCK_BYTES - 1)
|
|
178
168
|
);
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
new DataView(
|
|
182
|
-
return
|
|
169
|
+
padded2.set(bytes);
|
|
170
|
+
padded2[bytes.length] = 128;
|
|
171
|
+
new DataView(padded2.buffer).setUint32(padded2.length - 4, bytes.length * 8, false);
|
|
172
|
+
return padded2;
|
|
183
173
|
}
|
|
184
174
|
function fillSchedule(schedule, reader, block) {
|
|
185
|
-
for (let at = 0; at < 16; at++)
|
|
175
|
+
for (let at = 0; at < 16; at++) {
|
|
176
|
+
schedule[at] = reader.getUint32(block + at * 4, false);
|
|
177
|
+
}
|
|
186
178
|
for (let at = 16; at < SCHEDULE_WORDS; at++) {
|
|
187
179
|
const recent = schedule[at - 15];
|
|
188
180
|
const distant = schedule[at - 2];
|
|
@@ -216,12 +208,16 @@ function compress(state, schedule) {
|
|
|
216
208
|
a = carried + (mixedA + majority | 0) | 0;
|
|
217
209
|
}
|
|
218
210
|
const rolling = [a, b, c, d, e, f, g, h];
|
|
219
|
-
for (let at = 0; at < state.length; at++)
|
|
211
|
+
for (let at = 0; at < state.length; at++) {
|
|
212
|
+
state[at] = state[at] + rolling[at] | 0;
|
|
213
|
+
}
|
|
220
214
|
}
|
|
221
215
|
function asBytes(state) {
|
|
222
216
|
const digest = new Uint8Array(state.length * 4);
|
|
223
217
|
const writer = new DataView(digest.buffer);
|
|
224
|
-
for (let at = 0; at < state.length; at++)
|
|
218
|
+
for (let at = 0; at < state.length; at++) {
|
|
219
|
+
writer.setUint32(at * 4, state[at], false);
|
|
220
|
+
}
|
|
225
221
|
return digest;
|
|
226
222
|
}
|
|
227
223
|
function rotate(value, by) {
|
|
@@ -262,20 +258,30 @@ function decodeInvoice(bolt11) {
|
|
|
262
258
|
};
|
|
263
259
|
}
|
|
264
260
|
function preimageMatchesHash(preimage, paymentHash) {
|
|
265
|
-
if (!isHex(preimage))
|
|
261
|
+
if (!isHex(preimage)) {
|
|
262
|
+
return false;
|
|
263
|
+
}
|
|
266
264
|
return bytesToHex(sha256(hexToBytes(preimage))) === paymentHash.toLowerCase();
|
|
267
265
|
}
|
|
268
266
|
function splitBech32(bolt11) {
|
|
269
267
|
const lower = bolt11.toLowerCase();
|
|
270
|
-
if (isBolt12Encoding(lower))
|
|
268
|
+
if (isBolt12Encoding(lower)) {
|
|
269
|
+
return null;
|
|
270
|
+
}
|
|
271
271
|
const separator = lower.lastIndexOf("1");
|
|
272
|
-
if (separator < 1)
|
|
272
|
+
if (separator < 1) {
|
|
273
|
+
return null;
|
|
274
|
+
}
|
|
273
275
|
const dataChars = lower.slice(separator + 1);
|
|
274
|
-
if (dataChars.length < CHECKSUM_WORDS)
|
|
276
|
+
if (dataChars.length < CHECKSUM_WORDS) {
|
|
277
|
+
return null;
|
|
278
|
+
}
|
|
275
279
|
const words = [];
|
|
276
280
|
for (const char of dataChars) {
|
|
277
281
|
const value = BECH32_CHARSET.indexOf(char);
|
|
278
|
-
if (value === -1)
|
|
282
|
+
if (value === -1) {
|
|
283
|
+
return null;
|
|
284
|
+
}
|
|
279
285
|
words.push(value);
|
|
280
286
|
}
|
|
281
287
|
return { hrp: lower.slice(0, separator), words: words.slice(0, -CHECKSUM_WORDS) };
|
|
@@ -291,25 +297,35 @@ function taggedFields(words) {
|
|
|
291
297
|
const lengthWords = words[cursor + 1] * 32 + words[cursor + 2];
|
|
292
298
|
const dataStart = cursor + 1 + TAGGED_FIELD_LENGTH_WORDS;
|
|
293
299
|
const dataEnd = dataStart + lengthWords;
|
|
294
|
-
if (dataEnd > signatureStart)
|
|
295
|
-
|
|
300
|
+
if (dataEnd > signatureStart) {
|
|
301
|
+
return fields;
|
|
302
|
+
}
|
|
303
|
+
if (!fields.has(words[cursor])) {
|
|
304
|
+
fields.set(words[cursor], words.slice(dataStart, dataEnd));
|
|
305
|
+
}
|
|
296
306
|
cursor = dataEnd;
|
|
297
307
|
}
|
|
298
308
|
return fields;
|
|
299
309
|
}
|
|
300
310
|
function hexTag(fields, tag, expectedWords) {
|
|
301
311
|
const words = fields.get(tag);
|
|
302
|
-
if (words === void 0 || words.length !== expectedWords)
|
|
312
|
+
if (words === void 0 || words.length !== expectedWords) {
|
|
313
|
+
return null;
|
|
314
|
+
}
|
|
303
315
|
return bytesToHex(wordsToBytes(words));
|
|
304
316
|
}
|
|
305
317
|
function expiryOf(words, fields) {
|
|
306
|
-
if (words.length < TIMESTAMP_WORDS + SIGNATURE_WORDS)
|
|
318
|
+
if (words.length < TIMESTAMP_WORDS + SIGNATURE_WORDS) {
|
|
319
|
+
return null;
|
|
320
|
+
}
|
|
307
321
|
const expiry = fields.get(EXPIRY_TAG);
|
|
308
322
|
return readNumber(words.slice(0, TIMESTAMP_WORDS)) + (expiry ? readNumber(expiry) : DEFAULT_EXPIRY_SECS);
|
|
309
323
|
}
|
|
310
324
|
function amountFromHrp(hrp) {
|
|
311
325
|
const match = HRP_AMOUNT.exec(hrp);
|
|
312
|
-
if (match === null)
|
|
326
|
+
if (match === null) {
|
|
327
|
+
return null;
|
|
328
|
+
}
|
|
313
329
|
const [, digits, multiplier] = match;
|
|
314
330
|
return multiplier ? Number(digits) * HRP_MULTIPLIER_MSAT[multiplier] : Number(digits) * MSAT_PER_BTC;
|
|
315
331
|
}
|
|
@@ -331,172 +347,751 @@ function wordsToBytes(words) {
|
|
|
331
347
|
return Uint8Array.from(bytes);
|
|
332
348
|
}
|
|
333
349
|
|
|
334
|
-
// ../core/
|
|
335
|
-
var
|
|
336
|
-
|
|
337
|
-
|
|
350
|
+
// ../core/refusal.ts
|
|
351
|
+
var WalletRefused = class extends Error {
|
|
352
|
+
reason;
|
|
353
|
+
constructor(reason, message) {
|
|
354
|
+
super(message);
|
|
355
|
+
this.reason = reason;
|
|
356
|
+
}
|
|
357
|
+
};
|
|
358
|
+
var NoWalletAvailable = class extends Error {
|
|
359
|
+
wallets;
|
|
360
|
+
constructor(wallets) {
|
|
361
|
+
super(wallets.map((wallet) => `${wallet.address} ${wallet.reason}`).join(", "));
|
|
362
|
+
this.wallets = wallets;
|
|
363
|
+
}
|
|
364
|
+
};
|
|
365
|
+
|
|
366
|
+
// ../core/sealed.ts
|
|
367
|
+
var VERSION = "v1";
|
|
368
|
+
var IV_BYTES = 12;
|
|
369
|
+
var MIN_SECRET_CHARS = 32;
|
|
370
|
+
var MAX_PLAIN_BYTES = 3e3;
|
|
371
|
+
var INFO = new TextEncoder().encode("thunder-bridge/sealed");
|
|
372
|
+
async function seal(secret, plaintext) {
|
|
373
|
+
const body = new TextEncoder().encode(plaintext);
|
|
374
|
+
if (body.length > MAX_PLAIN_BYTES) {
|
|
375
|
+
throw new Error(`sealed takes at most ${MAX_PLAIN_BYTES} bytes, this was ${body.length}`);
|
|
376
|
+
}
|
|
377
|
+
const key = await keyFor(secret);
|
|
378
|
+
const iv = crypto.getRandomValues(new Uint8Array(IV_BYTES));
|
|
379
|
+
const cipher = new Uint8Array(await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, body));
|
|
380
|
+
const joined2 = new Uint8Array(iv.length + cipher.length);
|
|
381
|
+
joined2.set(iv);
|
|
382
|
+
joined2.set(cipher, iv.length);
|
|
383
|
+
return `${VERSION}.${toBase64Url(joined2)}`;
|
|
384
|
+
}
|
|
385
|
+
async function unseal(secret, sealed) {
|
|
386
|
+
const key = await keyFor(secret);
|
|
387
|
+
if (!sealed.startsWith(`${VERSION}.`)) {
|
|
388
|
+
return null;
|
|
389
|
+
}
|
|
390
|
+
const joined2 = fromBase64Url(sealed.slice(VERSION.length + 1));
|
|
391
|
+
if (joined2 === null || joined2.length <= IV_BYTES) {
|
|
392
|
+
return null;
|
|
393
|
+
}
|
|
338
394
|
try {
|
|
339
|
-
|
|
395
|
+
const body = await crypto.subtle.decrypt(
|
|
396
|
+
{ name: "AES-GCM", iv: joined2.slice(0, IV_BYTES) },
|
|
397
|
+
key,
|
|
398
|
+
joined2.slice(IV_BYTES)
|
|
399
|
+
);
|
|
400
|
+
return new TextDecoder().decode(body);
|
|
340
401
|
} catch {
|
|
341
|
-
return
|
|
402
|
+
return null;
|
|
342
403
|
}
|
|
343
|
-
return url.protocol === "https:" && hostIsPublic(url.hostname.toLowerCase());
|
|
344
404
|
}
|
|
345
|
-
function
|
|
405
|
+
async function keyFor(secret) {
|
|
406
|
+
if (secret.length < MIN_SECRET_CHARS) {
|
|
407
|
+
throw new Error(`the sealing secret needs ${MIN_SECRET_CHARS} characters of randomness`);
|
|
408
|
+
}
|
|
409
|
+
const material = await crypto.subtle.importKey(
|
|
410
|
+
"raw",
|
|
411
|
+
new TextEncoder().encode(secret),
|
|
412
|
+
"HKDF",
|
|
413
|
+
false,
|
|
414
|
+
["deriveKey"]
|
|
415
|
+
);
|
|
416
|
+
return crypto.subtle.deriveKey(
|
|
417
|
+
{ name: "HKDF", hash: "SHA-256", salt: new Uint8Array(0), info: INFO },
|
|
418
|
+
material,
|
|
419
|
+
{ name: "AES-GCM", length: 256 },
|
|
420
|
+
false,
|
|
421
|
+
["encrypt", "decrypt"]
|
|
422
|
+
);
|
|
423
|
+
}
|
|
424
|
+
function toBase64Url(bytes) {
|
|
425
|
+
let binary = "";
|
|
426
|
+
for (const byte of bytes) {
|
|
427
|
+
binary += String.fromCharCode(byte);
|
|
428
|
+
}
|
|
429
|
+
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
430
|
+
}
|
|
431
|
+
function fromBase64Url(text2) {
|
|
432
|
+
if (text2.length === 0 || !/^[A-Za-z0-9_-]+$/.test(text2)) {
|
|
433
|
+
return null;
|
|
434
|
+
}
|
|
346
435
|
try {
|
|
347
|
-
|
|
436
|
+
const binary = atob(text2.replace(/-/g, "+").replace(/_/g, "/"));
|
|
437
|
+
const bytes = new Uint8Array(binary.length);
|
|
438
|
+
for (let at = 0; at < bytes.length; at++) {
|
|
439
|
+
bytes[at] = binary.charCodeAt(at);
|
|
440
|
+
}
|
|
441
|
+
return bytes;
|
|
348
442
|
} catch {
|
|
349
|
-
return
|
|
443
|
+
return null;
|
|
350
444
|
}
|
|
351
445
|
}
|
|
352
|
-
|
|
353
|
-
|
|
446
|
+
|
|
447
|
+
// src/nostr.ts
|
|
448
|
+
var import_chacha = require("@noble/ciphers/chacha.js");
|
|
449
|
+
var import_secp256k1 = require("@noble/curves/secp256k1.js");
|
|
450
|
+
var import_hkdf = require("@noble/hashes/hkdf.js");
|
|
451
|
+
var import_hmac = require("@noble/hashes/hmac.js");
|
|
452
|
+
var import_sha2 = require("@noble/hashes/sha2.js");
|
|
453
|
+
var NIP44_SALT = new TextEncoder().encode("nip44-v2");
|
|
454
|
+
var NIP44_VERSION = 2;
|
|
455
|
+
var NONCE_BYTES = 32;
|
|
456
|
+
var MAC_BYTES = 32;
|
|
457
|
+
var MESSAGE_KEYS_BYTES = 76;
|
|
458
|
+
var CHACHA_KEY_END = 32;
|
|
459
|
+
var CHACHA_NONCE_END = 44;
|
|
460
|
+
var LENGTH_PREFIX_BYTES = 2;
|
|
461
|
+
var SMALLEST_PAD = 32;
|
|
462
|
+
var CHUNKS_STAY_SMALL_TO = 256;
|
|
463
|
+
var MAX_PLAINTEXT_BYTES = 65535;
|
|
464
|
+
function publicKeyFor(privateKeyHex) {
|
|
465
|
+
return bytesToHex(import_secp256k1.schnorr.getPublicKey(hexToBytes(privateKeyHex)));
|
|
466
|
+
}
|
|
467
|
+
function conversationKeyFor(privateKeyHex, publicKeyHex) {
|
|
468
|
+
const shared = import_secp256k1.secp256k1.getSharedSecret(
|
|
469
|
+
hexToBytes(privateKeyHex),
|
|
470
|
+
hexToBytes(`02${publicKeyHex}`)
|
|
471
|
+
);
|
|
472
|
+
return (0, import_hkdf.extract)(import_sha2.sha256, shared.subarray(1, 33), NIP44_SALT);
|
|
354
473
|
}
|
|
355
|
-
function
|
|
356
|
-
|
|
357
|
-
const
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
474
|
+
function signEvent(privateKeyHex, draft) {
|
|
475
|
+
const pubkey = publicKeyFor(privateKeyHex);
|
|
476
|
+
const id = idOf(pubkey, draft);
|
|
477
|
+
return {
|
|
478
|
+
id,
|
|
479
|
+
pubkey,
|
|
480
|
+
created_at: draft.created_at,
|
|
481
|
+
kind: draft.kind,
|
|
482
|
+
tags: draft.tags,
|
|
483
|
+
content: draft.content,
|
|
484
|
+
sig: bytesToHex(import_secp256k1.schnorr.sign(hexToBytes(id), hexToBytes(privateKeyHex)))
|
|
485
|
+
};
|
|
362
486
|
}
|
|
363
|
-
function
|
|
364
|
-
|
|
365
|
-
|
|
487
|
+
function verifyEvent(event) {
|
|
488
|
+
try {
|
|
489
|
+
return idOf(event.pubkey, event) === event.id && import_secp256k1.schnorr.verify(hexToBytes(event.sig), hexToBytes(event.id), hexToBytes(event.pubkey));
|
|
490
|
+
} catch {
|
|
366
491
|
return false;
|
|
367
492
|
}
|
|
368
|
-
const [a, b] = parts;
|
|
369
|
-
if (a === 0 || a === 10 || a === 127 || a >= 240) return false;
|
|
370
|
-
if (a === 100 && b >= 64 && b < 128) return false;
|
|
371
|
-
if (a === 169 && b === 254) return false;
|
|
372
|
-
if (a === 172 && b >= 16 && b < 32) return false;
|
|
373
|
-
if (a === 192 && b === 168) return false;
|
|
374
|
-
return true;
|
|
375
493
|
}
|
|
376
|
-
function
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
}
|
|
385
|
-
const first = groups[0];
|
|
386
|
-
if ((first & 65024) === 64512) return false;
|
|
387
|
-
if ((first & 65472) === 65152) return false;
|
|
388
|
-
return true;
|
|
494
|
+
function idOf(pubkey, draft) {
|
|
495
|
+
return bytesToHex(
|
|
496
|
+
(0, import_sha2.sha256)(
|
|
497
|
+
new TextEncoder().encode(
|
|
498
|
+
JSON.stringify([0, pubkey, draft.created_at, draft.kind, draft.tags, draft.content])
|
|
499
|
+
)
|
|
500
|
+
)
|
|
501
|
+
);
|
|
389
502
|
}
|
|
390
|
-
function
|
|
391
|
-
|
|
392
|
-
const left = head.split(":").filter((group) => group !== "");
|
|
393
|
-
const right = (tail ?? "").split(":").filter((group) => group !== "");
|
|
394
|
-
const gap = tail === void 0 ? [] : Array(8 - left.length - right.length).fill("0");
|
|
395
|
-
return [...left, ...gap, ...right].map((group) => parseInt(group, 16));
|
|
503
|
+
function encryptNip44(conversationKey, plaintext) {
|
|
504
|
+
return sealNip44(conversationKey, plaintext, crypto.getRandomValues(new Uint8Array(NONCE_BYTES)));
|
|
396
505
|
}
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
var throughFetch = (url, sent, signal) => fetch(url, {
|
|
407
|
-
method: sent.method ?? "GET",
|
|
408
|
-
headers: sent.headers ?? {},
|
|
409
|
-
body: sent.body,
|
|
410
|
-
redirect: "manual",
|
|
411
|
-
signal
|
|
412
|
-
});
|
|
413
|
-
var send = throughFetch;
|
|
414
|
-
async function ask(url, sent = {}) {
|
|
415
|
-
const capped = AbortSignal.timeout(HTTP_TIMEOUT_MS);
|
|
416
|
-
const signal = sent.deadline ? AbortSignal.any([sent.deadline, capped]) : capped;
|
|
417
|
-
let target = url;
|
|
418
|
-
let carried = sent;
|
|
419
|
-
for (let hop = 0; hop <= HOPS_FOLLOWED; hop += 1) {
|
|
420
|
-
const at = await addressesToReach(target);
|
|
421
|
-
const response = await send(target, carried, signal, at);
|
|
422
|
-
if (!REDIRECTS.includes(response.status)) return await answerOf(response);
|
|
423
|
-
const location = response.headers.get("location");
|
|
424
|
-
if (location === null) {
|
|
425
|
-
throw new Error(`${target} answered ${response.status} without saying where to`);
|
|
426
|
-
}
|
|
427
|
-
await response.body?.cancel();
|
|
428
|
-
const next = new URL(location, target).toString();
|
|
429
|
-
carried = KEEPS_THE_METHOD.includes(response.status) ? carried : withoutBody(carried);
|
|
430
|
-
carried = sameOrigin(target, next) ? carried : withoutCredentials(carried);
|
|
431
|
-
target = next;
|
|
506
|
+
function sealNip44(conversationKey, plaintext, nonce) {
|
|
507
|
+
const keys = messageKeysFrom(conversationKey, nonce);
|
|
508
|
+
const ciphertext = (0, import_chacha.chacha20)(keys.chachaKey, keys.chachaNonce, padded(plaintext));
|
|
509
|
+
const mac = (0, import_hmac.hmac)(import_sha2.sha256, keys.hmacKey, joined(nonce, ciphertext));
|
|
510
|
+
return toBase64(joined(Uint8Array.of(NIP44_VERSION), nonce, ciphertext, mac));
|
|
511
|
+
}
|
|
512
|
+
function decryptNip44(conversationKey, payload) {
|
|
513
|
+
if (payload.startsWith("#")) {
|
|
514
|
+
throw new Error("nip44 payload announces an encryption version we do not speak");
|
|
432
515
|
}
|
|
433
|
-
|
|
516
|
+
const raw = fromBase64(payload);
|
|
517
|
+
if (raw.length < 1 + NONCE_BYTES + MAC_BYTES) {
|
|
518
|
+
throw new Error(`nip44 payload is ${raw.length} bytes, too short to hold a nonce and a mac`);
|
|
519
|
+
}
|
|
520
|
+
if (raw[0] !== NIP44_VERSION) {
|
|
521
|
+
throw new Error(`nip44 payload is version ${raw[0]}, not ${NIP44_VERSION}`);
|
|
522
|
+
}
|
|
523
|
+
const nonce = raw.subarray(1, 1 + NONCE_BYTES);
|
|
524
|
+
const ciphertext = raw.subarray(1 + NONCE_BYTES, raw.length - MAC_BYTES);
|
|
525
|
+
const mac = raw.subarray(raw.length - MAC_BYTES);
|
|
526
|
+
const keys = messageKeysFrom(conversationKey, nonce);
|
|
527
|
+
if (!sameBytes((0, import_hmac.hmac)(import_sha2.sha256, keys.hmacKey, joined(nonce, ciphertext)), mac)) {
|
|
528
|
+
throw new Error("nip44 payload failed its mac, so it was not written with this key");
|
|
529
|
+
}
|
|
530
|
+
return unpadded((0, import_chacha.chacha20)(keys.chachaKey, keys.chachaNonce, ciphertext));
|
|
434
531
|
}
|
|
435
|
-
|
|
436
|
-
if (
|
|
437
|
-
|
|
438
|
-
if (found.length === 0 || !found.every((one) => publicAddress(one.address))) {
|
|
439
|
-
throw new Error(`${url} resolves to an address we do not reach`);
|
|
532
|
+
function messageKeysFrom(conversationKey, nonce) {
|
|
533
|
+
if (conversationKey.length !== NONCE_BYTES || nonce.length !== NONCE_BYTES) {
|
|
534
|
+
throw new Error("a nip44 conversation key and nonce are both 32 bytes");
|
|
440
535
|
}
|
|
441
|
-
|
|
536
|
+
const keys = (0, import_hkdf.expand)(import_sha2.sha256, conversationKey, nonce, MESSAGE_KEYS_BYTES);
|
|
537
|
+
return {
|
|
538
|
+
chachaKey: keys.subarray(0, CHACHA_KEY_END),
|
|
539
|
+
chachaNonce: keys.subarray(CHACHA_KEY_END, CHACHA_NONCE_END),
|
|
540
|
+
hmacKey: keys.subarray(CHACHA_NONCE_END)
|
|
541
|
+
};
|
|
442
542
|
}
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
const
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
]);
|
|
451
|
-
return found ?? [];
|
|
543
|
+
function paddedLengthFor(unpadded2) {
|
|
544
|
+
if (unpadded2 <= SMALLEST_PAD) {
|
|
545
|
+
return SMALLEST_PAD;
|
|
546
|
+
}
|
|
547
|
+
const nextPower = 1 << Math.floor(Math.log2(unpadded2 - 1)) + 1;
|
|
548
|
+
const chunk = nextPower <= CHUNKS_STAY_SMALL_TO ? SMALLEST_PAD : nextPower / 8;
|
|
549
|
+
return chunk * (Math.floor((unpadded2 - 1) / chunk) + 1);
|
|
452
550
|
}
|
|
453
|
-
|
|
454
|
-
const
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
const read = [];
|
|
458
|
-
let bytes = 0;
|
|
459
|
-
while (bytes <= BODY_LIMIT_BYTES) {
|
|
460
|
-
const { done, value } = await reader.read();
|
|
461
|
-
if (done) return { status, ok, body: text(read), truncated: false, headers };
|
|
462
|
-
read.push(value);
|
|
463
|
-
bytes += value.length;
|
|
551
|
+
function padded(plaintext) {
|
|
552
|
+
const body = new TextEncoder().encode(plaintext);
|
|
553
|
+
if (body.length === 0 || body.length > MAX_PLAINTEXT_BYTES) {
|
|
554
|
+
throw new Error(`nip44 takes 1 to ${MAX_PLAINTEXT_BYTES} bytes, this was ${body.length}`);
|
|
464
555
|
}
|
|
465
|
-
|
|
466
|
-
|
|
556
|
+
const buffer = new Uint8Array(LENGTH_PREFIX_BYTES + paddedLengthFor(body.length));
|
|
557
|
+
buffer.set(Uint8Array.of(body.length >>> 8, body.length & 255));
|
|
558
|
+
buffer.set(body, LENGTH_PREFIX_BYTES);
|
|
559
|
+
return buffer;
|
|
560
|
+
}
|
|
561
|
+
function unpadded(buffer) {
|
|
562
|
+
const length = (buffer[0] ?? 0) << 8 | (buffer[1] ?? 0);
|
|
563
|
+
if (length === 0 || buffer.length !== LENGTH_PREFIX_BYTES + paddedLengthFor(length)) {
|
|
564
|
+
throw new Error("nip44 plaintext does not match the length its own padding declares");
|
|
565
|
+
}
|
|
566
|
+
return new TextDecoder().decode(
|
|
567
|
+
buffer.subarray(LENGTH_PREFIX_BYTES, LENGTH_PREFIX_BYTES + length)
|
|
568
|
+
);
|
|
467
569
|
}
|
|
468
|
-
function
|
|
469
|
-
const
|
|
470
|
-
|
|
471
|
-
|
|
570
|
+
function joined(...parts) {
|
|
571
|
+
const whole = new Uint8Array(parts.reduce((total, part) => total + part.length, 0));
|
|
572
|
+
let at = 0;
|
|
573
|
+
for (const part of parts) {
|
|
574
|
+
whole.set(part, at);
|
|
575
|
+
at += part.length;
|
|
576
|
+
}
|
|
577
|
+
return whole;
|
|
472
578
|
}
|
|
473
|
-
function
|
|
474
|
-
|
|
475
|
-
for (
|
|
476
|
-
|
|
579
|
+
function sameBytes(left, right) {
|
|
580
|
+
let different = left.length ^ right.length;
|
|
581
|
+
for (let at = 0; at < left.length && at < right.length; at++) {
|
|
582
|
+
different |= (left[at] ?? 0) ^ (right[at] ?? 0);
|
|
583
|
+
}
|
|
584
|
+
return different === 0;
|
|
477
585
|
}
|
|
478
|
-
function
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
586
|
+
function toBase64(bytes) {
|
|
587
|
+
let binary = "";
|
|
588
|
+
for (const byte of bytes) {
|
|
589
|
+
binary += String.fromCharCode(byte);
|
|
590
|
+
}
|
|
591
|
+
return btoa(binary);
|
|
483
592
|
}
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
super(message);
|
|
490
|
-
this.reason = reason;
|
|
593
|
+
function fromBase64(text2) {
|
|
594
|
+
const binary = atob(text2);
|
|
595
|
+
const bytes = new Uint8Array(binary.length);
|
|
596
|
+
for (let at = 0; at < bytes.length; at++) {
|
|
597
|
+
bytes[at] = binary.charCodeAt(at);
|
|
491
598
|
}
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
599
|
+
return bytes;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
// ../core/ed25519.ts
|
|
603
|
+
var PKCS8_HEADER = hexToBytes("302e020100300506032b657004220420");
|
|
604
|
+
|
|
605
|
+
// src/webhook.ts
|
|
606
|
+
var VERIFY_CHALLENGE = "verify-challenge";
|
|
607
|
+
function nonceOf(text2, type) {
|
|
608
|
+
try {
|
|
609
|
+
const said = JSON.parse(text2);
|
|
610
|
+
if (said["type"] !== type || typeof said["nonce"] !== "string") {
|
|
611
|
+
return null;
|
|
612
|
+
}
|
|
613
|
+
return said["nonce"];
|
|
614
|
+
} catch {
|
|
615
|
+
return null;
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
function answerVerifyChallenge(body) {
|
|
619
|
+
const nonce = nonceOf(body, VERIFY_CHALLENGE);
|
|
620
|
+
return nonce === null ? null : JSON.stringify({ nonce });
|
|
621
|
+
}
|
|
622
|
+
async function answerVerifyChallengeRequest(request) {
|
|
623
|
+
if (request.method !== "POST") {
|
|
624
|
+
return null;
|
|
498
625
|
}
|
|
626
|
+
const answer = answerVerifyChallenge(await request.clone().text());
|
|
627
|
+
return answer === null ? null : new Response(answer, { headers: { "content-type": "application/json" } });
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
// src/nwc.ts
|
|
631
|
+
var REQUEST_KIND = 23194;
|
|
632
|
+
var RESPONSE_KIND = 23195;
|
|
633
|
+
var NIP44_V2 = "nip44_v2";
|
|
634
|
+
var SCHEME = "nostr+walletconnect:";
|
|
635
|
+
var HEX_32_BYTES = /^[0-9a-f]{64}$/i;
|
|
636
|
+
var DEFAULT_ASK_TIMEOUT_MS = 1e4;
|
|
637
|
+
var PAY_TIMEOUT_MS = 9e4;
|
|
638
|
+
var DEFAULT_POLL_EVERY_SECS = 5;
|
|
639
|
+
var HASH = "h";
|
|
640
|
+
var REFUSAL_BY_CODE = {
|
|
641
|
+
INSUFFICIENT_BALANCE: "amount-not-accepted",
|
|
642
|
+
QUOTA_EXCEEDED: "amount-not-accepted",
|
|
643
|
+
RESTRICTED: "address-unusable",
|
|
644
|
+
UNAUTHORIZED: "address-unusable",
|
|
645
|
+
UNSUPPORTED_ENCRYPTION: "address-unusable"
|
|
499
646
|
};
|
|
647
|
+
function nwcConnection(uri) {
|
|
648
|
+
const parsed = parsedUri(uri);
|
|
649
|
+
const walletPubkey = parsed.pathname.replace(/^\/+/, "") || parsed.host;
|
|
650
|
+
const secret = parsed.searchParams.get("secret") ?? "";
|
|
651
|
+
const relays = parsed.searchParams.getAll("relay");
|
|
652
|
+
if (!HEX_32_BYTES.test(walletPubkey)) {
|
|
653
|
+
throw new WalletRefused("address-unusable", "an nwc uri names a 32-byte wallet pubkey");
|
|
654
|
+
}
|
|
655
|
+
if (!HEX_32_BYTES.test(secret)) {
|
|
656
|
+
throw new WalletRefused("address-unusable", "an nwc uri carries a 32-byte hex secret");
|
|
657
|
+
}
|
|
658
|
+
if (relays.length === 0 || !relays.every(isSecureRelay)) {
|
|
659
|
+
throw new WalletRefused("address-unusable", "every nwc relay has to be a wss URL");
|
|
660
|
+
}
|
|
661
|
+
return { walletPubkey: walletPubkey.toLowerCase(), relays, secret: secret.toLowerCase() };
|
|
662
|
+
}
|
|
663
|
+
async function nwcInvoice(connection, amountMsat, description, timeoutMs = DEFAULT_ASK_TIMEOUT_MS) {
|
|
664
|
+
const answered2 = await askWallet(
|
|
665
|
+
connection,
|
|
666
|
+
"make_invoice",
|
|
667
|
+
{ amount: amountMsat, description },
|
|
668
|
+
timeoutMs
|
|
669
|
+
);
|
|
670
|
+
const bolt11 = String(answered2.invoice ?? "");
|
|
671
|
+
const issued = decodeInvoice(bolt11);
|
|
672
|
+
if (issued.paymentHash === null || issued.expiresAt === null) {
|
|
673
|
+
throw new WalletRefused("invoice-refused", "the nwc wallet issued an invoice we cannot decode");
|
|
674
|
+
}
|
|
675
|
+
if (issued.amountMsat !== amountMsat) {
|
|
676
|
+
throw new WalletRefused(
|
|
677
|
+
"invoice-refused",
|
|
678
|
+
`the nwc wallet issued an invoice for ${issued.amountMsat} msat, not ${amountMsat}`
|
|
679
|
+
);
|
|
680
|
+
}
|
|
681
|
+
return { bolt11, paymentHash: issued.paymentHash, expiresAt: issued.expiresAt };
|
|
682
|
+
}
|
|
683
|
+
async function nwcHoldInvoice(connection, held, timeoutMs = DEFAULT_ASK_TIMEOUT_MS) {
|
|
684
|
+
const answered2 = await askWallet(
|
|
685
|
+
connection,
|
|
686
|
+
"make_hold_invoice",
|
|
687
|
+
{
|
|
688
|
+
amount: held.amountMsat,
|
|
689
|
+
description: held.description,
|
|
690
|
+
payment_hash: held.paymentHash,
|
|
691
|
+
expiry: held.expirySecs,
|
|
692
|
+
...held.minCltvExpiryDelta === void 0 ? {} : { min_cltv_expiry_delta: held.minCltvExpiryDelta }
|
|
693
|
+
},
|
|
694
|
+
timeoutMs
|
|
695
|
+
);
|
|
696
|
+
const bolt11 = String(answered2.invoice ?? "");
|
|
697
|
+
const issued = decodeInvoice(bolt11);
|
|
698
|
+
if (issued.paymentHash === null || issued.expiresAt === null) {
|
|
699
|
+
throw new WalletRefused("invoice-refused", "the nwc wallet held an invoice we cannot decode");
|
|
700
|
+
}
|
|
701
|
+
if (issued.paymentHash.toLowerCase() !== held.paymentHash.toLowerCase()) {
|
|
702
|
+
throw new WalletRefused(
|
|
703
|
+
"invoice-refused",
|
|
704
|
+
`the nwc wallet held ${issued.paymentHash} rather than the ${held.paymentHash} asked for`
|
|
705
|
+
);
|
|
706
|
+
}
|
|
707
|
+
if (issued.amountMsat !== held.amountMsat) {
|
|
708
|
+
throw new WalletRefused(
|
|
709
|
+
"invoice-refused",
|
|
710
|
+
`the nwc wallet held an invoice for ${issued.amountMsat} msat, not ${held.amountMsat}`
|
|
711
|
+
);
|
|
712
|
+
}
|
|
713
|
+
return { bolt11, paymentHash: issued.paymentHash, expiresAt: issued.expiresAt };
|
|
714
|
+
}
|
|
715
|
+
async function nwcSettlement(connection, paymentHash, timeoutMs = DEFAULT_ASK_TIMEOUT_MS) {
|
|
716
|
+
const answered2 = await askWallet(
|
|
717
|
+
connection,
|
|
718
|
+
"lookup_invoice",
|
|
719
|
+
{ payment_hash: paymentHash },
|
|
720
|
+
timeoutMs
|
|
721
|
+
);
|
|
722
|
+
const preimage = answered2.preimage;
|
|
723
|
+
if (typeof preimage !== "string" || preimage.length === 0) {
|
|
724
|
+
return null;
|
|
725
|
+
}
|
|
726
|
+
if (!preimageMatchesHash(preimage, paymentHash)) {
|
|
727
|
+
throw new Error(`the nwc wallet returned a preimage that does not hash to ${paymentHash}`);
|
|
728
|
+
}
|
|
729
|
+
return preimage;
|
|
730
|
+
}
|
|
731
|
+
async function nwcPay(connection, bolt11, timeoutMs = PAY_TIMEOUT_MS) {
|
|
732
|
+
const owed = decodeInvoice(bolt11);
|
|
733
|
+
if (owed.paymentHash === null) {
|
|
734
|
+
throw new WalletRefused("invoice-refused", "the invoice to pay carries no payment hash");
|
|
735
|
+
}
|
|
736
|
+
const answered2 = await askWallet(connection, "pay_invoice", { invoice: bolt11 }, timeoutMs);
|
|
737
|
+
const preimage = answered2.preimage;
|
|
738
|
+
if (typeof preimage !== "string" || !preimageMatchesHash(preimage, owed.paymentHash)) {
|
|
739
|
+
throw new Error(`the nwc wallet paid and returned no preimage hashing to ${owed.paymentHash}`);
|
|
740
|
+
}
|
|
741
|
+
return preimage;
|
|
742
|
+
}
|
|
743
|
+
function nwcVerifyEndpoint(config) {
|
|
744
|
+
const paced = {
|
|
745
|
+
"cache-control": `max-age=${config.pollEverySecs ?? DEFAULT_POLL_EVERY_SECS}`
|
|
746
|
+
};
|
|
747
|
+
return async (request) => {
|
|
748
|
+
const consented = await answerVerifyChallengeRequest(request);
|
|
749
|
+
if (consented !== null) {
|
|
750
|
+
return consented;
|
|
751
|
+
}
|
|
752
|
+
const sealed = new URL(request.url).searchParams.get(HASH);
|
|
753
|
+
if (sealed === null) {
|
|
754
|
+
return Response.json({ settled: false }, { status: 400 });
|
|
755
|
+
}
|
|
756
|
+
const paymentHash = await unseal(config.secret, sealed);
|
|
757
|
+
if (paymentHash === null) {
|
|
758
|
+
return Response.json({ settled: false }, { status: 403 });
|
|
759
|
+
}
|
|
760
|
+
const preimage = await nwcSettlement(config.connection, paymentHash, config.askTimeoutMs).catch(
|
|
761
|
+
() => void 0
|
|
762
|
+
);
|
|
763
|
+
if (preimage === void 0) {
|
|
764
|
+
return Response.json({ settled: false }, { status: 502 });
|
|
765
|
+
}
|
|
766
|
+
return Response.json({ settled: preimage !== null, preimage }, { headers: paced });
|
|
767
|
+
};
|
|
768
|
+
}
|
|
769
|
+
async function nwcVerifyUrl(endpoint, paymentHash, secret) {
|
|
770
|
+
const sealed = new URL(endpoint);
|
|
771
|
+
sealed.searchParams.set(HASH, await seal(secret, paymentHash));
|
|
772
|
+
return sealed.toString();
|
|
773
|
+
}
|
|
774
|
+
async function askWallet(connection, method, params, timeoutMs = DEFAULT_ASK_TIMEOUT_MS) {
|
|
775
|
+
const key = conversationKeyFor(connection.secret, connection.walletPubkey);
|
|
776
|
+
const request = signEvent(connection.secret, {
|
|
777
|
+
kind: REQUEST_KIND,
|
|
778
|
+
tags: [
|
|
779
|
+
["p", connection.walletPubkey],
|
|
780
|
+
["encryption", NIP44_V2]
|
|
781
|
+
],
|
|
782
|
+
content: encryptNip44(key, JSON.stringify({ method, params })),
|
|
783
|
+
created_at: Math.floor(Date.now() / 1e3)
|
|
784
|
+
});
|
|
785
|
+
const answer = await overFirstRelayThatAnswers(connection, request, timeoutMs);
|
|
786
|
+
return resultOf(JSON.parse(decryptNip44(key, answer.content)), method);
|
|
787
|
+
}
|
|
788
|
+
function resultOf(said, method) {
|
|
789
|
+
const failure = said.error;
|
|
790
|
+
if (failure) {
|
|
791
|
+
throw new WalletRefused(
|
|
792
|
+
REFUSAL_BY_CODE[failure.code ?? ""] ?? "invoice-refused",
|
|
793
|
+
`the nwc wallet refused ${method}: ${failure.code ?? "unknown"} ${failure.message ?? ""}`.trim()
|
|
794
|
+
);
|
|
795
|
+
}
|
|
796
|
+
const result = said.result;
|
|
797
|
+
if (result === null || typeof result !== "object") {
|
|
798
|
+
throw new WalletRefused("invoice-refused", `the nwc wallet answered ${method} with no result`);
|
|
799
|
+
}
|
|
800
|
+
return result;
|
|
801
|
+
}
|
|
802
|
+
async function overFirstRelayThatAnswers(connection, request, timeoutMs) {
|
|
803
|
+
const deadline = AbortSignal.timeout(timeoutMs);
|
|
804
|
+
let lastFailure = null;
|
|
805
|
+
for (const relay of connection.relays) {
|
|
806
|
+
if (deadline.aborted) {
|
|
807
|
+
break;
|
|
808
|
+
}
|
|
809
|
+
try {
|
|
810
|
+
return await overRelay(relay, connection, request, deadline);
|
|
811
|
+
} catch (failure) {
|
|
812
|
+
lastFailure = failure;
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
throw new WalletRefused("unreachable", `no nwc relay answered: ${String(lastFailure)}`);
|
|
816
|
+
}
|
|
817
|
+
function overRelay(relay, connection, request, deadline) {
|
|
818
|
+
return new Promise((resolve2, reject) => {
|
|
819
|
+
const socket = new WebSocket(relay);
|
|
820
|
+
const subscription = request.id.slice(0, 16);
|
|
821
|
+
const expired = () => {
|
|
822
|
+
close();
|
|
823
|
+
reject(new Error(`${relay} did not answer before the deadline`));
|
|
824
|
+
};
|
|
825
|
+
deadline.addEventListener("abort", expired);
|
|
826
|
+
function close() {
|
|
827
|
+
deadline.removeEventListener("abort", expired);
|
|
828
|
+
socket.close();
|
|
829
|
+
}
|
|
830
|
+
socket.addEventListener("open", () => {
|
|
831
|
+
socket.send(
|
|
832
|
+
JSON.stringify([
|
|
833
|
+
"REQ",
|
|
834
|
+
subscription,
|
|
835
|
+
{
|
|
836
|
+
kinds: [RESPONSE_KIND],
|
|
837
|
+
authors: [connection.walletPubkey],
|
|
838
|
+
"#p": [publicKeyFor(connection.secret)],
|
|
839
|
+
"#e": [request.id]
|
|
840
|
+
}
|
|
841
|
+
])
|
|
842
|
+
);
|
|
843
|
+
socket.send(JSON.stringify(["EVENT", request]));
|
|
844
|
+
});
|
|
845
|
+
socket.addEventListener("error", () => {
|
|
846
|
+
close();
|
|
847
|
+
reject(new Error(`${relay} could not be reached`));
|
|
848
|
+
});
|
|
849
|
+
socket.addEventListener("message", (delivered) => {
|
|
850
|
+
const frame = String(delivered.data);
|
|
851
|
+
const rejection = rejectionOf(frame, request.id);
|
|
852
|
+
if (rejection !== null) {
|
|
853
|
+
close();
|
|
854
|
+
reject(new Error(`${relay} refused the request: ${rejection}`));
|
|
855
|
+
return;
|
|
856
|
+
}
|
|
857
|
+
const answer = answeredEvent(frame, subscription, connection, request.id);
|
|
858
|
+
if (answer === null) {
|
|
859
|
+
return;
|
|
860
|
+
}
|
|
861
|
+
close();
|
|
862
|
+
resolve2(answer);
|
|
863
|
+
});
|
|
864
|
+
});
|
|
865
|
+
}
|
|
866
|
+
function rejectionOf(frame, requestId) {
|
|
867
|
+
const parsed = parsedFrame(frame);
|
|
868
|
+
if (parsed === null || parsed[0] !== "OK" || parsed[1] !== requestId || parsed[2] !== false) {
|
|
869
|
+
return null;
|
|
870
|
+
}
|
|
871
|
+
return typeof parsed[3] === "string" && parsed[3].length > 0 ? parsed[3] : "no reason given";
|
|
872
|
+
}
|
|
873
|
+
function answeredEvent(frame, subscription, connection, requestId) {
|
|
874
|
+
const parsed = parsedFrame(frame);
|
|
875
|
+
if (parsed === null || parsed[0] !== "EVENT" || parsed[1] !== subscription) {
|
|
876
|
+
return null;
|
|
877
|
+
}
|
|
878
|
+
const event = parsed[2];
|
|
879
|
+
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);
|
|
880
|
+
return answers ? event : null;
|
|
881
|
+
}
|
|
882
|
+
function parsedFrame(frame) {
|
|
883
|
+
try {
|
|
884
|
+
const parsed = JSON.parse(frame);
|
|
885
|
+
return Array.isArray(parsed) ? parsed : null;
|
|
886
|
+
} catch {
|
|
887
|
+
return null;
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
function parsedUri(uri) {
|
|
891
|
+
try {
|
|
892
|
+
const parsed = new URL(uri);
|
|
893
|
+
if (parsed.protocol !== SCHEME) {
|
|
894
|
+
throw new Error(parsed.protocol);
|
|
895
|
+
}
|
|
896
|
+
return parsed;
|
|
897
|
+
} catch {
|
|
898
|
+
throw new WalletRefused("address-unusable", `an nwc uri starts with ${SCHEME}//`);
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
function isSecureRelay(relay) {
|
|
902
|
+
try {
|
|
903
|
+
return new URL(relay).protocol === "wss:";
|
|
904
|
+
} catch {
|
|
905
|
+
return false;
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
// ../core/url.ts
|
|
910
|
+
var PRIVATE_SUFFIXES = ["localhost", "local", "internal", "lan", "arpa", "test", "invalid"];
|
|
911
|
+
function publicHttps(raw) {
|
|
912
|
+
let url;
|
|
913
|
+
try {
|
|
914
|
+
url = new URL(raw);
|
|
915
|
+
} catch {
|
|
916
|
+
return false;
|
|
917
|
+
}
|
|
918
|
+
return url.protocol === "https:" && hostIsPublic(url.hostname.toLowerCase());
|
|
919
|
+
}
|
|
920
|
+
function sameOrigin(one, other) {
|
|
921
|
+
try {
|
|
922
|
+
return new URL(one).origin === new URL(other).origin;
|
|
923
|
+
} catch {
|
|
924
|
+
return false;
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
function publicAddress(literal) {
|
|
928
|
+
return literal.includes(":") ? ipv6IsGlobal(literal) : ipv4IsGlobal(literal);
|
|
929
|
+
}
|
|
930
|
+
function hostIsPublic(host) {
|
|
931
|
+
if (host.startsWith("[")) {
|
|
932
|
+
return publicAddress(host.slice(1, -1));
|
|
933
|
+
}
|
|
934
|
+
const name = host.replace(/\.$/, "");
|
|
935
|
+
if (/^\d+\.\d+\.\d+\.\d+$/.test(name)) {
|
|
936
|
+
return publicAddress(name);
|
|
937
|
+
}
|
|
938
|
+
const labels = name.split(".");
|
|
939
|
+
if (labels.length < 2 || labels.some((label) => label === "")) {
|
|
940
|
+
return false;
|
|
941
|
+
}
|
|
942
|
+
return !PRIVATE_SUFFIXES.includes(labels[labels.length - 1]);
|
|
943
|
+
}
|
|
944
|
+
function ipv4IsGlobal(literal) {
|
|
945
|
+
const parts = literal.split(".").map(Number);
|
|
946
|
+
if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) {
|
|
947
|
+
return false;
|
|
948
|
+
}
|
|
949
|
+
const [a, b] = parts;
|
|
950
|
+
if (a === 0 || a === 10 || a === 127 || a >= 240) {
|
|
951
|
+
return false;
|
|
952
|
+
}
|
|
953
|
+
if (a === 100 && b >= 64 && b < 128) {
|
|
954
|
+
return false;
|
|
955
|
+
}
|
|
956
|
+
if (a === 169 && b === 254) {
|
|
957
|
+
return false;
|
|
958
|
+
}
|
|
959
|
+
if (a === 172 && b >= 16 && b < 32) {
|
|
960
|
+
return false;
|
|
961
|
+
}
|
|
962
|
+
if (a === 192 && b === 168) {
|
|
963
|
+
return false;
|
|
964
|
+
}
|
|
965
|
+
return true;
|
|
966
|
+
}
|
|
967
|
+
function ipv6IsGlobal(literal) {
|
|
968
|
+
const groups = ipv6Groups(literal.toLowerCase());
|
|
969
|
+
if (groups.length !== 8 || groups.some(Number.isNaN)) {
|
|
970
|
+
return false;
|
|
971
|
+
}
|
|
972
|
+
const leading = groups.slice(0, 5).every((group) => group === 0);
|
|
973
|
+
if (leading && groups[5] === 0 && groups[6] === 0 && groups[7] <= 1) {
|
|
974
|
+
return false;
|
|
975
|
+
}
|
|
976
|
+
if (leading && groups[5] === 65535) {
|
|
977
|
+
const [high, low] = [groups[6], groups[7]];
|
|
978
|
+
return ipv4IsGlobal(`${high >> 8}.${high & 255}.${low >> 8}.${low & 255}`);
|
|
979
|
+
}
|
|
980
|
+
const first = groups[0];
|
|
981
|
+
if ((first & 65024) === 64512) {
|
|
982
|
+
return false;
|
|
983
|
+
}
|
|
984
|
+
if ((first & 65472) === 65152) {
|
|
985
|
+
return false;
|
|
986
|
+
}
|
|
987
|
+
return true;
|
|
988
|
+
}
|
|
989
|
+
function ipv6Groups(address) {
|
|
990
|
+
const [head = "", tail] = address.split("::");
|
|
991
|
+
const left = head.split(":").filter((group) => group !== "");
|
|
992
|
+
const right = (tail ?? "").split(":").filter((group) => group !== "");
|
|
993
|
+
const gap = tail === void 0 ? [] : Array(8 - left.length - right.length).fill("0");
|
|
994
|
+
return [...left, ...gap, ...right].map((group) => parseInt(group, 16));
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
// ../core/outbound.ts
|
|
998
|
+
var HTTP_TIMEOUT_MS = 15e3;
|
|
999
|
+
var LOOKUP_TIMEOUT_MS = 5e3;
|
|
1000
|
+
var HOPS_FOLLOWED = 2;
|
|
1001
|
+
var REDIRECTS = [301, 302, 303, 307, 308];
|
|
1002
|
+
var KEEPS_THE_METHOD = [307, 308];
|
|
1003
|
+
var CREDENTIALS = ["authorization", "cookie", "proxy-authorization"];
|
|
1004
|
+
var BODY_LIMIT_BYTES = 262144;
|
|
1005
|
+
var throughFetch = (url, sent, signal) => fetch(url, {
|
|
1006
|
+
method: sent.method ?? "GET",
|
|
1007
|
+
headers: sent.headers ?? {},
|
|
1008
|
+
body: sent.body,
|
|
1009
|
+
redirect: "manual",
|
|
1010
|
+
signal
|
|
1011
|
+
});
|
|
1012
|
+
var send = throughFetch;
|
|
1013
|
+
async function ask(url, sent = {}) {
|
|
1014
|
+
const capped = AbortSignal.timeout(HTTP_TIMEOUT_MS);
|
|
1015
|
+
const signal = sent.deadline ? AbortSignal.any([sent.deadline, capped]) : capped;
|
|
1016
|
+
let target = url;
|
|
1017
|
+
let carried = sent;
|
|
1018
|
+
for (let hop = 0; hop <= HOPS_FOLLOWED; hop += 1) {
|
|
1019
|
+
const at = await addressesToReach(target);
|
|
1020
|
+
const response = await send(target, carried, signal, at);
|
|
1021
|
+
if (!REDIRECTS.includes(response.status)) {
|
|
1022
|
+
return await answerOf(response);
|
|
1023
|
+
}
|
|
1024
|
+
const location = response.headers.get("location");
|
|
1025
|
+
if (location === null) {
|
|
1026
|
+
throw new Error(`${target} answered ${response.status} without saying where to`);
|
|
1027
|
+
}
|
|
1028
|
+
await response.body?.cancel();
|
|
1029
|
+
const next = new URL(location, target).toString();
|
|
1030
|
+
carried = KEEPS_THE_METHOD.includes(response.status) ? carried : withoutBody(carried);
|
|
1031
|
+
carried = sameOrigin(target, next) ? carried : withoutCredentials(carried);
|
|
1032
|
+
target = next;
|
|
1033
|
+
}
|
|
1034
|
+
throw new Error(`${url} redirected more than ${HOPS_FOLLOWED} times`);
|
|
1035
|
+
}
|
|
1036
|
+
async function addressesToReach(url) {
|
|
1037
|
+
if (!publicHttps(url)) {
|
|
1038
|
+
throw new Error(`${url} is not a public https URL`);
|
|
1039
|
+
}
|
|
1040
|
+
const found = await resolved(url);
|
|
1041
|
+
if (found.length === 0 || !found.every((one) => publicAddress(one.address))) {
|
|
1042
|
+
throw new Error(`${url} resolves to an address we do not reach`);
|
|
1043
|
+
}
|
|
1044
|
+
return found;
|
|
1045
|
+
}
|
|
1046
|
+
async function resolved(url) {
|
|
1047
|
+
const { lookup } = await import("dns/promises");
|
|
1048
|
+
const { setTimeout: sleep } = await import("timers/promises");
|
|
1049
|
+
const host = new URL(url).hostname.replace(/^\[|]$/g, "");
|
|
1050
|
+
const found = await Promise.race([
|
|
1051
|
+
lookup(host, { all: true, verbatim: true }).catch(() => []),
|
|
1052
|
+
sleep(LOOKUP_TIMEOUT_MS, null, { ref: false })
|
|
1053
|
+
]);
|
|
1054
|
+
return found ?? [];
|
|
1055
|
+
}
|
|
1056
|
+
async function answerOf(response) {
|
|
1057
|
+
const { status, ok, headers } = response;
|
|
1058
|
+
const reader = response.body?.getReader();
|
|
1059
|
+
if (!reader) {
|
|
1060
|
+
return { status, ok, body: "", truncated: false, headers };
|
|
1061
|
+
}
|
|
1062
|
+
const read = [];
|
|
1063
|
+
let bytes = 0;
|
|
1064
|
+
while (bytes <= BODY_LIMIT_BYTES) {
|
|
1065
|
+
const { done, value } = await reader.read();
|
|
1066
|
+
if (done) {
|
|
1067
|
+
return { status, ok, body: text(read), truncated: false, headers };
|
|
1068
|
+
}
|
|
1069
|
+
read.push(value);
|
|
1070
|
+
bytes += value.length;
|
|
1071
|
+
}
|
|
1072
|
+
await reader.cancel();
|
|
1073
|
+
return { status, ok, body: text(read), truncated: true, headers };
|
|
1074
|
+
}
|
|
1075
|
+
function withoutBody(sent) {
|
|
1076
|
+
const headers = { ...sent.headers };
|
|
1077
|
+
delete headers["content-type"];
|
|
1078
|
+
return { ...sent, method: "GET", headers, body: void 0 };
|
|
1079
|
+
}
|
|
1080
|
+
function withoutCredentials(sent) {
|
|
1081
|
+
const headers = { ...sent.headers };
|
|
1082
|
+
for (const named of CREDENTIALS) {
|
|
1083
|
+
delete headers[named];
|
|
1084
|
+
}
|
|
1085
|
+
return { ...sent, headers };
|
|
1086
|
+
}
|
|
1087
|
+
function text(chunks) {
|
|
1088
|
+
const decoder = new TextDecoder();
|
|
1089
|
+
let decoded = "";
|
|
1090
|
+
for (const chunk of chunks) {
|
|
1091
|
+
decoded += decoder.decode(chunk, { stream: true });
|
|
1092
|
+
}
|
|
1093
|
+
return decoded + decoder.decode();
|
|
1094
|
+
}
|
|
500
1095
|
|
|
501
1096
|
// ../core/lnurl.ts
|
|
502
1097
|
var VERIFY_WITHOUT_PREIMAGE = ["zeuspay.com", "zeusnuts.com", "ecash.love"];
|
|
@@ -517,11 +1112,15 @@ async function firstThatServes(addresses, attempt) {
|
|
|
517
1112
|
const deadline = AbortSignal.timeout(RESOLVE_TIMEOUT_MS);
|
|
518
1113
|
const refusals = [];
|
|
519
1114
|
for (const address of addresses) {
|
|
520
|
-
if (deadline.aborted)
|
|
1115
|
+
if (deadline.aborted) {
|
|
1116
|
+
break;
|
|
1117
|
+
}
|
|
521
1118
|
try {
|
|
522
1119
|
return { won: await attempt(address, deadline), refusals };
|
|
523
1120
|
} catch (refusal) {
|
|
524
|
-
if (!(refusal instanceof WalletRefused))
|
|
1121
|
+
if (!(refusal instanceof WalletRefused)) {
|
|
1122
|
+
throw refusal;
|
|
1123
|
+
}
|
|
525
1124
|
console.warn(`wallet ${address} refused, ${refusal.reason}: ${refusal.message}`);
|
|
526
1125
|
refusals.push({ address, reason: refusal.reason });
|
|
527
1126
|
}
|
|
@@ -533,10 +1132,7 @@ async function probe(address, amountMsat, deadline) {
|
|
|
533
1132
|
if (cannotReleaseAPreimage(domain)) {
|
|
534
1133
|
throw new WalletRefused("cannot-prove-delivery", `${address} never releases a preimage`);
|
|
535
1134
|
}
|
|
536
|
-
const pay = await answered(
|
|
537
|
-
`https://${domain}/.well-known/lnurlp/${user}`,
|
|
538
|
-
deadline
|
|
539
|
-
);
|
|
1135
|
+
const pay = await answered(`https://${domain}/.well-known/lnurlp/${user}`, deadline);
|
|
540
1136
|
if (pay.tag !== "payRequest" || !pay.callback) {
|
|
541
1137
|
throw new WalletRefused("unreachable", `${address} answered with no payRequest`);
|
|
542
1138
|
}
|
|
@@ -551,7 +1147,9 @@ async function probe(address, amountMsat, deadline) {
|
|
|
551
1147
|
async function resolveAddress(address, amountMsat, deadline) {
|
|
552
1148
|
const { pay } = await probe(address, amountMsat, deadline);
|
|
553
1149
|
const invoice = await answered(withAmount(pay.callback, amountMsat), deadline);
|
|
554
|
-
if (!invoice.pr)
|
|
1150
|
+
if (!invoice.pr) {
|
|
1151
|
+
throw new WalletRefused("unreachable", `${address} returned no invoice`);
|
|
1152
|
+
}
|
|
555
1153
|
if (!invoice.verify || !publicHttps(invoice.verify)) {
|
|
556
1154
|
throw new WalletRefused(
|
|
557
1155
|
"cannot-prove-delivery",
|
|
@@ -599,7 +1197,9 @@ async function checkSettled(verifyUrl, paymentHash) {
|
|
|
599
1197
|
pace: paceAskedFor(answer.headers),
|
|
600
1198
|
ceiling: ceilingAskedFor(answer.headers)
|
|
601
1199
|
};
|
|
602
|
-
if (!answer.said.settled || !answer.said.preimage)
|
|
1200
|
+
if (!answer.said.settled || !answer.said.preimage) {
|
|
1201
|
+
return { preimage: null, ...asked };
|
|
1202
|
+
}
|
|
603
1203
|
if (!preimageMatchesHash(answer.said.preimage, paymentHash)) {
|
|
604
1204
|
throw new Error(`verify returned a preimage that does not hash to ${paymentHash}`);
|
|
605
1205
|
}
|
|
@@ -607,15 +1207,21 @@ async function checkSettled(verifyUrl, paymentHash) {
|
|
|
607
1207
|
}
|
|
608
1208
|
function paceAskedFor(headers) {
|
|
609
1209
|
const asked = /max-age\s*=\s*(\d+)/i.exec(headers.get("cache-control") ?? "");
|
|
610
|
-
if (!asked)
|
|
1210
|
+
if (!asked) {
|
|
1211
|
+
return null;
|
|
1212
|
+
}
|
|
611
1213
|
return Math.min(Math.max(Number(asked[1]), MIN_PACE_SECS), MAX_PACE_SECS);
|
|
612
1214
|
}
|
|
613
1215
|
function ceilingAskedFor(headers) {
|
|
614
1216
|
const asked = /^\s*(\d+)\s*(?:;\s*w\s*=\s*(\d+))?/i.exec(headers.get("ratelimit-limit") ?? "");
|
|
615
|
-
if (!asked)
|
|
1217
|
+
if (!asked) {
|
|
1218
|
+
return null;
|
|
1219
|
+
}
|
|
616
1220
|
const perWindow = Number(asked[1]);
|
|
617
1221
|
const window = asked[2] === void 0 ? 1 : Number(asked[2]);
|
|
618
|
-
if (perWindow === 0 || window === 0)
|
|
1222
|
+
if (perWindow === 0 || window === 0) {
|
|
1223
|
+
return null;
|
|
1224
|
+
}
|
|
619
1225
|
return Math.min(Math.max(perWindow / window, MIN_PER_SECOND), MAX_PER_SECOND);
|
|
620
1226
|
}
|
|
621
1227
|
function cannotReleaseAPreimage(host) {
|
|
@@ -635,95 +1241,53 @@ function splitAddress(address) {
|
|
|
635
1241
|
}
|
|
636
1242
|
return [user, domain];
|
|
637
1243
|
}
|
|
638
|
-
function isNothingButAHost(domain) {
|
|
639
|
-
try {
|
|
640
|
-
const parsed = new URL(`https://${domain}/`);
|
|
641
|
-
return parsed.host === domain.toLowerCase() && parsed.pathname === "/" && parsed.search === "" && parsed.hash === "";
|
|
642
|
-
} catch {
|
|
643
|
-
return false;
|
|
644
|
-
}
|
|
645
|
-
}
|
|
646
|
-
async function fetchJson(url, deadline) {
|
|
647
|
-
return (await answeredJson(url, deadline)).said;
|
|
648
|
-
}
|
|
649
|
-
async function answeredJson(url, deadline) {
|
|
650
|
-
const answer = await ask(url, { headers: { accept: "application/json" }, deadline });
|
|
651
|
-
if (!answer.ok) throw new Error(`${url} answered ${answer.status}`);
|
|
652
|
-
if (answer.truncated) {
|
|
653
|
-
throw new Error(`${url} answered with more than the ${BODY_LIMIT_BYTES} bytes we read`);
|
|
654
|
-
}
|
|
655
|
-
return { said: JSON.parse(answer.body), headers: answer.headers };
|
|
656
|
-
}
|
|
657
|
-
|
|
658
|
-
// ../core/sealed.ts
|
|
659
|
-
var VERSION = "v1";
|
|
660
|
-
var IV_BYTES = 12;
|
|
661
|
-
var MIN_SECRET_CHARS = 32;
|
|
662
|
-
var MAX_PLAIN_BYTES = 3e3;
|
|
663
|
-
var INFO = new TextEncoder().encode("thunder-bridge/sealed");
|
|
664
|
-
async function seal(secret, plaintext) {
|
|
665
|
-
const body = new TextEncoder().encode(plaintext);
|
|
666
|
-
if (body.length > MAX_PLAIN_BYTES) {
|
|
667
|
-
throw new Error(`sealed takes at most ${MAX_PLAIN_BYTES} bytes, this was ${body.length}`);
|
|
668
|
-
}
|
|
669
|
-
const key = await keyFor(secret);
|
|
670
|
-
const iv = crypto.getRandomValues(new Uint8Array(IV_BYTES));
|
|
671
|
-
const cipher = new Uint8Array(await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, body));
|
|
672
|
-
const joined = new Uint8Array(iv.length + cipher.length);
|
|
673
|
-
joined.set(iv);
|
|
674
|
-
joined.set(cipher, iv.length);
|
|
675
|
-
return `${VERSION}.${toBase64Url(joined)}`;
|
|
676
|
-
}
|
|
677
|
-
async function unseal(secret, sealed) {
|
|
678
|
-
const key = await keyFor(secret);
|
|
679
|
-
if (!sealed.startsWith(`${VERSION}.`)) return null;
|
|
680
|
-
const joined = fromBase64Url(sealed.slice(VERSION.length + 1));
|
|
681
|
-
if (joined === null || joined.length <= IV_BYTES) return null;
|
|
1244
|
+
function isNothingButAHost(domain) {
|
|
682
1245
|
try {
|
|
683
|
-
const
|
|
684
|
-
|
|
685
|
-
key,
|
|
686
|
-
joined.slice(IV_BYTES)
|
|
687
|
-
);
|
|
688
|
-
return new TextDecoder().decode(body);
|
|
1246
|
+
const parsed = new URL(`https://${domain}/`);
|
|
1247
|
+
return parsed.host === domain.toLowerCase() && parsed.pathname === "/" && parsed.search === "" && parsed.hash === "";
|
|
689
1248
|
} catch {
|
|
690
|
-
return
|
|
1249
|
+
return false;
|
|
691
1250
|
}
|
|
692
1251
|
}
|
|
693
|
-
async function
|
|
694
|
-
|
|
695
|
-
|
|
1252
|
+
async function fetchJson(url, deadline) {
|
|
1253
|
+
return (await answeredJson(url, deadline)).said;
|
|
1254
|
+
}
|
|
1255
|
+
async function answeredJson(url, deadline) {
|
|
1256
|
+
const answer = await ask(url, { headers: { accept: "application/json" }, deadline });
|
|
1257
|
+
if (!answer.ok) {
|
|
1258
|
+
throw new Error(`${url} answered ${answer.status}`);
|
|
696
1259
|
}
|
|
697
|
-
|
|
1260
|
+
if (answer.truncated) {
|
|
1261
|
+
throw new Error(`${url} answered with more than the ${BODY_LIMIT_BYTES} bytes we read`);
|
|
1262
|
+
}
|
|
1263
|
+
return { said: JSON.parse(answer.body), headers: answer.headers };
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
// ../core/hmac.ts
|
|
1267
|
+
async function hmacHex(secret, payload) {
|
|
1268
|
+
const raw = typeof secret === "string" ? new TextEncoder().encode(secret) : secret;
|
|
1269
|
+
const key = await crypto.subtle.importKey(
|
|
698
1270
|
"raw",
|
|
699
|
-
|
|
700
|
-
"
|
|
701
|
-
false,
|
|
702
|
-
["deriveKey"]
|
|
703
|
-
);
|
|
704
|
-
return crypto.subtle.deriveKey(
|
|
705
|
-
{ name: "HKDF", hash: "SHA-256", salt: new Uint8Array(0), info: INFO },
|
|
706
|
-
material,
|
|
707
|
-
{ name: "AES-GCM", length: 256 },
|
|
1271
|
+
asBuffer(raw),
|
|
1272
|
+
{ name: "HMAC", hash: "SHA-256" },
|
|
708
1273
|
false,
|
|
709
|
-
["
|
|
1274
|
+
["sign"]
|
|
710
1275
|
);
|
|
1276
|
+
const message = typeof payload === "string" ? new TextEncoder().encode(payload) : payload;
|
|
1277
|
+
return bytesToHex(new Uint8Array(await crypto.subtle.sign("HMAC", key, asBuffer(message))));
|
|
711
1278
|
}
|
|
712
|
-
function
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
716
|
-
}
|
|
717
|
-
function fromBase64Url(text2) {
|
|
718
|
-
if (text2.length === 0 || !/^[A-Za-z0-9_-]+$/.test(text2)) return null;
|
|
719
|
-
try {
|
|
720
|
-
const binary = atob(text2.replace(/-/g, "+").replace(/_/g, "/"));
|
|
721
|
-
const bytes = new Uint8Array(binary.length);
|
|
722
|
-
for (let at = 0; at < bytes.length; at++) bytes[at] = binary.charCodeAt(at);
|
|
723
|
-
return bytes;
|
|
724
|
-
} catch {
|
|
725
|
-
return null;
|
|
1279
|
+
function equalInConstantTime(one, other) {
|
|
1280
|
+
if (one.length !== other.length) {
|
|
1281
|
+
return false;
|
|
726
1282
|
}
|
|
1283
|
+
let difference = 0;
|
|
1284
|
+
for (let at = 0; at < one.length; at++) {
|
|
1285
|
+
difference |= one.charCodeAt(at) ^ other.charCodeAt(at);
|
|
1286
|
+
}
|
|
1287
|
+
return difference === 0;
|
|
1288
|
+
}
|
|
1289
|
+
function asBuffer(bytes) {
|
|
1290
|
+
return bytes instanceof Uint8Array && bytes.buffer instanceof ArrayBuffer ? bytes : new Uint8Array(bytes);
|
|
727
1291
|
}
|
|
728
1292
|
|
|
729
1293
|
// src/errors.ts
|
|
@@ -756,8 +1320,124 @@ var NoWalletAvailableError = class extends ProblemError {
|
|
|
756
1320
|
}
|
|
757
1321
|
};
|
|
758
1322
|
|
|
1323
|
+
// src/qr.ts
|
|
1324
|
+
var import_uqr = require("uqr");
|
|
1325
|
+
function encodeForQr(destination) {
|
|
1326
|
+
const scanned = isLnAddress(destination) ? destination : destination.toUpperCase();
|
|
1327
|
+
return `LIGHTNING:${scanned}`;
|
|
1328
|
+
}
|
|
1329
|
+
function isLnAddress(destination) {
|
|
1330
|
+
const at = destination.indexOf("@");
|
|
1331
|
+
return at > 0 && destination.slice(at + 1).includes(".");
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
// src/relay.ts
|
|
1335
|
+
var DEFAULT_POLL_EVERY_SECS2 = 5;
|
|
1336
|
+
var WALLET = "w";
|
|
1337
|
+
function lightningVerifyEndpoint(config) {
|
|
1338
|
+
const paced = {
|
|
1339
|
+
"cache-control": `max-age=${config.pollEverySecs ?? DEFAULT_POLL_EVERY_SECS2}`
|
|
1340
|
+
};
|
|
1341
|
+
return async (request) => {
|
|
1342
|
+
const consented = await answerVerifyChallengeRequest(request);
|
|
1343
|
+
if (consented !== null) {
|
|
1344
|
+
return consented;
|
|
1345
|
+
}
|
|
1346
|
+
const sealed = new URL(request.url).searchParams.get(WALLET);
|
|
1347
|
+
if (sealed === null) {
|
|
1348
|
+
return Response.json({ settled: false }, { status: 400 });
|
|
1349
|
+
}
|
|
1350
|
+
const opened = await unseal(config.secret, sealed);
|
|
1351
|
+
if (opened === null) {
|
|
1352
|
+
return Response.json({ settled: false }, { status: 403 });
|
|
1353
|
+
}
|
|
1354
|
+
const wallet = JSON.parse(opened);
|
|
1355
|
+
const asked = await checkSettled(wallet.url, wallet.hash).catch(() => null);
|
|
1356
|
+
if (asked === null) {
|
|
1357
|
+
return Response.json({ settled: false }, { status: 502 });
|
|
1358
|
+
}
|
|
1359
|
+
return Response.json(
|
|
1360
|
+
{ settled: asked.preimage !== null, preimage: asked.preimage },
|
|
1361
|
+
{ headers: paced }
|
|
1362
|
+
);
|
|
1363
|
+
};
|
|
1364
|
+
}
|
|
1365
|
+
async function relayedVerifyUrl(endpoint, wallet, secret) {
|
|
1366
|
+
const relayed = new URL(endpoint);
|
|
1367
|
+
relayed.searchParams.set(WALLET, await seal(secret, JSON.stringify(wallet)));
|
|
1368
|
+
return relayed.toString();
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1371
|
+
// src/rail.ts
|
|
1372
|
+
var LIGHTNING = "lightning";
|
|
1373
|
+
function blindLightningRail(config) {
|
|
1374
|
+
return async (order) => {
|
|
1375
|
+
const resolved2 = await invoiceFrom(config.lnAddresses, await config.amountMsat(order));
|
|
1376
|
+
const relay = config.relayVerifyThrough;
|
|
1377
|
+
const watched = await config.gateway.watchPayment({
|
|
1378
|
+
paymentHash: resolved2.paymentHash,
|
|
1379
|
+
verifyUrl: relay ? await relayedVerifyUrl(
|
|
1380
|
+
relay.endpoint,
|
|
1381
|
+
{ url: resolved2.verifyUrl, hash: resolved2.paymentHash },
|
|
1382
|
+
relay.secret
|
|
1383
|
+
) : resolved2.verifyUrl,
|
|
1384
|
+
expiresAt: resolved2.expiresAt,
|
|
1385
|
+
trigger: config.trigger,
|
|
1386
|
+
replay: config.replay,
|
|
1387
|
+
sealed: await config.sealed?.(order),
|
|
1388
|
+
webhookUrl: config.webhookUrl
|
|
1389
|
+
});
|
|
1390
|
+
return {
|
|
1391
|
+
id: watched.id,
|
|
1392
|
+
rail: config.name ?? LIGHTNING,
|
|
1393
|
+
scan: resolved2.bolt11,
|
|
1394
|
+
qr: encodeForQr(resolved2.bolt11),
|
|
1395
|
+
expiresAt: resolved2.expiresAt
|
|
1396
|
+
};
|
|
1397
|
+
};
|
|
1398
|
+
}
|
|
1399
|
+
function nwcRail(config) {
|
|
1400
|
+
return async (order) => {
|
|
1401
|
+
const invoice = await nwcInvoice(
|
|
1402
|
+
config.connection,
|
|
1403
|
+
await config.amountMsat(order),
|
|
1404
|
+
config.description?.(order) ?? order.reference
|
|
1405
|
+
);
|
|
1406
|
+
const watched = await config.gateway.watchPayment({
|
|
1407
|
+
paymentHash: invoice.paymentHash,
|
|
1408
|
+
verifyUrl: await nwcVerifyUrl(
|
|
1409
|
+
config.verifyThrough.endpoint,
|
|
1410
|
+
invoice.paymentHash,
|
|
1411
|
+
config.verifyThrough.secret
|
|
1412
|
+
),
|
|
1413
|
+
expiresAt: invoice.expiresAt,
|
|
1414
|
+
trigger: config.trigger,
|
|
1415
|
+
replay: config.replay,
|
|
1416
|
+
sealed: await config.sealed?.(order),
|
|
1417
|
+
webhookUrl: config.webhookUrl
|
|
1418
|
+
});
|
|
1419
|
+
return {
|
|
1420
|
+
id: watched.id,
|
|
1421
|
+
rail: config.name ?? LIGHTNING,
|
|
1422
|
+
scan: invoice.bolt11,
|
|
1423
|
+
qr: encodeForQr(invoice.bolt11),
|
|
1424
|
+
expiresAt: invoice.expiresAt
|
|
1425
|
+
};
|
|
1426
|
+
};
|
|
1427
|
+
}
|
|
1428
|
+
async function invoiceFrom(lnAddresses, amountMsat) {
|
|
1429
|
+
try {
|
|
1430
|
+
return await resolve(lnAddresses, amountMsat);
|
|
1431
|
+
} catch (refused) {
|
|
1432
|
+
if (refused instanceof NoWalletAvailable) {
|
|
1433
|
+
throw new NoWalletAvailableError({ title: refused.message }, refused.wallets);
|
|
1434
|
+
}
|
|
1435
|
+
throw refused;
|
|
1436
|
+
}
|
|
1437
|
+
}
|
|
1438
|
+
|
|
759
1439
|
// src/trigger.ts
|
|
760
|
-
var
|
|
1440
|
+
var NONCE_BYTES2 = 16;
|
|
761
1441
|
function lnurlPayEndpoint(config) {
|
|
762
1442
|
return async (request) => {
|
|
763
1443
|
const url = new URL(request.url);
|
|
@@ -769,6 +1449,18 @@ function lnurlPayEndpoint(config) {
|
|
|
769
1449
|
}
|
|
770
1450
|
};
|
|
771
1451
|
}
|
|
1452
|
+
function watchTicketEndpoint(config) {
|
|
1453
|
+
return async (request) => {
|
|
1454
|
+
const offered = await offeredSecret(request);
|
|
1455
|
+
if (!equalInConstantTime(sha256Hex(offered), sha256Hex(config.watchSecret))) {
|
|
1456
|
+
return Response.json({ reason: "not the watch secret" }, { status: 403 });
|
|
1457
|
+
}
|
|
1458
|
+
return await issue(config);
|
|
1459
|
+
};
|
|
1460
|
+
}
|
|
1461
|
+
function publicWatchTicketEndpoint(config) {
|
|
1462
|
+
return () => issue(config);
|
|
1463
|
+
}
|
|
772
1464
|
async function offer(config, url) {
|
|
773
1465
|
const amountMsat = await config.amountMsat();
|
|
774
1466
|
const quote = await config.gateway.createQuote({
|
|
@@ -794,7 +1486,9 @@ async function mint(config, url, address) {
|
|
|
794
1486
|
const nonce = url.searchParams.get("n") ?? "";
|
|
795
1487
|
const signature = url.searchParams.get("sig") ?? "";
|
|
796
1488
|
const expected = await sign(config.secret, address, amountMsat, nonce);
|
|
797
|
-
if (!equalInConstantTime(signature, expected))
|
|
1489
|
+
if (!equalInConstantTime(signature, expected)) {
|
|
1490
|
+
return refuse("this callback was not signed here");
|
|
1491
|
+
}
|
|
798
1492
|
const wanted = url.searchParams.get("amount");
|
|
799
1493
|
if (wanted !== null && Number(wanted) !== amountMsat) {
|
|
800
1494
|
return refuse(`this trigger costs exactly ${amountMsat} msat`);
|
|
@@ -810,7 +1504,7 @@ async function mint(config, url, address) {
|
|
|
810
1504
|
async function mintThroughGateway(config, address, amountMsat, nonce) {
|
|
811
1505
|
const payment = await config.gateway.createPayment(
|
|
812
1506
|
{ lnAddresses: [address], amountMsat },
|
|
813
|
-
{ idempotencyKey: nonce, trigger: config.watchSecret }
|
|
1507
|
+
{ idempotencyKey: nonce, trigger: config.watchSecret, replay: config.replay }
|
|
814
1508
|
);
|
|
815
1509
|
return { bolt11: payment.bolt11, verifyUrl: payment.verifyUrl };
|
|
816
1510
|
}
|
|
@@ -824,10 +1518,13 @@ async function mintBlind(config, address, amountMsat) {
|
|
|
824
1518
|
verifyUrl: resolved2.verifyUrl,
|
|
825
1519
|
expiresAt: resolved2.expiresAt,
|
|
826
1520
|
trigger: config.watchSecret,
|
|
1521
|
+
replay: config.replay,
|
|
827
1522
|
sealed: locked ? await seal(locked.secret, JSON.stringify(locked.data(minted))) : void 0
|
|
828
1523
|
});
|
|
829
1524
|
} catch (refused) {
|
|
830
|
-
if (!alreadyWatched(refused))
|
|
1525
|
+
if (!alreadyWatched(refused)) {
|
|
1526
|
+
throw refused;
|
|
1527
|
+
}
|
|
831
1528
|
}
|
|
832
1529
|
return { bolt11: resolved2.bolt11, verifyUrl: resolved2.verifyUrl };
|
|
833
1530
|
}
|
|
@@ -838,121 +1535,46 @@ function sign(secret, address, amountMsat, nonce) {
|
|
|
838
1535
|
return hmacHex(secret, `${address}|${amountMsat}|${nonce}`);
|
|
839
1536
|
}
|
|
840
1537
|
function randomNonce() {
|
|
841
|
-
const bytes = crypto.getRandomValues(new Uint8Array(
|
|
1538
|
+
const bytes = crypto.getRandomValues(new Uint8Array(NONCE_BYTES2));
|
|
842
1539
|
let hex = "";
|
|
843
|
-
for (const byte of bytes)
|
|
1540
|
+
for (const byte of bytes) {
|
|
1541
|
+
hex += byte.toString(16).padStart(2, "0");
|
|
1542
|
+
}
|
|
844
1543
|
return hex;
|
|
845
1544
|
}
|
|
846
1545
|
function refuse(reason) {
|
|
847
1546
|
return Response.json({ status: "ERROR", reason });
|
|
848
1547
|
}
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
if (said["type"] !== type || typeof said["nonce"] !== "string") return null;
|
|
859
|
-
return said["nonce"];
|
|
860
|
-
} catch {
|
|
861
|
-
return null;
|
|
862
|
-
}
|
|
863
|
-
}
|
|
864
|
-
function answerVerifyChallenge(body) {
|
|
865
|
-
const nonce = nonceOf(body, VERIFY_CHALLENGE);
|
|
866
|
-
return nonce === null ? null : JSON.stringify({ nonce });
|
|
867
|
-
}
|
|
868
|
-
async function answerVerifyChallengeRequest(request) {
|
|
869
|
-
if (request.method !== "POST") return null;
|
|
870
|
-
const answer = answerVerifyChallenge(await request.clone().text());
|
|
871
|
-
return answer === null ? null : new Response(answer, { headers: { "content-type": "application/json" } });
|
|
872
|
-
}
|
|
873
|
-
|
|
874
|
-
// src/qr.ts
|
|
875
|
-
var import_uqr = require("uqr");
|
|
876
|
-
function encodeForQr(destination) {
|
|
877
|
-
const scanned = isLnAddress(destination) ? destination : destination.toUpperCase();
|
|
878
|
-
return `LIGHTNING:${scanned}`;
|
|
879
|
-
}
|
|
880
|
-
function isLnAddress(destination) {
|
|
881
|
-
const at = destination.indexOf("@");
|
|
882
|
-
return at > 0 && destination.slice(at + 1).includes(".");
|
|
883
|
-
}
|
|
884
|
-
|
|
885
|
-
// src/relay.ts
|
|
886
|
-
var DEFAULT_POLL_EVERY_SECS = 5;
|
|
887
|
-
var WALLET = "w";
|
|
888
|
-
function lightningVerifyEndpoint(config) {
|
|
889
|
-
const paced = {
|
|
890
|
-
"cache-control": `max-age=${config.pollEverySecs ?? DEFAULT_POLL_EVERY_SECS}`
|
|
891
|
-
};
|
|
892
|
-
return async (request) => {
|
|
893
|
-
const consented = await answerVerifyChallengeRequest(request);
|
|
894
|
-
if (consented !== null) return consented;
|
|
895
|
-
const sealed = new URL(request.url).searchParams.get(WALLET);
|
|
896
|
-
if (sealed === null) return Response.json({ settled: false }, { status: 400 });
|
|
897
|
-
const opened = await unseal(config.secret, sealed);
|
|
898
|
-
if (opened === null) return Response.json({ settled: false }, { status: 403 });
|
|
899
|
-
const wallet = JSON.parse(opened);
|
|
900
|
-
const asked = await checkSettled(wallet.url, wallet.hash).catch(() => null);
|
|
901
|
-
if (asked === null) return Response.json({ settled: false }, { status: 502 });
|
|
902
|
-
return Response.json(
|
|
903
|
-
{ settled: asked.preimage !== null, preimage: asked.preimage },
|
|
904
|
-
{ headers: paced }
|
|
905
|
-
);
|
|
906
|
-
};
|
|
907
|
-
}
|
|
908
|
-
async function relayedVerifyUrl(endpoint, wallet, secret) {
|
|
909
|
-
const relayed = new URL(endpoint);
|
|
910
|
-
relayed.searchParams.set(WALLET, await seal(secret, JSON.stringify(wallet)));
|
|
911
|
-
return relayed.toString();
|
|
912
|
-
}
|
|
913
|
-
|
|
914
|
-
// src/rail.ts
|
|
915
|
-
var LIGHTNING = "lightning";
|
|
916
|
-
function blindLightningRail(config) {
|
|
917
|
-
return async (order) => {
|
|
918
|
-
const resolved2 = await invoiceFrom(config.lnAddresses, await config.amountMsat(order));
|
|
919
|
-
const relay = config.relayVerifyThrough;
|
|
920
|
-
const watched = await config.gateway.watchPayment({
|
|
921
|
-
paymentHash: resolved2.paymentHash,
|
|
922
|
-
verifyUrl: relay ? await relayedVerifyUrl(
|
|
923
|
-
relay.endpoint,
|
|
924
|
-
{ url: resolved2.verifyUrl, hash: resolved2.paymentHash },
|
|
925
|
-
relay.secret
|
|
926
|
-
) : resolved2.verifyUrl,
|
|
927
|
-
expiresAt: resolved2.expiresAt,
|
|
928
|
-
trigger: config.trigger,
|
|
929
|
-
sealed: await config.sealed?.(order),
|
|
930
|
-
webhookUrl: config.webhookUrl
|
|
931
|
-
});
|
|
932
|
-
return {
|
|
933
|
-
id: watched.id,
|
|
934
|
-
rail: config.name ?? LIGHTNING,
|
|
935
|
-
scan: resolved2.bolt11,
|
|
936
|
-
qr: encodeForQr(resolved2.bolt11),
|
|
937
|
-
expiresAt: resolved2.expiresAt
|
|
938
|
-
};
|
|
939
|
-
};
|
|
1548
|
+
async function issue(config) {
|
|
1549
|
+
const issued = await config.gateway.createSocketTicket({
|
|
1550
|
+
trigger: config.watchSecret,
|
|
1551
|
+
replay: config.replay
|
|
1552
|
+
});
|
|
1553
|
+
return Response.json({
|
|
1554
|
+
ticket: issued.ticket,
|
|
1555
|
+
expires_at: new Date(issued.expiresAt * 1e3).toISOString()
|
|
1556
|
+
});
|
|
940
1557
|
}
|
|
941
|
-
async function
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
} catch (refused) {
|
|
945
|
-
if (refused instanceof NoWalletAvailable) {
|
|
946
|
-
throw new NoWalletAvailableError({ title: refused.message }, refused.wallets);
|
|
947
|
-
}
|
|
948
|
-
throw refused;
|
|
949
|
-
}
|
|
1558
|
+
async function offeredSecret(request) {
|
|
1559
|
+
const body = await request.json().catch(() => null);
|
|
1560
|
+
return typeof body?.secret === "string" ? body.secret : "";
|
|
950
1561
|
}
|
|
951
1562
|
// Annotate the CommonJS export names for ESM import in node:
|
|
952
1563
|
0 && (module.exports = {
|
|
1564
|
+
askWallet,
|
|
953
1565
|
blindLightningRail,
|
|
954
1566
|
invoiceFrom,
|
|
955
1567
|
lightningVerifyEndpoint,
|
|
956
1568
|
lnurlPayEndpoint,
|
|
957
|
-
|
|
1569
|
+
nwcConnection,
|
|
1570
|
+
nwcHoldInvoice,
|
|
1571
|
+
nwcInvoice,
|
|
1572
|
+
nwcPay,
|
|
1573
|
+
nwcRail,
|
|
1574
|
+
nwcSettlement,
|
|
1575
|
+
nwcVerifyEndpoint,
|
|
1576
|
+
nwcVerifyUrl,
|
|
1577
|
+
publicWatchTicketEndpoint,
|
|
1578
|
+
relayedVerifyUrl,
|
|
1579
|
+
watchTicketEndpoint
|
|
958
1580
|
});
|