feat(lightning): add withdraw links, balance watching, and npub linking

- Add createWithdrawLink/getWithdrawLink methods using Nostr RPC
- Add watchBalance with Nostr subscription + polling fallback
- Add linkNpub method for connecting Nostr identity to app user
- Fix getBalance to use GetUserInfo RPC instead of GetBalance
- Include appId in RPC requests for correct user resolution
- Add CreateWithdrawLinkParams, CreateWithdrawLinkResponse,
  GetWithdrawLinkResponse types

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Patrick Mulligan 2026-02-16 16:57:21 -05:00
commit 1e891765de
3 changed files with 189 additions and 3 deletions

View file

@ -34,6 +34,9 @@ import {
type ExchangeRate,
type InvoiceCallback,
type LnurlLinkResponse,
type CreateWithdrawLinkParams,
type CreateWithdrawLinkResponse,
type GetWithdrawLinkResponse,
isRPCError,
} from './types.js'
@ -82,13 +85,94 @@ export class LightningPubClient {
}
/**
* Get current balance
* Get current balance via GetUserInfo RPC
*/
async getBalance(): Promise<{ balanceSats: number }> {
const response = await this.sendRPC<BalanceResponse>('GetBalance', {})
const response = await this.sendRPC<BalanceResponse>('GetUserInfo', {})
return { balanceSats: response.balance ?? 0 }
}
/**
* Subscribe to balance updates via LiveUserOperation events + periodic polling.
*
* Uses two mechanisms for reliability:
* 1. Nostr subscription for instant updates (LiveUserOperation events)
* 2. Periodic RPC poll to catch changes from extension payments
* (e.g. LNURL-withdraw callbacks which don't emit LiveUserOperation)
*
* @param callback - Called with the new balance whenever it changes
* @param pollIntervalMs - Polling interval in ms (default: 5000)
* @returns Cleanup function to stop watching
*/
watchBalance(callback: (balanceSats: number) => void, pollIntervalMs = 5000): () => void {
if (!this.nostrClient || !this.identity) {
console.error('[LightningPub] Cannot watch balance: client not initialized')
return () => {}
}
let lastKnownBalance: number | null = null
// 1. Nostr subscription for instant updates (LiveUserOperation events)
const subId = this.nostrClient.subscribe(
[
{
kinds: [LightningPubEventKind.RPC],
authors: [this.config.accountPubkey],
'#p': [this.identity.publicKey],
since: Math.floor(Date.now() / 1000) - 5,
},
],
{
onEvent: (event) => {
try {
const response = decryptJSON<{
requestId: string
latest_balance?: number
}>(this.identity!, this.config.accountPubkey, event.content)
if (
response.requestId === 'GetLiveUserOperations' &&
response.latest_balance !== undefined
) {
lastKnownBalance = response.latest_balance
callback(response.latest_balance)
}
} catch {
// Decryption failed - not for us
}
},
}
)
// 2. Periodic poll as fallback
const pollId = setInterval(async () => {
try {
const { balanceSats } = await this.getBalance()
if (lastKnownBalance !== balanceSats) {
lastKnownBalance = balanceSats
callback(balanceSats)
}
} catch {
// Silently ignore poll failures
}
}, pollIntervalMs)
return () => {
this.nostrClient?.unsubscribe(subId)
clearInterval(pollId)
}
}
/**
* Link this Nostr pubkey to an app user via token
* This connects the Nostr identity to the app user's balance
*/
async linkNpub(token: string): Promise<void> {
console.log('[LightningPub] Linking npub with token:', token.slice(0, 16) + '...')
await this.sendRPC<Record<string, never>>('LinkNPubThroughToken', { token })
console.log('[LightningPub] Successfully linked npub to app user')
}
/**
* Create a Lightning invoice
*/
@ -200,6 +284,42 @@ export class LightningPubClient {
}
}
/**
* Create an LNURL-withdraw link via the withdraw extension
*
* This uses Nostr RPC (NIP-44 encrypted) to create a withdraw link,
* avoiding the need for HTTP and SSL certificates.
*
* The extension pays the invoice from the linked user's balance
* when a wallet claims the withdraw.
*/
async createWithdrawLink(params: CreateWithdrawLinkParams): Promise<CreateWithdrawLinkResponse> {
console.log('[LightningPub] Creating withdraw link via RPC:', params.title)
const response = await this.sendRPC<CreateWithdrawLinkResponse>('withdraw.createLink', {
title: params.title,
min_withdrawable: params.min_withdrawable,
max_withdrawable: params.max_withdrawable,
uses: params.uses ?? 1,
wait_time: params.wait_time ?? 0,
})
console.log('[LightningPub] Withdraw link created:', response.link?.unique_hash)
return response
}
/**
* Get a withdraw link's status via the withdraw extension
*
* Uses Nostr RPC (NIP-44 encrypted) to check if a withdraw link
* has been claimed/spent.
*/
async getWithdrawLink(uniqueHash: string): Promise<GetWithdrawLinkResponse> {
const response = await this.sendRPC<GetWithdrawLinkResponse>('withdraw.getLink', {
unique_hash: uniqueHash,
})
return response
}
/**
* Get payment state for an invoice
*
@ -549,7 +669,9 @@ export class LightningPubClient {
const requestId = this.generateRequestId()
// Lightning.Pub expects: rpcName, params, query, body, authIdentifier, requestId
// Lightning.Pub expects: rpcName, params, query, body, authIdentifier, requestId, appId
// The appId is critical for ensuring the Nostr user is created under the same app
// as HTTP API users, so they share the same balance
const request: RPCRequest = {
rpcName,
params: {},
@ -557,6 +679,7 @@ export class LightningPubClient {
body: body as Record<string, unknown>,
authIdentifier: this.identity.publicKey,
requestId,
appId: this.config.appId,
}
const content = encryptContent(this.identity, this.config.accountPubkey, request)

View file

@ -71,6 +71,9 @@ export {
type PayInvoiceResponse,
// LNURL types
type LnurlLinkResponse,
type CreateWithdrawLinkParams,
type CreateWithdrawLinkResponse,
type GetWithdrawLinkResponse,
// Exchange types
type ExchangeRate,
// Config

View file

@ -32,6 +32,8 @@ export interface RPCRequest {
authIdentifier: string
/** Unique request identifier */
requestId: string
/** Application ID - ensures Nostr user is created under the same app as HTTP API users */
appId?: string
}
/** RPC success response */
@ -187,6 +189,64 @@ export interface LnurlLinkResponse {
lnurl: string
}
/** Parameters for creating a withdraw link via extension RPC */
export interface CreateWithdrawLinkParams {
/** Title/description for the withdraw link */
title: string
/** Minimum withdrawable amount in sats */
min_withdrawable: number
/** Maximum withdrawable amount in sats */
max_withdrawable: number
/** Number of times the link can be used (default: 1) */
uses?: number
/** Minimum time between uses in seconds (default: 0) */
wait_time?: number
}
/** Response from withdraw.createLink RPC */
export interface CreateWithdrawLinkResponse {
/** The created withdraw link */
link: {
/** Unique hash identifier for the link */
unique_hash: string
/** Bech32-encoded LNURL string */
lnurl: string
/** Title of the link */
title: string
/** Minimum withdrawable in sats */
min_withdrawable: number
/** Maximum withdrawable in sats */
max_withdrawable: number
/** Number of uses remaining */
uses: number
/** Wait time between uses */
wait_time: number
}
}
/** Response from withdraw.getLink RPC */
export interface GetWithdrawLinkResponse {
/** The withdraw link details */
link: {
/** Unique hash identifier */
unique_hash: string
/** Bech32-encoded LNURL string */
lnurl: string
/** Title of the link */
title: string
/** Minimum withdrawable in sats */
min_withdrawable: number
/** Maximum withdrawable in sats */
max_withdrawable: number
/** Number of uses remaining (0 = spent) */
uses: number
/** Wait time between uses */
wait_time: number
/** Whether the link has been used/spent */
is_spent?: boolean
}
}
// ============================================================================
// Exchange Rates
// ============================================================================