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 ExchangeRate,
|
||||||
type InvoiceCallback,
|
type InvoiceCallback,
|
||||||
type LnurlLinkResponse,
|
type LnurlLinkResponse,
|
||||||
|
type CreateWithdrawLinkParams,
|
||||||
|
type CreateWithdrawLinkResponse,
|
||||||
|
type GetWithdrawLinkResponse,
|
||||||
isRPCError,
|
isRPCError,
|
||||||
} from './types.js'
|
} from './types.js'
|
||||||
|
|
||||||
|
|
@ -82,13 +85,94 @@ export class LightningPubClient {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get current balance
|
* Get current balance via GetUserInfo RPC
|
||||||
*/
|
*/
|
||||||
async getBalance(): Promise<{ balanceSats: number }> {
|
async getBalance(): Promise<{ balanceSats: number }> {
|
||||||
const response = await this.sendRPC<BalanceResponse>('GetBalance', {})
|
const response = await this.sendRPC<BalanceResponse>('GetUserInfo', {})
|
||||||
return { balanceSats: response.balance ?? 0 }
|
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
|
* 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
|
* Get payment state for an invoice
|
||||||
*
|
*
|
||||||
|
|
@ -549,7 +669,9 @@ export class LightningPubClient {
|
||||||
|
|
||||||
const requestId = this.generateRequestId()
|
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 = {
|
const request: RPCRequest = {
|
||||||
rpcName,
|
rpcName,
|
||||||
params: {},
|
params: {},
|
||||||
|
|
@ -557,6 +679,7 @@ export class LightningPubClient {
|
||||||
body: body as Record<string, unknown>,
|
body: body as Record<string, unknown>,
|
||||||
authIdentifier: this.identity.publicKey,
|
authIdentifier: this.identity.publicKey,
|
||||||
requestId,
|
requestId,
|
||||||
|
appId: this.config.appId,
|
||||||
}
|
}
|
||||||
|
|
||||||
const content = encryptContent(this.identity, this.config.accountPubkey, request)
|
const content = encryptContent(this.identity, this.config.accountPubkey, request)
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,9 @@ export {
|
||||||
type PayInvoiceResponse,
|
type PayInvoiceResponse,
|
||||||
// LNURL types
|
// LNURL types
|
||||||
type LnurlLinkResponse,
|
type LnurlLinkResponse,
|
||||||
|
type CreateWithdrawLinkParams,
|
||||||
|
type CreateWithdrawLinkResponse,
|
||||||
|
type GetWithdrawLinkResponse,
|
||||||
// Exchange types
|
// Exchange types
|
||||||
type ExchangeRate,
|
type ExchangeRate,
|
||||||
// Config
|
// Config
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,8 @@ export interface RPCRequest {
|
||||||
authIdentifier: string
|
authIdentifier: string
|
||||||
/** Unique request identifier */
|
/** Unique request identifier */
|
||||||
requestId: string
|
requestId: string
|
||||||
|
/** Application ID - ensures Nostr user is created under the same app as HTTP API users */
|
||||||
|
appId?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/** RPC success response */
|
/** RPC success response */
|
||||||
|
|
@ -187,6 +189,64 @@ export interface LnurlLinkResponse {
|
||||||
lnurl: string
|
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
|
// Exchange Rates
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue