fix(wallet): prioritize LND boot and reject unavailable balances

This commit is contained in:
archipelago
2026-09-15 15:09:08 -04:00
parent 4302138b4f
commit 4237fb5e79
7 changed files with 284 additions and 175 deletions
+14 -1
View File
@@ -133,6 +133,7 @@
class="order-2 lg:order-none"
:animate="animateCards"
:wallet-connected="walletConnected"
:wallet-balance-unavailable="walletBalanceUnavailable"
:wallet-onchain="walletOnchain"
:wallet-lightning="walletLightning"
:wallet-ecash="walletEcash"
@@ -685,6 +686,7 @@ async function devFaucet() { try { await rpcClient.call({ method: 'dev.faucet',
// readout instead; a rail only becomes a number when a call actually
// succeeds, so a real 0 is still a real 0.
const walletConnected = ref(false)
const walletBalanceUnavailable = ref(false)
const walletOnchain = ref<number | null>(null)
const walletLightning = ref<number | null>(null)
const walletEcash = ref<number | null>(null)
@@ -775,13 +777,24 @@ async function loadWeb5Status() {
// call, which is what makes the card feel like an app launch.
const balances = Promise.allSettled([
rpcClient.call<{ balance_sats: number; channel_balance_sats: number }>({ method: 'lnd.getinfo', timeout: 5000, dedup: true })
.then(res => { walletOnchain.value = res.balance_sats || 0; walletLightning.value = res.channel_balance_sats || 0; walletConnected.value = true; walletInfoFailures = 0 })
.then(res => {
if (!Number.isSafeInteger(res.balance_sats) || res.balance_sats < 0 ||
!Number.isSafeInteger(res.channel_balance_sats) || res.channel_balance_sats < 0) {
throw new Error('LND balance is unavailable')
}
walletOnchain.value = res.balance_sats
walletLightning.value = res.channel_balance_sats
walletConnected.value = true
walletBalanceUnavailable.value = false
walletInfoFailures = 0
})
.catch(() => {
// A single slow poll must NOT flip the card to "disconnected" and
// hide balances the user already knows — busy nodes routinely blow
// the 5s budget mid-payment or during IO storms (a test node user
// report: balances vanished while a payment settled). Only call it
// disconnected after three consecutive failures (~30s of silence).
walletBalanceUnavailable.value = true
walletInfoFailures += 1
if (walletInfoFailures >= 3) walletConnected.value = false
}),
@@ -238,6 +238,47 @@ describe('Home tab cache (Task 2): system/update/storage groups + wallet freshne
wrapper.unmount()
})
it.each(['failure', 'missing', 'partial'])('preserves known balances during %s and clears the warning on recovery', async (failure) => {
const wrapper = mountHomeHost()
await settle()
const home = wrapper.findComponent(Home)
const refresh = () => (home.vm as unknown as { loadWeb5Status: () => Promise<void> }).loadWeb5Status()
rpcCallMock.mockImplementationOnce(async () => {
if (failure === 'failure') throw new Error('wallet locked')
return failure === 'partial' ? { balance_sats: 0 } : {}
})
await refresh()
await settle()
const card = wrapper.findComponent(HomeWalletCard)
expect(card.props('walletOnchain')).toBe(5000)
expect(card.props('walletLightning')).toBe(2500)
expect(card.find('[data-testid="wallet-balance-unavailable"]').text()).toContain('last known')
const snapshot = JSON.parse(localStorage.getItem('archy-wallet-snapshot-v1')!)
expect(snapshot.onchain).toBe(5000)
expect(snapshot.lightning).toBe(2500)
rpcCallMock.mockImplementationOnce(async () => ({ balance_sats: 0, channel_balance_sats: 0, synced_to_chain: true }))
await refresh()
await settle()
expect(card.props('walletOnchain')).toBe(0)
expect(card.props('walletLightning')).toBe(0)
expect(card.find('[data-testid="wallet-balance-unavailable"]').exists()).toBe(false)
wrapper.unmount()
})
it('shows unknown rather than zero when the first LND request fails', async () => {
rpcCallMock.mockImplementation(async (request) => {
if (request.method === 'lnd.getinfo') throw new Error('wallet locked')
return defaultRpcCallImpl(request)
})
const wrapper = mountHomeHost()
await settle()
const card = wrapper.findComponent(HomeWalletCard)
expect(card.props('walletOnchain')).toBeNull()
expect(card.props('walletLightning')).toBeNull()
expect(card.find('[data-testid="wallet-balance-unavailable"]').text()).toContain('unavailable')
wrapper.unmount()
})
it('no sessionStorage key exists for the wallet resource after a mount and reactivation cycle', async () => {
const wrapper = mountHomeHost()
await settle()
@@ -54,6 +54,12 @@
</div>
</div>
<p v-if="walletBalanceUnavailable" data-testid="wallet-balance-unavailable" class="text-sm text-amber-200 mb-3" role="status">
{{ walletOnchain != null || walletLightning != null
? 'Bitcoin and Lightning balances could not be refreshed. Showing last known amounts.'
: 'Bitcoin and Lightning balances are unavailable while the wallet starts or reconnects.' }}
</p>
<!-- Incoming Transactions Panel -->
<transition name="incoming-tx-slide">
<div v-if="showIncomingTxPanel && incomingTransactions.length > 0" class="mb-4 rounded-xl overflow-hidden border border-green-500/20">
@@ -221,6 +227,7 @@ export interface WalletTransaction {
const props = defineProps<{
animate: boolean
walletConnected: boolean
walletBalanceUnavailable?: boolean
// `null` = not loaded yet, `0` = genuinely empty. Keeping those apart is
// what lets the card show a pixel readout instead of claiming a figure.
walletOnchain: number | null