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