thunder-bridge 0.8.0

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 ADDED
@@ -0,0 +1,946 @@
1
+ # thunder-bridge
2
+
3
+ A JavaScript client for a Thunder Bridge gateway. You give it a priority list of
4
+ Lightning addresses and an amount, it walks the list, asks each address's own
5
+ LNURL-pay endpoint for an invoice, and hands back the first one it could get.
6
+ The gateway mints nothing, holds nothing and forwards nothing. The payer pays
7
+ the recipient's own invoice.
8
+
9
+ None of that would be worth much if you had to take the gateway's word for it,
10
+ so this package does not. Before `createPayment` returns, the invoice is proven
11
+ against the recipient's own server: it is the invoice that address issued, it is
12
+ for the amount you asked for rather than the amount the gateway echoed back, and
13
+ the settlement proof url belongs to the recipient rather than to the gateway.
14
+ Two fetches, both straight at the recipient's domain, neither of them back to
15
+ the gateway. A gateway that substitutes an invoice is caught by the caller that
16
+ asked for it, before a payer ever sees a QR code.
17
+
18
+ Whether the money then arrived is a separate question with its own call.
19
+ `proveSettlement` asks the recipient's own server and returns the preimage that
20
+ server released. Everything else you can read about a payment is the gateway's
21
+ own account of it, and this package is deliberate about which of its functions
22
+ are proofs and which are only sanity checks.
23
+
24
+ It touches only `fetch`, `crypto.subtle`, `URL` and `WebSocket`, so it runs in
25
+ Node, Bun, Deno, Cloudflare Workers and the browser. The service it talks to lives in the same repository, one level up from this
26
+ package.
27
+
28
+ ## Install
29
+
30
+ ```bash
31
+ npm install thunder-bridge
32
+ ```
33
+
34
+ ## Quick start
35
+
36
+ A page can run the whole flow with no backend of its own. The gateway answers
37
+ every origin, and coinos, Alby and Stacker News serve their LNURL endpoints with
38
+ CORS open, so the verification fetches work from a browser too.
39
+
40
+ ```ts
41
+ import { ThunderBridge, invoiceToSvg, type CreatePaymentParams } from "thunder-bridge";
42
+
43
+ const gateway = new ThunderBridge("https://thunder-bridge-direct-production.up.railway.app");
44
+
45
+ const request: CreatePaymentParams = {
46
+ lnAddresses: ["alice@coinos.io", "alice@getalby.com"],
47
+ amountMsat: 21_000,
48
+ };
49
+
50
+ const payment = await gateway.createPayment(request);
51
+
52
+ const target = document.querySelector("#qr");
53
+ if (target) target.innerHTML = invoiceToSvg(payment.bolt11);
54
+ ```
55
+
56
+ By the time that resolves, the invoice has been checked against whichever
57
+ address won, so it is safe to put in front of a payer. Keep the `request`
58
+ object. Every proof in this package takes it, because what you asked for is the
59
+ one side of each comparison the gateway did not supply.
60
+
61
+ ```ts
62
+ import { proveSettlement } from "thunder-bridge";
63
+
64
+ const settled = await gateway.waitForPayment(payment.id, {
65
+ signal: AbortSignal.timeout(600_000),
66
+ });
67
+
68
+ if (settled.status === "paid") {
69
+ const preimage = await proveSettlement(settled, request);
70
+ if (preimage !== null) fulfil(payment.id);
71
+ }
72
+ ```
73
+
74
+ `waitForPayment` tells you what the gateway says, and rejects a `paid` frame
75
+ whose numbers do not hold together. `proveSettlement` goes to the recipient's
76
+ own server and comes back with the preimage that server released, or `null` if
77
+ it has released none. Only the second is evidence the money arrived.
78
+
79
+ ## API
80
+
81
+ Everything below is exported from the package root. There are no subpaths.
82
+
83
+ ### `new ThunderBridge(baseUrl, options?)`
84
+
85
+ ```ts
86
+ const gateway = new ThunderBridge("https://gateway.example");
87
+ const unchecked = new ThunderBridge("https://gateway.example", { verify: false });
88
+ ```
89
+
90
+ ```ts
91
+ const mine = new ThunderBridge("https://gateway.example", { token: process.env.GATEWAY_TOKEN });
92
+ ```
93
+
94
+ A trailing slash on `baseUrl` is stripped. `verify` defaults to `true`, and
95
+ setting it to `false` turns off both halves of the checking the client does for
96
+ you: the origin proof on
97
+ `createPayment`, and the `isProvablyPaid` consistency check on every `paid`
98
+ payment read back. Turn it off only when you are running the checks yourself.
99
+
100
+ `token` is sent as `Authorization: Bearer` and is what a gateway started with
101
+ `GATEWAY_TOKEN` requires on every call. That is a lock for a gateway you host
102
+ yourself, not a per-user login: it keeps strangers out of your instance. On a
103
+ shared gateway a token someone else issues you is a thing they can revoke, which
104
+ is the dependency this project exists to remove, so there is no account system
105
+ here and none is planned.
106
+
107
+ The token covers the WebSocket handshake too, and a socket without it is refused
108
+ with a 401 rather than upgraded. No browser can put a header on a socket, so
109
+ setting `token` also makes `waitForPayment` and `followTrigger` mint a
110
+ short-lived ticket per connection and open `/ws/tickets/...` with that. Nothing
111
+ to configure, and `tickets: true` stays there for the public gateway where you
112
+ want the same on purpose.
113
+
114
+ ### `gateway.listPayments(limit?)`
115
+
116
+ ```ts
117
+ const { payments, scanned } = await gateway.listPayments(50);
118
+ ```
119
+
120
+ Lists what the gateway is watching, newest first. Only a gateway with a token
121
+ serves it, because a list on a shared one would hand every caller everyone
122
+ else's payments. A public gateway answers 404 and this throws `ProblemError`.
123
+
124
+ `scanned` is how many settled records were read to build the page. Settled
125
+ payments older than that window are not in the answer. Entries are
126
+ `TriggerEvent`, so `lnAddress` and `amountMsat` are `null` for anything
127
+ registered blind through `watchPayment`.
128
+
129
+ ### `gateway.createPayment(params)`
130
+
131
+ ```ts
132
+ const payment = await gateway.createPayment({
133
+ lnAddresses: ["alice@coinos.io", "alice@getalby.com"],
134
+ amountMsat: 21_000,
135
+ webhookUrl: "https://myapp.example/hooks/paid",
136
+ webhookSecret: process.env.WEBHOOK_SECRET,
137
+ });
138
+ ```
139
+
140
+ | Field | Type | Meaning |
141
+ |---|---|---|
142
+ | `lnAddresses` | `string[]` | priority list, tried strictly in order, the first one that can issue a provable invoice wins |
143
+ | `amountMsat` | `number` | amount in millisatoshi |
144
+ | `webhookUrl` | `string?` | POSTed once the payment is paid, must be a public https URL |
145
+ | `webhookSecret` | `string?` | HMAC-SHA256 key for the `x-signature` header |
146
+
147
+ Resolves to a `Payment`, having run `proveOrigin(payment, params)` against it
148
+ first unless `verify` is off. The object you pass is the object the proof is run
149
+ against, so keep it if you intend to prove anything later. Throws
150
+ `NoWalletAvailableError` when no address on the list could serve one,
151
+ `GatewayCheatError` when the invoice returned is not the recipient's,
152
+ `UnverifiedRecipientError` when the recipient's server could not be reached to
153
+ find out, and `ProblemError` for everything else the gateway answered, including
154
+ a 2xx whose body is not a JSON object.
155
+
156
+ A second argument makes the call safe to retry:
157
+
158
+ ```ts
159
+ const payment = await gateway.createPayment(params, { idempotencyKey: orderId });
160
+ ```
161
+
162
+ The key is claimed before any wallet is contacted, so the retry a client fires
163
+ when its own timeout expires does not mint a second invoice. Repeating a finished
164
+ request replays its payment, repeating one that is still resolving throws
165
+ `IdempotencyConflictError` with `conflict: "request-in-flight"`, and sending the
166
+ same key for different addresses, a different amount or a different webhook
167
+ throws it with `conflict: "key-reused"`. Keys are held for 24 hours, which is
168
+ shorter than a payment lives, so a replay naming a payment the gateway has since
169
+ pruned throws a plain `ProblemError` with status 410 rather than minting again.
170
+ The replayed payment is verified exactly as a fresh one is.
171
+
172
+ ### `gateway.createQuote(params)`
173
+
174
+ ```ts
175
+ const quote = await gateway.createQuote({
176
+ lnAddresses: ["alice@coinos.io", "alice@getalby.com"],
177
+ amountMsat: 21_000,
178
+ });
179
+ ```
180
+
181
+ Asks which address would serve an amount without minting anything. It fetches
182
+ each address's LNURL-pay endpoint in order and returns the first that answers and
183
+ accepts the amount, never calling the callback, so nothing is charged to the
184
+ recipient's wallet and no invoice exists afterwards.
185
+
186
+ Resolves to a `Quote`. `feeMsat` is always zero: the payer pays the recipient's
187
+ own invoice and the gateway is never in the money's path, so it has nothing to
188
+ charge for. `refusals` lists the addresses ahead of the winner and why each was
189
+ passed over. Throws `NoWalletAvailableError` when none would take the amount.
190
+
191
+ A quote is a probe, not a promise. Whether a wallet returns a *provable* invoice
192
+ cannot be known without asking it for one, and asking mints it, so the LUD-21 and
193
+ description-hash checks only run at create time. An address that quotes cleanly
194
+ can still be refused by `createPayment`.
195
+
196
+ `metadata` is the LUD-06 metadata string that wallet serves, verbatim. You need
197
+ it if you put your own LNURL-pay endpoint in front of the address, which is what
198
+ `lnurlPayEndpoint` does for you.
199
+
200
+ ### `gateway.followTrigger(secret, options)`
201
+
202
+ ```ts
203
+ const stop = gateway.followTrigger(process.env.WATCH_SECRET, {
204
+ onPayment: (payment) => overlay.show(payment.amountMsat),
205
+ });
206
+ ```
207
+
208
+ Follows every payment made to one trigger rather than one payment. On connect the
209
+ gateway replays the trigger's recent settlements, then streams each new one. It
210
+ reconnects on its own until you call the returned function, because a trigger has
211
+ no terminal state to stop at. A `paid` event whose preimage does not hash to its
212
+ payment hash goes to `onError` and is never handed to `onPayment`.
213
+
214
+ Events are `TriggerEvent`, not `Payment`. `lnAddress` and `amountMsat` are `null`
215
+ when the payment was registered with `watchPayment`, because the gateway was
216
+ never told them. What the watcher needs in that case arrives in `sealed`.
217
+
218
+ ### `gateway.watchPayment(params)`
219
+
220
+ ```ts
221
+ await gateway.watchPayment({
222
+ paymentHash: resolved.paymentHash,
223
+ verifyUrl: resolved.verifyUrl,
224
+ expiresAt: resolved.expiresAt,
225
+ trigger: process.env.WATCH_SECRET,
226
+ sealed: await encrypt({ amountMsat }),
227
+ });
228
+ ```
229
+
230
+ Hands over an invoice you obtained yourself. The gateway polls its `verify_url`
231
+ and checks preimages against `paymentHash` exactly as it does for one it minted,
232
+ but it is given no address and no amount.
233
+
234
+ That difference is the point. Creating a payment tells the gateway who is being
235
+ paid and how much, and a gateway that knows can refuse one recipient rather than
236
+ all of them. Told only a hash and a URL, the only refusal left to it is refusing
237
+ everyone, which is visible and is what makes leaving cheap.
238
+
239
+ Be exact about what is hidden and what is not. The verify URL still carries the
240
+ recipient's **domain**, so the gateway knows the provider, just not which account
241
+ there and not the amount. `sealed` is stored and handed back untouched, so
242
+ anything the watcher needs but the gateway should not know goes there.
243
+
244
+ ### `seal(secret, plaintext)` and `unseal(secret, sealed)`
245
+
246
+ ```ts
247
+ const sealed = await seal(process.env.SEALING_SECRET, JSON.stringify({ amountMsat }));
248
+ const back = await unseal(process.env.SEALING_SECRET, sealed);
249
+ ```
250
+
251
+ AES-GCM through WebCrypto, so no dependency and it runs wherever the rest does.
252
+ `unseal` returns `null` for a blob sealed with another secret, edited on the way,
253
+ or simply not one of ours. It throws only when your own secret is unusable,
254
+ because that is your bug rather than someone else's input.
255
+
256
+ The secret needs **32 characters of randomness, not a passphrase**, and a shorter
257
+ one is refused rather than quietly making a weak key. Every watcher of one
258
+ trigger holds the same secret: there is no rotation and no per-watcher key.
259
+
260
+ Plaintext is capped at 3000 bytes, which is what fits the wire's 4096 once
261
+ encrypted and encoded, so an oversized payload fails here with a readable message
262
+ instead of as a 400 from the gateway.
263
+
264
+ Do not hand-roll this. A `sealed` value the gateway can read is a fact you told
265
+ it, and blind mode exists to not tell it.
266
+
267
+ Nothing is verified on your behalf here, because there is nothing to verify
268
+ against. You resolved the address, so running `proveOrigin` was yours to do.
269
+
270
+ A hash already watched throws `ProblemError` with status 409 and
271
+ `type === PAYMENT_ALREADY_WATCHED`. That is a disclosure control, not
272
+ bookkeeping: the payment id is an HMAC of the payment hash and that id is the
273
+ read capability, so handing back the stored record would turn a value every payer
274
+ holds into a key. Repeating your own registration byte for byte still succeeds,
275
+ so a retry after a timeout is fine. `lnurlPayEndpoint` under `blind: true`
276
+ already treats it as success, because either way the invoice is being watched.
277
+
278
+ Pass `tickets: true` and the client mints a short-lived ticket per connection and
279
+ puts that in the socket URL instead of the secret. A socket URL gets logged, by
280
+ the gateway, by proxies and by the browser, and a POST body does not, so this is
281
+ how you keep the secret out of logs. It costs one request before each connect. A
282
+ `token` turns it on by itself, because there the ticket is the only way in.
283
+
284
+ Leave it off for a microcontroller. One hardcoded `wss://` URL and a dumb
285
+ reconnect loop is the right shape for an ESP32, where a POST and a JSON parse
286
+ before every reconnect is more to go wrong rather than less, and against a
287
+ private gateway it can send the bearer as a header the way a browser cannot.
288
+ Turn it on for a browser overlay and for anything where the logs matter.
289
+
290
+ This is the piece a long-lived consumer needs: an OBS overlay, a microcontroller,
291
+ a game server. Note it needs a process that stays alive, so it is the one part of
292
+ this package that does not fit a serverless function. The serverless answer is
293
+ `parseWebhookRequest`.
294
+
295
+ ### `lnurlPayEndpoint(config)`
296
+
297
+ ```ts
298
+ export default {
299
+ fetch: lnurlPayEndpoint({
300
+ gateway: new ThunderBridge("https://gateway.example.net"),
301
+ lnAddresses: ["alice@coinos.io", "alice@getalby.com"],
302
+ amountMsat: () => 21_000,
303
+ secret: process.env.CALLBACK_SECRET,
304
+ watchSecret: process.env.WATCH_SECRET,
305
+ }),
306
+ };
307
+ ```
308
+
309
+ A whole LNURL-pay endpoint as one Fetch handler, so a static QR can point at your
310
+ own domain instead of at a gateway. It runs anywhere `Request` and `Response` do:
311
+ Deno Deploy, Cloudflare Workers, Hono, Next, Node. It holds no state, so there is
312
+ nothing to provision.
313
+
314
+ `amountMsat` is a plain function called once per payRequest, which is where a
315
+ fiat peg or a time-of-day rule goes. The price is published as
316
+ `minSendable === maxSendable`, so the payer's wallet has no amount to choose.
317
+
318
+ Both halves of the flow live on one path: a bare request is the payRequest, a
319
+ signed one is the callback. `secret` signs the callback URL, and without it
320
+ anyone could call your callback and have it mint invoices on wallets of their
321
+ choosing. `watchSecret` groups the payments so `followTrigger` can watch them,
322
+ and only its sha256 ever reaches the gateway.
323
+
324
+ **Why the recipient is pinned at payRequest.** LUD-06 makes the payer's wallet
325
+ check the invoice's description hash against the sha256 of the metadata it was
326
+ served. The invoice is minted by the recipient's own wallet, so that metadata has
327
+ to be the recipient's. If the address list were walked again at callback time and
328
+ a different address won, the hashes would differ and the wallet would refuse the
329
+ payment. So the winner is quoted at payRequest and pinned into the callback URL.
330
+
331
+ The cost of that is real and worth knowing: once pinned, a recipient that goes
332
+ down before the callback fails that payment, with no fallback behind it.
333
+
334
+ Pass `blind: true` and the endpoint resolves the address itself and registers the
335
+ result with `watchPayment` instead of asking the gateway to mint. The gateway
336
+ then never learns the address or the amount. It costs one more round trip and
337
+ gives up the gateway's CORS proxying, which a server does not need.
338
+
339
+ ```ts
340
+ sealed: {
341
+ secret: process.env.SEALING_SECRET,
342
+ data: (minted) => ({ amountMsat: minted.amountMsat }),
343
+ },
344
+ ```
345
+
346
+ `data` says what the watcher needs and `secret` encrypts it, so there is no way
347
+ to hand the gateway something it can read. If you want your own crypto instead,
348
+ skip this handler and call `gateway.watchPayment({ ..., sealed })` directly,
349
+ where `sealed` is still just a string.
350
+
351
+ For the same reason the description shown in the payer's wallet is always the
352
+ recipient's, never yours. Only the amount is yours to set.
353
+
354
+ ### `gateway.getPayment(id)`
355
+
356
+ ```ts
357
+ const payment = await gateway.getPayment(id);
358
+ if (payment === null) return;
359
+ ```
360
+
361
+ Reads a payment back. `null` when the gateway has never heard of that id, rather
362
+ than a throw. A `paid` payment that fails `isProvablyPaid` throws
363
+ `GatewayCheatError` with code `preimage_mismatch`. That is a consistency check on
364
+ the gateway's own report and not a proof of payment. The origin proof is not
365
+ re-run here, since it was run when the payment was created, so a payment this
366
+ process did not create has been proven against nothing at all.
367
+
368
+ ### `gateway.waitForPayment(id, options?)`
369
+
370
+ ```ts
371
+ const settled = await gateway.waitForPayment(id, {
372
+ signal: AbortSignal.timeout(600_000),
373
+ });
374
+ ```
375
+
376
+ Opens a WebSocket and resolves with the payment the first time its status leaves
377
+ `pending`, so the result is always `paid` or `expired`. Same `isProvablyPaid`
378
+ check as `getPayment`, and the same limit: the frame that arrives is the
379
+ gateway's account of the payment, checked only against itself.
380
+
381
+ A dropped connection is not the end of the wait. The gateway sends the current
382
+ state on connect, so a settlement during an outage arrives as the first frame of
383
+ the next attempt and nothing is missed. What ends the wait by itself is the
384
+ payment: once a frame has arrived its `expires_at` is known, and reconnecting
385
+ stops there. Before any frame has arrived there is nothing to bound it, so a few
386
+ tries decide it, which is also what keeps a wrong id from becoming a loop. Waits
387
+ grow from 3 seconds and are jittered, and a gateway that answers the ticket mint
388
+ with a refusal is final rather than retried.
389
+
390
+ Rejects if the signal aborts, if a frame is not JSON, if a `paid` frame does not
391
+ hold together, if nothing ever answered, or if the payment passed its expiry
392
+ unreported. There is no default timeout, pass a signal if you want one.
393
+
394
+ It follows a payment this gateway minted. One registered blind through
395
+ `watchPayment` has no address, amount or invoice, so it does not fit the
396
+ `Payment` shape this reads, and `followTrigger` is what watches those.
397
+
398
+ ### `proveOrigin(payment, request)`
399
+
400
+ ```ts
401
+ import { proveOrigin } from "thunder-bridge";
402
+
403
+ await proveOrigin(payment, request);
404
+ ```
405
+
406
+ The whole trust argument of this package, spelled out under
407
+ [The verification chain](#the-verification-chain). The second argument is the
408
+ same `CreatePaymentParams` you handed to `createPayment`, never the gateway's
409
+ echo of it, so on every comparison at least one side is a value the gateway did
410
+ not choose. Resolves when every check passed, throws `GatewayCheatError` on the
411
+ first one that failed and `UnverifiedRecipientError` when a check could not be
412
+ run at all. Call it directly when you built the payment some other way, for
413
+ example from a webhook body or from your own `fetch` against the gateway.
414
+
415
+ ### `proveSettlement(payment, request)`
416
+
417
+ ```ts
418
+ import { proveSettlement } from "thunder-bridge";
419
+
420
+ const preimage = await proveSettlement(payment, request);
421
+ if (preimage !== null) {
422
+ fulfil(payment.id);
423
+ }
424
+ ```
425
+
426
+ The only call here that is evidence the money arrived. It runs the full origin
427
+ proof first, because a `verifyUrl` the gateway made up would otherwise be
428
+ answering for itself, and then reads `settled` and `preimage` from that url,
429
+ which check 4 has just pinned to the recipient's own callback origin.
430
+
431
+ Returns the preimage when the recipient's server reports the invoice settled and
432
+ releases one. Returns `null` when the server answers without reporting a settled
433
+ invoice and a preimage, which is the ordinary answer for an invoice nobody has
434
+ paid yet, so it is safe to call on a timer. Throws
435
+ `GatewayCheatError("preimage_mismatch")` when the released preimage does not
436
+ hash to the payment hash, and anything `proveOrigin` throws, since it runs
437
+ `proveOrigin` first. Three fetches per call, all of them at the recipient's
438
+ domain, and nothing is cached between calls.
439
+
440
+ ### `isProvablyPaid(payment)`
441
+
442
+ ```ts
443
+ if (!isProvablyPaid(webhookPayment)) {
444
+ report(`payment ${webhookPayment.id} does not even agree with itself`);
445
+ }
446
+ ```
447
+
448
+ A sanity check, not a proof, and the name is older than the distinction. True
449
+ when the payment says `paid`, carries a preimage, its `bolt11` carries the
450
+ payment hash the record claims, and that preimage hashes to it. Synchronous, no
451
+ network, and every value it compares came out of the same gateway message. A
452
+ gateway willing to invent a preimage, hash it, and mint an invoice around that
453
+ hash passes.
454
+
455
+ It is worth running: it rejects a status flipped to `paid` without a preimage
456
+ that fits, a truncated preimage, and a record whose `bolt11` was swapped for
457
+ another. It is not worth acting on. Decide to ship goods on `proveSettlement`,
458
+ which asks the recipient rather than the gateway. This is what the client runs
459
+ for you on `getPayment` and `waitForPayment`, and it is the limit of what those
460
+ two can promise.
461
+
462
+ ### `preimageMatchesHash(preimage, paymentHash)`
463
+
464
+ ```ts
465
+ preimageMatchesHash(settled.preimage ?? "", settled.paymentHash);
466
+ ```
467
+
468
+ True when `preimage` is the secret behind `paymentHash`. Non-hex or odd-length
469
+ input is false rather than a throw. Both arguments are compared
470
+ case-insensitively.
471
+
472
+ ### `decodeInvoice(bolt11)`
473
+
474
+ ```ts
475
+ const invoice = decodeInvoice(payment.bolt11);
476
+ ```
477
+
478
+ Returns `{ paymentHash, descriptionHash, amountMsat }`, every field `string | null`
479
+ except `amountMsat` which is `number | null`. Hand-rolled bech32, no library, no
480
+ signature recovery. Anything it cannot read, including a BOLT12 offer, comes back
481
+ with all three fields null rather than throwing, so check for null before
482
+ comparing.
483
+
484
+ ### `invoiceToSvg(destination, options?)`
485
+
486
+ ```ts
487
+ const svg = invoiceToSvg(payment.bolt11, { size: 320, color: "#1a1a2e" });
488
+ const jar = invoiceToSvg("iamfatik@blink.sv");
489
+ ```
490
+
491
+ An SVG string, no canvas and no DOM required. `size` defaults to 256 and `color`
492
+ to `#000`. An invoice is encoded as `LIGHTNING:` plus the uppercased invoice,
493
+ which is what wallets expect and what lets the QR use alphanumeric mode. A
494
+ lightning address keeps the case it was given, because the part before the
495
+ at-sign is case sensitive and the at-sign is outside the alphanumeric set
496
+ anyway, so uppercasing one would only risk breaking it. A
497
+ `color` that is not a hex literal, an `rgb()` or `rgba()` value, or a bare colour
498
+ name throws, because it would otherwise be written straight into the markup.
499
+ Each alternative in that pattern is anchored at both ends, so a value that is a
500
+ legal colour followed by more markup, such as `rgba(0)" onload="alert(1)`, is
501
+ refused rather than allowed to close the `fill` attribute early.
502
+
503
+ ### `invoiceToDataUrl(destination, options?)`
504
+
505
+ ```ts
506
+ const src = invoiceToDataUrl(payment.bolt11);
507
+ ```
508
+
509
+ The same SVG, percent-encoded as a `data:image/svg+xml,` URL for an `<img>` tag.
510
+
511
+ ### `lnurlToSvg(endpoint, options?)` and `lnurlToDataUrl(endpoint, options?)`
512
+
513
+ ```ts
514
+ const svg = lnurlToSvg("https://agora.gripe/tip", { size: 320 });
515
+ ```
516
+
517
+ The QR for a trigger, meaning the URL you mounted `lnurlPayEndpoint` on. It
518
+ carries no invoice and nothing in it expires, so this is the code a tip jar
519
+ prints once and an overlay leaves on screen all stream. The endpoint is bech32
520
+ encoded as the `LNURL1` string LUD-01 defines and uppercased, which is the form
521
+ that spec asks a QR to carry and the one every LNURL wallet has read for years.
522
+ An input that is not an http or https URL throws rather than becoming a QR
523
+ nobody can pay.
524
+
525
+ `toLnurl(endpoint)` is the same encoding on its own, for a `lightning:` link or a
526
+ page that renders its own codes.
527
+
528
+ LUD-17 would let you write `lnurlp://agora.gripe/tip` instead, and its own text
529
+ calls bech32 a mistake, but wallet support for it is recent enough that the
530
+ bech32 form is still what scans everywhere. If your trigger sits at
531
+ `/.well-known/lnurlp/<name>` then it is also a lightning address, and
532
+ `invoiceToSvg("<name>@yourdomain")` gives a QR people can read and type.
533
+
534
+ ### `parseWebhookRequest(request, secret)`
535
+
536
+ ```ts
537
+ const payment = await parseWebhookRequest(request, secret);
538
+ ```
539
+
540
+ Takes a Fetch API `Request`, reads the `x-signature` header, verifies it against
541
+ the raw body and parses. `null` on a missing signature, on a bad signature, and
542
+ on a correctly signed body that is not JSON, so `null` always means do not trust
543
+ this and never means this was fine but empty. Use it in Hono, Next, SvelteKit,
544
+ Cloudflare Workers, Deno and Bun.
545
+
546
+ ### `parseWebhook(body, signature, secret)`
547
+
548
+ ```ts
549
+ const payment = await parseWebhook(rawBody, signature, secret);
550
+ ```
551
+
552
+ The same thing for frameworks that hand you a raw body and headers separately,
553
+ with the same three ways of returning `null`. `body` is a `string` or a
554
+ `Uint8Array`, and it must be the bytes as received. Parsed and re-serialised JSON
555
+ will not verify.
556
+
557
+ ### `verifyWebhookSignature(body, signature, secret)`
558
+
559
+ ```ts
560
+ if (!(await verifyWebhookSignature(rawBody, signature, secret))) return;
561
+ ```
562
+
563
+ The signature check on its own, for when you want to parse the body yourself.
564
+ The `sha256=` prefix is optional and the comparison is constant time.
565
+
566
+ ### Errors
567
+
568
+ `GatewayCheatError` carries `code: GatewayCheatCode` and `paymentId`.
569
+ `UnverifiedRecipientError` carries `lnAddress` and `paymentId`. `ProblemError`
570
+ carries `type`, `title`, `status` and `detail`, and `status` is always the HTTP
571
+ status of the response: a problem document naming a different one does not
572
+ override it. `NoWalletAvailableError` extends `ProblemError` and adds
573
+ `wallets: WalletFailure[]`, always an array and empty when the gateway sent
574
+ something that is not one, so test for it before you test for `ProblemError`.
575
+ `IdempotencyConflictError` also extends `ProblemError` and adds
576
+ `conflict: IdempotencyConflict`, so test for it first too.
577
+
578
+ ### Types
579
+
580
+ `Payment`, `PaymentStatus`, `CreatePaymentParams`, `Quote`, `CreateQuoteParams`,
581
+ `WalletFailure`, `WalletReason`, `GatewayCheatCode`, `IdempotencyConflict`,
582
+ `Invoice`, `QrOptions`, `ThunderBridgeOptions`, `CreateOptions`, `FollowOptions`,
583
+ `TriggerConfig`, `TriggerEvent`, `WatchPaymentParams`, `Minted` and `WaitOptions`
584
+ are all exported as types.
585
+
586
+ ```ts
587
+ interface Payment {
588
+ id: string;
589
+ lnAddress: string;
590
+ amountMsat: number;
591
+ status: "pending" | "paid" | "expired";
592
+ paymentHash: string;
593
+ bolt11: string;
594
+ preimage: string | null;
595
+ expiresAt: number;
596
+ createdAt: number;
597
+ verifyUrl: string;
598
+ }
599
+ ```
600
+
601
+ `lnAddress` is the one address out of your list that actually served the
602
+ invoice. `expiresAt` and `createdAt` are unix seconds. `preimage` is non-null
603
+ only once the status is `paid`.
604
+
605
+ ```ts
606
+ interface Quote {
607
+ lnAddress: string;
608
+ amountMsat: number;
609
+ feeMsat: number;
610
+ minMsat: number;
611
+ maxMsat: number;
612
+ metadata: string;
613
+ refusals: WalletFailure[];
614
+ }
615
+ ```
616
+
617
+ `minMsat` and `maxMsat` are the range that wallet accepts, so a quote also tells
618
+ you what else you could have asked it for.
619
+
620
+ ## The verification chain
621
+
622
+ `proveOrigin(payment, request)` runs five checks in order and stops at the first
623
+ failure. The first two need no network at all. The rest go to the recipient's own
624
+ domain, never back to the gateway, which is the point: a gateway cannot be the
625
+ witness to its own honesty. `request` is your own `CreatePaymentParams`, so
626
+ every comparison below has your value on one side of it.
627
+
628
+ **1. The chosen address is one you listed.** `payment.lnAddress` must appear in
629
+ `request.lnAddresses`, compared case-insensitively. Rules out the gateway paying
630
+ an address you never named, its own included. The entry that matched is then the
631
+ one the rest of the chain is run against: your `user@domain` becomes
632
+ `https://domain/.well-known/lnurlp/user` under LUD-16, with the domain lowercased
633
+ because host names are case-insensitive and the local part left exactly as you
634
+ wrote it. The gateway's spelling of the address is used to find the match and
635
+ never to build the url, so a gateway cannot aim the proof at a different account
636
+ on a provider that treats the local part as case-sensitive. Fails with
637
+ `address_not_requested`. No network.
638
+
639
+ **2. The invoice is for the amount you asked, and it is the invoice the record
640
+ describes.** Three local comparisons in order. `payment.amountMsat` must equal
641
+ `request.amountMsat`, so the gateway's own summary matches your order. The BOLT11
642
+ is then decoded in-package, and its payment hash must equal
643
+ `payment.paymentHash`. Its amount must equal `request.amountMsat`, your figure
644
+ again and not the gateway's. The hash check rules out a record that describes one
645
+ invoice while the `bolt11` field carries another, which would otherwise let a
646
+ later preimage check pass against a hash nobody paid. The amount checks rule out
647
+ being billed more than you asked for by a payment that is perfectly consistent
648
+ with itself, being shown a receipt for one figure and a QR code for another, and
649
+ an amountless invoice that a payer's wallet would let them fill in themselves.
650
+ Fails with `amount_mismatch` or `hash_mismatch`. No network.
651
+
652
+ **3. The invoice's description hash pins it to that user.** The well-known url
653
+ built in check 1 is fetched, and the sha256 of the `metadata` it serves must
654
+ equal the invoice's description hash under LUD-06. Rules out an invoice minted by
655
+ a different account on the same custodial domain, which is the substitution a
656
+ node id alone cannot see: on a shared custodian every account sits behind one
657
+ node. Fails with `description_hash_mismatch`, or with
658
+ `UnverifiedRecipientError` when the endpoint served no usable payRequest.
659
+
660
+ **4. The proof url belongs to the recipient.** `payment.verifyUrl` must share an
661
+ origin with the `callback` that same endpoint publishes. Rules out a settlement
662
+ proof pointed at the gateway or anywhere else it controls, which would let it
663
+ sign off on its own payments. Fails with `verify_url_foreign`.
664
+
665
+ **5. The recipient's own server says it issued this invoice.** A GET to
666
+ `verifyUrl` under LUD-21 echoes `pr`, and it must equal `payment.bolt11` byte for
667
+ byte, case aside. Rules out everything the earlier checks could still miss,
668
+ because the answer now comes from the recipient's server rather than from the
669
+ gateway. Fails with `invoice_not_issued`.
670
+
671
+ ### Proving the money arrived
672
+
673
+ Those five checks are about an invoice, not about a payment. They prove that what
674
+ you are putting in front of a payer is the recipient's own invoice for the right
675
+ amount. They say nothing about whether anybody paid it, and the two answers to
676
+ that question are not the same answer.
677
+
678
+ `isProvablyPaid`, which the client runs for you on `getPayment` and
679
+ `waitForPayment`, asks whether the gateway's report contradicts itself: a `paid`
680
+ status, a preimage, and a `bolt11` whose payment hash that preimage opens. All
681
+ three values arrive from the gateway in one message, so the check is internal
682
+ consistency and nothing more. A gateway that generates a preimage, hashes it, and
683
+ builds an invoice around that hash passes it. It catches breakage and
684
+ carelessness, not an operator who means it.
685
+
686
+ `proveSettlement` asks the recipient. It re-runs the origin proof, which is what
687
+ ties `verifyUrl` to the recipient's own callback origin instead of to somewhere
688
+ the gateway picked, then reads that url. Under LUD-21 the recipient's server
689
+ releases the preimage once the invoice has actually been claimed, so a preimage
690
+ from there was released by the party that got paid, and it is checked against the
691
+ payment hash before you see it. `null` means the recipient's own server is not
692
+ claiming the money arrived, whatever the gateway says.
693
+
694
+ Use `isProvablyPaid` to throw out a record that is obviously wrong. Use
695
+ `proveSettlement` before you part with anything.
696
+
697
+ ### The host guard
698
+
699
+ Every outbound url in the chain must be public https. The guard refuses loopback,
700
+ link-local, the RFC 1918 ranges, carrier-grade NAT, unique local addresses, and
701
+ IPv4-mapped IPv6 that unwraps into any of those IPv4 ranges. It also refuses a
702
+ host with no dot in it such as `nas`, the
703
+ trailing-dot form `localhost.`, and anything whose last label is `local`,
704
+ `internal`, `lan`, `arpa`, `test` or `invalid`. A lightning address pointing at
705
+ your own network is never fetched, and neither is a `verifyUrl` on a private host
706
+ that the recipient's own callback vouches for.
707
+
708
+ It vets the first hop only. See [What is still trusted](#what-is-still-trusted).
709
+
710
+ ## What is still trusted
711
+
712
+ Short, and worth saying out loud rather than burying.
713
+
714
+ **The gateway chooses which of your addresses gets paid.** It is supposed to
715
+ take the first that works, and nothing here can tell a genuine failure of the
716
+ first from a preference for the third. What it cannot do is pick an address that
717
+ is not on the list, so the money still lands somewhere you named.
718
+
719
+ **The gateway can refuse you.** Availability is not provable. It can answer 503,
720
+ answer nothing, or serve one caller and not another. Every check in this package
721
+ is about an invoice you were given, none is about an invoice you were not.
722
+
723
+ **The gateway sees your request.** The address list, the amount, the webhook url
724
+ and the webhook secret all pass through it, because it has to make the calls.
725
+ Treat the secret as shared with the gateway, and treat the address list as
726
+ public. Nothing here is a privacy layer, and the payer sees the recipient's real
727
+ invoice and node either way.
728
+
729
+ **Everything the gateway says about a settlement, until you ask the recipient.**
730
+ The status, the preimage and the body a webhook delivers are the gateway's own
731
+ account. `isProvablyPaid` only asks whether that account holds together, and a
732
+ gateway that fabricates the whole record passes it. `proveSettlement` is the one
733
+ call that goes and asks somebody else. If a payment matters, ask.
734
+
735
+ **The host guard vets the first hop and no further.** Both fetches use the
736
+ runtime's default redirect handling, so a public https host that answers a
737
+ request with a 302 to a private address is followed there. The guard runs on the
738
+ url the package is about to request, not on where that request ends up. If a
739
+ request into your own network would matter, keep the egress control outside this
740
+ package: a proxy, a network policy, or a `fetch` of your own that refuses
741
+ redirects.
742
+
743
+ **A colluding custodian defeats all of it.** The proof is the recipient's
744
+ server's word, made cryptographic. If the recipient's wallet provider and the
745
+ gateway are the same party, or are cooperating, then the party holding the money
746
+ is also the party serving the metadata, publishing the callback and answering the
747
+ verify requests. Every check would pass, `proveSettlement` included, and nothing
748
+ here would help. This protects a payer against the gateway, not against the
749
+ recipient's own custodian.
750
+
751
+ **TLS and DNS for the recipient's domain.** The whole chain hangs off reaching
752
+ the real `domain`, so whoever can forge a certificate for it can forge the proof.
753
+
754
+ **An address is not a person.** This proves an invoice belongs to an address. It
755
+ never proves the address belongs to whoever you think. Vouching for the address
756
+ stays with whoever published it.
757
+
758
+ **A payment read cold is only as pinned as its creation.** `getPayment` and
759
+ `waitForPayment` check the preimage against the `paymentHash` in the same record.
760
+ It is `proveOrigin` at creation, against the request you wrote, that tied that
761
+ hash to an invoice the recipient issued. If you fetch a payment id you never
762
+ created and never proved, you are checking the gateway's numbers against each
763
+ other and nothing more. Run `proveOrigin` with the request that created it, or
764
+ `proveSettlement` if you also need to know it was paid, which means storing the
765
+ request alongside the payment id.
766
+
767
+ ## Errors
768
+
769
+ Every failure from the gateway is an RFC 9457 problem document served as
770
+ `application/problem+json`. Branch on `type`, never on prose. One type maps to
771
+ its own class, the rest arrive as `ProblemError` with `type` intact.
772
+
773
+ | `type` | Status | Class | When |
774
+ |---|---|---|---|
775
+ | `urn:problem-type:thunder-bridge-direct:invalid-request` | 400 | `ProblemError` | the body or one of its fields could not be read, `detail` names the field |
776
+ | `urn:problem-type:thunder-bridge-direct:no-wallet-available` | 400, 422, 502 | `NoWalletAvailableError` | the list was walked and nothing served, `wallets` says why each one failed |
777
+ | `about:blank` | 404 | none | unknown payment, `getPayment` returns `null` instead of throwing |
778
+ | `about:blank` | 503 | `ProblemError` | the instance is watching as many payments as it can |
779
+ | `about:blank` | 500 | `ProblemError` | the gateway broke |
780
+
781
+ The `Status` column is what the transport carried, and it is what `error.status`
782
+ reports. A document that names a different status in its own body does not get to
783
+ override it.
784
+
785
+ The status on `no-wallet-available` follows the worst wallet, so a retry is never
786
+ advised in vain: 502 if any wallet was merely unreachable, else 422 if any
787
+ refused permanently, else 400.
788
+
789
+ Each entry in `wallets` is a `WalletFailure` with an `address` and a `reason`,
790
+ in the order the addresses were tried. The array is empty rather than absent when
791
+ the gateway sends a `wallets` field that is not a list.
792
+
793
+ | `reason` | What it means | Do |
794
+ |---|---|---|
795
+ | `address-unusable` | not a lightning address, or its domain is not a public https host | fix the address |
796
+ | `unreachable` | no usable payRequest or invoice came back, which also covers an unknown user | retry, or check the wallet |
797
+ | `amount-not-accepted` | the amount is outside that wallet's min and max | change the amount |
798
+ | `cannot-prove-delivery` | no LUD-21 `verify` url, or a provider known never to release a preimage | use another provider |
799
+ | `invoice-refused` | it answered with an invoice the gateway will not accept: wrong amount, unbound metadata, or undecodable | report it, use another wallet |
800
+
801
+ `GatewayCheatError` is different in kind. It does not report a request that
802
+ failed, it reports a gateway that demonstrably misbehaved, and `code` names the
803
+ check that caught it.
804
+
805
+ | `code` | The gateway did this |
806
+ |---|---|
807
+ | `address_not_requested` | returned an address that was not on the list you passed |
808
+ | `hash_mismatch` | the `bolt11` does not carry the `paymentHash` it reports |
809
+ | `amount_mismatch` | reported, or invoiced, an amount other than the `amountMsat` you asked for |
810
+ | `description_hash_mismatch` | the invoice is not bound to that user's LNURL metadata |
811
+ | `verify_url_foreign` | the `verifyUrl` is not on the recipient's callback origin |
812
+ | `invoice_not_issued` | the recipient's own server does not echo this invoice |
813
+ | `preimage_mismatch` | reported `paid` with a preimage that does not open the invoice, or the recipient released one that does not |
814
+
815
+ `UnverifiedRecipientError` is deliberately not in that table. It means a check
816
+ could not be run, because the recipient's server was down, timed out, answered
817
+ with something unreadable, or the browser was blocked by CORS. It is not an
818
+ accusation, and it is also not a clean bill of health. Decide what you want to
819
+ do with an unproven invoice, and decide it explicitly.
820
+
821
+ ```ts
822
+ import {
823
+ GatewayCheatError,
824
+ NoWalletAvailableError,
825
+ ProblemError,
826
+ ThunderBridge,
827
+ UnverifiedRecipientError,
828
+ } from "thunder-bridge";
829
+
830
+ try {
831
+ const payment = await gateway.createPayment({ lnAddresses: wallets, amountMsat: 21_000 });
832
+ show(payment);
833
+ } catch (error) {
834
+ if (error instanceof GatewayCheatError) {
835
+ report(`the gateway cheated: ${error.code} on payment ${error.paymentId}`);
836
+ } else if (error instanceof UnverifiedRecipientError) {
837
+ report(`could not reach ${error.lnAddress} to check the invoice`);
838
+ } else if (error instanceof NoWalletAvailableError) {
839
+ for (const wallet of error.wallets) report(`${wallet.address}: ${wallet.reason}`);
840
+ } else if (error instanceof ProblemError) {
841
+ report(`${error.status} ${error.title}`);
842
+ } else {
843
+ throw error;
844
+ }
845
+ }
846
+ ```
847
+
848
+ ## Webhooks
849
+
850
+ Pass `webhookUrl` and optionally `webhookSecret` when you create a payment. Once
851
+ it reaches `paid` the gateway POSTs the same JSON the API returns, so the body is
852
+ a `Payment`. With a secret set it carries `x-signature: sha256=<hmac>` over the
853
+ raw body, HMAC-SHA256. Three attempts, five seconds apart, then it gives up. An
854
+ invoice that expires fires nothing.
855
+
856
+ Delivery is at-least-once, so deduplicate on `id`.
857
+
858
+ ```ts
859
+ import { parseWebhookRequest, proveSettlement } from "thunder-bridge";
860
+
861
+ app.post("/hooks/paid", async (context) => {
862
+ const payment = await parseWebhookRequest(context.req.raw, secret);
863
+ if (payment === null) return context.text("bad signature", 401);
864
+
865
+ const preimage = await proveSettlement(payment, requestFor(payment.id));
866
+ if (preimage === null) return context.text("the recipient has not seen it", 402);
867
+
868
+ await fulfil(payment.id);
869
+ return context.text("ok");
870
+ });
871
+ ```
872
+
873
+ The signature proves the body came from someone holding your secret. It does not
874
+ prove the payment happened, since the gateway holds that secret too. Neither does
875
+ `isProvablyPaid`: the gateway wrote every field in that body, so all the check
876
+ can say is that the body does not contradict itself. The proof is
877
+ `proveSettlement`, and it needs the request you originally sent, which is why
878
+ `requestFor` above is your own lookup from a payment id back to the
879
+ `CreatePaymentParams` you stored when you created it.
880
+
881
+ For a framework that hands you the raw body and headers separately, use
882
+ `parseWebhook`. The body must be the bytes as received, so mount a raw body
883
+ parser on that route and not a JSON one. The settlement proof belongs here too,
884
+ and is left out only to keep the raw body point visible.
885
+
886
+ ```ts
887
+ import express from "express";
888
+ import { parseWebhook } from "thunder-bridge";
889
+
890
+ app.post("/hooks/paid", express.raw({ type: "application/json" }), async (request, response) => {
891
+ const payment = await parseWebhook(request.body, request.get("x-signature") ?? "", secret);
892
+ if (payment === null) {
893
+ response.sendStatus(401);
894
+ return;
895
+ }
896
+ response.sendStatus(200);
897
+ });
898
+ ```
899
+
900
+ ## QR codes
901
+
902
+ `invoiceToSvg` returns a string, so it works on a server, in a worker and in a
903
+ browser with no canvas involved.
904
+
905
+ ```ts
906
+ import { invoiceToDataUrl, invoiceToSvg } from "thunder-bridge";
907
+
908
+ const markup = invoiceToSvg(payment.bolt11, { size: 320, color: "#1a1a2e" });
909
+ const source = invoiceToDataUrl(payment.bolt11);
910
+ ```
911
+
912
+ Both encode the invoice as `LIGHTNING:` followed by the uppercased BOLT11, per
913
+ BIP 21 and the BOLT 11 QR section. Uppercase is not cosmetic, it lets the encoder
914
+ use alphanumeric mode, which fits the same invoice into a visibly smaller and
915
+ more scannable code.
916
+
917
+ Only BOLT11 is handled. This gateway never returns a BOLT12 offer, and the
918
+ `lightning:` scheme is BOLT11-specific anyway: wrapping an offer in it makes
919
+ wallets that recognise the scheme parse the body as BOLT11 and reject it.
920
+
921
+ ## Requirements
922
+
923
+ Node 22 or newer. `waitForPayment` and `followTrigger` use the global
924
+ `WebSocket`, which Node only exposes from 22 onwards, and there is no fallback
925
+ and no optional dependency to install. Everything else in the package works on
926
+ any runtime with `fetch` and `crypto.subtle`, so an older Node can still create
927
+ payments, quote them, verify them, poll `getPayment`, serve `lnurlPayEndpoint`
928
+ and handle webhooks.
929
+
930
+ On a serverless runtime, treat the two sockets as unavailable whatever the
931
+ platform supports: an invocation ends when it answers, and a payment can live for
932
+ weeks. `lnurlPayEndpoint` is built for that world and holds no state, and
933
+ `parseWebhookRequest` is how settlement reaches you there.
934
+
935
+ TypeScript is bundled. The package ships ESM and CJS builds with types for both.
936
+
937
+ ## Development
938
+
939
+ `npm test` runs the vitest suite. `npm run typecheck` type-checks `src` against
940
+ `tsconfig.json` and then `src` and `test` together against `tsconfig.test.json`,
941
+ so a test file that stops compiling fails the same gate the library does.
942
+ `npm run build` emits ESM, CJS and the two declaration flavours with tsup.
943
+
944
+ ## License
945
+
946
+ MIT