thunder-bridge 2.1.1 → 2.2.1
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 +40 -17
- package/dist/{bank-M8flcMks.d.ts → bank-BgA45yOU.d.ts} +186 -18
- package/dist/{bank-CEIuNhV_.d.cts → bank-DvcV85sa.d.cts} +186 -18
- package/dist/bank.cjs +10 -4
- package/dist/bank.d.cts +8 -2
- package/dist/bank.d.ts +8 -2
- package/dist/bank.js +10 -4
- package/dist/{errors-DriqW-lp.d.cts → errors-DJmsalYZ.d.cts} +1 -1
- package/dist/{errors-ZEUT-MhO.d.ts → errors-VX0R18oE.d.ts} +1 -1
- package/dist/index.cjs +618 -91
- package/dist/index.d.cts +13 -4
- package/dist/index.d.ts +13 -4
- package/dist/index.js +617 -91
- package/dist/nwc.cjs +78 -21
- package/dist/nwc.d.cts +10 -113
- package/dist/nwc.d.ts +10 -113
- package/dist/nwc.js +78 -21
- package/dist/price.d.cts +1 -1
- package/dist/price.d.ts +1 -1
- package/openapi.yaml +2 -2
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -98,11 +98,11 @@ var BLOCK_BYTES = 64;
|
|
|
98
98
|
var LENGTH_BYTES = 8;
|
|
99
99
|
var SCHEDULE_WORDS = 64;
|
|
100
100
|
function sha256(bytes) {
|
|
101
|
-
const
|
|
102
|
-
const reader = new DataView(
|
|
101
|
+
const padded2 = withPadding(bytes);
|
|
102
|
+
const reader = new DataView(padded2.buffer);
|
|
103
103
|
const state = Uint32Array.from(INITIAL_STATE);
|
|
104
104
|
const schedule = new Uint32Array(SCHEDULE_WORDS);
|
|
105
|
-
for (let block = 0; block <
|
|
105
|
+
for (let block = 0; block < padded2.length; block += BLOCK_BYTES) {
|
|
106
106
|
fillSchedule(schedule, reader, block);
|
|
107
107
|
compress(state, schedule);
|
|
108
108
|
}
|
|
@@ -112,13 +112,13 @@ function sha256Hex(text3) {
|
|
|
112
112
|
return bytesToHex(sha256(new TextEncoder().encode(text3)));
|
|
113
113
|
}
|
|
114
114
|
function withPadding(bytes) {
|
|
115
|
-
const
|
|
115
|
+
const padded2 = new Uint8Array(
|
|
116
116
|
bytes.length + 1 + LENGTH_BYTES + BLOCK_BYTES - 1 & ~(BLOCK_BYTES - 1)
|
|
117
117
|
);
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
new DataView(
|
|
121
|
-
return
|
|
118
|
+
padded2.set(bytes);
|
|
119
|
+
padded2[bytes.length] = 128;
|
|
120
|
+
new DataView(padded2.buffer).setUint32(padded2.length - 4, bytes.length * 8, false);
|
|
121
|
+
return padded2;
|
|
122
122
|
}
|
|
123
123
|
function fillSchedule(schedule, reader, block) {
|
|
124
124
|
for (let at = 0; at < 16; at++) {
|
|
@@ -355,63 +355,86 @@ function asBuffer(bytes) {
|
|
|
355
355
|
// ../core/sealed.ts
|
|
356
356
|
var VERSION = "v2";
|
|
357
357
|
var UNPADDED_VERSION = "v1";
|
|
358
|
+
var PURPOSE_VERSION = "v3";
|
|
358
359
|
var IV_BYTES = 12;
|
|
359
360
|
var MIN_SECRET_CHARS = 32;
|
|
360
361
|
var MAX_PLAIN_BYTES = 3e3;
|
|
361
362
|
var SIZE_CLASSES = [256, 1024, MAX_PLAIN_BYTES];
|
|
362
363
|
var END_OF_TEXT = 128;
|
|
363
|
-
var INFO =
|
|
364
|
+
var INFO = "thunder-bridge/sealed";
|
|
364
365
|
async function seal(secret, plaintext, paymentHash) {
|
|
365
366
|
return await sealUnder(
|
|
366
|
-
secret,
|
|
367
|
+
await keyFor(secret, INFO),
|
|
367
368
|
plaintext,
|
|
368
369
|
crypto.getRandomValues(new Uint8Array(IV_BYTES)),
|
|
370
|
+
VERSION,
|
|
369
371
|
boundTo(paymentHash)
|
|
370
372
|
);
|
|
371
373
|
}
|
|
372
|
-
async function
|
|
374
|
+
async function sealFor(purpose, secret, plaintext, { stable = false } = {}) {
|
|
375
|
+
return await sealUnder(
|
|
376
|
+
await keyFor(secret, `${INFO}/${purpose}`),
|
|
377
|
+
plaintext,
|
|
378
|
+
stable ? await nonceOf(secret, plaintext) : crypto.getRandomValues(new Uint8Array(IV_BYTES)),
|
|
379
|
+
PURPOSE_VERSION,
|
|
380
|
+
new TextEncoder().encode(PURPOSE_VERSION)
|
|
381
|
+
);
|
|
382
|
+
}
|
|
383
|
+
async function unsealFor(purpose, secret, sealed) {
|
|
384
|
+
if (!sealed.startsWith(`${PURPOSE_VERSION}.`)) {
|
|
385
|
+
return await unseal(secret, sealed);
|
|
386
|
+
}
|
|
387
|
+
return await opened(
|
|
388
|
+
await keyFor(secret, `${INFO}/${purpose}`),
|
|
389
|
+
sealed.slice(PURPOSE_VERSION.length + 1),
|
|
390
|
+
new TextEncoder().encode(PURPOSE_VERSION)
|
|
391
|
+
);
|
|
392
|
+
}
|
|
393
|
+
async function nonceOf(secret, plaintext) {
|
|
373
394
|
const derived = await hmacHex(secret, `sealed-nonce|${plaintext}`);
|
|
374
395
|
const nonce = new Uint8Array(IV_BYTES);
|
|
375
396
|
for (let at = 0; at < IV_BYTES; at++) {
|
|
376
397
|
nonce[at] = Number.parseInt(derived.slice(at * 2, at * 2 + 2), 16);
|
|
377
398
|
}
|
|
378
|
-
return
|
|
399
|
+
return nonce;
|
|
379
400
|
}
|
|
380
|
-
async function sealUnder(
|
|
401
|
+
async function sealUnder(key, plaintext, iv, version, additionalData) {
|
|
381
402
|
const body = padToClass(plaintext);
|
|
382
|
-
const key = await keyFor(secret);
|
|
383
403
|
const cipher = new Uint8Array(
|
|
384
404
|
await crypto.subtle.encrypt({ name: "AES-GCM", iv, additionalData }, key, body)
|
|
385
405
|
);
|
|
386
|
-
const
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
return `${
|
|
406
|
+
const joined2 = new Uint8Array(iv.length + cipher.length);
|
|
407
|
+
joined2.set(iv);
|
|
408
|
+
joined2.set(cipher, iv.length);
|
|
409
|
+
return `${version}.${toBase64Url(joined2)}`;
|
|
390
410
|
}
|
|
391
411
|
async function unseal(secret, sealed, paymentHash) {
|
|
392
|
-
const key = await keyFor(secret);
|
|
393
|
-
const
|
|
394
|
-
const unbound = !
|
|
395
|
-
if (!
|
|
412
|
+
const key = await keyFor(secret, INFO);
|
|
413
|
+
const padded2 = sealed.startsWith(`${VERSION}.`);
|
|
414
|
+
const unbound = !padded2 && sealed.startsWith(`${UNPADDED_VERSION}.`);
|
|
415
|
+
if (!padded2 && (!unbound || paymentHash !== void 0)) {
|
|
396
416
|
return null;
|
|
397
417
|
}
|
|
398
|
-
|
|
399
|
-
|
|
418
|
+
return await opened(
|
|
419
|
+
key,
|
|
420
|
+
sealed.slice(VERSION.length + 1),
|
|
421
|
+
padded2 ? boundTo(paymentHash) : void 0
|
|
422
|
+
);
|
|
423
|
+
}
|
|
424
|
+
async function opened(key, encoded, additionalData) {
|
|
425
|
+
const joined2 = fromBase64Url(encoded);
|
|
426
|
+
if (joined2 === null || joined2.length <= IV_BYTES) {
|
|
400
427
|
return null;
|
|
401
428
|
}
|
|
402
429
|
try {
|
|
403
430
|
const body = new Uint8Array(
|
|
404
431
|
await crypto.subtle.decrypt(
|
|
405
|
-
{
|
|
406
|
-
name: "AES-GCM",
|
|
407
|
-
iv: joined.slice(0, IV_BYTES),
|
|
408
|
-
additionalData: padded ? boundTo(paymentHash) : void 0
|
|
409
|
-
},
|
|
432
|
+
{ name: "AES-GCM", iv: joined2.slice(0, IV_BYTES), additionalData },
|
|
410
433
|
key,
|
|
411
|
-
|
|
434
|
+
joined2.slice(IV_BYTES)
|
|
412
435
|
)
|
|
413
436
|
);
|
|
414
|
-
const text3 =
|
|
437
|
+
const text3 = additionalData === void 0 ? body : unpad(body);
|
|
415
438
|
return text3 === null ? null : new TextDecoder().decode(text3);
|
|
416
439
|
} catch {
|
|
417
440
|
return null;
|
|
@@ -442,7 +465,7 @@ function unpad(body) {
|
|
|
442
465
|
const end = body.lastIndexOf(END_OF_TEXT);
|
|
443
466
|
return end === -1 ? null : body.subarray(0, end);
|
|
444
467
|
}
|
|
445
|
-
async function keyFor(secret) {
|
|
468
|
+
async function keyFor(secret, info) {
|
|
446
469
|
refuseAWeakSecret(secret);
|
|
447
470
|
const material = await crypto.subtle.importKey(
|
|
448
471
|
"raw",
|
|
@@ -452,7 +475,12 @@ async function keyFor(secret) {
|
|
|
452
475
|
["deriveKey"]
|
|
453
476
|
);
|
|
454
477
|
return crypto.subtle.deriveKey(
|
|
455
|
-
{
|
|
478
|
+
{
|
|
479
|
+
name: "HKDF",
|
|
480
|
+
hash: "SHA-256",
|
|
481
|
+
salt: new Uint8Array(0),
|
|
482
|
+
info: new TextEncoder().encode(info)
|
|
483
|
+
},
|
|
456
484
|
material,
|
|
457
485
|
{ name: "AES-GCM", length: 256 },
|
|
458
486
|
false,
|
|
@@ -885,8 +913,8 @@ function hasSmallOrder(publicKeyHex) {
|
|
|
885
913
|
return SMALL_ORDER.includes(bytesToHex(point));
|
|
886
914
|
}
|
|
887
915
|
function fromBase64Url2(text3) {
|
|
888
|
-
const
|
|
889
|
-
const raw = atob(
|
|
916
|
+
const padded2 = text3.replace(/-/g, "+").replace(/_/g, "/");
|
|
917
|
+
const raw = atob(padded2 + "=".repeat((4 - padded2.length % 4) % 4));
|
|
890
918
|
const bytes = new Uint8Array(raw.length);
|
|
891
919
|
for (let at = 0; at < raw.length; at++) {
|
|
892
920
|
bytes[at] = raw.charCodeAt(at);
|
|
@@ -901,7 +929,9 @@ function asBuffer2(bytes) {
|
|
|
901
929
|
var KEY_HEADER = "x-client-key";
|
|
902
930
|
var SIGNATURE_HEADER = "x-signature";
|
|
903
931
|
var TIMESTAMP_HEADER = "x-timestamp";
|
|
932
|
+
var NONCE_HEADER = "x-nonce";
|
|
904
933
|
var PREFIX = "ed25519=";
|
|
934
|
+
var NONCE_BYTES = 16;
|
|
905
935
|
var SIGNING_LABEL = "request-signing";
|
|
906
936
|
async function callerKey(secret) {
|
|
907
937
|
return await signingKeyFromSeed(hexToBytes(await hmacHex(secret, SIGNING_LABEL)));
|
|
@@ -909,17 +939,21 @@ async function callerKey(secret) {
|
|
|
909
939
|
function paymentNamedBy(caller, paymentHash) {
|
|
910
940
|
return sha256Hex(`${caller}.payment-id.${paymentHash}`);
|
|
911
941
|
}
|
|
912
|
-
async function signedAs(key, method, path, body) {
|
|
942
|
+
async function signedAs(key, method, path, body, host) {
|
|
913
943
|
const timestamp = String(Math.floor(Date.now() / 1e3));
|
|
944
|
+
const nonce = bytesToHex(crypto.getRandomValues(new Uint8Array(NONCE_BYTES)));
|
|
914
945
|
return {
|
|
915
946
|
[KEY_HEADER]: key.publicKeyHex,
|
|
916
947
|
[TIMESTAMP_HEADER]: timestamp,
|
|
917
|
-
[
|
|
948
|
+
[NONCE_HEADER]: nonce,
|
|
949
|
+
[SIGNATURE_HEADER]: `${PREFIX}${await key.sign(spokenTo(host, timestamp, nonce, method, path, body))}`
|
|
918
950
|
};
|
|
919
951
|
}
|
|
920
|
-
function
|
|
952
|
+
function spokenTo(host, timestamp, nonce, method, path, body) {
|
|
921
953
|
return new TextEncoder().encode(
|
|
922
|
-
|
|
954
|
+
["v2", host.toLowerCase(), timestamp, nonce, method.toUpperCase(), path, sha256Hex(body)].join(
|
|
955
|
+
"\n"
|
|
956
|
+
)
|
|
923
957
|
);
|
|
924
958
|
}
|
|
925
959
|
|
|
@@ -1101,14 +1135,39 @@ async function addressesToReach(url) {
|
|
|
1101
1135
|
return found;
|
|
1102
1136
|
}
|
|
1103
1137
|
async function resolved(url) {
|
|
1104
|
-
const {
|
|
1105
|
-
const { setTimeout: sleep } = await import("timers/promises");
|
|
1138
|
+
const { isIP } = await import("net");
|
|
1106
1139
|
const host = new URL(url).hostname.replace(/^\[|]$/g, "");
|
|
1140
|
+
const literal = isIP(host);
|
|
1141
|
+
if (literal !== 0) {
|
|
1142
|
+
return [{ address: host, family: literal }];
|
|
1143
|
+
}
|
|
1144
|
+
const { Resolver } = await import("dns/promises");
|
|
1145
|
+
const { setTimeout: sleep } = await import("timers/promises");
|
|
1146
|
+
const resolver = new Resolver({ timeout: LOOKUP_TIMEOUT_MS, tries: 1 });
|
|
1107
1147
|
const found = await Promise.race([
|
|
1108
|
-
|
|
1148
|
+
Promise.all([
|
|
1149
|
+
resolver.resolve4(host).catch(() => []),
|
|
1150
|
+
resolver.resolve6(host).catch(() => [])
|
|
1151
|
+
]),
|
|
1109
1152
|
sleep(LOOKUP_TIMEOUT_MS, null, { ref: false })
|
|
1110
1153
|
]);
|
|
1111
|
-
|
|
1154
|
+
cancelled(resolver);
|
|
1155
|
+
if (found === null) {
|
|
1156
|
+
return [];
|
|
1157
|
+
}
|
|
1158
|
+
const [four, six] = found;
|
|
1159
|
+
return [
|
|
1160
|
+
...four.map((address) => ({ address, family: 4 })),
|
|
1161
|
+
...six.map((address) => ({ address, family: 6 }))
|
|
1162
|
+
];
|
|
1163
|
+
}
|
|
1164
|
+
function cancelled(resolver) {
|
|
1165
|
+
try {
|
|
1166
|
+
resolver.cancel();
|
|
1167
|
+
return true;
|
|
1168
|
+
} catch {
|
|
1169
|
+
return false;
|
|
1170
|
+
}
|
|
1112
1171
|
}
|
|
1113
1172
|
async function answerOf(response) {
|
|
1114
1173
|
const { status, ok, headers } = response;
|
|
@@ -1437,6 +1496,14 @@ function headersOf(raw) {
|
|
|
1437
1496
|
return headers;
|
|
1438
1497
|
}
|
|
1439
1498
|
|
|
1499
|
+
// ../core/delivery.ts
|
|
1500
|
+
function deliverySigned(url, timestamp, body) {
|
|
1501
|
+
const endpoint = new URL(url);
|
|
1502
|
+
return new TextEncoder().encode(
|
|
1503
|
+
["v2", `${endpoint.origin}${endpoint.pathname}`, timestamp, body].join("\n")
|
|
1504
|
+
);
|
|
1505
|
+
}
|
|
1506
|
+
|
|
1440
1507
|
// src/verify.ts
|
|
1441
1508
|
var HTTP_TIMEOUT_MS2 = 15e3;
|
|
1442
1509
|
var ISSUED_SKEW_SECS = 300;
|
|
@@ -1508,7 +1575,7 @@ async function proveSettlement(payment, asked) {
|
|
|
1508
1575
|
}
|
|
1509
1576
|
return verified.preimage;
|
|
1510
1577
|
}
|
|
1511
|
-
function
|
|
1578
|
+
function agreesWithItself(report) {
|
|
1512
1579
|
if (report.status !== "paid" || report.preimage === null) {
|
|
1513
1580
|
return false;
|
|
1514
1581
|
}
|
|
@@ -1520,6 +1587,7 @@ function carriesProof(report) {
|
|
|
1520
1587
|
}
|
|
1521
1588
|
return preimageMatchesHash(report.preimage, report.paymentHash);
|
|
1522
1589
|
}
|
|
1590
|
+
var carriesProof = agreesWithItself;
|
|
1523
1591
|
function wrapFeeCeiling(amountMsat, allowance) {
|
|
1524
1592
|
const proportional = amountMsat * (allowance?.proportion ?? DEFAULT_WRAP_PROPORTION);
|
|
1525
1593
|
return msat(Math.max(allowance?.baseMsat ?? DEFAULT_WRAP_BASE_MSAT, Math.floor(proportional)));
|
|
@@ -1801,7 +1869,7 @@ function isStatus(value) {
|
|
|
1801
1869
|
}
|
|
1802
1870
|
|
|
1803
1871
|
// src/webhook.ts
|
|
1804
|
-
var SIGNATURE_HEADER2 = "x-signature";
|
|
1872
|
+
var SIGNATURE_HEADER2 = "x-signature-v2";
|
|
1805
1873
|
var TIMESTAMP_HEADER2 = "x-timestamp";
|
|
1806
1874
|
var GATEWAY_KEY_PREFIX = "ed25519=";
|
|
1807
1875
|
var DEFAULT_TOLERANCE_SECS = 300;
|
|
@@ -1817,14 +1885,14 @@ async function readSettlement(request, credential, options = {}) {
|
|
|
1817
1885
|
}
|
|
1818
1886
|
async function answerWebhookChallenge(request, credential, options = {}) {
|
|
1819
1887
|
const body = await believable(request, credential, options);
|
|
1820
|
-
const nonce = body === null ? null :
|
|
1888
|
+
const nonce = body === null ? null : nonceOf2(body, CHALLENGE);
|
|
1821
1889
|
return nonce === null ? null : answered2(nonce);
|
|
1822
1890
|
}
|
|
1823
1891
|
async function answerVerifyChallenge(request) {
|
|
1824
1892
|
if (request.method !== "POST") {
|
|
1825
1893
|
return null;
|
|
1826
1894
|
}
|
|
1827
|
-
const nonce =
|
|
1895
|
+
const nonce = nonceOf2(await request.clone().text(), VERIFY_CHALLENGE);
|
|
1828
1896
|
return nonce === null ? null : answered2(nonce);
|
|
1829
1897
|
}
|
|
1830
1898
|
async function believable(request, credential, options) {
|
|
@@ -1843,12 +1911,12 @@ async function believable(request, credential, options) {
|
|
|
1843
1911
|
const signed = await verifyHex(
|
|
1844
1912
|
credential.publicKey.toLowerCase(),
|
|
1845
1913
|
signature.slice(GATEWAY_KEY_PREFIX.length).toLowerCase(),
|
|
1846
|
-
|
|
1914
|
+
deliverySigned(options.url ?? request.url, timestamp, body)
|
|
1847
1915
|
);
|
|
1848
1916
|
return signed ? body : null;
|
|
1849
1917
|
}
|
|
1850
1918
|
function proved(read) {
|
|
1851
|
-
return read !== null && read.status === "paid" && !
|
|
1919
|
+
return read !== null && read.status === "paid" && !agreesWithItself(read) ? null : read;
|
|
1852
1920
|
}
|
|
1853
1921
|
function decoded(body, from) {
|
|
1854
1922
|
try {
|
|
@@ -1862,7 +1930,7 @@ function answered2(nonce) {
|
|
|
1862
1930
|
headers: { "content-type": "application/json" }
|
|
1863
1931
|
});
|
|
1864
1932
|
}
|
|
1865
|
-
function
|
|
1933
|
+
function nonceOf2(text3, type) {
|
|
1866
1934
|
try {
|
|
1867
1935
|
const said = JSON.parse(text3);
|
|
1868
1936
|
if (said["type"] !== type || typeof said["nonce"] !== "string") {
|
|
@@ -1880,9 +1948,6 @@ function recent(timestamp, toleranceSecs) {
|
|
|
1880
1948
|
}
|
|
1881
1949
|
return Math.abs(Math.floor(Date.now() / 1e3) - sent) <= toleranceSecs;
|
|
1882
1950
|
}
|
|
1883
|
-
function stamped(timestamp, body) {
|
|
1884
|
-
return new TextEncoder().encode(`${timestamp}.${body}`);
|
|
1885
|
-
}
|
|
1886
1951
|
|
|
1887
1952
|
// src/bank.ts
|
|
1888
1953
|
var DEFAULT_CURRENCY = "CZK";
|
|
@@ -1924,7 +1989,7 @@ function bankVerifyEndpoint(config) {
|
|
|
1924
1989
|
throw new Error(`${config.iban} is not an IBAN, so this endpoint answers for no account`);
|
|
1925
1990
|
}
|
|
1926
1991
|
const paced = {
|
|
1927
|
-
"cache-control": `max-age=${config.pollEverySecs ?? DEFAULT_POLL_EVERY_SECS}`
|
|
1992
|
+
"cache-control": `max-age=${config.pollEverySecs ?? config.statement.freshEverySecs ?? DEFAULT_POLL_EVERY_SECS}`
|
|
1928
1993
|
};
|
|
1929
1994
|
return async (request) => {
|
|
1930
1995
|
const consented = await answerVerifyChallenge(request);
|
|
@@ -1935,7 +2000,7 @@ function bankVerifyEndpoint(config) {
|
|
|
1935
2000
|
if (sealed === null) {
|
|
1936
2001
|
return Response.json({ settled: false }, { status: 400 });
|
|
1937
2002
|
}
|
|
1938
|
-
const subject = await
|
|
2003
|
+
const subject = await unsealFor("bank-verify", config.secret, sealed);
|
|
1939
2004
|
const asked = subject === null ? null : askedFrom(subject);
|
|
1940
2005
|
if (asked === null || asked.iban !== answersFor) {
|
|
1941
2006
|
return Response.json({ settled: false }, { status: 403 });
|
|
@@ -1954,7 +2019,10 @@ async function answeredAt(params, subject) {
|
|
|
1954
2019
|
return `agent:${(await callerKey(params.secret)).publicKeyHex}`;
|
|
1955
2020
|
}
|
|
1956
2021
|
const polling = new URL(params.verifyUrl ?? "");
|
|
1957
|
-
polling.searchParams.set(
|
|
2022
|
+
polling.searchParams.set(
|
|
2023
|
+
"q",
|
|
2024
|
+
await sealFor("bank-verify", params.secret, subject, { stable: true })
|
|
2025
|
+
);
|
|
1958
2026
|
return polling.toString();
|
|
1959
2027
|
}
|
|
1960
2028
|
async function creditedPreimage(params) {
|
|
@@ -2060,6 +2128,161 @@ function unixNow() {
|
|
|
2060
2128
|
return Math.floor(Date.now() / 1e3);
|
|
2061
2129
|
}
|
|
2062
2130
|
|
|
2131
|
+
// src/nostr.ts
|
|
2132
|
+
import { chacha20 } from "@noble/ciphers/chacha.js";
|
|
2133
|
+
import { schnorr, secp256k1 } from "@noble/curves/secp256k1.js";
|
|
2134
|
+
import { expand, extract } from "@noble/hashes/hkdf.js";
|
|
2135
|
+
import { hmac } from "@noble/hashes/hmac.js";
|
|
2136
|
+
import { sha256 as sha2562 } from "@noble/hashes/sha2.js";
|
|
2137
|
+
var NIP44_SALT = new TextEncoder().encode("nip44-v2");
|
|
2138
|
+
var NIP44_VERSION = 2;
|
|
2139
|
+
var NONCE_BYTES2 = 32;
|
|
2140
|
+
var MAC_BYTES = 32;
|
|
2141
|
+
var MESSAGE_KEYS_BYTES = 76;
|
|
2142
|
+
var CHACHA_KEY_END = 32;
|
|
2143
|
+
var CHACHA_NONCE_END = 44;
|
|
2144
|
+
var LENGTH_PREFIX_BYTES = 2;
|
|
2145
|
+
var SMALLEST_PAD = 32;
|
|
2146
|
+
var CHUNKS_STAY_SMALL_TO = 256;
|
|
2147
|
+
var MAX_PLAINTEXT_BYTES = 65535;
|
|
2148
|
+
function publicKeyFor(privateKeyHex) {
|
|
2149
|
+
return bytesToHex(schnorr.getPublicKey(hexToBytes(privateKeyHex)));
|
|
2150
|
+
}
|
|
2151
|
+
function conversationKeyFor(privateKeyHex, publicKeyHex) {
|
|
2152
|
+
const shared = secp256k1.getSharedSecret(
|
|
2153
|
+
hexToBytes(privateKeyHex),
|
|
2154
|
+
hexToBytes(`02${publicKeyHex}`)
|
|
2155
|
+
);
|
|
2156
|
+
return extract(sha2562, shared.subarray(1, 33), NIP44_SALT);
|
|
2157
|
+
}
|
|
2158
|
+
function signEvent(privateKeyHex, draft) {
|
|
2159
|
+
const pubkey = publicKeyFor(privateKeyHex);
|
|
2160
|
+
const id = idOf(pubkey, draft);
|
|
2161
|
+
return {
|
|
2162
|
+
id,
|
|
2163
|
+
pubkey,
|
|
2164
|
+
created_at: draft.created_at,
|
|
2165
|
+
kind: draft.kind,
|
|
2166
|
+
tags: draft.tags,
|
|
2167
|
+
content: draft.content,
|
|
2168
|
+
sig: bytesToHex(schnorr.sign(hexToBytes(id), hexToBytes(privateKeyHex)))
|
|
2169
|
+
};
|
|
2170
|
+
}
|
|
2171
|
+
function verifyEvent(event) {
|
|
2172
|
+
try {
|
|
2173
|
+
return idOf(event.pubkey, event) === event.id && schnorr.verify(hexToBytes(event.sig), hexToBytes(event.id), hexToBytes(event.pubkey));
|
|
2174
|
+
} catch {
|
|
2175
|
+
return false;
|
|
2176
|
+
}
|
|
2177
|
+
}
|
|
2178
|
+
function idOf(pubkey, draft) {
|
|
2179
|
+
return bytesToHex(
|
|
2180
|
+
sha2562(
|
|
2181
|
+
new TextEncoder().encode(
|
|
2182
|
+
JSON.stringify([0, pubkey, draft.created_at, draft.kind, draft.tags, draft.content])
|
|
2183
|
+
)
|
|
2184
|
+
)
|
|
2185
|
+
);
|
|
2186
|
+
}
|
|
2187
|
+
function encryptNip44(conversationKey, plaintext) {
|
|
2188
|
+
return sealNip44(conversationKey, plaintext, crypto.getRandomValues(new Uint8Array(NONCE_BYTES2)));
|
|
2189
|
+
}
|
|
2190
|
+
function sealNip44(conversationKey, plaintext, nonce) {
|
|
2191
|
+
const keys = messageKeysFrom(conversationKey, nonce);
|
|
2192
|
+
const ciphertext = chacha20(keys.chachaKey, keys.chachaNonce, padded(plaintext));
|
|
2193
|
+
const mac = hmac(sha2562, keys.hmacKey, joined(nonce, ciphertext));
|
|
2194
|
+
return toBase64(joined(Uint8Array.of(NIP44_VERSION), nonce, ciphertext, mac));
|
|
2195
|
+
}
|
|
2196
|
+
function decryptNip44(conversationKey, payload) {
|
|
2197
|
+
if (payload.startsWith("#")) {
|
|
2198
|
+
throw new Error("nip44 payload announces an encryption version we do not speak");
|
|
2199
|
+
}
|
|
2200
|
+
const raw = fromBase64(payload);
|
|
2201
|
+
if (raw.length < 1 + NONCE_BYTES2 + MAC_BYTES) {
|
|
2202
|
+
throw new Error(`nip44 payload is ${raw.length} bytes, too short to hold a nonce and a mac`);
|
|
2203
|
+
}
|
|
2204
|
+
if (raw[0] !== NIP44_VERSION) {
|
|
2205
|
+
throw new Error(`nip44 payload is version ${raw[0]}, not ${NIP44_VERSION}`);
|
|
2206
|
+
}
|
|
2207
|
+
const nonce = raw.subarray(1, 1 + NONCE_BYTES2);
|
|
2208
|
+
const ciphertext = raw.subarray(1 + NONCE_BYTES2, raw.length - MAC_BYTES);
|
|
2209
|
+
const mac = raw.subarray(raw.length - MAC_BYTES);
|
|
2210
|
+
const keys = messageKeysFrom(conversationKey, nonce);
|
|
2211
|
+
if (!sameBytes(hmac(sha2562, keys.hmacKey, joined(nonce, ciphertext)), mac)) {
|
|
2212
|
+
throw new Error("nip44 payload failed its mac, so it was not written with this key");
|
|
2213
|
+
}
|
|
2214
|
+
return unpadded(chacha20(keys.chachaKey, keys.chachaNonce, ciphertext));
|
|
2215
|
+
}
|
|
2216
|
+
function messageKeysFrom(conversationKey, nonce) {
|
|
2217
|
+
if (conversationKey.length !== NONCE_BYTES2 || nonce.length !== NONCE_BYTES2) {
|
|
2218
|
+
throw new Error("a nip44 conversation key and nonce are both 32 bytes");
|
|
2219
|
+
}
|
|
2220
|
+
const keys = expand(sha2562, conversationKey, nonce, MESSAGE_KEYS_BYTES);
|
|
2221
|
+
return {
|
|
2222
|
+
chachaKey: keys.subarray(0, CHACHA_KEY_END),
|
|
2223
|
+
chachaNonce: keys.subarray(CHACHA_KEY_END, CHACHA_NONCE_END),
|
|
2224
|
+
hmacKey: keys.subarray(CHACHA_NONCE_END)
|
|
2225
|
+
};
|
|
2226
|
+
}
|
|
2227
|
+
function paddedLengthFor(unpadded2) {
|
|
2228
|
+
if (unpadded2 <= SMALLEST_PAD) {
|
|
2229
|
+
return SMALLEST_PAD;
|
|
2230
|
+
}
|
|
2231
|
+
const nextPower = 1 << Math.floor(Math.log2(unpadded2 - 1)) + 1;
|
|
2232
|
+
const chunk = nextPower <= CHUNKS_STAY_SMALL_TO ? SMALLEST_PAD : nextPower / 8;
|
|
2233
|
+
return chunk * (Math.floor((unpadded2 - 1) / chunk) + 1);
|
|
2234
|
+
}
|
|
2235
|
+
function padded(plaintext) {
|
|
2236
|
+
const body = new TextEncoder().encode(plaintext);
|
|
2237
|
+
if (body.length === 0 || body.length > MAX_PLAINTEXT_BYTES) {
|
|
2238
|
+
throw new Error(`nip44 takes 1 to ${MAX_PLAINTEXT_BYTES} bytes, this was ${body.length}`);
|
|
2239
|
+
}
|
|
2240
|
+
const buffer = new Uint8Array(LENGTH_PREFIX_BYTES + paddedLengthFor(body.length));
|
|
2241
|
+
buffer.set(Uint8Array.of(body.length >>> 8, body.length & 255));
|
|
2242
|
+
buffer.set(body, LENGTH_PREFIX_BYTES);
|
|
2243
|
+
return buffer;
|
|
2244
|
+
}
|
|
2245
|
+
function unpadded(buffer) {
|
|
2246
|
+
const length = (buffer[0] ?? 0) << 8 | (buffer[1] ?? 0);
|
|
2247
|
+
if (length === 0 || buffer.length !== LENGTH_PREFIX_BYTES + paddedLengthFor(length)) {
|
|
2248
|
+
throw new Error("nip44 plaintext does not match the length its own padding declares");
|
|
2249
|
+
}
|
|
2250
|
+
return new TextDecoder().decode(
|
|
2251
|
+
buffer.subarray(LENGTH_PREFIX_BYTES, LENGTH_PREFIX_BYTES + length)
|
|
2252
|
+
);
|
|
2253
|
+
}
|
|
2254
|
+
function joined(...parts) {
|
|
2255
|
+
const whole = new Uint8Array(parts.reduce((total, part) => total + part.length, 0));
|
|
2256
|
+
let at = 0;
|
|
2257
|
+
for (const part of parts) {
|
|
2258
|
+
whole.set(part, at);
|
|
2259
|
+
at += part.length;
|
|
2260
|
+
}
|
|
2261
|
+
return whole;
|
|
2262
|
+
}
|
|
2263
|
+
function sameBytes(left, right) {
|
|
2264
|
+
let different = left.length ^ right.length;
|
|
2265
|
+
for (let at = 0; at < left.length && at < right.length; at++) {
|
|
2266
|
+
different |= (left[at] ?? 0) ^ (right[at] ?? 0);
|
|
2267
|
+
}
|
|
2268
|
+
return different === 0;
|
|
2269
|
+
}
|
|
2270
|
+
function toBase64(bytes) {
|
|
2271
|
+
let binary = "";
|
|
2272
|
+
for (const byte of bytes) {
|
|
2273
|
+
binary += String.fromCharCode(byte);
|
|
2274
|
+
}
|
|
2275
|
+
return btoa(binary);
|
|
2276
|
+
}
|
|
2277
|
+
function fromBase64(text3) {
|
|
2278
|
+
const binary = atob(text3);
|
|
2279
|
+
const bytes = new Uint8Array(binary.length);
|
|
2280
|
+
for (let at = 0; at < bytes.length; at++) {
|
|
2281
|
+
bytes[at] = binary.charCodeAt(at);
|
|
2282
|
+
}
|
|
2283
|
+
return bytes;
|
|
2284
|
+
}
|
|
2285
|
+
|
|
2063
2286
|
// src/qr.ts
|
|
2064
2287
|
import { encode } from "uqr";
|
|
2065
2288
|
var SAFE_COLOR = /^#(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$|^rgba?\([\d.,%/\s]+\)$|^[a-z]+$/i;
|
|
@@ -2102,12 +2325,244 @@ function renderSvg(content, size, color) {
|
|
|
2102
2325
|
].join("");
|
|
2103
2326
|
}
|
|
2104
2327
|
|
|
2105
|
-
// src/
|
|
2328
|
+
// src/nwc.ts
|
|
2329
|
+
var REQUEST_KIND = 23194;
|
|
2330
|
+
var RESPONSE_KIND = 23195;
|
|
2331
|
+
var NIP44_V2 = "nip44_v2";
|
|
2332
|
+
var DEFAULT_ASK_TIMEOUT_MS = 1e4;
|
|
2106
2333
|
var DEFAULT_POLL_EVERY_SECS2 = 5;
|
|
2334
|
+
var HASH = "h";
|
|
2335
|
+
var REFUSAL_BY_CODE = {
|
|
2336
|
+
INSUFFICIENT_BALANCE: "amount-not-accepted",
|
|
2337
|
+
QUOTA_EXCEEDED: "amount-not-accepted",
|
|
2338
|
+
RESTRICTED: "address-unusable",
|
|
2339
|
+
UNAUTHORIZED: "address-unusable",
|
|
2340
|
+
UNSUPPORTED_ENCRYPTION: "address-unusable"
|
|
2341
|
+
};
|
|
2342
|
+
async function nwcInvoice(connection, amountMsat, description, timeoutMs = DEFAULT_ASK_TIMEOUT_MS) {
|
|
2343
|
+
const answered3 = await askWallet(
|
|
2344
|
+
connection,
|
|
2345
|
+
"make_invoice",
|
|
2346
|
+
{ amount: amountMsat, description },
|
|
2347
|
+
timeoutMs
|
|
2348
|
+
);
|
|
2349
|
+
const bolt11 = String(answered3.invoice ?? "");
|
|
2350
|
+
const issued = decodeInvoice(bolt11);
|
|
2351
|
+
if (issued.paymentHash === null || issued.expiresAt === null) {
|
|
2352
|
+
throw new WalletRefused("invoice-refused", "the nwc wallet issued an invoice we cannot decode");
|
|
2353
|
+
}
|
|
2354
|
+
if (issued.amountMsat !== amountMsat) {
|
|
2355
|
+
throw new WalletRefused(
|
|
2356
|
+
"invoice-refused",
|
|
2357
|
+
`the nwc wallet issued an invoice for ${issued.amountMsat} msat, not ${amountMsat}`
|
|
2358
|
+
);
|
|
2359
|
+
}
|
|
2360
|
+
return { bolt11, paymentHash: issued.paymentHash, expiresAt: issued.expiresAt };
|
|
2361
|
+
}
|
|
2362
|
+
async function nwcSettlement(connection, paymentHash, timeoutMs = DEFAULT_ASK_TIMEOUT_MS) {
|
|
2363
|
+
const answered3 = await askWallet(
|
|
2364
|
+
connection,
|
|
2365
|
+
"lookup_invoice",
|
|
2366
|
+
{ payment_hash: paymentHash },
|
|
2367
|
+
timeoutMs
|
|
2368
|
+
);
|
|
2369
|
+
const preimage = answered3.preimage;
|
|
2370
|
+
if (typeof preimage !== "string" || preimage.length === 0) {
|
|
2371
|
+
return null;
|
|
2372
|
+
}
|
|
2373
|
+
if (!preimageMatchesHash(preimage, paymentHash)) {
|
|
2374
|
+
throw new Error(`the nwc wallet returned a preimage that does not hash to ${paymentHash}`);
|
|
2375
|
+
}
|
|
2376
|
+
return preimage;
|
|
2377
|
+
}
|
|
2378
|
+
function nwcVerifyEndpoint(config) {
|
|
2379
|
+
const paced = {
|
|
2380
|
+
"cache-control": `max-age=${config.pollEverySecs ?? DEFAULT_POLL_EVERY_SECS2}`
|
|
2381
|
+
};
|
|
2382
|
+
return async (request) => {
|
|
2383
|
+
const consented = await answerVerifyChallenge(request);
|
|
2384
|
+
if (consented !== null) {
|
|
2385
|
+
return consented;
|
|
2386
|
+
}
|
|
2387
|
+
const sealed = new URL(request.url).searchParams.get(HASH);
|
|
2388
|
+
if (sealed === null) {
|
|
2389
|
+
return Response.json({ settled: false }, { status: 400 });
|
|
2390
|
+
}
|
|
2391
|
+
const paymentHash = await unsealFor("nwc-verify", config.secret, sealed);
|
|
2392
|
+
if (paymentHash === null) {
|
|
2393
|
+
return Response.json({ settled: false }, { status: 403 });
|
|
2394
|
+
}
|
|
2395
|
+
const preimage = await nwcSettlement(config.connection, paymentHash, config.askTimeoutMs).catch(
|
|
2396
|
+
() => void 0
|
|
2397
|
+
);
|
|
2398
|
+
if (preimage === void 0) {
|
|
2399
|
+
return Response.json({ settled: false }, { status: 502 });
|
|
2400
|
+
}
|
|
2401
|
+
return Response.json({ settled: preimage !== null, preimage }, { headers: paced });
|
|
2402
|
+
};
|
|
2403
|
+
}
|
|
2404
|
+
async function nwcVerifyUrl(endpoint, paymentHash, secret) {
|
|
2405
|
+
const sealed = new URL(endpoint);
|
|
2406
|
+
sealed.searchParams.set(HASH, await sealFor("nwc-verify", secret, paymentHash));
|
|
2407
|
+
return sealed.toString();
|
|
2408
|
+
}
|
|
2409
|
+
async function askWallet(connection, method, params, timeoutMs = DEFAULT_ASK_TIMEOUT_MS) {
|
|
2410
|
+
const key = conversationKeyFor(connection.secret, connection.walletPubkey);
|
|
2411
|
+
const request = signEvent(connection.secret, {
|
|
2412
|
+
kind: REQUEST_KIND,
|
|
2413
|
+
tags: [
|
|
2414
|
+
["p", connection.walletPubkey],
|
|
2415
|
+
["encryption", NIP44_V2]
|
|
2416
|
+
],
|
|
2417
|
+
content: encryptNip44(key, JSON.stringify({ method, params })),
|
|
2418
|
+
created_at: Math.floor(Date.now() / 1e3)
|
|
2419
|
+
});
|
|
2420
|
+
const answer = await overFirstRelayThatAnswers(connection, request, timeoutMs);
|
|
2421
|
+
return resultOf(JSON.parse(decryptNip44(key, answer.content)), method);
|
|
2422
|
+
}
|
|
2423
|
+
function resultOf(said, method) {
|
|
2424
|
+
const failure = said.error;
|
|
2425
|
+
if (failure) {
|
|
2426
|
+
throw new WalletRefused(
|
|
2427
|
+
REFUSAL_BY_CODE[failure.code ?? ""] ?? "invoice-refused",
|
|
2428
|
+
`the nwc wallet refused ${method}: ${failure.code ?? "unknown"} ${failure.message ?? ""}`.trim()
|
|
2429
|
+
);
|
|
2430
|
+
}
|
|
2431
|
+
const result = said.result;
|
|
2432
|
+
if (result === null || typeof result !== "object") {
|
|
2433
|
+
throw new WalletRefused("invoice-refused", `the nwc wallet answered ${method} with no result`);
|
|
2434
|
+
}
|
|
2435
|
+
return result;
|
|
2436
|
+
}
|
|
2437
|
+
async function overFirstRelayThatAnswers(connection, request, timeoutMs) {
|
|
2438
|
+
const deadline = AbortSignal.timeout(timeoutMs);
|
|
2439
|
+
let lastFailure = null;
|
|
2440
|
+
for (const relay of connection.relays) {
|
|
2441
|
+
if (deadline.aborted) {
|
|
2442
|
+
break;
|
|
2443
|
+
}
|
|
2444
|
+
try {
|
|
2445
|
+
return await overRelay(relay, connection, request, deadline);
|
|
2446
|
+
} catch (failure) {
|
|
2447
|
+
lastFailure = failure;
|
|
2448
|
+
}
|
|
2449
|
+
}
|
|
2450
|
+
throw new WalletRefused("unreachable", `no nwc relay answered: ${String(lastFailure)}`);
|
|
2451
|
+
}
|
|
2452
|
+
function overRelay(relay, connection, request, deadline) {
|
|
2453
|
+
return new Promise((resolve2, reject) => {
|
|
2454
|
+
const socket = new WebSocket(relay);
|
|
2455
|
+
const subscription = request.id.slice(0, 16);
|
|
2456
|
+
const expired = () => {
|
|
2457
|
+
close();
|
|
2458
|
+
reject(new Error(`${relay} did not answer before the deadline`));
|
|
2459
|
+
};
|
|
2460
|
+
deadline.addEventListener("abort", expired);
|
|
2461
|
+
function close() {
|
|
2462
|
+
deadline.removeEventListener("abort", expired);
|
|
2463
|
+
socket.close();
|
|
2464
|
+
}
|
|
2465
|
+
socket.addEventListener("open", () => {
|
|
2466
|
+
socket.send(
|
|
2467
|
+
JSON.stringify([
|
|
2468
|
+
"REQ",
|
|
2469
|
+
subscription,
|
|
2470
|
+
{
|
|
2471
|
+
kinds: [RESPONSE_KIND],
|
|
2472
|
+
authors: [connection.walletPubkey],
|
|
2473
|
+
"#p": [publicKeyFor(connection.secret)],
|
|
2474
|
+
"#e": [request.id]
|
|
2475
|
+
}
|
|
2476
|
+
])
|
|
2477
|
+
);
|
|
2478
|
+
socket.send(JSON.stringify(["EVENT", request]));
|
|
2479
|
+
});
|
|
2480
|
+
socket.addEventListener("error", () => {
|
|
2481
|
+
close();
|
|
2482
|
+
reject(new Error(`${relay} could not be reached`));
|
|
2483
|
+
});
|
|
2484
|
+
socket.addEventListener("message", (delivered) => {
|
|
2485
|
+
const frame = String(delivered.data);
|
|
2486
|
+
const rejection = rejectionOf(frame, request.id);
|
|
2487
|
+
if (rejection !== null) {
|
|
2488
|
+
close();
|
|
2489
|
+
reject(new Error(`${relay} refused the request: ${rejection}`));
|
|
2490
|
+
return;
|
|
2491
|
+
}
|
|
2492
|
+
const answer = answeredEvent(frame, subscription, connection, request.id);
|
|
2493
|
+
if (answer === null) {
|
|
2494
|
+
return;
|
|
2495
|
+
}
|
|
2496
|
+
close();
|
|
2497
|
+
resolve2(answer);
|
|
2498
|
+
});
|
|
2499
|
+
});
|
|
2500
|
+
}
|
|
2501
|
+
function rejectionOf(frame, requestId) {
|
|
2502
|
+
const parsed = parsedFrame(frame);
|
|
2503
|
+
if (parsed === null || parsed[0] !== "OK" || parsed[1] !== requestId || parsed[2] !== false) {
|
|
2504
|
+
return null;
|
|
2505
|
+
}
|
|
2506
|
+
return typeof parsed[3] === "string" && parsed[3].length > 0 ? parsed[3] : "no reason given";
|
|
2507
|
+
}
|
|
2508
|
+
function answeredEvent(frame, subscription, connection, requestId) {
|
|
2509
|
+
const parsed = parsedFrame(frame);
|
|
2510
|
+
if (parsed === null || parsed[0] !== "EVENT" || parsed[1] !== subscription) {
|
|
2511
|
+
return null;
|
|
2512
|
+
}
|
|
2513
|
+
const event = parsed[2];
|
|
2514
|
+
const answers = event?.kind === RESPONSE_KIND && event.pubkey === connection.walletPubkey && Array.isArray(event.tags) && event.tags.some((tag) => tag?.[0] === "e" && tag[1] === requestId) && verifyEvent(event);
|
|
2515
|
+
return answers ? event : null;
|
|
2516
|
+
}
|
|
2517
|
+
function parsedFrame(frame) {
|
|
2518
|
+
try {
|
|
2519
|
+
const parsed = JSON.parse(frame);
|
|
2520
|
+
return Array.isArray(parsed) ? parsed : null;
|
|
2521
|
+
} catch {
|
|
2522
|
+
return null;
|
|
2523
|
+
}
|
|
2524
|
+
}
|
|
2525
|
+
function nwcRail(gateway, config) {
|
|
2526
|
+
return async (order) => {
|
|
2527
|
+
const invoice = await nwcInvoice(
|
|
2528
|
+
config.connection,
|
|
2529
|
+
await msatForOrder(order, config.amount, config.rate),
|
|
2530
|
+
config.description?.(order) ?? order.reference
|
|
2531
|
+
);
|
|
2532
|
+
const watched = await gateway.watch({
|
|
2533
|
+
paymentHash: invoice.paymentHash,
|
|
2534
|
+
verifyUrl: await nwcVerifyUrl(
|
|
2535
|
+
config.verifyThrough.endpoint,
|
|
2536
|
+
invoice.paymentHash,
|
|
2537
|
+
config.verifyThrough.secret
|
|
2538
|
+
),
|
|
2539
|
+
expiresAt: invoice.expiresAt,
|
|
2540
|
+
trigger: config.trigger,
|
|
2541
|
+
replay: config.replay,
|
|
2542
|
+
sealed: config.sealed ? await seal(
|
|
2543
|
+
config.sealed.secret,
|
|
2544
|
+
JSON.stringify(config.sealed.data(order)),
|
|
2545
|
+
invoice.paymentHash
|
|
2546
|
+
) : void 0,
|
|
2547
|
+
webhookUrl: config.webhookUrl
|
|
2548
|
+
});
|
|
2549
|
+
return {
|
|
2550
|
+
id: watched.id,
|
|
2551
|
+
paymentHash: invoice.paymentHash,
|
|
2552
|
+
rail: config.name ?? "lightning",
|
|
2553
|
+
scan: invoice.bolt11,
|
|
2554
|
+
qr: toLightningUri(invoice.bolt11),
|
|
2555
|
+
expiresAt: invoice.expiresAt
|
|
2556
|
+
};
|
|
2557
|
+
};
|
|
2558
|
+
}
|
|
2559
|
+
|
|
2560
|
+
// src/relay.ts
|
|
2561
|
+
var DEFAULT_POLL_EVERY_SECS3 = 5;
|
|
2107
2562
|
var WALLET = "w";
|
|
2108
2563
|
function lightningVerifyEndpoint(config) {
|
|
2109
2564
|
const paced = {
|
|
2110
|
-
"cache-control": `max-age=${config.pollEverySecs ??
|
|
2565
|
+
"cache-control": `max-age=${config.pollEverySecs ?? DEFAULT_POLL_EVERY_SECS3}`
|
|
2111
2566
|
};
|
|
2112
2567
|
return async (request) => {
|
|
2113
2568
|
const consented = await answerVerifyChallenge(request);
|
|
@@ -2118,8 +2573,8 @@ function lightningVerifyEndpoint(config) {
|
|
|
2118
2573
|
if (sealed === null) {
|
|
2119
2574
|
return Response.json({ settled: false }, { status: 400 });
|
|
2120
2575
|
}
|
|
2121
|
-
const
|
|
2122
|
-
const wallet =
|
|
2576
|
+
const opened2 = await unsealFor("relay", config.secret, sealed);
|
|
2577
|
+
const wallet = opened2 === null ? null : relayedFrom(opened2);
|
|
2123
2578
|
if (wallet === null) {
|
|
2124
2579
|
return Response.json({ settled: false }, { status: 403 });
|
|
2125
2580
|
}
|
|
@@ -2137,9 +2592,9 @@ function lightningVerifyEndpoint(config) {
|
|
|
2137
2592
|
);
|
|
2138
2593
|
};
|
|
2139
2594
|
}
|
|
2140
|
-
function relayedFrom(
|
|
2595
|
+
function relayedFrom(opened2) {
|
|
2141
2596
|
try {
|
|
2142
|
-
const said = JSON.parse(
|
|
2597
|
+
const said = JSON.parse(opened2);
|
|
2143
2598
|
return typeof said?.url === "string" && typeof said.hash === "string" ? { url: said.url, hash: said.hash } : null;
|
|
2144
2599
|
} catch {
|
|
2145
2600
|
return null;
|
|
@@ -2147,7 +2602,7 @@ function relayedFrom(opened) {
|
|
|
2147
2602
|
}
|
|
2148
2603
|
async function relayedVerifyUrl(endpoint, wallet, secret) {
|
|
2149
2604
|
const relayed = new URL(endpoint);
|
|
2150
|
-
relayed.searchParams.set(WALLET, await
|
|
2605
|
+
relayed.searchParams.set(WALLET, await sealFor("relay", secret, JSON.stringify(wallet)));
|
|
2151
2606
|
return relayed.toString();
|
|
2152
2607
|
}
|
|
2153
2608
|
|
|
@@ -2278,6 +2733,15 @@ var Rails = class {
|
|
|
2278
2733
|
bank(config) {
|
|
2279
2734
|
return bankRail(this.gateway, config);
|
|
2280
2735
|
}
|
|
2736
|
+
/**
|
|
2737
|
+
* Lightning against a wallet of your own over NIP-47, for a wallet that has no
|
|
2738
|
+
* LUD-21 address to be watched at. Your node mints the invoice and releases the
|
|
2739
|
+
* preimage, so the proof comes from one hop nearer than any hosted address can
|
|
2740
|
+
* manage, and the gateway sees a hash and a URL of yours
|
|
2741
|
+
*/
|
|
2742
|
+
nwc(config) {
|
|
2743
|
+
return nwcRail(this.gateway, config);
|
|
2744
|
+
}
|
|
2281
2745
|
/**
|
|
2282
2746
|
* One bank transfer without building a rail first, for a shop that asks for
|
|
2283
2747
|
* them one at a time rather than beside another payment method
|
|
@@ -2327,7 +2791,7 @@ function waitingOf(options) {
|
|
|
2327
2791
|
}
|
|
2328
2792
|
|
|
2329
2793
|
// src/trigger.ts
|
|
2330
|
-
var
|
|
2794
|
+
var NONCE_BYTES3 = 16;
|
|
2331
2795
|
function lnurlPayEndpoint(gateway, config) {
|
|
2332
2796
|
return async (request) => {
|
|
2333
2797
|
const url = new URL(request.url);
|
|
@@ -2466,10 +2930,10 @@ function alreadyWatched(refused) {
|
|
|
2466
2930
|
return refused instanceof ProblemError && isProblemType(refused, PAYMENT_ALREADY_WATCHED);
|
|
2467
2931
|
}
|
|
2468
2932
|
function sign(secret, address, least, most, nonce) {
|
|
2469
|
-
return hmacHex(secret,
|
|
2933
|
+
return hmacHex(secret, `lnurl-callback|${address}|${least}|${most}|${nonce}`);
|
|
2470
2934
|
}
|
|
2471
2935
|
function randomNonce() {
|
|
2472
|
-
const bytes = crypto.getRandomValues(new Uint8Array(
|
|
2936
|
+
const bytes = crypto.getRandomValues(new Uint8Array(NONCE_BYTES3));
|
|
2473
2937
|
let hex = "";
|
|
2474
2938
|
for (const byte of bytes) {
|
|
2475
2939
|
hex += byte.toString(16).padStart(2, "0");
|
|
@@ -2519,23 +2983,39 @@ var Serve = class {
|
|
|
2519
2983
|
* A verify endpoint of your own that asks the recipient's wallet for you, so
|
|
2520
2984
|
* the gateway polls you and never the wallet
|
|
2521
2985
|
*/
|
|
2522
|
-
|
|
2986
|
+
lightningVerify(config) {
|
|
2523
2987
|
return lightningVerifyEndpoint(config);
|
|
2524
2988
|
}
|
|
2989
|
+
/**
|
|
2990
|
+
* What `lightningVerify` was called before 2.2.0
|
|
2991
|
+
*
|
|
2992
|
+
* @deprecated Use `lightningVerify`, beside `bankVerify` and `nwcVerify`
|
|
2993
|
+
*/
|
|
2994
|
+
verify(config) {
|
|
2995
|
+
return this.lightningVerify(config);
|
|
2996
|
+
}
|
|
2525
2997
|
/** The verify endpoint a bank rail is polled at, answering off your own statement */
|
|
2526
2998
|
bankVerify(config) {
|
|
2527
2999
|
return bankVerifyEndpoint(config);
|
|
2528
3000
|
}
|
|
3001
|
+
/**
|
|
3002
|
+
* The verify endpoint an NWC rail is polled at, asking your own wallet over
|
|
3003
|
+
* NIP-47, so the gateway never learns the connection, the relay or the wallet
|
|
3004
|
+
*/
|
|
3005
|
+
nwcVerify(config) {
|
|
3006
|
+
return nwcVerifyEndpoint(config);
|
|
3007
|
+
}
|
|
2529
3008
|
/**
|
|
2530
3009
|
* The whole webhook route: it answers the gateway's challenge, checks the
|
|
2531
3010
|
* signature against the key the gateway publishes, refuses a settlement that
|
|
2532
|
-
* proves nothing, and calls you for the one that does.
|
|
3011
|
+
* proves nothing, and calls you once for the one that does.
|
|
2533
3012
|
*
|
|
2534
3013
|
* `export const POST = gateway.serve.webhook({ onSettled: fulfil })` is the
|
|
2535
3014
|
* entire integration
|
|
2536
3015
|
*/
|
|
2537
3016
|
webhook(handlers) {
|
|
2538
|
-
const options = { toleranceSecs: handlers.toleranceSecs };
|
|
3017
|
+
const options = { toleranceSecs: handlers.toleranceSecs, url: handlers.url };
|
|
3018
|
+
const once = onceWhileReplayable(handlers.toleranceSecs ?? DEFAULT_TOLERANCE_SECS);
|
|
2539
3019
|
return async (request) => {
|
|
2540
3020
|
const credential = await this.credential(handlers);
|
|
2541
3021
|
const challenge = await answerWebhookChallenge(request, credential, options);
|
|
@@ -2544,12 +3024,17 @@ var Serve = class {
|
|
|
2544
3024
|
}
|
|
2545
3025
|
const settlement = await readSettlement(request, credential, options);
|
|
2546
3026
|
if (settlement !== null) {
|
|
2547
|
-
return await
|
|
3027
|
+
return await once(
|
|
3028
|
+
`${settlement.id}:${settlement.status}`,
|
|
3029
|
+
() => acted(settlement, handlers)
|
|
3030
|
+
);
|
|
2548
3031
|
}
|
|
2549
3032
|
const payment = await readPayment(request, credential, options);
|
|
2550
3033
|
if (payment !== null) {
|
|
2551
|
-
await
|
|
2552
|
-
|
|
3034
|
+
return await once(`${payment.id}:${payment.status}`, async () => {
|
|
3035
|
+
await handlers.onPayment?.(payment);
|
|
3036
|
+
return new Response("ok");
|
|
3037
|
+
});
|
|
2553
3038
|
}
|
|
2554
3039
|
return new Response("bad signature", { status: 401 });
|
|
2555
3040
|
};
|
|
@@ -2570,8 +3055,34 @@ var Serve = class {
|
|
|
2570
3055
|
return handlers.credential ?? { publicKey: await this.gateway.webhookKey() };
|
|
2571
3056
|
}
|
|
2572
3057
|
};
|
|
3058
|
+
function onceWhileReplayable(toleranceSecs) {
|
|
3059
|
+
const acting = /* @__PURE__ */ new Map();
|
|
3060
|
+
const replayableForMs = 2 * toleranceSecs * 1e3;
|
|
3061
|
+
return async (delivery, act) => {
|
|
3062
|
+
const now = Date.now();
|
|
3063
|
+
for (const [earlier2, { forgetAt }] of acting) {
|
|
3064
|
+
if (forgetAt > now) {
|
|
3065
|
+
break;
|
|
3066
|
+
}
|
|
3067
|
+
acting.delete(earlier2);
|
|
3068
|
+
}
|
|
3069
|
+
const earlier = acting.get(delivery);
|
|
3070
|
+
if (earlier !== void 0) {
|
|
3071
|
+
await earlier.done;
|
|
3072
|
+
return new Response("ok");
|
|
3073
|
+
}
|
|
3074
|
+
const done = act();
|
|
3075
|
+
acting.set(delivery, { forgetAt: now + replayableForMs, done });
|
|
3076
|
+
try {
|
|
3077
|
+
return await done;
|
|
3078
|
+
} catch (failure) {
|
|
3079
|
+
acting.delete(delivery);
|
|
3080
|
+
throw failure;
|
|
3081
|
+
}
|
|
3082
|
+
};
|
|
3083
|
+
}
|
|
2573
3084
|
async function acted(settlement, handlers) {
|
|
2574
|
-
if (
|
|
3085
|
+
if (agreesWithItself(settlement)) {
|
|
2575
3086
|
await handlers.onSettled?.(settlement);
|
|
2576
3087
|
return new Response("ok");
|
|
2577
3088
|
}
|
|
@@ -2795,7 +3306,7 @@ var ThunderBridge = class {
|
|
|
2795
3306
|
throw await problemFrom(response);
|
|
2796
3307
|
}
|
|
2797
3308
|
const body = await response.json().catch(() => null);
|
|
2798
|
-
const listed2 = Array.isArray(body?.payments) ? body.payments.map(paymentFromWire).filter((one) => one !== null).filter((one) => one.status !== "paid" ||
|
|
3309
|
+
const listed2 = Array.isArray(body?.payments) ? body.payments.map(paymentFromWire).filter((one) => one !== null).filter((one) => one.status !== "paid" || agreesWithItself(one)) : null;
|
|
2799
3310
|
if (listed2 === null || typeof body?.settled_scanned !== "number") {
|
|
2800
3311
|
throw new ProblemError({
|
|
2801
3312
|
status: response.status,
|
|
@@ -2880,9 +3391,9 @@ var ThunderBridge = class {
|
|
|
2880
3391
|
if (settled) {
|
|
2881
3392
|
return;
|
|
2882
3393
|
}
|
|
2883
|
-
const
|
|
2884
|
-
socket =
|
|
2885
|
-
|
|
3394
|
+
const opened2 = new WebSocket(url);
|
|
3395
|
+
socket = opened2;
|
|
3396
|
+
opened2.onmessage = (event) => {
|
|
2886
3397
|
try {
|
|
2887
3398
|
const frame = JSON.parse(String(event.data));
|
|
2888
3399
|
const watched = paymentFromWire(frame);
|
|
@@ -2899,7 +3410,7 @@ var ThunderBridge = class {
|
|
|
2899
3410
|
settle(() => reject(refused));
|
|
2900
3411
|
}
|
|
2901
3412
|
};
|
|
2902
|
-
|
|
3413
|
+
opened2.onclose = again;
|
|
2903
3414
|
};
|
|
2904
3415
|
options?.signal?.addEventListener("abort", abort, { once: true });
|
|
2905
3416
|
void connect();
|
|
@@ -2922,6 +3433,10 @@ var ThunderBridge = class {
|
|
|
2922
3433
|
* the recipient's own wallet minted it, so a payer who pays the loser afterwards
|
|
2923
3434
|
* really does pay twice and that shows up on `follow` as a second settlement to
|
|
2924
3435
|
* refund.
|
|
3436
|
+
*
|
|
3437
|
+
* A leg the gateway is caught lying about before any leg is paid ends the wait
|
|
3438
|
+
* with its `GatewayCheatError`, even when another leg is paid after it, so a
|
|
3439
|
+
* detected cheat is never traded for a later win.
|
|
2925
3440
|
*/
|
|
2926
3441
|
async firstSettled(held, options) {
|
|
2927
3442
|
if (held.length === 0) {
|
|
@@ -2931,7 +3446,7 @@ var ThunderBridge = class {
|
|
|
2931
3446
|
const signal = options?.signal ? AbortSignal.any([stopLosers.signal, options.signal]) : stopLosers.signal;
|
|
2932
3447
|
let refused = null;
|
|
2933
3448
|
try {
|
|
2934
|
-
const winner = await new Promise((resolve2) => {
|
|
3449
|
+
const winner = await new Promise((resolve2, caught) => {
|
|
2935
3450
|
let waiting = held.length;
|
|
2936
3451
|
const lost = () => {
|
|
2937
3452
|
waiting -= 1;
|
|
@@ -2941,6 +3456,10 @@ var ThunderBridge = class {
|
|
|
2941
3456
|
};
|
|
2942
3457
|
for (const leg of held) {
|
|
2943
3458
|
this.settled(leg, { ...options, signal }).then((watched) => watched.status === "paid" ? resolve2(watched) : lost()).catch((failure) => {
|
|
3459
|
+
if (failure instanceof GatewayCheatError) {
|
|
3460
|
+
caught(failure);
|
|
3461
|
+
return;
|
|
3462
|
+
}
|
|
2944
3463
|
refused ??= failure;
|
|
2945
3464
|
lost();
|
|
2946
3465
|
});
|
|
@@ -3017,13 +3536,13 @@ var ThunderBridge = class {
|
|
|
3017
3536
|
}
|
|
3018
3537
|
retry = setTimeout(() => void connect(), backoffMs(firstDelay, attempt));
|
|
3019
3538
|
};
|
|
3020
|
-
const answered3 = async (
|
|
3539
|
+
const answered3 = async (opened2, raw) => {
|
|
3021
3540
|
const asked = askFromWire(raw);
|
|
3022
3541
|
if (asked === null) {
|
|
3023
3542
|
return;
|
|
3024
3543
|
}
|
|
3025
3544
|
const preimage = await options.answer(asked.paymentHash);
|
|
3026
|
-
|
|
3545
|
+
opened2.send(
|
|
3027
3546
|
JSON.stringify(
|
|
3028
3547
|
preimage === null ? { ask: asked.ask, settled: false } : { ask: asked.ask, settled: true, preimage }
|
|
3029
3548
|
)
|
|
@@ -3042,18 +3561,18 @@ var ThunderBridge = class {
|
|
|
3042
3561
|
if (stopped) {
|
|
3043
3562
|
return;
|
|
3044
3563
|
}
|
|
3045
|
-
const
|
|
3046
|
-
socket =
|
|
3047
|
-
|
|
3564
|
+
const opened2 = new WebSocket(url);
|
|
3565
|
+
socket = opened2;
|
|
3566
|
+
opened2.onopen = () => {
|
|
3048
3567
|
attempt = 1;
|
|
3049
3568
|
};
|
|
3050
|
-
|
|
3051
|
-
void answered3(
|
|
3569
|
+
opened2.onmessage = (event) => {
|
|
3570
|
+
void answered3(opened2, String(event.data)).catch(
|
|
3052
3571
|
(refused) => options.onError?.(refused)
|
|
3053
3572
|
);
|
|
3054
3573
|
};
|
|
3055
|
-
|
|
3056
|
-
|
|
3574
|
+
opened2.onerror = () => options.onError?.(new Error(`could not attend ${base}`));
|
|
3575
|
+
opened2.onclose = again;
|
|
3057
3576
|
};
|
|
3058
3577
|
void connect();
|
|
3059
3578
|
return () => {
|
|
@@ -3168,7 +3687,7 @@ var ThunderBridge = class {
|
|
|
3168
3687
|
async reading(method, path, body = "") {
|
|
3169
3688
|
return {
|
|
3170
3689
|
...this.token === null ? {} : { authorization: `Bearer ${this.token}` },
|
|
3171
|
-
...this.secret === null ? {} : await signedAs(await this.speaking(), method, path, body)
|
|
3690
|
+
...this.secret === null ? {} : await signedAs(await this.speaking(), method, path, body, new URL(this.baseUrl).host)
|
|
3172
3691
|
};
|
|
3173
3692
|
}
|
|
3174
3693
|
async speaking() {
|
|
@@ -3194,7 +3713,7 @@ var ThunderBridge = class {
|
|
|
3194
3713
|
if (other) {
|
|
3195
3714
|
throw new GatewayCheatError("hash_mismatch", payment.id);
|
|
3196
3715
|
}
|
|
3197
|
-
if (payment.status === "paid" && !
|
|
3716
|
+
if (payment.status === "paid" && !agreesWithItself(payment)) {
|
|
3198
3717
|
throw new GatewayCheatError("preimage_mismatch", payment.id);
|
|
3199
3718
|
}
|
|
3200
3719
|
return payment;
|
|
@@ -3248,6 +3767,7 @@ var Gateways = class {
|
|
|
3248
3767
|
each;
|
|
3249
3768
|
urls;
|
|
3250
3769
|
onRefused;
|
|
3770
|
+
onCaught;
|
|
3251
3771
|
constructor(baseUrls, options) {
|
|
3252
3772
|
if (baseUrls.length === 0) {
|
|
3253
3773
|
throw new Error("name at least one gateway to watch at");
|
|
@@ -3256,6 +3776,8 @@ var Gateways = class {
|
|
|
3256
3776
|
this.each = baseUrls.map((baseUrl) => new ThunderBridge(baseUrl, options));
|
|
3257
3777
|
this.onRefused = options?.onRefused ?? (() => {
|
|
3258
3778
|
});
|
|
3779
|
+
this.onCaught = options?.onCaught ?? (() => {
|
|
3780
|
+
});
|
|
3259
3781
|
}
|
|
3260
3782
|
/** What this payment is called, which every gateway here will agree on */
|
|
3261
3783
|
async nameFor(paymentHash) {
|
|
@@ -3303,7 +3825,7 @@ var Gateways = class {
|
|
|
3303
3825
|
resolve2(null);
|
|
3304
3826
|
}
|
|
3305
3827
|
};
|
|
3306
|
-
for (const gateway of this.each) {
|
|
3828
|
+
for (const [at, gateway] of this.each.entries()) {
|
|
3307
3829
|
gateway.settled(held, { ...options, signal }).then((watched) => {
|
|
3308
3830
|
if (watched.status === "paid") {
|
|
3309
3831
|
return resolve2(watched);
|
|
@@ -3311,6 +3833,9 @@ var Gateways = class {
|
|
|
3311
3833
|
unpaid.push(watched);
|
|
3312
3834
|
lost();
|
|
3313
3835
|
}).catch((failure) => {
|
|
3836
|
+
if (failure instanceof GatewayCheatError) {
|
|
3837
|
+
this.onCaught(this.urls[at], failure);
|
|
3838
|
+
}
|
|
3314
3839
|
refused ??= failure;
|
|
3315
3840
|
lost();
|
|
3316
3841
|
});
|
|
@@ -3338,6 +3863,7 @@ export {
|
|
|
3338
3863
|
ThunderBridge,
|
|
3339
3864
|
UnverifiedRecipientError,
|
|
3340
3865
|
WrapRefusedError,
|
|
3866
|
+
agreesWithItself,
|
|
3341
3867
|
answerVerifyChallenge,
|
|
3342
3868
|
carriesProof,
|
|
3343
3869
|
decodeInvoice,
|