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:
parent
5e94cdc11b
commit
1e891765de
3 changed files with 189 additions and 3 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -71,6 +71,9 @@ export {
|
|||
type PayInvoiceResponse,
|
||||
// LNURL types
|
||||
type LnurlLinkResponse,
|
||||
type CreateWithdrawLinkParams,
|
||||
type CreateWithdrawLinkResponse,
|
||||
type GetWithdrawLinkResponse,
|
||||
// Exchange types
|
||||
type ExchangeRate,
|
||||
// Config
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// ============================================================================
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue