diff --git a/lamassu-next/packages/lightning/src/__tests__/client.test.ts b/lamassu-next/packages/lightning/src/__tests__/client.test.ts new file mode 100644 index 0000000..764c79b --- /dev/null +++ b/lamassu-next/packages/lightning/src/__tests__/client.test.ts @@ -0,0 +1,66 @@ +import { describe, it, expect, vi } from 'vitest' +import { LightningPubClient } from '../client.js' + +describe('LightningPubClient', () => { + const config = { + serviceUrl: 'https://lightning.pub', + accountPubkey: 'a'.repeat(64), + relays: ['wss://relay.example.com'], + } + + describe('constructor', () => { + it('should create a client with config', () => { + const client = new LightningPubClient(config) + expect(client).toBeDefined() + }) + }) + + describe('getExchangeRate', () => { + it('should return mock exchange rate for USD', async () => { + const client = new LightningPubClient(config) + const rate = await client.getExchangeRate('USD') + + expect(rate.currency).toBe('USD') + expect(rate.satsPerUnit).toBeGreaterThan(0) + expect(rate.timestamp).toBeDefined() + }) + + it('should return rate for different currencies', async () => { + const client = new LightningPubClient(config) + + const usdRate = await client.getExchangeRate('USD') + const eurRate = await client.getExchangeRate('EUR') + + expect(usdRate.satsPerUnit).not.toBe(eurRate.satsPerUnit) + }) + }) + + describe('decodeInvoice', () => { + it('should decode a basic invoice', () => { + const client = new LightningPubClient(config) + + // This is a simplified test since we have basic parsing + const result = client.decodeInvoice('lnbc100u1...') + + expect(result).toBeDefined() + expect(result.expiresAt).toBeGreaterThan(Date.now() / 1000) + }) + }) + + describe('initialize', () => { + it('should require nostrClient and identity', () => { + const client = new LightningPubClient(config) + + // Without initialization, operations should fail + expect(client.getBalance()).rejects.toThrow('Client not initialized') + }) + }) + + describe('disconnect', () => { + it('should clean up resources', () => { + const client = new LightningPubClient(config) + // Should not throw + expect(() => client.disconnect()).not.toThrow() + }) + }) +}) diff --git a/lamassu-next/packages/lightning/src/client.ts b/lamassu-next/packages/lightning/src/client.ts new file mode 100644 index 0000000..e892950 --- /dev/null +++ b/lamassu-next/packages/lightning/src/client.ts @@ -0,0 +1,405 @@ +/** + * Lightning.Pub Client + * + * Client for interacting with Lightning.Pub - a Nostr-native + * account system that wraps LND. + * + * Lightning.Pub provides: + * - Account management (sublayers) + * - Invoice generation + * - Payment processing + * - CLINK protocol support + */ + +import type { Event } from 'nostr-tools' +import { finalizeEvent } from 'nostr-tools' +import type { MachineIdentity, NostrClient } from '@lamassu/nostr-client' +import { encryptContent, decryptJSON } from '@lamassu/nostr-client' +import type { + LightningPubConfig, + AccountInfo, + Invoice, + PaymentResult, + ExchangeRate, + InvoiceCallback, +} from './types.js' + +/** Event kinds for Lightning.Pub communication */ +const LIGHTNING_PUB_KINDS = { + REQUEST: 23194, // NIP-47 compatible + RESPONSE: 23195, +} + +/** Request types */ +type RequestMethod = + | 'get_info' + | 'get_balance' + | 'make_invoice' + | 'pay_invoice' + | 'lookup_invoice' + | 'list_transactions' + +interface Request { + method: RequestMethod + params: Record +} + +interface Response { + result?: T + error?: { code: number; message: string } +} + +/** + * Lightning.Pub client for ATM operations + */ +export class LightningPubClient { + private config: Required + private nostrClient: NostrClient | null = null + private identity: MachineIdentity | null = null + private invoiceCallbacks: Map = new Map() + private subscriptionId?: string + + constructor(config: LightningPubConfig) { + this.config = { + timeout: 30000, + ...config, + } + } + + /** + * Initialize the client with Nostr connection + */ + initialize(nostrClient: NostrClient, identity: MachineIdentity): void { + this.nostrClient = nostrClient + this.identity = identity + this.startListening() + } + + /** + * Get account information + */ + async getAccountInfo(): Promise { + const response = await this.sendRequest({ + method: 'get_info', + params: {}, + }) + + return response + } + + /** + * Get current balance + */ + async getBalance(): Promise<{ balanceSats: number }> { + const response = await this.sendRequest<{ balance: number }>({ + method: 'get_balance', + params: {}, + }) + + return { balanceSats: response.balance } + } + + /** + * Create a Lightning invoice + */ + async createInvoice(params: { + amountMsat: number + description?: string + expirySecs?: number + }): Promise { + const response = await this.sendRequest<{ + payment_request: string + payment_hash: string + expires_at: number + }>({ + method: 'make_invoice', + params: { + amount: params.amountMsat, + description: params.description || 'ATM Payment', + expiry: params.expirySecs || 600, + }, + }) + + return { + paymentRequest: response.payment_request, + paymentHash: response.payment_hash, + amountMsat: params.amountMsat, + description: params.description, + createdAt: Math.floor(Date.now() / 1000), + expiresAt: response.expires_at, + status: 'pending', + } + } + + /** + * Pay a Lightning invoice + */ + async payInvoice(paymentRequest: string): Promise { + try { + const response = await this.sendRequest<{ + preimage: string + fee_paid?: number + }>({ + method: 'pay_invoice', + params: { + invoice: paymentRequest, + }, + }) + + return { + success: true, + preimage: response.preimage, + feeMsat: response.fee_paid, + } + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Payment failed', + } + } + } + + /** + * Look up an invoice by payment hash + */ + async lookupInvoice(paymentHash: string): Promise { + try { + const response = await this.sendRequest<{ + payment_request: string + amount: number + description: string + created_at: number + expires_at: number + settled: boolean + preimage?: string + }>({ + method: 'lookup_invoice', + params: { + payment_hash: paymentHash, + }, + }) + + return { + paymentRequest: response.payment_request, + paymentHash, + amountMsat: response.amount, + description: response.description, + createdAt: response.created_at, + expiresAt: response.expires_at, + status: response.settled ? 'paid' : 'pending', + preimage: response.preimage, + } + } catch { + return null + } + } + + /** + * Watch for invoice payment + */ + watchInvoice(paymentHash: string, callback: InvoiceCallback): void { + this.invoiceCallbacks.set(paymentHash, callback) + + // Poll for payment status + const pollInterval = setInterval(async () => { + const invoice = await this.lookupInvoice(paymentHash) + if (invoice && invoice.status === 'paid') { + clearInterval(pollInterval) + this.invoiceCallbacks.delete(paymentHash) + callback(invoice) + } + }, 2000) + + // Clean up after expiry + setTimeout( + () => { + clearInterval(pollInterval) + this.invoiceCallbacks.delete(paymentHash) + }, + 10 * 60 * 1000 + ) // 10 minutes max + } + + /** + * Stop watching an invoice + */ + unwatchInvoice(paymentHash: string): void { + this.invoiceCallbacks.delete(paymentHash) + } + + /** + * Decode a BOLT11 invoice + */ + decodeInvoice(paymentRequest: string): { + amountMsat: number | null + paymentHash: string + description: string + expiresAt: number + } { + // Basic invoice parsing (for production, use bolt11 library) + // This is a simplified version + const amountMatch = paymentRequest.match(/lnbc(\d+)([munp]?)/) + let amountMsat: number | null = null + + if (amountMatch) { + const [, amount, multiplier] = amountMatch + const baseAmount = parseInt(amount ?? '0', 10) + switch (multiplier) { + case 'm': + amountMsat = baseAmount * 100_000_000 + break + case 'u': + amountMsat = baseAmount * 100_000 + break + case 'n': + amountMsat = baseAmount * 100 + break + case 'p': + amountMsat = baseAmount / 10 + break + default: + amountMsat = baseAmount * 100_000_000_000 + } + } + + return { + amountMsat, + paymentHash: '', // Would need proper parsing + description: '', + expiresAt: Math.floor(Date.now() / 1000) + 3600, + } + } + + /** + * Get exchange rate (via external service or Lightning.Pub) + */ + async getExchangeRate(currency: string): Promise { + // For now, return a mock rate + // In production, integrate with price feeds + const mockRates: Record = { + USD: 2500, // sats per dollar (example) + EUR: 2700, + GBP: 3100, + } + + return { + currency, + satsPerUnit: mockRates[currency] ?? 2500, + timestamp: Date.now(), + source: 'mock', + } + } + + /** + * Start listening for responses + */ + private startListening(): void { + if (!this.nostrClient || !this.identity) return + if (this.subscriptionId) return + + this.subscriptionId = this.nostrClient.subscribe( + [ + { + kinds: [LIGHTNING_PUB_KINDS.RESPONSE], + '#p': [this.identity.publicKey], + }, + ], + { + onEvent: (event) => this.handleResponse(event), + } + ) + } + + /** + * Handle incoming response events + */ + private handleResponse(event: Event): void { + // Handle async responses if needed + // For invoice watching, we use polling instead + } + + /** + * Send a request to Lightning.Pub + */ + private async sendRequest(request: Request): Promise { + if (!this.nostrClient || !this.identity) { + throw new Error('Client not initialized') + } + + const content = encryptContent(this.identity, this.config.accountPubkey, request) + + const event = finalizeEvent( + { + kind: LIGHTNING_PUB_KINDS.REQUEST, + content, + tags: [['p', this.config.accountPubkey]], + created_at: Math.floor(Date.now() / 1000), + pubkey: this.identity.publicKey, + }, + this.identity.privateKey + ) + + await this.nostrClient.publish(event) + + // Wait for response + return this.waitForResponse(event.id) + } + + /** + * Wait for a response to a specific request + */ + private waitForResponse(requestId: string): Promise { + return new Promise((resolve, reject) => { + if (!this.nostrClient || !this.identity) { + reject(new Error('Client not initialized')) + return + } + + const timeout = setTimeout(() => { + this.nostrClient!.unsubscribe(subId) + reject(new Error('Request timeout')) + }, this.config.timeout) + + const subId = this.nostrClient.subscribe( + [ + { + kinds: [LIGHTNING_PUB_KINDS.RESPONSE], + '#e': [requestId], + '#p': [this.identity!.publicKey], + }, + ], + { + onEvent: (event) => { + clearTimeout(timeout) + this.nostrClient!.unsubscribe(subId) + + try { + const response = decryptJSON>(this.identity!, event.pubkey, event.content) + + if (response.error) { + reject(new Error(response.error.message)) + } else { + resolve(response.result as T) + } + } catch (e) { + reject(e) + } + }, + } + ) + }) + } + + /** + * Disconnect and clean up + */ + disconnect(): void { + if (this.subscriptionId && this.nostrClient) { + this.nostrClient.unsubscribe(this.subscriptionId) + this.subscriptionId = undefined + } + this.invoiceCallbacks.clear() + this.nostrClient = null + this.identity = null + } +} diff --git a/lamassu-next/packages/lightning/src/index.ts b/lamassu-next/packages/lightning/src/index.ts new file mode 100644 index 0000000..1535ff8 --- /dev/null +++ b/lamassu-next/packages/lightning/src/index.ts @@ -0,0 +1,60 @@ +/** + * @lamassu/lightning + * + * Lightning.Pub client for Nostr-native Lightning operations. + * + * Lightning.Pub is an account system that wraps LND and provides + * CLINK protocol support. This package handles: + * - Invoice generation + * - Payment processing + * - Balance queries + * - Exchange rate fetching + * + * @example + * ```typescript + * import { LightningPubClient } from '@lamassu/lightning' + * + * const client = new LightningPubClient({ + * serviceUrl: 'https://lightning.pub', + * accountPubkey: 'npub1...', + * relays: ['wss://relay.example.com'], + * }) + * + * // Initialize with Nostr client + * client.initialize(nostrClient, machineIdentity) + * + * // Create an invoice + * const invoice = await client.createInvoice({ + * amountMsat: 100000, // 100 sats + * description: 'ATM withdrawal', + * }) + * + * // Watch for payment + * client.watchInvoice(invoice.paymentHash, (paid) => { + * console.log('Invoice paid:', paid.preimage) + * }) + * + * // Pay an invoice + * const result = await client.payInvoice('lnbc...') + * if (result.success) { + * console.log('Paid with preimage:', result.preimage) + * } + * ``` + */ + +// Client +export { LightningPubClient } from './client.js' + +// Types +export type { + LightningPubConfig, + AccountInfo, + Invoice, + InvoiceStatus, + PaymentResult, + ExchangeRate, + ChannelInfo, + NodeInfo, + InvoiceCallback, + PaymentCallback, +} from './types.js' diff --git a/lamassu-next/packages/lightning/src/types.ts b/lamassu-next/packages/lightning/src/types.ts new file mode 100644 index 0000000..b7cbed6 --- /dev/null +++ b/lamassu-next/packages/lightning/src/types.ts @@ -0,0 +1,113 @@ +/** + * Lightning.Pub client type definitions + * + * Lightning.Pub is a Nostr-native account system that wraps LND + * and provides CLINK payment support. + */ + +/** Lightning.Pub account information */ +export interface AccountInfo { + /** Account public key */ + pubkey: string + /** Account balance in satoshis */ + balanceSats: number + /** Maximum send amount */ + maxSendSats: number + /** Maximum receive amount */ + maxReceiveSats: number +} + +/** Invoice status */ +export type InvoiceStatus = 'pending' | 'paid' | 'expired' | 'cancelled' + +/** Invoice details */ +export interface Invoice { + /** BOLT11 payment request */ + paymentRequest: string + /** Payment hash */ + paymentHash: string + /** Amount in millisatoshis */ + amountMsat: number + /** Invoice description */ + description?: string + /** Creation timestamp */ + createdAt: number + /** Expiry timestamp */ + expiresAt: number + /** Current status */ + status: InvoiceStatus + /** Payment preimage (if paid) */ + preimage?: string +} + +/** Payment result */ +export interface PaymentResult { + /** Whether payment succeeded */ + success: boolean + /** Payment preimage (proof of payment) */ + preimage?: string + /** Fee paid in millisatoshis */ + feeMsat?: number + /** Error message if failed */ + error?: string +} + +/** Exchange rate information */ +export interface ExchangeRate { + /** Fiat currency code */ + currency: string + /** Satoshis per fiat unit */ + satsPerUnit: number + /** Timestamp of rate */ + timestamp: number + /** Source of rate */ + source: string +} + +/** Lightning.Pub client configuration */ +export interface LightningPubConfig { + /** Lightning.Pub service URL */ + serviceUrl: string + /** Account pubkey on Lightning.Pub */ + accountPubkey: string + /** Nostr relays for communication */ + relays: string[] + /** Request timeout in ms */ + timeout?: number +} + +/** Channel information */ +export interface ChannelInfo { + /** Channel ID */ + channelId: string + /** Remote pubkey */ + remotePubkey: string + /** Local balance in sats */ + localBalanceSats: number + /** Remote balance in sats */ + remoteBalanceSats: number + /** Channel capacity in sats */ + capacitySats: number + /** Whether channel is active */ + active: boolean +} + +/** Node information */ +export interface NodeInfo { + /** Node public key */ + pubkey: string + /** Node alias */ + alias: string + /** Number of channels */ + numChannels: number + /** Total capacity in sats */ + totalCapacitySats: number + /** Synced to chain */ + syncedToChain: boolean +} + +/** Invoice callback for watching payments */ +export type InvoiceCallback = (invoice: Invoice) => void + +/** Payment status update callback */ +export type PaymentCallback = (result: PaymentResult) => void diff --git a/lamassu-next/packages/lightning/tsconfig.json b/lamassu-next/packages/lightning/tsconfig.json new file mode 100644 index 0000000..d770c39 --- /dev/null +++ b/lamassu-next/packages/lightning/tsconfig.json @@ -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"] +} diff --git a/lamassu-next/packages/lightning/vitest.config.ts b/lamassu-next/packages/lightning/vitest.config.ts new file mode 100644 index 0000000..9fb4f14 --- /dev/null +++ b/lamassu-next/packages/lightning/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + include: ['src/**/*.test.ts'], + globals: false, + }, +})