thunder-bridge 1.4.1 → 1.4.2
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 +338 -528
- package/openapi.yaml +1 -1
- package/package.json +1 -1
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` 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,326 @@ one level up in [this repository](../README.md).
|
|
|
28
47
|
npm install thunder-bridge
|
|
29
48
|
```
|
|
30
49
|
|
|
31
|
-
|
|
50
|
+
Node 22 or newer, for anything that opens a socket: `waitForPayment`,
|
|
51
|
+
`waitForWatched`, `firstToSettle` and `followTrigger` on the gateway side, and every
|
|
52
|
+
NWC call on the wallet side, since a nostr relay is a socket too. Node only exposes
|
|
53
|
+
a global `WebSocket` from 22 onwards and there is no fallback to install. The rest,
|
|
54
|
+
which is every call that is one or more `fetch` requests, runs on any runtime with
|
|
55
|
+
`fetch` and `crypto.subtle`.
|
|
56
|
+
|
|
57
|
+
The package has two entry points.
|
|
32
58
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
59
|
+
| Import | Needs | Has |
|
|
60
|
+
|---|---|---|
|
|
61
|
+
| `thunder-bridge` | `fetch`, `crypto.subtle`, `URL`, `WebSocket` | everything except the server-only exports |
|
|
62
|
+
| `thunder-bridge/server` | `node:dns` as well | `invoiceFrom`, `askWallet`, `lnurlPayEndpoint`, the ticket handlers, `lightningVerifyEndpoint`, `nwcVerifyEndpoint`, and the blind and NWC rails |
|
|
36
63
|
|
|
37
|
-
The
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
64
|
+
The second one resolves lightning addresses and refuses a private host, so it needs
|
|
65
|
+
DNS and will not run on Cloudflare Workers. `bankVerifyEndpoint` is on the main
|
|
66
|
+
entry rather than there, because a bank statement needs no DNS.
|
|
67
|
+
|
|
68
|
+
## Quick start
|
|
69
|
+
|
|
70
|
+
A page can run the whole flow with no backend of its own. The url below is a shared
|
|
71
|
+
demo gateway that answers anyone and forgets everything on restart, so this snippet
|
|
72
|
+
runs as written. It is a base url rather than a page, so opening it in a browser
|
|
73
|
+
gives a `404` and [`/health`](https://public.thunder-bridge.agora.gripe/health) is
|
|
74
|
+
what tells you it is up.
|
|
42
75
|
|
|
43
76
|
```ts
|
|
44
|
-
import {
|
|
77
|
+
import {
|
|
78
|
+
ThunderBridge,
|
|
79
|
+
invoiceToSvg,
|
|
80
|
+
proveSettlement,
|
|
81
|
+
type CreatePaymentParams,
|
|
82
|
+
} from "thunder-bridge";
|
|
45
83
|
|
|
46
84
|
const gateway = new ThunderBridge("https://public.thunder-bridge.agora.gripe");
|
|
47
85
|
|
|
48
86
|
const request: CreatePaymentParams = {
|
|
49
|
-
lnAddresses: ["
|
|
87
|
+
lnAddresses: ["iamfatik@blink.sv", "iamfatik@coinos.io"],
|
|
50
88
|
amountMsat: 21_000,
|
|
51
89
|
};
|
|
52
90
|
|
|
53
91
|
const payment = await gateway.createPayment(request);
|
|
54
92
|
|
|
55
93
|
const target = document.querySelector("#qr");
|
|
56
|
-
if (target
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
Keep the `request` object. Every proof takes it, because what you asked for is the
|
|
60
|
-
side of each comparison the gateway did not supply.
|
|
61
|
-
|
|
62
|
-
```ts
|
|
63
|
-
import { proveSettlement } from "thunder-bridge";
|
|
94
|
+
if (target !== null) {
|
|
95
|
+
target.innerHTML = invoiceToSvg(payment.bolt11);
|
|
96
|
+
}
|
|
64
97
|
|
|
65
98
|
const settled = await gateway.waitForPayment(payment.id, {
|
|
66
99
|
signal: AbortSignal.timeout(600_000),
|
|
67
100
|
});
|
|
68
101
|
|
|
69
|
-
|
|
70
|
-
const preimage = await proveSettlement(settled, request);
|
|
71
|
-
if (preimage !== null) fulfil(payment.id);
|
|
72
|
-
}
|
|
102
|
+
const preimage = settled.status === "paid" ? await proveSettlement(settled, request) : null;
|
|
73
103
|
```
|
|
74
104
|
|
|
75
|
-
`
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
105
|
+
Keep the `request` object. Every proof that asks the recipient takes it, because
|
|
106
|
+
what you asked for is the side of each comparison the gateway did not supply.
|
|
107
|
+
`proveWrapped` is the exception, since it compares two invoices and asks nobody. `waitForPayment` tells you what
|
|
108
|
+
the gateway says. `proveSettlement` goes to the recipient's own server. Only the
|
|
109
|
+
second is evidence the money arrived, and
|
|
110
|
+
[docs/proving-a-payment.md](../docs/proving-a-payment.md) is the whole argument for
|
|
111
|
+
why.
|
|
79
112
|
|
|
80
|
-
|
|
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.
|
|
113
|
+
## How each payment method gets verified
|
|
83
114
|
|
|
84
|
-
|
|
115
|
+
Every rail ends the same way, with a preimage that has to hash to the payment hash
|
|
116
|
+
the gateway was given. What differs is who obtains the invoice, who is asked for the
|
|
117
|
+
preimage, and which side does the checking.
|
|
85
118
|
|
|
86
|
-
|
|
|
119
|
+
| `lightningRail` | the gateway asks, at the recipient's LNURL callback |
|
|
87
120
|
|---|---|
|
|
88
|
-
|
|
|
89
|
-
|
|
|
90
|
-
|
|
|
91
|
-
|
|
|
92
|
-
| `
|
|
93
|
-
|
|
|
94
|
-
|
|
95
|
-
| `
|
|
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 |
|
|
121
|
+
| 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 |
|
|
122
|
+
| the invoice is checked by | you, `proveOrigin` runs five checks against the recipient's own domain |
|
|
123
|
+
| the gateway probes first | nothing, it resolved the address itself |
|
|
124
|
+
| the gateway polls | the wallet, directly |
|
|
125
|
+
| `settled` comes from | the wallet releasing its preimage |
|
|
126
|
+
| the pace is set by | the wallet, when it sends `Cache-Control: max-age`. When it sends none the gateway's own schedule decides |
|
|
127
|
+
|
|
128
|
+
| `blindLightningRail` | you ask, with `invoiceFrom` on your server |
|
|
107
129
|
|---|---|
|
|
108
|
-
|
|
|
109
|
-
|
|
|
110
|
-
|
|
|
111
|
-
| `
|
|
112
|
-
| `
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
| Export | What it does |
|
|
130
|
+
| the gateway is told | a hash, an expiry and your URL, with the wallet's sealed inside |
|
|
131
|
+
| the invoice is checked by | nobody needs to, you resolved the address yourself |
|
|
132
|
+
| the gateway probes first | `speaksVerify`: a GET on the URL, then a signed POST nonce it must echo |
|
|
133
|
+
| the gateway polls | your `lightningVerifyEndpoint`, once `relayVerifyThrough` is set. Leave it off and the gateway polls the wallet directly, as on the minted rail |
|
|
134
|
+
| `settled` comes from | your endpoint, which unseals, asks the wallet and relays the answer |
|
|
135
|
+
| the pace is set by | you, `pollEverySecs` |
|
|
136
|
+
|
|
137
|
+
| `nwcRail` | your own wallet mints it, over NIP-47 `make_invoice` |
|
|
117
138
|
|---|---|
|
|
118
|
-
|
|
|
119
|
-
|
|
|
120
|
-
|
|
|
121
|
-
|
|
|
122
|
-
| `
|
|
123
|
-
|
|
|
124
|
-
|
|
125
|
-
| `
|
|
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.
|
|
138
|
-
|
|
139
|
-
What your service answers once those handlers are mounted is written out in
|
|
140
|
-
[`openapi.yaml`](openapi.yaml), shipped with this package.
|
|
141
|
-
|
|
142
|
-
`Statement` is a plain `(sinceUnix) => Promise<Credit[]>`, so another bank is
|
|
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.
|
|
146
|
-
|
|
147
|
-
**One shape for every payment method** - [`src/rail.ts`](src/rail.ts)
|
|
148
|
-
|
|
149
|
-
| Export | What it does |
|
|
139
|
+
| the gateway is told | a hash and your URL, with the hash sealed inside |
|
|
140
|
+
| the invoice is checked by | nobody, it is your wallet |
|
|
141
|
+
| the gateway probes first | the same GET and signed nonce |
|
|
142
|
+
| the gateway polls | your `nwcVerifyEndpoint` |
|
|
143
|
+
| `settled` comes from | `lookup_invoice`, refused unless the wallet's own key signed it |
|
|
144
|
+
| the pace is set by | you, `pollEverySecs` |
|
|
145
|
+
|
|
146
|
+
| `bankRail` | nobody, there is no invoice |
|
|
150
147
|
|---|---|
|
|
151
|
-
|
|
|
152
|
-
|
|
|
153
|
-
|
|
|
154
|
-
|
|
|
148
|
+
| the gateway is told | a hash and your URL, which names the amount and the reference |
|
|
149
|
+
| the invoice is checked by | nobody, there is no invoice to check |
|
|
150
|
+
| the gateway probes first | the same GET and signed nonce |
|
|
151
|
+
| the gateway polls | your `bankVerifyEndpoint` |
|
|
152
|
+
| `settled` comes from | a `Statement` credit matching amount and currency exactly, with the reference anywhere in the payer's text |
|
|
153
|
+
| the pace is set by | you, `pollEverySecs` |
|
|
154
|
+
|
|
155
|
+
Two things are worth reading off those blocks rather than inferring.
|
|
156
|
+
|
|
157
|
+
**The checking side flips.** On the minted path the gateway resolved the address, so
|
|
158
|
+
it runs no verify probe and no verify challenge, and `proveOrigin` on your side is
|
|
159
|
+
the whole defence. A `webhookUrl` is challenged on both paths. On every watched path the gateway resolved nothing, so it probes the URL
|
|
160
|
+
and challenges it with a nonce before accepting the watch, refusing with `424` if
|
|
161
|
+
nothing answers. Deploy the endpoint before you register it. The challenge is on
|
|
162
|
+
unless the operator set `VERIFY_CHALLENGE=0`, which is also why a bare wallet
|
|
163
|
+
`verify` URL cannot be handed to `watchPayment`: a wallet will not echo a nonce.
|
|
164
|
+
|
|
165
|
+
**What a preimage proves is the same on all four, and narrower than it looks:** that
|
|
166
|
+
the server holding the secret says the money arrived, made unforgeable by anyone
|
|
167
|
+
else. On the bank rail that secret is an HMAC you derive, which sounds weaker and is
|
|
168
|
+
not, because a wallet also minted the preimage it later releases. It rules out a
|
|
169
|
+
gateway inventing a settlement. It does not rule out a recipient lying about one, so
|
|
170
|
+
this protects a payer against the operator, not against the person being paid.
|
|
171
|
+
|
|
172
|
+
`proveWrapped` sits on a different axis. It compares two invoices on one payment
|
|
173
|
+
hash and asks nobody anything, so it says whether an operator's wrap is honest
|
|
174
|
+
without saying whether either invoice was paid.
|
|
175
|
+
|
|
176
|
+
### What each one costs you
|
|
177
|
+
|
|
178
|
+
**`lightningRail`**
|
|
179
|
+
|
|
180
|
+
- the gateway holds the address and the amount, so your order book is readable
|
|
181
|
+
from its own logs
|
|
182
|
+
- it polls the wallet directly, which puts the recipient's provider in its logs and
|
|
183
|
+
in front of its peers
|
|
184
|
+
- the wallet's `Cache-Control` sets the poll pace, so how fast a settlement is
|
|
185
|
+
noticed is not yours to decide
|
|
186
|
+
|
|
187
|
+
**`blindLightningRail`**
|
|
188
|
+
|
|
189
|
+
- a service of your own that has to stay up, so a browser-only integration cannot
|
|
190
|
+
use this rail at all
|
|
191
|
+
- one long-lived sealing secret, which `seal` refuses under 32 characters, so
|
|
192
|
+
`openssl rand -hex 16` is the shortest thing that works
|
|
193
|
+
- your endpoint being down means the gateway cannot verify and the payment sits
|
|
194
|
+
`pending`
|
|
195
|
+
- a wallet you cannot reach answers `502`, so the gateway retries instead of
|
|
196
|
+
concluding the invoice went unpaid. The body still reads `settled: false`, and the
|
|
197
|
+
status is what separates "could not ask" from "asked, and no"
|
|
198
|
+
|
|
199
|
+
**`nwcRail`**
|
|
200
|
+
|
|
201
|
+
- an NWC connection to your own wallet, and the nostr relays behind it
|
|
202
|
+
- **scope the connection to `make_invoice` and `lookup_invoice`, never
|
|
203
|
+
`pay_invoice`.** It is a key that spends, and a leak with the wrong scope drains
|
|
204
|
+
the wallet
|
|
205
|
+
- relays unreachable means no verification
|
|
206
|
+
|
|
207
|
+
**`bankRail`**
|
|
208
|
+
|
|
209
|
+
- the gateway has to be one of your own: the verify URL names the amount and the
|
|
210
|
+
reference, so whoever runs the gateway reads your order book from the watches
|
|
211
|
+
alone
|
|
212
|
+
- the secret is the entire proof. **Lose it and every past proof is gone**, because
|
|
213
|
+
each preimage is derived from it
|
|
214
|
+
|
|
215
|
+
**The bank rail has one silent failure worth testing before you promise anybody a
|
|
216
|
+
rail.** Two shapes leave a payment `pending` while the money is already in the
|
|
217
|
+
account: a bank that truncates the reference, since the match asks whether the
|
|
218
|
+
reference is inside what the bank forwarded rather than the other way round, and a
|
|
219
|
+
payer whose bank forwards nothing but a numeric variable symbol, since an
|
|
220
|
+
alphanumeric reference cannot travel in a numeric field and `X-VS` is not read as an
|
|
221
|
+
alternative. Neither has been seen with Fio, which forwards the message untouched.
|
|
222
|
+
Check it against the banks your payers actually use.
|
|
223
|
+
|
|
224
|
+
## Who you still have to trust
|
|
225
|
+
|
|
226
|
+
The proof narrows the trust rather than removing it. Three parties are left, and
|
|
227
|
+
they are not equally constrained.
|
|
228
|
+
|
|
229
|
+
| | You trust it with | It cannot |
|
|
230
|
+
|---|---|---|
|
|
231
|
+
| 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 |
|
|
232
|
+
| 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 |
|
|
233
|
+
| the recipient | that the sum they asked for is the sum they are owed | nothing here checks this at all |
|
|
155
234
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
235
|
+
The gateway also sees your address list and your amount. It cannot invent a
|
|
236
|
+
settlement because the preimage comes from the recipient's own server, and the
|
|
237
|
+
provider cannot mint for another account because the description hash pins an
|
|
238
|
+
invoice to one user's metadata under LUD-06. A recipient inflating a total is
|
|
239
|
+
outside what any of it proves.
|
|
161
240
|
|
|
162
|
-
|
|
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.
|
|
241
|
+
Four sharp edges, worth reading before you build:
|
|
165
242
|
|
|
166
|
-
**
|
|
243
|
+
- **A colluding custodian defeats all of it.** If the recipient's wallet provider
|
|
244
|
+
and the gateway are the same party, then whoever holds the money also serves the
|
|
245
|
+
metadata and answers the verify requests. Every check passes. This protects a
|
|
246
|
+
payer against the operator, never against the recipient's own custodian.
|
|
247
|
+
- **The two proof fetches vet the first hop and no further.** `proveOrigin` and
|
|
248
|
+
`proveSettlement` use the runtime's default redirect handling, so a public https
|
|
249
|
+
host answering `302` to a private address is followed there. `invoiceFrom` is not
|
|
250
|
+
like this: it resolves through the outbound guard, which sets `redirect: "manual"`
|
|
251
|
+
and re-vets every hop. Keep egress control outside this package if that matters.
|
|
252
|
+
- **A payment read cold is only as pinned as its creation.** `getPayment` checks
|
|
253
|
+
the preimage against the `paymentHash` in the same record, and it was
|
|
254
|
+
`proveOrigin` at creation, against the request you wrote, that tied that hash to
|
|
255
|
+
an invoice the recipient issued. Store the request alongside the payment id, or a
|
|
256
|
+
cold read is checking the gateway's numbers against each other and nothing more.
|
|
257
|
+
- **Availability is not provable, and an address is not a person.** Every check
|
|
258
|
+
here is about an invoice you were given, none about one you were refused, and
|
|
259
|
+
proving an invoice belongs to an address never proves the address belongs to
|
|
260
|
+
whoever you think it does.
|
|
261
|
+
|
|
262
|
+
`isProvablyPaid` is the one to be careful with: it asks only whether the gateway's
|
|
263
|
+
own report holds together, so a gateway that generates a preimage, hashes it and
|
|
264
|
+
builds an invoice around that hash passes it. If a payment matters, ask the
|
|
265
|
+
recipient with `proveSettlement`. The full argument, including the five origin
|
|
266
|
+
checks and their failure codes, is in
|
|
267
|
+
[docs/proving-a-payment.md](../docs/proving-a-payment.md).
|
|
268
|
+
|
|
269
|
+
## What you call
|
|
270
|
+
|
|
271
|
+
Signatures and the caveats on each export are in the TSDoc on the export itself, so
|
|
272
|
+
your editor has them and this table does not repeat them.
|
|
167
273
|
|
|
168
274
|
| Export | What it does |
|
|
169
275
|
|---|---|
|
|
170
|
-
| `
|
|
171
|
-
| `
|
|
172
|
-
| `
|
|
173
|
-
| `
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
`
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
the gateway only a hash, a url and an expiry. Server side, because it resolves
|
|
200
|
-
hostnames and refuses a private one.
|
|
201
|
-
|
|
202
|
-
**Webhooks** - [`src/webhook.ts`](src/webhook.ts): `parseSettlementRequest`,
|
|
203
|
-
`parseSettlement`, `isProvablySettled`, `parseWebhookRequest`, `parseWebhook`,
|
|
204
|
-
`parseWatchedWebhookRequest`, `parseWatchedWebhook`, `verifyWebhookSignature`,
|
|
205
|
-
`answerWebhookChallengeRequest`, `answerWebhookChallenge`. See
|
|
206
|
-
[Webhooks](#webhooks).
|
|
207
|
-
|
|
208
|
-
**Errors** - [`src/errors.ts`](src/errors.ts): `ProblemError`,
|
|
209
|
-
`NoWalletAvailableError`, `GatewayCheatError`, `UnverifiedRecipientError`,
|
|
210
|
-
`IdempotencyConflictError`, `isProblemType`. See [Errors](#errors).
|
|
211
|
-
|
|
212
|
-
## The proof
|
|
213
|
-
|
|
214
|
-
`proveOrigin(payment, request)` runs five checks in order and stops at the first
|
|
215
|
-
failure. The first two need no network. The rest go to the recipient's own domain,
|
|
216
|
-
never back to the gateway, which is the point: a gateway cannot witness its own
|
|
217
|
-
honesty.
|
|
218
|
-
|
|
219
|
-
| # | Check | Rules out | Fails with |
|
|
220
|
-
|---|---|---|---|
|
|
221
|
-
| 1 | the chosen address is one you listed, compared case-insensitively | the gateway paying an address you never named, its own included | `address_not_requested` |
|
|
222
|
-
| 2 | the invoice decodes to the amount you asked for and the payment hash the record reports | being billed more than you asked, or a record describing one invoice while carrying another | `amount_mismatch`, `hash_mismatch` |
|
|
223
|
-
| 3 | the invoice's description hash equals the sha256 of the `metadata` that address serves, under LUD-06 | an invoice minted by a different account on the same custodial domain | `description_hash_mismatch` |
|
|
224
|
-
| 4 | `verifyUrl` shares an origin with the `callback` that endpoint publishes | a settlement proof pointed anywhere the gateway controls | `verify_url_foreign` |
|
|
225
|
-
| 5 | a GET to `verifyUrl` echoes `pr`, and it equals `bolt11` byte for byte | everything the earlier checks could still miss, because the answer now comes from the recipient | `invoice_not_issued` |
|
|
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.
|
|
292
|
-
|
|
293
|
-
```ts
|
|
294
|
-
import { blindLightningRail, lightningVerifyEndpoint } from "thunder-bridge/server";
|
|
295
|
-
|
|
296
|
-
app.get("/verify/lightning", (context) =>
|
|
297
|
-
lightningVerifyEndpoint({ secret: RELAY_SECRET, pollEverySecs: 5 })(context.req.raw),
|
|
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 },
|
|
305
|
-
});
|
|
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
|
-
|
|
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
|
-
|
|
354
|
-
### How often the gateway asks
|
|
355
|
-
|
|
356
|
-
Your endpoint decides, not the gateway. `bankVerifyEndpoint` answers with
|
|
357
|
-
`Cache-Control: max-age=30`, and the gateway uses that as the interval for every
|
|
358
|
-
payment on your host. Set `pollEverySecs` to whatever your bank's own refresh makes
|
|
359
|
-
sensible: reading a statement that moves once an hour every five seconds only burns
|
|
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";
|
|
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
|
-
```
|
|
276
|
+
| `new ThunderBridge(baseUrl, options?)` | a gateway handle. `{ secret }` signs the calls that create something, so a payment you create comes back to you and nobody else, while the reads and `webhookKey` stay unsigned, `{ token }` makes the instance yours, `{ verify: false }` turns off the automatic proof |
|
|
277
|
+
| `gateway.createPayment(params, options?)` | mint an invoice on the first address that can prove one, and prove it before returning |
|
|
278
|
+
| `gateway.watchPayment(params)` | hand over an invoice you obtained yourself, so the gateway never learns the address or the amount |
|
|
279
|
+
| `gateway.waitForPayment(id, options?)` | follow one payment over WebSocket until it is paid or expired. `waitForWatched` is the same for a watched one |
|
|
280
|
+
| `gateway.followTrigger(secret, options)` | stream every payment carrying one trigger, reconnecting on its own. The secret is the only thing guarding that stream and nothing rate limits a guess, so it is refused under 16 characters |
|
|
281
|
+
| `proveSettlement(payment, request)` | ask the recipient whether it settled, returns the preimage or `null` |
|
|
282
|
+
| `invoiceFrom(lnAddresses, amountMsat)` | get a provable invoice yourself, from `thunder-bridge/server` |
|
|
283
|
+
| `lnurlPayEndpoint(config)` | a whole LNURL-pay endpoint as one Fetch handler, so a static QR points at your own domain. From `thunder-bridge/server` |
|
|
284
|
+
| `invoiceToSvg(bolt11, options?)` | a QR as a string, no canvas involved. `lnurlToSvg` and `spdToSvg` are the siblings, each with a `…ToDataUrl` twin |
|
|
285
|
+
|
|
286
|
+
The rest of the surface, by job:
|
|
287
|
+
|
|
288
|
+
- **more gateway calls:** `Gateways`, `createQuote`, `getPayment`, `getWatched`,
|
|
289
|
+
`listPayments`, `firstToSettle`, `nameFor`, `createSocketTicket`, `webhookKey`,
|
|
290
|
+
`isPrivate`
|
|
291
|
+
- **proving:** `proveOrigin`, `isProvablyPaid`, `preimageMatchesHash`,
|
|
292
|
+
`decodeInvoice`, `proveWrapped`, `wrapFeeCeiling`
|
|
293
|
+
- **serving your own endpoints:** `lightningVerifyEndpoint`, `bankVerifyEndpoint`,
|
|
294
|
+
`nwcVerifyEndpoint`, `watchTicketEndpoint`, `publicWatchTicketEndpoint`,
|
|
295
|
+
`relayedVerifyUrl`, `seal`, `unseal`, `toLnurl`
|
|
296
|
+
- **one shape per payment method:** `lightningRail`, `blindLightningRail`,
|
|
297
|
+
`bankRail`, `nwcRail`, `bankTransfer`, `fioStatement`
|
|
298
|
+
- **pricing a fiat order:** `medianOf`, `msatFor`, `coinbase`, `kraken`, `bitstamp`,
|
|
299
|
+
`coinmate`, `minorUnitsOf`, `minorScaleOf`
|
|
300
|
+
- **webhooks:** `parseSettlementRequest`, `answerWebhookChallengeRequest`,
|
|
301
|
+
`isProvablySettled`, `verifyWebhookSignature`, and the `parse*` variants for each
|
|
302
|
+
shape
|
|
303
|
+
- **errors:** `ProblemError`, `GatewayCheatError`, `NoWalletAvailableError`,
|
|
304
|
+
`UnverifiedRecipientError`, `IdempotencyConflictError`, `isProblemType`
|
|
394
305
|
|
|
395
|
-
|
|
396
|
-
|
|
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.
|
|
306
|
+
What your service answers once those handlers are mounted is written out in
|
|
307
|
+
[`openapi.yaml`](openapi.yaml), shipped with this package.
|
|
446
308
|
|
|
447
309
|
## Errors
|
|
448
310
|
|
|
449
311
|
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
|
-
|
|
312
|
+
never on prose. `error.status` is what the transport carried, and a document naming
|
|
313
|
+
a different status in its own body does not override it.
|
|
314
|
+
|
|
315
|
+
Every `type` below is prefixed `urn:problem-type:thunder-bridge:`, and the four the
|
|
316
|
+
SDK exports as constants are named in the last column.
|
|
452
317
|
|
|
453
|
-
| `type` | Status |
|
|
318
|
+
| `type` | Status | What it is |
|
|
454
319
|
|---|---|---|
|
|
455
|
-
|
|
|
456
|
-
|
|
|
457
|
-
| `
|
|
458
|
-
| `
|
|
459
|
-
| `
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
the
|
|
465
|
-
|
|
320
|
+
| `invalid-request` | 400, or 413 for a body over the size ceiling | `detail` names the field |
|
|
321
|
+
| `no-wallet-available` | 502, else 422, else 400, following the worst wallet | `NoWalletAvailableError`, `wallets` says why each failed. `NO_WALLET_AVAILABLE` |
|
|
322
|
+
| `request-in-flight` | 409 | a request with this `Idempotency-Key` is still running. `REQUEST_IN_FLIGHT` |
|
|
323
|
+
| `idempotency-key-reused` | 409 | that key was used for a different request. `IdempotencyConflictError`, `IDEMPOTENCY_KEY_REUSED` |
|
|
324
|
+
| `payment-already-watched` | 409 | that payment hash is already watched here. `PAYMENT_ALREADY_WATCHED` |
|
|
325
|
+
| `caller-unknown` | 403 | the instance keeps a list of callers and your key is not on it |
|
|
326
|
+
| `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 `watchPayment`. A verify URL that is not public https is `invalid-request` instead |
|
|
327
|
+
| `verify-unconfirmed` | 424 | the URL did not answer the LUD-21 shape |
|
|
328
|
+
| `verify-unconsented` | 424 | the URL did not echo the challenge nonce |
|
|
329
|
+
| `webhook-unconfirmed` | 424 | the webhook URL did not answer its challenge |
|
|
330
|
+
| `too-many-pending` | 429, with `ratelimit-limit` and `ratelimit-remaining` set | the caller is over its share of the instance's `MAX_PENDING` |
|
|
331
|
+
|
|
332
|
+
The rest carry `about:blank` as their type, which the gateway seeds into every
|
|
333
|
+
problem body: `401` when the bearer token does not match, `404` both for an id this
|
|
334
|
+
gateway never heard of and for one it knows that belongs to a different caller key,
|
|
335
|
+
so a `403` can never confirm an id exists, `410` when you replay an
|
|
336
|
+
`Idempotency-Key` whose payment has since been pruned, `500`, and `503` while the
|
|
337
|
+
instance is draining or its own health check reads stalled. On a `404` `getPayment`
|
|
338
|
+
returns `null` rather than throwing.
|
|
466
339
|
|
|
467
340
|
`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.
|
|
341
|
+
misbehaved, and `code` names the check that caught it. `UnverifiedRecipientError` is
|
|
342
|
+
neither an accusation nor a clean bill of health: it means a check could not be run
|
|
343
|
+
at all, because the recipient's server was down, timed out, answered something
|
|
344
|
+
unreadable, or the browser was blocked by CORS. Decide what you want to do with an
|
|
345
|
+
unproven invoice, and decide it explicitly.
|
|
477
346
|
|
|
478
347
|
```ts
|
|
479
348
|
import {
|
|
480
349
|
GatewayCheatError,
|
|
481
350
|
NoWalletAvailableError,
|
|
482
351
|
ProblemError,
|
|
352
|
+
ThunderBridge,
|
|
483
353
|
UnverifiedRecipientError,
|
|
484
354
|
} from "thunder-bridge";
|
|
485
355
|
|
|
356
|
+
declare const gateway: ThunderBridge;
|
|
357
|
+
declare function report(line: string): void;
|
|
358
|
+
|
|
486
359
|
try {
|
|
487
|
-
|
|
488
|
-
show(payment);
|
|
360
|
+
await gateway.createPayment({ lnAddresses: ["iamfatik@blink.sv"], amountMsat: 21_000 });
|
|
489
361
|
} catch (error) {
|
|
490
362
|
if (error instanceof GatewayCheatError) {
|
|
491
363
|
report(`the gateway cheated: ${error.code} on payment ${error.paymentId}`);
|
|
492
364
|
} else if (error instanceof UnverifiedRecipientError) {
|
|
493
365
|
report(`could not reach ${error.lnAddress} to check the invoice`);
|
|
494
366
|
} else if (error instanceof NoWalletAvailableError) {
|
|
495
|
-
for (const wallet of error.wallets)
|
|
367
|
+
for (const wallet of error.wallets) {
|
|
368
|
+
report(`${wallet.address}: ${wallet.reason}`);
|
|
369
|
+
}
|
|
496
370
|
} else if (error instanceof ProblemError) {
|
|
497
371
|
report(`${error.status} ${error.title}`);
|
|
498
372
|
} else {
|
|
@@ -507,127 +381,63 @@ Pass `webhookUrl` when you create a payment, or on any rail. There is no webhook
|
|
|
507
381
|
secret: a gateway holds nothing of yours, and sending one is refused rather than
|
|
508
382
|
ignored. Every delivery is signed `ed25519=<signature>` with the key the gateway
|
|
509
383
|
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`.
|
|
384
|
+
alone, so a captured delivery cannot be replayed at you later. Delivery is
|
|
385
|
+
at-least-once, so deduplicate on `id`.
|
|
519
386
|
|
|
520
387
|
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.
|
|
388
|
+
`{"type":"webhook-challenge","nonce":"..."}` to the URL while the create is still
|
|
389
|
+
open, and refuses the payment with a `424` unless the nonce comes back, so deploy
|
|
390
|
+
the endpoint before you register it.
|
|
526
391
|
|
|
527
392
|
```ts
|
|
528
393
|
import {
|
|
394
|
+
ThunderBridge,
|
|
529
395
|
answerWebhookChallengeRequest,
|
|
530
396
|
isProvablySettled,
|
|
531
397
|
parseSettlementRequest,
|
|
532
398
|
} from "thunder-bridge";
|
|
533
399
|
|
|
534
|
-
|
|
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
|
-
```
|
|
400
|
+
declare function fulfil(paymentId: string, preimage: string): Promise<void>;
|
|
548
401
|
|
|
549
|
-
|
|
550
|
-
|
|
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
|
|
569
|
-
|
|
570
|
-
There is nothing to hand it. A delivery is signed with the gateway's own key,
|
|
571
|
-
`x-signature: ed25519=<signature>` over `<x-timestamp>.<raw body>`. Fetch the public
|
|
572
|
-
half once and keep it.
|
|
573
|
-
|
|
574
|
-
```ts
|
|
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
|
-
```
|
|
402
|
+
const gateway = new ThunderBridge("https://public.thunder-bridge.agora.gripe");
|
|
403
|
+
const signer = { publicKey: await gateway.webhookKey() };
|
|
586
404
|
|
|
587
|
-
|
|
588
|
-
|
|
405
|
+
export async function POST(request: Request): Promise<Response> {
|
|
406
|
+
const challenge = await answerWebhookChallengeRequest(request, signer);
|
|
407
|
+
if (challenge) {
|
|
408
|
+
return challenge;
|
|
409
|
+
}
|
|
589
410
|
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
is gone.
|
|
411
|
+
const settled = await parseSettlementRequest(request, signer);
|
|
412
|
+
if (settled === null) {
|
|
413
|
+
return new Response("bad signature", { status: 401 });
|
|
414
|
+
}
|
|
595
415
|
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
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.
|
|
416
|
+
const preimage = isProvablySettled(settled) ? settled.preimage : null;
|
|
417
|
+
if (preimage === null) {
|
|
418
|
+
return new Response("no preimage that hashes to it", { status: 402 });
|
|
419
|
+
}
|
|
602
420
|
|
|
603
|
-
|
|
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.
|
|
421
|
+
await fulfil(settled.id, preimage);
|
|
606
422
|
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
import { parseWebhook } from "thunder-bridge";
|
|
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);
|
|
621
|
-
});
|
|
423
|
+
return new Response("ok");
|
|
424
|
+
}
|
|
622
425
|
```
|
|
623
426
|
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
427
|
+
`isProvablySettled` answers the only question that matters about a delivery: it says
|
|
428
|
+
paid and it carries a preimage that hashes to the payment hash the same body names.
|
|
429
|
+
Ask the recipient's own server with `proveSettlement` when you want the proof to come
|
|
430
|
+
from somewhere other than the delivery.
|
|
431
|
+
|
|
432
|
+
## More
|
|
433
|
+
|
|
434
|
+
- [docs/lud21-coverage.md](../docs/lud21-coverage.md) - which address domains
|
|
435
|
+
release a preimage, and how that was measured
|
|
436
|
+
- [docs/proving-a-payment.md](../docs/proving-a-payment.md) - the five origin checks
|
|
437
|
+
and their failure codes, what settlement means, making the gateway poll nobody but
|
|
438
|
+
you, the NWC rail, wrapped invoices, and webhooks in full
|
|
439
|
+
- [the gateway](../README.md) - one level up in this repository
|
|
440
|
+
- [examples](../examples) - a paywall on Deno Deploy, a trigger watcher, a bank rail
|
|
631
441
|
|
|
632
442
|
## Development
|
|
633
443
|
|
package/openapi.yaml
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "thunder-bridge",
|
|
3
|
-
"version": "1.4.
|
|
3
|
+
"version": "1.4.2",
|
|
4
4
|
"description": "Trustless JavaScript client for the Thunder Bridge Lightning payment gateway. Proves the invoice came from your own wallet before the payer sees it.",
|
|
5
5
|
"author": "i-am-fatik",
|
|
6
6
|
"homepage": "https://agora.gripe/en/tools/thunder-bridge",
|