thunder-bridge 1.4.1 → 1.5.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 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` from `thunder-bridge/nwc` 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,345 @@ one level up in [this repository](../README.md).
28
47
  npm install thunder-bridge
29
48
  ```
30
49
 
31
- ## Quick start
32
-
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.
50
+ Node 22 or newer, for anything that opens a socket: `requestPayment`, `settled`,
51
+ `firstSettled` and `follow` on the gateway side, and every NWC call on the wallet
52
+ side, since a nostr relay is a socket too. Node only exposes a global `WebSocket`
53
+ from 22 onwards and there is no fallback to install. The rest, which is every call
54
+ that is one or more `fetch` requests, runs on any runtime with `fetch` and
55
+ `crypto.subtle`.
36
56
 
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.
57
+ One import is the whole thing. The other four are for what a checkout page has no
58
+ reason to download.
42
59
 
43
- ```ts
44
- import { ThunderBridge, invoiceToSvg, type CreatePaymentParams } from "thunder-bridge";
45
-
46
- const gateway = new ThunderBridge("https://public.thunder-bridge.agora.gripe");
47
-
48
- const request: CreatePaymentParams = {
49
- lnAddresses: ["alice@coinos.io", "alice@getalby.com"],
50
- amountMsat: 21_000,
51
- };
60
+ | Import | What it is for |
61
+ |---|---|
62
+ | `thunder-bridge` | the gateway, the proofs, the errors and the amounts. Everything is reached through one instance |
63
+ | `thunder-bridge/qr` | a payload as an SVG or a data URL, for a page that draws a QR of its own |
64
+ | `thunder-bridge/price` | the exchange venues behind `fiat`, for pricing off your own book instead |
65
+ | `thunder-bridge/bank` | a bank statement reader, currently Fio |
66
+ | `thunder-bridge/nwc` | your own wallet over NIP-47, which carries the nostr crypto no browser wants |
52
67
 
53
- const payment = await gateway.createPayment(request);
68
+ `invoiceFrom` resolves lightning addresses and refuses a private host, so it needs
69
+ `node:dns` and will not run on Cloudflare Workers. Nothing else on the main import
70
+ does.
54
71
 
55
- const target = document.querySelector("#qr");
56
- if (target) target.innerHTML = invoiceToSvg(payment.bolt11);
57
- ```
72
+ ## Quick start
58
73
 
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.
74
+ A page can run the whole flow with no backend of its own. The url below is a shared
75
+ demo gateway that answers anyone and forgets everything on restart, so this snippet
76
+ runs as written. It is a base url rather than a page, so opening it in a browser
77
+ gives a `404` and [`/health`](https://public.thunder-bridge.agora.gripe/health) is
78
+ what tells you it is up.
61
79
 
62
80
  ```ts
63
- import { proveSettlement } from "thunder-bridge";
81
+ import { sats, ThunderBridge } from "thunder-bridge";
82
+
83
+ const gateway = new ThunderBridge("https://public.thunder-bridge.agora.gripe");
64
84
 
65
- const settled = await gateway.waitForPayment(payment.id, {
85
+ const asked = await gateway.requestPayment({
86
+ paidTo: ["iamfatik@blink.sv", "iamfatik@coinos.io"],
87
+ amount: sats(21),
66
88
  signal: AbortSignal.timeout(600_000),
67
89
  });
68
90
 
69
- if (settled.status === "paid") {
70
- const preimage = await proveSettlement(settled, request);
71
- if (preimage !== null) fulfil(payment.id);
91
+ const target = document.querySelector("#qr");
92
+ if (target !== null) {
93
+ target.innerHTML = asked.qr;
72
94
  }
73
- ```
74
-
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
79
-
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.
83
95
 
84
- **The gateway** - [`src/client.ts`](src/client.ts)
96
+ await asked.paid();
85
97
 
86
- | Export | What it does |
87
- |---|---|
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 |
107
- |---|---|
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 |
98
+ const preimage = await asked.prove();
99
+ ```
113
100
 
114
- **Serving your own endpoint** - [`src/trigger.ts`](src/trigger.ts), [`src/bank.ts`](src/bank.ts), [`src/fio.ts`](src/fio.ts)
101
+ Two names and one call. `requestPayment` mints the invoice on the first address that can
102
+ prove one, checks that invoice against the recipient's own domain before returning,
103
+ and draws the QR.
115
104
 
116
- | Export | What it does |
117
- |---|---|
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.
105
+ Then there are two different questions and both are worth asking. `paid` tells you
106
+ what the gateway says, and it is the fast one. `prove` asks the recipient's own
107
+ server, and only that is evidence the money arrived.
108
+ [docs/proving-a-payment.md](../docs/proving-a-payment.md) is the whole argument for
109
+ why.
138
110
 
139
- What your service answers once those handlers are mounted is written out in
140
- [`openapi.yaml`](openapi.yaml), shipped with this package.
111
+ An amount is `sats(21)`, `msat(21_000)` or `fiat("4.99", "USD")`, and a bare number
112
+ does not compile: the type is branded, so `21` cannot pass for a price and quietly
113
+ mean twenty-one thousandths of a satoshi. A fiat price is converted when the invoice
114
+ is minted, off the median of four MiCA authorised venues unless you pass your own,
115
+ and a decimal string is read digit by digit rather than through a float. Every
116
+ refusal is an `AmountError` carrying a `code`, and `AmountError.is(error)` is how
117
+ you recognise one: each entry point bundles its own copy of the class, so
118
+ `instanceof` holds within one import and that static holds across all of them.
141
119
 
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.
120
+ ## How each payment method gets verified
146
121
 
147
- **One shape for every payment method** - [`src/rail.ts`](src/rail.ts)
122
+ Every rail ends the same way, with a preimage that has to hash to the payment hash
123
+ the gateway was given. What differs is who obtains the invoice, who is asked for the
124
+ preimage, and which side does the checking.
148
125
 
149
- | Export | What it does |
126
+ | `rails.lightning` | the gateway asks, at the recipient's LNURL callback |
150
127
  |---|---|
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` |
155
-
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.
161
-
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.
165
-
166
- **Pricing a fiat order** - [`src/price.ts`](src/price.ts), [`src/currency.ts`](src/currency.ts)
167
-
168
- | Export | What it does |
128
+ | 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 |
129
+ | the invoice is checked by | you, `proveOrigin` runs five checks against the recipient's own domain |
130
+ | the gateway probes first | nothing, it resolved the address itself |
131
+ | the gateway polls | the wallet, directly |
132
+ | `settled` comes from | the wallet releasing its preimage |
133
+ | the pace is set by | the wallet, when it sends `Cache-Control: max-age`. When it sends none the gateway's own schedule decides |
134
+
135
+ | `rails.blindLightning` | you ask, with `invoiceFrom` on your server |
169
136
  |---|---|
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.
137
+ | the gateway is told | a hash, an expiry and your URL, with the wallet's sealed inside |
138
+ | the invoice is checked by | nobody needs to, you resolved the address yourself |
139
+ | the gateway probes first | `speaksVerify`: a GET on the URL, then a signed POST nonce it must echo |
140
+ | the gateway polls | your `serve.verify` endpoint, once `relayThrough` is set. Leave it off and the gateway polls the wallet directly, as on the minted rail |
141
+ | `settled` comes from | your endpoint, which unseals, asks the wallet and relays the answer |
142
+ | the pace is set by | you, `pollEverySecs` |
143
+
144
+ | `nwcRail` | your own wallet mints it, over NIP-47 `make_invoice` |
145
+ |---|---|
146
+ | the gateway is told | a hash and your URL, with the hash sealed inside |
147
+ | the invoice is checked by | nobody, it is your wallet |
148
+ | the gateway probes first | the same GET and signed nonce |
149
+ | the gateway polls | your `nwcVerifyEndpoint` |
150
+ | `settled` comes from | `lookup_invoice`, refused unless the wallet's own key signed it |
151
+ | the pace is set by | you, `pollEverySecs` |
152
+
153
+ | `rails.bank` | nobody, there is no invoice |
154
+ |---|---|
155
+ | the gateway is told | a hash and your URL, which names the amount and the reference |
156
+ | the invoice is checked by | nobody, there is no invoice to check |
157
+ | the gateway probes first | the same GET and signed nonce |
158
+ | the gateway polls | your `serve.bankVerify` endpoint |
159
+ | `settled` comes from | a `Statement` credit matching amount and currency exactly, with the reference anywhere in the payer's text |
160
+ | the pace is set by | you, `pollEverySecs` |
161
+
162
+ Two things are worth reading off those blocks rather than inferring.
163
+
164
+ **The checking side flips.** On the minted path the gateway resolved the address, so
165
+ it runs no verify probe and no verify challenge, and `proveOrigin` on your side is
166
+ the whole defence. A `webhookUrl` is challenged on both paths. On every watched path the gateway resolved nothing, so it probes the URL
167
+ and challenges it with a nonce before accepting the watch, refusing with `424` if
168
+ nothing answers. Deploy the endpoint before you register it. The challenge is on
169
+ unless the operator set `VERIFY_CHALLENGE=0`, which is also why a bare wallet
170
+ `verify` URL cannot be handed to `watch`: a wallet will not echo a nonce.
171
+
172
+ **What a preimage proves is the same on all four, and narrower than it looks:** that
173
+ the server holding the secret says the money arrived, made unforgeable by anyone
174
+ else. On the bank rail that secret is an HMAC you derive, which sounds weaker and is
175
+ not, because a wallet also minted the preimage it later releases. It rules out a
176
+ gateway inventing a settlement. It does not rule out a recipient lying about one, so
177
+ this protects a payer against the operator, not against the person being paid.
178
+
179
+ `proveWrapped` sits on a different axis. It compares two invoices on one payment
180
+ hash and asks nobody anything, so it says whether an operator's wrap is honest
181
+ without saying whether either invoice was paid.
182
+
183
+ ### What each one costs you
184
+
185
+ **`rails.lightning`**
186
+
187
+ - the gateway holds the address and the amount, so your order book is readable
188
+ from its own logs
189
+ - it polls the wallet directly, which puts the recipient's provider in its logs and
190
+ in front of its peers
191
+ - the wallet's `Cache-Control` sets the poll pace, so how fast a settlement is
192
+ noticed is not yours to decide
193
+
194
+ **`rails.blindLightning`**
195
+
196
+ - a service of your own that has to stay up, so a browser-only integration cannot
197
+ use this rail at all
198
+ - one long-lived sealing secret, which `seal` refuses under 32 characters, so
199
+ `openssl rand -hex 16` is the shortest thing that works
200
+ - your endpoint being down means the gateway cannot verify and the payment sits
201
+ `pending`
202
+ - a wallet you cannot reach answers `502`, so the gateway retries instead of
203
+ concluding the invoice went unpaid. The body still reads `settled: false`, and the
204
+ status is what separates "could not ask" from "asked, and no"
205
+
206
+ **`nwcRail`**
207
+
208
+ - an NWC connection to your own wallet, and the nostr relays behind it
209
+ - **scope the connection to `make_invoice` and `lookup_invoice`, never
210
+ `pay_invoice`.** It is a key that spends, and a leak with the wrong scope drains
211
+ the wallet
212
+ - relays unreachable means no verification
213
+
214
+ **`rails.bank`**
215
+
216
+ - the gateway has to be one of your own: the verify URL names the amount and the
217
+ reference, so whoever runs the gateway reads your order book from the watches
218
+ alone
219
+ - the secret is the entire proof. **Lose it and every past proof is gone**, because
220
+ each preimage is derived from it
221
+
222
+ **The bank rail has one silent failure worth testing before you promise anybody a
223
+ rail.** Two shapes leave a payment `pending` while the money is already in the
224
+ account: a bank that truncates the reference, since the match asks whether the
225
+ reference is inside what the bank forwarded rather than the other way round, and a
226
+ payer whose bank forwards nothing but a numeric variable symbol, since an
227
+ alphanumeric reference cannot travel in a numeric field and `X-VS` is not read as an
228
+ alternative. Neither has been seen with Fio, which forwards the message untouched.
229
+ Check it against the banks your payers actually use.
230
+
231
+ ## Who you still have to trust
232
+
233
+ The proof narrows the trust rather than removing it. Three parties are left, and
234
+ they are not equally constrained.
235
+
236
+ | | You trust it with | It cannot |
237
+ |---|---|---|
238
+ | 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 |
239
+ | 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 |
240
+ | the recipient | that the sum they asked for is the sum they are owed | nothing here checks this at all |
184
241
 
185
- ```ts
186
- import { invoiceToSvg, lnurlToSvg } from "thunder-bridge";
242
+ The gateway also sees your address list and your amount. It cannot invent a
243
+ settlement because the preimage comes from the recipient's own server, and the
244
+ provider cannot mint for another account because the description hash pins an
245
+ invoice to one user's metadata under LUD-06. A recipient inflating a total is
246
+ outside what any of it proves.
187
247
 
188
- const toPay = invoiceToSvg(payment.bolt11, { size: 320, color: "#1a1a2e" });
189
- const tipJar = lnurlToSvg("https://thunder-bridge.agora.gripe/.well-known/lnurlp/21sats");
190
- ```
248
+ Four sharp edges, worth reading before you build:
191
249
 
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.
250
+ - **A colluding custodian defeats all of it.** If the recipient's wallet provider
251
+ and the gateway are the same party, then whoever holds the money also serves the
252
+ metadata and answers the verify requests. Every check passes. This protects a
253
+ payer against the operator, never against the recipient's own custodian.
254
+ - **The two proof fetches vet the first hop and no further.** `proveOrigin` and
255
+ `proveSettlement` use the runtime's default redirect handling, so a public https
256
+ host answering `302` to a private address is followed there. `invoiceFrom` is not
257
+ like this: it resolves through the outbound guard, which sets `redirect: "manual"`
258
+ and re-vets every hop. Keep egress control outside this package if that matters.
259
+ - **A payment read cold is only as pinned as its creation.** `payment` checks
260
+ the preimage against the `paymentHash` in the same record, and it was
261
+ `proveOrigin` at creation, against the request you wrote, that tied that hash to
262
+ an invoice the recipient issued. Store the request alongside the payment id, or a
263
+ cold read is checking the gateway's numbers against each other and nothing more.
264
+ - **Availability is not provable, and an address is not a person.** Every check
265
+ here is about an invoice you were given, none about one you were refused, and
266
+ proving an invoice belongs to an address never proves the address belongs to
267
+ whoever you think it does.
268
+
269
+ `carriesProof` is the one to be careful with: it asks only whether a report holds
270
+ together, so a gateway that generates a preimage, hashes it and builds an invoice
271
+ around that hash passes it. If a payment matters, ask the recipient with
272
+ `prove` on the payment request or with `proveSettlement`. The full argument, including the five
273
+ origin checks and their failure codes, is in
274
+ [docs/proving-a-payment.md](../docs/proving-a-payment.md).
275
+
276
+ ## What you call
277
+
278
+ [docs/api.md](../docs/api.md) is the whole surface, generated from the TSDoc on
279
+ every export by [`tools/api-reference.ts`](../tools/api-reference.ts) and checked in
280
+ CI, so nothing there can be out of date and nothing here repeats it. Your editor
281
+ has the same text on the export itself.
282
+
283
+ The shape of it is worth stating once, because it is the thing that makes the rest
284
+ findable. One instance is the entry to everything:
292
285
 
293
286
  ```ts
294
- import { blindLightningRail, lightningVerifyEndpoint } from "thunder-bridge/server";
287
+ import { sats, ThunderBridge } from "thunder-bridge";
295
288
 
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 },
289
+ const gateway = new ThunderBridge("https://public.thunder-bridge.agora.gripe", {
290
+ secret: "a-long-lived-server-side-secret",
305
291
  });
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
292
 
327
- ```ts
328
- import { nwcConnection, nwcRail, nwcVerifyEndpoint } from "thunder-bridge/server";
329
-
330
- const connection = nwcConnection(process.env.NWC_URI);
293
+ const asked = await gateway.requestPayment({ paidTo: "iamfatik@blink.sv", amount: sats(21) });
294
+ const read = await gateway.payment(asked.id);
295
+ const quoted = await gateway.quote({ paidTo: "iamfatik@blink.sv", amount: sats(21) });
331
296
 
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 },
297
+ const lnurl = gateway.serve.lnurlPay({
298
+ paidTo: "iamfatik@blink.sv",
299
+ amount: sats(21),
300
+ secret: "a-long-lived-server-side-secret",
341
301
  });
302
+ const rail = gateway.rails.lightning({ paidTo: "iamfatik@blink.sv", amount: () => sats(21) });
342
303
  ```
343
304
 
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";
305
+ - **the payments themselves** are `requestPayment`, `mint`, `quote`, `watch`, `payment`,
306
+ `payments`, `settled`, `firstSettled`, `follow`, `ticket`, `nameFor` and
307
+ `webhookKey`, all on the instance
308
+ - **what you mount** is on `gateway.serve`: an LNURL-pay endpoint, the two ticket
309
+ endpoints, the verify endpoints for Lightning and for a bank, the webhook route,
310
+ and the readers under it
311
+ - **one call per sale** is on `gateway.rails`: `lightning`, `blindLightning`,
312
+ `bank`, and `transfer` for a bank transfer on its own
313
+ - **the proofs** are free functions, deliberately, because a proof you cannot run
314
+ without the thing being audited is not a proof: `proveOrigin`, `proveSettlement`,
315
+ `proveWrapped`, `carriesProof`, `decodeInvoice`, `preimageMatchesHash`
316
+ - **a payment reads without an assertion.** `Payment` is `MintedPayment |
317
+ WatchedPayment`, so checking `kind` is what makes the address, the amount and the
318
+ invoice non-null. The gateway writes those three together or writes none of them,
319
+ and a record carrying some of the three is refused rather than read
320
+ - **the amounts** are `sats`, `msat` and `fiat`
388
321
 
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
- ```
394
-
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.
322
+ What your service answers once those handlers are mounted is written out in
323
+ [`openapi.yaml`](openapi.yaml), shipped with this package.
446
324
 
447
325
  ## Errors
448
326
 
449
327
  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.
328
+ never on prose. `error.status` is what the transport carried, and a document naming
329
+ a different status in its own body does not override it.
452
330
 
453
- | `type` | Status | Class |
331
+ Every `type` below is prefixed `urn:problem-type:thunder-bridge:`, and every one of
332
+ them is a static string on `ProblemError`, so nothing has to be copied out of this
333
+ table by hand. `ProblemError.is(error, ProblemError.PAYMENT_ALREADY_WATCHED)` is how
334
+ you branch on a type that has no error class of its own.
335
+
336
+ | `type` | Status | What it is |
454
337
  |---|---|---|
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).
338
+ | `invalid-request` | 400, or 413 for a body over the size ceiling | `detail` names the field |
339
+ | `no-wallet-available` | 502, else 422, else 400, following the worst wallet | `NoWalletAvailableError`, `wallets` says why each failed |
340
+ | `request-in-flight` | 409 | a request with this `Idempotency-Key` is still running, as `IdempotencyConflictError` |
341
+ | `idempotency-key-reused` | 409 | that key was used for a different request, as `IdempotencyConflictError` |
342
+ | `payment-already-watched` | 409 | that payment hash is already watched here |
343
+ | `caller-unknown` | 403 | the instance keeps a list of callers and your key is not on it |
344
+ | `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 `watch`. A verify URL that is not public https is `invalid-request` instead |
345
+ | `verify-unconfirmed` | 424 | the URL did not answer the LUD-21 shape |
346
+ | `verify-unconsented` | 424 | the URL did not echo the challenge nonce |
347
+ | `webhook-unconfirmed` | 424 | the webhook URL did not answer its challenge |
348
+ | `too-many-pending` | 429, with `ratelimit-limit` and `ratelimit-remaining` set | the caller is over its share of the instance's `MAX_PENDING` |
349
+
350
+ The rest carry `about:blank` as their type, which the gateway seeds into every
351
+ problem body: `401` when the bearer token does not match, `404` both for an id this
352
+ gateway never heard of and for one it knows that belongs to a different caller key,
353
+ so a `403` can never confirm an id exists, `410` when you replay an
354
+ `Idempotency-Key` whose payment has since been pruned, `500`, and `503` while the
355
+ instance is draining or its own health check reads stalled. On a `404` `payment`
356
+ returns `null` rather than throwing.
466
357
 
467
358
  `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.
359
+ misbehaved, and `code` names the check that caught it. `UnverifiedRecipientError` is
360
+ neither an accusation nor a clean bill of health: it means a check could not be run
361
+ at all, because the recipient's server was down, timed out, answered something
362
+ unreadable, or the browser was blocked by CORS. Decide what you want to do with an
363
+ unproven invoice, and decide it explicitly.
477
364
 
478
365
  ```ts
479
366
  import {
480
367
  GatewayCheatError,
368
+ msat,
481
369
  NoWalletAvailableError,
482
370
  ProblemError,
371
+ ThunderBridge,
483
372
  UnverifiedRecipientError,
484
373
  } from "thunder-bridge";
485
374
 
375
+ declare const gateway: ThunderBridge;
376
+ declare function report(line: string): void;
377
+
486
378
  try {
487
- const payment = await gateway.createPayment({ lnAddresses: wallets, amountMsat: 21_000 });
488
- show(payment);
379
+ await gateway.mint({ paidTo: "iamfatik@blink.sv", amount: msat(21_000) });
489
380
  } catch (error) {
490
381
  if (error instanceof GatewayCheatError) {
491
382
  report(`the gateway cheated: ${error.code} on payment ${error.paymentId}`);
492
383
  } else if (error instanceof UnverifiedRecipientError) {
493
384
  report(`could not reach ${error.lnAddress} to check the invoice`);
494
385
  } else if (error instanceof NoWalletAvailableError) {
495
- for (const wallet of error.wallets) report(`${wallet.address}: ${wallet.reason}`);
386
+ for (const wallet of error.wallets) {
387
+ report(`${wallet.address}: ${wallet.reason}`);
388
+ }
496
389
  } else if (error instanceof ProblemError) {
497
390
  report(`${error.status} ${error.title}`);
498
391
  } else {
@@ -507,127 +400,54 @@ Pass `webhookUrl` when you create a payment, or on any rail. There is no webhook
507
400
  secret: a gateway holds nothing of yours, and sending one is refused rather than
508
401
  ignored. Every delivery is signed `ed25519=<signature>` with the key the gateway
509
402
  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`.
403
+ alone, so a captured delivery cannot be replayed at you later. Delivery is
404
+ at-least-once, so deduplicate on `id`.
519
405
 
520
406
  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.
407
+ `{"type":"webhook-challenge","nonce":"..."}` to the URL while the create is still
408
+ open, and refuses the payment with a `424` unless the nonce comes back, so deploy
409
+ the endpoint before you register it.
526
410
 
527
411
  ```ts
528
- import {
529
- answerWebhookChallengeRequest,
530
- isProvablySettled,
531
- parseSettlementRequest,
532
- } from "thunder-bridge";
533
-
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
- ```
548
-
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
412
+ import { ThunderBridge } from "thunder-bridge";
569
413
 
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.
414
+ declare function fulfil(paymentId: string, preimage: string): Promise<void>;
573
415
 
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
- ```
586
-
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.
589
-
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.
595
-
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.
602
-
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.
416
+ const gateway = new ThunderBridge("https://public.thunder-bridge.agora.gripe");
606
417
 
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);
418
+ export const POST = gateway.serve.webhook({
419
+ onSettled: async (settlement) => {
420
+ await fulfil(settlement.id, settlement.preimage);
421
+ },
621
422
  });
622
423
  ```
623
424
 
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.
425
+ That route answers the challenge, reads the gateway's own published key, checks the
426
+ signature, and calls you only for a delivery that proves itself: it says paid and it
427
+ carries a preimage that hashes to the payment hash the same body names. A delivery
428
+ that proves nothing gets a `202` and no callback, because acting on an unproven
429
+ claim is the one thing this refuses to do. Pass `onUnproven` when an expiry is news
430
+ you want.
431
+
432
+ `onSettled` is handed a `Proven<Settlement>`, so the preimage is a `string` rather
433
+ than something to coerce: the check the route already ran is what narrows it.
434
+
435
+ `gateway.serve.readSettlement` and `gateway.serve.readPayment` are the same checks
436
+ without the route, for a handler you would rather write yourself. Ask the
437
+ recipient's own server with `proveSettlement` when you want the proof to come from
438
+ somewhere other than the delivery.
439
+
440
+ ## More
441
+
442
+ - [docs/lud21-coverage.md](../docs/lud21-coverage.md) - which address domains
443
+ release a preimage, and how that was measured
444
+ - [docs/proving-a-payment.md](../docs/proving-a-payment.md) - the five origin checks
445
+ and their failure codes, what settlement means, making the gateway poll nobody but
446
+ you, the NWC rail, wrapped invoices, and webhooks in full
447
+ - [docs/api.md](../docs/api.md) - every export, generated from the code
448
+ - [docs/recipes.md](../docs/recipes.md) - one runnable program per use case, every name in
449
+ it linked to its own entry in the reference
450
+ - [the gateway](../README.md) - one level up in this repository
631
451
 
632
452
  ## Development
633
453