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:
Padreug 2026-05-13 12:48:55 +02:00
commit cdde060e43
7 changed files with 848 additions and 0 deletions

View 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"
}
}

View 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)
}
}
}
}
}

View 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'

View 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

View 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
View file

@ -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':

View file

@ -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"]