thunder-bridge 0.8.0 → 0.8.1
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 +199 -785
- package/dist/index.cjs +514 -26
- package/dist/index.d.cts +293 -5
- package/dist/index.d.ts +293 -5
- package/dist/index.js +500 -26
- package/openapi.yaml +307 -0
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -1,29 +1,25 @@
|
|
|
1
1
|
# thunder-bridge
|
|
2
2
|
|
|
3
|
-
A JavaScript client for a Thunder Bridge gateway.
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
the
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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.
|
|
3
|
+
A JavaScript client for a Thunder Bridge gateway. Give it a priority list of
|
|
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.
|
|
16
|
+
`bankTransfer` derives a preimage and hands the gateway its hash, which puts money
|
|
17
|
+
landing in a bank account behind the same watch and the same proof. `Statement` is
|
|
18
|
+
the seam a bank plugs into and `fioStatement` is the first one.
|
|
23
19
|
|
|
24
20
|
It touches only `fetch`, `crypto.subtle`, `URL` and `WebSocket`, so it runs in
|
|
25
|
-
Node, Bun, Deno, Cloudflare Workers and the browser. The
|
|
26
|
-
|
|
21
|
+
Node, Bun, Deno, Cloudflare Workers and the browser. The gateway it talks to is
|
|
22
|
+
one level up in [this repository](../README.md).
|
|
27
23
|
|
|
28
24
|
## Install
|
|
29
25
|
|
|
@@ -35,7 +31,7 @@ npm install thunder-bridge
|
|
|
35
31
|
|
|
36
32
|
A page can run the whole flow with no backend of its own. The gateway answers
|
|
37
33
|
every origin, and coinos, Alby and Stacker News serve their LNURL endpoints with
|
|
38
|
-
CORS open, so the
|
|
34
|
+
CORS open, so the proof fetches work from a browser too.
|
|
39
35
|
|
|
40
36
|
```ts
|
|
41
37
|
import { ThunderBridge, invoiceToSvg, type CreatePaymentParams } from "thunder-bridge";
|
|
@@ -53,10 +49,8 @@ const target = document.querySelector("#qr");
|
|
|
53
49
|
if (target) target.innerHTML = invoiceToSvg(payment.bolt11);
|
|
54
50
|
```
|
|
55
51
|
|
|
56
|
-
|
|
57
|
-
|
|
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.
|
|
52
|
+
Keep the `request` object. Every proof takes it, because what you asked for is the
|
|
53
|
+
side of each comparison the gateway did not supply.
|
|
60
54
|
|
|
61
55
|
```ts
|
|
62
56
|
import { proveSettlement } from "thunder-bridge";
|
|
@@ -71,625 +65,128 @@ if (settled.status === "paid") {
|
|
|
71
65
|
}
|
|
72
66
|
```
|
|
73
67
|
|
|
74
|
-
`waitForPayment` tells you what the gateway says
|
|
75
|
-
|
|
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.
|
|
68
|
+
`waitForPayment` tells you what the gateway says. `proveSettlement` goes to the
|
|
69
|
+
recipient's own server. Only the second is evidence the money arrived.
|
|
78
70
|
|
|
79
|
-
##
|
|
71
|
+
## What is exported
|
|
80
72
|
|
|
81
|
-
Everything
|
|
73
|
+
Everything comes from the package root, there are no subpaths. Signatures and the
|
|
74
|
+
caveats on each export are in the TSDoc on the export itself, so your editor has
|
|
75
|
+
them and this table does not repeat them.
|
|
82
76
|
|
|
83
|
-
|
|
77
|
+
**The gateway** - [`src/client.ts`](src/client.ts)
|
|
84
78
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
you
|
|
97
|
-
`
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
```
|
|
79
|
+
| Export | What it does |
|
|
80
|
+
|---|---|
|
|
81
|
+
| `new ThunderBridge(baseUrl, options?)` | a gateway handle. `{ verify: false }` turns off the automatic proof, `{ token }` makes the instance yours |
|
|
82
|
+
| `gateway.createPayment(params, options?)` | mint an invoice on the first address that can prove one, and prove it before returning |
|
|
83
|
+
| `gateway.createQuote(params)` | ask which address would take an amount without minting anything |
|
|
84
|
+
| `gateway.getPayment(id)` | read a payment back, `null` when the gateway never heard of it |
|
|
85
|
+
| `gateway.getWatched(id)` | the same for one the gateway only watches, which carries no address, amount or invoice |
|
|
86
|
+
| `gateway.listPayments(limit?)` | what this gateway is watching, newest first. Needs a token |
|
|
87
|
+
| `gateway.waitForPayment(id, options?)` | follow one payment over WebSocket until it is paid or expired |
|
|
88
|
+
| `gateway.waitForWatched(id, options?)` | the same for a watched one, answering the shape both rails share |
|
|
89
|
+
| `gateway.firstToSettle(ids, options?)` | wait on several legs, keep the first really paid, drop the losers |
|
|
90
|
+
| `gateway.watchPayment(params)` | hand over an invoice you obtained yourself, without the address or the amount |
|
|
91
|
+
| `gateway.followTrigger(secret, options)` | stream every payment carrying one trigger, reconnecting on its own |
|
|
92
|
+
| `gateway.isPrivate` | whether a token was given |
|
|
93
|
+
|
|
94
|
+
**Proving it** - [`src/verify.ts`](src/verify.ts)
|
|
95
|
+
|
|
96
|
+
| Export | What it does |
|
|
97
|
+
|---|---|
|
|
98
|
+
| `proveOrigin(payment, request)` | the five checks below, against the recipient's own server |
|
|
99
|
+
| `proveSettlement(payment, request)` | ask the recipient whether it settled, returns the preimage or `null` |
|
|
100
|
+
| `isProvablyPaid(payment)` | whether the gateway's own report is self-consistent. A sanity check, not a proof |
|
|
101
|
+
| `preimageMatchesHash(preimage, hash)` | one sha256 comparison |
|
|
102
|
+
| `decodeInvoice(bolt11)` | the invoice's own amount, payment hash and description hash |
|
|
516
103
|
|
|
517
|
-
|
|
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.
|
|
104
|
+
**Serving your own endpoint** - [`src/trigger.ts`](src/trigger.ts), [`src/bank.ts`](src/bank.ts), [`src/fio.ts`](src/fio.ts)
|
|
524
105
|
|
|
525
|
-
|
|
526
|
-
|
|
106
|
+
| Export | What it does |
|
|
107
|
+
|---|---|
|
|
108
|
+
| `lnurlPayEndpoint(config)` | a whole LNURL-pay endpoint as one Fetch handler, so a static QR points at your domain |
|
|
109
|
+
| `bankTransfer(params)` | register a Czech QR platba as a watched payment. Refuses a gateway with no token |
|
|
110
|
+
| `bankVerifyEndpoint(config)` | the other half, the LUD-21 shape backed by your own statement |
|
|
111
|
+
| `fioStatement(config)` | a `Statement` reading a Fio account, several tokens used strictly in turn |
|
|
112
|
+
| `seal(secret, plaintext)`, `unseal` | the blob the gateway stores and cannot read |
|
|
113
|
+
| `toLnurl(url)` | bech32-encode an endpoint url |
|
|
527
114
|
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
`invoiceToSvg("<name>@yourdomain")` gives a QR people can read and type.
|
|
115
|
+
`Statement` is a plain `(sinceUnix) => Promise<Credit[]>`, so another bank is
|
|
116
|
+
another function of that shape and persistence wraps it from outside rather than
|
|
117
|
+
living inside it. Nothing above it changes, and the package stays ignorant of
|
|
118
|
+
whatever runtime you keep state in.
|
|
533
119
|
|
|
534
|
-
|
|
120
|
+
**Pricing a fiat order** - [`src/price.ts`](src/price.ts), [`src/currency.ts`](src/currency.ts)
|
|
535
121
|
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
122
|
+
| Export | What it does |
|
|
123
|
+
|---|---|
|
|
124
|
+
| `medianOf(tickers?, options?)` | ask several venues, take the middle, refuse the lot when they disagree too much |
|
|
125
|
+
| `coinbase`, `kraken`, `bitstamp`, `coinmate` | the four MiCA authorised venues, every one replaceable |
|
|
126
|
+
| `msatFor(amountMinor, priceMinorPerBtc, options?)` | exact BigInt arithmetic from fiat to millisatoshi |
|
|
127
|
+
| `minorUnitsOf(currency)`, `minorScaleOf` | what ISO 4217 says the currency's minor unit is |
|
|
539
128
|
|
|
540
|
-
|
|
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.
|
|
129
|
+
**QR codes** - [`src/qr.ts`](src/qr.ts)
|
|
545
130
|
|
|
546
|
-
|
|
131
|
+
Every renderer returns a string, so they work on a server, in a worker and in a
|
|
132
|
+
browser with no canvas involved. `invoiceToSvg` takes an invoice or a lightning
|
|
133
|
+
address, `lnurlToSvg` takes your own endpoint url, `spdToSvg` takes the `spd` from
|
|
134
|
+
`bankTransfer`. Each has a `…ToDataUrl` twin for an `<img>` `src`. A BOLT12 offer
|
|
135
|
+
is not handled, because this gateway never returns one.
|
|
547
136
|
|
|
548
137
|
```ts
|
|
549
|
-
|
|
550
|
-
```
|
|
138
|
+
import { invoiceToSvg, lnurlToSvg } from "thunder-bridge";
|
|
551
139
|
|
|
552
|
-
|
|
553
|
-
|
|
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;
|
|
140
|
+
const toPay = invoiceToSvg(payment.bolt11, { size: 320, color: "#1a1a2e" });
|
|
141
|
+
const tipJar = lnurlToSvg("https://agora.gripe/tip");
|
|
561
142
|
```
|
|
562
143
|
|
|
563
|
-
|
|
564
|
-
|
|
144
|
+
**Webhooks** - [`src/webhook.ts`](src/webhook.ts): `parseWebhookRequest`,
|
|
145
|
+
`parseWebhook`, `verifyWebhookSignature`. See [Webhooks](#webhooks).
|
|
565
146
|
|
|
566
|
-
|
|
147
|
+
**Errors** - [`src/errors.ts`](src/errors.ts): `ProblemError`,
|
|
148
|
+
`NoWalletAvailableError`, `GatewayCheatError`, `UnverifiedRecipientError`,
|
|
149
|
+
`IdempotencyConflictError`, `isProblemType`. See [Errors](#errors).
|
|
567
150
|
|
|
568
|
-
|
|
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.
|
|
151
|
+
## The proof
|
|
577
152
|
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
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
|
-
```
|
|
153
|
+
`proveOrigin(payment, request)` runs five checks in order and stops at the first
|
|
154
|
+
failure. The first two need no network. The rest go to the recipient's own domain,
|
|
155
|
+
never back to the gateway, which is the point: a gateway cannot witness its own
|
|
156
|
+
honesty.
|
|
600
157
|
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
158
|
+
| # | Check | Rules out | Fails with |
|
|
159
|
+
|---|---|---|---|
|
|
160
|
+
| 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` |
|
|
161
|
+
| 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` |
|
|
162
|
+
| 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` |
|
|
163
|
+
| 4 | `verifyUrl` shares an origin with the `callback` that endpoint publishes | a settlement proof pointed anywhere the gateway controls | `verify_url_foreign` |
|
|
164
|
+
| 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` |
|
|
604
165
|
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
minMsat: number;
|
|
611
|
-
maxMsat: number;
|
|
612
|
-
metadata: string;
|
|
613
|
-
refusals: WalletFailure[];
|
|
614
|
-
}
|
|
615
|
-
```
|
|
166
|
+
Check 1 also builds the url the rest of the chain uses: your `user@domain` becomes
|
|
167
|
+
`https://domain/.well-known/lnurlp/user` under LUD-16, with the domain lowercased
|
|
168
|
+
and the local part left exactly as you wrote it. The gateway's spelling is used to
|
|
169
|
+
find the match and never to build the url, so it cannot aim the proof at a
|
|
170
|
+
different account on a provider that treats the local part as case-sensitive.
|
|
616
171
|
|
|
617
|
-
|
|
618
|
-
you what else you could have asked it for.
|
|
172
|
+
### Origin is not settlement
|
|
619
173
|
|
|
620
|
-
|
|
174
|
+
Those five checks are about an invoice. They prove that what you are putting in
|
|
175
|
+
front of a payer is the recipient's own invoice for the right amount. They say
|
|
176
|
+
nothing about whether anybody paid it, and the two answers to that are not the
|
|
177
|
+
same answer.
|
|
621
178
|
|
|
622
|
-
`
|
|
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`
|
|
179
|
+
`isProvablyPaid` asks whether the gateway's report contradicts itself: a `paid`
|
|
680
180
|
status, a preimage, and a `bolt11` whose payment hash that preimage opens. All
|
|
681
|
-
three values arrive from the gateway in one message, so
|
|
682
|
-
consistency and nothing more. A gateway that generates a preimage, hashes it
|
|
181
|
+
three values arrive from the gateway in one message, so this is internal
|
|
182
|
+
consistency and nothing more. A gateway that generates a preimage, hashes it and
|
|
683
183
|
builds an invoice around that hash passes it. It catches breakage and
|
|
684
184
|
carelessness, not an operator who means it.
|
|
685
185
|
|
|
686
186
|
`proveSettlement` asks the recipient. It re-runs the origin proof, which is what
|
|
687
|
-
ties `verifyUrl` to the recipient's own callback origin
|
|
688
|
-
|
|
689
|
-
|
|
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.
|
|
187
|
+
ties `verifyUrl` to the recipient's own callback origin, then reads that url.
|
|
188
|
+
`null` means the recipient's own server is not claiming the money arrived,
|
|
189
|
+
whatever the gateway says.
|
|
693
190
|
|
|
694
191
|
Use `isProvablyPaid` to throw out a record that is obviously wrong. Use
|
|
695
192
|
`proveSettlement` before you part with anything.
|
|
@@ -698,132 +195,79 @@ Use `isProvablyPaid` to throw out a record that is obviously wrong. Use
|
|
|
698
195
|
|
|
699
196
|
Every outbound url in the chain must be public https. The guard refuses loopback,
|
|
700
197
|
link-local, the RFC 1918 ranges, carrier-grade NAT, unique local addresses, and
|
|
701
|
-
IPv4-mapped IPv6
|
|
702
|
-
|
|
703
|
-
|
|
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.
|
|
198
|
+
IPv4-mapped IPv6 unwrapping into any of those. It also refuses a host with no dot
|
|
199
|
+
such as `nas`, the trailing-dot `localhost.`, and anything whose last label is
|
|
200
|
+
`local`, `internal`, `lan`, `arpa`, `test` or `invalid`.
|
|
707
201
|
|
|
708
|
-
It vets the first hop only. See
|
|
202
|
+
It vets the first hop only. See below.
|
|
709
203
|
|
|
710
204
|
## What is still trusted
|
|
711
205
|
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
**The
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
**
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
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.
|
|
206
|
+
- **The gateway chooses which of your addresses gets paid.** Nothing here can
|
|
207
|
+
tell a genuine failure of the first from a preference for the third. What it
|
|
208
|
+
cannot do is pick an address off your list.
|
|
209
|
+
- **The gateway can refuse you.** Availability is not provable. Every check here
|
|
210
|
+
is about an invoice you were given, none about one you were not.
|
|
211
|
+
- **The gateway sees your request.** The address list, the amount and the webhook
|
|
212
|
+
secret pass through it, because it has to make the calls. Treat the secret as
|
|
213
|
+
shared with it and the address list as public.
|
|
214
|
+
- **Everything it says about a settlement, until you ask the recipient.**
|
|
215
|
+
`isProvablyPaid` only asks whether that account holds together. If a payment
|
|
216
|
+
matters, ask.
|
|
217
|
+
- **The host guard vets the first hop and no further.** Both fetches use the
|
|
218
|
+
runtime's default redirect handling, so a public https host answering with a 302
|
|
219
|
+
to a private address is followed there. Keep egress control outside this
|
|
220
|
+
package if that matters.
|
|
221
|
+
- **A colluding custodian defeats all of it.** If the recipient's wallet provider
|
|
222
|
+
and the gateway are the same party, then the party holding the money is also the
|
|
223
|
+
one serving the metadata and answering the verify requests. Every check would
|
|
224
|
+
pass. This protects a payer against the gateway, not against the recipient's own
|
|
225
|
+
custodian.
|
|
226
|
+
- **TLS and DNS for the recipient's domain**, and an address is not a person. This
|
|
227
|
+
proves an invoice belongs to an address, never that the address belongs to
|
|
228
|
+
whoever you think.
|
|
229
|
+
- **A payment read cold is only as pinned as its creation.** `getPayment` checks
|
|
230
|
+
the preimage against the `paymentHash` in the same record. It was `proveOrigin`
|
|
231
|
+
at creation, against the request you wrote, that tied that hash to an invoice
|
|
232
|
+
the recipient issued. So store the request alongside the payment id, or you are
|
|
233
|
+
checking the gateway's numbers against each other and nothing more.
|
|
766
234
|
|
|
767
235
|
## Errors
|
|
768
236
|
|
|
769
|
-
Every failure from the gateway is an RFC 9457 problem document
|
|
770
|
-
|
|
771
|
-
|
|
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 |
|
|
237
|
+
Every failure from the gateway is an RFC 9457 problem document. Branch on `type`,
|
|
238
|
+
never on prose. `error.status` is what the transport carried, and a document
|
|
239
|
+
naming a different status in its own body does not override it.
|
|
780
240
|
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
241
|
+
| `type` | Status | Class |
|
|
242
|
+
|---|---|---|
|
|
243
|
+
| `…:invalid-request` | 400 | `ProblemError`, `detail` names the field |
|
|
244
|
+
| `…:no-wallet-available` | 400, 422, 502 | `NoWalletAvailableError`, `wallets` says why each failed |
|
|
245
|
+
| `about:blank` | 404 | none, `getPayment` returns `null` |
|
|
246
|
+
| `about:blank` | 503 | `ProblemError`, the instance is at capacity |
|
|
247
|
+
| `about:blank` | 500 | `ProblemError` |
|
|
784
248
|
|
|
785
249
|
The status on `no-wallet-available` follows the worst wallet, so a retry is never
|
|
786
|
-
advised in vain: 502 if any
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
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.
|
|
250
|
+
advised in vain: 502 if any was merely unreachable, else 422 if any refused
|
|
251
|
+
permanently, else 400. Each entry in `wallets` is a `WalletFailure` in the order
|
|
252
|
+
the addresses were tried, and every reason is enumerated in
|
|
253
|
+
[`openapi.yaml`](../openapi.yaml).
|
|
792
254
|
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
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.
|
|
255
|
+
`GatewayCheatError` is different in kind. It reports a gateway that demonstrably
|
|
256
|
+
misbehaved, and `code` names the check that caught it: the five in the table above
|
|
257
|
+
plus `preimage_mismatch`, a reported `paid` whose preimage does not open the
|
|
258
|
+
invoice.
|
|
804
259
|
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
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.
|
|
260
|
+
`UnverifiedRecipientError` is deliberately neither. It means a check could not be
|
|
261
|
+
run, because the recipient's server was down, timed out, answered something
|
|
262
|
+
unreadable, or the browser was blocked by CORS. Not an accusation, and not a clean
|
|
263
|
+
bill of health either. Decide what you want to do with an unproven invoice, and
|
|
264
|
+
decide it explicitly.
|
|
820
265
|
|
|
821
266
|
```ts
|
|
822
267
|
import {
|
|
823
268
|
GatewayCheatError,
|
|
824
269
|
NoWalletAvailableError,
|
|
825
270
|
ProblemError,
|
|
826
|
-
ThunderBridge,
|
|
827
271
|
UnverifiedRecipientError,
|
|
828
272
|
} from "thunder-bridge";
|
|
829
273
|
|
|
@@ -849,9 +293,10 @@ try {
|
|
|
849
293
|
|
|
850
294
|
Pass `webhookUrl` and optionally `webhookSecret` when you create a payment. Once
|
|
851
295
|
it reaches `paid` the gateway POSTs the same JSON the API returns, so the body is
|
|
852
|
-
a `Payment`.
|
|
853
|
-
|
|
854
|
-
|
|
296
|
+
a `Payment`. Every delivery carries `x-timestamp`, and with a secret set
|
|
297
|
+
`x-signature: sha256=<hmac>` over `<timestamp>.<body>` rather than the body alone,
|
|
298
|
+
so a captured delivery cannot be replayed at you later. Six attempts on a widening
|
|
299
|
+
backoff, then it parks. An invoice that expires fires nothing.
|
|
855
300
|
|
|
856
301
|
Delivery is at-least-once, so deduplicate on `id`.
|
|
857
302
|
|
|
@@ -870,77 +315,46 @@ app.post("/hooks/paid", async (context) => {
|
|
|
870
315
|
});
|
|
871
316
|
```
|
|
872
317
|
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
`CreatePaymentParams` you stored when you created it.
|
|
318
|
+
`parseWebhookRequest` refuses anything more than five minutes out of date,
|
|
319
|
+
adjustable with `toleranceSecs`. The signature proves the body came from someone
|
|
320
|
+
holding your secret. It does not prove the payment happened, since the gateway
|
|
321
|
+
holds that secret too. The proof is `proveSettlement`, and it needs the request you
|
|
322
|
+
originally sent, which is why `requestFor` above is your own lookup from a payment
|
|
323
|
+
id back to the `CreatePaymentParams` you stored.
|
|
880
324
|
|
|
881
325
|
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
|
-
|
|
884
|
-
and is left out only to keep the raw body point visible.
|
|
326
|
+
`parseWebhook`. The body must be the bytes as received, so mount a raw body parser
|
|
327
|
+
on that route and not a JSON one.
|
|
885
328
|
|
|
886
329
|
```ts
|
|
887
330
|
import express from "express";
|
|
888
331
|
import { parseWebhook } from "thunder-bridge";
|
|
889
332
|
|
|
890
333
|
app.post("/hooks/paid", express.raw({ type: "application/json" }), async (request, response) => {
|
|
891
|
-
const payment = await parseWebhook(
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
334
|
+
const payment = await parseWebhook(
|
|
335
|
+
request.body,
|
|
336
|
+
request.get("x-signature") ?? "",
|
|
337
|
+
secret,
|
|
338
|
+
request.get("x-timestamp") ?? "",
|
|
339
|
+
);
|
|
340
|
+
response.sendStatus(payment === null ? 401 : 200);
|
|
897
341
|
});
|
|
898
342
|
```
|
|
899
343
|
|
|
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
344
|
## Requirements
|
|
922
345
|
|
|
923
|
-
Node 22 or newer. `waitForPayment` and `followTrigger` use the global
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
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.
|
|
346
|
+
Node 22 or newer. `waitForPayment` and `followTrigger` use the global `WebSocket`,
|
|
347
|
+
which Node only exposes from 22 onwards, and there is no fallback and no optional
|
|
348
|
+
dependency to install. Everything else works on any runtime with `fetch` and
|
|
349
|
+
`crypto.subtle`, so an older Node can still create payments, quote them, verify
|
|
350
|
+
them, poll `getPayment`, serve `lnurlPayEndpoint` and handle webhooks.
|
|
936
351
|
|
|
937
352
|
## Development
|
|
938
353
|
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
## License
|
|
354
|
+
```bash
|
|
355
|
+
npm install
|
|
356
|
+
npm test
|
|
357
|
+
npm run build
|
|
358
|
+
```
|
|
945
359
|
|
|
946
|
-
MIT
|
|
360
|
+
MIT.
|