@arkade-os/lnurl-client 0.6.0 → 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
@@ -10,7 +10,7 @@ pnpm add @arkade-os/lnurl-client
10
10
 
11
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
12
 
13
- Runtime dependencies are `@scure/base` and `@noble/hashes` - nothing else is pulled in, and the package never imports the server, so app bundles stay free of `express` and `sqlite`.
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
14
 
15
15
  ## With an Arkade wallet
16
16
 
@@ -64,6 +64,42 @@ CI checks the client against SDK `0.5.0-rc.0` and `0.5.0-rc.19` in isolated inst
64
64
 
65
65
  Everything below is the surface these are built on; reach for it when the facade does not fit.
66
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
+
67
103
  ## Payer
68
104
 
69
105
  `createLnurlClient()` with no `baseUrl` covers payer-only use: `resolve`, `requestInvoice` and `pollVerify` talk to whatever host the address or LNURL points at.
@@ -89,7 +125,11 @@ A settled bolt11 status is only returned when `SHA256(preimage)` equals the paym
89
125
 
90
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.
91
127
 
92
- `requestInvoice` takes `amountSat` plus an optional `comment`. It also accepts `paymentOption` and `unit`, except on a session payRequest that does not advertise them (a live session's); a nameless receiver's session LNURL advertises both. The amount is range-checked locally before anything hits the wire.
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.
93
133
 
94
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.
95
135
 
@@ -287,7 +327,7 @@ const { synced, failures } = await syncPayments(
287
327
  )
288
328
  ```
289
329
 
290
- **`browserPaymentStore()` is the only storage shipped, and it is `localStorage`** — synchronous, a few megabytes, and it rewrites every record per upsert. Fine for a demo, wrong past that, and absent entirely in Node and React Native. Anything real supplies its own store, the same injection the package uses for `fetchImpl`:
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`:
291
331
 
292
332
  ```ts
293
333
  interface PaymentSyncStore {
@@ -311,7 +351,104 @@ These matter for correctness. `syncPayments` handles them all; you must handle t
311
351
 
312
352
  ### Activity resolvers
313
353
 
314
- `@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 getter rather than a store: storage — where `payments`/`sends` come from, and where a completed send is recorded (`mergeSentPayment` computes the merge; writing it is yours) — stays entirely with the caller.
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.
315
452
 
316
453
  ## Errors
317
454