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