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