From 9e7fb0a0f1f886a45cdf3933e8e3695c24cc8df2 Mon Sep 17 00:00:00 2001 From: Padreug Date: Tue, 22 Sep 2026 22:46:38 +0200 Subject: [PATCH] fix(wallet): refetch balance and history on resume MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A payment that settled while the app was backgrounded stayed invisible until the user pressed Refresh by hand. `onPause` closes the socket to save battery, and the LNbits websocket only pushes on live events — it replays nothing on reconnect. `onResume` reconnected the socket and stopped there, so the notification for anything that settled in between reached no one. Balance and the transaction list both sat at their pre-pause values. `onResume` now pairs reconnection with an explicit refetch of both: the balance via PaymentService and the rows via WalletService. Neither implies the other, so both are needed. `allSettled` keeps a failure in one from leaving the other un-refreshed. The balance fetch is extracted from the polling fallback rather than duplicated, so the two paths cannot drift. Verified against the live LNbits with a real internal payment: socket closed (backgrounded) paid a 33-sat invoice, waited 5s so the notification task fired reconnected -> nothing arrived in 8s; the 33 sats were invisible REST refetch -> 283633 sat, a 33 sat delta the UI had missed A first attempt reconnected immediately and did see a message. That was a test artifact, not a replay: LNbits dispatches the notification from an async task, which found the new socket. Hence the deliberate delay above, which is what a backgrounded app actually looks like. --- .../wallet/services/WalletWebSocketService.ts | 114 ++++++++++++------ 1 file changed, 76 insertions(+), 38 deletions(-) diff --git a/src/modules/wallet/services/WalletWebSocketService.ts b/src/modules/wallet/services/WalletWebSocketService.ts index 5b332d0..9c99b13 100644 --- a/src/modules/wallet/services/WalletWebSocketService.ts +++ b/src/modules/wallet/services/WalletWebSocketService.ts @@ -431,6 +431,39 @@ export class WalletWebSocketService extends BaseService { // Resume polling if we were using fallback this.schedulePolling() } + + // Reconnecting is not enough on its own — see below. + await this.reconcileMissedActivity() + } + + /** + * Re-sync balance and history after the app was backgrounded. + * + * `onPause` closes the socket to save battery, and the LNbits websocket + * only pushes on live events — it replays nothing on reconnect. So any + * payment that settled while we were away produced a notification that + * reached no one: the balance and the transaction list both stayed at + * their pre-pause values until the user hit Refresh by hand. + * + * Reconnecting therefore has to be paired with an explicit refetch. Both + * halves are needed: the balance comes from PaymentService, the rows come + * from WalletService, and neither implies the other. + * + * `allSettled` so a failure in one does not leave the other un-refreshed. + */ + private async reconcileMissedActivity(): Promise { + console.log('WalletWebSocketService: Reconciling activity missed while paused') + + const results = await Promise.allSettled([ + this.fetchAndUpdateBalance(), + this.walletService?.refresh?.() + ]) + + results + .filter((r): r is PromiseRejectedResult => r.status === 'rejected') + .forEach(r => + console.error('WalletWebSocketService: Resume reconciliation failed', r.reason) + ) } /** @@ -550,46 +583,51 @@ export class WalletWebSocketService extends BaseService { */ private async performPollingUpdate(): Promise { try { - const wallet = this.paymentService?.getPreferredWallet?.() - if (!wallet?.inkey) { - console.log('WalletWebSocketService: No wallet available for polling') - this.schedulePolling() // Continue polling - return - } - - // Fetch balance from LNbits API - const baseUrl = import.meta.env.VITE_LNBITS_BASE_URL || 'http://localhost:5000' - const response = await fetch(`${baseUrl}/api/v1/wallet`, { - headers: { - 'X-Api-Key': wallet.inkey - } - }) - - if (!response.ok) { - console.error('WalletWebSocketService: Polling failed', response.status, response.statusText) - this.schedulePolling() // Continue polling despite error - return - } - - const data = await response.json() - - if (data.balance !== undefined) { - // LNbits API returns balance in millisats - const balanceMsat = parseInt(data.balance) - console.log('WalletWebSocketService: Polling update - balance:', balanceMsat, 'msat') - - // Update balance via PaymentService - if (this.paymentService?.updateWalletBalance) { - this.paymentService.updateWalletBalance(balanceMsat, wallet.id) - } - } - - // Schedule next polling - this.schedulePolling() - + await this.fetchAndUpdateBalance() } catch (error) { console.error('WalletWebSocketService: Polling error', error) - this.schedulePolling() // Continue polling despite error + } finally { + // Keep polling regardless of the outcome of this round. + this.schedulePolling() + } + } + + /** + * Fetch the authoritative wallet balance from LNbits and push it into + * PaymentService. Shared by the polling fallback and by resume + * reconciliation. + */ + private async fetchAndUpdateBalance(): Promise { + const wallet = this.paymentService?.getPreferredWallet?.() + if (!wallet?.inkey) { + console.log('WalletWebSocketService: No wallet available for balance fetch') + return + } + + const baseUrl = import.meta.env.VITE_LNBITS_BASE_URL || 'http://localhost:5000' + const response = await fetch(`${baseUrl}/api/v1/wallet`, { + headers: { + 'X-Api-Key': wallet.inkey + } + }) + + if (!response.ok) { + console.error( + 'WalletWebSocketService: Balance fetch failed', + response.status, + response.statusText + ) + return + } + + const data = await response.json() + if (data.balance !== undefined) { + // LNbits API returns balance in millisats + const balanceMsat = parseInt(data.balance) + console.log('WalletWebSocketService: Balance fetch -', balanceMsat, 'msat') + if (this.paymentService?.updateWalletBalance) { + this.paymentService.updateWalletBalance(balanceMsat, wallet.id) + } } }