From 49e94a894c5b81729d5bedf49082cc561996b9b9 Mon Sep 17 00:00:00 2001 From: padreug Date: Wed, 17 Sep 2025 20:49:05 +0200 Subject: [PATCH] Add WebSocket support for wallet transactions and balance updates - Introduced WalletWebSocketService to manage WebSocket connections for real-time wallet updates. - Implemented methods to handle incoming messages, including wallet balance updates and transaction notifications. - Updated WalletService to add transactions based on WebSocket notifications, ensuring accurate wallet state. - Enhanced app configuration to support WebSocket settings, improving flexibility in connection management. These changes enhance the wallet module's responsiveness and user experience by providing real-time updates on wallet activities. Enhance WalletWebSocketService with improved logging and authentication event handling - Added detailed logging throughout the WalletWebSocketService to aid in debugging and monitoring. - Integrated eventBus to listen for authentication events, enabling automatic connection and disconnection based on user login status. - Adjusted WebSocket connection logic to handle wallet credentials more effectively and ensure accurate balance updates. - Improved error handling and connection management for a more robust WebSocket experience. These enhancements improve the reliability and transparency of the wallet's WebSocket interactions, contributing to a better user experience. Enhance wallet balance update logic and logging in WalletWebSocketService - Improved logging for wallet balance updates to provide clearer insights into balance changes and payment adjustments. - Refined balance adjustment logic to correctly handle outgoing and incoming payments, ensuring accurate wallet state. - Updated AuthService to log both the old and new wallet balance during updates, enhancing debugging capabilities. These changes improve the reliability and transparency of wallet balance management, contributing to a better user experience. Refactor wallet balance update logic in AuthService and WalletWebSocketService - Enhanced the updateWalletBalance method in AuthService to accept an optional walletId, allowing for more flexible wallet balance updates. - Improved logging to indicate which wallet's balance is being updated, aiding in debugging. - Updated WalletWebSocketService to retrieve the wallet ID from PaymentService before updating the balance, ensuring accurate wallet state management. These changes improve the robustness and clarity of wallet balance handling across the application. --- src/app.config.ts | 5 + src/core/di-container.ts | 1 + src/modules/base/auth/auth-service.ts | 30 ++ src/modules/wallet/index.ts | 25 +- src/modules/wallet/services/WalletService.ts | 40 ++ .../wallet/services/WalletWebSocketService.ts | 391 ++++++++++++++++++ 6 files changed, 487 insertions(+), 5 deletions(-) create mode 100644 src/modules/wallet/services/WalletWebSocketService.ts 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