From 1e891765de283b5fa8dab1a795734fc93597e252 Mon Sep 17 00:00:00 2001 From: Patrick Mulligan Date: Mon, 16 Feb 2026 16:57:21 -0500 Subject: [PATCH] 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 --- packages/lightning/src/client.ts | 129 ++++++++++++++++++++++++++++++- packages/lightning/src/index.ts | 3 + packages/lightning/src/types.ts | 60 ++++++++++++++ 3 files changed, 189 insertions(+), 3 deletions(-) diff --git a/packages/lightning/src/client.ts b/packages/lightning/src/client.ts index d8527d7..832d62a 100644 --- a/packages/lightning/src/client.ts +++ b/packages/lightning/src/client.ts @@ -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('GetBalance', {}) + const response = await this.sendRPC('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 { + console.log('[LightningPub] Linking npub with token:', token.slice(0, 16) + '...') + await this.sendRPC>('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 { + console.log('[LightningPub] Creating withdraw link via RPC:', params.title) + const response = await this.sendRPC('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 { + const response = await this.sendRPC('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, authIdentifier: this.identity.publicKey, requestId, + appId: this.config.appId, } const content = encryptContent(this.identity, this.config.accountPubkey, request) diff --git a/packages/lightning/src/index.ts b/packages/lightning/src/index.ts index f6ea185..6916463 100644 --- a/packages/lightning/src/index.ts +++ b/packages/lightning/src/index.ts @@ -71,6 +71,9 @@ export { type PayInvoiceResponse, // LNURL types type LnurlLinkResponse, + type CreateWithdrawLinkParams, + type CreateWithdrawLinkResponse, + type GetWithdrawLinkResponse, // Exchange types type ExchangeRate, // Config diff --git a/packages/lightning/src/types.ts b/packages/lightning/src/types.ts index 634e46c..192b88a 100644 --- a/packages/lightning/src/types.ts +++ b/packages/lightning/src/types.ts @@ -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 // ============================================================================