feat(lnbits): new @bitSpire/lnbits package — LNbits client over the nostr-native-transport
New TypeScript workspace package targeting aiolabs/lnbits's
nostr-native-transport (kind-21000 NIP-44 v2 RPC). Designed as a
drop-in peer of @bitSpire/lightning's LightningPubClient so the
machine app can swap backends with minimal churn (3b).
packages/lnbits/
package.json workspace + nostr-tools deps
tsconfig.json extends root config
src/types.ts wire envelope, Payment, WalletInfo,
SubscribePayments filter / ack / push,
WithdrawLink, PayLink, callback types
src/client.ts LnbitsClient with:
getWallet / getBalance / listWallets
createInvoice / payInvoice
getPayment / decodePayment
subscribePayments / unsubscribe
watchInvoice (resolve-on-paid)
watchWallet (cancel-fn)
createWithdrawLink / getWithdrawLink
listWithdrawLinks / updateWithdrawLink
deleteWithdrawLink
getWithdrawLinkUniqueHashes
src/index.ts public exports
Key differences vs LightningPubClient:
- Auth: signing pubkey IS the credential (no authIdentifier/appId
in the request envelope).
- Subscriptions: one `subscribe_payments` RPC handles all three
cases (payment_hash, wallet_id, tag+link_id). Server emits an
ack first, then push events sharing subscription_id. unsubscribe
teardown emits a closed-push immediately followed by ACK.
- Sharding: outbound responses ≤40K plaintext arrive as a single
event. Larger responses come back as multiple kind-21000 events
sharing a `shardsId`; client reassembly is TODO (lazy: ATM RPCs
rarely return that much data).
- CLINK/ndebit/noffer is NOT covered — LNbits has no CLINK. For
the ATM that means cash-in uses lnurlw + subscribe_payments
instead of ndebit (wired in 3b).
Reuses @bitSpire/nostr-client's encryptContentV2 / decryptContentV2
(NIP-44 v2 via nostr-tools/nip44). No new crypto code.
tsconfig.json path mapping added so `@bitSpire/lnbits` imports
resolve to ./packages/lnbits/src across the monorepo.
Verified: pnpm typecheck clean (13/13 turbo tasks).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
219e7e1e4d
commit
cdde060e43
7 changed files with 848 additions and 0 deletions
31
packages/lnbits/package.json
Normal file
31
packages/lnbits/package.json
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
{
|
||||
"name": "@bitSpire/lnbits",
|
||||
"version": "0.1.0",
|
||||
"description": "LNbits client over the nostr-native-transport (kind-21000, NIP-44 v2)",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"dev": "tsc --watch",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "eslint src/"
|
||||
},
|
||||
"dependencies": {
|
||||
"@bitSpire/nostr-client": "workspace:*",
|
||||
"nostr-tools": "^2.10.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"typescript": "^5.7.0",
|
||||
"vitest": "^2.1.0"
|
||||
}
|
||||
}
|
||||
472
packages/lnbits/src/client.ts
Normal file
472
packages/lnbits/src/client.ts
Normal file
|
|
@ -0,0 +1,472 @@
|
|||
/**
|
||||
* LnbitsClient — talks to an LNbits instance via its
|
||||
* nostr-native-transport (kind-21000 NIP-44 v2 RPC).
|
||||
*
|
||||
* Wire-compatible peer of LightningPubClient (@bitSpire/lightning) so the
|
||||
* machine app's services/lightning.ts can swap one for the other with no
|
||||
* shape changes to its public consumers. Differences vs LightningPubClient:
|
||||
*
|
||||
* - Auth: LNbits derives the calling identity from the event signature.
|
||||
* There is no `authIdentifier`/`appId` field; the signing pubkey IS
|
||||
* the credential, and the LNbits server auto-creates an account on
|
||||
* first contact (with `prvkey=NULL` — see issue aiolabs/lnbits#9).
|
||||
* - Subscriptions: LNbits has a single `subscribe_payments` RPC that
|
||||
* handles both "watch this hash" and "watch this wallet" via filter
|
||||
* fields. It also covers tag/link_id for LNURL settlement events.
|
||||
* - CLINK/ndebit/noffer is NOT in LNbits. Anything that used kind
|
||||
* 21001/21002/21003 must use a different flow under LNbits — for the
|
||||
* ATM that means LNURL-withdraw + subscribe_payments for cash-in.
|
||||
*
|
||||
* See docs/devs/nostr-transport.md in aiolabs/lnbits for the full surface.
|
||||
*/
|
||||
|
||||
import {
|
||||
type NostrClient,
|
||||
type MachineIdentity,
|
||||
type Event as NostrEvent,
|
||||
encryptContentV2,
|
||||
decryptContentV2,
|
||||
} from '@bitSpire/nostr-client'
|
||||
import { finalizeEvent } from 'nostr-tools'
|
||||
|
||||
import type {
|
||||
LnbitsConfig,
|
||||
LnbitsRpcRequest,
|
||||
LnbitsRpcResponse,
|
||||
LnbitsPayment,
|
||||
CreateInvoiceBody,
|
||||
PayInvoiceBody,
|
||||
WalletInfo,
|
||||
SubscribePaymentsBody,
|
||||
SubscribeAck,
|
||||
PaymentPushCallback,
|
||||
SubscriptionCloseCallback,
|
||||
CreateWithdrawLinkBody,
|
||||
LnbitsWithdrawLink,
|
||||
UniqueHashesResponse,
|
||||
} from './types.js'
|
||||
|
||||
const LNBITS_KIND_RPC = 21000
|
||||
|
||||
/** Active streaming subscription state held client-side. */
|
||||
interface ActiveSubscription {
|
||||
subscriptionId: string
|
||||
requestId: string
|
||||
/** Underlying nostr relay subscription id (so we can close it). */
|
||||
relaySubId: string
|
||||
onPush: PaymentPushCallback
|
||||
onClose?: SubscriptionCloseCallback
|
||||
}
|
||||
|
||||
export class LnbitsClient {
|
||||
private readonly config: Required<LnbitsConfig>
|
||||
private nostr: NostrClient | null = null
|
||||
private identity: MachineIdentity | null = null
|
||||
private requestCounter = 0
|
||||
private readonly pending = new Map<
|
||||
string,
|
||||
{ resolve: (r: LnbitsRpcResponse) => void; reject: (e: Error) => void; timer: ReturnType<typeof setTimeout> }
|
||||
>()
|
||||
private readonly subscriptions = new Map<string, ActiveSubscription>()
|
||||
private relaySubIdForReplies?: string
|
||||
|
||||
constructor(config: LnbitsConfig) {
|
||||
this.config = {
|
||||
timeout: 30_000,
|
||||
...config,
|
||||
}
|
||||
}
|
||||
|
||||
initialize(nostr: NostrClient, identity: MachineIdentity): void {
|
||||
this.nostr = nostr
|
||||
this.identity = identity
|
||||
this.startReplyListener()
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Wallet
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Returns this caller's wallet via `get_wallet`. If `walletId` is omitted,
|
||||
* uses the first wallet from `list_wallets` (the default wallet, auto-
|
||||
* created on first contact under LNBITS_DEMO_MODE or via web flow).
|
||||
*/
|
||||
async getWallet(walletId?: string): Promise<WalletInfo> {
|
||||
if (walletId) {
|
||||
const data = await this.sendRpc<WalletInfo>('get_wallet', { walletId })
|
||||
return data
|
||||
}
|
||||
const wallets = await this.listWallets()
|
||||
if (wallets.length === 0) {
|
||||
throw new Error('LnbitsClient.getWallet: no wallets for this account')
|
||||
}
|
||||
return wallets[0]!
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns balance in **satoshis** (the LightningPubClient surface used
|
||||
* sats; preserve that here to keep services/lightning.ts mechanical).
|
||||
* Internally LNbits stores msat.
|
||||
*/
|
||||
async getBalance(walletId?: string): Promise<{ balanceSats: number }> {
|
||||
const wallet = await this.getWallet(walletId)
|
||||
return { balanceSats: Math.floor(wallet.balance / 1000) }
|
||||
}
|
||||
|
||||
/** Enumerate every wallet owned by the calling account. */
|
||||
async listWallets(): Promise<WalletInfo[]> {
|
||||
const data = await this.sendRpc<WalletInfo[]>('list_wallets', {})
|
||||
return data ?? []
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Invoices
|
||||
// ============================================================================
|
||||
|
||||
async createInvoice(walletId: string, body: CreateInvoiceBody): Promise<LnbitsPayment> {
|
||||
const data = await this.sendRpc<LnbitsPayment>('create_invoice', { walletId, body })
|
||||
return data
|
||||
}
|
||||
|
||||
async payInvoice(walletId: string, body: PayInvoiceBody): Promise<LnbitsPayment> {
|
||||
const data = await this.sendRpc<LnbitsPayment>('pay_invoice', { walletId, body })
|
||||
return data
|
||||
}
|
||||
|
||||
/** Point-lookup of a payment by hash. AUTH_NONE — hashes are hard to guess. */
|
||||
async getPayment(paymentHash: string): Promise<LnbitsPayment | null> {
|
||||
const data = await this.sendRpc<LnbitsPayment | null>('get_payment', {
|
||||
body: { payment_hash: paymentHash },
|
||||
})
|
||||
return data ?? null
|
||||
}
|
||||
|
||||
async decodePayment(paymentRequest: string): Promise<Record<string, unknown>> {
|
||||
const data = await this.sendRpc<Record<string, unknown>>('decode_payment', {
|
||||
body: { payment_request: paymentRequest },
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Subscriptions
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Open a long-lived subscription that pushes settlement events to `onPush`
|
||||
* whenever a Payment matches the filter. Returns the server-issued
|
||||
* subscription_id (also tracked client-side so `unsubscribe` works).
|
||||
*
|
||||
* Filter must specify at least one of `wallet_id`/`payment_hash`/`tag+link_id`
|
||||
* — empty filters are rejected server-side with status:"ERROR".
|
||||
*/
|
||||
async subscribePayments(
|
||||
walletId: string | undefined,
|
||||
filter: SubscribePaymentsBody,
|
||||
onPush: PaymentPushCallback,
|
||||
onClose?: SubscriptionCloseCallback,
|
||||
): Promise<string> {
|
||||
if (!this.nostr || !this.identity) {
|
||||
throw new Error('LnbitsClient.subscribePayments: client not initialized')
|
||||
}
|
||||
const requestId = this.nextRequestId('sub')
|
||||
|
||||
// Pre-register the push handlers BEFORE the ack arrives so we don't
|
||||
// race against an extremely fast first push (rare but possible).
|
||||
const placeholderSub: ActiveSubscription = {
|
||||
subscriptionId: '',
|
||||
requestId,
|
||||
relaySubId: this.relaySubIdForReplies ?? '',
|
||||
onPush,
|
||||
onClose,
|
||||
}
|
||||
// We don't have subscriptionId yet — index by requestId temporarily.
|
||||
// The reply listener will move it under the real subscriptionId once
|
||||
// the ack comes back.
|
||||
this.subscriptions.set(`pending:${requestId}`, placeholderSub)
|
||||
|
||||
try {
|
||||
const ack = await this.sendRpc<SubscribeAck>('subscribe_payments', {
|
||||
walletId,
|
||||
body: filter,
|
||||
requestId,
|
||||
})
|
||||
const subId = ack.subscription_id
|
||||
placeholderSub.subscriptionId = subId
|
||||
this.subscriptions.delete(`pending:${requestId}`)
|
||||
this.subscriptions.set(subId, placeholderSub)
|
||||
return subId
|
||||
} catch (err) {
|
||||
this.subscriptions.delete(`pending:${requestId}`)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience: watch a single payment by hash until it settles. Resolves
|
||||
* with the Payment object on success; rejects on TTL close without a
|
||||
* settlement. Mirrors LightningPubClient.watchInvoice's resolve-on-paid
|
||||
* shape so services/lightning.ts can swap mechanically.
|
||||
*/
|
||||
async watchInvoice(
|
||||
walletId: string,
|
||||
paymentHash: string,
|
||||
timeoutMs = 5 * 60_000,
|
||||
): Promise<LnbitsPayment> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let resolved = false
|
||||
const subId = this.subscribePayments(
|
||||
walletId,
|
||||
{ payment_hash: paymentHash, max_seconds: Math.ceil(timeoutMs / 1000) },
|
||||
(payment) => {
|
||||
if (payment.payment_hash !== paymentHash) return
|
||||
if (payment.status !== 'success') return
|
||||
if (resolved) return
|
||||
resolved = true
|
||||
void this.unsubscribe(walletId, subIdRef).catch(() => {})
|
||||
resolve(payment)
|
||||
},
|
||||
(reason) => {
|
||||
if (resolved) return
|
||||
resolved = true
|
||||
reject(new Error(`watchInvoice: subscription closed (${reason}) before settlement`))
|
||||
},
|
||||
)
|
||||
let subIdRef = ''
|
||||
subId.then((id) => (subIdRef = id)).catch(reject)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Watch all settlements on a wallet. Useful for live balance updates.
|
||||
* Returns a `cancel()` function that closes the subscription server-side.
|
||||
*/
|
||||
async watchWallet(
|
||||
walletId: string,
|
||||
onPush: PaymentPushCallback,
|
||||
onClose?: SubscriptionCloseCallback,
|
||||
): Promise<() => void> {
|
||||
const subId = await this.subscribePayments(walletId, { max_seconds: 600 }, onPush, onClose)
|
||||
return () => {
|
||||
void this.unsubscribe(walletId, subId).catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel a server-side subscription. The wire mechanic is asymmetric:
|
||||
* the handler emits the `closed` push FIRST and the dispatcher ACK SECOND,
|
||||
* so the relay sees two events. We discard the close push (the local
|
||||
* subscription state is already torn down by the time it arrives) and
|
||||
* await only the ACK.
|
||||
*/
|
||||
async unsubscribe(walletId: string | undefined, subscriptionId: string): Promise<boolean> {
|
||||
const sub = this.subscriptions.get(subscriptionId)
|
||||
if (sub) this.subscriptions.delete(subscriptionId)
|
||||
try {
|
||||
const data = await this.sendRpc<{ ok: boolean }>('unsubscribe', {
|
||||
walletId,
|
||||
body: { subscription_id: subscriptionId },
|
||||
})
|
||||
return data.ok
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// LNURL-withdraw (cash-in for the ATM)
|
||||
// ============================================================================
|
||||
|
||||
async createWithdrawLink(walletId: string, body: CreateWithdrawLinkBody): Promise<LnbitsWithdrawLink> {
|
||||
const data = await this.sendRpc<LnbitsWithdrawLink>('lnurlw_create_link', { walletId, body })
|
||||
return data
|
||||
}
|
||||
|
||||
async getWithdrawLink(walletId: string, id: string): Promise<LnbitsWithdrawLink> {
|
||||
const data = await this.sendRpc<LnbitsWithdrawLink>('lnurlw_get_link', { walletId, body: { id } })
|
||||
return data
|
||||
}
|
||||
|
||||
async listWithdrawLinks(
|
||||
walletId: string | undefined,
|
||||
body: { limit?: number; offset?: number } = {},
|
||||
): Promise<{ data: LnbitsWithdrawLink[]; total: number }> {
|
||||
return this.sendRpc<{ data: LnbitsWithdrawLink[]; total: number }>('lnurlw_list_links', {
|
||||
walletId,
|
||||
body,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* For an `is_unique=true` link, returns the per-use `id_unique_hash`
|
||||
* values for each unredeemed slot. Each one becomes the trailing path
|
||||
* component of `/withdraw/api/v1/lnurl/<unique_hash>/<id_unique_hash>`
|
||||
* — the URL a customer wallet GETs to redeem that specific sub-link.
|
||||
*/
|
||||
async getWithdrawLinkUniqueHashes(walletId: string, id: string): Promise<UniqueHashesResponse> {
|
||||
return this.sendRpc<UniqueHashesResponse>('lnurlw_unique_hashes', {
|
||||
walletId,
|
||||
body: { id },
|
||||
})
|
||||
}
|
||||
|
||||
async updateWithdrawLink(
|
||||
walletId: string,
|
||||
id: string,
|
||||
patch: Partial<CreateWithdrawLinkBody>,
|
||||
): Promise<LnbitsWithdrawLink> {
|
||||
return this.sendRpc<LnbitsWithdrawLink>('lnurlw_update_link', {
|
||||
walletId,
|
||||
body: { id, ...patch },
|
||||
})
|
||||
}
|
||||
|
||||
async deleteWithdrawLink(walletId: string, id: string): Promise<{ ok: boolean }> {
|
||||
return this.sendRpc<{ ok: boolean }>('lnurlw_delete_link', { walletId, body: { id } })
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Internal — RPC plumbing
|
||||
// ============================================================================
|
||||
|
||||
private nextRequestId(prefix = 'req'): string {
|
||||
this.requestCounter += 1
|
||||
return `${prefix}-${this.requestCounter}-${Math.random().toString(36).slice(2, 8)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish an RPC event and resolve with the first matching reply.
|
||||
* The reply listener (started by initialize()) matches by `request_id`.
|
||||
*/
|
||||
private async sendRpc<T = unknown>(
|
||||
rpcName: string,
|
||||
args: {
|
||||
walletId?: string
|
||||
body?: unknown
|
||||
query?: unknown
|
||||
requestId?: string
|
||||
},
|
||||
): Promise<T> {
|
||||
if (!this.nostr || !this.identity) {
|
||||
throw new Error(`LnbitsClient.${rpcName}: client not initialized`)
|
||||
}
|
||||
const requestId = args.requestId ?? this.nextRequestId(rpcName)
|
||||
const request: LnbitsRpcRequest = {
|
||||
rpc_name: rpcName,
|
||||
request_id: requestId,
|
||||
}
|
||||
if (args.walletId !== undefined) request.wallet_id = args.walletId
|
||||
if (args.body !== undefined) request.body = args.body as Record<string, unknown>
|
||||
if (args.query !== undefined) request.query = args.query as Record<string, unknown>
|
||||
|
||||
const plaintext = JSON.stringify(request)
|
||||
const encrypted = encryptContentV2(this.identity, this.config.serverPubkey, plaintext)
|
||||
|
||||
// Build + sign the kind-21000 event ourselves. The server reads our
|
||||
// pubkey directly off the signature, so there's no separate
|
||||
// authIdentifier in the envelope (unlike LightningPubClient).
|
||||
const event = finalizeEvent(
|
||||
{
|
||||
kind: LNBITS_KIND_RPC,
|
||||
content: encrypted,
|
||||
tags: [['p', this.config.serverPubkey]],
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
},
|
||||
this.identity.privateKey,
|
||||
)
|
||||
|
||||
// The pending entry MUST be registered before publish so we don't race
|
||||
// an extremely fast reply.
|
||||
const result = new Promise<T>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
this.pending.delete(requestId)
|
||||
reject(new Error(`LnbitsClient.${rpcName}: timeout after ${this.config.timeout}ms`))
|
||||
}, this.config.timeout)
|
||||
|
||||
this.pending.set(requestId, {
|
||||
resolve: (response) => {
|
||||
clearTimeout(timer)
|
||||
this.pending.delete(requestId)
|
||||
if (response.status === 'ERROR') {
|
||||
reject(new Error(response.error ?? `${rpcName}: server returned ERROR`))
|
||||
return
|
||||
}
|
||||
resolve(response.data as T)
|
||||
},
|
||||
reject: (err) => {
|
||||
clearTimeout(timer)
|
||||
this.pending.delete(requestId)
|
||||
reject(err)
|
||||
},
|
||||
timer,
|
||||
})
|
||||
})
|
||||
|
||||
await this.nostr.publish(event)
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* One-time setup: subscribe on the relay for kind-21000 events tagged to
|
||||
* our pubkey from the LNbits server. Every reply (one-shot ACK or
|
||||
* subscription push) flows through this single listener; we dispatch
|
||||
* based on `request_id` and `subscription_id`.
|
||||
*/
|
||||
private startReplyListener(): void {
|
||||
if (!this.nostr || !this.identity) return
|
||||
const myPubkey = this.identity.publicKey
|
||||
const since = Math.floor(Date.now() / 1000) - 5
|
||||
|
||||
this.relaySubIdForReplies = this.nostr.subscribe(
|
||||
[
|
||||
{
|
||||
kinds: [LNBITS_KIND_RPC],
|
||||
authors: [this.config.serverPubkey],
|
||||
'#p': [myPubkey],
|
||||
since,
|
||||
},
|
||||
],
|
||||
{
|
||||
onEvent: (ev: NostrEvent) => this.handleReply(ev),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private handleReply(ev: NostrEvent): void {
|
||||
if (!this.identity) return
|
||||
let plaintext: string
|
||||
try {
|
||||
plaintext = decryptContentV2(this.identity, this.config.serverPubkey, ev.content)
|
||||
} catch {
|
||||
return // not our peer or wrong key
|
||||
}
|
||||
let parsed: LnbitsRpcResponse
|
||||
try {
|
||||
parsed = JSON.parse(plaintext) as LnbitsRpcResponse
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
// Pending one-shot RPC ack — match by request_id.
|
||||
const pending = this.pending.get(parsed.request_id)
|
||||
if (pending) {
|
||||
pending.resolve(parsed)
|
||||
}
|
||||
|
||||
// Subscription push — match by subscription_id at the top level.
|
||||
const subId = parsed.subscription_id ?? null
|
||||
if (subId) {
|
||||
const sub = this.subscriptions.get(subId)
|
||||
if (sub) {
|
||||
const data = (parsed.data ?? {}) as { payment?: LnbitsPayment; closed?: boolean; reason?: 'ttl' | 'unsubscribed' }
|
||||
if (data.closed === true) {
|
||||
this.subscriptions.delete(subId)
|
||||
sub.onClose?.(data.reason ?? 'ttl')
|
||||
} else if (data.payment) {
|
||||
sub.onPush(data.payment)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
71
packages/lnbits/src/index.ts
Normal file
71
packages/lnbits/src/index.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
/**
|
||||
* @bitSpire/lnbits — public surface.
|
||||
*
|
||||
* LNbits client that speaks the nostr-native-transport (kind-21000,
|
||||
* NIP-44 v2) shipped on aiolabs/lnbits@nostr-native-transport. Designed
|
||||
* as a drop-in peer of @bitSpire/lightning's LightningPubClient so the
|
||||
* machine app can swap backends with minimal call-site churn.
|
||||
*
|
||||
* The transport is fully documented at
|
||||
* docs/devs/nostr-transport.md
|
||||
* in the LNbits repo. See packages/lnbits/src/types.ts for the wire
|
||||
* envelope and packages/lnbits/src/client.ts for the API surface.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* import { LnbitsClient } from '@bitSpire/lnbits'
|
||||
* import { NostrClient, loadIdentityFromHex } from '@bitSpire/nostr-client'
|
||||
*
|
||||
* const nostr = new NostrClient({ relays: ['wss://relay.example/'] })
|
||||
* await nostr.connect()
|
||||
* const identity = loadIdentityFromHex(process.env.VITE_ATM_PRIVATE_KEY!)
|
||||
*
|
||||
* const client = new LnbitsClient({
|
||||
* serverPubkey: process.env.VITE_LNBITS_SERVER_PUBKEY!,
|
||||
* relays: ['wss://relay.example/'],
|
||||
* })
|
||||
* client.initialize(nostr, identity)
|
||||
*
|
||||
* // Discover the auto-created default wallet
|
||||
* const [wallet] = await client.listWallets()
|
||||
*
|
||||
* // Create an invoice + watch for settlement
|
||||
* const invoice = await client.createInvoice(wallet.id, { amount: 100, memo: 'cash-out' })
|
||||
* const paid = await client.watchInvoice(wallet.id, invoice.payment_hash)
|
||||
* console.log('paid:', paid.preimage)
|
||||
*
|
||||
* // Cash-in via LNURL-withdraw + subscribe
|
||||
* const link = await client.createWithdrawLink(wallet.id, {
|
||||
* title: 'cash-in session',
|
||||
* min_withdrawable: 100, max_withdrawable: 100,
|
||||
* uses: 1, wait_time: 1, is_unique: false,
|
||||
* })
|
||||
* await client.subscribePayments(
|
||||
* wallet.id,
|
||||
* { tag: 'withdraw', link_id: link.id, max_seconds: 300 },
|
||||
* (push) => console.log('link claimed:', push.payment_hash),
|
||||
* )
|
||||
* ```
|
||||
*/
|
||||
|
||||
export { LnbitsClient } from './client.js'
|
||||
export type {
|
||||
LnbitsConfig,
|
||||
LnbitsRpcRequest,
|
||||
LnbitsRpcResponse,
|
||||
LnbitsPayment,
|
||||
CreateInvoiceBody,
|
||||
PayInvoiceBody,
|
||||
WalletInfo,
|
||||
SubscribePaymentsBody,
|
||||
SubscribeAck,
|
||||
SubscribePush,
|
||||
SubscribeClose,
|
||||
PaymentPushCallback,
|
||||
SubscriptionCloseCallback,
|
||||
CreateWithdrawLinkBody,
|
||||
LnbitsWithdrawLink,
|
||||
UniqueHashEntry,
|
||||
UniqueHashesResponse,
|
||||
LnbitsPayLink,
|
||||
} from './types.js'
|
||||
232
packages/lnbits/src/types.ts
Normal file
232
packages/lnbits/src/types.ts
Normal file
|
|
@ -0,0 +1,232 @@
|
|||
/**
|
||||
* @bitSpire/lnbits — type definitions for the LNbits nostr-native-transport.
|
||||
*
|
||||
* Wire envelope mirrors `lnbits/core/services/nostr_transport/models.py` in
|
||||
* the aiolabs/lnbits@nostr-native-transport branch. All RPC traffic is
|
||||
* NIP-44 v2 encrypted kind-21000 events on a relay; the inner JSON has
|
||||
* the shape below.
|
||||
*/
|
||||
|
||||
// ============================================================================
|
||||
// Wire envelope (decrypted plaintext inside kind-21000 events)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Inbound request — what the client publishes to the LNbits server's pubkey.
|
||||
*/
|
||||
export interface LnbitsRpcRequest {
|
||||
rpc_name: string
|
||||
request_id: string
|
||||
/** Required for AUTH_WALLET RPCs; selects which of the caller's wallets to act on. */
|
||||
wallet_id?: string
|
||||
body?: Record<string, unknown>
|
||||
/** Used by a small number of RPCs (e.g. list_payments filters). */
|
||||
query?: Record<string, unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
* Outbound response — what the server emits back on the same relay.
|
||||
*
|
||||
* For one-shot RPCs the client awaits a single response with this shape.
|
||||
* For subscribe_payments the server emits an immediate ack (with status:OK
|
||||
* and data.subscription_id) followed by one push response per matching
|
||||
* Payment, each carrying the same subscription_id at top level. The final
|
||||
* push when the subscription closes has data.closed=true.
|
||||
*/
|
||||
export interface LnbitsRpcResponse<T = unknown> {
|
||||
status: 'OK' | 'ERROR'
|
||||
request_id: string
|
||||
/** Non-null on subscription push events. Null on regular acks. */
|
||||
subscription_id?: string | null
|
||||
data?: T
|
||||
error?: string
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Client configuration
|
||||
// ============================================================================
|
||||
|
||||
export interface LnbitsConfig {
|
||||
/** Server's nostr-transport pubkey (hex). Published in the server's startup log. */
|
||||
serverPubkey: string
|
||||
/** Relays where the server is publishing/subscribing. Same set the server uses. */
|
||||
relays: string[]
|
||||
/** Per-call timeout in ms (default 30_000). */
|
||||
timeout?: number
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Payment object — server-side shape, returned by every payment-touching RPC
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Mirrors lnbits.core.models.payments.Payment.dict().
|
||||
* Negative `amount` ⇒ outgoing. Positive ⇒ incoming. Units are msat.
|
||||
*/
|
||||
export interface LnbitsPayment {
|
||||
checking_id: string
|
||||
payment_hash: string
|
||||
wallet_id: string
|
||||
amount: number // msat
|
||||
fee: number // msat
|
||||
bolt11: string
|
||||
payment_request: string
|
||||
status: 'pending' | 'success' | 'failed'
|
||||
memo?: string | null
|
||||
expiry?: string
|
||||
preimage?: string | null
|
||||
time: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
extra?: Record<string, unknown>
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Payment-RPC inputs / outputs (the subset relevant to the ATM)
|
||||
// ============================================================================
|
||||
|
||||
export interface CreateInvoiceBody {
|
||||
amount: number
|
||||
memo?: string
|
||||
unit?: 'sat' | 'msat'
|
||||
expiry?: number
|
||||
extra?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface PayInvoiceBody {
|
||||
bolt11: string
|
||||
max_sat?: number
|
||||
extra?: Record<string, unknown>
|
||||
description?: string
|
||||
}
|
||||
|
||||
export interface WalletInfo {
|
||||
id: string
|
||||
name: string
|
||||
/** Balance in millisat. */
|
||||
balance: number
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Subscriptions
|
||||
// ============================================================================
|
||||
|
||||
export interface SubscribePaymentsBody {
|
||||
payment_hash?: string
|
||||
tag?: string
|
||||
link_id?: string
|
||||
/** Server-side clamped to [1, 600]. Default 300. */
|
||||
max_seconds?: number
|
||||
}
|
||||
|
||||
export interface SubscribeAck {
|
||||
subscription_id: string
|
||||
/** Unix epoch seconds. */
|
||||
expires_at: number
|
||||
}
|
||||
|
||||
/** Push event payload — every settlement that matches the filter. */
|
||||
export interface SubscribePush {
|
||||
payment: LnbitsPayment
|
||||
}
|
||||
|
||||
/** Final close push. */
|
||||
export interface SubscribeClose {
|
||||
subscription_id: string
|
||||
closed: true
|
||||
reason: 'ttl' | 'unsubscribed'
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// LNURL-withdraw (the `withdraw` extension's transport surface)
|
||||
// ============================================================================
|
||||
|
||||
export interface CreateWithdrawLinkBody {
|
||||
title: string
|
||||
min_withdrawable: number
|
||||
max_withdrawable: number
|
||||
uses: number
|
||||
wait_time: number
|
||||
is_unique?: boolean
|
||||
webhook_url?: string
|
||||
webhook_headers?: string
|
||||
webhook_body?: string
|
||||
custom_url?: string
|
||||
enabled?: boolean
|
||||
}
|
||||
|
||||
/** Full WithdrawLink as the LNbits side returns it. */
|
||||
export interface LnbitsWithdrawLink {
|
||||
id: string
|
||||
wallet: string
|
||||
title: string
|
||||
min_withdrawable: number
|
||||
max_withdrawable: number
|
||||
uses: number
|
||||
used: number
|
||||
wait_time: number
|
||||
is_unique: boolean
|
||||
unique_hash: string
|
||||
k1: string
|
||||
open_time: number
|
||||
usescsv: string
|
||||
webhook_url?: string | null
|
||||
webhook_headers?: string | null
|
||||
webhook_body?: string | null
|
||||
custom_url?: string | null
|
||||
enabled?: boolean
|
||||
lnurl?: string | null
|
||||
lnurl_url?: string | null
|
||||
}
|
||||
|
||||
export interface UniqueHashEntry {
|
||||
index: string
|
||||
id_unique_hash: string
|
||||
}
|
||||
|
||||
export interface UniqueHashesResponse {
|
||||
link_id: string
|
||||
unique_hash: string
|
||||
is_unique: boolean
|
||||
unredeemed_hashes: UniqueHashEntry[]
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// LNURL-pay (the `lnurlp` extension's transport surface) — kept for parity
|
||||
// even though the ATM use case primarily exercises lnurlw. The shape is
|
||||
// looser than the withdraw side; mirror what lnurlp.crud returns.
|
||||
// ============================================================================
|
||||
|
||||
export interface LnbitsPayLink {
|
||||
id: string
|
||||
wallet: string
|
||||
description: string
|
||||
min: number
|
||||
max: number
|
||||
served_meta: number
|
||||
served_pr: number
|
||||
comment_chars: number
|
||||
currency?: string | null
|
||||
domain?: string | null
|
||||
username?: string | null
|
||||
zaps?: boolean
|
||||
disposable?: boolean
|
||||
webhook_url?: string | null
|
||||
webhook_headers?: string | null
|
||||
webhook_body?: string | null
|
||||
success_text?: string | null
|
||||
success_url?: string | null
|
||||
fiat_base_multiplier?: number
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Callbacks
|
||||
// ============================================================================
|
||||
|
||||
/** Called once per subscription push event (after the initial ack). */
|
||||
export type PaymentPushCallback = (payment: LnbitsPayment) => void
|
||||
|
||||
/** Called when the subscription has been closed (by TTL or explicit unsubscribe). */
|
||||
export type SubscriptionCloseCallback = (reason: 'ttl' | 'unsubscribed') => void
|
||||
22
packages/lnbits/tsconfig.json
Normal file
22
packages/lnbits/tsconfig.json
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"strictNullChecks": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist", "**/*.test.ts"]
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue