thunder-bridge 0.8.6 → 0.8.9

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/dist/index.js CHANGED
@@ -1215,9 +1215,155 @@ function minorScaleOf(currency) {
1215
1215
  return 10 ** minorUnitsOf(currency);
1216
1216
  }
1217
1217
 
1218
+ // ../core/ed25519.ts
1219
+ var ALGORITHM = { name: "Ed25519" };
1220
+ var SIGNATURE_BYTES = 64;
1221
+ var PUBLIC_KEY_BYTES = 32;
1222
+ var PKCS8_HEADER = hexToBytes("302e020100300506032b657004220420");
1223
+ async function verifyHex(publicKeyHex, signatureHex, payload) {
1224
+ if (!isHex(publicKeyHex) || publicKeyHex.length !== PUBLIC_KEY_BYTES * 2) return false;
1225
+ if (!isHex(signatureHex) || signatureHex.length !== SIGNATURE_BYTES * 2) return false;
1226
+ try {
1227
+ const key = await crypto.subtle.importKey(
1228
+ "raw",
1229
+ asBuffer2(hexToBytes(publicKeyHex)),
1230
+ ALGORITHM,
1231
+ false,
1232
+ ["verify"]
1233
+ );
1234
+ return await crypto.subtle.verify(
1235
+ ALGORITHM,
1236
+ key,
1237
+ asBuffer2(hexToBytes(signatureHex)),
1238
+ asBuffer2(payload)
1239
+ );
1240
+ } catch {
1241
+ return false;
1242
+ }
1243
+ }
1244
+ function asBuffer2(bytes) {
1245
+ return bytes.buffer instanceof ArrayBuffer ? bytes : new Uint8Array(bytes);
1246
+ }
1247
+
1248
+ // src/webhook.ts
1249
+ var SIGNATURE_HEADER = "x-signature";
1250
+ var TIMESTAMP_HEADER = "x-timestamp";
1251
+ var SHARED_SECRET_PREFIX = "sha256=";
1252
+ var GATEWAY_KEY_PREFIX = "ed25519=";
1253
+ var DEFAULT_TOLERANCE_SECS = 300;
1254
+ var CHALLENGE = "webhook-challenge";
1255
+ var VERIFY_CHALLENGE = "verify-challenge";
1256
+ async function verifyWebhookSignature(body, signature, credential, timestamp, options = {}) {
1257
+ if (!recent(timestamp, options.toleranceSecs ?? DEFAULT_TOLERANCE_SECS)) return false;
1258
+ if (typeof credential !== "string") {
1259
+ if (!signature.startsWith(GATEWAY_KEY_PREFIX)) return false;
1260
+ return verifyHex(
1261
+ credential.publicKey.toLowerCase(),
1262
+ signature.slice(GATEWAY_KEY_PREFIX.length).toLowerCase(),
1263
+ signed(timestamp, body)
1264
+ );
1265
+ }
1266
+ if (signature.startsWith(GATEWAY_KEY_PREFIX)) return false;
1267
+ const received = signature.startsWith(SHARED_SECRET_PREFIX) ? signature.slice(SHARED_SECRET_PREFIX.length) : signature;
1268
+ return equalInConstantTime(await sign(credential, timestamp, body), received.toLowerCase());
1269
+ }
1270
+ async function parseWebhook(body, signature, credential, timestamp, options = {}) {
1271
+ if (!await verifyWebhookSignature(body, signature, credential, timestamp, options)) return null;
1272
+ const text2 = typeof body === "string" ? body : new TextDecoder().decode(body);
1273
+ try {
1274
+ return paymentFromWire(JSON.parse(text2));
1275
+ } catch {
1276
+ return null;
1277
+ }
1278
+ }
1279
+ async function parseWebhookRequest(request, credential, options = {}) {
1280
+ const signature = request.headers.get(SIGNATURE_HEADER);
1281
+ const timestamp = request.headers.get(TIMESTAMP_HEADER);
1282
+ if (signature === null || timestamp === null) return null;
1283
+ return parseWebhook(await request.text(), signature, credential, timestamp, options);
1284
+ }
1285
+ async function parseWatchedWebhook(body, signature, credential, timestamp, options = {}) {
1286
+ if (!await verifyWebhookSignature(body, signature, credential, timestamp, options)) return null;
1287
+ const text2 = typeof body === "string" ? body : new TextDecoder().decode(body);
1288
+ try {
1289
+ return triggerEventFromWire(JSON.parse(text2));
1290
+ } catch {
1291
+ return null;
1292
+ }
1293
+ }
1294
+ async function parseWatchedWebhookRequest(request, credential, options = {}) {
1295
+ const signature = request.headers.get(SIGNATURE_HEADER);
1296
+ const timestamp = request.headers.get(TIMESTAMP_HEADER);
1297
+ if (signature === null || timestamp === null) return null;
1298
+ return parseWatchedWebhook(await request.text(), signature, credential, timestamp, options);
1299
+ }
1300
+ async function answerWebhookChallenge(body, signature, credential, timestamp, options = {}) {
1301
+ if (!await verifyWebhookSignature(body, signature, credential, timestamp, options)) return null;
1302
+ const text2 = typeof body === "string" ? body : new TextDecoder().decode(body);
1303
+ const nonce = challenged(text2);
1304
+ if (nonce === null) return null;
1305
+ if (typeof credential !== "string") return JSON.stringify({ nonce });
1306
+ return JSON.stringify({
1307
+ nonce,
1308
+ signature: `${SHARED_SECRET_PREFIX}${await hmacHex(credential, nonce)}`
1309
+ });
1310
+ }
1311
+ async function answerWebhookChallengeRequest(request, credential, options = {}) {
1312
+ const signature = request.headers.get(SIGNATURE_HEADER);
1313
+ const timestamp = request.headers.get(TIMESTAMP_HEADER);
1314
+ if (signature === null || timestamp === null) return null;
1315
+ const answer = await answerWebhookChallenge(
1316
+ await request.clone().text(),
1317
+ signature,
1318
+ credential,
1319
+ timestamp,
1320
+ options
1321
+ );
1322
+ return answer === null ? null : new Response(answer, { headers: { "content-type": "application/json" } });
1323
+ }
1324
+ function challenged(text2) {
1325
+ return nonceOf(text2, CHALLENGE);
1326
+ }
1327
+ function nonceOf(text2, type) {
1328
+ try {
1329
+ const said = JSON.parse(text2);
1330
+ if (said["type"] !== type || typeof said["nonce"] !== "string") return null;
1331
+ return said["nonce"];
1332
+ } catch {
1333
+ return null;
1334
+ }
1335
+ }
1336
+ function answerVerifyChallenge(body) {
1337
+ const nonce = nonceOf(body, VERIFY_CHALLENGE);
1338
+ return nonce === null ? null : JSON.stringify({ nonce });
1339
+ }
1340
+ async function answerVerifyChallengeRequest(request) {
1341
+ if (request.method !== "POST") return null;
1342
+ const answer = answerVerifyChallenge(await request.clone().text());
1343
+ return answer === null ? null : new Response(answer, { headers: { "content-type": "application/json" } });
1344
+ }
1345
+ function recent(timestamp, toleranceSecs) {
1346
+ const sent = Number(timestamp);
1347
+ if (!Number.isFinite(sent)) return false;
1348
+ return Math.abs(Math.floor(Date.now() / 1e3) - sent) <= toleranceSecs;
1349
+ }
1350
+ async function sign(secret, timestamp, body) {
1351
+ return hmacHex(secret, signed(timestamp, body));
1352
+ }
1353
+ function signed(timestamp, body) {
1354
+ const encoder = new TextEncoder();
1355
+ const stamp = encoder.encode(`${timestamp}.`);
1356
+ const rest = typeof body === "string" ? encoder.encode(body) : new Uint8Array(body);
1357
+ const payload = new Uint8Array(stamp.length + rest.length);
1358
+ payload.set(stamp);
1359
+ payload.set(rest, stamp.length);
1360
+ return payload;
1361
+ }
1362
+
1218
1363
  // src/bank.ts
1219
1364
  var DEFAULT_CURRENCY = "CZK";
1220
1365
  var DEFAULT_LOOK_BACK_SECS = 7 * 24 * 60 * 60;
1366
+ var DEFAULT_POLL_EVERY_SECS = 30;
1221
1367
  var IBAN = /^[A-Z]{2}[0-9]{2}[0-9A-Z]{8,30}$/;
1222
1368
  var FORBIDDEN_IN_SPD = "*";
1223
1369
  async function bankTransfer(params) {
@@ -1244,7 +1390,12 @@ async function bankTransfer(params) {
1244
1390
  return { id: watched.id, paymentHash: watched.paymentHash, verifyUrl, spd };
1245
1391
  }
1246
1392
  function bankVerifyEndpoint(config) {
1393
+ const paced = {
1394
+ "cache-control": `max-age=${config.pollEverySecs ?? DEFAULT_POLL_EVERY_SECS}`
1395
+ };
1247
1396
  return async (request) => {
1397
+ const consented = await answerVerifyChallengeRequest(request);
1398
+ if (consented !== null) return consented;
1248
1399
  const asked = readQuery(new URL(request.url));
1249
1400
  if (asked === null) return Response.json({ settled: false }, { status: 400 });
1250
1401
  const subject = subjectOf(asked.reference, asked.amountMinor, asked.currency);
@@ -1254,11 +1405,11 @@ function bankVerifyEndpoint(config) {
1254
1405
  }
1255
1406
  const since = unixNow() - (config.lookBackSecs ?? DEFAULT_LOOK_BACK_SECS);
1256
1407
  const landed = (await config.statement(since)).some((credit) => pays(credit, asked));
1257
- if (!landed) return Response.json({ settled: false });
1258
- return Response.json({
1259
- settled: true,
1260
- preimage: await hmacHex(config.secret, `preimage|${subject}`)
1261
- });
1408
+ if (!landed) return Response.json({ settled: false }, { headers: paced });
1409
+ return Response.json(
1410
+ { settled: true, preimage: await hmacHex(config.secret, `preimage|${subject}`) },
1411
+ { headers: paced }
1412
+ );
1262
1413
  };
1263
1414
  }
1264
1415
  function readQuery(url) {
@@ -1715,138 +1866,6 @@ function refusals2(answers) {
1715
1866
  (answer) => answer.reason instanceof Error ? answer.reason.message : String(answer.reason)
1716
1867
  ).join("; ");
1717
1868
  }
1718
-
1719
- // ../core/ed25519.ts
1720
- var ALGORITHM = { name: "Ed25519" };
1721
- var SIGNATURE_BYTES = 64;
1722
- var PUBLIC_KEY_BYTES = 32;
1723
- var PKCS8_HEADER = hexToBytes("302e020100300506032b657004220420");
1724
- async function verifyHex(publicKeyHex, signatureHex, payload) {
1725
- if (!isHex(publicKeyHex) || publicKeyHex.length !== PUBLIC_KEY_BYTES * 2) return false;
1726
- if (!isHex(signatureHex) || signatureHex.length !== SIGNATURE_BYTES * 2) return false;
1727
- try {
1728
- const key = await crypto.subtle.importKey(
1729
- "raw",
1730
- asBuffer2(hexToBytes(publicKeyHex)),
1731
- ALGORITHM,
1732
- false,
1733
- ["verify"]
1734
- );
1735
- return await crypto.subtle.verify(
1736
- ALGORITHM,
1737
- key,
1738
- asBuffer2(hexToBytes(signatureHex)),
1739
- asBuffer2(payload)
1740
- );
1741
- } catch {
1742
- return false;
1743
- }
1744
- }
1745
- function asBuffer2(bytes) {
1746
- return bytes.buffer instanceof ArrayBuffer ? bytes : new Uint8Array(bytes);
1747
- }
1748
-
1749
- // src/webhook.ts
1750
- var SIGNATURE_HEADER = "x-signature";
1751
- var TIMESTAMP_HEADER = "x-timestamp";
1752
- var SHARED_SECRET_PREFIX = "sha256=";
1753
- var GATEWAY_KEY_PREFIX = "ed25519=";
1754
- var DEFAULT_TOLERANCE_SECS = 300;
1755
- var CHALLENGE = "webhook-challenge";
1756
- async function verifyWebhookSignature(body, signature, credential, timestamp, options = {}) {
1757
- if (!recent(timestamp, options.toleranceSecs ?? DEFAULT_TOLERANCE_SECS)) return false;
1758
- if (typeof credential !== "string") {
1759
- if (!signature.startsWith(GATEWAY_KEY_PREFIX)) return false;
1760
- return verifyHex(
1761
- credential.publicKey.toLowerCase(),
1762
- signature.slice(GATEWAY_KEY_PREFIX.length).toLowerCase(),
1763
- signed(timestamp, body)
1764
- );
1765
- }
1766
- if (signature.startsWith(GATEWAY_KEY_PREFIX)) return false;
1767
- const received = signature.startsWith(SHARED_SECRET_PREFIX) ? signature.slice(SHARED_SECRET_PREFIX.length) : signature;
1768
- return equalInConstantTime(await sign(credential, timestamp, body), received.toLowerCase());
1769
- }
1770
- async function parseWebhook(body, signature, credential, timestamp, options = {}) {
1771
- if (!await verifyWebhookSignature(body, signature, credential, timestamp, options)) return null;
1772
- const text2 = typeof body === "string" ? body : new TextDecoder().decode(body);
1773
- try {
1774
- return paymentFromWire(JSON.parse(text2));
1775
- } catch {
1776
- return null;
1777
- }
1778
- }
1779
- async function parseWebhookRequest(request, credential, options = {}) {
1780
- const signature = request.headers.get(SIGNATURE_HEADER);
1781
- const timestamp = request.headers.get(TIMESTAMP_HEADER);
1782
- if (signature === null || timestamp === null) return null;
1783
- return parseWebhook(await request.text(), signature, credential, timestamp, options);
1784
- }
1785
- async function parseWatchedWebhook(body, signature, credential, timestamp, options = {}) {
1786
- if (!await verifyWebhookSignature(body, signature, credential, timestamp, options)) return null;
1787
- const text2 = typeof body === "string" ? body : new TextDecoder().decode(body);
1788
- try {
1789
- return triggerEventFromWire(JSON.parse(text2));
1790
- } catch {
1791
- return null;
1792
- }
1793
- }
1794
- async function parseWatchedWebhookRequest(request, credential, options = {}) {
1795
- const signature = request.headers.get(SIGNATURE_HEADER);
1796
- const timestamp = request.headers.get(TIMESTAMP_HEADER);
1797
- if (signature === null || timestamp === null) return null;
1798
- return parseWatchedWebhook(await request.text(), signature, credential, timestamp, options);
1799
- }
1800
- async function answerWebhookChallenge(body, signature, credential, timestamp, options = {}) {
1801
- if (!await verifyWebhookSignature(body, signature, credential, timestamp, options)) return null;
1802
- const text2 = typeof body === "string" ? body : new TextDecoder().decode(body);
1803
- const nonce = challenged(text2);
1804
- if (nonce === null) return null;
1805
- if (typeof credential !== "string") return JSON.stringify({ nonce });
1806
- return JSON.stringify({
1807
- nonce,
1808
- signature: `${SHARED_SECRET_PREFIX}${await hmacHex(credential, nonce)}`
1809
- });
1810
- }
1811
- async function answerWebhookChallengeRequest(request, credential, options = {}) {
1812
- const signature = request.headers.get(SIGNATURE_HEADER);
1813
- const timestamp = request.headers.get(TIMESTAMP_HEADER);
1814
- if (signature === null || timestamp === null) return null;
1815
- const answer = await answerWebhookChallenge(
1816
- await request.clone().text(),
1817
- signature,
1818
- credential,
1819
- timestamp,
1820
- options
1821
- );
1822
- return answer === null ? null : new Response(answer, { headers: { "content-type": "application/json" } });
1823
- }
1824
- function challenged(text2) {
1825
- try {
1826
- const said = JSON.parse(text2);
1827
- if (said["type"] !== CHALLENGE || typeof said["nonce"] !== "string") return null;
1828
- return said["nonce"];
1829
- } catch {
1830
- return null;
1831
- }
1832
- }
1833
- function recent(timestamp, toleranceSecs) {
1834
- const sent = Number(timestamp);
1835
- if (!Number.isFinite(sent)) return false;
1836
- return Math.abs(Math.floor(Date.now() / 1e3) - sent) <= toleranceSecs;
1837
- }
1838
- async function sign(secret, timestamp, body) {
1839
- return hmacHex(secret, signed(timestamp, body));
1840
- }
1841
- function signed(timestamp, body) {
1842
- const encoder = new TextEncoder();
1843
- const stamp = encoder.encode(`${timestamp}.`);
1844
- const rest = typeof body === "string" ? encoder.encode(body) : new Uint8Array(body);
1845
- const payload = new Uint8Array(stamp.length + rest.length);
1846
- payload.set(stamp);
1847
- payload.set(rest, stamp.length);
1848
- return payload;
1849
- }
1850
1869
  export {
1851
1870
  GatewayCheatError,
1852
1871
  IDEMPOTENCY_KEY_REUSED,
@@ -1858,6 +1877,8 @@ export {
1858
1877
  REQUEST_IN_FLIGHT,
1859
1878
  ThunderBridge,
1860
1879
  UnverifiedRecipientError,
1880
+ answerVerifyChallenge,
1881
+ answerVerifyChallengeRequest,
1861
1882
  answerWebhookChallenge,
1862
1883
  answerWebhookChallengeRequest,
1863
1884
  bankRail,
@@ -362,6 +362,16 @@ interface BlindLightningRailConfig {
362
362
  sealed?: (order: Order) => string | Promise<string>;
363
363
  webhookUrl?: string;
364
364
  webhookSecret?: string;
365
+ /**
366
+ * Where your own `lightningVerifyEndpoint` is mounted, and the secret it
367
+ * unseals with. Set both and the gateway is handed your URL rather than the
368
+ * wallet's, so it polls you, never a third party, and learns nothing about
369
+ * which provider the recipient uses. Leave them out and it polls the wallet
370
+ */
371
+ relayVerifyThrough?: {
372
+ endpoint: string;
373
+ secret: string;
374
+ };
365
375
  /** What `Leg.rail` reads, for a shop running more than one wallet */
366
376
  name?: string;
367
377
  }
@@ -362,6 +362,16 @@ interface BlindLightningRailConfig {
362
362
  sealed?: (order: Order) => string | Promise<string>;
363
363
  webhookUrl?: string;
364
364
  webhookSecret?: string;
365
+ /**
366
+ * Where your own `lightningVerifyEndpoint` is mounted, and the secret it
367
+ * unseals with. Set both and the gateway is handed your URL rather than the
368
+ * wallet's, so it polls you, never a third party, and learns nothing about
369
+ * which provider the recipient uses. Leave them out and it polls the wallet
370
+ */
371
+ relayVerifyThrough?: {
372
+ endpoint: string;
373
+ secret: string;
374
+ };
365
375
  /** What `Leg.rail` reads, for a shop running more than one wallet */
366
376
  name?: string;
367
377
  }
package/dist/server.cjs CHANGED
@@ -31,7 +31,9 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
31
31
  var server_exports = {};
32
32
  __export(server_exports, {
33
33
  blindLightningRail: () => blindLightningRail,
34
- lnurlPayEndpoint: () => lnurlPayEndpoint
34
+ lightningVerifyEndpoint: () => lightningVerifyEndpoint,
35
+ lnurlPayEndpoint: () => lnurlPayEndpoint,
36
+ relayedVerifyUrl: () => relayedVerifyUrl
35
37
  });
36
38
  module.exports = __toCommonJS(server_exports);
37
39
 
@@ -41,6 +43,16 @@ function bytesToHex(bytes) {
41
43
  for (const byte of bytes) hex += byte.toString(16).padStart(2, "0");
42
44
  return hex;
43
45
  }
46
+ function hexToBytes(hex) {
47
+ const bytes = new Uint8Array(hex.length >> 1);
48
+ for (let at = 0; at < bytes.length; at++) {
49
+ bytes[at] = parseInt(hex.slice(at * 2, at * 2 + 2), 16);
50
+ }
51
+ return bytes;
52
+ }
53
+ function isHex(text2) {
54
+ return /^[0-9a-f]+$/i.test(text2) && text2.length % 2 === 0;
55
+ }
44
56
 
45
57
  // ../core/hmac.ts
46
58
  async function hmacHex(secret, payload) {
@@ -248,6 +260,10 @@ function decodeInvoice(bolt11) {
248
260
  expiresAt: expiryOf(parts.words, tagged)
249
261
  };
250
262
  }
263
+ function preimageMatchesHash(preimage, paymentHash) {
264
+ if (!isHex(preimage)) return false;
265
+ return bytesToHex(sha256(hexToBytes(preimage))) === paymentHash.toLowerCase();
266
+ }
251
267
  function splitBech32(bolt11) {
252
268
  const lower = bolt11.toLowerCase();
253
269
  if (isBolt12Encoding(lower)) return null;
@@ -428,19 +444,19 @@ async function refuseUnlessPublic(url) {
428
444
  if (!await resolvesNothingPrivate(url)) throw new Error(`${url} resolves to an address we do not reach`);
429
445
  }
430
446
  async function answerOf(response) {
431
- const { status, ok } = response;
447
+ const { status, ok, headers } = response;
432
448
  const reader = response.body?.getReader();
433
- if (!reader) return { status, ok, body: "", truncated: false };
449
+ if (!reader) return { status, ok, body: "", truncated: false, headers };
434
450
  const read = [];
435
451
  let bytes = 0;
436
452
  while (bytes <= BODY_LIMIT_BYTES) {
437
453
  const { done, value } = await reader.read();
438
- if (done) return { status, ok, body: text(read), truncated: false };
454
+ if (done) return { status, ok, body: text(read), truncated: false, headers };
439
455
  read.push(value);
440
456
  bytes += value.length;
441
457
  }
442
458
  await reader.cancel();
443
- return { status, ok, body: text(read), truncated: true };
459
+ return { status, ok, body: text(read), truncated: true, headers };
444
460
  }
445
461
  function withoutBody(sent) {
446
462
  const headers = { ...sent.headers };
@@ -478,6 +494,11 @@ var NoWalletAvailable = class extends Error {
478
494
  // ../core/lnurl.ts
479
495
  var VERIFY_WITHOUT_PREIMAGE = ["zeuspay.com", "zeusnuts.com", "ecash.love"];
480
496
  var RESOLVE_TIMEOUT_MS = 3e4;
497
+ var NAMES_ONE_ACCOUNT = /^[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)*$/;
498
+ var MIN_PACE_SECS = 1;
499
+ var MAX_PACE_SECS = 3600;
500
+ var MIN_PER_SECOND = 0.01;
501
+ var MAX_PER_SECOND = 100;
481
502
  async function resolve(addresses, amountMsat) {
482
503
  const served = await firstThatServes(
483
504
  addresses,
@@ -565,6 +586,31 @@ function decodeIssued(address, bolt11) {
565
586
  }
566
587
  return { paymentHash, descriptionHash, amountMsat, expiresAt };
567
588
  }
589
+ async function checkSettled(verifyUrl, paymentHash) {
590
+ const answer = await answeredJson(verifyUrl);
591
+ const asked = {
592
+ pace: paceAskedFor(answer.headers),
593
+ ceiling: ceilingAskedFor(answer.headers)
594
+ };
595
+ if (!answer.said.settled || !answer.said.preimage) return { preimage: null, ...asked };
596
+ if (!preimageMatchesHash(answer.said.preimage, paymentHash)) {
597
+ throw new Error(`verify returned a preimage that does not hash to ${paymentHash}`);
598
+ }
599
+ return { preimage: answer.said.preimage, ...asked };
600
+ }
601
+ function paceAskedFor(headers) {
602
+ const asked = /max-age\s*=\s*(\d+)/i.exec(headers.get("cache-control") ?? "");
603
+ if (!asked) return null;
604
+ return Math.min(Math.max(Number(asked[1]), MIN_PACE_SECS), MAX_PACE_SECS);
605
+ }
606
+ function ceilingAskedFor(headers) {
607
+ const asked = /^\s*(\d+)\s*(?:;\s*w\s*=\s*(\d+))?/i.exec(headers.get("ratelimit-limit") ?? "");
608
+ if (!asked) return null;
609
+ const perWindow = Number(asked[1]);
610
+ const window = asked[2] === void 0 ? 1 : Number(asked[2]);
611
+ if (perWindow === 0 || window === 0) return null;
612
+ return Math.min(Math.max(perWindow / window, MIN_PER_SECOND), MAX_PER_SECOND);
613
+ }
568
614
  function cannotReleaseAPreimage(host) {
569
615
  const lowered = host.toLowerCase();
570
616
  return VERIFY_WITHOUT_PREIMAGE.some(
@@ -577,18 +623,29 @@ function withAmount(callback, amountMsat) {
577
623
  }
578
624
  function splitAddress(address) {
579
625
  const [user, domain, ...rest] = address.split("@");
580
- if (!user || !domain || rest.length > 0 || !publicHttps(`https://${domain}/`)) {
626
+ if (!user || !domain || rest.length > 0 || !NAMES_ONE_ACCOUNT.test(user) || !isNothingButAHost(domain) || !publicHttps(`https://${domain}/`)) {
581
627
  throw new WalletRefused("address-unusable", `${address} is not a usable lightning address`);
582
628
  }
583
629
  return [user, domain];
584
630
  }
631
+ function isNothingButAHost(domain) {
632
+ try {
633
+ const parsed = new URL(`https://${domain}/`);
634
+ return parsed.host === domain.toLowerCase() && parsed.pathname === "/" && parsed.search === "" && parsed.hash === "";
635
+ } catch {
636
+ return false;
637
+ }
638
+ }
585
639
  async function fetchJson(url, deadline) {
640
+ return (await answeredJson(url, deadline)).said;
641
+ }
642
+ async function answeredJson(url, deadline) {
586
643
  const answer = await ask(url, { headers: { accept: "application/json" }, deadline });
587
644
  if (!answer.ok) throw new Error(`${url} answered ${answer.status}`);
588
645
  if (answer.truncated) {
589
646
  throw new Error(`${url} answered with more than the ${BODY_LIMIT_BYTES} bytes we read`);
590
647
  }
591
- return JSON.parse(answer.body);
648
+ return { said: JSON.parse(answer.body), headers: answer.headers };
592
649
  }
593
650
 
594
651
  // ../core/sealed.ts
@@ -610,6 +667,22 @@ async function seal(secret, plaintext) {
610
667
  joined.set(cipher, iv.length);
611
668
  return `${VERSION}.${toBase64Url(joined)}`;
612
669
  }
670
+ async function unseal(secret, sealed) {
671
+ const key = await keyFor(secret);
672
+ if (!sealed.startsWith(`${VERSION}.`)) return null;
673
+ const joined = fromBase64Url(sealed.slice(VERSION.length + 1));
674
+ if (joined === null || joined.length <= IV_BYTES) return null;
675
+ try {
676
+ const body = await crypto.subtle.decrypt(
677
+ { name: "AES-GCM", iv: joined.slice(0, IV_BYTES) },
678
+ key,
679
+ joined.slice(IV_BYTES)
680
+ );
681
+ return new TextDecoder().decode(body);
682
+ } catch {
683
+ return null;
684
+ }
685
+ }
613
686
  async function keyFor(secret) {
614
687
  if (secret.length < MIN_SECRET_CHARS) {
615
688
  throw new Error(`the sealing secret needs ${MIN_SECRET_CHARS} characters of randomness`);
@@ -634,6 +707,17 @@ function toBase64Url(bytes) {
634
707
  for (const byte of bytes) binary += String.fromCharCode(byte);
635
708
  return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
636
709
  }
710
+ function fromBase64Url(text2) {
711
+ if (text2.length === 0 || !/^[A-Za-z0-9_-]+$/.test(text2)) return null;
712
+ try {
713
+ const binary = atob(text2.replace(/-/g, "+").replace(/_/g, "/"));
714
+ const bytes = new Uint8Array(binary.length);
715
+ for (let at = 0; at < bytes.length; at++) bytes[at] = binary.charCodeAt(at);
716
+ return bytes;
717
+ } catch {
718
+ return null;
719
+ }
720
+ }
637
721
 
638
722
  // src/errors.ts
639
723
  var ProblemError = class extends Error {
@@ -756,6 +840,30 @@ function refuse(reason) {
756
840
  return Response.json({ status: "ERROR", reason });
757
841
  }
758
842
 
843
+ // ../core/ed25519.ts
844
+ var PKCS8_HEADER = hexToBytes("302e020100300506032b657004220420");
845
+
846
+ // src/webhook.ts
847
+ var VERIFY_CHALLENGE = "verify-challenge";
848
+ function nonceOf(text2, type) {
849
+ try {
850
+ const said = JSON.parse(text2);
851
+ if (said["type"] !== type || typeof said["nonce"] !== "string") return null;
852
+ return said["nonce"];
853
+ } catch {
854
+ return null;
855
+ }
856
+ }
857
+ function answerVerifyChallenge(body) {
858
+ const nonce = nonceOf(body, VERIFY_CHALLENGE);
859
+ return nonce === null ? null : JSON.stringify({ nonce });
860
+ }
861
+ async function answerVerifyChallengeRequest(request) {
862
+ if (request.method !== "POST") return null;
863
+ const answer = answerVerifyChallenge(await request.clone().text());
864
+ return answer === null ? null : new Response(answer, { headers: { "content-type": "application/json" } });
865
+ }
866
+
759
867
  // src/qr.ts
760
868
  var import_uqr = require("uqr");
761
869
  function encodeForQr(destination) {
@@ -767,14 +875,48 @@ function isLnAddress(destination) {
767
875
  return at > 0 && destination.slice(at + 1).includes(".");
768
876
  }
769
877
 
878
+ // src/relay.ts
879
+ var DEFAULT_POLL_EVERY_SECS = 5;
880
+ var WALLET = "w";
881
+ function lightningVerifyEndpoint(config) {
882
+ const paced = {
883
+ "cache-control": `max-age=${config.pollEverySecs ?? DEFAULT_POLL_EVERY_SECS}`
884
+ };
885
+ return async (request) => {
886
+ const consented = await answerVerifyChallengeRequest(request);
887
+ if (consented !== null) return consented;
888
+ const sealed = new URL(request.url).searchParams.get(WALLET);
889
+ if (sealed === null) return Response.json({ settled: false }, { status: 400 });
890
+ const opened = await unseal(config.secret, sealed);
891
+ if (opened === null) return Response.json({ settled: false }, { status: 403 });
892
+ const wallet = JSON.parse(opened);
893
+ const asked = await checkSettled(wallet.url, wallet.hash).catch(() => null);
894
+ if (asked === null) return Response.json({ settled: false }, { status: 502 });
895
+ return Response.json(
896
+ { settled: asked.preimage !== null, preimage: asked.preimage },
897
+ { headers: paced }
898
+ );
899
+ };
900
+ }
901
+ async function relayedVerifyUrl(endpoint, wallet, secret) {
902
+ const relayed = new URL(endpoint);
903
+ relayed.searchParams.set(WALLET, await seal(secret, JSON.stringify(wallet)));
904
+ return relayed.toString();
905
+ }
906
+
770
907
  // src/rail.ts
771
908
  var LIGHTNING = "lightning";
772
909
  function blindLightningRail(config) {
773
910
  return async (order) => {
774
911
  const resolved = await invoiceFrom(config.lnAddresses, await config.amountMsat(order));
912
+ const relay = config.relayVerifyThrough;
775
913
  const watched = await config.gateway.watchPayment({
776
914
  paymentHash: resolved.paymentHash,
777
- verifyUrl: resolved.verifyUrl,
915
+ verifyUrl: relay ? await relayedVerifyUrl(
916
+ relay.endpoint,
917
+ { url: resolved.verifyUrl, hash: resolved.paymentHash },
918
+ relay.secret
919
+ ) : resolved.verifyUrl,
778
920
  expiresAt: resolved.expiresAt,
779
921
  trigger: config.trigger,
780
922
  sealed: await config.sealed?.(order),
@@ -803,5 +945,7 @@ async function invoiceFrom(lnAddresses, amountMsat) {
803
945
  // Annotate the CommonJS export names for ESM import in node:
804
946
  0 && (module.exports = {
805
947
  blindLightningRail,
806
- lnurlPayEndpoint
948
+ lightningVerifyEndpoint,
949
+ lnurlPayEndpoint,
950
+ relayedVerifyUrl
807
951
  });
package/dist/server.d.cts CHANGED
@@ -1,5 +1,5 @@
1
- import { T as ThunderBridge } from './rail-CdKeyVGn.cjs';
2
- export { m as BlindLightningRailConfig, n as blindLightningRail } from './rail-CdKeyVGn.cjs';
1
+ import { T as ThunderBridge } from './rail-J8QoYbSr.cjs';
2
+ export { m as BlindLightningRailConfig, n as blindLightningRail } from './rail-J8QoYbSr.cjs';
3
3
 
4
4
  interface TriggerConfig {
5
5
  /** The gateway that quotes the addresses and mints the invoice */
@@ -61,4 +61,43 @@ interface Minted {
61
61
  */
62
62
  declare function lnurlPayEndpoint(config: TriggerConfig): (request: Request) => Promise<Response>;
63
63
 
64
- export { type Minted, type TriggerConfig, lnurlPayEndpoint };
64
+ /** The wallet's own LUD-21 URL and the hash its preimage has to match */
65
+ interface Relayed {
66
+ url: string;
67
+ hash: string;
68
+ }
69
+ interface LightningVerifyConfig {
70
+ /** The secret the sealed wallet URL was made with, and nothing else uses it */
71
+ secret: string;
72
+ /**
73
+ * How often you want the gateway to ask, in seconds. It goes out as
74
+ * `Cache-Control: max-age`, so the pace is yours rather than the operator's.
75
+ * Five by default, which is what a Lightning checkout wants
76
+ */
77
+ pollEverySecs?: number;
78
+ }
79
+ /**
80
+ * A verify endpoint of your own that asks the recipient's wallet for you, so the
81
+ * gateway polls you and never the wallet.
82
+ *
83
+ * `relayedVerifyUrl` seals the wallet's own LUD-21 URL into the query with your
84
+ * secret, so what the gateway stores and replicates is opaque: not the wallet's
85
+ * host, not which provider the recipient uses, nothing but a blob it cannot read.
86
+ * This handler unseals it, asks the wallet, and answers the same LUD-21 shape.
87
+ *
88
+ * It relays rather than decides. The preimage still comes from the recipient's
89
+ * own server and still has to hash to the payment hash, so standing between the
90
+ * two buys privacy and pacing without becoming something anyone has to trust.
91
+ *
92
+ * A wallet it cannot reach answers `502` rather than "not settled", because those
93
+ * are different claims and only one of them is true. The gateway logs a failed
94
+ * poll and asks again, which is what a broken relay should look like.
95
+ */
96
+ declare function lightningVerifyEndpoint(config: LightningVerifyConfig): (request: Request) => Promise<Response>;
97
+ /**
98
+ * The URL to hand the gateway instead of the wallet's own, with the wallet's
99
+ * sealed inside it. Point it at wherever `lightningVerifyEndpoint` is mounted
100
+ */
101
+ declare function relayedVerifyUrl(endpoint: string, wallet: Relayed, secret: string): Promise<string>;
102
+
103
+ export { type LightningVerifyConfig, type Minted, type Relayed, type TriggerConfig, lightningVerifyEndpoint, lnurlPayEndpoint, relayedVerifyUrl };