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"]
|
||||||
|
}
|
||||||
19
pnpm-lock.yaml
generated
19
pnpm-lock.yaml
generated
|
|
@ -206,6 +206,25 @@ importers:
|
||||||
specifier: ^2.1.0
|
specifier: ^2.1.0
|
||||||
version: 2.1.9(@types/node@22.19.7)(lightningcss@1.30.2)
|
version: 2.1.9(@types/node@22.19.7)(lightningcss@1.30.2)
|
||||||
|
|
||||||
|
packages/lnbits:
|
||||||
|
dependencies:
|
||||||
|
'@bitSpire/nostr-client':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../nostr-client
|
||||||
|
nostr-tools:
|
||||||
|
specifier: ^2.10.0
|
||||||
|
version: 2.19.4(typescript@5.9.3)
|
||||||
|
devDependencies:
|
||||||
|
'@types/node':
|
||||||
|
specifier: ^22.0.0
|
||||||
|
version: 22.19.7
|
||||||
|
typescript:
|
||||||
|
specifier: ^5.7.0
|
||||||
|
version: 5.9.3
|
||||||
|
vitest:
|
||||||
|
specifier: ^2.1.0
|
||||||
|
version: 2.1.9(@types/node@22.19.7)(lightningcss@1.30.2)
|
||||||
|
|
||||||
packages/nostr-client:
|
packages/nostr-client:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@noble/curves':
|
'@noble/curves':
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@
|
||||||
"@bitSpire/clink": ["./packages/clink/src"],
|
"@bitSpire/clink": ["./packages/clink/src"],
|
||||||
"@bitSpire/state-machine": ["./packages/state-machine/src"],
|
"@bitSpire/state-machine": ["./packages/state-machine/src"],
|
||||||
"@bitSpire/lightning": ["./packages/lightning/src"],
|
"@bitSpire/lightning": ["./packages/lightning/src"],
|
||||||
|
"@bitSpire/lnbits": ["./packages/lnbits/src"],
|
||||||
"@bitSpire/cashu": ["./packages/cashu/src"],
|
"@bitSpire/cashu": ["./packages/cashu/src"],
|
||||||
"@bitSpire/ui-shared": ["./packages/ui-shared/src"],
|
"@bitSpire/ui-shared": ["./packages/ui-shared/src"],
|
||||||
"@bitSpire/hal": ["./packages/hal"]
|
"@bitSpire/hal": ["./packages/hal"]
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue