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 successfully') 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 } // Send a ping to test connection stability this.sendPing() } /** * 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 via PaymentService */ 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 via PaymentService (which manages wallet state) if (this.paymentService?.updateWalletBalance) { this.paymentService.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 // Handle specific close codes if (event.code === 1006) { console.warn('WalletWebSocketService: Abnormal closure detected - possible server issue') // For code 1006, increase delay to avoid overwhelming server this.scheduleReconnect(true) } else if (event.code !== 1000) { // Normal reconnection for other non-normal closures this.scheduleReconnect(false) } // Code 1000 = normal closure, don't reconnect } /** * 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) } // Check if this is a network connectivity issue if (!navigator.onLine) { console.log('WalletWebSocketService: Network appears to be offline') this.connectionStatus.value = 'offline' } } /** * Schedule a reconnection attempt */ private scheduleReconnect(isAbnormalClosure = false): void { // Don't reconnect if we've exceeded max attempts if (this.reconnectAttempts >= this.config.maxReconnectAttempts) { console.log('WalletWebSocketService: Max reconnection attempts reached - disabling WebSocket') this.connectionStatus.value = 'failed' // Show user notification about WebSocket issues if (this.toast) { this.toast.info('Real-time balance updates temporarily unavailable', { description: 'WebSocket connection failed. Balance will update on page refresh.' }) } return } // Clear any existing reconnect timer if (this.reconnectTimer) { clearTimeout(this.reconnectTimer) } // Calculate delay with exponential backoff let delay = this.config.reconnectDelay * Math.pow(2, this.reconnectAttempts) // For abnormal closures (1006), use longer delays to avoid overwhelming server if (isAbnormalClosure) { delay = Math.max(delay, 5000) // Minimum 5 second delay for 1006 errors } this.reconnectAttempts++ console.log(`WalletWebSocketService: Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts}/${this.config.maxReconnectAttempts})${isAbnormalClosure ? ' [abnormal closure]' : ''}`) 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() } /** * Send ping to test connection */ private sendPing(): void { if (this.ws && this.ws.readyState === WebSocket.OPEN) { try { // Send a ping frame (most WebSocket implementations support this) this.ws.ping?.() } catch (error) { console.log('WalletWebSocketService: Ping not supported, connection seems stable') } } } /** * Manual reconnection method */ public async reconnect(): Promise { console.log('WalletWebSocketService: Manual reconnection triggered') this.reconnectAttempts = 0 // Disconnect current connection if any this.disconnect() // Wait a moment before reconnecting setTimeout(() => { this.connectIfNeeded() }, 1000) } /** * Clean up on service destruction */ public async cleanup(): Promise { this.disconnect() } } export default WalletWebSocketService