@arkade-os/lnurl-client 0.0.0-stage → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,3 +1,499 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
1
+ # @arkade-os/lnurl-client
2
+
3
+ Full-duplex client for the lnurl-server protocol: payer (`resolve`, `requestInvoice`, `pollVerify`) and receiver (sessions, settled reporting, address and Arkade-identity registration).
4
+
5
+ ## Install
6
+
7
+ ```sh
8
+ pnpm add @arkade-os/lnurl-client
9
+ ```
10
+
11
+ The SDK is an optional peer: `>=0.5.0-rc.0` (0.5.0 release candidates and stable 0.5+ releases). The main entry point needs no SDK; `/arkade` uses the SDK installed by your app.
12
+
13
+ Runtime dependencies are the Scure and Noble cryptography packages; the package never imports the server, so app bundles stay free of `express` and `sqlite`.
14
+
15
+ ## With an Arkade wallet
16
+
17
+ If you hold an `@arkade-os/sdk` wallet, start here. `arkadeLnurl` takes the wallet and the server root and does the rest - token derivation, claiming the address, binding the identity, the sync loop, and the send rails:
18
+
19
+ ```ts
20
+ import { arkadeLnurl } from '@arkade-os/lnurl-client/arkade'
21
+ import { browserPaymentStore } from '@arkade-os/lnurl-client'
22
+
23
+ const lnurl = arkadeLnurl({ wallet, baseUrl: 'https://lnurl.example.com', store: browserPaymentStore() })
24
+
25
+ // Receive: claims the name AND binds the identity, which is what makes the
26
+ // address advertise any paymentOptions at all. Omit the name for a
27
+ // server-assigned one, or pass `{ nameless: true }` for no address at all -
28
+ // just the stable `lnurl` below.
29
+ const rx = await lnurl.claim({ username: 'alice' }) // rx.lightningAddress === 'alice@lnurl.example.com'
30
+ await rx.sync() // pull the server's own records
31
+
32
+ // Send: the router already knows how to pay a Lightning address.
33
+ const [best] = await lnurl.options('bob@example.com', 1000)
34
+ const handle = await (await best.quote()).send()
35
+ ```
36
+
37
+ `wallet.identity` supplies the signing, so nothing else is passed in. The token is derived once and memoised - it signs twice on first use to prove the signer is deterministic, and a second call costs nothing. `claim` and `owned` return a `Receiver`, scoped to one address:
38
+
39
+ | you want | call |
40
+ | --- | --- |
41
+ | what this domain allows | `capabilities()` |
42
+ | the address this wallet already owns here | `owned()` |
43
+ | claim a name (or none) and bind the identity | `claim(opts?)` |
44
+ | its `user@domain`, or `undefined` when nameless | `receiver.lightningAddress` |
45
+ | an LNURL for a QR | `receiver.lnurl` |
46
+ | its own payRequest | `receiver.payRequest()` |
47
+ | one page of the server's records | `receiver.payments(opts)` |
48
+ | everything new, into your store | `receiver.sync()` |
49
+ | name a nameless receiver in place | `receiver.upgrade(opts?)` |
50
+ | rank the ways to pay a target | `options(target, amountSat)` |
51
+ | the router itself | `router()` |
52
+
53
+ **`payRequest()` fetches once per 30 s.** Every call on one `Receiver` within that window shares a single fetch, in flight or done, so a screen asking on every amount change costs one GET; a failed fetch is not kept. `claim` and `upgrade` retire it at once, even when they fail; a change made anywhere else - another device, the operator - shows within 30 s. `createLnurlClient().resolve(receiver.lnurl)` always asks the server.
54
+
55
+ `capabilities()` reads what a domain allows before you call it - `{ domain, allocationModes, usernameRules, requireApiKey }` - so onboarding can offer only the modes actually enabled instead of guessing and hitting `forbidden_mode`. A domain requiring an operator-reserved name takes a `claimCode` the same way on either call below: `claim({ username: 'alice', claimCode })`.
56
+
57
+ **Nameless, and upgrading later.** `claim({ nameless: true })` returns a `Receiver` with no lightning address at all - offline receive, rails and payment sync all work off its stable `lnurl` alone. `receiver.upgrade({ username: 'alice' })` (or `{ username, claimCode }` for a reserved name) names it in place: the wallet keeps its history and the same `lnurl` keeps paying it, but the upgrade **publicly links** that LNURL to the new name. To keep them unlinked instead, call `lnurl.claim({ username: 'alice' })` on the side - a second, separate named address (counted against the domain's per-wallet limit) with no connection to the nameless one.
58
+
59
+ Paying needs no address of your own, so the router is available separately - `arkadePaymentRouter({ wallet, lightning })` returns the SDK router with the LNURL rails registered, which is what makes `route()` accept a Lightning address instead of throwing `no rail for`.
60
+
61
+ Receiving accepts a `LnurlWallet`: only `identity.signMessage`, `identity.compressedPublicKey`, `getAddress`, and `getBoardingAddress` are required. It does not require the SDK's concrete `Wallet` class. For sending, the default adapter expects a full SDK spending wallet. Custom wallets can pass a `LnurlPaymentRouter` as `router` to either `arkadeLnurl` or `arkadePaymentRouter`; the adapter registers the LNURL rails on that router. `LnurlPaymentRouter` requires only `use`, `remove`, `options`, and `route`, without the SDK class's private members. SDK rails and router data types remain part of the `/arkade` contract.
62
+
63
+ CI checks the client against SDK `0.5.0-rc.0` and `0.5.0-rc.19` in isolated installs, independent of the server's SDK pin. Run the same types, tests, build, and consumer checks with `node scripts/check-client-sdk-compat.mjs <sdk-version>` from the repository root.
64
+
65
+ Everything below is the surface these are built on; reach for it when the facade does not fit.
66
+
67
+ ## HD offline receiving
68
+
69
+ The optional `/hd` entry contains the branch and restoration helpers. HD receiving keeps the account authentication signer stable while deriving a fresh payout address and claim key for each payment. The ordinary Arkade and boarding addresses are never sent when `hd` is configured.
70
+
71
+ ```ts
72
+ import { arkadeLnurl } from '@arkade-os/lnurl-client/arkade'
73
+ import { createHdReceiveBranch, restoreHdReceiveAllocation } from '@arkade-os/lnurl-client/hd'
74
+
75
+ const delay = Number(arkInfo.unilateralExitDelay)
76
+ const branch = createHdReceiveBranch({
77
+ accountDescriptor: wallet.identity.descriptor,
78
+ branchPath: '2/7',
79
+ serverPubKey: arkInfo.signerPubkey.slice(-64),
80
+ hrp: 'tark',
81
+ exitDelay: { type: delay < 512 ? 'blocks' : 'seconds', value: delay },
82
+ })
83
+ const lnurl = arkadeLnurl({ wallet, hd: branch.hd, baseUrl: 'https://lnurl.example.com' })
84
+ const receiver = await lnurl.claim({ username: 'alice' })
85
+ const manager = await wallet.getContractManager()
86
+ let after = 0
87
+ for (;;) {
88
+ const page = await receiver.allocations({ after, limit: 100 })
89
+ for (const allocation of page.allocations) {
90
+ await restoreHdReceiveAllocation(manager, allocation, branch)
91
+ }
92
+ if (!page.allocations.length) break
93
+ after = page.nextAfter
94
+ }
95
+ ```
96
+
97
+ Reserve a distinct branch path for each LNURL service, outside ordinary receive and change paths. Persist the branch path and public configuration before registration; the example reserves `2/7`. Only its scoped branch xpub goes to the server. `accountDescriptor` and the returned `signingDescriptor(index)` mapping stay local so the original SDK identity can sign received coins. A raw mainnet `signerPubkey` may include a compression prefix: pass the x-only 64-character key. Read the actual exit delay and signer from Arkade info rather than copying the example's delay. Match the SDK's delay convention: values below 512 are blocks, otherwise seconds.
98
+
99
+ Allocation history includes unpaid and failed invoice attempts, including old configurations after registration changes. Restore each historical allocation with its original saved branch configuration; do not substitute today's server key or delay. Save a pagination cursor only after every row on that page was registered successfully, and start at zero when restoring a fresh local wallet. A normal gap-limit wallet scan cannot cover arbitrary unpaid-invoice gaps. Allocation history remains authenticated and available after revocation. Registration requires an explicit HD-mode acknowledgement; it never falls back to a static address.
100
+
101
+ The main entry point exposes `HdReceiveConfig`, `HdReceiveAllocation`, `HdReceiveAllocationPage`, `registerArkadeIdentity({ token, handle, hd })`, and `listHdAllocations(token, handle, opts)` without importing the SDK. The separate `/hd` entry adds the branch and restore helpers without loading them for static `/arkade` callers. Received funds still need the application's normal shielding flow; the LNURL service can still associate payments with the alias.
102
+
103
+ ## Payer
104
+
105
+ `createLnurlClient()` with no `baseUrl` covers payer-only use: `resolve`, `requestInvoice` and `pollVerify` talk to whatever host the address or LNURL points at.
106
+
107
+ ```ts
108
+ import { createLnurlClient } from "@arkade-os/lnurl-client";
109
+
110
+ const payer = createLnurlClient();
111
+
112
+ const payRequest = await payer.resolve("alice@example.com"); // or an lnurl bech32 string
113
+ const invoice = await payer.requestInvoice(payRequest, { amountSat: 50 });
114
+
115
+ // Pay invoice.pr through your own Lightning path, then wait for settlement:
116
+ if (invoice.kind === "bolt11" && invoice.verify) {
117
+ const status = await payer.pollVerify(invoice.verify, { timeoutMs: 120_000, intervalMs: 1_000, expect: invoice });
118
+ if (status.kind === "bolt11" && status.settled) {
119
+ console.log("settled, preimage:", status.preimage);
120
+ }
121
+ }
122
+ ```
123
+
124
+ A settled bolt11 status is only returned when `SHA256(preimage)` equals the payment hash in `pr`. Anything else — a wrong preimage, or a `pr` that cannot be decoded — is rejected rather than reported as settled.
125
+
126
+ That proves the answer's own `pr`, not that it is the invoice you are watching. Pass the callback's answer as `expect` and an answer about any other payment — another invoice with its genuine preimage, a destination status, another destination — fails with `LnurlError` instead. lnurl rails record the same value on their quotes as `lnurlQuoteMeta(quote).expect`; for batched or streamed answers, `verifyStatusIsFor(status, expect)` is the same check.
127
+
128
+ `requestInvoice` takes `amountSat` plus an optional `comment`. It also accepts `paymentOption`, except on a session payRequest that does not advertise options (a live session's); a nameless receiver's session LNURL advertises them. The amount is range-checked locally before anything hits the wire.
129
+
130
+ To pay in one of the payRequest's `units` instead, pass `{ unit, amount }` in place of `amountSat`, with `amount` in the unit's smallest denomination: `{ unit: "USD", amount: 500 }` is $5.00. The receiver quotes it to millisats; the answer must carry a `paymentQuote` for exactly that amount and unit, and a BOLT11 must be for the quoted millisats, or `requestInvoice` throws. `parseUnitAmount("5.00", 2)` and `formatUnitAmount("500", 2)` convert between what a person types and that integer.
131
+
132
+ The payment router takes the same thing as `unitAmount` beside `raw`: `router.options({ raw: "alice@example.com", unitAmount: { unit: "USD", amount: 500 } })`, or `arkadeLnurl(...).options(target, { unit: "USD", amount: 500 })`. Only the lnurl rails can price it, on whichever of arkade and lightning the target offers; the quote records it as `lnurlQuoteMeta(quote).quote`. Keep that on the `SentPayment` you write as `quote`, and the sent row shows it as `requested`. A received payment synced from a server that records quotes carries the same `quote`, and its row shows `requested` too.
133
+
134
+ **Rails can carry different amounts, so check the option you selected.** A covenant destination is bounded by dust and VTXO shape, a solver-mediated swap by whatever the solver quotes — so an entry in `paymentOptions` may publish its own `minSendable`/`maxSendable`, and those win over the top-level pair when you select it. The top-level pair describes the rail you get by sending no `paymentOption` at all. `requestInvoice` applies that rule for you; apply it yourself if you build the callback URL by hand, or you will reject amounts the rail would have accepted.
135
+
136
+ ### Many pending invoices: verifyBatch
137
+
138
+ When the callback also returns `verifyBatch` ([LUD-XX, lnurl/luds#299](https://github.com/lnurl/luds/pull/299)), one request covers every pending invoice at that endpoint:
139
+
140
+ ```ts
141
+ const { results } = await payer.batchVerify(invoice.verifyBatch, pendingVerifyUrls);
142
+ // results[url] is { kind: "verify", status } or { kind: "error", reason }, one per URL you sent
143
+
144
+ const stream = payer.openVerifyBatchStream(
145
+ { verifyBatchUrl: invoice.verifyBatch, verifyUrls: pendingVerifyUrls },
146
+ { onUpdate: (url, status) => { /* snapshot first, then live settlements */ }, onClose: () => { /* reopen if still pending */ } },
147
+ );
148
+ stream.update([newVerifyUrl], [settledVerifyUrl]); // grow or shrink the set without reconnecting
149
+ ```
150
+
151
+ `batchVerify` splits the set and retries when the server answers `414`/`431`. The stream falls back to the one-shot snapshot when the server does not stream (including a `406`), and closing is final: reopening is the caller's job.
152
+
153
+ ### Token options
154
+
155
+ A payRequest may offer a token on another chain (an lnurl-server with FixedFloat deposits, say): an option whose `type` is a CAIP-2 namespace, with a CAIP-19 `asset`, a `unit` naming a `units[]` entry, and the `provider` the payer hands the tokens to. Read them with `tokenOptions`, which accepts exactly what the BTCPay LNURLVerify plugin accepts:
156
+
157
+ ```ts
158
+ import { tokenOptions } from "@arkade-os/lnurl-client";
159
+
160
+ for (const option of tokenOptions(payRequest)) {
161
+ const answer = await payer.requestInvoice(payRequest, { amountSat: 10_000, paymentOption: option.id });
162
+ if (answer.kind !== "destination") continue;
163
+ // answer.paymentURI (EIP-681 or Solana Pay; absent on Tron), answer.paymentDestination,
164
+ // answer.paymentQuote.payment = { amount: base units, unit: option.unit.code },
165
+ // answer.paymentQuote.expiresAt = the payer's deadline (ISO 8601)
166
+ }
167
+ ```
168
+
169
+ `requestInvoice` refuses a token answer for another option, one whose destination is not an address on the token's chain, and one that quotes no whole amount of the token. The amount you request is still sats: what the receiver is paid before the swap's fee.
170
+
171
+ ## Receiver
172
+
173
+ Receiver and management calls need `baseUrl`. `deriveSessionToken` turns a wallet private key and a domain into a stable session token; opening a session with the same token reconnects the same session id.
174
+
175
+ ```ts
176
+ import { createLnurlClient, deriveSessionToken } from "@arkade-os/lnurl-client";
177
+
178
+ const receiver = createLnurlClient({ baseUrl: "https://lnurl.example.com" });
179
+ const token = deriveSessionToken("<wallet-private-key-hex>", "example.com");
180
+
181
+ const session = await receiver.openSession({ token }, {
182
+ onInvoiceRequest: async ({ amountMsat }, respond) => {
183
+ const pr = await makeInvoice(amountMsat); // injected invoice provider: your Lightning node
184
+ await respond.answerInvoice(pr); // or: await respond.rejectInvoice(reason)
185
+ },
186
+ });
187
+
188
+ // session.lnurl is what payers resolve. Once your node reports the invoice settled:
189
+ await session.reportSettled("<preimage-hex>");
190
+ session.close();
191
+ ```
192
+
193
+ ### Deriving the token without handing over your key
194
+
195
+ A library should not need your private key, so there are two ways to derive the token and **they produce the same value** — a wallet can move between them without losing its addresses:
196
+
197
+ ```ts
198
+ import { deriveSessionToken, deriveSessionTokenWithSigner } from '@arkade-os/lnurl-client'
199
+
200
+ // Preferred: the key never leaves the wallet.
201
+ const token = await deriveSessionTokenWithSigner(
202
+ (msg, type) => identity.signMessage(msg, type), // @arkade-os/ts-sdk Identity
203
+ 'example.com',
204
+ )
205
+
206
+ // Equivalent, for callers that already hold raw key material:
207
+ const same = deriveSessionToken('<private-key-hex>', 'example.com')
208
+ ```
209
+
210
+ They agree because the token is `sha256` of a **deterministic ECDSA signature** over `sha256("lnurl-session:<domain>")` — a pure function of key and message — and `deriveSessionToken` simply performs that signature itself.
211
+
212
+ **It must be ECDSA, and the function passes the type for you.** BIP-340 schnorr is randomised unless given an explicit aux, which `Identity.signMessage` does not expose — and schnorr is its *default*. A schnorr signature is also 64 bytes, exactly like compact ECDSA, so nothing about the returned bytes reveals the mistake; it would simply mint a new token on every call and silently orphan the address. `deriveSessionTokenWithSigner` therefore signs twice and throws if the results differ, rather than trusting the wiring.
213
+
214
+ **The token is per-domain, and that is a security property, not bookkeeping.** It is a bearer credential: the server receives it and stores it (encrypted, but a server it is stored on can read it). Were it derived from the private key alone, the same credential would authenticate its holder at *every* lnurl-server the user has ever used — so one malicious or breached server could call `registerArkadeIdentity` on a different server and repoint the victim's receive address, which the covenant would then faithfully pay. Binding the domain into the derivation makes a token minted for `example.com` useless at `other.com`.
215
+
216
+ Two consequences worth planning for:
217
+
218
+ - A wallet using several servers holds **several tokens**, one per domain. Derive per domain rather than caching one.
219
+ - The server cannot enforce this — it sees an opaque token and cannot tell which domain produced it — so the protection holds only for clients that derive this way.
220
+
221
+ Derive the token from wallet key material rather than generating a random one you then have to persist: losing it loses the address, since the server identifies the owner by `sha256` of the token bytes and has no other record of who you are.
222
+
223
+ `deriveSessionId(token)` computes that session id locally — the same value the server derives — which is useful for asserting a reconnect resumed the session you expected rather than trusting the id echoed back to you.
224
+
225
+ ## Validating input
226
+
227
+ Exported standalone, because a UI validates what a user pasted or scanned before it has a client or a `baseUrl`:
228
+
229
+ ```ts
230
+ import { isValidLnUrl, isLnAddress, isLnUrl, toPayRequestUrl } from '@arkade-os/lnurl-client'
231
+
232
+ isValidLnUrl('alice@example.com') // true — either form
233
+ isValidLnUrl('LNURL1DP68…') // true
234
+ isValidLnUrl('not an lnurl') // false
235
+
236
+ toPayRequestUrl('alice@example.com')
237
+ // → { url: 'https://example.com/.well-known/lnurlp/alice', surface: 'address' }
238
+ ```
239
+
240
+ `isLnAddress` and `isLnUrl` test one form each; `isValidLnUrl` accepts either. All are shape checks only — they never touch the network, so a well-formed but unregistered address passes here and fails at `resolve`.
241
+
242
+ `toPayRequestUrl` also reports the **surface**. A live `session` LNURL takes an amount and a comment and nothing else, so `requestInvoice` rejects rail options on a session payRequest that advertises none, rather than letting the server ignore them silently. A nameless receiver's session LNURL serves its address, `paymentOptions` and units included, and is paid like one.
243
+
244
+ Mixed-case LNURLs are rejected per BIP-173 — bech32 forbids mixed case so that case-mangling in transit cannot slip past the checksum. All-uppercase, which is what QR codes carry, decodes normally.
245
+
246
+ ## Lightning addresses
247
+
248
+ A LUD-16 address (`alice@example.com`) is payable whether or not the wallet is online. Registering one is what unlocks the offline rails — without it a payer can only reach a live session.
249
+
250
+ **The token is the credential for everything here.** The server derives the address's owner from it (`sha256` of the token bytes), so the same token that opens a session also owns the addresses registered with it. Losing it loses the address; leaking it hands someone else control of where payments go. Derive it from wallet key material with `deriveSessionToken` rather than generating a fresh random one you then have to store.
251
+
252
+ All calls in this section need `baseUrl`.
253
+
254
+ ```ts
255
+ import { createLnurlClient, deriveSessionToken } from '@arkade-os/lnurl-client'
256
+
257
+ const client = createLnurlClient({ baseUrl: 'https://lnurl.example.com' })
258
+ // Per-domain: this token authenticates at example.com and nowhere else.
259
+ const token = deriveSessionToken('<wallet-private-key-hex>', 'example.com')
260
+
261
+ const address = await client.registerAddress({ token, username: 'alice' })
262
+ // → { lightningAddress: 'alice@example.com', lnurl: 'LNURL1…', username, domain, status }
263
+
264
+ const mine = await client.listAddresses(token)
265
+ // → [{ username, domain, status, createdAt, lightningAddress, lnurl }]
266
+
267
+ await client.revokeAddress(token, 'alice')
268
+ ```
269
+
270
+ `username` is optional — omit it and the server allocates one, subject to the domain's policy. Some domains require an API key to register; pass it as `apiKey` and it is sent as `X-API-Key`. Both `revokeAddress` and `registerArkadeIdentity` take an optional `domain` when the server hosts several.
271
+
272
+ ### Offline receive
273
+
274
+ Binding an Arkade identity to an address is what lets payments arrive while the wallet is closed — the server takes a solver-mediated swap or a covenant destination on its behalf, and the funds are constrained to pay only the address you register here.
275
+
276
+ ```ts
277
+ await client.registerArkadeIdentity({
278
+ token,
279
+ username: 'alice',
280
+ arkadeAddress: 'ark1…',
281
+ claimPublicKey: '02…', // compressed 33-byte key, validated locally
282
+ boardingAddress: 'bc1…', // optional: adds the onchain rail
283
+ })
284
+ ```
285
+
286
+ **Call it again to update it.** The server overwrites, so re-registering is how you point an address at a new Arkade address or claim key; there is no separate update call.
287
+
288
+ `boardingAddress` is the one exception to that overwrite: register it and the address advertises an `onchain` payment option paying it, omit it and an already-registered one is left alone — so a later identity update does not silently withdraw the rail. It is sent unvalidated, unlike `claimPublicKey`, because it is an ordinary Bitcoin address on whatever network the operator runs and this package cannot know which. Payers get no `verify` URL on that rail: nothing server-side observes Bitcoin, so those payments never settle there and a URL could only ever answer "not yet".
289
+
290
+ ### With the Arkade SDK
291
+
292
+ The main entry point stays free of `@arkade-os/sdk`, because the payer half of this package has nothing to do with Arkade and a checkout page should not pull the SDK and its Expo peers to ask an address for an invoice. A receiver already has the SDK, so the Arkade-aware helpers live behind a subpath with the SDK as an **optional peer dependency**:
293
+
294
+ ```ts
295
+ import { arkadeIdentityRequest, deriveSessionTokenForIdentity } from '@arkade-os/lnurl-client/arkade'
296
+
297
+ // The key never leaves the wallet, and ECDSA is chosen for you.
298
+ const token = await deriveSessionTokenForIdentity(identity, 'example.com')
299
+
300
+ // Validates the Arkade address and derives claimPublicKey from the identity.
301
+ // boardingAddress is optional here too, and passed through unchecked.
302
+ await client.registerArkadeIdentity(
303
+ await arkadeIdentityRequest({ identity, arkadeAddress, token, username: 'alice' }),
304
+ )
305
+ ```
306
+
307
+ Three things this buys over doing it by hand:
308
+
309
+ - **`isArkadeAddress` decodes locally.** Without the SDK a malformed address is only caught server-side, a round trip later.
310
+ - **`deriveSessionTokenForIdentity` pins ECDSA.** Schnorr is `Identity.signMessage`'s default and is randomised, which would mint a fresh token on every call and orphan the address — and a schnorr signature is 64 bytes exactly like compact ECDSA, so nothing about the bytes reveals the mistake.
311
+ - **`claimPublicKeyOf` derives the compressed key** in the form the server validates, instead of leaving the caller to extract and hex-encode it.
312
+
313
+ Not yet covered: verifying on-chain that a settled destination payment actually paid the registered address. `StoredPayment` carries `paymentReference` and `covenantScript` for exactly that check, but performing it needs an indexer and is not implemented here — a consumer still trusts the server's word that a payment happened.
314
+
315
+ ### Payment activity
316
+
317
+ `listPayments` is a sync source, not just a list: it exists so a wallet that was offline can recover receives it never witnessed, which for the offline rails is every one of them.
318
+
319
+ `syncPayments` drives it for you across every address you hold — paging, cursors, retry and per-server isolation — writing through a store you supply:
320
+
321
+ ```ts
322
+ import { createLnurlClient, syncPayments } from '@arkade-os/lnurl-client'
323
+
324
+ const { synced, failures } = await syncPayments(
325
+ [{ baseUrl: 'https://lnurl.example.com', token, username: 'alice', domain: 'example.com' }],
326
+ { client: (baseUrl) => createLnurlClient({ baseUrl }), store: myStore },
327
+ )
328
+ ```
329
+
330
+ **`browserPaymentStore()` is a small localStorage demo store**: synchronous, a few megabytes, and it rewrites every record per upsert. Browser apps can instead use the optional `/browser` IndexedDB backend described below. Node and React Native supply their own persistence through the same injection the package uses for `fetchImpl`:
331
+
332
+ ```ts
333
+ interface PaymentSyncStore {
334
+ upsert(records: StoredPayment[]): Promise<void> // keyed by record.key; overwrite, never append
335
+ readWatermark(baseUrl: string, lightningAddress: string): Promise<number | undefined>
336
+ writeWatermark(baseUrl: string, lightningAddress: string, since: number): Promise<void> // may move backwards; store verbatim
337
+ }
338
+ ```
339
+
340
+ `client` is a factory, not a client, because a client is pinned to one `baseUrl`. Sharing one across targets would send every target's bearer token to whichever server that client was built for.
341
+
342
+ A target that fails lands in `failures` while the others keep syncing, and its watermark stays put — so a transient outage cannot silently skip the payments that arrived during it.
343
+
344
+ These matter for correctness. `syncPayments` handles them all; you must handle them yourself if you drive `listPayments` directly:
345
+
346
+ - **`page.source`** carries `{ domain, lightningAddress }`. Store it with each entry: a wallet holding addresses on several servers needs it to attribute them, and the deduplication key is `(baseUrl, identifier)` — identifiers are unique per server, not globally.
347
+ - **`nextSince` is inclusive**, so the boundary row comes back on the next sync. Upsert rather than insert. An exclusive cursor would silently drop payments sharing a millisecond, which is why it works this way.
348
+ - **A creation-ordered cursor cannot track settlement.** `settled` mutates long after a row is created, so a cursor parked at the newest `createdAt` never re-reads an older row that settles later — the wallet shows it pending forever. The server now pages on `updatedAt`, which lists a row again whenever it changes, but older servers page on `createdAt`. For those, `syncPayments` persists the oldest row still waiting to settle instead, and reaches at most one page back so a row nothing will ever settle — an `onchain` destination, which nothing server-side watches, or an invoice nobody paid — costs one extra page per sync rather than re-paging your whole history.
349
+ - **Switch on `kind`, never on the presence of a payment hash.** On the arkade rail the server's own record has no payment hash — the identifier is an opaque verify id — which is why `DestinationActivity` calls it `verifyId` and has no `paymentHash` property at all.
350
+ - **A full page need not advance the cursor.** `nextSince` is the last row's `updatedAt` (`createdAt` on older servers), so if a whole page shares one millisecond the next request returns that same page. Stop and report rather than loop; `syncPayments` fails that target with a terminal `LnurlError`.
351
+
352
+ ### Activity resolvers
353
+
354
+ `@arkade-os/lnurl-client/arkade` also exports `lnurlActivityResolver(payments)` and `sentActivityResolver(sends)`, `ActivityResolver`s that label an `ArkTransaction` with the LNURL payment or send it corresponds to. Both take a synchronous getter. For a database-backed receive store, refresh a local snapshot from `repository.all()` before calling the resolver's `prepare()`.
355
+
356
+ ### Shared persistence and send tracking
357
+
358
+ `createLnurlPaymentRepository(payments, watermarks)` provides the write gate and reset ordering over injected backends. It implements `PaymentSyncStore`, so pass it directly as `arkadeLnurl`'s or `syncPayments`'s `store`. Each wallet instance needs its own repository.
359
+
360
+ ```ts
361
+ import { createLnurlPaymentRepository, clearLnurlPaymentsForReset } from '@arkade-os/lnurl-client'
362
+
363
+ const repository = createLnurlPaymentRepository(
364
+ {
365
+ read: () => paymentDb.readAll(),
366
+ write: (records) => paymentDb.upsert(records),
367
+ clear: () => paymentDb.clear(),
368
+ },
369
+ {
370
+ read: (baseUrl, address) => cursorDb.read(baseUrl, address),
371
+ write: (baseUrl, address, since) => cursorDb.write(baseUrl, address, since),
372
+ clear: () => cursorDb.clear(),
373
+ },
374
+ )
375
+ ```
376
+
377
+ The record backend must upsert by `key` and resolve writes only after commit. Cursor values are inclusive and may move backwards; store them exactly. All writes must go through the repository for its gate to cover them. Keep each backend scoped to the wallet's LNURL data.
378
+
379
+ Before clearing other wallet data, stop the app's sync producers and call `await clearLnurlPaymentsForReset(repository)`. This closes admission synchronously, waits for accepted record and cursor writes, then clears cursors followed by records. A late writer is ignored. Failure rejects and reopens admission so the app can abandon reset; success leaves writes closed until a new wallet repository is created. The two backends are not cleared atomically: if clearing records fails, cursors have already been removed, allowing the next sync to replay safely. An explicit `repository.clear()` purges both backends without closing admission; use the reset helper when writers may still be active.
380
+
381
+ `createPendingConfirmations(client?)` shares a batch request per advertised endpoint and falls back to `pollVerify` without one. Pass the quote's `expect` to bind a response to the intended payment. Each registration accepts `startedAt` in milliseconds and optional `timeoutMs` (default 180,000); resuming retains the original deadline. Registrations deduplicate by optional `key`, defaulting to `verifyUrl`. Use distinct keys when separate sends share a verify URL. `forget()` aborts active work and suppresses its later callbacks. The demo wallet uses this factory too.
382
+
383
+ `createLnurlSendTracker` from `/arkade` records funding and later settlement updates, merges by transaction id, and resumes unfinished receiver confirmations:
384
+
385
+ ```ts
386
+ import { createLnurlSendTracker, sentActivityResolver } from '@arkade-os/lnurl-client/arkade'
387
+
388
+ const sends = createLnurlSendTracker({
389
+ read: () => sentStore.read(),
390
+ write: (records) => sentStore.write(records),
391
+ }, { onError: showStorageError })
392
+ wallet.activity.use(sentActivityResolver(sends.all))
393
+ sends.resume()
394
+
395
+ const handle = await quote.send()
396
+ sends.record(handle, quote, target)
397
+ // Once a funding txid is known, start its receiver confirmation.
398
+ sends.watch(txid, verifyUrl, verifyBatch, startedAt, expect)
399
+ ```
400
+
401
+ The send backend reads and writes synchronously. Supply `onError` to surface persistence failures from payment or confirmation callbacks; SDK payment handles otherwise swallow subscriber exceptions. An optional `confirmations` in the same options object injects a shared tracker. `record()` returns an unsubscribe function and releases subscriptions on settlement or failure. It retains the quote's verify URLs, expected payment identity, and unit amount alongside later preimages. `resume()` watches only rows without a recorded confirmation outcome. `mark(txid, confirmed)` updates an existing row; `all()` reads the stored sends.
402
+
403
+ Call `sends.forget()` before clearing its backend on reset: it unsubscribes payment handles and suppresses stale confirmation callbacks, so an old wallet cannot write a preimage into the next wallet's records. It does not delete records; clearing the app's send backend remains the app's responsibility. The existing browser demo store and all earlier APIs remain available. No IndexedDB, localStorage or native persistence dependency is added to these helpers.
404
+
405
+ ### Token quotes
406
+
407
+ For selected token options, `requestInvoice` now keeps a supplied `paymentURI` only when it pays the quoted token, chain, recipient and amount. A malformed, unsupported or mismatched URI falls back to the validated deposit address; an absent URI remains absent. EIP-681 transfers reject ambiguous or extra transfer arguments, and Solana Pay transfers bind any required memo. The result still carries `paymentDestinationTag`: an app that cannot present or carry it must refuse that deposit.
408
+
409
+ ```ts
410
+ import { normalizeTokenQuote, tokenOptions } from '@arkade-os/lnurl-client'
411
+
412
+ const token = tokenOptions(payRequest).find((option) => option.id === selectedId)!
413
+ const answer = await client.requestInvoice(payRequest, { amountSat, paymentOption: token.id })
414
+ if (answer.kind !== 'destination') throw new Error('Expected a token destination')
415
+ const quote = normalizeTokenQuote(token, answer)
416
+ // quote.value, destination, baseUnits, amount, expiresAt, paymentDestinationTag
417
+ ```
418
+
419
+ `normalizeTokenQuote` requires a valid future `paymentQuote.expiresAt` and formats base units exactly through 36 decimals without floating-point conversion. `payableTokenUri(token, answer)` performs the same payment binding without requiring expiry. Existing `requestInvoice` callers retain control over optional expiry handling. Minimum remaining lifetime, fiat-price tolerance, expiry timers and explicit regeneration remain app policy; neither helper places another order.
420
+
421
+ ### Receive rails and startup
422
+
423
+ ```ts
424
+ import { bootstrapReceiver, claimOrAdopt, lnurlRailLimits, resolveLnurlRails } from '@arkade-os/lnurl-client/arkade'
425
+
426
+ const boot = await bootstrapReceiver(facade, { autoClaimNameless: true })
427
+ if (boot.kind === 'ready') {
428
+ const limits = lnurlRailLimits(await boot.receiver.payRequest())
429
+ const targets = await resolveLnurlRails(boot.receiver, amountSat,
430
+ ['lightning', 'arkade', 'onchain'], { arkade: ownArkade, onchain: ownBoarding }, client)
431
+ }
432
+ const receiver = await claimOrAdopt(facade, { username: chosenName })
433
+ ```
434
+
435
+ Bootstrap discovers an owned receiver first. Named receivers need no capabilities request; a nameless receiver remains usable if naming capabilities cannot be fetched, with the error returned as `capabilitiesError`. Only a successful empty ownership lookup can lead to onboarding. Nameless allocation defaults off and requires explicit opt-in, the advertised `session` mode, and no API-key requirement. A newly allocated result carries `claimed: true`; the app decides whether to sync afterwards.
436
+
437
+ `claimOrAdopt` returns an owned receiver or claims with the supplied options; lookup errors propagate without allocation. The demo wallet uses these startup helpers. UI choice ordering and translated errors stay with the app.
438
+
439
+ `lnurlRailLimits` converts each enabled Bitcoin option's millisat bounds to whole sats and excludes token options. `resolveLnurlRails` returns an ordered `Promise.allSettled` result, validates option availability, bounds and response identity, and checks returned payment kinds. Legacy Lightning fallback applies only when `paymentOptions` is absent. Onchain targets must equal the supplied boarding address; Arkade targets must share the supplied address's network and operator. That operator check does not prove recipient ownership: covenant-derived destinations may differ from the static address. An optional injected client controls callback transport. The demo uses the bounds helper when offering receive options.
440
+
441
+ ### IndexedDB backend
442
+
443
+ ```ts
444
+ import { createIndexedDbLnurlPaymentStore } from '@arkade-os/lnurl-client/browser'
445
+ import { createLnurlPaymentRepository } from '@arkade-os/lnurl-client'
446
+
447
+ const payments = createIndexedDbLnurlPaymentStore()
448
+ const repository = createLnurlPaymentRepository(payments, watermarks)
449
+ ```
450
+
451
+ The optional browser entry point touches IndexedDB only when a store operation runs. Defaults preserve the wallet backend's `arkade-lnurl-payments` database, version 1, `payments` store and `key` key path; custom database/store names are accepted. Writes resolve after commit, an aborted transaction rejects, and a synchronous failure while queuing a batch aborts its earlier writes. Every opened operation closes its connection on completion or failure. Cursor persistence stays injected through the repository; this adapter neither migrates nor deletes an existing schema.
452
+
453
+ ## Errors
454
+
455
+ Two shapes, both surfaced as `LnurlError`:
456
+
457
+ - LUD-06 endpoints answer HTTP 200 carrying `{ status: "ERROR", reason }`.
458
+ - Management endpoints answer a real status code carrying `{ error, code? }` (for example 409 with `USERNAME_TAKEN`).
459
+ - Rate limits mix the two: HTTP 429 with the `{ status: "ERROR" }` body.
460
+
461
+ `LnurlError` carries `reason`, optional `httpStatus` and `code`, and `retryable`, which is true if and only if `httpStatus === 429`. Everything else is terminal - do not retry it into a different answer.
462
+
463
+ `LnurlTransportError` means the request or the stream itself failed (network down, unreadable SSE body). `LnurlTimeoutError` means `pollVerify` ran past `timeoutMs`; its `lastSnapshot` holds the last verify status seen.
464
+
465
+ ## Transport
466
+
467
+ The receiver session is POST-SSE over `fetch`: `openSession` POSTs a JSON body to `/lnurl/session` and reads the event stream from `response.body`.
468
+
469
+ **It is not `EventSource`, and cannot be.** `EventSource` is GET-only and carries neither a request body nor headers, while this protocol needs both — the session token goes in the POST body, and the invoice and settled calls are Bearer-authed. That is a property of the server's endpoint rather than a choice this package makes. The practical cost is that reconnect and backoff are implemented here instead of inherited from the platform.
470
+
471
+ The only requirement is therefore a `fetch` whose `Response` exposes a readable `response.body`. `openSession` throws `LnurlTransportError` immediately when it does not, rather than hanging.
472
+
473
+ ### React Native / Expo
474
+
475
+ React Native's global `fetch` does **not** expose a readable `response.body`, so inject [`expo/fetch`](https://docs.expo.dev/versions/latest/sdk/expo/#fetch), which does. This is the same approach `@arkade-os/ts-sdk` takes for its own SSE streams.
476
+
477
+ ```ts
478
+ import { fetch as expoFetch } from 'expo/fetch'
479
+ import { createLnurlClient } from '@arkade-os/lnurl-client'
480
+
481
+ const client = createLnurlClient({
482
+ baseUrl: 'https://lnurl.example.com',
483
+ fetchImpl: expoFetch,
484
+ })
485
+ ```
486
+
487
+ No cast is needed: `FetchImpl` is a structural signature — `(input: string, init?: RequestInit) => Promise<Response>` — rather than `typeof globalThis.fetch`, so any conforming implementation is assignable. The same seam takes an instrumented fetch, one that adds headers, or a test double:
488
+
489
+ ```ts
490
+ const client = createLnurlClient({
491
+ baseUrl: 'https://lnurl.example.com',
492
+ fetchImpl: (url, init) =>
493
+ fetch(url, { ...init, headers: { ...init?.headers, 'X-App': 'wallet' } }),
494
+ })
495
+ ```
496
+
497
+ Passing nothing resolves `globalThis.fetch` **per call** rather than binding it once, so a client constructed at module scope still picks up a fetch installed later — a mock in a test, a polyfill, or a service worker.
498
+
499
+ The payer surface (`resolve`, `requestInvoice`, `pollVerify`) is plain request/response and works anywhere `fetch` exists, streaming or not.