persist the wallet's view of the chain
Opening a wallet meant waiting for a full gap-limit scan before any number appeared, and a restart threw that work away. Worse, an unreachable Esplora rendered identically to an empty wallet — as a zero balance — which is alarming for the one case where it is not true. The backend now keeps a snapshot and serves it stale-while-revalidate: a snapshot inside the TTL is served as-is, an older one is served immediately with a refresh started behind it, and only a wallet that has genuinely never been read blocks on the network. wallet_chain_cache holds one row per wallet so a refresh is a single atomic upsert. The snapshot, not the endpoint, is the unit of caching. Balances, UTXOs and history were three fetches over a shared scan, so the three queries a wallet screen fires on mount could each observe a different moment; building them together costs the same requests and fixes that incidentally. Only the chain's own facts are stored. Addresses, scripts and pubkeys are re-derived from the account xpub on load — cheaper than persisting them, and it means a restored snapshot cannot disagree with the wallet's actual keys. Stored coordinates are validated rather than trusted, and a snapshot at an unknown version is discarded, not migrated. Two reads deliberately opt out. sendCoins takes a fresh snapshot because selecting coins from a cached UTXO set builds a transaction spending outputs that may already be gone, and that failure arrives as a broadcast rejection after signing. nextUnused does too, because handing out an address whose stale record says "unused" is silent address reuse — a privacy leak the owner cannot see or undo. Receive-address generation is therefore the one read that stops working while the upstream is down, on purpose. Failures are recorded alongside the last good snapshot rather than replacing it; wiping data on failure would reproduce the exact bug this exists to fix. Every cache operation is best-effort, so a database problem degrades to a slow load and can never fail a wallet request. A sync block on balances, transactions and utxos carries the age to the UI, which now distinguishes "empty" from "never read". Verified against three live mainnet wallets: snapshots persisted and reloaded, and two wallets kept their data and age through a real Esplora rate-limit failure while recording the error separately. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -5,6 +5,7 @@ import { formatConfirmations, truncateMiddle } from './format';
|
||||
import { walletSectionPath } from './shared';
|
||||
import { Amount } from './Amount';
|
||||
import { EmptyWallet, UnsupportedSection } from './EmptyWallet';
|
||||
import { SyncBadge } from './SyncBadge';
|
||||
import { useSelectedWallet } from './useSelectedWallet';
|
||||
import { useCoinSelection } from './useCoinSelection';
|
||||
import { useCapabilities, useUtxos, useWalletOperations } from './useWalletData';
|
||||
@@ -19,7 +20,7 @@ export const CoinsView = () => {
|
||||
const { capabilities } = useCapabilities(walletId);
|
||||
const supported = capabilities.includes('coinControl');
|
||||
|
||||
const { utxos, isLoading: utxosLoading } = useUtxos(walletId, supported);
|
||||
const { utxos, sync, isLoading: utxosLoading } = useUtxos(walletId, supported);
|
||||
const { selected, toggle, clear } = useCoinSelection();
|
||||
|
||||
if (!walletId) return <EmptyWallet isLoading={isLoading} />;
|
||||
@@ -37,6 +38,7 @@ export const CoinsView = () => {
|
||||
{utxos.length} coin{utxos.length === 1 ? '' : 's'} · <Amount sats={spendableTotal} /> spendable
|
||||
</span>
|
||||
<span className="flex-1" />
|
||||
<SyncBadge sync={sync} />
|
||||
{selected.length > 0 && (
|
||||
<>
|
||||
<span>
|
||||
@@ -63,8 +65,13 @@ export const CoinsView = () => {
|
||||
</div>
|
||||
) : utxos.length === 0 ? (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-1 p-8 text-center">
|
||||
<p className="text-sm font-medium">No coins</p>
|
||||
<p className="text-xs text-muted-foreground">Receive something and it will show up here as a UTXO.</p>
|
||||
{/* "No coins" is only true if the chain was actually read. Never-synced is a different fact. */}
|
||||
<p className="text-sm font-medium">{sync && sync.syncedAt === null ? 'Coins unavailable' : 'No coins'}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{sync && sync.syncedAt === null
|
||||
? (sync.lastError ?? 'The chain source could not be reached.')
|
||||
: 'Receive something and it will show up here as a UTXO.'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="divide-y divide-border">
|
||||
|
||||
@@ -5,6 +5,7 @@ import { KIND_LABELS, walletSectionPath } from './shared';
|
||||
import { formatSats, formatTimestamp, truncateMiddle } from './format';
|
||||
import { Amount, UnitToggle } from './Amount';
|
||||
import { EmptyWallet } from './EmptyWallet';
|
||||
import { SyncBadge } from './SyncBadge';
|
||||
import { useSelectedWallet } from './useSelectedWallet';
|
||||
import { useBalances, useCapabilities, useTransactions, useWalletInfo } from './useWalletData';
|
||||
|
||||
@@ -16,7 +17,7 @@ import { useBalances, useCapabilities, useTransactions, useWalletInfo } from './
|
||||
export const OverviewView = () => {
|
||||
const { wallet, walletId, isLoading } = useSelectedWallet();
|
||||
const { capabilities } = useCapabilities(walletId);
|
||||
const { balances, isLoading: balancesLoading } = useBalances(walletId);
|
||||
const { balances, sync, isLoading: balancesLoading } = useBalances(walletId);
|
||||
const { info } = useWalletInfo(walletId);
|
||||
const { transactions } = useTransactions(walletId, 5);
|
||||
|
||||
@@ -37,7 +38,10 @@ export const OverviewView = () => {
|
||||
{info?.version && ` · ${info.version}`}
|
||||
</p>
|
||||
</div>
|
||||
<UnitToggle />
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<SyncBadge sync={sync} />
|
||||
<UnitToggle />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{balancesLoading && !balances ? (
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import type { SyncState } from './shared';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { AlertTriangle, Check, RefreshCw } from 'lucide-react';
|
||||
|
||||
// How old the numbers on screen are.
|
||||
//
|
||||
// This exists because "empty wallet" and "we could not reach the chain" used to render identically — as a
|
||||
// zero balance — and the second one is alarming while the first is not. The sidecar now serves the last
|
||||
// snapshot it managed to read and refreshes behind the response, so the number is almost always real; what
|
||||
// the owner needs is its age, and a plain statement when the upstream is failing.
|
||||
//
|
||||
// Nothing here is a control. Refreshing is the data layer's job and it is already happening.
|
||||
|
||||
/** Re-render on a timer so "2 minutes ago" does not sit frozen on an idle screen. */
|
||||
const TICK_MS = 15_000;
|
||||
|
||||
function relativeAge(syncedAt: number, now: number): string {
|
||||
const seconds = Math.max(0, Math.round((now - syncedAt) / 1000));
|
||||
if (seconds < 45) return 'just now';
|
||||
const minutes = Math.round(seconds / 60);
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
const hours = Math.round(minutes / 60);
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
return `${Math.round(hours / 24)}d ago`;
|
||||
}
|
||||
|
||||
type SyncBadgeProps = { sync: SyncState | null; className?: string };
|
||||
|
||||
export const SyncBadge = ({ sync, className }: SyncBadgeProps) => {
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => setNow(Date.now()), TICK_MS);
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
// Null from a node backend, which reads live and has no age to report. Rendering "just now" for it
|
||||
// would be inventing a fact.
|
||||
if (!sync) return null;
|
||||
|
||||
const failing = sync.lastError !== null;
|
||||
|
||||
// Never read successfully AND failing: the only case where the numbers beside this are not real. Say so
|
||||
// loudly, because a zero here means "unknown", not "empty".
|
||||
if (sync.syncedAt === null) {
|
||||
if (!failing) return null;
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center gap-1 text-[11px] text-destructive ${className ?? ''}`}
|
||||
title={sync.lastError ?? undefined}
|
||||
>
|
||||
<AlertTriangle className="h-3 w-3" />
|
||||
Could not reach the chain
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const age = relativeAge(sync.syncedAt, now);
|
||||
|
||||
if (failing) {
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center gap-1 text-[11px] text-amber-600 dark:text-amber-500 ${className ?? ''}`}
|
||||
title={sync.lastError ?? undefined}
|
||||
>
|
||||
<AlertTriangle className="h-3 w-3" />
|
||||
{age} · chain unreachable
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1 text-[11px] text-muted-foreground ${className ?? ''}`}>
|
||||
{sync.stale ? <RefreshCw className="h-3 w-3 animate-spin" /> : <Check className="h-3 w-3" />}
|
||||
{sync.stale ? `${age} · refreshing` : age}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
@@ -3,6 +3,7 @@ import { ArrowDownLeft, ArrowUpRight, Loader2 } from 'lucide-react';
|
||||
import { formatConfirmations, formatSats, formatTimestamp, truncateMiddle } from './format';
|
||||
import { Amount } from './Amount';
|
||||
import { EmptyWallet } from './EmptyWallet';
|
||||
import { SyncBadge } from './SyncBadge';
|
||||
import { useSelectedWallet } from './useSelectedWallet';
|
||||
import { useTransactions } from './useWalletData';
|
||||
|
||||
@@ -10,7 +11,7 @@ import { useTransactions } from './useWalletData';
|
||||
|
||||
export const TransactionsView = () => {
|
||||
const { walletId, isLoading } = useSelectedWallet();
|
||||
const { transactions, isLoading: txLoading } = useTransactions(walletId);
|
||||
const { transactions, sync, isLoading: txLoading } = useTransactions(walletId);
|
||||
|
||||
if (!walletId) return <EmptyWallet isLoading={isLoading} />;
|
||||
|
||||
@@ -24,21 +25,33 @@ export const TransactionsView = () => {
|
||||
}
|
||||
|
||||
if (transactions.length === 0) {
|
||||
// An empty list means "no transactions" only if we actually managed to look. When the chain has never
|
||||
// been read, the same screen would otherwise assert a fact nobody established.
|
||||
const neverRead = sync !== null && sync.syncedAt === null;
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-1 p-8 text-center">
|
||||
<p className="text-sm font-medium">Nothing here yet</p>
|
||||
<p className="text-xs text-muted-foreground">Transactions appear as soon as they hit the mempool.</p>
|
||||
<p className="text-sm font-medium">{neverRead ? 'History unavailable' : 'Nothing here yet'}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{neverRead
|
||||
? (sync.lastError ?? 'The chain source could not be reached.')
|
||||
: 'Transactions appear as soon as they hit the mempool.'}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-y-auto">
|
||||
<ul className="divide-y divide-border">
|
||||
{transactions.map((tx) => (
|
||||
<TransactionRow key={tx.txid} tx={tx} />
|
||||
))}
|
||||
</ul>
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex shrink-0 items-center justify-end border-b border-border px-4 py-1.5">
|
||||
<SyncBadge sync={sync} />
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<ul className="divide-y divide-border">
|
||||
{transactions.map((tx) => (
|
||||
<TransactionRow key={tx.txid} tx={tx} />
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -112,6 +112,20 @@ export type Balances = {
|
||||
lightningInbound: number | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* How old the numbers alongside this are, from a backend that caches its view of the chain. Null from a
|
||||
* node backend, which answers from its own live state and has no age to report.
|
||||
*
|
||||
* `syncedAt: null` with a `lastError` is the case worth rendering carefully: the wallet has never been
|
||||
* read successfully, so a zero balance means "unknown", not "empty".
|
||||
*/
|
||||
export type SyncState = {
|
||||
/** Unix ms of the last successful chain read. */
|
||||
syncedAt: number | null;
|
||||
stale: boolean;
|
||||
lastError: string | null;
|
||||
};
|
||||
|
||||
export type AddressType = 'p2wpkh' | 'p2tr' | 'p2sh-p2wpkh' | 'p2pkh';
|
||||
|
||||
export type OnchainTx = {
|
||||
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
Payment,
|
||||
Peer,
|
||||
SendCoinsResult,
|
||||
SyncState,
|
||||
Utxo,
|
||||
WalletConfig,
|
||||
WalletSummary,
|
||||
@@ -138,18 +139,28 @@ export function useWalletInfo(walletId: number | null) {
|
||||
return { info: query.data?.info ?? null, isLoading: query.isLoading, error: query.error };
|
||||
}
|
||||
|
||||
// The three reads below carry a `sync` block from the sidecar. An on-chain wallet serves the last
|
||||
// snapshot it managed to read and refreshes behind the response, so `isLoading` no longer means "we know
|
||||
// nothing" — after the first successful read it is never true again, and the honest signal for "these
|
||||
// numbers are old" or "we could not reach the chain" is `sync`, not the query state.
|
||||
|
||||
export function useBalances(walletId: number | null) {
|
||||
const { get } = useClient();
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: [...ROOT_KEY, 'balances', walletId] as const,
|
||||
queryFn: () => get<{ balances: Balances }>(`${base(walletId!)}/balances`),
|
||||
queryFn: () => get<{ balances: Balances; sync: SyncState | null }>(`${base(walletId!)}/balances`),
|
||||
enabled: walletId != null,
|
||||
refetchInterval: BALANCE_POLL_MS,
|
||||
staleTime: BALANCE_POLL_MS - 1_000,
|
||||
});
|
||||
|
||||
return { balances: query.data?.balances ?? null, isLoading: query.isLoading, error: query.error };
|
||||
return {
|
||||
balances: query.data?.balances ?? null,
|
||||
sync: query.data?.sync ?? null,
|
||||
isLoading: query.isLoading,
|
||||
error: query.error,
|
||||
};
|
||||
}
|
||||
|
||||
export function useTransactions(walletId: number | null, limit = 50) {
|
||||
@@ -157,13 +168,19 @@ export function useTransactions(walletId: number | null, limit = 50) {
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: [...ROOT_KEY, 'transactions', walletId, limit] as const,
|
||||
queryFn: () => get<{ transactions: OnchainTx[] }>(`${base(walletId!)}/transactions?limit=${limit}`),
|
||||
queryFn: () =>
|
||||
get<{ transactions: OnchainTx[]; sync: SyncState | null }>(`${base(walletId!)}/transactions?limit=${limit}`),
|
||||
enabled: walletId != null,
|
||||
refetchInterval: HISTORY_POLL_MS,
|
||||
staleTime: 15_000,
|
||||
});
|
||||
|
||||
return { transactions: query.data?.transactions ?? EMPTY_TXS, isLoading: query.isLoading, error: query.error };
|
||||
return {
|
||||
transactions: query.data?.transactions ?? EMPTY_TXS,
|
||||
sync: query.data?.sync ?? null,
|
||||
isLoading: query.isLoading,
|
||||
error: query.error,
|
||||
};
|
||||
}
|
||||
|
||||
export function useUtxos(walletId: number | null, enabled = true) {
|
||||
@@ -171,13 +188,18 @@ export function useUtxos(walletId: number | null, enabled = true) {
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: [...ROOT_KEY, 'utxos', walletId] as const,
|
||||
queryFn: () => get<{ utxos: Utxo[] }>(`${base(walletId!)}/utxos`),
|
||||
queryFn: () => get<{ utxos: Utxo[]; sync: SyncState | null }>(`${base(walletId!)}/utxos`),
|
||||
enabled: walletId != null && enabled,
|
||||
refetchInterval: HISTORY_POLL_MS,
|
||||
staleTime: 15_000,
|
||||
});
|
||||
|
||||
return { utxos: query.data?.utxos ?? EMPTY_UTXOS, isLoading: query.isLoading, error: query.error };
|
||||
return {
|
||||
utxos: query.data?.utxos ?? EMPTY_UTXOS,
|
||||
sync: query.data?.sync ?? null,
|
||||
isLoading: query.isLoading,
|
||||
error: query.error,
|
||||
};
|
||||
}
|
||||
|
||||
export function useFees(walletId: number | null, enabled = true) {
|
||||
@@ -472,7 +494,7 @@ export function useWalletOperations(walletId: number | null) {
|
||||
* "[object Object]". Never called with anything that could contain a passphrase: the sidecar's error
|
||||
* bodies are messages and codes only.
|
||||
*/
|
||||
function errorMessage(err: unknown, fallback: string): string {
|
||||
export function errorMessage(err: unknown, fallback: string): string {
|
||||
const raw = typeof err === 'object' && err !== null && 'message' in err ? String(err.message) : '';
|
||||
if (!raw) return fallback;
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user