thunder-bridge 0.8.3 → 0.8.6

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
@@ -166,7 +166,9 @@ const tipJar = lnurlToSvg("https://agora.gripe/tip");
166
166
  ```
167
167
 
168
168
  **Webhooks** - [`src/webhook.ts`](src/webhook.ts): `parseWebhookRequest`,
169
- `parseWebhook`, `verifyWebhookSignature`. See [Webhooks](#webhooks).
169
+ `parseWebhook`, `parseWatchedWebhookRequest`, `parseWatchedWebhook`,
170
+ `verifyWebhookSignature`, `answerWebhookChallengeRequest`,
171
+ `answerWebhookChallenge`. See [Webhooks](#webhooks).
170
172
 
171
173
  **Errors** - [`src/errors.ts`](src/errors.ts): `ProblemError`,
172
174
  `NoWalletAvailableError`, `GatewayCheatError`, `UnverifiedRecipientError`,
@@ -225,6 +227,27 @@ such as `nas`, the trailing-dot `localhost.`, and anything whose last label is
225
227
 
226
228
  It vets the first hop only. See below.
227
229
 
230
+ ### Which transfer counts as paying
231
+
232
+ `bankVerifyEndpoint` calls a credit a settlement when the amount and the currency
233
+ match exactly and the reference appears anywhere in what the payer wrote,
234
+ case-insensitively. With `fioStatement` "what the payer wrote" is four Fio columns
235
+ joined: the variable symbol, the user identification, the message for the recipient
236
+ and the payer's own reference. So a bank that prefixes, appends, or moves the text
237
+ between those fields still settles.
238
+
239
+ Two shapes do not settle, and both leave the payment `pending` while the money is
240
+ already in the account:
241
+
242
+ - **A shortened reference.** The match asks whether the reference is inside what the
243
+ bank forwarded, not the other way round, so a bank that truncates it never matches.
244
+ - **A payer whose bank forwards nothing but a numeric variable symbol.** The
245
+ reference is alphanumeric and cannot travel in a numeric field, and the match does
246
+ not read `X-VS` as an alternative.
247
+
248
+ Neither has been seen with Fio, which forwards the message untouched. Check it
249
+ against the banks your payers actually use before you promise them a rail.
250
+
228
251
  ## What is still trusted
229
252
 
230
253
  - **The gateway chooses which of your addresses gets paid.** Nothing here can
@@ -315,19 +338,36 @@ try {
315
338
 
316
339
  ## Webhooks
317
340
 
318
- Pass `webhookUrl` and optionally `webhookSecret` when you create a payment. Once
319
- it reaches `paid` the gateway POSTs the same JSON the API returns, so the body is
320
- a `Payment`. Every delivery carries `x-timestamp`, and with a secret set
321
- `x-signature: sha256=<hmac>` over `<timestamp>.<body>` rather than the body alone,
322
- so a captured delivery cannot be replayed at you later. Six attempts on a widening
323
- backoff, then it parks. An invoice that expires fires nothing.
341
+ Pass `webhookUrl` and optionally `webhookSecret` when you create a payment, or on
342
+ any rail. Once it reaches `paid` the gateway POSTs the same JSON the API returns,
343
+ so the body is a `Payment`. Every delivery carries `x-timestamp` and an
344
+ `x-signature` over `<timestamp>.<body>` rather than the body alone, so a captured
345
+ delivery cannot be replayed at you later. With a secret set it is
346
+ `sha256=<hmac>` keyed with that secret, and without one it is `ed25519=<signature>`
347
+ from the gateway's own key, which is the better default and is below. Retries widen
348
+ until the payment itself runs out, never sooner than an hour. An invoice that expires
349
+ fires nothing.
324
350
 
325
351
  Delivery is at-least-once, so deduplicate on `id`.
326
352
 
353
+ Your handler answers one challenge before any of that. The gateway POSTs
354
+ `{"type":"webhook-challenge","nonce":"..."}` to the URL while the create is still open
355
+ and refuses the payment with a 424 unless the nonce comes back, so the endpoint has to
356
+ be deployed before you register it. `answerWebhookChallengeRequest` verifies that
357
+ challenge and hands you the response to return, or `null` when the delivery was a real
358
+ settlement, and it leaves the body unread either way.
359
+
327
360
  ```ts
328
- import { parseWebhookRequest, proveSettlement } from "thunder-bridge";
361
+ import {
362
+ answerWebhookChallengeRequest,
363
+ parseWebhookRequest,
364
+ proveSettlement,
365
+ } from "thunder-bridge";
329
366
 
330
367
  app.post("/hooks/paid", async (context) => {
368
+ const challenge = await answerWebhookChallengeRequest(context.req.raw, secret);
369
+ if (challenge) return challenge;
370
+
331
371
  const payment = await parseWebhookRequest(context.req.raw, secret);
332
372
  if (payment === null) return context.text("bad signature", 401);
333
373
 
@@ -339,6 +379,60 @@ app.post("/hooks/paid", async (context) => {
339
379
  });
340
380
  ```
341
381
 
382
+ ### A watched payment sends a different body
383
+
384
+ `bankRail` and `blindLightningRail` register a payment the gateway was told almost
385
+ nothing about, so its webhook carries no address, no amount and no invoice. That is
386
+ not a `Payment`, and `parseWebhook` answers `null` for it, which looks exactly like a
387
+ bad signature. Use `parseWatchedWebhookRequest` there instead and you get a
388
+ `TriggerEvent`, the shape `getWatched` hands back.
389
+
390
+ ```ts
391
+ import { parseWatchedWebhookRequest } from "thunder-bridge";
392
+
393
+ app.post("/hooks/bank", async (context) => {
394
+ const settled = await parseWatchedWebhookRequest(context.req.raw, secret);
395
+ if (settled === null) return context.text("bad signature", 401);
396
+
397
+ await fulfil(settled.id, settled.preimage);
398
+ return context.text("ok");
399
+ });
400
+ ```
401
+
402
+ Give each rail its own path, as above, and neither endpoint has to guess which body
403
+ it was handed. Both events also carry `kind`, `"minted"` or `"watched"`, so a single
404
+ path serving a trigger that both rails settle on can branch on the field instead of
405
+ on which fields are missing.
406
+
407
+ ### Or hand the gateway no secret at all
408
+
409
+ A secret you give the gateway is kept in its ledger and replicated to its peers,
410
+ because any instance may be the one that delivers. Leave `webhookSecret` out and the
411
+ delivery is signed with the gateway's own key instead, `x-signature:
412
+ ed25519=<signature>` over the same `<timestamp>.<body>`. Fetch the public half once
413
+ and pass it as `{ publicKey }` wherever a secret would go.
414
+
415
+ ```ts
416
+ const publicKey = await gateway.webhookKey();
417
+
418
+ app.post("/hooks/paid", async (context) => {
419
+ const challenge = await answerWebhookChallengeRequest(context.req.raw, { publicKey });
420
+ if (challenge) return challenge;
421
+
422
+ const payment = await parseWebhookRequest(context.req.raw, { publicKey });
423
+ if (payment === null) return context.text("bad signature", 401);
424
+ ...
425
+ });
426
+ ```
427
+
428
+ Answering echoes the nonce and nothing else here, because there is no secret to sign it
429
+ with. Holding the URL the gateway challenged is the whole proof in that case.
430
+
431
+ The key is derived from the gateway's `CLUSTER_KEY`, so every instance in one cluster
432
+ signs alike and an operator rotating that key changes this one too. Neither
433
+ credential is ever accepted for the other's scheme, so a secret cannot check an
434
+ `ed25519=` delivery and a public key cannot check a `sha256=` one.
435
+
342
436
  `parseWebhookRequest` refuses anything more than five minutes out of date,
343
437
  adjustable with `toleranceSecs`. The signature proves the body came from someone
344
438
  holding your secret. It does not prove the payment happened, since the gateway
package/dist/index.cjs CHANGED
@@ -30,6 +30,8 @@ __export(index_exports, {
30
30
  REQUEST_IN_FLIGHT: () => REQUEST_IN_FLIGHT,
31
31
  ThunderBridge: () => ThunderBridge,
32
32
  UnverifiedRecipientError: () => UnverifiedRecipientError,
33
+ answerWebhookChallenge: () => answerWebhookChallenge,
34
+ answerWebhookChallengeRequest: () => answerWebhookChallengeRequest,
33
35
  bankRail: () => bankRail,
34
36
  bankTransfer: () => bankTransfer,
35
37
  bankVerifyEndpoint: () => bankVerifyEndpoint,
@@ -50,6 +52,8 @@ __export(index_exports, {
50
52
  minorScaleOf: () => minorScaleOf,
51
53
  minorUnitsOf: () => minorUnitsOf,
52
54
  msatFor: () => msatFor,
55
+ parseWatchedWebhook: () => parseWatchedWebhook,
56
+ parseWatchedWebhookRequest: () => parseWatchedWebhookRequest,
53
57
  parseWebhook: () => parseWebhook,
54
58
  parseWebhookRequest: () => parseWebhookRequest,
55
59
  preimageMatchesHash: () => preimageMatchesHash,
@@ -609,7 +613,8 @@ function watchRequestBody(params, trigger) {
609
613
  verify_url: params.verifyUrl,
610
614
  expires_at: expiresAt.toISOString(),
611
615
  trigger: trigger ?? void 0,
612
- sealed: params.sealed
616
+ sealed: params.sealed,
617
+ webhook: params.webhookUrl ? { url: params.webhookUrl, secret: params.webhookSecret } : void 0
613
618
  });
614
619
  }
615
620
  function triggerEventFromWire(body) {
@@ -629,6 +634,7 @@ function triggerEventFromWire(body) {
629
634
  if (sealed !== null && typeof sealed !== "string") return null;
630
635
  return {
631
636
  id,
637
+ kind: kindOf(wire),
632
638
  paymentHash,
633
639
  verifyUrl,
634
640
  status,
@@ -640,6 +646,11 @@ function triggerEventFromWire(body) {
640
646
  amountMsat: wire["incoming_amount"] === void 0 ? null : msatFrom(wire["incoming_amount"], 1)
641
647
  };
642
648
  }
649
+ function kindOf(wire) {
650
+ const said = wire["kind"];
651
+ if (said === "minted" || said === "watched") return said;
652
+ return text(wire["ln_address"]) === null ? "watched" : "minted";
653
+ }
643
654
  function toAmount(msat) {
644
655
  return { value: String(msat), asset_code: ASSET_CODE, asset_scale: ASSET_SCALE };
645
656
  }
@@ -777,6 +788,23 @@ var ThunderBridge = class {
777
788
  return quote;
778
789
  }
779
790
  /** Read a payment back, null when the gateway has never heard of it */
791
+ /**
792
+ * The key this gateway signs webhooks with when you registered none of your own,
793
+ * ready to hand to `parseWebhookRequest` as `{ publicKey }`. Fetch it once and
794
+ * keep it: it is the same for every instance in the cluster
795
+ */
796
+ async webhookKey() {
797
+ const response = await fetch(`${this.baseUrl}/webhook-key`);
798
+ if (!response.ok) throw await problemFrom(response);
799
+ const body = await response.json().catch(() => null);
800
+ if (body?.algorithm !== "ed25519" || typeof body.public_key !== "string") {
801
+ throw new ProblemError({
802
+ status: response.status,
803
+ title: "The gateway published no ed25519 webhook key"
804
+ });
805
+ }
806
+ return body.public_key;
807
+ }
780
808
  async getPayment(id) {
781
809
  const response = await fetch(`${this.baseUrl}/incoming-payments/${encodeURIComponent(id)}`, {
782
810
  headers: this.reading()
@@ -1281,7 +1309,9 @@ async function bankTransfer(params) {
1281
1309
  verifyUrl,
1282
1310
  expiresAt: params.expiresAt,
1283
1311
  trigger: params.trigger,
1284
- sealed: params.sealed
1312
+ sealed: params.sealed,
1313
+ webhookUrl: params.webhookUrl,
1314
+ webhookSecret: params.webhookSecret
1285
1315
  });
1286
1316
  return { id: watched.id, paymentHash: watched.paymentHash, verifyUrl, spd };
1287
1317
  }
@@ -1602,6 +1632,8 @@ function bankRail(config) {
1602
1632
  trigger: config.trigger,
1603
1633
  sealed: await config.sealed?.(order),
1604
1634
  variableSymbol: config.variableSymbol?.(order),
1635
+ webhookUrl: config.webhookUrl,
1636
+ webhookSecret: config.webhookSecret,
1605
1637
  allowPublicGateway: config.allowPublicGateway
1606
1638
  });
1607
1639
  return {
@@ -1756,18 +1788,59 @@ function refusals2(answers) {
1756
1788
  ).join("; ");
1757
1789
  }
1758
1790
 
1791
+ // ../core/ed25519.ts
1792
+ var ALGORITHM = { name: "Ed25519" };
1793
+ var SIGNATURE_BYTES = 64;
1794
+ var PUBLIC_KEY_BYTES = 32;
1795
+ var PKCS8_HEADER = hexToBytes("302e020100300506032b657004220420");
1796
+ async function verifyHex(publicKeyHex, signatureHex, payload) {
1797
+ if (!isHex(publicKeyHex) || publicKeyHex.length !== PUBLIC_KEY_BYTES * 2) return false;
1798
+ if (!isHex(signatureHex) || signatureHex.length !== SIGNATURE_BYTES * 2) return false;
1799
+ try {
1800
+ const key = await crypto.subtle.importKey(
1801
+ "raw",
1802
+ asBuffer2(hexToBytes(publicKeyHex)),
1803
+ ALGORITHM,
1804
+ false,
1805
+ ["verify"]
1806
+ );
1807
+ return await crypto.subtle.verify(
1808
+ ALGORITHM,
1809
+ key,
1810
+ asBuffer2(hexToBytes(signatureHex)),
1811
+ asBuffer2(payload)
1812
+ );
1813
+ } catch {
1814
+ return false;
1815
+ }
1816
+ }
1817
+ function asBuffer2(bytes) {
1818
+ return bytes.buffer instanceof ArrayBuffer ? bytes : new Uint8Array(bytes);
1819
+ }
1820
+
1759
1821
  // src/webhook.ts
1760
1822
  var SIGNATURE_HEADER = "x-signature";
1761
1823
  var TIMESTAMP_HEADER = "x-timestamp";
1762
- var SIGNATURE_PREFIX = "sha256=";
1824
+ var SHARED_SECRET_PREFIX = "sha256=";
1825
+ var GATEWAY_KEY_PREFIX = "ed25519=";
1763
1826
  var DEFAULT_TOLERANCE_SECS = 300;
1764
- async function verifyWebhookSignature(body, signature, secret, timestamp, options = {}) {
1827
+ var CHALLENGE = "webhook-challenge";
1828
+ async function verifyWebhookSignature(body, signature, credential, timestamp, options = {}) {
1765
1829
  if (!recent(timestamp, options.toleranceSecs ?? DEFAULT_TOLERANCE_SECS)) return false;
1766
- const received = signature.startsWith(SIGNATURE_PREFIX) ? signature.slice(SIGNATURE_PREFIX.length) : signature;
1767
- return equalInConstantTime(await sign(secret, timestamp, body), received.toLowerCase());
1830
+ if (typeof credential !== "string") {
1831
+ if (!signature.startsWith(GATEWAY_KEY_PREFIX)) return false;
1832
+ return verifyHex(
1833
+ credential.publicKey.toLowerCase(),
1834
+ signature.slice(GATEWAY_KEY_PREFIX.length).toLowerCase(),
1835
+ signed(timestamp, body)
1836
+ );
1837
+ }
1838
+ if (signature.startsWith(GATEWAY_KEY_PREFIX)) return false;
1839
+ const received = signature.startsWith(SHARED_SECRET_PREFIX) ? signature.slice(SHARED_SECRET_PREFIX.length) : signature;
1840
+ return equalInConstantTime(await sign(credential, timestamp, body), received.toLowerCase());
1768
1841
  }
1769
- async function parseWebhook(body, signature, secret, timestamp, options = {}) {
1770
- if (!await verifyWebhookSignature(body, signature, secret, timestamp, options)) return null;
1842
+ async function parseWebhook(body, signature, credential, timestamp, options = {}) {
1843
+ if (!await verifyWebhookSignature(body, signature, credential, timestamp, options)) return null;
1771
1844
  const text2 = typeof body === "string" ? body : new TextDecoder().decode(body);
1772
1845
  try {
1773
1846
  return paymentFromWire(JSON.parse(text2));
@@ -1775,11 +1848,59 @@ async function parseWebhook(body, signature, secret, timestamp, options = {}) {
1775
1848
  return null;
1776
1849
  }
1777
1850
  }
1778
- async function parseWebhookRequest(request, secret, options = {}) {
1851
+ async function parseWebhookRequest(request, credential, options = {}) {
1852
+ const signature = request.headers.get(SIGNATURE_HEADER);
1853
+ const timestamp = request.headers.get(TIMESTAMP_HEADER);
1854
+ if (signature === null || timestamp === null) return null;
1855
+ return parseWebhook(await request.text(), signature, credential, timestamp, options);
1856
+ }
1857
+ async function parseWatchedWebhook(body, signature, credential, timestamp, options = {}) {
1858
+ if (!await verifyWebhookSignature(body, signature, credential, timestamp, options)) return null;
1859
+ const text2 = typeof body === "string" ? body : new TextDecoder().decode(body);
1860
+ try {
1861
+ return triggerEventFromWire(JSON.parse(text2));
1862
+ } catch {
1863
+ return null;
1864
+ }
1865
+ }
1866
+ async function parseWatchedWebhookRequest(request, credential, options = {}) {
1779
1867
  const signature = request.headers.get(SIGNATURE_HEADER);
1780
1868
  const timestamp = request.headers.get(TIMESTAMP_HEADER);
1781
1869
  if (signature === null || timestamp === null) return null;
1782
- return parseWebhook(await request.text(), signature, secret, timestamp, options);
1870
+ return parseWatchedWebhook(await request.text(), signature, credential, timestamp, options);
1871
+ }
1872
+ async function answerWebhookChallenge(body, signature, credential, timestamp, options = {}) {
1873
+ if (!await verifyWebhookSignature(body, signature, credential, timestamp, options)) return null;
1874
+ const text2 = typeof body === "string" ? body : new TextDecoder().decode(body);
1875
+ const nonce = challenged(text2);
1876
+ if (nonce === null) return null;
1877
+ if (typeof credential !== "string") return JSON.stringify({ nonce });
1878
+ return JSON.stringify({
1879
+ nonce,
1880
+ signature: `${SHARED_SECRET_PREFIX}${await hmacHex(credential, nonce)}`
1881
+ });
1882
+ }
1883
+ async function answerWebhookChallengeRequest(request, credential, options = {}) {
1884
+ const signature = request.headers.get(SIGNATURE_HEADER);
1885
+ const timestamp = request.headers.get(TIMESTAMP_HEADER);
1886
+ if (signature === null || timestamp === null) return null;
1887
+ const answer = await answerWebhookChallenge(
1888
+ await request.clone().text(),
1889
+ signature,
1890
+ credential,
1891
+ timestamp,
1892
+ options
1893
+ );
1894
+ return answer === null ? null : new Response(answer, { headers: { "content-type": "application/json" } });
1895
+ }
1896
+ function challenged(text2) {
1897
+ try {
1898
+ const said = JSON.parse(text2);
1899
+ if (said["type"] !== CHALLENGE || typeof said["nonce"] !== "string") return null;
1900
+ return said["nonce"];
1901
+ } catch {
1902
+ return null;
1903
+ }
1783
1904
  }
1784
1905
  function recent(timestamp, toleranceSecs) {
1785
1906
  const sent = Number(timestamp);
@@ -1810,6 +1931,8 @@ function signed(timestamp, body) {
1810
1931
  REQUEST_IN_FLIGHT,
1811
1932
  ThunderBridge,
1812
1933
  UnverifiedRecipientError,
1934
+ answerWebhookChallenge,
1935
+ answerWebhookChallengeRequest,
1813
1936
  bankRail,
1814
1937
  bankTransfer,
1815
1938
  bankVerifyEndpoint,
@@ -1830,6 +1953,8 @@ function signed(timestamp, body) {
1830
1953
  minorScaleOf,
1831
1954
  minorUnitsOf,
1832
1955
  msatFor,
1956
+ parseWatchedWebhook,
1957
+ parseWatchedWebhookRequest,
1833
1958
  parseWebhook,
1834
1959
  parseWebhookRequest,
1835
1960
  preimageMatchesHash,
package/dist/index.d.cts CHANGED
@@ -1,5 +1,5 @@
1
- import { T as ThunderBridge, P as Payment, C as CreatePaymentParams, W as WalletFailure } from './rail-Cyc5zTtA.cjs';
2
- export { B as BankRailConfig, a as CreateOptions, b as CreateQuoteParams, F as FollowOptions, L as Leg, c as LightningRailConfig, O as Order, d as PaymentStatus, Q as Quote, R as Rail, e as ThunderBridgeOptions, f as TriggerEvent, g as WaitOptions, h as WalletReason, i as WatchPaymentParams, j as bankRail, l as lightningRail } from './rail-Cyc5zTtA.cjs';
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';
3
3
 
4
4
  /**
5
5
  * Encrypt what the watcher needs and the gateway must not have. The gateway
@@ -66,6 +66,13 @@ interface BankTransferParams {
66
66
  * without asking anyone. `seal` it and the gateway cannot read it either
67
67
  */
68
68
  sealed?: string;
69
+ /**
70
+ * Where the gateway posts once the money lands, a public https URL. Without one
71
+ * a transfer is only ever learned by following the trigger or asking
72
+ */
73
+ webhookUrl?: string;
74
+ /** Signs that delivery, so `verifyWebhookSignature` can tell it came from the gateway */
75
+ webhookSecret?: string;
69
76
  /**
70
77
  * Register on a gateway you do not own anyway. The verify URL names the amount
71
78
  * and the reference, so its operator ends up reading your order book, and the
@@ -331,15 +338,42 @@ declare function toLnurl(endpoint: string): string;
331
338
  type WebhookOptions = {
332
339
  toleranceSecs?: number;
333
340
  };
341
+ /**
342
+ * What checks a delivery. A string is the `webhook.secret` you registered. Pass
343
+ * `{ publicKey }` instead, the hex from the gateway's `/webhook-key`, when you
344
+ * registered no secret and would rather it held nothing of yours
345
+ */
346
+ type WebhookCredential = string | {
347
+ publicKey: string;
348
+ };
334
349
  /** Verify the `X-Signature` header against the raw body and the `X-Timestamp` that came with it */
335
- declare function verifyWebhookSignature(body: string | Uint8Array, signature: string, secret: string, timestamp: string, options?: WebhookOptions): Promise<boolean>;
350
+ declare function verifyWebhookSignature(body: string | Uint8Array, signature: string, credential: WebhookCredential, timestamp: string, options?: WebhookOptions): Promise<boolean>;
336
351
  /** Verify and parse in one step, returns null on a bad signature or a body that is not a payment */
337
- declare function parseWebhook(body: string | Uint8Array, signature: string, secret: string, timestamp: string, options?: WebhookOptions): Promise<Payment | null>;
352
+ declare function parseWebhook(body: string | Uint8Array, signature: string, credential: WebhookCredential, timestamp: string, options?: WebhookOptions): Promise<Payment | null>;
338
353
  /**
339
354
  * Verify and parse from a Fetch API `Request` as used by Hono, Next, SvelteKit,
340
355
  * Cloudflare Workers and Deno, WebCrypto only so it runs anywhere fetch does
341
356
  */
342
- declare function parseWebhookRequest(request: Request, secret: string, options?: WebhookOptions): Promise<Payment | null>;
357
+ declare function parseWebhookRequest(request: Request, credential: WebhookCredential, options?: WebhookOptions): Promise<Payment | null>;
358
+ /**
359
+ * The same, for a payment the gateway only watched. A bank transfer and a blind
360
+ * Lightning leg carry no address, amount or invoice, so they arrive in the shape
361
+ * `followTrigger` and `getWatched` hand back rather than the minted one
362
+ */
363
+ declare function parseWatchedWebhook(body: string | Uint8Array, signature: string, credential: WebhookCredential, timestamp: string, options?: WebhookOptions): Promise<TriggerEvent | null>;
364
+ /** `parseWatchedWebhook` from a Fetch API `Request`, the way `parseWebhookRequest` is */
365
+ declare function parseWatchedWebhookRequest(request: Request, credential: WebhookCredential, options?: WebhookOptions): Promise<TriggerEvent | null>;
366
+ /**
367
+ * Answer the one challenge the gateway sends before it will watch a payment your
368
+ * webhook is registered on. Returns the body to send back with a 200, or null when
369
+ * this delivery is not a challenge, so a handler tries this first and then parses
370
+ */
371
+ declare function answerWebhookChallenge(body: string | Uint8Array, signature: string, credential: WebhookCredential, timestamp: string, options?: WebhookOptions): Promise<string | null>;
372
+ /**
373
+ * `answerWebhookChallenge` from a Fetch API `Request`, leaving the body unread so
374
+ * the same handler can go on to `parseWebhookRequest` when this was no challenge
375
+ */
376
+ declare function answerWebhookChallengeRequest(request: Request, credential: WebhookCredential, options?: WebhookOptions): Promise<Response | null>;
343
377
 
344
378
  /**
345
379
  * The way a gateway was caught out, every code is a check that held against the
@@ -417,4 +451,4 @@ declare class NoWalletAvailableError extends ProblemError {
417
451
  }, wallets: WalletFailure[]);
418
452
  }
419
453
 
420
- export { type BankTransfer, type BankTransferParams, type BankVerifyConfig, CreatePaymentParams, type Credit, type FioConfig, type GatewayCheatCode, GatewayCheatError, IDEMPOTENCY_KEY_REUSED, type IdempotencyConflict, IdempotencyConflictError, type Invoice, type MedianOptions, NO_WALLET_AVAILABLE, NoWalletAvailableError, PAYMENT_ALREADY_WATCHED, Payment, ProblemError, type QrOptions, REQUEST_IN_FLIGHT, type Statement, ThunderBridge, type Ticker, UnverifiedRecipientError, WalletFailure, type WebhookOptions, bankTransfer, bankVerifyEndpoint, bitstamp, coinbase, coinmate, decodeInvoice, fioStatement, invoiceToDataUrl, invoiceToSvg, isProblemType, isProvablyPaid, kraken, lnurlToDataUrl, lnurlToSvg, medianOf, minorScaleOf, minorUnitsOf, msatFor, parseWebhook, parseWebhookRequest, preimageMatchesHash, proveOrigin, proveSettlement, qrToDataUrl, qrToSvg, seal, spdToDataUrl, spdToSvg, toLnurl, unseal, verifyWebhookSignature };
454
+ export { type BankTransfer, type BankTransferParams, type BankVerifyConfig, CreatePaymentParams, type Credit, type FioConfig, type GatewayCheatCode, GatewayCheatError, IDEMPOTENCY_KEY_REUSED, type IdempotencyConflict, IdempotencyConflictError, type Invoice, type MedianOptions, NO_WALLET_AVAILABLE, NoWalletAvailableError, PAYMENT_ALREADY_WATCHED, Payment, ProblemError, type QrOptions, REQUEST_IN_FLIGHT, type Statement, ThunderBridge, type Ticker, TriggerEvent, UnverifiedRecipientError, WalletFailure, type WebhookCredential, type WebhookOptions, answerWebhookChallenge, answerWebhookChallengeRequest, bankTransfer, bankVerifyEndpoint, bitstamp, coinbase, coinmate, decodeInvoice, fioStatement, invoiceToDataUrl, invoiceToSvg, isProblemType, isProvablyPaid, kraken, lnurlToDataUrl, lnurlToSvg, medianOf, minorScaleOf, minorUnitsOf, msatFor, parseWatchedWebhook, parseWatchedWebhookRequest, parseWebhook, parseWebhookRequest, preimageMatchesHash, proveOrigin, proveSettlement, qrToDataUrl, qrToSvg, seal, spdToDataUrl, spdToSvg, toLnurl, unseal, verifyWebhookSignature };
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { T as ThunderBridge, P as Payment, C as CreatePaymentParams, W as WalletFailure } from './rail-Cyc5zTtA.js';
2
- export { B as BankRailConfig, a as CreateOptions, b as CreateQuoteParams, F as FollowOptions, L as Leg, c as LightningRailConfig, O as Order, d as PaymentStatus, Q as Quote, R as Rail, e as ThunderBridgeOptions, f as TriggerEvent, g as WaitOptions, h as WalletReason, i as WatchPaymentParams, j as bankRail, l as lightningRail } from './rail-Cyc5zTtA.js';
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';
3
3
 
4
4
  /**
5
5
  * Encrypt what the watcher needs and the gateway must not have. The gateway
@@ -66,6 +66,13 @@ interface BankTransferParams {
66
66
  * without asking anyone. `seal` it and the gateway cannot read it either
67
67
  */
68
68
  sealed?: string;
69
+ /**
70
+ * Where the gateway posts once the money lands, a public https URL. Without one
71
+ * a transfer is only ever learned by following the trigger or asking
72
+ */
73
+ webhookUrl?: string;
74
+ /** Signs that delivery, so `verifyWebhookSignature` can tell it came from the gateway */
75
+ webhookSecret?: string;
69
76
  /**
70
77
  * Register on a gateway you do not own anyway. The verify URL names the amount
71
78
  * and the reference, so its operator ends up reading your order book, and the
@@ -331,15 +338,42 @@ declare function toLnurl(endpoint: string): string;
331
338
  type WebhookOptions = {
332
339
  toleranceSecs?: number;
333
340
  };
341
+ /**
342
+ * What checks a delivery. A string is the `webhook.secret` you registered. Pass
343
+ * `{ publicKey }` instead, the hex from the gateway's `/webhook-key`, when you
344
+ * registered no secret and would rather it held nothing of yours
345
+ */
346
+ type WebhookCredential = string | {
347
+ publicKey: string;
348
+ };
334
349
  /** Verify the `X-Signature` header against the raw body and the `X-Timestamp` that came with it */
335
- declare function verifyWebhookSignature(body: string | Uint8Array, signature: string, secret: string, timestamp: string, options?: WebhookOptions): Promise<boolean>;
350
+ declare function verifyWebhookSignature(body: string | Uint8Array, signature: string, credential: WebhookCredential, timestamp: string, options?: WebhookOptions): Promise<boolean>;
336
351
  /** Verify and parse in one step, returns null on a bad signature or a body that is not a payment */
337
- declare function parseWebhook(body: string | Uint8Array, signature: string, secret: string, timestamp: string, options?: WebhookOptions): Promise<Payment | null>;
352
+ declare function parseWebhook(body: string | Uint8Array, signature: string, credential: WebhookCredential, timestamp: string, options?: WebhookOptions): Promise<Payment | null>;
338
353
  /**
339
354
  * Verify and parse from a Fetch API `Request` as used by Hono, Next, SvelteKit,
340
355
  * Cloudflare Workers and Deno, WebCrypto only so it runs anywhere fetch does
341
356
  */
342
- declare function parseWebhookRequest(request: Request, secret: string, options?: WebhookOptions): Promise<Payment | null>;
357
+ declare function parseWebhookRequest(request: Request, credential: WebhookCredential, options?: WebhookOptions): Promise<Payment | null>;
358
+ /**
359
+ * The same, for a payment the gateway only watched. A bank transfer and a blind
360
+ * Lightning leg carry no address, amount or invoice, so they arrive in the shape
361
+ * `followTrigger` and `getWatched` hand back rather than the minted one
362
+ */
363
+ declare function parseWatchedWebhook(body: string | Uint8Array, signature: string, credential: WebhookCredential, timestamp: string, options?: WebhookOptions): Promise<TriggerEvent | null>;
364
+ /** `parseWatchedWebhook` from a Fetch API `Request`, the way `parseWebhookRequest` is */
365
+ declare function parseWatchedWebhookRequest(request: Request, credential: WebhookCredential, options?: WebhookOptions): Promise<TriggerEvent | null>;
366
+ /**
367
+ * Answer the one challenge the gateway sends before it will watch a payment your
368
+ * webhook is registered on. Returns the body to send back with a 200, or null when
369
+ * this delivery is not a challenge, so a handler tries this first and then parses
370
+ */
371
+ declare function answerWebhookChallenge(body: string | Uint8Array, signature: string, credential: WebhookCredential, timestamp: string, options?: WebhookOptions): Promise<string | null>;
372
+ /**
373
+ * `answerWebhookChallenge` from a Fetch API `Request`, leaving the body unread so
374
+ * the same handler can go on to `parseWebhookRequest` when this was no challenge
375
+ */
376
+ declare function answerWebhookChallengeRequest(request: Request, credential: WebhookCredential, options?: WebhookOptions): Promise<Response | null>;
343
377
 
344
378
  /**
345
379
  * The way a gateway was caught out, every code is a check that held against the
@@ -417,4 +451,4 @@ declare class NoWalletAvailableError extends ProblemError {
417
451
  }, wallets: WalletFailure[]);
418
452
  }
419
453
 
420
- export { type BankTransfer, type BankTransferParams, type BankVerifyConfig, CreatePaymentParams, type Credit, type FioConfig, type GatewayCheatCode, GatewayCheatError, IDEMPOTENCY_KEY_REUSED, type IdempotencyConflict, IdempotencyConflictError, type Invoice, type MedianOptions, NO_WALLET_AVAILABLE, NoWalletAvailableError, PAYMENT_ALREADY_WATCHED, Payment, ProblemError, type QrOptions, REQUEST_IN_FLIGHT, type Statement, ThunderBridge, type Ticker, UnverifiedRecipientError, WalletFailure, type WebhookOptions, bankTransfer, bankVerifyEndpoint, bitstamp, coinbase, coinmate, decodeInvoice, fioStatement, invoiceToDataUrl, invoiceToSvg, isProblemType, isProvablyPaid, kraken, lnurlToDataUrl, lnurlToSvg, medianOf, minorScaleOf, minorUnitsOf, msatFor, parseWebhook, parseWebhookRequest, preimageMatchesHash, proveOrigin, proveSettlement, qrToDataUrl, qrToSvg, seal, spdToDataUrl, spdToSvg, toLnurl, unseal, verifyWebhookSignature };
454
+ export { type BankTransfer, type BankTransferParams, type BankVerifyConfig, CreatePaymentParams, type Credit, type FioConfig, type GatewayCheatCode, GatewayCheatError, IDEMPOTENCY_KEY_REUSED, type IdempotencyConflict, IdempotencyConflictError, type Invoice, type MedianOptions, NO_WALLET_AVAILABLE, NoWalletAvailableError, PAYMENT_ALREADY_WATCHED, Payment, ProblemError, type QrOptions, REQUEST_IN_FLIGHT, type Statement, ThunderBridge, type Ticker, TriggerEvent, UnverifiedRecipientError, WalletFailure, type WebhookCredential, type WebhookOptions, answerWebhookChallenge, answerWebhookChallengeRequest, bankTransfer, bankVerifyEndpoint, bitstamp, coinbase, coinmate, decodeInvoice, fioStatement, invoiceToDataUrl, invoiceToSvg, isProblemType, isProvablyPaid, kraken, lnurlToDataUrl, lnurlToSvg, medianOf, minorScaleOf, minorUnitsOf, msatFor, parseWatchedWebhook, parseWatchedWebhookRequest, parseWebhook, parseWebhookRequest, preimageMatchesHash, proveOrigin, proveSettlement, qrToDataUrl, qrToSvg, seal, spdToDataUrl, spdToSvg, toLnurl, unseal, verifyWebhookSignature };
package/dist/index.js CHANGED
@@ -541,7 +541,8 @@ function watchRequestBody(params, trigger) {
541
541
  verify_url: params.verifyUrl,
542
542
  expires_at: expiresAt.toISOString(),
543
543
  trigger: trigger ?? void 0,
544
- sealed: params.sealed
544
+ sealed: params.sealed,
545
+ webhook: params.webhookUrl ? { url: params.webhookUrl, secret: params.webhookSecret } : void 0
545
546
  });
546
547
  }
547
548
  function triggerEventFromWire(body) {
@@ -561,6 +562,7 @@ function triggerEventFromWire(body) {
561
562
  if (sealed !== null && typeof sealed !== "string") return null;
562
563
  return {
563
564
  id,
565
+ kind: kindOf(wire),
564
566
  paymentHash,
565
567
  verifyUrl,
566
568
  status,
@@ -572,6 +574,11 @@ function triggerEventFromWire(body) {
572
574
  amountMsat: wire["incoming_amount"] === void 0 ? null : msatFrom(wire["incoming_amount"], 1)
573
575
  };
574
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
+ }
575
582
  function toAmount(msat) {
576
583
  return { value: String(msat), asset_code: ASSET_CODE, asset_scale: ASSET_SCALE };
577
584
  }
@@ -709,6 +716,23 @@ var ThunderBridge = class {
709
716
  return quote;
710
717
  }
711
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
+ }
712
736
  async getPayment(id) {
713
737
  const response = await fetch(`${this.baseUrl}/incoming-payments/${encodeURIComponent(id)}`, {
714
738
  headers: this.reading()
@@ -1213,7 +1237,9 @@ async function bankTransfer(params) {
1213
1237
  verifyUrl,
1214
1238
  expiresAt: params.expiresAt,
1215
1239
  trigger: params.trigger,
1216
- sealed: params.sealed
1240
+ sealed: params.sealed,
1241
+ webhookUrl: params.webhookUrl,
1242
+ webhookSecret: params.webhookSecret
1217
1243
  });
1218
1244
  return { id: watched.id, paymentHash: watched.paymentHash, verifyUrl, spd };
1219
1245
  }
@@ -1534,6 +1560,8 @@ function bankRail(config) {
1534
1560
  trigger: config.trigger,
1535
1561
  sealed: await config.sealed?.(order),
1536
1562
  variableSymbol: config.variableSymbol?.(order),
1563
+ webhookUrl: config.webhookUrl,
1564
+ webhookSecret: config.webhookSecret,
1537
1565
  allowPublicGateway: config.allowPublicGateway
1538
1566
  });
1539
1567
  return {
@@ -1688,18 +1716,59 @@ function refusals2(answers) {
1688
1716
  ).join("; ");
1689
1717
  }
1690
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
+
1691
1749
  // src/webhook.ts
1692
1750
  var SIGNATURE_HEADER = "x-signature";
1693
1751
  var TIMESTAMP_HEADER = "x-timestamp";
1694
- var SIGNATURE_PREFIX = "sha256=";
1752
+ var SHARED_SECRET_PREFIX = "sha256=";
1753
+ var GATEWAY_KEY_PREFIX = "ed25519=";
1695
1754
  var DEFAULT_TOLERANCE_SECS = 300;
1696
- async function verifyWebhookSignature(body, signature, secret, timestamp, options = {}) {
1755
+ var CHALLENGE = "webhook-challenge";
1756
+ async function verifyWebhookSignature(body, signature, credential, timestamp, options = {}) {
1697
1757
  if (!recent(timestamp, options.toleranceSecs ?? DEFAULT_TOLERANCE_SECS)) return false;
1698
- const received = signature.startsWith(SIGNATURE_PREFIX) ? signature.slice(SIGNATURE_PREFIX.length) : signature;
1699
- return equalInConstantTime(await sign(secret, timestamp, body), received.toLowerCase());
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());
1700
1769
  }
1701
- async function parseWebhook(body, signature, secret, timestamp, options = {}) {
1702
- if (!await verifyWebhookSignature(body, signature, secret, timestamp, options)) return null;
1770
+ async function parseWebhook(body, signature, credential, timestamp, options = {}) {
1771
+ if (!await verifyWebhookSignature(body, signature, credential, timestamp, options)) return null;
1703
1772
  const text2 = typeof body === "string" ? body : new TextDecoder().decode(body);
1704
1773
  try {
1705
1774
  return paymentFromWire(JSON.parse(text2));
@@ -1707,11 +1776,59 @@ async function parseWebhook(body, signature, secret, timestamp, options = {}) {
1707
1776
  return null;
1708
1777
  }
1709
1778
  }
1710
- async function parseWebhookRequest(request, secret, options = {}) {
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 = {}) {
1711
1795
  const signature = request.headers.get(SIGNATURE_HEADER);
1712
1796
  const timestamp = request.headers.get(TIMESTAMP_HEADER);
1713
1797
  if (signature === null || timestamp === null) return null;
1714
- return parseWebhook(await request.text(), signature, secret, timestamp, options);
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
+ }
1715
1832
  }
1716
1833
  function recent(timestamp, toleranceSecs) {
1717
1834
  const sent = Number(timestamp);
@@ -1741,6 +1858,8 @@ export {
1741
1858
  REQUEST_IN_FLIGHT,
1742
1859
  ThunderBridge,
1743
1860
  UnverifiedRecipientError,
1861
+ answerWebhookChallenge,
1862
+ answerWebhookChallengeRequest,
1744
1863
  bankRail,
1745
1864
  bankTransfer,
1746
1865
  bankVerifyEndpoint,
@@ -1761,6 +1880,8 @@ export {
1761
1880
  minorScaleOf,
1762
1881
  minorUnitsOf,
1763
1882
  msatFor,
1883
+ parseWatchedWebhook,
1884
+ parseWatchedWebhookRequest,
1764
1885
  parseWebhook,
1765
1886
  parseWebhookRequest,
1766
1887
  preimageMatchesHash,
@@ -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;
@@ -74,6 +77,8 @@ interface WatchPaymentParams {
74
77
  expiresAt: number;
75
78
  trigger?: string;
76
79
  sealed?: string;
80
+ webhookUrl?: string;
81
+ webhookSecret?: string;
77
82
  }
78
83
  /** Why one wallet in the list could not be used */
79
84
  type WalletReason = "address-unusable" | "unreachable" | "amount-not-accepted" | "cannot-prove-delivery" | "invoice-refused";
@@ -187,6 +192,12 @@ declare class ThunderBridge {
187
192
  */
188
193
  createQuote(params: CreateQuoteParams): Promise<Quote>;
189
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>;
190
201
  getPayment(id: string): Promise<Payment | null>;
191
202
  /**
192
203
  * Read back a payment the gateway is only watching, null when it has never
@@ -312,6 +323,8 @@ interface BankRailConfig {
312
323
  sealed?: (order: Order) => string | Promise<string>;
313
324
  /** Up to ten digits, for accounting systems that still want one */
314
325
  variableSymbol?: (order: Order) => string | undefined;
326
+ webhookUrl?: string;
327
+ webhookSecret?: string;
315
328
  /** Register on a gateway you do not own anyway, on the terms `bankTransfer` sets out */
316
329
  allowPublicGateway?: boolean;
317
330
  /** What `Leg.rail` reads, for a shop running more than one account */
@@ -347,6 +360,8 @@ interface BlindLightningRailConfig {
347
360
  trigger?: string;
348
361
  /** Only a watched leg has anywhere to carry this */
349
362
  sealed?: (order: Order) => string | Promise<string>;
363
+ webhookUrl?: string;
364
+ webhookSecret?: string;
350
365
  /** What `Leg.rail` reads, for a shop running more than one wallet */
351
366
  name?: string;
352
367
  }
@@ -372,4 +387,4 @@ declare function lightningRail(config: LightningRailConfig): Rail;
372
387
  */
373
388
  declare function blindLightningRail(config: BlindLightningRailConfig): Rail;
374
389
 
375
- 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 CreateOptions as a, type CreateQuoteParams as b, type LightningRailConfig as c, type PaymentStatus as d, type ThunderBridgeOptions as e, type TriggerEvent 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 };
390
+ 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;
@@ -74,6 +77,8 @@ interface WatchPaymentParams {
74
77
  expiresAt: number;
75
78
  trigger?: string;
76
79
  sealed?: string;
80
+ webhookUrl?: string;
81
+ webhookSecret?: string;
77
82
  }
78
83
  /** Why one wallet in the list could not be used */
79
84
  type WalletReason = "address-unusable" | "unreachable" | "amount-not-accepted" | "cannot-prove-delivery" | "invoice-refused";
@@ -187,6 +192,12 @@ declare class ThunderBridge {
187
192
  */
188
193
  createQuote(params: CreateQuoteParams): Promise<Quote>;
189
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>;
190
201
  getPayment(id: string): Promise<Payment | null>;
191
202
  /**
192
203
  * Read back a payment the gateway is only watching, null when it has never
@@ -312,6 +323,8 @@ interface BankRailConfig {
312
323
  sealed?: (order: Order) => string | Promise<string>;
313
324
  /** Up to ten digits, for accounting systems that still want one */
314
325
  variableSymbol?: (order: Order) => string | undefined;
326
+ webhookUrl?: string;
327
+ webhookSecret?: string;
315
328
  /** Register on a gateway you do not own anyway, on the terms `bankTransfer` sets out */
316
329
  allowPublicGateway?: boolean;
317
330
  /** What `Leg.rail` reads, for a shop running more than one account */
@@ -347,6 +360,8 @@ interface BlindLightningRailConfig {
347
360
  trigger?: string;
348
361
  /** Only a watched leg has anywhere to carry this */
349
362
  sealed?: (order: Order) => string | Promise<string>;
363
+ webhookUrl?: string;
364
+ webhookSecret?: string;
350
365
  /** What `Leg.rail` reads, for a shop running more than one wallet */
351
366
  name?: string;
352
367
  }
@@ -372,4 +387,4 @@ declare function lightningRail(config: LightningRailConfig): Rail;
372
387
  */
373
388
  declare function blindLightningRail(config: BlindLightningRailConfig): Rail;
374
389
 
375
- 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 CreateOptions as a, type CreateQuoteParams as b, type LightningRailConfig as c, type PaymentStatus as d, type ThunderBridgeOptions as e, type TriggerEvent 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 };
390
+ 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
@@ -777,7 +777,9 @@ function blindLightningRail(config) {
777
777
  verifyUrl: resolved.verifyUrl,
778
778
  expiresAt: resolved.expiresAt,
779
779
  trigger: config.trigger,
780
- sealed: await config.sealed?.(order)
780
+ sealed: await config.sealed?.(order),
781
+ webhookUrl: config.webhookUrl,
782
+ webhookSecret: config.webhookSecret
781
783
  });
782
784
  return {
783
785
  id: watched.id,
package/dist/server.d.cts CHANGED
@@ -1,5 +1,5 @@
1
- import { T as ThunderBridge } from './rail-Cyc5zTtA.cjs';
2
- export { k as BlindLightningRailConfig, m as blindLightningRail } from './rail-Cyc5zTtA.cjs';
1
+ import { T as ThunderBridge } from './rail-CdKeyVGn.cjs';
2
+ export { m as BlindLightningRailConfig, n as blindLightningRail } from './rail-CdKeyVGn.cjs';
3
3
 
4
4
  interface TriggerConfig {
5
5
  /** The gateway that quotes the addresses and mints the invoice */
package/dist/server.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { T as ThunderBridge } from './rail-Cyc5zTtA.js';
2
- export { k as BlindLightningRailConfig, m as blindLightningRail } from './rail-Cyc5zTtA.js';
1
+ import { T as ThunderBridge } from './rail-CdKeyVGn.js';
2
+ export { m as BlindLightningRailConfig, n as blindLightningRail } from './rail-CdKeyVGn.js';
3
3
 
4
4
  interface TriggerConfig {
5
5
  /** The gateway that quotes the addresses and mints the invoice */
package/dist/server.js CHANGED
@@ -740,7 +740,9 @@ function blindLightningRail(config) {
740
740
  verifyUrl: resolved.verifyUrl,
741
741
  expiresAt: resolved.expiresAt,
742
742
  trigger: config.trigger,
743
- sealed: await config.sealed?.(order)
743
+ sealed: await config.sealed?.(order),
744
+ webhookUrl: config.webhookUrl,
745
+ webhookSecret: config.webhookSecret
744
746
  });
745
747
  return {
746
748
  id: watched.id,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thunder-bridge",
3
- "version": "0.8.3",
3
+ "version": "0.8.6",
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",