thunder-bridge 0.8.9 → 1.0.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 +60 -62
- package/dist/index.cjs +291 -118
- package/dist/index.d.cts +63 -10
- package/dist/index.d.ts +63 -10
- package/dist/index.js +287 -118
- package/dist/{rail-J8QoYbSr.d.cts → rail-CL9QkiHo.d.cts} +30 -6
- package/dist/{rail-J8QoYbSr.d.ts → rail-CL9QkiHo.d.ts} +30 -6
- package/dist/server.cjs +35 -30
- package/dist/server.d.cts +2 -2
- package/dist/server.d.ts +2 -2
- package/dist/server.js +35 -30
- package/openapi.yaml +43 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -79,7 +79,9 @@ them and this table does not repeat them.
|
|
|
79
79
|
|
|
80
80
|
| Export | What it does |
|
|
81
81
|
|---|---|
|
|
82
|
-
| `new ThunderBridge(baseUrl, options?)` | a gateway handle. `{ verify: false }` turns off the automatic proof, `{ token }` makes the instance yours |
|
|
82
|
+
| `new ThunderBridge(baseUrl, options?)` | a gateway handle. `{ secret }` is your rail secret and makes every call speak as you, `{ verify: false }` turns off the automatic proof, `{ token }` makes the instance yours |
|
|
83
|
+
| `new Gateways(baseUrls, options?)` | the same payment watched at several gateways, so any one of them is replaceable. `onRefused` says which of them would not take it |
|
|
84
|
+
| `gateway.nameFor(paymentHash)` | what this payment is called, worked out before any gateway has heard of it, and the same at all of them |
|
|
83
85
|
| `gateway.createPayment(params, options?)` | mint an invoice on the first address that can prove one, and prove it before returning |
|
|
84
86
|
| `gateway.createQuote(params)` | ask which address would take an amount without minting anything |
|
|
85
87
|
| `gateway.getPayment(id)` | read a payment back, `null` when the gateway never heard of it |
|
|
@@ -167,10 +169,11 @@ const toPay = invoiceToSvg(payment.bolt11, { size: 320, color: "#1a1a2e" });
|
|
|
167
169
|
const tipJar = lnurlToSvg("https://agora.gripe/tip");
|
|
168
170
|
```
|
|
169
171
|
|
|
170
|
-
**Webhooks** - [`src/webhook.ts`](src/webhook.ts): `
|
|
171
|
-
`
|
|
172
|
-
`
|
|
173
|
-
`answerWebhookChallenge`. See
|
|
172
|
+
**Webhooks** - [`src/webhook.ts`](src/webhook.ts): `parseSettlementRequest`,
|
|
173
|
+
`parseSettlement`, `isProvablySettled`, `parseWebhookRequest`, `parseWebhook`,
|
|
174
|
+
`parseWatchedWebhookRequest`, `parseWatchedWebhook`, `verifyWebhookSignature`,
|
|
175
|
+
`answerWebhookChallengeRequest`, `answerWebhookChallenge`. See
|
|
176
|
+
[Webhooks](#webhooks).
|
|
174
177
|
|
|
175
178
|
**Errors** - [`src/errors.ts`](src/errors.ts): `ProblemError`,
|
|
176
179
|
`NoWalletAvailableError`, `GatewayCheatError`, `UnverifiedRecipientError`,
|
|
@@ -387,15 +390,17 @@ try {
|
|
|
387
390
|
|
|
388
391
|
## Webhooks
|
|
389
392
|
|
|
390
|
-
Pass `webhookUrl`
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
delivery cannot be replayed at you later.
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
393
|
+
Pass `webhookUrl` when you create a payment, or on any rail. There is no webhook
|
|
394
|
+
secret: a gateway holds nothing of yours, and sending one is refused rather than
|
|
395
|
+
ignored. Every delivery is signed `ed25519=<signature>` with the key the gateway
|
|
396
|
+
publishes at `/webhook-key`, over `<x-timestamp>.<raw body>` rather than the body
|
|
397
|
+
alone, so a captured delivery cannot be replayed at you later.
|
|
398
|
+
|
|
399
|
+
The body is a `Settlement`: the id, the status, the payment hash, the preimage and
|
|
400
|
+
the time. Enough to act on and to check, and no more, so a retry is the same size
|
|
401
|
+
whatever you put in your own record. Read `sealed` back by id when you want it.
|
|
402
|
+
Retries widen until the payment itself runs out, never sooner than an hour. An
|
|
403
|
+
invoice that expires fires nothing.
|
|
399
404
|
|
|
400
405
|
Delivery is at-least-once, so deduplicate on `id`.
|
|
401
406
|
|
|
@@ -409,85 +414,78 @@ settlement, and it leaves the body unread either way.
|
|
|
409
414
|
```ts
|
|
410
415
|
import {
|
|
411
416
|
answerWebhookChallengeRequest,
|
|
412
|
-
|
|
413
|
-
|
|
417
|
+
isProvablySettled,
|
|
418
|
+
parseSettlementRequest,
|
|
414
419
|
} from "thunder-bridge";
|
|
415
420
|
|
|
421
|
+
const signs = { publicKey: await gateway.webhookKey() };
|
|
422
|
+
|
|
416
423
|
app.post("/hooks/paid", async (context) => {
|
|
417
|
-
const challenge = await answerWebhookChallengeRequest(context.req.raw,
|
|
424
|
+
const challenge = await answerWebhookChallengeRequest(context.req.raw, signs);
|
|
418
425
|
if (challenge) return challenge;
|
|
419
426
|
|
|
420
|
-
const
|
|
421
|
-
if (
|
|
422
|
-
|
|
423
|
-
const preimage = await proveSettlement(payment, requestFor(payment.id));
|
|
424
|
-
if (preimage === null) return context.text("the recipient has not seen it", 402);
|
|
427
|
+
const settled = await parseSettlementRequest(context.req.raw, signs);
|
|
428
|
+
if (settled === null) return context.text("bad signature", 401);
|
|
429
|
+
if (!isProvablySettled(settled)) return context.text("no preimage that hashes to it", 402);
|
|
425
430
|
|
|
426
|
-
await fulfil(
|
|
431
|
+
await fulfil(settled.id, settled.preimage);
|
|
427
432
|
return context.text("ok");
|
|
428
433
|
});
|
|
429
434
|
```
|
|
430
435
|
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
bad signature. Use `parseWatchedWebhookRequest` there instead and you get a
|
|
437
|
-
`TriggerEvent`, the shape `getWatched` hands back.
|
|
438
|
-
|
|
439
|
-
```ts
|
|
440
|
-
import { parseWatchedWebhookRequest } from "thunder-bridge";
|
|
436
|
+
`isProvablySettled` answers the only question that matters about a delivery: it says
|
|
437
|
+
paid and it carries a preimage that hashes to the payment hash the same body names.
|
|
438
|
+
Ask the recipient's own server with `proveSettlement` when the payment is one you
|
|
439
|
+
minted through the gateway and you want the proof to come from somewhere other than
|
|
440
|
+
the delivery.
|
|
441
441
|
|
|
442
|
-
|
|
443
|
-
const settled = await parseWatchedWebhookRequest(context.req.raw, secret);
|
|
444
|
-
if (settled === null) return context.text("bad signature", 401);
|
|
442
|
+
### Every rail sends the same body
|
|
445
443
|
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
444
|
+
`bankRail` and `blindLightningRail` used to need a parser of their own, because their
|
|
445
|
+
webhook carried no address, no amount and no invoice while a minted one did. A
|
|
446
|
+
delivery is a `Settlement` on every rail now, so `parseSettlementRequest` is the only
|
|
447
|
+
one to reach for. `parseWatchedWebhookRequest` is still there for reading the shape a
|
|
448
|
+
socket frame and `getWatched` hand back, which is a payment rather than a delivery.
|
|
450
449
|
|
|
451
450
|
Give each rail its own path, as above, and neither endpoint has to guess which body
|
|
452
451
|
it was handed. Both events also carry `kind`, `"minted"` or `"watched"`, so a single
|
|
453
452
|
path serving a trigger that both rails settle on can branch on the field instead of
|
|
454
453
|
on which fields are missing.
|
|
455
454
|
|
|
456
|
-
###
|
|
455
|
+
### The gateway holds nothing of yours
|
|
457
456
|
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
ed25519=<signature>` over the same `<timestamp>.<body>`. Fetch the public half once
|
|
462
|
-
and pass it as `{ publicKey }` wherever a secret would go.
|
|
457
|
+
There is nothing to hand it. A delivery is signed with the gateway's own key,
|
|
458
|
+
`x-signature: ed25519=<signature>` over `<x-timestamp>.<raw body>`. Fetch the public
|
|
459
|
+
half once and keep it.
|
|
463
460
|
|
|
464
461
|
```ts
|
|
465
|
-
const
|
|
462
|
+
const signs = { publicKey: await gateway.webhookKey() };
|
|
466
463
|
|
|
467
464
|
app.post("/hooks/paid", async (context) => {
|
|
468
|
-
const challenge = await answerWebhookChallengeRequest(context.req.raw,
|
|
465
|
+
const challenge = await answerWebhookChallengeRequest(context.req.raw, signs);
|
|
469
466
|
if (challenge) return challenge;
|
|
470
467
|
|
|
471
|
-
const
|
|
472
|
-
if (
|
|
468
|
+
const settled = await parseSettlementRequest(context.req.raw, signs);
|
|
469
|
+
if (settled === null) return context.text("bad signature", 401);
|
|
473
470
|
...
|
|
474
471
|
});
|
|
475
472
|
```
|
|
476
473
|
|
|
477
|
-
Answering echoes the nonce and nothing else
|
|
478
|
-
|
|
474
|
+
Answering echoes the nonce and nothing else, because there is nothing to sign it with.
|
|
475
|
+
Holding the URL the gateway challenged is the whole proof.
|
|
479
476
|
|
|
480
477
|
The key is derived from the gateway's `CLUSTER_KEY`, so every instance in one cluster
|
|
481
|
-
signs alike and an operator rotating that key changes this one too.
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
478
|
+
signs alike and an operator rotating that key changes this one too. A signature that
|
|
479
|
+
stops verifying is therefore a reason to read `/webhook-key` again before it is a
|
|
480
|
+
reason to distrust the gateway. A `sha256=` signature is refused outright: that scheme
|
|
481
|
+
is gone.
|
|
482
|
+
|
|
483
|
+
`parseSettlementRequest` refuses anything more than five minutes out of date,
|
|
484
|
+
adjustable with `toleranceSecs`. The signature proves the delivery came from the
|
|
485
|
+
gateway. It does not prove the payment happened, because the gateway holds the key
|
|
486
|
+
that signs it either way. The proof is the preimage, checked by `isProvablySettled`
|
|
487
|
+
against the hash in the same body, or `proveSettlement` against the recipient's own
|
|
488
|
+
server when you want the answer from somewhere else entirely.
|
|
491
489
|
|
|
492
490
|
For a framework that hands you the raw body and headers separately, use
|
|
493
491
|
`parseWebhook`. The body must be the bytes as received, so mount a raw body parser
|