diff --git a/src/app.config.ts b/src/app.config.ts index 0384841..fdc0e52 100644 --- a/src/app.config.ts +++ b/src/app.config.ts @@ -74,6 +74,11 @@ export const appConfig: AppConfig = { maxReceiveAmount: 1000000, // 1M sats apiConfig: { baseUrl: import.meta.env.VITE_LNBITS_BASE_URL || 'http://localhost:5000' + }, + websocket: { + enabled: true, + reconnectDelay: 1000, // 1 second + maxReconnectAttempts: 5 } } } diff --git a/src/core/di-container.ts b/src/core/di-container.ts index 907b8e2..7a0a137 100644 --- a/src/core/di-container.ts +++ b/src/core/di-container.ts @@ -144,6 +144,7 @@ export const SERVICE_TOKENS = { // Wallet services WALLET_SERVICE: Symbol('walletService'), + WALLET_WEBSOCKET_SERVICE: Symbol('walletWebSocketService'), // API services LNBITS_API: Symbol('lnbitsAPI'), diff --git a/src/modules/base/auth/auth-service.ts b/src/modules/base/auth/auth-service.ts index decf10c..c8e6eb4 100644 --- a/src/modules/base/auth/auth-service.ts +++ b/src/modules/base/auth/auth-service.ts @@ -132,6 +132,36 @@ export class AuthService extends BaseService { // Re-fetch user data from API await this.checkAuth() } + + /** + * Update wallet balance from WebSocket notifications + * @param walletId - The wallet ID to update (if not provided, updates first wallet for backwards compatibility) + * @param balanceMsat - The new balance in millisatoshis + */ + updateWalletBalance(balanceMsat: number, walletId?: string): void { + if (!this.user.value?.wallets?.length) return + + // Find the wallet to update + let walletToUpdate + if (walletId) { + walletToUpdate = this.user.value.wallets.find(w => w.id === walletId) + } else { + // Fallback to first wallet for backwards compatibility + // TODO: This should eventually be removed once all callers provide walletId + walletToUpdate = this.user.value.wallets[0] + console.warn('updateWalletBalance called without walletId, using wallets[0] as fallback') + } + + if (walletToUpdate) { + const oldBalance = walletToUpdate.balance_msat + walletToUpdate.balance_msat = balanceMsat + + // Trigger reactivity + this.user.value = { ...this.user.value } + + this.debug(`Wallet ${walletToUpdate.id} balance updated: ${oldBalance} -> ${balanceMsat} msat (${Math.round(balanceMsat/1000)} sats) via WebSocket`) + } + } async initialize(): Promise { // Call BaseService initialize first to inject dependencies diff --git a/src/modules/wallet/index.ts b/src/modules/wallet/index.ts index 1cf2bbc..f2be67a 100644 --- a/src/modules/wallet/index.ts +++ b/src/modules/wallet/index.ts @@ -2,6 +2,7 @@ import type { App } from 'vue' import type { ModulePlugin } from '@/core/types' import { container, SERVICE_TOKENS } from '@/core/di-container' import WalletService from './services/WalletService' +import { WalletWebSocketService } from './services/WalletWebSocketService' import { WalletPage, SendDialog, ReceiveDialog, WalletTransactions } from './components' export const walletModule: ModulePlugin = { @@ -10,17 +11,31 @@ export const walletModule: ModulePlugin = { dependencies: ['base'], async install(app: App) { - // Register wallet service in DI container + // Create service instances const walletService = new WalletService() + const walletWebSocketService = new WalletWebSocketService() + + // Register services in DI container BEFORE initialization container.provide(SERVICE_TOKENS.WALLET_SERVICE, walletService) - - // Register components globally + container.provide(SERVICE_TOKENS.WALLET_WEBSOCKET_SERVICE, walletWebSocketService) + + // Initialize services with proper dependency waiting + await walletService.initialize({ + waitForDependencies: true, + maxRetries: 3 + }) + + // Initialize WebSocket service after wallet service + await walletWebSocketService.initialize({ + waitForDependencies: true, + maxRetries: 3 + }) + + // Register components globally AFTER services are initialized app.component('WalletPage', WalletPage) app.component('SendDialog', SendDialog) app.component('ReceiveDialog', ReceiveDialog) app.component('WalletTransactions', WalletTransactions) - - await walletService.initialize() }, routes: [ diff --git a/src/modules/wallet/services/WalletService.ts b/src/modules/wallet/services/WalletService.ts index 598c355..ab1966f 100644 --- a/src/modules/wallet/services/WalletService.ts +++ b/src/modules/wallet/services/WalletService.ts @@ -350,4 +350,44 @@ export default class WalletService extends BaseService { this.loadTransactions() ]) } + + /** + * Add a new transaction from WebSocket notification + */ + addTransaction(payment: any): void { + try { + const transaction = this.mapPaymentToTransaction(payment) + + // Check if transaction already exists (avoid duplicates) + const existingIndex = this._transactions.value.findIndex(t => t.id === transaction.id) + + if (existingIndex >= 0) { + // Update existing transaction + this._transactions.value[existingIndex] = transaction + } else { + // Add new transaction at the beginning (most recent first) + this._transactions.value = [transaction, ...this._transactions.value] + } + + console.log('WalletService: Added/updated transaction', transaction) + } catch (error) { + console.error('WalletService: Failed to add transaction', error) + } + } + + /** + * Map LNbits payment object to our transaction format + */ + private mapPaymentToTransaction(payment: any): PaymentTransaction { + return { + id: payment.payment_hash || payment.checking_id || payment.id, + amount: payment.amount || 0, + description: payment.description || payment.memo || 'Payment', + timestamp: payment.time ? new Date(payment.time * 1000) : new Date(), + type: payment.amount > 0 ? 'received' : 'sent', + status: payment.pending ? 'pending' : payment.status === 'settled' ? 'confirmed' : 'failed', + fee: payment.fee_msat ? payment.fee_msat / 1000 : undefined, + tag: payment.tag || null + } + } } \ No newline at end of file diff --git a/src/modules/wallet/services/WalletWebSocketService.ts b/src/modules/wallet/services/WalletWebSocketService.ts new file mode 100644 index 0000000..911606e --- /dev/null +++ b/src/modules/wallet/services/WalletWebSocketService.ts @@ -0,0 +1,391 @@ +import { BaseService } from '@/core/base/BaseService' +import { injectService, SERVICE_TOKENS } from '@/core/di-container' +import { useToast } from '@/core/composables/useToast' +import { eventBus } from '@/core/event-bus' +import type { Ref } from 'vue' +import { ref } from 'vue' + +interface WebSocketConfig { + enabled: boolean + reconnectDelay: number + maxReconnectAttempts: number +} + +export class WalletWebSocketService extends BaseService { + protected readonly metadata = { + name: 'WalletWebSocketService', + version: '1.0.0', + dependencies: ['PaymentService', 'AuthService', 'WalletService'] + } + + private ws: WebSocket | null = null + private reconnectTimer: NodeJS.Timeout | null = null + private reconnectAttempts = 0 + private config: WebSocketConfig = { + enabled: true, + reconnectDelay: 1000, + maxReconnectAttempts: 5 + } + + // Service dependencies (auto-injected by BaseService) + protected paymentService: any + protected walletService: any + + // WebSocket state + public readonly isConnected: Ref = ref(false) + public readonly connectionStatus: Ref = ref('disconnected') + + private toast = useToast() + + protected async onInitialize(): Promise { + console.log('WalletWebSocketService: Starting initialization...') + + // Get services (already injected by BaseService) + this.paymentService = injectService(SERVICE_TOKENS.PAYMENT_SERVICE) as any + this.walletService = injectService(SERVICE_TOKENS.WALLET_SERVICE) as any + + console.log('WalletWebSocketService: Services injected', { + paymentService: !!this.paymentService, + walletService: !!this.walletService + }) + + // Load config from app config if available + const appConfig = (window as any).appConfig + if (appConfig?.modules?.wallet?.config?.websocket) { + this.config = { ...this.config, ...appConfig.modules.wallet.config.websocket } + } + + console.log('WalletWebSocketService: Config loaded', this.config) + + // Only proceed if WebSocket is enabled + if (!this.config.enabled) { + console.log('WalletWebSocketService: WebSocket disabled in config') + return + } + + // Register with VisibilityService for connection management + if (this.visibilityService) { + this.visibilityService.registerService( + this.metadata.name, + this.onResume.bind(this), + this.onPause.bind(this) + ) + console.log('WalletWebSocketService: Registered with VisibilityService') + } else { + console.warn('WalletWebSocketService: VisibilityService not available') + } + + // Listen for authentication events to connect when user logs in + eventBus.on('auth:login', () => { + console.log('WalletWebSocketService: Auth login detected, attempting connection...') + setTimeout(() => { + // Small delay to ensure PaymentService has processed the auth event + this.connectIfNeeded() + }, 500) + }) + + eventBus.on('auth:logout', () => { + console.log('WalletWebSocketService: Auth logout detected, disconnecting...') + this.disconnect() + }) + + console.log('WalletWebSocketService: Registered auth event listeners') + + // Initial connection attempt (will fail if not authenticated, but that's OK) + console.log('WalletWebSocketService: Attempting initial connection...') + await this.connectIfNeeded() + } + + /** + * Connect to LNbits WebSocket if we have wallet credentials + */ + private async connectIfNeeded(): Promise { + console.log('WalletWebSocketService: Checking for wallet credentials...') + + const wallet = this.paymentService?.getPreferredWallet?.() + console.log('WalletWebSocketService: Wallet check result', { + hasPaymentService: !!this.paymentService, + hasGetPreferredWallet: !!this.paymentService?.getPreferredWallet, + wallet: wallet ? { id: wallet.id, hasInkey: !!wallet.inkey } : null + }) + + if (wallet?.inkey) { + console.log('WalletWebSocketService: Connecting with wallet inkey...') + await this.connect(wallet.inkey) + } else { + console.log('WalletWebSocketService: No wallet available for WebSocket connection') + } + } + + /** + * Establish WebSocket connection to LNbits + */ + private async connect(walletInkey: string): Promise { + try { + console.log('WalletWebSocketService: Starting connection process...') + + // Close existing connection if any + this.disconnect() + + // Build WebSocket URL + const baseUrl = import.meta.env.VITE_LNBITS_BASE_URL || import.meta.env.VITE_API_BASE_URL || 'http://localhost:5000' + const wsProtocol = baseUrl.startsWith('https') ? 'wss:' : 'ws:' + const host = baseUrl.replace(/^https?:\/\//, '').replace(/\/$/, '') + const wsUrl = `${wsProtocol}//${host}/api/v1/ws/${walletInkey}` + + console.log('WalletWebSocketService: Connection details', { + baseUrl, + wsProtocol, + host, + wsUrl, + walletInkey: walletInkey.substring(0, 8) + '...' // Only show first 8 chars for security + }) + + this.connectionStatus.value = 'connecting' + + // Create WebSocket connection + this.ws = new WebSocket(wsUrl) + + // Set up event handlers + this.ws.onopen = this.handleOpen.bind(this) + this.ws.onmessage = this.handleMessage.bind(this) + this.ws.onclose = this.handleClose.bind(this) + this.ws.onerror = this.handleWebSocketError.bind(this) + + console.log('WalletWebSocketService: WebSocket created, waiting for connection...') + + } catch (error) { + console.error('WalletWebSocketService: Failed to create WebSocket', error) + this.connectionStatus.value = 'error' + this.scheduleReconnect() + } + } + + /** + * Handle WebSocket connection opened + */ + private handleOpen(_event: Event): void { + console.log('WalletWebSocketService: Connected') + this.isConnected.value = true + this.connectionStatus.value = 'connected' + this.reconnectAttempts = 0 + + // Clear any pending reconnect timer + if (this.reconnectTimer) { + clearTimeout(this.reconnectTimer) + this.reconnectTimer = null + } + } + + /** + * Handle incoming WebSocket messages + */ + private handleMessage(event: MessageEvent): void { + try { + const data = JSON.parse(event.data) + console.log('WalletWebSocketService: Received message', data) + + // Handle payment notification first (this updates transactions) + if (data.payment) { + this.handlePaymentNotification(data.payment) + } + + // Handle wallet balance update + if (data.wallet_balance !== undefined) { + console.log('WalletWebSocketService: Processing balance update', { + newBalance: data.wallet_balance, + hasPayment: !!data.payment, + paymentAmount: data.payment?.amount + }) + + let finalBalance = data.wallet_balance + + // For outgoing payments, LNbits sends pre-payment balance, so we need to adjust + // For incoming payments, LNbits sends post-payment balance, so use as-is + if (data.payment && data.payment.amount < 0) { + // Outgoing payment - subtract the payment amount from the balance + const paymentSats = Math.abs(data.payment.amount) / 1000 + finalBalance = data.wallet_balance - paymentSats + console.log('WalletWebSocketService: Adjusting balance for outgoing payment', { + originalBalance: data.wallet_balance, + paymentSats: paymentSats, + finalBalance: finalBalance + }) + } else if (data.payment && data.payment.amount > 0) { + // Incoming payment - use balance as-is (already post-payment) + console.log('WalletWebSocketService: Using balance as-is for incoming payment', { + balance: data.wallet_balance + }) + } else { + // No payment in message - use balance as-is + console.log('WalletWebSocketService: Using balance as-is (no payment)', { + balance: data.wallet_balance + }) + } + + console.log('WalletWebSocketService: Updating balance to', finalBalance, 'sats') + this.updateWalletBalance(finalBalance) + } + + } catch (error) { + console.error('WalletWebSocketService: Failed to parse message', error) + } + } + + /** + * Update wallet balance in auth service + */ + private updateWalletBalance(balanceSats: number): void { + console.log('WalletWebSocketService: Updating balance to', balanceSats, 'sats') + + // Convert sats to millisats for internal storage (LNbits WebSocket sends balance in sats) + const balanceMsat = balanceSats * 1000 + + // Get the wallet ID we're connected to + const wallet = this.paymentService?.getPreferredWallet?.() + const walletId = wallet?.id + + // Update balance in auth service (source of truth) + if (this.authService?.updateWalletBalance) { + this.authService.updateWalletBalance(balanceMsat, walletId) + } + } + + /** + * Handle incoming payment notification + */ + private handlePaymentNotification(payment: any): void { + console.log('WalletWebSocketService: Payment notification', payment) + + // Add transaction to wallet service + if (this.walletService?.addTransaction) { + this.walletService.addTransaction(payment) + } + + // Show toast notification for incoming payments + if (payment.amount > 0 && !payment.pending) { + const amountSats = Math.abs(payment.amount / 1000) + if (payment.amount > 0) { + this.toast.success(`Received ${amountSats} sats!`) + } else { + this.toast.info(`Sent ${amountSats} sats`) + } + } + } + + /** + * Handle WebSocket connection closed + */ + private handleClose(event: CloseEvent): void { + console.log('WalletWebSocketService: Connection closed', event.code, event.reason) + this.isConnected.value = false + this.connectionStatus.value = 'disconnected' + this.ws = null + + // Schedule reconnection if not a normal closure + if (event.code !== 1000) { + this.scheduleReconnect() + } + } + + /** + * Handle WebSocket error + */ + private handleWebSocketError(event: Event): void { + console.error('WalletWebSocketService: WebSocket error', event) + this.connectionStatus.value = 'error' + + // Additional error details if available + if (this.ws) { + console.error('WalletWebSocketService: WebSocket state:', this.ws.readyState) + console.error('WalletWebSocketService: WebSocket URL:', this.ws.url) + } + } + + /** + * Schedule a reconnection attempt + */ + private scheduleReconnect(): void { + // Don't reconnect if we've exceeded max attempts + if (this.reconnectAttempts >= this.config.maxReconnectAttempts) { + console.log('WalletWebSocketService: Max reconnection attempts reached') + this.connectionStatus.value = 'failed' + return + } + + // Clear any existing reconnect timer + if (this.reconnectTimer) { + clearTimeout(this.reconnectTimer) + } + + // Calculate delay with exponential backoff + const delay = this.config.reconnectDelay * Math.pow(2, this.reconnectAttempts) + this.reconnectAttempts++ + + console.log(`WalletWebSocketService: Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts})`) + this.connectionStatus.value = 'reconnecting' + + this.reconnectTimer = setTimeout(() => { + this.connectIfNeeded() + }, delay) + } + + /** + * Disconnect WebSocket connection + */ + public disconnect(): void { + if (this.reconnectTimer) { + clearTimeout(this.reconnectTimer) + this.reconnectTimer = null + } + + if (this.ws) { + // Close with normal closure code + this.ws.close(1000, 'Client disconnect') + this.ws = null + } + + this.isConnected.value = false + this.connectionStatus.value = 'disconnected' + } + + /** + * Resume connection when app becomes visible + */ + private async onResume(): Promise { + console.log('WalletWebSocketService: Resuming connection') + + // Reconnect if not connected + if (!this.isConnected.value) { + this.reconnectAttempts = 0 + await this.connectIfNeeded() + } + } + + /** + * Pause connection when app loses visibility + */ + private async onPause(): Promise { + console.log('WalletWebSocketService: Pausing connection') + + // Disconnect to save battery + this.disconnect() + } + + /** + * Manual reconnection method + */ + public async reconnect(): Promise { + this.reconnectAttempts = 0 + await this.connectIfNeeded() + } + + /** + * Clean up on service destruction + */ + public async cleanup(): Promise { + this.disconnect() + } +} + +export default WalletWebSocketService \ No newline at end of file