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 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-production.up.railway.app");
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/tip");
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 three days.
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
- secret,
617
+ signs,
509
618
  request.get("x-timestamp") ?? "",
510
619
  );
511
620
  response.sendStatus(payment === null ? 401 : 200);