thunder-bridge 0.8.6 → 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/README.md CHANGED
@@ -112,6 +112,8 @@ them and this table does not repeat them.
112
112
  | `bankTransfer(params)` | register a Czech QR platba as a watched payment. Refuses a gateway that serves strangers |
113
113
  | `bankVerifyEndpoint(config)` | the other half, the LUD-21 shape backed by your own statement |
114
114
  | `fioStatement(config)` | a `Statement` reading a Fio account, several tokens used strictly in turn |
115
+ | `lightningVerifyEndpoint(config)` | the same shape for Lightning, asking the wallet on the gateway's behalf. From `thunder-bridge/server` |
116
+ | `relayedVerifyUrl(mount, wallet, secret)` | the URL to hand the gateway instead of the wallet's, with the wallet's sealed inside |
115
117
 
116
118
  What your service answers once those handlers are mounted is written out in
117
119
  [`openapi.yaml`](openapi.yaml), shipped with this package.
@@ -248,6 +250,53 @@ already in the account:
248
250
  Neither has been seen with Fio, which forwards the message untouched. Check it
249
251
  against the banks your payers actually use before you promise them a rail.
250
252
 
253
+ ## Making the gateway poll nobody but you
254
+
255
+ By default a Lightning watch hands the gateway the wallet's own verify URL, so the
256
+ gateway polls `blink.sv` or `coinos.io` directly and its logs, its ledger and its
257
+ peers all carry that domain. If you would rather it never touched a third party and
258
+ never learned which provider your recipient uses, put your own endpoint in between.
259
+
260
+ ```ts
261
+ import { blindLightningRail, lightningVerifyEndpoint } from "thunder-bridge/server";
262
+
263
+ app.get("/verify/lightning", (context) =>
264
+ lightningVerifyEndpoint({ secret: RELAY_SECRET, pollEverySecs: 5 })(context.req.raw),
265
+ );
266
+
267
+ const rail = blindLightningRail({
268
+ gateway,
269
+ lnAddresses: ["you@blink.sv"],
270
+ amountMsat: (order) => order.amountMinor * 40,
271
+ relayVerifyThrough: { endpoint: "https://shop.example/verify/lightning", secret: RELAY_SECRET },
272
+ });
273
+ ```
274
+
275
+ The wallet's URL is sealed into the query with your secret, so what the gateway
276
+ stores and replicates is a blob it cannot read. It polls you, you ask the wallet,
277
+ and the preimage still comes from the recipient's own server and still has to hash
278
+ to the payment hash, so standing in the middle buys privacy and pacing without
279
+ making you something anyone has to trust. A wallet you cannot reach answers `502`
280
+ rather than "not settled", because those are different claims.
281
+
282
+ Both rails then run through endpoints of yours, on a pace you set, and the gateway
283
+ is only ever talking to servers that asked to be talked to. It costs you a service
284
+ that has to stay up: a browser-only integration cannot do this, and should keep
285
+ letting the gateway poll the wallet.
286
+
287
+ ### How often the gateway asks
288
+
289
+ Your endpoint decides, not the gateway. `bankVerifyEndpoint` answers with
290
+ `Cache-Control: max-age=30`, and the gateway uses that as the interval for every
291
+ payment on your host. Set `pollEverySecs` to whatever your bank's own refresh makes
292
+ sensible: reading a statement that moves once an hour every five seconds only burns
293
+ your rate limit.
294
+
295
+ The gateway also asks the URL once, before it accepts the watch, and refuses with
296
+ `424` if it does not answer this shape. So deploy the endpoint first and register
297
+ second. That is what stops anyone pointing a gateway at a server that never asked to
298
+ be polled for three days.
299
+
251
300
  ## What is still trusted
252
301
 
253
302
  - **The gateway chooses which of your addresses gets paid.** Nothing here can
package/dist/index.cjs CHANGED
@@ -1290,6 +1290,7 @@ function minorScaleOf(currency) {
1290
1290
  // src/bank.ts
1291
1291
  var DEFAULT_CURRENCY = "CZK";
1292
1292
  var DEFAULT_LOOK_BACK_SECS = 7 * 24 * 60 * 60;
1293
+ var DEFAULT_POLL_EVERY_SECS = 30;
1293
1294
  var IBAN = /^[A-Z]{2}[0-9]{2}[0-9A-Z]{8,30}$/;
1294
1295
  var FORBIDDEN_IN_SPD = "*";
1295
1296
  async function bankTransfer(params) {
@@ -1316,6 +1317,9 @@ async function bankTransfer(params) {
1316
1317
  return { id: watched.id, paymentHash: watched.paymentHash, verifyUrl, spd };
1317
1318
  }
1318
1319
  function bankVerifyEndpoint(config) {
1320
+ const paced = {
1321
+ "cache-control": `max-age=${config.pollEverySecs ?? DEFAULT_POLL_EVERY_SECS}`
1322
+ };
1319
1323
  return async (request) => {
1320
1324
  const asked = readQuery(new URL(request.url));
1321
1325
  if (asked === null) return Response.json({ settled: false }, { status: 400 });
@@ -1326,11 +1330,11 @@ function bankVerifyEndpoint(config) {
1326
1330
  }
1327
1331
  const since = unixNow() - (config.lookBackSecs ?? DEFAULT_LOOK_BACK_SECS);
1328
1332
  const landed = (await config.statement(since)).some((credit) => pays(credit, asked));
1329
- if (!landed) return Response.json({ settled: false });
1330
- return Response.json({
1331
- settled: true,
1332
- preimage: await hmacHex(config.secret, `preimage|${subject}`)
1333
- });
1333
+ if (!landed) return Response.json({ settled: false }, { headers: paced });
1334
+ return Response.json(
1335
+ { settled: true, preimage: await hmacHex(config.secret, `preimage|${subject}`) },
1336
+ { headers: paced }
1337
+ );
1334
1338
  };
1335
1339
  }
1336
1340
  function readQuery(url) {
package/dist/index.d.cts CHANGED
@@ -1,5 +1,5 @@
1
- import { T as ThunderBridge, P as Payment, C as CreatePaymentParams, a as TriggerEvent, W as WalletFailure } from './rail-CdKeyVGn.cjs';
2
- export { B as BankRailConfig, b as CreateOptions, c as CreateQuoteParams, F as FollowOptions, L as Leg, d as LightningRailConfig, O as Order, e as PaymentKind, f as PaymentStatus, Q as Quote, R as Rail, g as ThunderBridgeOptions, h as WaitOptions, i as WalletReason, j as WatchPaymentParams, k as bankRail, l as lightningRail } from './rail-CdKeyVGn.cjs';
1
+ import { T as ThunderBridge, P as Payment, C as CreatePaymentParams, a as TriggerEvent, W as WalletFailure } from './rail-J8QoYbSr.cjs';
2
+ export { B as BankRailConfig, b as CreateOptions, c as CreateQuoteParams, F as FollowOptions, L as Leg, d as LightningRailConfig, O as Order, e as PaymentKind, f as PaymentStatus, Q as Quote, R as Rail, g as ThunderBridgeOptions, h as WaitOptions, i as WalletReason, j as WatchPaymentParams, k as bankRail, l as lightningRail } from './rail-J8QoYbSr.cjs';
3
3
 
4
4
  /**
5
5
  * Encrypt what the watcher needs and the gateway must not have. The gateway
@@ -98,6 +98,13 @@ interface BankVerifyConfig {
98
98
  statement: Statement;
99
99
  /** How far back a credit still counts, seven days by default */
100
100
  lookBackSecs?: number;
101
+ /**
102
+ * How often you want the gateway to ask, in seconds. It goes out as
103
+ * `Cache-Control: max-age`, so the pace is yours to set rather than the
104
+ * gateway's, and a bank that updates once a minute should say so instead of
105
+ * being polled every few seconds. Thirty by default, clamped to an hour
106
+ */
107
+ pollEverySecs?: number;
101
108
  }
102
109
  /**
103
110
  * Ask for a bank transfer and put it under the gateway's watch, so it settles
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { T as ThunderBridge, P as Payment, C as CreatePaymentParams, a as TriggerEvent, W as WalletFailure } from './rail-CdKeyVGn.js';
2
- export { B as BankRailConfig, b as CreateOptions, c as CreateQuoteParams, F as FollowOptions, L as Leg, d as LightningRailConfig, O as Order, e as PaymentKind, f as PaymentStatus, Q as Quote, R as Rail, g as ThunderBridgeOptions, h as WaitOptions, i as WalletReason, j as WatchPaymentParams, k as bankRail, l as lightningRail } from './rail-CdKeyVGn.js';
1
+ import { T as ThunderBridge, P as Payment, C as CreatePaymentParams, a as TriggerEvent, W as WalletFailure } from './rail-J8QoYbSr.js';
2
+ export { B as BankRailConfig, b as CreateOptions, c as CreateQuoteParams, F as FollowOptions, L as Leg, d as LightningRailConfig, O as Order, e as PaymentKind, f as PaymentStatus, Q as Quote, R as Rail, g as ThunderBridgeOptions, h as WaitOptions, i as WalletReason, j as WatchPaymentParams, k as bankRail, l as lightningRail } from './rail-J8QoYbSr.js';
3
3
 
4
4
  /**
5
5
  * Encrypt what the watcher needs and the gateway must not have. The gateway
@@ -98,6 +98,13 @@ interface BankVerifyConfig {
98
98
  statement: Statement;
99
99
  /** How far back a credit still counts, seven days by default */
100
100
  lookBackSecs?: number;
101
+ /**
102
+ * How often you want the gateway to ask, in seconds. It goes out as
103
+ * `Cache-Control: max-age`, so the pace is yours to set rather than the
104
+ * gateway's, and a bank that updates once a minute should say so instead of
105
+ * being polled every few seconds. Thirty by default, clamped to an hour
106
+ */
107
+ pollEverySecs?: number;
101
108
  }
102
109
  /**
103
110
  * Ask for a bank transfer and put it under the gateway's watch, so it settles
package/dist/index.js CHANGED
@@ -1218,6 +1218,7 @@ function minorScaleOf(currency) {
1218
1218
  // src/bank.ts
1219
1219
  var DEFAULT_CURRENCY = "CZK";
1220
1220
  var DEFAULT_LOOK_BACK_SECS = 7 * 24 * 60 * 60;
1221
+ var DEFAULT_POLL_EVERY_SECS = 30;
1221
1222
  var IBAN = /^[A-Z]{2}[0-9]{2}[0-9A-Z]{8,30}$/;
1222
1223
  var FORBIDDEN_IN_SPD = "*";
1223
1224
  async function bankTransfer(params) {
@@ -1244,6 +1245,9 @@ async function bankTransfer(params) {
1244
1245
  return { id: watched.id, paymentHash: watched.paymentHash, verifyUrl, spd };
1245
1246
  }
1246
1247
  function bankVerifyEndpoint(config) {
1248
+ const paced = {
1249
+ "cache-control": `max-age=${config.pollEverySecs ?? DEFAULT_POLL_EVERY_SECS}`
1250
+ };
1247
1251
  return async (request) => {
1248
1252
  const asked = readQuery(new URL(request.url));
1249
1253
  if (asked === null) return Response.json({ settled: false }, { status: 400 });
@@ -1254,11 +1258,11 @@ function bankVerifyEndpoint(config) {
1254
1258
  }
1255
1259
  const since = unixNow() - (config.lookBackSecs ?? DEFAULT_LOOK_BACK_SECS);
1256
1260
  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
- });
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
+ );
1262
1266
  };
1263
1267
  }
1264
1268
  function readQuery(url) {
@@ -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,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-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 };
package/dist/server.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { T as ThunderBridge } from './rail-CdKeyVGn.js';
2
- export { m as BlindLightningRailConfig, n as blindLightningRail } from './rail-CdKeyVGn.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 };
package/dist/server.js CHANGED
@@ -4,6 +4,16 @@ function bytesToHex(bytes) {
4
4
  for (const byte of bytes) hex += byte.toString(16).padStart(2, "0");
5
5
  return hex;
6
6
  }
7
+ function hexToBytes(hex) {
8
+ const bytes = new Uint8Array(hex.length >> 1);
9
+ for (let at = 0; at < bytes.length; at++) {
10
+ bytes[at] = parseInt(hex.slice(at * 2, at * 2 + 2), 16);
11
+ }
12
+ return bytes;
13
+ }
14
+ function isHex(text2) {
15
+ return /^[0-9a-f]+$/i.test(text2) && text2.length % 2 === 0;
16
+ }
7
17
 
8
18
  // ../core/hmac.ts
9
19
  async function hmacHex(secret, payload) {
@@ -211,6 +221,10 @@ function decodeInvoice(bolt11) {
211
221
  expiresAt: expiryOf(parts.words, tagged)
212
222
  };
213
223
  }
224
+ function preimageMatchesHash(preimage, paymentHash) {
225
+ if (!isHex(preimage)) return false;
226
+ return bytesToHex(sha256(hexToBytes(preimage))) === paymentHash.toLowerCase();
227
+ }
214
228
  function splitBech32(bolt11) {
215
229
  const lower = bolt11.toLowerCase();
216
230
  if (isBolt12Encoding(lower)) return null;
@@ -391,19 +405,19 @@ async function refuseUnlessPublic(url) {
391
405
  if (!await resolvesNothingPrivate(url)) throw new Error(`${url} resolves to an address we do not reach`);
392
406
  }
393
407
  async function answerOf(response) {
394
- const { status, ok } = response;
408
+ const { status, ok, headers } = response;
395
409
  const reader = response.body?.getReader();
396
- if (!reader) return { status, ok, body: "", truncated: false };
410
+ if (!reader) return { status, ok, body: "", truncated: false, headers };
397
411
  const read = [];
398
412
  let bytes = 0;
399
413
  while (bytes <= BODY_LIMIT_BYTES) {
400
414
  const { done, value } = await reader.read();
401
- if (done) return { status, ok, body: text(read), truncated: false };
415
+ if (done) return { status, ok, body: text(read), truncated: false, headers };
402
416
  read.push(value);
403
417
  bytes += value.length;
404
418
  }
405
419
  await reader.cancel();
406
- return { status, ok, body: text(read), truncated: true };
420
+ return { status, ok, body: text(read), truncated: true, headers };
407
421
  }
408
422
  function withoutBody(sent) {
409
423
  const headers = { ...sent.headers };
@@ -441,6 +455,10 @@ var NoWalletAvailable = class extends Error {
441
455
  // ../core/lnurl.ts
442
456
  var VERIFY_WITHOUT_PREIMAGE = ["zeuspay.com", "zeusnuts.com", "ecash.love"];
443
457
  var RESOLVE_TIMEOUT_MS = 3e4;
458
+ var MIN_PACE_SECS = 1;
459
+ var MAX_PACE_SECS = 3600;
460
+ var MIN_PER_SECOND = 0.01;
461
+ var MAX_PER_SECOND = 100;
444
462
  async function resolve(addresses, amountMsat) {
445
463
  const served = await firstThatServes(
446
464
  addresses,
@@ -528,6 +546,31 @@ function decodeIssued(address, bolt11) {
528
546
  }
529
547
  return { paymentHash, descriptionHash, amountMsat, expiresAt };
530
548
  }
549
+ async function checkSettled(verifyUrl, paymentHash) {
550
+ const answer = await answeredJson(verifyUrl);
551
+ const asked = {
552
+ pace: paceAskedFor(answer.headers),
553
+ ceiling: ceilingAskedFor(answer.headers)
554
+ };
555
+ if (!answer.said.settled || !answer.said.preimage) return { preimage: null, ...asked };
556
+ if (!preimageMatchesHash(answer.said.preimage, paymentHash)) {
557
+ throw new Error(`verify returned a preimage that does not hash to ${paymentHash}`);
558
+ }
559
+ return { preimage: answer.said.preimage, ...asked };
560
+ }
561
+ function paceAskedFor(headers) {
562
+ const asked = /max-age\s*=\s*(\d+)/i.exec(headers.get("cache-control") ?? "");
563
+ if (!asked) return null;
564
+ return Math.min(Math.max(Number(asked[1]), MIN_PACE_SECS), MAX_PACE_SECS);
565
+ }
566
+ function ceilingAskedFor(headers) {
567
+ const asked = /^\s*(\d+)\s*(?:;\s*w\s*=\s*(\d+))?/i.exec(headers.get("ratelimit-limit") ?? "");
568
+ if (!asked) return null;
569
+ const perWindow = Number(asked[1]);
570
+ const window = asked[2] === void 0 ? 1 : Number(asked[2]);
571
+ if (perWindow === 0 || window === 0) return null;
572
+ return Math.min(Math.max(perWindow / window, MIN_PER_SECOND), MAX_PER_SECOND);
573
+ }
531
574
  function cannotReleaseAPreimage(host) {
532
575
  const lowered = host.toLowerCase();
533
576
  return VERIFY_WITHOUT_PREIMAGE.some(
@@ -546,12 +589,15 @@ function splitAddress(address) {
546
589
  return [user, domain];
547
590
  }
548
591
  async function fetchJson(url, deadline) {
592
+ return (await answeredJson(url, deadline)).said;
593
+ }
594
+ async function answeredJson(url, deadline) {
549
595
  const answer = await ask(url, { headers: { accept: "application/json" }, deadline });
550
596
  if (!answer.ok) throw new Error(`${url} answered ${answer.status}`);
551
597
  if (answer.truncated) {
552
598
  throw new Error(`${url} answered with more than the ${BODY_LIMIT_BYTES} bytes we read`);
553
599
  }
554
- return JSON.parse(answer.body);
600
+ return { said: JSON.parse(answer.body), headers: answer.headers };
555
601
  }
556
602
 
557
603
  // ../core/sealed.ts
@@ -573,6 +619,22 @@ async function seal(secret, plaintext) {
573
619
  joined.set(cipher, iv.length);
574
620
  return `${VERSION}.${toBase64Url(joined)}`;
575
621
  }
622
+ async function unseal(secret, sealed) {
623
+ const key = await keyFor(secret);
624
+ if (!sealed.startsWith(`${VERSION}.`)) return null;
625
+ const joined = fromBase64Url(sealed.slice(VERSION.length + 1));
626
+ if (joined === null || joined.length <= IV_BYTES) return null;
627
+ try {
628
+ const body = await crypto.subtle.decrypt(
629
+ { name: "AES-GCM", iv: joined.slice(0, IV_BYTES) },
630
+ key,
631
+ joined.slice(IV_BYTES)
632
+ );
633
+ return new TextDecoder().decode(body);
634
+ } catch {
635
+ return null;
636
+ }
637
+ }
576
638
  async function keyFor(secret) {
577
639
  if (secret.length < MIN_SECRET_CHARS) {
578
640
  throw new Error(`the sealing secret needs ${MIN_SECRET_CHARS} characters of randomness`);
@@ -597,6 +659,17 @@ function toBase64Url(bytes) {
597
659
  for (const byte of bytes) binary += String.fromCharCode(byte);
598
660
  return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
599
661
  }
662
+ function fromBase64Url(text2) {
663
+ if (text2.length === 0 || !/^[A-Za-z0-9_-]+$/.test(text2)) return null;
664
+ try {
665
+ const binary = atob(text2.replace(/-/g, "+").replace(/_/g, "/"));
666
+ const bytes = new Uint8Array(binary.length);
667
+ for (let at = 0; at < bytes.length; at++) bytes[at] = binary.charCodeAt(at);
668
+ return bytes;
669
+ } catch {
670
+ return null;
671
+ }
672
+ }
600
673
 
601
674
  // src/errors.ts
602
675
  var ProblemError = class extends Error {
@@ -730,14 +803,46 @@ function isLnAddress(destination) {
730
803
  return at > 0 && destination.slice(at + 1).includes(".");
731
804
  }
732
805
 
806
+ // src/relay.ts
807
+ var DEFAULT_POLL_EVERY_SECS = 5;
808
+ var WALLET = "w";
809
+ function lightningVerifyEndpoint(config) {
810
+ const paced = {
811
+ "cache-control": `max-age=${config.pollEverySecs ?? DEFAULT_POLL_EVERY_SECS}`
812
+ };
813
+ return async (request) => {
814
+ const sealed = new URL(request.url).searchParams.get(WALLET);
815
+ if (sealed === null) return Response.json({ settled: false }, { status: 400 });
816
+ const opened = await unseal(config.secret, sealed);
817
+ if (opened === null) return Response.json({ settled: false }, { status: 403 });
818
+ const wallet = JSON.parse(opened);
819
+ const asked = await checkSettled(wallet.url, wallet.hash).catch(() => null);
820
+ if (asked === null) return Response.json({ settled: false }, { status: 502 });
821
+ return Response.json(
822
+ { settled: asked.preimage !== null, preimage: asked.preimage },
823
+ { headers: paced }
824
+ );
825
+ };
826
+ }
827
+ async function relayedVerifyUrl(endpoint, wallet, secret) {
828
+ const relayed = new URL(endpoint);
829
+ relayed.searchParams.set(WALLET, await seal(secret, JSON.stringify(wallet)));
830
+ return relayed.toString();
831
+ }
832
+
733
833
  // src/rail.ts
734
834
  var LIGHTNING = "lightning";
735
835
  function blindLightningRail(config) {
736
836
  return async (order) => {
737
837
  const resolved = await invoiceFrom(config.lnAddresses, await config.amountMsat(order));
838
+ const relay = config.relayVerifyThrough;
738
839
  const watched = await config.gateway.watchPayment({
739
840
  paymentHash: resolved.paymentHash,
740
- verifyUrl: resolved.verifyUrl,
841
+ verifyUrl: relay ? await relayedVerifyUrl(
842
+ relay.endpoint,
843
+ { url: resolved.verifyUrl, hash: resolved.paymentHash },
844
+ relay.secret
845
+ ) : resolved.verifyUrl,
741
846
  expiresAt: resolved.expiresAt,
742
847
  trigger: config.trigger,
743
848
  sealed: await config.sealed?.(order),
@@ -765,5 +870,7 @@ async function invoiceFrom(lnAddresses, amountMsat) {
765
870
  }
766
871
  export {
767
872
  blindLightningRail,
768
- lnurlPayEndpoint
873
+ lightningVerifyEndpoint,
874
+ lnurlPayEndpoint,
875
+ relayedVerifyUrl
769
876
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thunder-bridge",
3
- "version": "0.8.6",
3
+ "version": "0.8.8",
4
4
  "description": "Trustless JavaScript client for the Thunder Bridge Lightning payment gateway. Proves the invoice came from your own wallet before the payer sees it.",
5
5
  "author": "i-am-fatik",
6
6
  "homepage": "https://agora.gripe/en/tools/thunder-bridge",