thunder-bridge 1.4.1 → 1.4.2

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.
Files changed (3) hide show
  1. package/README.md +338 -528
  2. package/openapi.yaml +1 -1
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -2,25 +2,44 @@
2
2
 
3
3
  A JavaScript client for a Thunder Bridge gateway. Give it a priority list of
4
4
  lightning addresses and an amount, and it hands back an invoice minted by the
5
- recipient's own wallet. The gateway mints nothing, holds nothing and forwards
6
- nothing.
7
-
8
- None of that would be worth much if you had to take the gateway's word for it, so
9
- this package does not. Before `createPayment` returns, the invoice is proven
10
- against the recipient's own server: it is the invoice that address issued, for the
11
- amount you asked for rather than the amount the gateway echoed back, and its
12
- settlement proof url belongs to the recipient. A gateway that substitutes an
13
- invoice is caught before a payer sees a QR code.
14
-
15
- Lightning is the rail it was built for, not the only one it can prove. A bank
16
- transfer has no preimage, so `bankTransfer` derives one and hands the gateway its
17
- hash, which puts money arriving in a bank account behind the same watch, the same
18
- poll and the same proof. `Statement` is where a bank plugs in and `fioStatement`
19
- is the first one.
20
-
21
- It touches only `fetch`, `crypto.subtle`, `URL` and `WebSocket`, so it runs in
22
- Node, Bun, Deno, Cloudflare Workers and the browser. The gateway it talks to is
23
- one level up in [this repository](../README.md).
5
+ recipient's own wallet, proven against that recipient's own server before it
6
+ returns. The gateway mints nothing, holds nothing and forwards nothing.
7
+
8
+ LUD-21 is the shape of the proof rather than the whole of it. Whichever rail a
9
+ payment runs on, the gateway holds a payment hash, polls a `verify` URL and reports
10
+ what came back, and four different things can be the one answering.
11
+
12
+ ## Whose wallets this works with
13
+
14
+ Check this before you build on it. The gateway can watch a payment only when the
15
+ recipient's lightning address publishes a LUD-21 `verify` URL **and** releases the
16
+ preimage through it. Support belongs to the address domain rather than to the app,
17
+ so the same wallet on another domain can answer differently.
18
+
19
+ | Works | Address ends with |
20
+ |---|---|
21
+ | Blink | `@blink.sv` |
22
+ | Alby | `@getalby.com` |
23
+ | coinos | `@coinos.io`, `@coinos.pro` |
24
+ | Minibits | `@minibits.cash` |
25
+ | Speed | `@speed.app` |
26
+ | Cake, Breez, Blitz, the Spark-hosted brands | `@cake.cash`, `@breez.tips`, `@blitzwalletapp.com` |
27
+ | a BTCPay Server of your own | your domain, from v2.3.8 |
28
+
29
+ | Refused | Why |
30
+ |---|---|
31
+ | Wallet of Satoshi, Strike, Cash App, ZBD, Primal, Fountain, every LNbits wallet | no `verify` at all |
32
+ | ZEUS Pay, ecash.love | `verify` without a preimage, refused deliberately, since `settled: true` with nothing to hash proves nothing |
33
+
34
+ A refusal happens at creation rather than leaving a payment pending until a
35
+ watcher gives up, so a recipient finds out before a payer sees a QR code.
36
+
37
+ If your recipient is on a refused name, `nwcRail` is the way round it: your own
38
+ wallet answers over NIP-47 instead of over an address, and the gateway watches the
39
+ hash exactly the same. [docs/lud21-coverage.md](../docs/lud21-coverage.md) is the
40
+ measured list rather than a reading of changelogs, last surveyed 2026-08-12. Read
41
+ every row as true of that date: a wallet that has shipped LUD-21 since still reads
42
+ as refused here until the next survey says otherwise.
24
43
 
25
44
  ## Install
26
45
 
@@ -28,471 +47,326 @@ one level up in [this repository](../README.md).
28
47
  npm install thunder-bridge
29
48
  ```
30
49
 
31
- ## Quick start
50
+ Node 22 or newer, for anything that opens a socket: `waitForPayment`,
51
+ `waitForWatched`, `firstToSettle` and `followTrigger` on the gateway side, and every
52
+ NWC call on the wallet side, since a nostr relay is a socket too. Node only exposes
53
+ a global `WebSocket` from 22 onwards and there is no fallback to install. The rest,
54
+ which is every call that is one or more `fetch` requests, runs on any runtime with
55
+ `fetch` and `crypto.subtle`.
56
+
57
+ The package has two entry points.
32
58
 
33
- A page can run the whole flow with no backend of its own. The gateway answers
34
- every origin, and coinos, Alby and Stacker News serve their LNURL endpoints with
35
- CORS open, so the proof fetches work from a browser too.
59
+ | Import | Needs | Has |
60
+ |---|---|---|
61
+ | `thunder-bridge` | `fetch`, `crypto.subtle`, `URL`, `WebSocket` | everything except the server-only exports |
62
+ | `thunder-bridge/server` | `node:dns` as well | `invoiceFrom`, `askWallet`, `lnurlPayEndpoint`, the ticket handlers, `lightningVerifyEndpoint`, `nwcVerifyEndpoint`, and the blind and NWC rails |
36
63
 
37
- The url below is a shared demo that answers anyone and forgets everything on
38
- restart. It is there so this snippet runs as written. It is a base url rather than a
39
- page, so opening it in a browser gives a `404` and
40
- [`/health`](https://public.thunder-bridge.agora.gripe/health) is what tells you it is
41
- up. Point production at a gateway of your own, which takes one command.
64
+ The second one resolves lightning addresses and refuses a private host, so it needs
65
+ DNS and will not run on Cloudflare Workers. `bankVerifyEndpoint` is on the main
66
+ entry rather than there, because a bank statement needs no DNS.
67
+
68
+ ## Quick start
69
+
70
+ A page can run the whole flow with no backend of its own. The url below is a shared
71
+ demo gateway that answers anyone and forgets everything on restart, so this snippet
72
+ runs as written. It is a base url rather than a page, so opening it in a browser
73
+ gives a `404` and [`/health`](https://public.thunder-bridge.agora.gripe/health) is
74
+ what tells you it is up.
42
75
 
43
76
  ```ts
44
- import { ThunderBridge, invoiceToSvg, type CreatePaymentParams } from "thunder-bridge";
77
+ import {
78
+ ThunderBridge,
79
+ invoiceToSvg,
80
+ proveSettlement,
81
+ type CreatePaymentParams,
82
+ } from "thunder-bridge";
45
83
 
46
84
  const gateway = new ThunderBridge("https://public.thunder-bridge.agora.gripe");
47
85
 
48
86
  const request: CreatePaymentParams = {
49
- lnAddresses: ["alice@coinos.io", "alice@getalby.com"],
87
+ lnAddresses: ["iamfatik@blink.sv", "iamfatik@coinos.io"],
50
88
  amountMsat: 21_000,
51
89
  };
52
90
 
53
91
  const payment = await gateway.createPayment(request);
54
92
 
55
93
  const target = document.querySelector("#qr");
56
- if (target) target.innerHTML = invoiceToSvg(payment.bolt11);
57
- ```
58
-
59
- Keep the `request` object. Every proof takes it, because what you asked for is the
60
- side of each comparison the gateway did not supply.
61
-
62
- ```ts
63
- import { proveSettlement } from "thunder-bridge";
94
+ if (target !== null) {
95
+ target.innerHTML = invoiceToSvg(payment.bolt11);
96
+ }
64
97
 
65
98
  const settled = await gateway.waitForPayment(payment.id, {
66
99
  signal: AbortSignal.timeout(600_000),
67
100
  });
68
101
 
69
- if (settled.status === "paid") {
70
- const preimage = await proveSettlement(settled, request);
71
- if (preimage !== null) fulfil(payment.id);
72
- }
102
+ const preimage = settled.status === "paid" ? await proveSettlement(settled, request) : null;
73
103
  ```
74
104
 
75
- `waitForPayment` tells you what the gateway says. `proveSettlement` goes to the
76
- recipient's own server. Only the second is evidence the money arrived.
77
-
78
- ## What is exported
105
+ Keep the `request` object. Every proof that asks the recipient takes it, because
106
+ what you asked for is the side of each comparison the gateway did not supply.
107
+ `proveWrapped` is the exception, since it compares two invoices and asks nobody. `waitForPayment` tells you what
108
+ the gateway says. `proveSettlement` goes to the recipient's own server. Only the
109
+ second is evidence the money arrived, and
110
+ [docs/proving-a-payment.md](../docs/proving-a-payment.md) is the whole argument for
111
+ why.
79
112
 
80
- Everything comes from the package root, there are no subpaths. Signatures and the
81
- caveats on each export are in the TSDoc on the export itself, so your editor has
82
- them and this table does not repeat them.
113
+ ## How each payment method gets verified
83
114
 
84
- **The gateway** - [`src/client.ts`](src/client.ts)
115
+ Every rail ends the same way, with a preimage that has to hash to the payment hash
116
+ the gateway was given. What differs is who obtains the invoice, who is asked for the
117
+ preimage, and which side does the checking.
85
118
 
86
- | Export | What it does |
119
+ | `lightningRail` | the gateway asks, at the recipient's LNURL callback |
87
120
  |---|---|
88
- | `new ThunderBridge(baseUrl, options?)` | a gateway handle. `{ secret }` is your rail secret and makes every call speak as you, `{ verify: false }` turns off the automatic proof, `{ token }` makes the instance yours |
89
- | `new Gateways(baseUrls, options?)` | the same payment watched at several gateways, so any one of them is replaceable. `onRefused` says which of them would not take it |
90
- | `gateway.nameFor(paymentHash)` | what this payment is called, worked out before any gateway has heard of it, and the same at all of them |
91
- | `gateway.createPayment(params, options?)` | mint an invoice on the first address that can prove one, and prove it before returning |
92
- | `gateway.createQuote(params)` | ask which address would take an amount without minting anything |
93
- | `gateway.getPayment(id)` | read a payment back, `null` when the gateway never heard of it |
94
- | `gateway.getWatched(id)` | the same for one the gateway only watches, which carries no address, amount or invoice |
95
- | `gateway.listPayments(limit?)` | what this gateway is watching, newest first. Needs a token |
96
- | `gateway.waitForPayment(id, options?)` | follow one payment over WebSocket until it is paid or expired |
97
- | `gateway.waitForWatched(id, options?)` | the same for a watched one, answering the shape both rails share |
98
- | `gateway.firstToSettle(ids, options?)` | wait on several legs, keep the first really paid, drop the losers |
99
- | `gateway.watchPayment(params)` | hand over an invoice you obtained yourself, without the address or the amount |
100
- | `gateway.followTrigger(secret, options)` | stream every payment carrying one trigger, reconnecting on its own. `replay` asks for how many past settlements on connect |
101
- | `gateway.createSocketTicket(params)` | a one minute pass onto one trigger's stream, to hand something that must not hold the secret |
102
- | `gateway.isPrivate` | whether a token was given |
103
-
104
- **Proving it** - [`src/verify.ts`](src/verify.ts)
105
-
106
- | Export | What it does |
121
+ | the gateway is told | the address list and the amount, and nothing else. It derives the hash and the `verify` URL by resolving the address, and hands both back |
122
+ | the invoice is checked by | you, `proveOrigin` runs five checks against the recipient's own domain |
123
+ | the gateway probes first | nothing, it resolved the address itself |
124
+ | the gateway polls | the wallet, directly |
125
+ | `settled` comes from | the wallet releasing its preimage |
126
+ | the pace is set by | the wallet, when it sends `Cache-Control: max-age`. When it sends none the gateway's own schedule decides |
127
+
128
+ | `blindLightningRail` | you ask, with `invoiceFrom` on your server |
107
129
  |---|---|
108
- | `proveOrigin(payment, request)` | the five checks below, against the recipient's own server |
109
- | `proveSettlement(payment, request)` | ask the recipient whether it settled, returns the preimage or `null` |
110
- | `isProvablyPaid(payment)` | whether the gateway's own report is self-consistent. A sanity check, not a proof |
111
- | `preimageMatchesHash(preimage, hash)` | one sha256 comparison |
112
- | `decodeInvoice(bolt11)` | the invoice's own amount, payment hash and description hash |
113
-
114
- **Serving your own endpoint** - [`src/trigger.ts`](src/trigger.ts), [`src/bank.ts`](src/bank.ts), [`src/fio.ts`](src/fio.ts)
115
-
116
- | Export | What it does |
130
+ | the gateway is told | a hash, an expiry and your URL, with the wallet's sealed inside |
131
+ | the invoice is checked by | nobody needs to, you resolved the address yourself |
132
+ | the gateway probes first | `speaksVerify`: a GET on the URL, then a signed POST nonce it must echo |
133
+ | the gateway polls | your `lightningVerifyEndpoint`, once `relayVerifyThrough` is set. Leave it off and the gateway polls the wallet directly, as on the minted rail |
134
+ | `settled` comes from | your endpoint, which unseals, asks the wallet and relays the answer |
135
+ | the pace is set by | you, `pollEverySecs` |
136
+
137
+ | `nwcRail` | your own wallet mints it, over NIP-47 `make_invoice` |
117
138
  |---|---|
118
- | `lnurlPayEndpoint(config)` | a whole LNURL-pay endpoint as one Fetch handler, so a static QR points at your domain. `replay` keeps its last settlements on the gateway for a page that opens later. From `thunder-bridge/server` |
119
- | `watchTicketEndpoint(config)` | mints socket tickets for callers who already know the watch secret, 403 for the rest. From `thunder-bridge/server` |
120
- | `publicWatchTicketEndpoint(config)` | the same for a board strangers are meant to read, minting for anyone. From `thunder-bridge/server` |
121
- | `seal(secret, plaintext)`, `unseal` | the blob the gateway stores and cannot read |
122
- | `toLnurl(url)` | bech32-encode an endpoint url |
123
- | `bankTransfer(params)` | register a Czech QR platba as a watched payment. Refuses a gateway that serves strangers |
124
- | `bankVerifyEndpoint(config)` | the other half, the LUD-21 shape backed by your own statement |
125
- | `fioStatement(config)` | a `Statement` reading a Fio account, several tokens used strictly in turn |
126
- | `lightningVerifyEndpoint(config)` | the same shape for Lightning, asking the wallet on the gateway's behalf. From `thunder-bridge/server` |
127
- | `relayedVerifyUrl(mount, wallet, secret)` | the URL to hand the gateway instead of the wallet's, with the wallet's sealed inside |
128
-
129
- There are two ticket endpoints rather than one taking a flag, so the call site
130
- says which board this is. Mount one on its own path and POST to it from the page
131
- before every connect, because a ticket lives a minute. Whichever you mount, the
132
- page never holds the watch secret, which is the reason to mount either.
133
-
134
- A public board is public in full: every viewer of that socket gets each
135
- settlement's preimage, verify url and payment hash. Fine for a tip jar, wrong the
136
- moment anything is gated behind those preimages, because then a viewer holds the
137
- unlock.
138
-
139
- What your service answers once those handlers are mounted is written out in
140
- [`openapi.yaml`](openapi.yaml), shipped with this package.
141
-
142
- `Statement` is a plain `(sinceUnix) => Promise<Credit[]>`, so another bank is
143
- another function of that shape and persistence wraps it from outside rather than
144
- living inside it. Nothing above it changes, and the package stays ignorant of
145
- whatever runtime you keep state in.
146
-
147
- **One shape for every payment method** - [`src/rail.ts`](src/rail.ts)
148
-
149
- | Export | What it does |
139
+ | the gateway is told | a hash and your URL, with the hash sealed inside |
140
+ | the invoice is checked by | nobody, it is your wallet |
141
+ | the gateway probes first | the same GET and signed nonce |
142
+ | the gateway polls | your `nwcVerifyEndpoint` |
143
+ | `settled` comes from | `lookup_invoice`, refused unless the wallet's own key signed it |
144
+ | the pace is set by | you, `pollEverySecs` |
145
+
146
+ | `bankRail` | nobody, there is no invoice |
150
147
  |---|---|
151
- | `bankRail(config)` | a `Rail` selling for a bank transfer, reading back through any `Statement` |
152
- | `lightningRail(config)` | a `Rail` where the gateway mints the invoice, so it learns the address and the amount |
153
- | `blindLightningRail(config)` | a `Rail` that resolves the address itself and tells the gateway only a hash. From `thunder-bridge/server` |
154
- | `nwcRail(config)` | a `Rail` that mints on a wallet of your own over NIP-47, for a wallet with no LUD-21 address to be watched at. From `thunder-bridge/server` |
148
+ | the gateway is told | a hash and your URL, which names the amount and the reference |
149
+ | the invoice is checked by | nobody, there is no invoice to check |
150
+ | the gateway probes first | the same GET and signed nonce |
151
+ | the gateway polls | your `bankVerifyEndpoint` |
152
+ | `settled` comes from | a `Statement` credit matching amount and currency exactly, with the reference anywhere in the payer's text |
153
+ | the pace is set by | you, `pollEverySecs` |
154
+
155
+ Two things are worth reading off those blocks rather than inferring.
156
+
157
+ **The checking side flips.** On the minted path the gateway resolved the address, so
158
+ it runs no verify probe and no verify challenge, and `proveOrigin` on your side is
159
+ the whole defence. A `webhookUrl` is challenged on both paths. On every watched path the gateway resolved nothing, so it probes the URL
160
+ and challenges it with a nonce before accepting the watch, refusing with `424` if
161
+ nothing answers. Deploy the endpoint before you register it. The challenge is on
162
+ unless the operator set `VERIFY_CHALLENGE=0`, which is also why a bare wallet
163
+ `verify` URL cannot be handed to `watchPayment`: a wallet will not echo a nonce.
164
+
165
+ **What a preimage proves is the same on all four, and narrower than it looks:** that
166
+ the server holding the secret says the money arrived, made unforgeable by anyone
167
+ else. On the bank rail that secret is an HMAC you derive, which sounds weaker and is
168
+ not, because a wallet also minted the preimage it later releases. It rules out a
169
+ gateway inventing a settlement. It does not rule out a recipient lying about one, so
170
+ this protects a payer against the operator, not against the person being paid.
171
+
172
+ `proveWrapped` sits on a different axis. It compares two invoices on one payment
173
+ hash and asks nobody anything, so it says whether an operator's wrap is honest
174
+ without saying whether either invoice was paid.
175
+
176
+ ### What each one costs you
177
+
178
+ **`lightningRail`**
179
+
180
+ - the gateway holds the address and the amount, so your order book is readable
181
+ from its own logs
182
+ - it polls the wallet directly, which puts the recipient's provider in its logs and
183
+ in front of its peers
184
+ - the wallet's `Cache-Control` sets the poll pace, so how fast a settlement is
185
+ noticed is not yours to decide
186
+
187
+ **`blindLightningRail`**
188
+
189
+ - a service of your own that has to stay up, so a browser-only integration cannot
190
+ use this rail at all
191
+ - one long-lived sealing secret, which `seal` refuses under 32 characters, so
192
+ `openssl rand -hex 16` is the shortest thing that works
193
+ - your endpoint being down means the gateway cannot verify and the payment sits
194
+ `pending`
195
+ - a wallet you cannot reach answers `502`, so the gateway retries instead of
196
+ concluding the invoice went unpaid. The body still reads `settled: false`, and the
197
+ status is what separates "could not ask" from "asked, and no"
198
+
199
+ **`nwcRail`**
200
+
201
+ - an NWC connection to your own wallet, and the nostr relays behind it
202
+ - **scope the connection to `make_invoice` and `lookup_invoice`, never
203
+ `pay_invoice`.** It is a key that spends, and a leak with the wrong scope drains
204
+ the wallet
205
+ - relays unreachable means no verification
206
+
207
+ **`bankRail`**
208
+
209
+ - the gateway has to be one of your own: the verify URL names the amount and the
210
+ reference, so whoever runs the gateway reads your order book from the watches
211
+ alone
212
+ - the secret is the entire proof. **Lose it and every past proof is gone**, because
213
+ each preimage is derived from it
214
+
215
+ **The bank rail has one silent failure worth testing before you promise anybody a
216
+ rail.** Two shapes leave a payment `pending` while the money is already in the
217
+ account: a bank that truncates the reference, since the match asks whether the
218
+ reference is inside what the bank forwarded rather than the other way round, and a
219
+ payer whose bank forwards nothing but a numeric variable symbol, since an
220
+ alphanumeric reference cannot travel in a numeric field and `X-VS` is not read as an
221
+ alternative. Neither has been seen with Fio, which forwards the message untouched.
222
+ Check it against the banks your payers actually use.
223
+
224
+ ## Who you still have to trust
225
+
226
+ The proof narrows the trust rather than removing it. Three parties are left, and
227
+ they are not equally constrained.
228
+
229
+ | | You trust it with | It cannot |
230
+ |---|---|---|
231
+ | the gateway | which of your addresses gets paid, and whether it answers at all | pay an address not on your list, bill you more than you asked, or invent a settlement |
232
+ | the recipient's wallet provider | that a preimage it releases means the money arrived | mint an invoice for a different account on the same domain |
233
+ | the recipient | that the sum they asked for is the sum they are owed | nothing here checks this at all |
155
234
 
156
- `Rail` is `(order: Order) => Promise<Leg>`. Everything that differs between rails
157
- is bound once when the rail is built, so the only thing passed per sale is which
158
- sale it is: a reference, an amount in minor units and a currency. A `Leg` reads
159
- the same whichever rail made it, which is what lets `firstToSettle` take a mixed
160
- list without being told what is in it.
235
+ The gateway also sees your address list and your amount. It cannot invent a
236
+ settlement because the preimage comes from the recipient's own server, and the
237
+ provider cannot mint for another account because the description hash pins an
238
+ invoice to one user's metadata under LUD-06. A recipient inflating a total is
239
+ outside what any of it proves.
161
240
 
162
- The gateway is already indifferent to all of this. It holds a payment hash, polls
163
- a verify URL and reports what came back, so a rail is an SDK-side arrangement of
164
- calls the gateway already answers, not a plugin it has to load.
241
+ Four sharp edges, worth reading before you build:
165
242
 
166
- **Pricing a fiat order** - [`src/price.ts`](src/price.ts), [`src/currency.ts`](src/currency.ts)
243
+ - **A colluding custodian defeats all of it.** If the recipient's wallet provider
244
+ and the gateway are the same party, then whoever holds the money also serves the
245
+ metadata and answers the verify requests. Every check passes. This protects a
246
+ payer against the operator, never against the recipient's own custodian.
247
+ - **The two proof fetches vet the first hop and no further.** `proveOrigin` and
248
+ `proveSettlement` use the runtime's default redirect handling, so a public https
249
+ host answering `302` to a private address is followed there. `invoiceFrom` is not
250
+ like this: it resolves through the outbound guard, which sets `redirect: "manual"`
251
+ and re-vets every hop. Keep egress control outside this package if that matters.
252
+ - **A payment read cold is only as pinned as its creation.** `getPayment` checks
253
+ the preimage against the `paymentHash` in the same record, and it was
254
+ `proveOrigin` at creation, against the request you wrote, that tied that hash to
255
+ an invoice the recipient issued. Store the request alongside the payment id, or a
256
+ cold read is checking the gateway's numbers against each other and nothing more.
257
+ - **Availability is not provable, and an address is not a person.** Every check
258
+ here is about an invoice you were given, none about one you were refused, and
259
+ proving an invoice belongs to an address never proves the address belongs to
260
+ whoever you think it does.
261
+
262
+ `isProvablyPaid` is the one to be careful with: it asks only whether the gateway's
263
+ own report holds together, so a gateway that generates a preimage, hashes it and
264
+ builds an invoice around that hash passes it. If a payment matters, ask the
265
+ recipient with `proveSettlement`. The full argument, including the five origin
266
+ checks and their failure codes, is in
267
+ [docs/proving-a-payment.md](../docs/proving-a-payment.md).
268
+
269
+ ## What you call
270
+
271
+ Signatures and the caveats on each export are in the TSDoc on the export itself, so
272
+ your editor has them and this table does not repeat them.
167
273
 
168
274
  | Export | What it does |
169
275
  |---|---|
170
- | `medianOf(tickers?, options?)` | ask several venues, take the middle, refuse the lot when they disagree too much |
171
- | `coinbase`, `kraken`, `bitstamp`, `coinmate` | the four MiCA authorised venues, every one replaceable |
172
- | `msatFor(amountMinor, priceMinorPerBtc, options?)` | exact BigInt arithmetic from fiat to millisatoshi |
173
- | `minorUnitsOf(currency)`, `minorScaleOf` | what ISO 4217 says the currency's minor unit is |
174
-
175
- **QR codes** - [`src/qr.ts`](src/qr.ts)
176
-
177
- Every renderer returns a string, so they work on a server, in a worker and in a
178
- browser with no canvas involved. `qrToSvg` takes any rail's `Leg.qr` and needs to
179
- know nothing else, because a rail states its own payload. Below it sit the
180
- format-named ones for calling directly: `invoiceToSvg` takes an invoice or a
181
- lightning address, `lnurlToSvg` takes your own endpoint url, `spdToSvg` takes a
182
- Short Payment Descriptor. Each has a `…ToDataUrl` twin for an `<img>` `src`. A
183
- BOLT12 offer is not handled, because this gateway never returns one.
184
-
185
- ```ts
186
- import { invoiceToSvg, lnurlToSvg } from "thunder-bridge";
187
-
188
- const toPay = invoiceToSvg(payment.bolt11, { size: 320, color: "#1a1a2e" });
189
- const tipJar = lnurlToSvg("https://thunder-bridge.agora.gripe/.well-known/lnurlp/21sats");
190
- ```
191
-
192
- That second url is live and built with this library, a fixed 21 sat tip served by
193
- `lnurlPayEndpoint`, so the QR it returns is one you can scan to see the whole flow
194
- end to end.
195
-
196
- **Minting your own invoice** - [`src/rail.ts`](src/rail.ts): `invoiceFrom`, from
197
- `thunder-bridge/server`. A gateway that does not mint is one that never sees an
198
- address or an amount, so this is how a client gets a provable invoice itself and hands
199
- the gateway only a hash, a url and an expiry. Server side, because it resolves
200
- hostnames and refuses a private one.
201
-
202
- **Webhooks** - [`src/webhook.ts`](src/webhook.ts): `parseSettlementRequest`,
203
- `parseSettlement`, `isProvablySettled`, `parseWebhookRequest`, `parseWebhook`,
204
- `parseWatchedWebhookRequest`, `parseWatchedWebhook`, `verifyWebhookSignature`,
205
- `answerWebhookChallengeRequest`, `answerWebhookChallenge`. See
206
- [Webhooks](#webhooks).
207
-
208
- **Errors** - [`src/errors.ts`](src/errors.ts): `ProblemError`,
209
- `NoWalletAvailableError`, `GatewayCheatError`, `UnverifiedRecipientError`,
210
- `IdempotencyConflictError`, `isProblemType`. See [Errors](#errors).
211
-
212
- ## The proof
213
-
214
- `proveOrigin(payment, request)` runs five checks in order and stops at the first
215
- failure. The first two need no network. The rest go to the recipient's own domain,
216
- never back to the gateway, which is the point: a gateway cannot witness its own
217
- honesty.
218
-
219
- | # | Check | Rules out | Fails with |
220
- |---|---|---|---|
221
- | 1 | the chosen address is one you listed, compared case-insensitively | the gateway paying an address you never named, its own included | `address_not_requested` |
222
- | 2 | the invoice decodes to the amount you asked for and the payment hash the record reports | being billed more than you asked, or a record describing one invoice while carrying another | `amount_mismatch`, `hash_mismatch` |
223
- | 3 | the invoice's description hash equals the sha256 of the `metadata` that address serves, under LUD-06 | an invoice minted by a different account on the same custodial domain | `description_hash_mismatch` |
224
- | 4 | `verifyUrl` shares an origin with the `callback` that endpoint publishes | a settlement proof pointed anywhere the gateway controls | `verify_url_foreign` |
225
- | 5 | a GET to `verifyUrl` echoes `pr`, and it equals `bolt11` byte for byte | everything the earlier checks could still miss, because the answer now comes from the recipient | `invoice_not_issued` |
226
-
227
- Check 1 also builds the url the rest of the chain uses: your `user@domain` becomes
228
- `https://domain/.well-known/lnurlp/user` under LUD-16, with the domain lowercased
229
- and the local part left exactly as you wrote it. The gateway's spelling is used to
230
- find the match and never to build the url, so it cannot aim the proof at a
231
- different account on a provider that treats the local part as case-sensitive.
232
-
233
- ### Origin is not settlement
234
-
235
- Those five checks are about an invoice. They prove that what you are putting in
236
- front of a payer is the recipient's own invoice for the right amount. They say
237
- nothing about whether anybody paid it, and the two answers to that are not the
238
- same answer.
239
-
240
- `isProvablyPaid` asks whether the gateway's report contradicts itself: a `paid`
241
- status, a preimage, and a `bolt11` whose payment hash that preimage opens. All
242
- three values arrive from the gateway in one message, so this is internal
243
- consistency and nothing more. A gateway that generates a preimage, hashes it and
244
- builds an invoice around that hash passes it. It catches breakage and
245
- carelessness, not an operator who means it.
246
-
247
- `proveSettlement` asks the recipient. It re-runs the origin proof, which is what
248
- ties `verifyUrl` to the recipient's own callback origin, then reads that url.
249
- `null` means the recipient's own server is not claiming the money arrived,
250
- whatever the gateway says.
251
-
252
- Use `isProvablyPaid` to throw out a record that is obviously wrong. Use
253
- `proveSettlement` before you part with anything.
254
-
255
- ### The host guard
256
-
257
- Every outbound url in the chain must be public https. The guard refuses loopback,
258
- link-local, the RFC 1918 ranges, carrier-grade NAT, unique local addresses, and
259
- IPv4-mapped IPv6 unwrapping into any of those. It also refuses a host with no dot
260
- such as `nas`, the trailing-dot `localhost.`, and anything whose last label is
261
- `local`, `internal`, `lan`, `arpa`, `test` or `invalid`.
262
-
263
- It vets the first hop only. See below.
264
-
265
- ### Which transfer counts as paying
266
-
267
- `bankVerifyEndpoint` calls a credit a settlement when the amount and the currency
268
- match exactly and the reference appears anywhere in what the payer wrote,
269
- case-insensitively. With `fioStatement` "what the payer wrote" is four Fio columns
270
- joined: the variable symbol, the user identification, the message for the recipient
271
- and the payer's own reference. So a bank that prefixes, appends, or moves the text
272
- between those fields still settles.
273
-
274
- Two shapes do not settle, and both leave the payment `pending` while the money is
275
- already in the account:
276
-
277
- - **A shortened reference.** The match asks whether the reference is inside what the
278
- bank forwarded, not the other way round, so a bank that truncates it never matches.
279
- - **A payer whose bank forwards nothing but a numeric variable symbol.** The
280
- reference is alphanumeric and cannot travel in a numeric field, and the match does
281
- not read `X-VS` as an alternative.
282
-
283
- Neither has been seen with Fio, which forwards the message untouched. Check it
284
- against the banks your payers actually use before you promise them a rail.
285
-
286
- ## Making the gateway poll nobody but you
287
-
288
- By default a Lightning watch hands the gateway the wallet's own verify URL, so the
289
- gateway polls `blink.sv` or `coinos.io` directly and its logs, its ledger and its
290
- peers all carry that domain. If you would rather it never touched a third party and
291
- never learned which provider your recipient uses, put your own endpoint in between.
292
-
293
- ```ts
294
- import { blindLightningRail, lightningVerifyEndpoint } from "thunder-bridge/server";
295
-
296
- app.get("/verify/lightning", (context) =>
297
- lightningVerifyEndpoint({ secret: RELAY_SECRET, pollEverySecs: 5 })(context.req.raw),
298
- );
299
-
300
- const rail = blindLightningRail({
301
- gateway,
302
- lnAddresses: ["you@blink.sv"],
303
- amountMsat: (order) => order.amountMinor * 40,
304
- relayVerifyThrough: { endpoint: "https://shop.example/verify/lightning", secret: RELAY_SECRET },
305
- });
306
- ```
307
-
308
- The wallet's URL is sealed into the query with your secret, so what the gateway
309
- stores and replicates is a blob it cannot read. It polls you, you ask the wallet,
310
- and the preimage still comes from the recipient's own server and still has to hash
311
- to the payment hash, so standing in the middle buys privacy and pacing without
312
- making you something anyone has to trust. A wallet you cannot reach answers `502`
313
- rather than "not settled", because those are different claims.
314
-
315
- Both rails then run through endpoints of yours, on a pace you set, and the gateway
316
- is only ever talking to servers that asked to be talked to. It costs you a service
317
- that has to stay up: a browser-only integration cannot do this, and should keep
318
- letting the gateway poll the wallet.
319
-
320
- ### A wallet with no LUD-21 address at all
321
-
322
- `nwcRail` is the same arrangement with the far side swapped. Your wallet answers
323
- over [NIP-47](https://github.com/nostr-protocol/nips/blob/master/47.md) instead of
324
- over an address, so a recipient whose provider publishes no `verify` - or publishes
325
- one with no preimage, which is worse - is watchable anyway.
326
-
327
- ```ts
328
- import { nwcConnection, nwcRail, nwcVerifyEndpoint } from "thunder-bridge/server";
329
-
330
- const connection = nwcConnection(process.env.NWC_URI);
331
-
332
- app.get("/verify/nwc", (context) =>
333
- nwcVerifyEndpoint({ connection, secret: NWC_SECRET })(context.req.raw),
334
- );
335
-
336
- const rail = nwcRail({
337
- gateway,
338
- connection,
339
- amountMsat: (order) => order.amountMinor * 40,
340
- verifyThrough: { endpoint: "https://shop.example/verify/nwc", secret: NWC_SECRET },
341
- });
342
- ```
343
-
344
- `make_invoice` mints it, `lookup_invoice` reads the preimage back, and the payment
345
- hash is sealed into the query for the reason the wallet's URL is sealed above: it
346
- is what stops a stranger driving your wallet through your own handler. Only the
347
- hash travels, so the gateway never holds the connection, the relay or the wallet
348
- key, and every answer is refused unless the wallet's own key signed it.
349
-
350
- Take the connection string scoped. ZEUS, Alby Hub and Blink all issue one per app
351
- with its own permissions and budget, and this needs `make_invoice` and
352
- `lookup_invoice` and nothing else - never `pay_invoice`.
353
-
354
- ### How often the gateway asks
355
-
356
- Your endpoint decides, not the gateway. `bankVerifyEndpoint` answers with
357
- `Cache-Control: max-age=30`, and the gateway uses that as the interval for every
358
- payment on your host. Set `pollEverySecs` to whatever your bank's own refresh makes
359
- sensible: reading a statement that moves once an hour every five seconds only burns
360
- your rate limit.
361
-
362
- How long one answer may take is the other half of the pacing. The gateway abandons a
363
- poll after 15 seconds, and `askTimeoutMs` is one deadline over the whole connection
364
- rather than one per relay, so a connection listing four relays still answers inside
365
- that window.
366
-
367
- The gateway also asks the URL once, before it accepts the watch, and refuses with
368
- `424` if it does not answer this shape. So deploy the endpoint first and register
369
- second. That is what stops anyone pointing a gateway at a server that never asked to
370
- be polled for thirty days.
371
-
372
- ## Paying through an operator who fronts the liquidity
373
-
374
- A recipient with no inbound liquidity cannot be paid at all. An operator with a node
375
- can stand in the middle without holding anything: it takes the recipient's own
376
- invoice, mints a **hold invoice on the same payment hash** for the amount plus a fee,
377
- and can settle its own only by revealing the preimage it learned from paying the
378
- recipient. Claiming and delivering are one act, so there is no moment where it keeps
379
- the money and walks away.
380
-
381
- This SDK does not wrap. `proveWrapped` checks a wrap somebody else offers, and the
382
- NIP-47 primitives an operator would build one from, `nwcHoldInvoice` and `nwcPay`,
383
- are on `thunder-bridge/server` with nothing here driving them.
384
-
385
- ```ts
386
- import { invoiceFrom } from "thunder-bridge/server";
387
- import { proveWrapped, wrapFeeCeiling } from "thunder-bridge";
388
-
389
- const real = await invoiceFrom(["you@blink.sv"], 21_000_000);
390
- const wrapped = await myOperator.wrap(real.bolt11);
391
-
392
- proveWrapped(wrapped, real.bolt11);
393
- ```
276
+ | `new ThunderBridge(baseUrl, options?)` | a gateway handle. `{ secret }` signs the calls that create something, so a payment you create comes back to you and nobody else, while the reads and `webhookKey` stay unsigned, `{ token }` makes the instance yours, `{ verify: false }` turns off the automatic proof |
277
+ | `gateway.createPayment(params, options?)` | mint an invoice on the first address that can prove one, and prove it before returning |
278
+ | `gateway.watchPayment(params)` | hand over an invoice you obtained yourself, so the gateway never learns the address or the amount |
279
+ | `gateway.waitForPayment(id, options?)` | follow one payment over WebSocket until it is paid or expired. `waitForWatched` is the same for a watched one |
280
+ | `gateway.followTrigger(secret, options)` | stream every payment carrying one trigger, reconnecting on its own. The secret is the only thing guarding that stream and nothing rate limits a guess, so it is refused under 16 characters |
281
+ | `proveSettlement(payment, request)` | ask the recipient whether it settled, returns the preimage or `null` |
282
+ | `invoiceFrom(lnAddresses, amountMsat)` | get a provable invoice yourself, from `thunder-bridge/server` |
283
+ | `lnurlPayEndpoint(config)` | a whole LNURL-pay endpoint as one Fetch handler, so a static QR points at your own domain. From `thunder-bridge/server` |
284
+ | `invoiceToSvg(bolt11, options?)` | a QR as a string, no canvas involved. `lnurlToSvg` and `spdToSvg` are the siblings, each with a `…ToDataUrl` twin |
285
+
286
+ The rest of the surface, by job:
287
+
288
+ - **more gateway calls:** `Gateways`, `createQuote`, `getPayment`, `getWatched`,
289
+ `listPayments`, `firstToSettle`, `nameFor`, `createSocketTicket`, `webhookKey`,
290
+ `isPrivate`
291
+ - **proving:** `proveOrigin`, `isProvablyPaid`, `preimageMatchesHash`,
292
+ `decodeInvoice`, `proveWrapped`, `wrapFeeCeiling`
293
+ - **serving your own endpoints:** `lightningVerifyEndpoint`, `bankVerifyEndpoint`,
294
+ `nwcVerifyEndpoint`, `watchTicketEndpoint`, `publicWatchTicketEndpoint`,
295
+ `relayedVerifyUrl`, `seal`, `unseal`, `toLnurl`
296
+ - **one shape per payment method:** `lightningRail`, `blindLightningRail`,
297
+ `bankRail`, `nwcRail`, `bankTransfer`, `fioStatement`
298
+ - **pricing a fiat order:** `medianOf`, `msatFor`, `coinbase`, `kraken`, `bitstamp`,
299
+ `coinmate`, `minorUnitsOf`, `minorScaleOf`
300
+ - **webhooks:** `parseSettlementRequest`, `answerWebhookChallengeRequest`,
301
+ `isProvablySettled`, `verifyWebhookSignature`, and the `parse*` variants for each
302
+ shape
303
+ - **errors:** `ProblemError`, `GatewayCheatError`, `NoWalletAvailableError`,
304
+ `UnverifiedRecipientError`, `IdempotencyConflictError`, `isProblemType`
394
305
 
395
- `proveWrapped` compares two invoices and asks nobody anything, so it runs in a
396
- browser. It refuses a wrap on another hash, one that cannot cover what the recipient
397
- asked, one charging over the allowance, and one that outlives the invoice it has to
398
- forward to.
399
-
400
- The allowance is the **client's ceiling**, not a fee the operator names per payment,
401
- so it sits deliberately above any list price: `1%` by default with a floor of one
402
- satoshi. An operator running this charges `0.75%`, which leaves room for a wrap a
403
- shade over list to still go through. Set `proportion` under an operator's price and
404
- you refuse that operator, which is the point of it being yours.
405
- `wrapFeeCeiling(amountMsat, allowance)` is the same number if you want to show it.
406
-
407
- There is no settlement check to add. Both invoices carry one payment hash, so the
408
- preimage that settles the wrap is the one the recipient released, and
409
- `proveSettlement` already reads it from the recipient's own server. The gateway needs
410
- no change either: it watches that hash and polls the recipient's verify URL, and a
411
- wrap is invisible to it.
412
-
413
- What this does not cover: an operator that accepts the payment and stalls until the
414
- HTLC times out. Your money comes back, and it was locked meanwhile.
415
-
416
- ## What is still trusted
417
-
418
- - **The gateway chooses which of your addresses gets paid.** Nothing here can
419
- tell a genuine failure of the first from a preference for the third. What it
420
- cannot do is pick an address off your list.
421
- - **The gateway can refuse you.** Availability is not provable. Every check here
422
- is about an invoice you were given, none about one you were not.
423
- - **The gateway sees your request.** The address list, the amount and the webhook
424
- secret pass through it, because it has to make the calls. Treat the secret as
425
- shared with it and the address list as public.
426
- - **Everything it says about a settlement, until you ask the recipient.**
427
- `isProvablyPaid` only asks whether that account holds together. If a payment
428
- matters, ask.
429
- - **The host guard vets the first hop and no further.** Both fetches use the
430
- runtime's default redirect handling, so a public https host answering with a 302
431
- to a private address is followed there. Keep egress control outside this
432
- package if that matters.
433
- - **A colluding custodian defeats all of it.** If the recipient's wallet provider
434
- and the gateway are the same party, then the party holding the money is also the
435
- one serving the metadata and answering the verify requests. Every check would
436
- pass. This protects a payer against the gateway, not against the recipient's own
437
- custodian.
438
- - **TLS and DNS for the recipient's domain**, and an address is not a person. This
439
- proves an invoice belongs to an address, never that the address belongs to
440
- whoever you think.
441
- - **A payment read cold is only as pinned as its creation.** `getPayment` checks
442
- the preimage against the `paymentHash` in the same record. It was `proveOrigin`
443
- at creation, against the request you wrote, that tied that hash to an invoice
444
- the recipient issued. So store the request alongside the payment id, or you are
445
- checking the gateway's numbers against each other and nothing more.
306
+ What your service answers once those handlers are mounted is written out in
307
+ [`openapi.yaml`](openapi.yaml), shipped with this package.
446
308
 
447
309
  ## Errors
448
310
 
449
311
  Every failure from the gateway is an RFC 9457 problem document. Branch on `type`,
450
- never on prose. `error.status` is what the transport carried, and a document
451
- naming a different status in its own body does not override it.
312
+ never on prose. `error.status` is what the transport carried, and a document naming
313
+ a different status in its own body does not override it.
314
+
315
+ Every `type` below is prefixed `urn:problem-type:thunder-bridge:`, and the four the
316
+ SDK exports as constants are named in the last column.
452
317
 
453
- | `type` | Status | Class |
318
+ | `type` | Status | What it is |
454
319
  |---|---|---|
455
- | `…:invalid-request` | 400 | `ProblemError`, `detail` names the field |
456
- | `…:no-wallet-available` | 400, 422, 502 | `NoWalletAvailableError`, `wallets` says why each failed |
457
- | `about:blank` | 404 | none, `getPayment` returns `null` |
458
- | `about:blank` | 503 | `ProblemError`, the instance is at capacity |
459
- | `about:blank` | 500 | `ProblemError` |
460
-
461
- The status on `no-wallet-available` follows the worst wallet, so a retry is never
462
- advised in vain: 502 if any was merely unreachable, else 422 if any refused
463
- permanently, else 400. Each entry in `wallets` is a `WalletFailure` in the order
464
- the addresses were tried, and every reason is enumerated in
465
- [`openapi.yaml`](../openapi.yaml).
320
+ | `invalid-request` | 400, or 413 for a body over the size ceiling | `detail` names the field |
321
+ | `no-wallet-available` | 502, else 422, else 400, following the worst wallet | `NoWalletAvailableError`, `wallets` says why each failed. `NO_WALLET_AVAILABLE` |
322
+ | `request-in-flight` | 409 | a request with this `Idempotency-Key` is still running. `REQUEST_IN_FLIGHT` |
323
+ | `idempotency-key-reused` | 409 | that key was used for a different request. `IdempotencyConflictError`, `IDEMPOTENCY_KEY_REUSED` |
324
+ | `payment-already-watched` | 409 | that payment hash is already watched here. `PAYMENT_ALREADY_WATCHED` |
325
+ | `caller-unknown` | 403 | the instance keeps a list of callers and your key is not on it |
326
+ | `verify-host-refused` | 403 | this instance will not mint, because minting is off or `VERIFY_HOSTS` pins it to a list. Resolve the address yourself and use `watchPayment`. A verify URL that is not public https is `invalid-request` instead |
327
+ | `verify-unconfirmed` | 424 | the URL did not answer the LUD-21 shape |
328
+ | `verify-unconsented` | 424 | the URL did not echo the challenge nonce |
329
+ | `webhook-unconfirmed` | 424 | the webhook URL did not answer its challenge |
330
+ | `too-many-pending` | 429, with `ratelimit-limit` and `ratelimit-remaining` set | the caller is over its share of the instance's `MAX_PENDING` |
331
+
332
+ The rest carry `about:blank` as their type, which the gateway seeds into every
333
+ problem body: `401` when the bearer token does not match, `404` both for an id this
334
+ gateway never heard of and for one it knows that belongs to a different caller key,
335
+ so a `403` can never confirm an id exists, `410` when you replay an
336
+ `Idempotency-Key` whose payment has since been pruned, `500`, and `503` while the
337
+ instance is draining or its own health check reads stalled. On a `404` `getPayment`
338
+ returns `null` rather than throwing.
466
339
 
467
340
  `GatewayCheatError` is different in kind. It reports a gateway that demonstrably
468
- misbehaved, and `code` names the check that caught it: the five in the table above
469
- plus `preimage_mismatch`, a reported `paid` whose preimage does not open the
470
- invoice.
471
-
472
- `UnverifiedRecipientError` is deliberately neither. It means a check could not be
473
- run, because the recipient's server was down, timed out, answered something
474
- unreadable, or the browser was blocked by CORS. Not an accusation, and not a clean
475
- bill of health either. Decide what you want to do with an unproven invoice, and
476
- decide it explicitly.
341
+ misbehaved, and `code` names the check that caught it. `UnverifiedRecipientError` is
342
+ neither an accusation nor a clean bill of health: it means a check could not be run
343
+ at all, because the recipient's server was down, timed out, answered something
344
+ unreadable, or the browser was blocked by CORS. Decide what you want to do with an
345
+ unproven invoice, and decide it explicitly.
477
346
 
478
347
  ```ts
479
348
  import {
480
349
  GatewayCheatError,
481
350
  NoWalletAvailableError,
482
351
  ProblemError,
352
+ ThunderBridge,
483
353
  UnverifiedRecipientError,
484
354
  } from "thunder-bridge";
485
355
 
356
+ declare const gateway: ThunderBridge;
357
+ declare function report(line: string): void;
358
+
486
359
  try {
487
- const payment = await gateway.createPayment({ lnAddresses: wallets, amountMsat: 21_000 });
488
- show(payment);
360
+ await gateway.createPayment({ lnAddresses: ["iamfatik@blink.sv"], amountMsat: 21_000 });
489
361
  } catch (error) {
490
362
  if (error instanceof GatewayCheatError) {
491
363
  report(`the gateway cheated: ${error.code} on payment ${error.paymentId}`);
492
364
  } else if (error instanceof UnverifiedRecipientError) {
493
365
  report(`could not reach ${error.lnAddress} to check the invoice`);
494
366
  } else if (error instanceof NoWalletAvailableError) {
495
- for (const wallet of error.wallets) report(`${wallet.address}: ${wallet.reason}`);
367
+ for (const wallet of error.wallets) {
368
+ report(`${wallet.address}: ${wallet.reason}`);
369
+ }
496
370
  } else if (error instanceof ProblemError) {
497
371
  report(`${error.status} ${error.title}`);
498
372
  } else {
@@ -507,127 +381,63 @@ Pass `webhookUrl` when you create a payment, or on any rail. There is no webhook
507
381
  secret: a gateway holds nothing of yours, and sending one is refused rather than
508
382
  ignored. Every delivery is signed `ed25519=<signature>` with the key the gateway
509
383
  publishes at `/webhook-key`, over `<x-timestamp>.<raw body>` rather than the body
510
- alone, so a captured delivery cannot be replayed at you later.
511
-
512
- The body is a `Settlement`: the id, the status, the payment hash, the preimage and
513
- the time. Enough to act on and to check, and no more, so a retry is the same size
514
- whatever you put in your own record. Read `sealed` back by id when you want it.
515
- Retries widen until the payment itself runs out, never sooner than an hour. An
516
- invoice that expires fires nothing.
517
-
518
- Delivery is at-least-once, so deduplicate on `id`.
384
+ alone, so a captured delivery cannot be replayed at you later. Delivery is
385
+ at-least-once, so deduplicate on `id`.
519
386
 
520
387
  Your handler answers one challenge before any of that. The gateway POSTs
521
- `{"type":"webhook-challenge","nonce":"..."}` to the URL while the create is still open
522
- and refuses the payment with a 424 unless the nonce comes back, so the endpoint has to
523
- be deployed before you register it. `answerWebhookChallengeRequest` verifies that
524
- challenge and hands you the response to return, or `null` when the delivery was a real
525
- settlement, and it leaves the body unread either way.
388
+ `{"type":"webhook-challenge","nonce":"..."}` to the URL while the create is still
389
+ open, and refuses the payment with a `424` unless the nonce comes back, so deploy
390
+ the endpoint before you register it.
526
391
 
527
392
  ```ts
528
393
  import {
394
+ ThunderBridge,
529
395
  answerWebhookChallengeRequest,
530
396
  isProvablySettled,
531
397
  parseSettlementRequest,
532
398
  } from "thunder-bridge";
533
399
 
534
- const signs = { publicKey: await gateway.webhookKey() };
535
-
536
- app.post("/hooks/paid", async (context) => {
537
- const challenge = await answerWebhookChallengeRequest(context.req.raw, signs);
538
- if (challenge) return challenge;
539
-
540
- const settled = await parseSettlementRequest(context.req.raw, signs);
541
- if (settled === null) return context.text("bad signature", 401);
542
- if (!isProvablySettled(settled)) return context.text("no preimage that hashes to it", 402);
543
-
544
- await fulfil(settled.id, settled.preimage);
545
- return context.text("ok");
546
- });
547
- ```
400
+ declare function fulfil(paymentId: string, preimage: string): Promise<void>;
548
401
 
549
- `isProvablySettled` answers the only question that matters about a delivery: it says
550
- paid and it carries a preimage that hashes to the payment hash the same body names.
551
- Ask the recipient's own server with `proveSettlement` when the payment is one you
552
- minted through the gateway and you want the proof to come from somewhere other than
553
- the delivery.
554
-
555
- ### Every rail sends the same body
556
-
557
- `bankRail` and `blindLightningRail` used to need a parser of their own, because their
558
- webhook carried no address, no amount and no invoice while a minted one did. A
559
- delivery is a `Settlement` on every rail now, so `parseSettlementRequest` is the only
560
- one to reach for. `parseWatchedWebhookRequest` is still there for reading the shape a
561
- socket frame and `getWatched` hand back, which is a payment rather than a delivery.
562
-
563
- Give each rail its own path, as above, and neither endpoint has to guess which body
564
- it was handed. Both events also carry `kind`, `"minted"` or `"watched"`, so a single
565
- path serving a trigger that both rails settle on can branch on the field instead of
566
- on which fields are missing.
567
-
568
- ### The gateway holds nothing of yours
569
-
570
- There is nothing to hand it. A delivery is signed with the gateway's own key,
571
- `x-signature: ed25519=<signature>` over `<x-timestamp>.<raw body>`. Fetch the public
572
- half once and keep it.
573
-
574
- ```ts
575
- const signs = { publicKey: await gateway.webhookKey() };
576
-
577
- app.post("/hooks/paid", async (context) => {
578
- const challenge = await answerWebhookChallengeRequest(context.req.raw, signs);
579
- if (challenge) return challenge;
580
-
581
- const settled = await parseSettlementRequest(context.req.raw, signs);
582
- if (settled === null) return context.text("bad signature", 401);
583
- ...
584
- });
585
- ```
402
+ const gateway = new ThunderBridge("https://public.thunder-bridge.agora.gripe");
403
+ const signer = { publicKey: await gateway.webhookKey() };
586
404
 
587
- Answering echoes the nonce and nothing else, because there is nothing to sign it with.
588
- Holding the URL the gateway challenged is the whole proof.
405
+ export async function POST(request: Request): Promise<Response> {
406
+ const challenge = await answerWebhookChallengeRequest(request, signer);
407
+ if (challenge) {
408
+ return challenge;
409
+ }
589
410
 
590
- The key is derived from the gateway's `CLUSTER_KEY`, so every instance in one cluster
591
- signs alike and an operator rotating that key changes this one too. A signature that
592
- stops verifying is therefore a reason to read `/webhook-key` again before it is a
593
- reason to distrust the gateway. A `sha256=` signature is refused outright: that scheme
594
- is gone.
411
+ const settled = await parseSettlementRequest(request, signer);
412
+ if (settled === null) {
413
+ return new Response("bad signature", { status: 401 });
414
+ }
595
415
 
596
- `parseSettlementRequest` refuses anything more than five minutes out of date,
597
- adjustable with `toleranceSecs`. The signature proves the delivery came from the
598
- gateway. It does not prove the payment happened, because the gateway holds the key
599
- that signs it either way. The proof is the preimage, checked by `isProvablySettled`
600
- against the hash in the same body, or `proveSettlement` against the recipient's own
601
- server when you want the answer from somewhere else entirely.
416
+ const preimage = isProvablySettled(settled) ? settled.preimage : null;
417
+ if (preimage === null) {
418
+ return new Response("no preimage that hashes to it", { status: 402 });
419
+ }
602
420
 
603
- For a framework that hands you the raw body and headers separately, use
604
- `parseWebhook`. The body must be the bytes as received, so mount a raw body parser
605
- on that route and not a JSON one.
421
+ await fulfil(settled.id, preimage);
606
422
 
607
- ```ts
608
- import express from "express";
609
- import { parseWebhook } from "thunder-bridge";
610
-
611
- const signs = { publicKey: await gateway.webhookKey() };
612
-
613
- app.post("/hooks/paid", express.raw({ type: "application/json" }), async (request, response) => {
614
- const payment = await parseWebhook(
615
- request.body,
616
- request.get("x-signature") ?? "",
617
- signs,
618
- request.get("x-timestamp") ?? "",
619
- );
620
- response.sendStatus(payment === null ? 401 : 200);
621
- });
423
+ return new Response("ok");
424
+ }
622
425
  ```
623
426
 
624
- ## Requirements
625
-
626
- Node 22 or newer. `waitForPayment` and `followTrigger` use the global `WebSocket`,
627
- which Node only exposes from 22 onwards, and there is no fallback and no optional
628
- dependency to install. Everything else works on any runtime with `fetch` and
629
- `crypto.subtle`, so an older Node can still create payments, quote them, verify
630
- them, poll `getPayment`, serve `lnurlPayEndpoint` and handle webhooks.
427
+ `isProvablySettled` answers the only question that matters about a delivery: it says
428
+ paid and it carries a preimage that hashes to the payment hash the same body names.
429
+ Ask the recipient's own server with `proveSettlement` when you want the proof to come
430
+ from somewhere other than the delivery.
431
+
432
+ ## More
433
+
434
+ - [docs/lud21-coverage.md](../docs/lud21-coverage.md) - which address domains
435
+ release a preimage, and how that was measured
436
+ - [docs/proving-a-payment.md](../docs/proving-a-payment.md) - the five origin checks
437
+ and their failure codes, what settlement means, making the gateway poll nobody but
438
+ you, the NWC rail, wrapped invoices, and webhooks in full
439
+ - [the gateway](../README.md) - one level up in this repository
440
+ - [examples](../examples) - a paywall on Deno Deploy, a trigger watcher, a bank rail
631
441
 
632
442
  ## Development
633
443
 
package/openapi.yaml CHANGED
@@ -2,7 +2,7 @@ openapi: 3.1.0
2
2
 
3
3
  info:
4
4
  title: thunder-bridge, the endpoint your own service serves
5
- version: 1.4.1
5
+ version: 1.4.2
6
6
  license:
7
7
  name: MIT
8
8
  identifier: MIT
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thunder-bridge",
3
- "version": "1.4.1",
3
+ "version": "1.4.2",
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",