fix(wallet): prioritize LND boot and reject unavailable balances
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user