thunder-bridge 1.1.0 → 1.4.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 +115 -6
- package/dist/index.cjs +1677 -1301
- package/dist/index.d.cts +217 -166
- package/dist/index.d.ts +217 -166
- package/dist/index.js +1673 -1300
- package/dist/{rail-Dp8bs6uZ.d.cts → rail-CqUfuYXJ.d.cts} +176 -9
- package/dist/{rail-Dp8bs6uZ.d.ts → rail-CqUfuYXJ.d.ts} +176 -9
- package/dist/server.cjs +1003 -381
- package/dist/server.d.cts +79 -37
- package/dist/server.d.ts +79 -37
- package/dist/server.js +991 -380
- package/openapi.yaml +1 -1
- package/package.json +33 -13
package/README.md
CHANGED
|
@@ -34,10 +34,16 @@ A page can run the whole flow with no backend of its own. The gateway answers
|
|
|
34
34
|
every origin, and coinos, Alby and Stacker News serve their LNURL endpoints with
|
|
35
35
|
CORS open, so the proof fetches work from a browser too.
|
|
36
36
|
|
|
37
|
+
The url below is a shared demo that answers anyone and forgets everything on
|
|
38
|
+
restart. It is there so this snippet runs as written. It is a base url rather than a
|
|
39
|
+
page, so opening it in a browser gives a `404` and
|
|
40
|
+
[`/health`](https://public.thunder-bridge.agora.gripe/health) is what tells you it is
|
|
41
|
+
up. Point production at a gateway of your own, which takes one command.
|
|
42
|
+
|
|
37
43
|
```ts
|
|
38
44
|
import { ThunderBridge, invoiceToSvg, type CreatePaymentParams } from "thunder-bridge";
|
|
39
45
|
|
|
40
|
-
const gateway = new ThunderBridge("https://thunder-bridge
|
|
46
|
+
const gateway = new ThunderBridge("https://public.thunder-bridge.agora.gripe");
|
|
41
47
|
|
|
42
48
|
const request: CreatePaymentParams = {
|
|
43
49
|
lnAddresses: ["alice@coinos.io", "alice@getalby.com"],
|
|
@@ -91,7 +97,8 @@ them and this table does not repeat them.
|
|
|
91
97
|
| `gateway.waitForWatched(id, options?)` | the same for a watched one, answering the shape both rails share |
|
|
92
98
|
| `gateway.firstToSettle(ids, options?)` | wait on several legs, keep the first really paid, drop the losers |
|
|
93
99
|
| `gateway.watchPayment(params)` | hand over an invoice you obtained yourself, without the address or the amount |
|
|
94
|
-
| `gateway.followTrigger(secret, options)` | stream every payment carrying one trigger, reconnecting on its own |
|
|
100
|
+
| `gateway.followTrigger(secret, options)` | stream every payment carrying one trigger, reconnecting on its own. `replay` asks for how many past settlements on connect |
|
|
101
|
+
| `gateway.createSocketTicket(params)` | a one minute pass onto one trigger's stream, to hand something that must not hold the secret |
|
|
95
102
|
| `gateway.isPrivate` | whether a token was given |
|
|
96
103
|
|
|
97
104
|
**Proving it** - [`src/verify.ts`](src/verify.ts)
|
|
@@ -108,7 +115,9 @@ them and this table does not repeat them.
|
|
|
108
115
|
|
|
109
116
|
| Export | What it does |
|
|
110
117
|
|---|---|
|
|
111
|
-
| `lnurlPayEndpoint(config)` | a whole LNURL-pay endpoint as one Fetch handler, so a static QR points at your domain. From `thunder-bridge/server` |
|
|
118
|
+
| `lnurlPayEndpoint(config)` | a whole LNURL-pay endpoint as one Fetch handler, so a static QR points at your domain. `replay` keeps its last settlements on the gateway for a page that opens later. From `thunder-bridge/server` |
|
|
119
|
+
| `watchTicketEndpoint(config)` | mints socket tickets for callers who already know the watch secret, 403 for the rest. From `thunder-bridge/server` |
|
|
120
|
+
| `publicWatchTicketEndpoint(config)` | the same for a board strangers are meant to read, minting for anyone. From `thunder-bridge/server` |
|
|
112
121
|
| `seal(secret, plaintext)`, `unseal` | the blob the gateway stores and cannot read |
|
|
113
122
|
| `toLnurl(url)` | bech32-encode an endpoint url |
|
|
114
123
|
| `bankTransfer(params)` | register a Czech QR platba as a watched payment. Refuses a gateway that serves strangers |
|
|
@@ -117,6 +126,16 @@ them and this table does not repeat them.
|
|
|
117
126
|
| `lightningVerifyEndpoint(config)` | the same shape for Lightning, asking the wallet on the gateway's behalf. From `thunder-bridge/server` |
|
|
118
127
|
| `relayedVerifyUrl(mount, wallet, secret)` | the URL to hand the gateway instead of the wallet's, with the wallet's sealed inside |
|
|
119
128
|
|
|
129
|
+
There are two ticket endpoints rather than one taking a flag, so the call site
|
|
130
|
+
says which board this is. Mount one on its own path and POST to it from the page
|
|
131
|
+
before every connect, because a ticket lives a minute. Whichever you mount, the
|
|
132
|
+
page never holds the watch secret, which is the reason to mount either.
|
|
133
|
+
|
|
134
|
+
A public board is public in full: every viewer of that socket gets each
|
|
135
|
+
settlement's preimage, verify url and payment hash. Fine for a tip jar, wrong the
|
|
136
|
+
moment anything is gated behind those preimages, because then a viewer holds the
|
|
137
|
+
unlock.
|
|
138
|
+
|
|
120
139
|
What your service answers once those handlers are mounted is written out in
|
|
121
140
|
[`openapi.yaml`](openapi.yaml), shipped with this package.
|
|
122
141
|
|
|
@@ -132,6 +151,7 @@ whatever runtime you keep state in.
|
|
|
132
151
|
| `bankRail(config)` | a `Rail` selling for a bank transfer, reading back through any `Statement` |
|
|
133
152
|
| `lightningRail(config)` | a `Rail` where the gateway mints the invoice, so it learns the address and the amount |
|
|
134
153
|
| `blindLightningRail(config)` | a `Rail` that resolves the address itself and tells the gateway only a hash. From `thunder-bridge/server` |
|
|
154
|
+
| `nwcRail(config)` | a `Rail` that mints on a wallet of your own over NIP-47, for a wallet with no LUD-21 address to be watched at. From `thunder-bridge/server` |
|
|
135
155
|
|
|
136
156
|
`Rail` is `(order: Order) => Promise<Leg>`. Everything that differs between rails
|
|
137
157
|
is bound once when the rail is built, so the only thing passed per sale is which
|
|
@@ -166,9 +186,13 @@ BOLT12 offer is not handled, because this gateway never returns one.
|
|
|
166
186
|
import { invoiceToSvg, lnurlToSvg } from "thunder-bridge";
|
|
167
187
|
|
|
168
188
|
const toPay = invoiceToSvg(payment.bolt11, { size: 320, color: "#1a1a2e" });
|
|
169
|
-
const tipJar = lnurlToSvg("https://agora.gripe/
|
|
189
|
+
const tipJar = lnurlToSvg("https://thunder-bridge.agora.gripe/.well-known/lnurlp/21sats");
|
|
170
190
|
```
|
|
171
191
|
|
|
192
|
+
That second url is live and built with this library, a fixed 21 sat tip served by
|
|
193
|
+
`lnurlPayEndpoint`, so the QR it returns is one you can scan to see the whole flow
|
|
194
|
+
end to end.
|
|
195
|
+
|
|
172
196
|
**Minting your own invoice** - [`src/rail.ts`](src/rail.ts): `invoiceFrom`, from
|
|
173
197
|
`thunder-bridge/server`. A gateway that does not mint is one that never sees an
|
|
174
198
|
address or an amount, so this is how a client gets a provable invoice itself and hands
|
|
@@ -293,6 +317,40 @@ is only ever talking to servers that asked to be talked to. It costs you a servi
|
|
|
293
317
|
that has to stay up: a browser-only integration cannot do this, and should keep
|
|
294
318
|
letting the gateway poll the wallet.
|
|
295
319
|
|
|
320
|
+
### A wallet with no LUD-21 address at all
|
|
321
|
+
|
|
322
|
+
`nwcRail` is the same arrangement with the far side swapped. Your wallet answers
|
|
323
|
+
over [NIP-47](https://github.com/nostr-protocol/nips/blob/master/47.md) instead of
|
|
324
|
+
over an address, so a recipient whose provider publishes no `verify` - or publishes
|
|
325
|
+
one with no preimage, which is worse - is watchable anyway.
|
|
326
|
+
|
|
327
|
+
```ts
|
|
328
|
+
import { nwcConnection, nwcRail, nwcVerifyEndpoint } from "thunder-bridge/server";
|
|
329
|
+
|
|
330
|
+
const connection = nwcConnection(process.env.NWC_URI);
|
|
331
|
+
|
|
332
|
+
app.get("/verify/nwc", (context) =>
|
|
333
|
+
nwcVerifyEndpoint({ connection, secret: NWC_SECRET })(context.req.raw),
|
|
334
|
+
);
|
|
335
|
+
|
|
336
|
+
const rail = nwcRail({
|
|
337
|
+
gateway,
|
|
338
|
+
connection,
|
|
339
|
+
amountMsat: (order) => order.amountMinor * 40,
|
|
340
|
+
verifyThrough: { endpoint: "https://shop.example/verify/nwc", secret: NWC_SECRET },
|
|
341
|
+
});
|
|
342
|
+
```
|
|
343
|
+
|
|
344
|
+
`make_invoice` mints it, `lookup_invoice` reads the preimage back, and the payment
|
|
345
|
+
hash is sealed into the query for the reason the wallet's URL is sealed above: it
|
|
346
|
+
is what stops a stranger driving your wallet through your own handler. Only the
|
|
347
|
+
hash travels, so the gateway never holds the connection, the relay or the wallet
|
|
348
|
+
key, and every answer is refused unless the wallet's own key signed it.
|
|
349
|
+
|
|
350
|
+
Take the connection string scoped. ZEUS, Alby Hub and Blink all issue one per app
|
|
351
|
+
with its own permissions and budget, and this needs `make_invoice` and
|
|
352
|
+
`lookup_invoice` and nothing else - never `pay_invoice`.
|
|
353
|
+
|
|
296
354
|
### How often the gateway asks
|
|
297
355
|
|
|
298
356
|
Your endpoint decides, not the gateway. `bankVerifyEndpoint` answers with
|
|
@@ -301,10 +359,59 @@ payment on your host. Set `pollEverySecs` to whatever your bank's own refresh ma
|
|
|
301
359
|
sensible: reading a statement that moves once an hour every five seconds only burns
|
|
302
360
|
your rate limit.
|
|
303
361
|
|
|
362
|
+
How long one answer may take is the other half of the pacing. The gateway abandons a
|
|
363
|
+
poll after 15 seconds, and `askTimeoutMs` is one deadline over the whole connection
|
|
364
|
+
rather than one per relay, so a connection listing four relays still answers inside
|
|
365
|
+
that window.
|
|
366
|
+
|
|
304
367
|
The gateway also asks the URL once, before it accepts the watch, and refuses with
|
|
305
368
|
`424` if it does not answer this shape. So deploy the endpoint first and register
|
|
306
369
|
second. That is what stops anyone pointing a gateway at a server that never asked to
|
|
307
|
-
be polled for
|
|
370
|
+
be polled for thirty days.
|
|
371
|
+
|
|
372
|
+
## Paying through an operator who fronts the liquidity
|
|
373
|
+
|
|
374
|
+
A recipient with no inbound liquidity cannot be paid at all. An operator with a node
|
|
375
|
+
can stand in the middle without holding anything: it takes the recipient's own
|
|
376
|
+
invoice, mints a **hold invoice on the same payment hash** for the amount plus a fee,
|
|
377
|
+
and can settle its own only by revealing the preimage it learned from paying the
|
|
378
|
+
recipient. Claiming and delivering are one act, so there is no moment where it keeps
|
|
379
|
+
the money and walks away.
|
|
380
|
+
|
|
381
|
+
This SDK does not wrap. `proveWrapped` checks a wrap somebody else offers, and the
|
|
382
|
+
NIP-47 primitives an operator would build one from, `nwcHoldInvoice` and `nwcPay`,
|
|
383
|
+
are on `thunder-bridge/server` with nothing here driving them.
|
|
384
|
+
|
|
385
|
+
```ts
|
|
386
|
+
import { invoiceFrom } from "thunder-bridge/server";
|
|
387
|
+
import { proveWrapped, wrapFeeCeiling } from "thunder-bridge";
|
|
388
|
+
|
|
389
|
+
const real = await invoiceFrom(["you@blink.sv"], 21_000_000);
|
|
390
|
+
const wrapped = await myOperator.wrap(real.bolt11);
|
|
391
|
+
|
|
392
|
+
proveWrapped(wrapped, real.bolt11);
|
|
393
|
+
```
|
|
394
|
+
|
|
395
|
+
`proveWrapped` compares two invoices and asks nobody anything, so it runs in a
|
|
396
|
+
browser. It refuses a wrap on another hash, one that cannot cover what the recipient
|
|
397
|
+
asked, one charging over the allowance, and one that outlives the invoice it has to
|
|
398
|
+
forward to.
|
|
399
|
+
|
|
400
|
+
The allowance is the **client's ceiling**, not a fee the operator names per payment,
|
|
401
|
+
so it sits deliberately above any list price: `1%` by default with a floor of one
|
|
402
|
+
satoshi. An operator running this charges `0.75%`, which leaves room for a wrap a
|
|
403
|
+
shade over list to still go through. Set `proportion` under an operator's price and
|
|
404
|
+
you refuse that operator, which is the point of it being yours.
|
|
405
|
+
`wrapFeeCeiling(amountMsat, allowance)` is the same number if you want to show it.
|
|
406
|
+
|
|
407
|
+
There is no settlement check to add. Both invoices carry one payment hash, so the
|
|
408
|
+
preimage that settles the wrap is the one the recipient released, and
|
|
409
|
+
`proveSettlement` already reads it from the recipient's own server. The gateway needs
|
|
410
|
+
no change either: it watches that hash and polls the recipient's verify URL, and a
|
|
411
|
+
wrap is invisible to it.
|
|
412
|
+
|
|
413
|
+
What this does not cover: an operator that accepts the payment and stalls until the
|
|
414
|
+
HTLC times out. Your money comes back, and it was locked meanwhile.
|
|
308
415
|
|
|
309
416
|
## What is still trusted
|
|
310
417
|
|
|
@@ -501,11 +608,13 @@ on that route and not a JSON one.
|
|
|
501
608
|
import express from "express";
|
|
502
609
|
import { parseWebhook } from "thunder-bridge";
|
|
503
610
|
|
|
611
|
+
const signs = { publicKey: await gateway.webhookKey() };
|
|
612
|
+
|
|
504
613
|
app.post("/hooks/paid", express.raw({ type: "application/json" }), async (request, response) => {
|
|
505
614
|
const payment = await parseWebhook(
|
|
506
615
|
request.body,
|
|
507
616
|
request.get("x-signature") ?? "",
|
|
508
|
-
|
|
617
|
+
signs,
|
|
509
618
|
request.get("x-timestamp") ?? "",
|
|
510
619
|
);
|
|
511
620
|
response.sendStatus(payment === null ? 401 : 200);
|