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:
parent
e5db949aae
commit
49e94a894c
6 changed files with 487 additions and 5 deletions
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
||||
// Call BaseService initialize first to inject dependencies
|
||||
|
|
|
|||
|
|
@ -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: [
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
391
src/modules/wallet/services/WalletWebSocketService.ts
Normal file
391
src/modules/wallet/services/WalletWebSocketService.ts
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue