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.
This commit is contained in:
padreug 2025-09-17 20:49:05 +02:00
parent e5db949aae
commit 49e94a894c
6 changed files with 487 additions and 5 deletions

View file

@ -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
}
}
}

View file

@ -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'),

View file

@ -133,6 +133,36 @@ export class AuthService extends BaseService {
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<void> {
// Call BaseService initialize first to inject dependencies
await super.initialize()

View file

@ -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()
container.provide(SERVICE_TOKENS.WALLET_SERVICE, walletService)
const walletWebSocketService = new WalletWebSocketService()
// Register components globally
// Register services in DI container BEFORE initialization
container.provide(SERVICE_TOKENS.WALLET_SERVICE, walletService)
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: [

View file

@ -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
}
}
}

View file

@ -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<boolean> = ref(false)
public readonly connectionStatus: Ref<string> = ref('disconnected')
private toast = useToast()
protected async onInitialize(): Promise<void> {
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<void> {
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<void> {
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<void> {
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<void> {
console.log('WalletWebSocketService: Pausing connection')
// Disconnect to save battery
this.disconnect()
}
/**
* Manual reconnection method
*/
public async reconnect(): Promise<void> {
this.reconnectAttempts = 0
await this.connectIfNeeded()
}
/**
* Clean up on service destruction
*/
public async cleanup(): Promise<void> {
this.disconnect()
}
}
export default WalletWebSocketService