Compare commits

...

2 commits

Author SHA1 Message Date
586698259a Merge pull request 'fix(wallet): refetch balance and history on resume' (#164) from fix/wallet-resume-refetch into dev
Reviewed-on: #164
2026-09-22 21:06:18 +00:00
9e7fb0a0f1 fix(wallet): refetch balance and history on resume
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.
2026-09-22 22:46:38 +02:00

View file

@ -437,6 +437,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<void> {
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)
)
}
/**
@ -556,46 +589,51 @@ export class WalletWebSocketService extends BaseService {
*/
private async performPollingUpdate(): Promise<void> {
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<void> {
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)
}
}
}