thunder-bridge 1.4.1 → 1.5.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 +356 -536
- package/dist/bank-B3mHISn1.d.cts +92 -0
- package/dist/bank-B3mHISn1.d.ts +92 -0
- package/dist/bank.cjs +141 -0
- package/dist/bank.d.cts +44 -0
- package/dist/bank.d.ts +44 -0
- package/dist/bank.js +114 -0
- package/dist/client-Cc5gtjGV.d.ts +733 -0
- package/dist/client-CzGZcByI.d.cts +733 -0
- package/dist/errors-0vbVoISA.d.ts +126 -0
- package/dist/errors-Dmh-Uoh8.d.cts +126 -0
- package/dist/index.cjs +1576 -1094
- package/dist/index.d.cts +11 -503
- package/dist/index.d.ts +11 -503
- package/dist/index.js +1556 -1052
- package/dist/{server.cjs → nwc.cjs} +261 -689
- package/dist/nwc.d.cts +120 -0
- package/dist/nwc.d.ts +120 -0
- package/dist/{server.js → nwc.js} +256 -667
- package/dist/price.cjs +240 -0
- package/dist/price.d.cts +17 -0
- package/dist/price.d.ts +17 -0
- package/dist/price.js +205 -0
- package/dist/qr-CF-YeXU1.d.cts +55 -0
- package/dist/qr-CF-YeXU1.d.ts +55 -0
- package/dist/qr.cjs +262 -0
- package/dist/qr.d.cts +1 -0
- package/dist/qr.d.ts +1 -0
- package/dist/qr.js +227 -0
- package/dist/types-BNPmVnA7.d.cts +252 -0
- package/dist/types-BNPmVnA7.d.ts +252 -0
- package/openapi.yaml +1 -1
- package/package.json +48 -9
- package/dist/rail-CqUfuYXJ.d.cts +0 -615
- package/dist/rail-CqUfuYXJ.d.ts +0 -615
- package/dist/server.d.cts +0 -145
- package/dist/server.d.ts +0 -145
package/README.md
CHANGED
|
@@ -2,25 +2,44 @@
|
|
|
2
2
|
|
|
3
3
|
A JavaScript client for a Thunder Bridge gateway. Give it a priority list of
|
|
4
4
|
lightning addresses and an amount, and it hands back an invoice minted by the
|
|
5
|
-
recipient's own wallet
|
|
6
|
-
nothing.
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
5
|
+
recipient's own wallet, proven against that recipient's own server before it
|
|
6
|
+
returns. The gateway mints nothing, holds nothing and forwards nothing.
|
|
7
|
+
|
|
8
|
+
LUD-21 is the shape of the proof rather than the whole of it. Whichever rail a
|
|
9
|
+
payment runs on, the gateway holds a payment hash, polls a `verify` URL and reports
|
|
10
|
+
what came back, and four different things can be the one answering.
|
|
11
|
+
|
|
12
|
+
## Whose wallets this works with
|
|
13
|
+
|
|
14
|
+
Check this before you build on it. The gateway can watch a payment only when the
|
|
15
|
+
recipient's lightning address publishes a LUD-21 `verify` URL **and** releases the
|
|
16
|
+
preimage through it. Support belongs to the address domain rather than to the app,
|
|
17
|
+
so the same wallet on another domain can answer differently.
|
|
18
|
+
|
|
19
|
+
| Works | Address ends with |
|
|
20
|
+
|---|---|
|
|
21
|
+
| Blink | `@blink.sv` |
|
|
22
|
+
| Alby | `@getalby.com` |
|
|
23
|
+
| coinos | `@coinos.io`, `@coinos.pro` |
|
|
24
|
+
| Minibits | `@minibits.cash` |
|
|
25
|
+
| Speed | `@speed.app` |
|
|
26
|
+
| Cake, Breez, Blitz, the Spark-hosted brands | `@cake.cash`, `@breez.tips`, `@blitzwalletapp.com` |
|
|
27
|
+
| a BTCPay Server of your own | your domain, from v2.3.8 |
|
|
28
|
+
|
|
29
|
+
| Refused | Why |
|
|
30
|
+
|---|---|
|
|
31
|
+
| Wallet of Satoshi, Strike, Cash App, ZBD, Primal, Fountain, every LNbits wallet | no `verify` at all |
|
|
32
|
+
| ZEUS Pay, ecash.love | `verify` without a preimage, refused deliberately, since `settled: true` with nothing to hash proves nothing |
|
|
33
|
+
|
|
34
|
+
A refusal happens at creation rather than leaving a payment pending until a
|
|
35
|
+
watcher gives up, so a recipient finds out before a payer sees a QR code.
|
|
36
|
+
|
|
37
|
+
If your recipient is on a refused name, `nwcRail` from `thunder-bridge/nwc` is the way round it: your own
|
|
38
|
+
wallet answers over NIP-47 instead of over an address, and the gateway watches the
|
|
39
|
+
hash exactly the same. [docs/lud21-coverage.md](../docs/lud21-coverage.md) is the
|
|
40
|
+
measured list rather than a reading of changelogs, last surveyed 2026-08-12. Read
|
|
41
|
+
every row as true of that date: a wallet that has shipped LUD-21 since still reads
|
|
42
|
+
as refused here until the next survey says otherwise.
|
|
24
43
|
|
|
25
44
|
## Install
|
|
26
45
|
|
|
@@ -28,471 +47,345 @@ one level up in [this repository](../README.md).
|
|
|
28
47
|
npm install thunder-bridge
|
|
29
48
|
```
|
|
30
49
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
50
|
+
Node 22 or newer, for anything that opens a socket: `requestPayment`, `settled`,
|
|
51
|
+
`firstSettled` and `follow` on the gateway side, and every NWC call on the wallet
|
|
52
|
+
side, since a nostr relay is a socket too. Node only exposes a global `WebSocket`
|
|
53
|
+
from 22 onwards and there is no fallback to install. The rest, which is every call
|
|
54
|
+
that is one or more `fetch` requests, runs on any runtime with `fetch` and
|
|
55
|
+
`crypto.subtle`.
|
|
36
56
|
|
|
37
|
-
|
|
38
|
-
|
|
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.
|
|
57
|
+
One import is the whole thing. The other four are for what a checkout page has no
|
|
58
|
+
reason to download.
|
|
42
59
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
amountMsat: 21_000,
|
|
51
|
-
};
|
|
60
|
+
| Import | What it is for |
|
|
61
|
+
|---|---|
|
|
62
|
+
| `thunder-bridge` | the gateway, the proofs, the errors and the amounts. Everything is reached through one instance |
|
|
63
|
+
| `thunder-bridge/qr` | a payload as an SVG or a data URL, for a page that draws a QR of its own |
|
|
64
|
+
| `thunder-bridge/price` | the exchange venues behind `fiat`, for pricing off your own book instead |
|
|
65
|
+
| `thunder-bridge/bank` | a bank statement reader, currently Fio |
|
|
66
|
+
| `thunder-bridge/nwc` | your own wallet over NIP-47, which carries the nostr crypto no browser wants |
|
|
52
67
|
|
|
53
|
-
|
|
68
|
+
`invoiceFrom` resolves lightning addresses and refuses a private host, so it needs
|
|
69
|
+
`node:dns` and will not run on Cloudflare Workers. Nothing else on the main import
|
|
70
|
+
does.
|
|
54
71
|
|
|
55
|
-
|
|
56
|
-
if (target) target.innerHTML = invoiceToSvg(payment.bolt11);
|
|
57
|
-
```
|
|
72
|
+
## Quick start
|
|
58
73
|
|
|
59
|
-
|
|
60
|
-
|
|
74
|
+
A page can run the whole flow with no backend of its own. The url below is a shared
|
|
75
|
+
demo gateway that answers anyone and forgets everything on restart, so this snippet
|
|
76
|
+
runs as written. It is a base url rather than a page, so opening it in a browser
|
|
77
|
+
gives a `404` and [`/health`](https://public.thunder-bridge.agora.gripe/health) is
|
|
78
|
+
what tells you it is up.
|
|
61
79
|
|
|
62
80
|
```ts
|
|
63
|
-
import {
|
|
81
|
+
import { sats, ThunderBridge } from "thunder-bridge";
|
|
82
|
+
|
|
83
|
+
const gateway = new ThunderBridge("https://public.thunder-bridge.agora.gripe");
|
|
64
84
|
|
|
65
|
-
const
|
|
85
|
+
const asked = await gateway.requestPayment({
|
|
86
|
+
paidTo: ["iamfatik@blink.sv", "iamfatik@coinos.io"],
|
|
87
|
+
amount: sats(21),
|
|
66
88
|
signal: AbortSignal.timeout(600_000),
|
|
67
89
|
});
|
|
68
90
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
91
|
+
const target = document.querySelector("#qr");
|
|
92
|
+
if (target !== null) {
|
|
93
|
+
target.innerHTML = asked.qr;
|
|
72
94
|
}
|
|
73
|
-
```
|
|
74
|
-
|
|
75
|
-
`waitForPayment` tells you what the gateway says. `proveSettlement` goes to the
|
|
76
|
-
recipient's own server. Only the second is evidence the money arrived.
|
|
77
|
-
|
|
78
|
-
## What is exported
|
|
79
|
-
|
|
80
|
-
Everything comes from the package root, there are no subpaths. Signatures and the
|
|
81
|
-
caveats on each export are in the TSDoc on the export itself, so your editor has
|
|
82
|
-
them and this table does not repeat them.
|
|
83
95
|
|
|
84
|
-
|
|
96
|
+
await asked.paid();
|
|
85
97
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
| `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 |
|
|
89
|
-
| `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 |
|
|
90
|
-
| `gateway.nameFor(paymentHash)` | what this payment is called, worked out before any gateway has heard of it, and the same at all of them |
|
|
91
|
-
| `gateway.createPayment(params, options?)` | mint an invoice on the first address that can prove one, and prove it before returning |
|
|
92
|
-
| `gateway.createQuote(params)` | ask which address would take an amount without minting anything |
|
|
93
|
-
| `gateway.getPayment(id)` | read a payment back, `null` when the gateway never heard of it |
|
|
94
|
-
| `gateway.getWatched(id)` | the same for one the gateway only watches, which carries no address, amount or invoice |
|
|
95
|
-
| `gateway.listPayments(limit?)` | what this gateway is watching, newest first. Needs a token |
|
|
96
|
-
| `gateway.waitForPayment(id, options?)` | follow one payment over WebSocket until it is paid or expired |
|
|
97
|
-
| `gateway.waitForWatched(id, options?)` | the same for a watched one, answering the shape both rails share |
|
|
98
|
-
| `gateway.firstToSettle(ids, options?)` | wait on several legs, keep the first really paid, drop the losers |
|
|
99
|
-
| `gateway.watchPayment(params)` | hand over an invoice you obtained yourself, without the address or the amount |
|
|
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 |
|
|
102
|
-
| `gateway.isPrivate` | whether a token was given |
|
|
103
|
-
|
|
104
|
-
**Proving it** - [`src/verify.ts`](src/verify.ts)
|
|
105
|
-
|
|
106
|
-
| Export | What it does |
|
|
107
|
-
|---|---|
|
|
108
|
-
| `proveOrigin(payment, request)` | the five checks below, against the recipient's own server |
|
|
109
|
-
| `proveSettlement(payment, request)` | ask the recipient whether it settled, returns the preimage or `null` |
|
|
110
|
-
| `isProvablyPaid(payment)` | whether the gateway's own report is self-consistent. A sanity check, not a proof |
|
|
111
|
-
| `preimageMatchesHash(preimage, hash)` | one sha256 comparison |
|
|
112
|
-
| `decodeInvoice(bolt11)` | the invoice's own amount, payment hash and description hash |
|
|
98
|
+
const preimage = await asked.prove();
|
|
99
|
+
```
|
|
113
100
|
|
|
114
|
-
|
|
101
|
+
Two names and one call. `requestPayment` mints the invoice on the first address that can
|
|
102
|
+
prove one, checks that invoice against the recipient's own domain before returning,
|
|
103
|
+
and draws the QR.
|
|
115
104
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
| `seal(secret, plaintext)`, `unseal` | the blob the gateway stores and cannot read |
|
|
122
|
-
| `toLnurl(url)` | bech32-encode an endpoint url |
|
|
123
|
-
| `bankTransfer(params)` | register a Czech QR platba as a watched payment. Refuses a gateway that serves strangers |
|
|
124
|
-
| `bankVerifyEndpoint(config)` | the other half, the LUD-21 shape backed by your own statement |
|
|
125
|
-
| `fioStatement(config)` | a `Statement` reading a Fio account, several tokens used strictly in turn |
|
|
126
|
-
| `lightningVerifyEndpoint(config)` | the same shape for Lightning, asking the wallet on the gateway's behalf. From `thunder-bridge/server` |
|
|
127
|
-
| `relayedVerifyUrl(mount, wallet, secret)` | the URL to hand the gateway instead of the wallet's, with the wallet's sealed inside |
|
|
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.
|
|
105
|
+
Then there are two different questions and both are worth asking. `paid` tells you
|
|
106
|
+
what the gateway says, and it is the fast one. `prove` asks the recipient's own
|
|
107
|
+
server, and only that is evidence the money arrived.
|
|
108
|
+
[docs/proving-a-payment.md](../docs/proving-a-payment.md) is the whole argument for
|
|
109
|
+
why.
|
|
138
110
|
|
|
139
|
-
|
|
140
|
-
|
|
111
|
+
An amount is `sats(21)`, `msat(21_000)` or `fiat("4.99", "USD")`, and a bare number
|
|
112
|
+
does not compile: the type is branded, so `21` cannot pass for a price and quietly
|
|
113
|
+
mean twenty-one thousandths of a satoshi. A fiat price is converted when the invoice
|
|
114
|
+
is minted, off the median of four MiCA authorised venues unless you pass your own,
|
|
115
|
+
and a decimal string is read digit by digit rather than through a float. Every
|
|
116
|
+
refusal is an `AmountError` carrying a `code`, and `AmountError.is(error)` is how
|
|
117
|
+
you recognise one: each entry point bundles its own copy of the class, so
|
|
118
|
+
`instanceof` holds within one import and that static holds across all of them.
|
|
141
119
|
|
|
142
|
-
|
|
143
|
-
another function of that shape and persistence wraps it from outside rather than
|
|
144
|
-
living inside it. Nothing above it changes, and the package stays ignorant of
|
|
145
|
-
whatever runtime you keep state in.
|
|
120
|
+
## How each payment method gets verified
|
|
146
121
|
|
|
147
|
-
|
|
122
|
+
Every rail ends the same way, with a preimage that has to hash to the payment hash
|
|
123
|
+
the gateway was given. What differs is who obtains the invoice, who is asked for the
|
|
124
|
+
preimage, and which side does the checking.
|
|
148
125
|
|
|
149
|
-
|
|
|
126
|
+
| `rails.lightning` | the gateway asks, at the recipient's LNURL callback |
|
|
150
127
|
|---|---|
|
|
151
|
-
|
|
|
152
|
-
|
|
|
153
|
-
|
|
|
154
|
-
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
the same whichever rail made it, which is what lets `firstToSettle` take a mixed
|
|
160
|
-
list without being told what is in it.
|
|
161
|
-
|
|
162
|
-
The gateway is already indifferent to all of this. It holds a payment hash, polls
|
|
163
|
-
a verify URL and reports what came back, so a rail is an SDK-side arrangement of
|
|
164
|
-
calls the gateway already answers, not a plugin it has to load.
|
|
165
|
-
|
|
166
|
-
**Pricing a fiat order** - [`src/price.ts`](src/price.ts), [`src/currency.ts`](src/currency.ts)
|
|
167
|
-
|
|
168
|
-
| Export | What it does |
|
|
128
|
+
| the gateway is told | the address list and the amount, and nothing else. It derives the hash and the `verify` URL by resolving the address, and hands both back |
|
|
129
|
+
| the invoice is checked by | you, `proveOrigin` runs five checks against the recipient's own domain |
|
|
130
|
+
| the gateway probes first | nothing, it resolved the address itself |
|
|
131
|
+
| the gateway polls | the wallet, directly |
|
|
132
|
+
| `settled` comes from | the wallet releasing its preimage |
|
|
133
|
+
| the pace is set by | the wallet, when it sends `Cache-Control: max-age`. When it sends none the gateway's own schedule decides |
|
|
134
|
+
|
|
135
|
+
| `rails.blindLightning` | you ask, with `invoiceFrom` on your server |
|
|
169
136
|
|---|---|
|
|
170
|
-
|
|
|
171
|
-
|
|
|
172
|
-
| `
|
|
173
|
-
| `
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
137
|
+
| the gateway is told | a hash, an expiry and your URL, with the wallet's sealed inside |
|
|
138
|
+
| the invoice is checked by | nobody needs to, you resolved the address yourself |
|
|
139
|
+
| the gateway probes first | `speaksVerify`: a GET on the URL, then a signed POST nonce it must echo |
|
|
140
|
+
| the gateway polls | your `serve.verify` endpoint, once `relayThrough` is set. Leave it off and the gateway polls the wallet directly, as on the minted rail |
|
|
141
|
+
| `settled` comes from | your endpoint, which unseals, asks the wallet and relays the answer |
|
|
142
|
+
| the pace is set by | you, `pollEverySecs` |
|
|
143
|
+
|
|
144
|
+
| `nwcRail` | your own wallet mints it, over NIP-47 `make_invoice` |
|
|
145
|
+
|---|---|
|
|
146
|
+
| the gateway is told | a hash and your URL, with the hash sealed inside |
|
|
147
|
+
| the invoice is checked by | nobody, it is your wallet |
|
|
148
|
+
| the gateway probes first | the same GET and signed nonce |
|
|
149
|
+
| the gateway polls | your `nwcVerifyEndpoint` |
|
|
150
|
+
| `settled` comes from | `lookup_invoice`, refused unless the wallet's own key signed it |
|
|
151
|
+
| the pace is set by | you, `pollEverySecs` |
|
|
152
|
+
|
|
153
|
+
| `rails.bank` | nobody, there is no invoice |
|
|
154
|
+
|---|---|
|
|
155
|
+
| the gateway is told | a hash and your URL, which names the amount and the reference |
|
|
156
|
+
| the invoice is checked by | nobody, there is no invoice to check |
|
|
157
|
+
| the gateway probes first | the same GET and signed nonce |
|
|
158
|
+
| the gateway polls | your `serve.bankVerify` endpoint |
|
|
159
|
+
| `settled` comes from | a `Statement` credit matching amount and currency exactly, with the reference anywhere in the payer's text |
|
|
160
|
+
| the pace is set by | you, `pollEverySecs` |
|
|
161
|
+
|
|
162
|
+
Two things are worth reading off those blocks rather than inferring.
|
|
163
|
+
|
|
164
|
+
**The checking side flips.** On the minted path the gateway resolved the address, so
|
|
165
|
+
it runs no verify probe and no verify challenge, and `proveOrigin` on your side is
|
|
166
|
+
the whole defence. A `webhookUrl` is challenged on both paths. On every watched path the gateway resolved nothing, so it probes the URL
|
|
167
|
+
and challenges it with a nonce before accepting the watch, refusing with `424` if
|
|
168
|
+
nothing answers. Deploy the endpoint before you register it. The challenge is on
|
|
169
|
+
unless the operator set `VERIFY_CHALLENGE=0`, which is also why a bare wallet
|
|
170
|
+
`verify` URL cannot be handed to `watch`: a wallet will not echo a nonce.
|
|
171
|
+
|
|
172
|
+
**What a preimage proves is the same on all four, and narrower than it looks:** that
|
|
173
|
+
the server holding the secret says the money arrived, made unforgeable by anyone
|
|
174
|
+
else. On the bank rail that secret is an HMAC you derive, which sounds weaker and is
|
|
175
|
+
not, because a wallet also minted the preimage it later releases. It rules out a
|
|
176
|
+
gateway inventing a settlement. It does not rule out a recipient lying about one, so
|
|
177
|
+
this protects a payer against the operator, not against the person being paid.
|
|
178
|
+
|
|
179
|
+
`proveWrapped` sits on a different axis. It compares two invoices on one payment
|
|
180
|
+
hash and asks nobody anything, so it says whether an operator's wrap is honest
|
|
181
|
+
without saying whether either invoice was paid.
|
|
182
|
+
|
|
183
|
+
### What each one costs you
|
|
184
|
+
|
|
185
|
+
**`rails.lightning`**
|
|
186
|
+
|
|
187
|
+
- the gateway holds the address and the amount, so your order book is readable
|
|
188
|
+
from its own logs
|
|
189
|
+
- it polls the wallet directly, which puts the recipient's provider in its logs and
|
|
190
|
+
in front of its peers
|
|
191
|
+
- the wallet's `Cache-Control` sets the poll pace, so how fast a settlement is
|
|
192
|
+
noticed is not yours to decide
|
|
193
|
+
|
|
194
|
+
**`rails.blindLightning`**
|
|
195
|
+
|
|
196
|
+
- a service of your own that has to stay up, so a browser-only integration cannot
|
|
197
|
+
use this rail at all
|
|
198
|
+
- one long-lived sealing secret, which `seal` refuses under 32 characters, so
|
|
199
|
+
`openssl rand -hex 16` is the shortest thing that works
|
|
200
|
+
- your endpoint being down means the gateway cannot verify and the payment sits
|
|
201
|
+
`pending`
|
|
202
|
+
- a wallet you cannot reach answers `502`, so the gateway retries instead of
|
|
203
|
+
concluding the invoice went unpaid. The body still reads `settled: false`, and the
|
|
204
|
+
status is what separates "could not ask" from "asked, and no"
|
|
205
|
+
|
|
206
|
+
**`nwcRail`**
|
|
207
|
+
|
|
208
|
+
- an NWC connection to your own wallet, and the nostr relays behind it
|
|
209
|
+
- **scope the connection to `make_invoice` and `lookup_invoice`, never
|
|
210
|
+
`pay_invoice`.** It is a key that spends, and a leak with the wrong scope drains
|
|
211
|
+
the wallet
|
|
212
|
+
- relays unreachable means no verification
|
|
213
|
+
|
|
214
|
+
**`rails.bank`**
|
|
215
|
+
|
|
216
|
+
- the gateway has to be one of your own: the verify URL names the amount and the
|
|
217
|
+
reference, so whoever runs the gateway reads your order book from the watches
|
|
218
|
+
alone
|
|
219
|
+
- the secret is the entire proof. **Lose it and every past proof is gone**, because
|
|
220
|
+
each preimage is derived from it
|
|
221
|
+
|
|
222
|
+
**The bank rail has one silent failure worth testing before you promise anybody a
|
|
223
|
+
rail.** Two shapes leave a payment `pending` while the money is already in the
|
|
224
|
+
account: a bank that truncates the reference, since the match asks whether the
|
|
225
|
+
reference is inside what the bank forwarded rather than the other way round, and a
|
|
226
|
+
payer whose bank forwards nothing but a numeric variable symbol, since an
|
|
227
|
+
alphanumeric reference cannot travel in a numeric field and `X-VS` is not read as an
|
|
228
|
+
alternative. Neither has been seen with Fio, which forwards the message untouched.
|
|
229
|
+
Check it against the banks your payers actually use.
|
|
230
|
+
|
|
231
|
+
## Who you still have to trust
|
|
232
|
+
|
|
233
|
+
The proof narrows the trust rather than removing it. Three parties are left, and
|
|
234
|
+
they are not equally constrained.
|
|
235
|
+
|
|
236
|
+
| | You trust it with | It cannot |
|
|
237
|
+
|---|---|---|
|
|
238
|
+
| the gateway | which of your addresses gets paid, and whether it answers at all | pay an address not on your list, bill you more than you asked, or invent a settlement |
|
|
239
|
+
| the recipient's wallet provider | that a preimage it releases means the money arrived | mint an invoice for a different account on the same domain |
|
|
240
|
+
| the recipient | that the sum they asked for is the sum they are owed | nothing here checks this at all |
|
|
184
241
|
|
|
185
|
-
|
|
186
|
-
|
|
242
|
+
The gateway also sees your address list and your amount. It cannot invent a
|
|
243
|
+
settlement because the preimage comes from the recipient's own server, and the
|
|
244
|
+
provider cannot mint for another account because the description hash pins an
|
|
245
|
+
invoice to one user's metadata under LUD-06. A recipient inflating a total is
|
|
246
|
+
outside what any of it proves.
|
|
187
247
|
|
|
188
|
-
|
|
189
|
-
const tipJar = lnurlToSvg("https://thunder-bridge.agora.gripe/.well-known/lnurlp/21sats");
|
|
190
|
-
```
|
|
248
|
+
Four sharp edges, worth reading before you build:
|
|
191
249
|
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
**
|
|
197
|
-
`
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
`
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
`
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
Check 1 also builds the url the rest of the chain uses: your `user@domain` becomes
|
|
228
|
-
`https://domain/.well-known/lnurlp/user` under LUD-16, with the domain lowercased
|
|
229
|
-
and the local part left exactly as you wrote it. The gateway's spelling is used to
|
|
230
|
-
find the match and never to build the url, so it cannot aim the proof at a
|
|
231
|
-
different account on a provider that treats the local part as case-sensitive.
|
|
232
|
-
|
|
233
|
-
### Origin is not settlement
|
|
234
|
-
|
|
235
|
-
Those five checks are about an invoice. They prove that what you are putting in
|
|
236
|
-
front of a payer is the recipient's own invoice for the right amount. They say
|
|
237
|
-
nothing about whether anybody paid it, and the two answers to that are not the
|
|
238
|
-
same answer.
|
|
239
|
-
|
|
240
|
-
`isProvablyPaid` asks whether the gateway's report contradicts itself: a `paid`
|
|
241
|
-
status, a preimage, and a `bolt11` whose payment hash that preimage opens. All
|
|
242
|
-
three values arrive from the gateway in one message, so this is internal
|
|
243
|
-
consistency and nothing more. A gateway that generates a preimage, hashes it and
|
|
244
|
-
builds an invoice around that hash passes it. It catches breakage and
|
|
245
|
-
carelessness, not an operator who means it.
|
|
246
|
-
|
|
247
|
-
`proveSettlement` asks the recipient. It re-runs the origin proof, which is what
|
|
248
|
-
ties `verifyUrl` to the recipient's own callback origin, then reads that url.
|
|
249
|
-
`null` means the recipient's own server is not claiming the money arrived,
|
|
250
|
-
whatever the gateway says.
|
|
251
|
-
|
|
252
|
-
Use `isProvablyPaid` to throw out a record that is obviously wrong. Use
|
|
253
|
-
`proveSettlement` before you part with anything.
|
|
254
|
-
|
|
255
|
-
### The host guard
|
|
256
|
-
|
|
257
|
-
Every outbound url in the chain must be public https. The guard refuses loopback,
|
|
258
|
-
link-local, the RFC 1918 ranges, carrier-grade NAT, unique local addresses, and
|
|
259
|
-
IPv4-mapped IPv6 unwrapping into any of those. It also refuses a host with no dot
|
|
260
|
-
such as `nas`, the trailing-dot `localhost.`, and anything whose last label is
|
|
261
|
-
`local`, `internal`, `lan`, `arpa`, `test` or `invalid`.
|
|
262
|
-
|
|
263
|
-
It vets the first hop only. See below.
|
|
264
|
-
|
|
265
|
-
### Which transfer counts as paying
|
|
266
|
-
|
|
267
|
-
`bankVerifyEndpoint` calls a credit a settlement when the amount and the currency
|
|
268
|
-
match exactly and the reference appears anywhere in what the payer wrote,
|
|
269
|
-
case-insensitively. With `fioStatement` "what the payer wrote" is four Fio columns
|
|
270
|
-
joined: the variable symbol, the user identification, the message for the recipient
|
|
271
|
-
and the payer's own reference. So a bank that prefixes, appends, or moves the text
|
|
272
|
-
between those fields still settles.
|
|
273
|
-
|
|
274
|
-
Two shapes do not settle, and both leave the payment `pending` while the money is
|
|
275
|
-
already in the account:
|
|
276
|
-
|
|
277
|
-
- **A shortened reference.** The match asks whether the reference is inside what the
|
|
278
|
-
bank forwarded, not the other way round, so a bank that truncates it never matches.
|
|
279
|
-
- **A payer whose bank forwards nothing but a numeric variable symbol.** The
|
|
280
|
-
reference is alphanumeric and cannot travel in a numeric field, and the match does
|
|
281
|
-
not read `X-VS` as an alternative.
|
|
282
|
-
|
|
283
|
-
Neither has been seen with Fio, which forwards the message untouched. Check it
|
|
284
|
-
against the banks your payers actually use before you promise them a rail.
|
|
285
|
-
|
|
286
|
-
## Making the gateway poll nobody but you
|
|
287
|
-
|
|
288
|
-
By default a Lightning watch hands the gateway the wallet's own verify URL, so the
|
|
289
|
-
gateway polls `blink.sv` or `coinos.io` directly and its logs, its ledger and its
|
|
290
|
-
peers all carry that domain. If you would rather it never touched a third party and
|
|
291
|
-
never learned which provider your recipient uses, put your own endpoint in between.
|
|
250
|
+
- **A colluding custodian defeats all of it.** If the recipient's wallet provider
|
|
251
|
+
and the gateway are the same party, then whoever holds the money also serves the
|
|
252
|
+
metadata and answers the verify requests. Every check passes. This protects a
|
|
253
|
+
payer against the operator, never against the recipient's own custodian.
|
|
254
|
+
- **The two proof fetches vet the first hop and no further.** `proveOrigin` and
|
|
255
|
+
`proveSettlement` use the runtime's default redirect handling, so a public https
|
|
256
|
+
host answering `302` to a private address is followed there. `invoiceFrom` is not
|
|
257
|
+
like this: it resolves through the outbound guard, which sets `redirect: "manual"`
|
|
258
|
+
and re-vets every hop. Keep egress control outside this package if that matters.
|
|
259
|
+
- **A payment read cold is only as pinned as its creation.** `payment` checks
|
|
260
|
+
the preimage against the `paymentHash` in the same record, and it was
|
|
261
|
+
`proveOrigin` at creation, against the request you wrote, that tied that hash to
|
|
262
|
+
an invoice the recipient issued. Store the request alongside the payment id, or a
|
|
263
|
+
cold read is checking the gateway's numbers against each other and nothing more.
|
|
264
|
+
- **Availability is not provable, and an address is not a person.** Every check
|
|
265
|
+
here is about an invoice you were given, none about one you were refused, and
|
|
266
|
+
proving an invoice belongs to an address never proves the address belongs to
|
|
267
|
+
whoever you think it does.
|
|
268
|
+
|
|
269
|
+
`carriesProof` is the one to be careful with: it asks only whether a report holds
|
|
270
|
+
together, so a gateway that generates a preimage, hashes it and builds an invoice
|
|
271
|
+
around that hash passes it. If a payment matters, ask the recipient with
|
|
272
|
+
`prove` on the payment request or with `proveSettlement`. The full argument, including the five
|
|
273
|
+
origin checks and their failure codes, is in
|
|
274
|
+
[docs/proving-a-payment.md](../docs/proving-a-payment.md).
|
|
275
|
+
|
|
276
|
+
## What you call
|
|
277
|
+
|
|
278
|
+
[docs/api.md](../docs/api.md) is the whole surface, generated from the TSDoc on
|
|
279
|
+
every export by [`tools/api-reference.ts`](../tools/api-reference.ts) and checked in
|
|
280
|
+
CI, so nothing there can be out of date and nothing here repeats it. Your editor
|
|
281
|
+
has the same text on the export itself.
|
|
282
|
+
|
|
283
|
+
The shape of it is worth stating once, because it is the thing that makes the rest
|
|
284
|
+
findable. One instance is the entry to everything:
|
|
292
285
|
|
|
293
286
|
```ts
|
|
294
|
-
import {
|
|
287
|
+
import { sats, ThunderBridge } from "thunder-bridge";
|
|
295
288
|
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
);
|
|
299
|
-
|
|
300
|
-
const rail = blindLightningRail({
|
|
301
|
-
gateway,
|
|
302
|
-
lnAddresses: ["you@blink.sv"],
|
|
303
|
-
amountMsat: (order) => order.amountMinor * 40,
|
|
304
|
-
relayVerifyThrough: { endpoint: "https://shop.example/verify/lightning", secret: RELAY_SECRET },
|
|
289
|
+
const gateway = new ThunderBridge("https://public.thunder-bridge.agora.gripe", {
|
|
290
|
+
secret: "a-long-lived-server-side-secret",
|
|
305
291
|
});
|
|
306
|
-
```
|
|
307
|
-
|
|
308
|
-
The wallet's URL is sealed into the query with your secret, so what the gateway
|
|
309
|
-
stores and replicates is a blob it cannot read. It polls you, you ask the wallet,
|
|
310
|
-
and the preimage still comes from the recipient's own server and still has to hash
|
|
311
|
-
to the payment hash, so standing in the middle buys privacy and pacing without
|
|
312
|
-
making you something anyone has to trust. A wallet you cannot reach answers `502`
|
|
313
|
-
rather than "not settled", because those are different claims.
|
|
314
|
-
|
|
315
|
-
Both rails then run through endpoints of yours, on a pace you set, and the gateway
|
|
316
|
-
is only ever talking to servers that asked to be talked to. It costs you a service
|
|
317
|
-
that has to stay up: a browser-only integration cannot do this, and should keep
|
|
318
|
-
letting the gateway poll the wallet.
|
|
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
292
|
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
const connection = nwcConnection(process.env.NWC_URI);
|
|
293
|
+
const asked = await gateway.requestPayment({ paidTo: "iamfatik@blink.sv", amount: sats(21) });
|
|
294
|
+
const read = await gateway.payment(asked.id);
|
|
295
|
+
const quoted = await gateway.quote({ paidTo: "iamfatik@blink.sv", amount: sats(21) });
|
|
331
296
|
|
|
332
|
-
|
|
333
|
-
|
|
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 },
|
|
297
|
+
const lnurl = gateway.serve.lnurlPay({
|
|
298
|
+
paidTo: "iamfatik@blink.sv",
|
|
299
|
+
amount: sats(21),
|
|
300
|
+
secret: "a-long-lived-server-side-secret",
|
|
341
301
|
});
|
|
302
|
+
const rail = gateway.rails.lightning({ paidTo: "iamfatik@blink.sv", amount: () => sats(21) });
|
|
342
303
|
```
|
|
343
304
|
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
your rate limit.
|
|
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
|
-
|
|
367
|
-
The gateway also asks the URL once, before it accepts the watch, and refuses with
|
|
368
|
-
`424` if it does not answer this shape. So deploy the endpoint first and register
|
|
369
|
-
second. That is what stops anyone pointing a gateway at a server that never asked to
|
|
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";
|
|
305
|
+
- **the payments themselves** are `requestPayment`, `mint`, `quote`, `watch`, `payment`,
|
|
306
|
+
`payments`, `settled`, `firstSettled`, `follow`, `ticket`, `nameFor` and
|
|
307
|
+
`webhookKey`, all on the instance
|
|
308
|
+
- **what you mount** is on `gateway.serve`: an LNURL-pay endpoint, the two ticket
|
|
309
|
+
endpoints, the verify endpoints for Lightning and for a bank, the webhook route,
|
|
310
|
+
and the readers under it
|
|
311
|
+
- **one call per sale** is on `gateway.rails`: `lightning`, `blindLightning`,
|
|
312
|
+
`bank`, and `transfer` for a bank transfer on its own
|
|
313
|
+
- **the proofs** are free functions, deliberately, because a proof you cannot run
|
|
314
|
+
without the thing being audited is not a proof: `proveOrigin`, `proveSettlement`,
|
|
315
|
+
`proveWrapped`, `carriesProof`, `decodeInvoice`, `preimageMatchesHash`
|
|
316
|
+
- **a payment reads without an assertion.** `Payment` is `MintedPayment |
|
|
317
|
+
WatchedPayment`, so checking `kind` is what makes the address, the amount and the
|
|
318
|
+
invoice non-null. The gateway writes those three together or writes none of them,
|
|
319
|
+
and a record carrying some of the three is refused rather than read
|
|
320
|
+
- **the amounts** are `sats`, `msat` and `fiat`
|
|
388
321
|
|
|
389
|
-
|
|
390
|
-
|
|
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.
|
|
415
|
-
|
|
416
|
-
## What is still trusted
|
|
417
|
-
|
|
418
|
-
- **The gateway chooses which of your addresses gets paid.** Nothing here can
|
|
419
|
-
tell a genuine failure of the first from a preference for the third. What it
|
|
420
|
-
cannot do is pick an address off your list.
|
|
421
|
-
- **The gateway can refuse you.** Availability is not provable. Every check here
|
|
422
|
-
is about an invoice you were given, none about one you were not.
|
|
423
|
-
- **The gateway sees your request.** The address list, the amount and the webhook
|
|
424
|
-
secret pass through it, because it has to make the calls. Treat the secret as
|
|
425
|
-
shared with it and the address list as public.
|
|
426
|
-
- **Everything it says about a settlement, until you ask the recipient.**
|
|
427
|
-
`isProvablyPaid` only asks whether that account holds together. If a payment
|
|
428
|
-
matters, ask.
|
|
429
|
-
- **The host guard vets the first hop and no further.** Both fetches use the
|
|
430
|
-
runtime's default redirect handling, so a public https host answering with a 302
|
|
431
|
-
to a private address is followed there. Keep egress control outside this
|
|
432
|
-
package if that matters.
|
|
433
|
-
- **A colluding custodian defeats all of it.** If the recipient's wallet provider
|
|
434
|
-
and the gateway are the same party, then the party holding the money is also the
|
|
435
|
-
one serving the metadata and answering the verify requests. Every check would
|
|
436
|
-
pass. This protects a payer against the gateway, not against the recipient's own
|
|
437
|
-
custodian.
|
|
438
|
-
- **TLS and DNS for the recipient's domain**, and an address is not a person. This
|
|
439
|
-
proves an invoice belongs to an address, never that the address belongs to
|
|
440
|
-
whoever you think.
|
|
441
|
-
- **A payment read cold is only as pinned as its creation.** `getPayment` checks
|
|
442
|
-
the preimage against the `paymentHash` in the same record. It was `proveOrigin`
|
|
443
|
-
at creation, against the request you wrote, that tied that hash to an invoice
|
|
444
|
-
the recipient issued. So store the request alongside the payment id, or you are
|
|
445
|
-
checking the gateway's numbers against each other and nothing more.
|
|
322
|
+
What your service answers once those handlers are mounted is written out in
|
|
323
|
+
[`openapi.yaml`](openapi.yaml), shipped with this package.
|
|
446
324
|
|
|
447
325
|
## Errors
|
|
448
326
|
|
|
449
327
|
Every failure from the gateway is an RFC 9457 problem document. Branch on `type`,
|
|
450
|
-
never on prose. `error.status` is what the transport carried, and a document
|
|
451
|
-
|
|
328
|
+
never on prose. `error.status` is what the transport carried, and a document naming
|
|
329
|
+
a different status in its own body does not override it.
|
|
452
330
|
|
|
453
|
-
|
|
331
|
+
Every `type` below is prefixed `urn:problem-type:thunder-bridge:`, and every one of
|
|
332
|
+
them is a static string on `ProblemError`, so nothing has to be copied out of this
|
|
333
|
+
table by hand. `ProblemError.is(error, ProblemError.PAYMENT_ALREADY_WATCHED)` is how
|
|
334
|
+
you branch on a type that has no error class of its own.
|
|
335
|
+
|
|
336
|
+
| `type` | Status | What it is |
|
|
454
337
|
|---|---|---|
|
|
455
|
-
|
|
|
456
|
-
|
|
|
457
|
-
| `
|
|
458
|
-
| `
|
|
459
|
-
| `
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
the
|
|
465
|
-
|
|
338
|
+
| `invalid-request` | 400, or 413 for a body over the size ceiling | `detail` names the field |
|
|
339
|
+
| `no-wallet-available` | 502, else 422, else 400, following the worst wallet | `NoWalletAvailableError`, `wallets` says why each failed |
|
|
340
|
+
| `request-in-flight` | 409 | a request with this `Idempotency-Key` is still running, as `IdempotencyConflictError` |
|
|
341
|
+
| `idempotency-key-reused` | 409 | that key was used for a different request, as `IdempotencyConflictError` |
|
|
342
|
+
| `payment-already-watched` | 409 | that payment hash is already watched here |
|
|
343
|
+
| `caller-unknown` | 403 | the instance keeps a list of callers and your key is not on it |
|
|
344
|
+
| `verify-host-refused` | 403 | this instance will not mint, because minting is off or `VERIFY_HOSTS` pins it to a list. Resolve the address yourself and use `watch`. A verify URL that is not public https is `invalid-request` instead |
|
|
345
|
+
| `verify-unconfirmed` | 424 | the URL did not answer the LUD-21 shape |
|
|
346
|
+
| `verify-unconsented` | 424 | the URL did not echo the challenge nonce |
|
|
347
|
+
| `webhook-unconfirmed` | 424 | the webhook URL did not answer its challenge |
|
|
348
|
+
| `too-many-pending` | 429, with `ratelimit-limit` and `ratelimit-remaining` set | the caller is over its share of the instance's `MAX_PENDING` |
|
|
349
|
+
|
|
350
|
+
The rest carry `about:blank` as their type, which the gateway seeds into every
|
|
351
|
+
problem body: `401` when the bearer token does not match, `404` both for an id this
|
|
352
|
+
gateway never heard of and for one it knows that belongs to a different caller key,
|
|
353
|
+
so a `403` can never confirm an id exists, `410` when you replay an
|
|
354
|
+
`Idempotency-Key` whose payment has since been pruned, `500`, and `503` while the
|
|
355
|
+
instance is draining or its own health check reads stalled. On a `404` `payment`
|
|
356
|
+
returns `null` rather than throwing.
|
|
466
357
|
|
|
467
358
|
`GatewayCheatError` is different in kind. It reports a gateway that demonstrably
|
|
468
|
-
misbehaved, and `code` names the check that caught it
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
run, because the recipient's server was down, timed out, answered something
|
|
474
|
-
unreadable, or the browser was blocked by CORS. Not an accusation, and not a clean
|
|
475
|
-
bill of health either. Decide what you want to do with an unproven invoice, and
|
|
476
|
-
decide it explicitly.
|
|
359
|
+
misbehaved, and `code` names the check that caught it. `UnverifiedRecipientError` is
|
|
360
|
+
neither an accusation nor a clean bill of health: it means a check could not be run
|
|
361
|
+
at all, because the recipient's server was down, timed out, answered something
|
|
362
|
+
unreadable, or the browser was blocked by CORS. Decide what you want to do with an
|
|
363
|
+
unproven invoice, and decide it explicitly.
|
|
477
364
|
|
|
478
365
|
```ts
|
|
479
366
|
import {
|
|
480
367
|
GatewayCheatError,
|
|
368
|
+
msat,
|
|
481
369
|
NoWalletAvailableError,
|
|
482
370
|
ProblemError,
|
|
371
|
+
ThunderBridge,
|
|
483
372
|
UnverifiedRecipientError,
|
|
484
373
|
} from "thunder-bridge";
|
|
485
374
|
|
|
375
|
+
declare const gateway: ThunderBridge;
|
|
376
|
+
declare function report(line: string): void;
|
|
377
|
+
|
|
486
378
|
try {
|
|
487
|
-
|
|
488
|
-
show(payment);
|
|
379
|
+
await gateway.mint({ paidTo: "iamfatik@blink.sv", amount: msat(21_000) });
|
|
489
380
|
} catch (error) {
|
|
490
381
|
if (error instanceof GatewayCheatError) {
|
|
491
382
|
report(`the gateway cheated: ${error.code} on payment ${error.paymentId}`);
|
|
492
383
|
} else if (error instanceof UnverifiedRecipientError) {
|
|
493
384
|
report(`could not reach ${error.lnAddress} to check the invoice`);
|
|
494
385
|
} else if (error instanceof NoWalletAvailableError) {
|
|
495
|
-
for (const wallet of error.wallets)
|
|
386
|
+
for (const wallet of error.wallets) {
|
|
387
|
+
report(`${wallet.address}: ${wallet.reason}`);
|
|
388
|
+
}
|
|
496
389
|
} else if (error instanceof ProblemError) {
|
|
497
390
|
report(`${error.status} ${error.title}`);
|
|
498
391
|
} else {
|
|
@@ -507,127 +400,54 @@ Pass `webhookUrl` when you create a payment, or on any rail. There is no webhook
|
|
|
507
400
|
secret: a gateway holds nothing of yours, and sending one is refused rather than
|
|
508
401
|
ignored. Every delivery is signed `ed25519=<signature>` with the key the gateway
|
|
509
402
|
publishes at `/webhook-key`, over `<x-timestamp>.<raw body>` rather than the body
|
|
510
|
-
alone, so a captured delivery cannot be replayed at you later.
|
|
511
|
-
|
|
512
|
-
The body is a `Settlement`: the id, the status, the payment hash, the preimage and
|
|
513
|
-
the time. Enough to act on and to check, and no more, so a retry is the same size
|
|
514
|
-
whatever you put in your own record. Read `sealed` back by id when you want it.
|
|
515
|
-
Retries widen until the payment itself runs out, never sooner than an hour. An
|
|
516
|
-
invoice that expires fires nothing.
|
|
517
|
-
|
|
518
|
-
Delivery is at-least-once, so deduplicate on `id`.
|
|
403
|
+
alone, so a captured delivery cannot be replayed at you later. Delivery is
|
|
404
|
+
at-least-once, so deduplicate on `id`.
|
|
519
405
|
|
|
520
406
|
Your handler answers one challenge before any of that. The gateway POSTs
|
|
521
|
-
`{"type":"webhook-challenge","nonce":"..."}` to the URL while the create is still
|
|
522
|
-
and refuses the payment with a 424 unless the nonce comes back, so
|
|
523
|
-
|
|
524
|
-
challenge and hands you the response to return, or `null` when the delivery was a real
|
|
525
|
-
settlement, and it leaves the body unread either way.
|
|
407
|
+
`{"type":"webhook-challenge","nonce":"..."}` to the URL while the create is still
|
|
408
|
+
open, and refuses the payment with a `424` unless the nonce comes back, so deploy
|
|
409
|
+
the endpoint before you register it.
|
|
526
410
|
|
|
527
411
|
```ts
|
|
528
|
-
import {
|
|
529
|
-
answerWebhookChallengeRequest,
|
|
530
|
-
isProvablySettled,
|
|
531
|
-
parseSettlementRequest,
|
|
532
|
-
} from "thunder-bridge";
|
|
533
|
-
|
|
534
|
-
const signs = { publicKey: await gateway.webhookKey() };
|
|
535
|
-
|
|
536
|
-
app.post("/hooks/paid", async (context) => {
|
|
537
|
-
const challenge = await answerWebhookChallengeRequest(context.req.raw, signs);
|
|
538
|
-
if (challenge) return challenge;
|
|
539
|
-
|
|
540
|
-
const settled = await parseSettlementRequest(context.req.raw, signs);
|
|
541
|
-
if (settled === null) return context.text("bad signature", 401);
|
|
542
|
-
if (!isProvablySettled(settled)) return context.text("no preimage that hashes to it", 402);
|
|
543
|
-
|
|
544
|
-
await fulfil(settled.id, settled.preimage);
|
|
545
|
-
return context.text("ok");
|
|
546
|
-
});
|
|
547
|
-
```
|
|
548
|
-
|
|
549
|
-
`isProvablySettled` answers the only question that matters about a delivery: it says
|
|
550
|
-
paid and it carries a preimage that hashes to the payment hash the same body names.
|
|
551
|
-
Ask the recipient's own server with `proveSettlement` when the payment is one you
|
|
552
|
-
minted through the gateway and you want the proof to come from somewhere other than
|
|
553
|
-
the delivery.
|
|
554
|
-
|
|
555
|
-
### Every rail sends the same body
|
|
556
|
-
|
|
557
|
-
`bankRail` and `blindLightningRail` used to need a parser of their own, because their
|
|
558
|
-
webhook carried no address, no amount and no invoice while a minted one did. A
|
|
559
|
-
delivery is a `Settlement` on every rail now, so `parseSettlementRequest` is the only
|
|
560
|
-
one to reach for. `parseWatchedWebhookRequest` is still there for reading the shape a
|
|
561
|
-
socket frame and `getWatched` hand back, which is a payment rather than a delivery.
|
|
562
|
-
|
|
563
|
-
Give each rail its own path, as above, and neither endpoint has to guess which body
|
|
564
|
-
it was handed. Both events also carry `kind`, `"minted"` or `"watched"`, so a single
|
|
565
|
-
path serving a trigger that both rails settle on can branch on the field instead of
|
|
566
|
-
on which fields are missing.
|
|
567
|
-
|
|
568
|
-
### The gateway holds nothing of yours
|
|
412
|
+
import { ThunderBridge } from "thunder-bridge";
|
|
569
413
|
|
|
570
|
-
|
|
571
|
-
`x-signature: ed25519=<signature>` over `<x-timestamp>.<raw body>`. Fetch the public
|
|
572
|
-
half once and keep it.
|
|
414
|
+
declare function fulfil(paymentId: string, preimage: string): Promise<void>;
|
|
573
415
|
|
|
574
|
-
|
|
575
|
-
const signs = { publicKey: await gateway.webhookKey() };
|
|
576
|
-
|
|
577
|
-
app.post("/hooks/paid", async (context) => {
|
|
578
|
-
const challenge = await answerWebhookChallengeRequest(context.req.raw, signs);
|
|
579
|
-
if (challenge) return challenge;
|
|
580
|
-
|
|
581
|
-
const settled = await parseSettlementRequest(context.req.raw, signs);
|
|
582
|
-
if (settled === null) return context.text("bad signature", 401);
|
|
583
|
-
...
|
|
584
|
-
});
|
|
585
|
-
```
|
|
586
|
-
|
|
587
|
-
Answering echoes the nonce and nothing else, because there is nothing to sign it with.
|
|
588
|
-
Holding the URL the gateway challenged is the whole proof.
|
|
589
|
-
|
|
590
|
-
The key is derived from the gateway's `CLUSTER_KEY`, so every instance in one cluster
|
|
591
|
-
signs alike and an operator rotating that key changes this one too. A signature that
|
|
592
|
-
stops verifying is therefore a reason to read `/webhook-key` again before it is a
|
|
593
|
-
reason to distrust the gateway. A `sha256=` signature is refused outright: that scheme
|
|
594
|
-
is gone.
|
|
595
|
-
|
|
596
|
-
`parseSettlementRequest` refuses anything more than five minutes out of date,
|
|
597
|
-
adjustable with `toleranceSecs`. The signature proves the delivery came from the
|
|
598
|
-
gateway. It does not prove the payment happened, because the gateway holds the key
|
|
599
|
-
that signs it either way. The proof is the preimage, checked by `isProvablySettled`
|
|
600
|
-
against the hash in the same body, or `proveSettlement` against the recipient's own
|
|
601
|
-
server when you want the answer from somewhere else entirely.
|
|
602
|
-
|
|
603
|
-
For a framework that hands you the raw body and headers separately, use
|
|
604
|
-
`parseWebhook`. The body must be the bytes as received, so mount a raw body parser
|
|
605
|
-
on that route and not a JSON one.
|
|
416
|
+
const gateway = new ThunderBridge("https://public.thunder-bridge.agora.gripe");
|
|
606
417
|
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
const signs = { publicKey: await gateway.webhookKey() };
|
|
612
|
-
|
|
613
|
-
app.post("/hooks/paid", express.raw({ type: "application/json" }), async (request, response) => {
|
|
614
|
-
const payment = await parseWebhook(
|
|
615
|
-
request.body,
|
|
616
|
-
request.get("x-signature") ?? "",
|
|
617
|
-
signs,
|
|
618
|
-
request.get("x-timestamp") ?? "",
|
|
619
|
-
);
|
|
620
|
-
response.sendStatus(payment === null ? 401 : 200);
|
|
418
|
+
export const POST = gateway.serve.webhook({
|
|
419
|
+
onSettled: async (settlement) => {
|
|
420
|
+
await fulfil(settlement.id, settlement.preimage);
|
|
421
|
+
},
|
|
621
422
|
});
|
|
622
423
|
```
|
|
623
424
|
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
425
|
+
That route answers the challenge, reads the gateway's own published key, checks the
|
|
426
|
+
signature, and calls you only for a delivery that proves itself: it says paid and it
|
|
427
|
+
carries a preimage that hashes to the payment hash the same body names. A delivery
|
|
428
|
+
that proves nothing gets a `202` and no callback, because acting on an unproven
|
|
429
|
+
claim is the one thing this refuses to do. Pass `onUnproven` when an expiry is news
|
|
430
|
+
you want.
|
|
431
|
+
|
|
432
|
+
`onSettled` is handed a `Proven<Settlement>`, so the preimage is a `string` rather
|
|
433
|
+
than something to coerce: the check the route already ran is what narrows it.
|
|
434
|
+
|
|
435
|
+
`gateway.serve.readSettlement` and `gateway.serve.readPayment` are the same checks
|
|
436
|
+
without the route, for a handler you would rather write yourself. Ask the
|
|
437
|
+
recipient's own server with `proveSettlement` when you want the proof to come from
|
|
438
|
+
somewhere other than the delivery.
|
|
439
|
+
|
|
440
|
+
## More
|
|
441
|
+
|
|
442
|
+
- [docs/lud21-coverage.md](../docs/lud21-coverage.md) - which address domains
|
|
443
|
+
release a preimage, and how that was measured
|
|
444
|
+
- [docs/proving-a-payment.md](../docs/proving-a-payment.md) - the five origin checks
|
|
445
|
+
and their failure codes, what settlement means, making the gateway poll nobody but
|
|
446
|
+
you, the NWC rail, wrapped invoices, and webhooks in full
|
|
447
|
+
- [docs/api.md](../docs/api.md) - every export, generated from the code
|
|
448
|
+
- [docs/recipes.md](../docs/recipes.md) - one runnable program per use case, every name in
|
|
449
|
+
it linked to its own entry in the reference
|
|
450
|
+
- [the gateway](../README.md) - one level up in this repository
|
|
631
451
|
|
|
632
452
|
## Development
|
|
633
453
|
|