thunder-bridge 0.8.4 → 0.8.8

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
@@ -562,6 +562,7 @@ function triggerEventFromWire(body) {
562
562
  if (sealed !== null && typeof sealed !== "string") return null;
563
563
  return {
564
564
  id,
565
+ kind: kindOf(wire),
565
566
  paymentHash,
566
567
  verifyUrl,
567
568
  status,
@@ -573,6 +574,11 @@ function triggerEventFromWire(body) {
573
574
  amountMsat: wire["incoming_amount"] === void 0 ? null : msatFrom(wire["incoming_amount"], 1)
574
575
  };
575
576
  }
577
+ function kindOf(wire) {
578
+ const said = wire["kind"];
579
+ if (said === "minted" || said === "watched") return said;
580
+ return text(wire["ln_address"]) === null ? "watched" : "minted";
581
+ }
576
582
  function toAmount(msat) {
577
583
  return { value: String(msat), asset_code: ASSET_CODE, asset_scale: ASSET_SCALE };
578
584
  }
@@ -710,6 +716,23 @@ var ThunderBridge = class {
710
716
  return quote;
711
717
  }
712
718
  /** Read a payment back, null when the gateway has never heard of it */
719
+ /**
720
+ * The key this gateway signs webhooks with when you registered none of your own,
721
+ * ready to hand to `parseWebhookRequest` as `{ publicKey }`. Fetch it once and
722
+ * keep it: it is the same for every instance in the cluster
723
+ */
724
+ async webhookKey() {
725
+ const response = await fetch(`${this.baseUrl}/webhook-key`);
726
+ if (!response.ok) throw await problemFrom(response);
727
+ const body = await response.json().catch(() => null);
728
+ if (body?.algorithm !== "ed25519" || typeof body.public_key !== "string") {
729
+ throw new ProblemError({
730
+ status: response.status,
731
+ title: "The gateway published no ed25519 webhook key"
732
+ });
733
+ }
734
+ return body.public_key;
735
+ }
713
736
  async getPayment(id) {
714
737
  const response = await fetch(`${this.baseUrl}/incoming-payments/${encodeURIComponent(id)}`, {
715
738
  headers: this.reading()
@@ -1195,6 +1218,7 @@ function minorScaleOf(currency) {
1195
1218
  // src/bank.ts
1196
1219
  var DEFAULT_CURRENCY = "CZK";
1197
1220
  var DEFAULT_LOOK_BACK_SECS = 7 * 24 * 60 * 60;
1221
+ var DEFAULT_POLL_EVERY_SECS = 30;
1198
1222
  var IBAN = /^[A-Z]{2}[0-9]{2}[0-9A-Z]{8,30}$/;
1199
1223
  var FORBIDDEN_IN_SPD = "*";
1200
1224
  async function bankTransfer(params) {
@@ -1221,6 +1245,9 @@ async function bankTransfer(params) {
1221
1245
  return { id: watched.id, paymentHash: watched.paymentHash, verifyUrl, spd };
1222
1246
  }
1223
1247
  function bankVerifyEndpoint(config) {
1248
+ const paced = {
1249
+ "cache-control": `max-age=${config.pollEverySecs ?? DEFAULT_POLL_EVERY_SECS}`
1250
+ };
1224
1251
  return async (request) => {
1225
1252
  const asked = readQuery(new URL(request.url));
1226
1253
  if (asked === null) return Response.json({ settled: false }, { status: 400 });
@@ -1231,11 +1258,11 @@ function bankVerifyEndpoint(config) {
1231
1258
  }
1232
1259
  const since = unixNow() - (config.lookBackSecs ?? DEFAULT_LOOK_BACK_SECS);
1233
1260
  const landed = (await config.statement(since)).some((credit) => pays(credit, asked));
1234
- if (!landed) return Response.json({ settled: false });
1235
- return Response.json({
1236
- settled: true,
1237
- preimage: await hmacHex(config.secret, `preimage|${subject}`)
1238
- });
1261
+ if (!landed) return Response.json({ settled: false }, { headers: paced });
1262
+ return Response.json(
1263
+ { settled: true, preimage: await hmacHex(config.secret, `preimage|${subject}`) },
1264
+ { headers: paced }
1265
+ );
1239
1266
  };
1240
1267
  }
1241
1268
  function readQuery(url) {
@@ -1693,18 +1720,59 @@ function refusals2(answers) {
1693
1720
  ).join("; ");
1694
1721
  }
1695
1722
 
1723
+ // ../core/ed25519.ts
1724
+ var ALGORITHM = { name: "Ed25519" };
1725
+ var SIGNATURE_BYTES = 64;
1726
+ var PUBLIC_KEY_BYTES = 32;
1727
+ var PKCS8_HEADER = hexToBytes("302e020100300506032b657004220420");
1728
+ async function verifyHex(publicKeyHex, signatureHex, payload) {
1729
+ if (!isHex(publicKeyHex) || publicKeyHex.length !== PUBLIC_KEY_BYTES * 2) return false;
1730
+ if (!isHex(signatureHex) || signatureHex.length !== SIGNATURE_BYTES * 2) return false;
1731
+ try {
1732
+ const key = await crypto.subtle.importKey(
1733
+ "raw",
1734
+ asBuffer2(hexToBytes(publicKeyHex)),
1735
+ ALGORITHM,
1736
+ false,
1737
+ ["verify"]
1738
+ );
1739
+ return await crypto.subtle.verify(
1740
+ ALGORITHM,
1741
+ key,
1742
+ asBuffer2(hexToBytes(signatureHex)),
1743
+ asBuffer2(payload)
1744
+ );
1745
+ } catch {
1746
+ return false;
1747
+ }
1748
+ }
1749
+ function asBuffer2(bytes) {
1750
+ return bytes.buffer instanceof ArrayBuffer ? bytes : new Uint8Array(bytes);
1751
+ }
1752
+
1696
1753
  // src/webhook.ts
1697
1754
  var SIGNATURE_HEADER = "x-signature";
1698
1755
  var TIMESTAMP_HEADER = "x-timestamp";
1699
- var SIGNATURE_PREFIX = "sha256=";
1756
+ var SHARED_SECRET_PREFIX = "sha256=";
1757
+ var GATEWAY_KEY_PREFIX = "ed25519=";
1700
1758
  var DEFAULT_TOLERANCE_SECS = 300;
1701
- async function verifyWebhookSignature(body, signature, secret, timestamp, options = {}) {
1759
+ var CHALLENGE = "webhook-challenge";
1760
+ async function verifyWebhookSignature(body, signature, credential, timestamp, options = {}) {
1702
1761
  if (!recent(timestamp, options.toleranceSecs ?? DEFAULT_TOLERANCE_SECS)) return false;
1703
- const received = signature.startsWith(SIGNATURE_PREFIX) ? signature.slice(SIGNATURE_PREFIX.length) : signature;
1704
- return equalInConstantTime(await sign(secret, timestamp, body), received.toLowerCase());
1762
+ if (typeof credential !== "string") {
1763
+ if (!signature.startsWith(GATEWAY_KEY_PREFIX)) return false;
1764
+ return verifyHex(
1765
+ credential.publicKey.toLowerCase(),
1766
+ signature.slice(GATEWAY_KEY_PREFIX.length).toLowerCase(),
1767
+ signed(timestamp, body)
1768
+ );
1769
+ }
1770
+ if (signature.startsWith(GATEWAY_KEY_PREFIX)) return false;
1771
+ const received = signature.startsWith(SHARED_SECRET_PREFIX) ? signature.slice(SHARED_SECRET_PREFIX.length) : signature;
1772
+ return equalInConstantTime(await sign(credential, timestamp, body), received.toLowerCase());
1705
1773
  }
1706
- async function parseWebhook(body, signature, secret, timestamp, options = {}) {
1707
- if (!await verifyWebhookSignature(body, signature, secret, timestamp, options)) return null;
1774
+ async function parseWebhook(body, signature, credential, timestamp, options = {}) {
1775
+ if (!await verifyWebhookSignature(body, signature, credential, timestamp, options)) return null;
1708
1776
  const text2 = typeof body === "string" ? body : new TextDecoder().decode(body);
1709
1777
  try {
1710
1778
  return paymentFromWire(JSON.parse(text2));
@@ -1712,14 +1780,14 @@ async function parseWebhook(body, signature, secret, timestamp, options = {}) {
1712
1780
  return null;
1713
1781
  }
1714
1782
  }
1715
- async function parseWebhookRequest(request, secret, options = {}) {
1783
+ async function parseWebhookRequest(request, credential, options = {}) {
1716
1784
  const signature = request.headers.get(SIGNATURE_HEADER);
1717
1785
  const timestamp = request.headers.get(TIMESTAMP_HEADER);
1718
1786
  if (signature === null || timestamp === null) return null;
1719
- return parseWebhook(await request.text(), signature, secret, timestamp, options);
1787
+ return parseWebhook(await request.text(), signature, credential, timestamp, options);
1720
1788
  }
1721
- async function parseWatchedWebhook(body, signature, secret, timestamp, options = {}) {
1722
- if (!await verifyWebhookSignature(body, signature, secret, timestamp, options)) return null;
1789
+ async function parseWatchedWebhook(body, signature, credential, timestamp, options = {}) {
1790
+ if (!await verifyWebhookSignature(body, signature, credential, timestamp, options)) return null;
1723
1791
  const text2 = typeof body === "string" ? body : new TextDecoder().decode(body);
1724
1792
  try {
1725
1793
  return triggerEventFromWire(JSON.parse(text2));
@@ -1727,11 +1795,44 @@ async function parseWatchedWebhook(body, signature, secret, timestamp, options =
1727
1795
  return null;
1728
1796
  }
1729
1797
  }
1730
- async function parseWatchedWebhookRequest(request, secret, options = {}) {
1798
+ async function parseWatchedWebhookRequest(request, credential, options = {}) {
1731
1799
  const signature = request.headers.get(SIGNATURE_HEADER);
1732
1800
  const timestamp = request.headers.get(TIMESTAMP_HEADER);
1733
1801
  if (signature === null || timestamp === null) return null;
1734
- return parseWatchedWebhook(await request.text(), signature, secret, timestamp, options);
1802
+ return parseWatchedWebhook(await request.text(), signature, credential, timestamp, options);
1803
+ }
1804
+ async function answerWebhookChallenge(body, signature, credential, timestamp, options = {}) {
1805
+ if (!await verifyWebhookSignature(body, signature, credential, timestamp, options)) return null;
1806
+ const text2 = typeof body === "string" ? body : new TextDecoder().decode(body);
1807
+ const nonce = challenged(text2);
1808
+ if (nonce === null) return null;
1809
+ if (typeof credential !== "string") return JSON.stringify({ nonce });
1810
+ return JSON.stringify({
1811
+ nonce,
1812
+ signature: `${SHARED_SECRET_PREFIX}${await hmacHex(credential, nonce)}`
1813
+ });
1814
+ }
1815
+ async function answerWebhookChallengeRequest(request, credential, options = {}) {
1816
+ const signature = request.headers.get(SIGNATURE_HEADER);
1817
+ const timestamp = request.headers.get(TIMESTAMP_HEADER);
1818
+ if (signature === null || timestamp === null) return null;
1819
+ const answer = await answerWebhookChallenge(
1820
+ await request.clone().text(),
1821
+ signature,
1822
+ credential,
1823
+ timestamp,
1824
+ options
1825
+ );
1826
+ return answer === null ? null : new Response(answer, { headers: { "content-type": "application/json" } });
1827
+ }
1828
+ function challenged(text2) {
1829
+ try {
1830
+ const said = JSON.parse(text2);
1831
+ if (said["type"] !== CHALLENGE || typeof said["nonce"] !== "string") return null;
1832
+ return said["nonce"];
1833
+ } catch {
1834
+ return null;
1835
+ }
1735
1836
  }
1736
1837
  function recent(timestamp, toleranceSecs) {
1737
1838
  const sent = Number(timestamp);
@@ -1761,6 +1862,8 @@ export {
1761
1862
  REQUEST_IN_FLIGHT,
1762
1863
  ThunderBridge,
1763
1864
  UnverifiedRecipientError,
1865
+ answerWebhookChallenge,
1866
+ answerWebhookChallengeRequest,
1764
1867
  bankRail,
1765
1868
  bankTransfer,
1766
1869
  bankVerifyEndpoint,
@@ -1,5 +1,7 @@
1
1
  /** Where a payment stands, `paid` is the only status that carries a preimage */
2
2
  type PaymentStatus = "pending" | "paid" | "expired";
3
+ /** Whether the gateway resolved the address and got the invoice, or was handed one to watch */
4
+ type PaymentKind = "minted" | "watched";
3
5
  /** A payment as the gateway reports it, every field is checkable against the recipient */
4
6
  interface Payment {
5
7
  id: string;
@@ -53,6 +55,7 @@ interface Quote {
53
55
  */
54
56
  interface TriggerEvent {
55
57
  id: string;
58
+ kind: PaymentKind;
56
59
  paymentHash: string;
57
60
  verifyUrl: string;
58
61
  status: PaymentStatus;
@@ -189,6 +192,12 @@ declare class ThunderBridge {
189
192
  */
190
193
  createQuote(params: CreateQuoteParams): Promise<Quote>;
191
194
  /** Read a payment back, null when the gateway has never heard of it */
195
+ /**
196
+ * The key this gateway signs webhooks with when you registered none of your own,
197
+ * ready to hand to `parseWebhookRequest` as `{ publicKey }`. Fetch it once and
198
+ * keep it: it is the same for every instance in the cluster
199
+ */
200
+ webhookKey(): Promise<string>;
192
201
  getPayment(id: string): Promise<Payment | null>;
193
202
  /**
194
203
  * Read back a payment the gateway is only watching, null when it has never
@@ -353,6 +362,16 @@ interface BlindLightningRailConfig {
353
362
  sealed?: (order: Order) => string | Promise<string>;
354
363
  webhookUrl?: string;
355
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
+ };
356
375
  /** What `Leg.rail` reads, for a shop running more than one wallet */
357
376
  name?: string;
358
377
  }
@@ -378,4 +397,4 @@ declare function lightningRail(config: LightningRailConfig): Rail;
378
397
  */
379
398
  declare function blindLightningRail(config: BlindLightningRailConfig): Rail;
380
399
 
381
- export { type BankRailConfig as B, type CreatePaymentParams as C, type FollowOptions as F, type Leg as L, type Order as O, type Payment as P, type Quote as Q, type Rail as R, ThunderBridge as T, type WalletFailure as W, type TriggerEvent as a, type CreateOptions as b, type CreateQuoteParams as c, type LightningRailConfig as d, type PaymentStatus as e, type ThunderBridgeOptions as f, type WaitOptions as g, type WalletReason as h, type WatchPaymentParams as i, bankRail as j, type BlindLightningRailConfig as k, lightningRail as l, blindLightningRail as m };
400
+ export { type BankRailConfig as B, type CreatePaymentParams as C, type FollowOptions as F, type Leg as L, type Order as O, type Payment as P, type Quote as Q, type Rail as R, ThunderBridge as T, type WalletFailure as W, type TriggerEvent as a, type CreateOptions as b, type CreateQuoteParams as c, type LightningRailConfig as d, type PaymentKind as e, type PaymentStatus as f, type ThunderBridgeOptions as g, type WaitOptions as h, type WalletReason as i, type WatchPaymentParams as j, bankRail as k, lightningRail as l, type BlindLightningRailConfig as m, blindLightningRail as n };
@@ -1,5 +1,7 @@
1
1
  /** Where a payment stands, `paid` is the only status that carries a preimage */
2
2
  type PaymentStatus = "pending" | "paid" | "expired";
3
+ /** Whether the gateway resolved the address and got the invoice, or was handed one to watch */
4
+ type PaymentKind = "minted" | "watched";
3
5
  /** A payment as the gateway reports it, every field is checkable against the recipient */
4
6
  interface Payment {
5
7
  id: string;
@@ -53,6 +55,7 @@ interface Quote {
53
55
  */
54
56
  interface TriggerEvent {
55
57
  id: string;
58
+ kind: PaymentKind;
56
59
  paymentHash: string;
57
60
  verifyUrl: string;
58
61
  status: PaymentStatus;
@@ -189,6 +192,12 @@ declare class ThunderBridge {
189
192
  */
190
193
  createQuote(params: CreateQuoteParams): Promise<Quote>;
191
194
  /** Read a payment back, null when the gateway has never heard of it */
195
+ /**
196
+ * The key this gateway signs webhooks with when you registered none of your own,
197
+ * ready to hand to `parseWebhookRequest` as `{ publicKey }`. Fetch it once and
198
+ * keep it: it is the same for every instance in the cluster
199
+ */
200
+ webhookKey(): Promise<string>;
192
201
  getPayment(id: string): Promise<Payment | null>;
193
202
  /**
194
203
  * Read back a payment the gateway is only watching, null when it has never
@@ -353,6 +362,16 @@ interface BlindLightningRailConfig {
353
362
  sealed?: (order: Order) => string | Promise<string>;
354
363
  webhookUrl?: string;
355
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
+ };
356
375
  /** What `Leg.rail` reads, for a shop running more than one wallet */
357
376
  name?: string;
358
377
  }
@@ -378,4 +397,4 @@ declare function lightningRail(config: LightningRailConfig): Rail;
378
397
  */
379
398
  declare function blindLightningRail(config: BlindLightningRailConfig): Rail;
380
399
 
381
- export { type BankRailConfig as B, type CreatePaymentParams as C, type FollowOptions as F, type Leg as L, type Order as O, type Payment as P, type Quote as Q, type Rail as R, ThunderBridge as T, type WalletFailure as W, type TriggerEvent as a, type CreateOptions as b, type CreateQuoteParams as c, type LightningRailConfig as d, type PaymentStatus as e, type ThunderBridgeOptions as f, type WaitOptions as g, type WalletReason as h, type WatchPaymentParams as i, bankRail as j, type BlindLightningRailConfig as k, lightningRail as l, blindLightningRail as m };
400
+ export { type BankRailConfig as B, type CreatePaymentParams as C, type FollowOptions as F, type Leg as L, type Order as O, type Payment as P, type Quote as Q, type Rail as R, ThunderBridge as T, type WalletFailure as W, type TriggerEvent as a, type CreateOptions as b, type CreateQuoteParams as c, type LightningRailConfig as d, type PaymentKind as e, type PaymentStatus as f, type ThunderBridgeOptions as g, type WaitOptions as h, type WalletReason as i, type WatchPaymentParams as j, bankRail as k, lightningRail as l, type BlindLightningRailConfig as m, blindLightningRail as n };
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,10 @@ 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 MIN_PACE_SECS = 1;
498
+ var MAX_PACE_SECS = 3600;
499
+ var MIN_PER_SECOND = 0.01;
500
+ var MAX_PER_SECOND = 100;
481
501
  async function resolve(addresses, amountMsat) {
482
502
  const served = await firstThatServes(
483
503
  addresses,
@@ -565,6 +585,31 @@ function decodeIssued(address, bolt11) {
565
585
  }
566
586
  return { paymentHash, descriptionHash, amountMsat, expiresAt };
567
587
  }
588
+ async function checkSettled(verifyUrl, paymentHash) {
589
+ const answer = await answeredJson(verifyUrl);
590
+ const asked = {
591
+ pace: paceAskedFor(answer.headers),
592
+ ceiling: ceilingAskedFor(answer.headers)
593
+ };
594
+ if (!answer.said.settled || !answer.said.preimage) return { preimage: null, ...asked };
595
+ if (!preimageMatchesHash(answer.said.preimage, paymentHash)) {
596
+ throw new Error(`verify returned a preimage that does not hash to ${paymentHash}`);
597
+ }
598
+ return { preimage: answer.said.preimage, ...asked };
599
+ }
600
+ function paceAskedFor(headers) {
601
+ const asked = /max-age\s*=\s*(\d+)/i.exec(headers.get("cache-control") ?? "");
602
+ if (!asked) return null;
603
+ return Math.min(Math.max(Number(asked[1]), MIN_PACE_SECS), MAX_PACE_SECS);
604
+ }
605
+ function ceilingAskedFor(headers) {
606
+ const asked = /^\s*(\d+)\s*(?:;\s*w\s*=\s*(\d+))?/i.exec(headers.get("ratelimit-limit") ?? "");
607
+ if (!asked) return null;
608
+ const perWindow = Number(asked[1]);
609
+ const window = asked[2] === void 0 ? 1 : Number(asked[2]);
610
+ if (perWindow === 0 || window === 0) return null;
611
+ return Math.min(Math.max(perWindow / window, MIN_PER_SECOND), MAX_PER_SECOND);
612
+ }
568
613
  function cannotReleaseAPreimage(host) {
569
614
  const lowered = host.toLowerCase();
570
615
  return VERIFY_WITHOUT_PREIMAGE.some(
@@ -583,12 +628,15 @@ function splitAddress(address) {
583
628
  return [user, domain];
584
629
  }
585
630
  async function fetchJson(url, deadline) {
631
+ return (await answeredJson(url, deadline)).said;
632
+ }
633
+ async function answeredJson(url, deadline) {
586
634
  const answer = await ask(url, { headers: { accept: "application/json" }, deadline });
587
635
  if (!answer.ok) throw new Error(`${url} answered ${answer.status}`);
588
636
  if (answer.truncated) {
589
637
  throw new Error(`${url} answered with more than the ${BODY_LIMIT_BYTES} bytes we read`);
590
638
  }
591
- return JSON.parse(answer.body);
639
+ return { said: JSON.parse(answer.body), headers: answer.headers };
592
640
  }
593
641
 
594
642
  // ../core/sealed.ts
@@ -610,6 +658,22 @@ async function seal(secret, plaintext) {
610
658
  joined.set(cipher, iv.length);
611
659
  return `${VERSION}.${toBase64Url(joined)}`;
612
660
  }
661
+ async function unseal(secret, sealed) {
662
+ const key = await keyFor(secret);
663
+ if (!sealed.startsWith(`${VERSION}.`)) return null;
664
+ const joined = fromBase64Url(sealed.slice(VERSION.length + 1));
665
+ if (joined === null || joined.length <= IV_BYTES) return null;
666
+ try {
667
+ const body = await crypto.subtle.decrypt(
668
+ { name: "AES-GCM", iv: joined.slice(0, IV_BYTES) },
669
+ key,
670
+ joined.slice(IV_BYTES)
671
+ );
672
+ return new TextDecoder().decode(body);
673
+ } catch {
674
+ return null;
675
+ }
676
+ }
613
677
  async function keyFor(secret) {
614
678
  if (secret.length < MIN_SECRET_CHARS) {
615
679
  throw new Error(`the sealing secret needs ${MIN_SECRET_CHARS} characters of randomness`);
@@ -634,6 +698,17 @@ function toBase64Url(bytes) {
634
698
  for (const byte of bytes) binary += String.fromCharCode(byte);
635
699
  return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
636
700
  }
701
+ function fromBase64Url(text2) {
702
+ if (text2.length === 0 || !/^[A-Za-z0-9_-]+$/.test(text2)) return null;
703
+ try {
704
+ const binary = atob(text2.replace(/-/g, "+").replace(/_/g, "/"));
705
+ const bytes = new Uint8Array(binary.length);
706
+ for (let at = 0; at < bytes.length; at++) bytes[at] = binary.charCodeAt(at);
707
+ return bytes;
708
+ } catch {
709
+ return null;
710
+ }
711
+ }
637
712
 
638
713
  // src/errors.ts
639
714
  var ProblemError = class extends Error {
@@ -767,14 +842,46 @@ function isLnAddress(destination) {
767
842
  return at > 0 && destination.slice(at + 1).includes(".");
768
843
  }
769
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 sealed = new URL(request.url).searchParams.get(WALLET);
854
+ if (sealed === null) return Response.json({ settled: false }, { status: 400 });
855
+ const opened = await unseal(config.secret, sealed);
856
+ if (opened === null) return Response.json({ settled: false }, { status: 403 });
857
+ const wallet = JSON.parse(opened);
858
+ const asked = await checkSettled(wallet.url, wallet.hash).catch(() => null);
859
+ if (asked === null) return Response.json({ settled: false }, { status: 502 });
860
+ return Response.json(
861
+ { settled: asked.preimage !== null, preimage: asked.preimage },
862
+ { headers: paced }
863
+ );
864
+ };
865
+ }
866
+ async function relayedVerifyUrl(endpoint, wallet, secret) {
867
+ const relayed = new URL(endpoint);
868
+ relayed.searchParams.set(WALLET, await seal(secret, JSON.stringify(wallet)));
869
+ return relayed.toString();
870
+ }
871
+
770
872
  // src/rail.ts
771
873
  var LIGHTNING = "lightning";
772
874
  function blindLightningRail(config) {
773
875
  return async (order) => {
774
876
  const resolved = await invoiceFrom(config.lnAddresses, await config.amountMsat(order));
877
+ const relay = config.relayVerifyThrough;
775
878
  const watched = await config.gateway.watchPayment({
776
879
  paymentHash: resolved.paymentHash,
777
- verifyUrl: resolved.verifyUrl,
880
+ verifyUrl: relay ? await relayedVerifyUrl(
881
+ relay.endpoint,
882
+ { url: resolved.verifyUrl, hash: resolved.paymentHash },
883
+ relay.secret
884
+ ) : resolved.verifyUrl,
778
885
  expiresAt: resolved.expiresAt,
779
886
  trigger: config.trigger,
780
887
  sealed: await config.sealed?.(order),
@@ -803,5 +910,7 @@ async function invoiceFrom(lnAddresses, amountMsat) {
803
910
  // Annotate the CommonJS export names for ESM import in node:
804
911
  0 && (module.exports = {
805
912
  blindLightningRail,
806
- lnurlPayEndpoint
913
+ lightningVerifyEndpoint,
914
+ lnurlPayEndpoint,
915
+ relayedVerifyUrl
807
916
  });
package/dist/server.d.cts CHANGED
@@ -1,5 +1,5 @@
1
- import { T as ThunderBridge } from './rail-QZtUN1D-.cjs';
2
- export { k as BlindLightningRailConfig, m as blindLightningRail } from './rail-QZtUN1D-.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 };
package/dist/server.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { T as ThunderBridge } from './rail-QZtUN1D-.js';
2
- export { k as BlindLightningRailConfig, m as blindLightningRail } from './rail-QZtUN1D-.js';
1
+ import { T as ThunderBridge } from './rail-J8QoYbSr.js';
2
+ export { m as BlindLightningRailConfig, n as blindLightningRail } from './rail-J8QoYbSr.js';
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 };