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:
2026-07-31 15:12:57 +00:00
co-authored by Claude Opus 5
parent bed8854206
commit 2eda855551
13 changed files with 626 additions and 70 deletions
+18 -3
View File
@@ -32,6 +32,7 @@ import {
BIP_ADDRESS_TYPE,
type BitcoinNetwork,
type Capability,
type SyncState,
type Utxo,
type WalletBackend,
} from './types';
@@ -68,6 +69,17 @@ async function body<T>(req: Request): Promise<T> {
}
}
/**
* Freshness of the data a read just returned, or null from a backend that has no cache to be stale.
* Attached to balances, transactions and utxos so the UI can distinguish an empty wallet from one it
* could not reach — those rendered identically before, as a zero balance.
*
* Must be read AFTER the response it describes: it reports the snapshot that was actually served.
*/
function syncOf(backend: WalletBackend): SyncState | null {
return backend.getSyncState?.() ?? null;
}
/** Guard a capability before dispatching, so callers get 501 rather than a confusing upstream error. */
function requireCap(backend: WalletBackend, cap: Capability, op: string): void {
if (!backend.supports(cap)) {
@@ -219,7 +231,7 @@ async function handleWallets(ctx: OfficerContext, seg: string[]): Promise<Respon
return json({ info: await backend.getInfo() });
case 'balances':
return json({ balances: await backend.getBalances() });
return json({ balances: await backend.getBalances(), sync: syncOf(backend) });
case 'transactions': {
const limit = Number(ctx.url.searchParams.get('limit') ?? 50);
@@ -227,7 +239,10 @@ async function handleWallets(ctx: OfficerContext, seg: string[]): Promise<Respon
// Overlay owner labels, which live in Officer's DB rather than any backend.
const labels = await getWalletLabels(walletId);
const byRef = new Map(labels.filter((l) => l.kind === 'tx').map((l) => [l.ref, l.label]));
return json({ transactions: txs.map((t) => ({ ...t, label: byRef.get(t.txid) ?? t.label })) });
return json({
transactions: txs.map((t) => ({ ...t, label: byRef.get(t.txid) ?? t.label })),
sync: syncOf(backend),
});
}
case 'address': {
@@ -507,7 +522,7 @@ async function utxosRoute(
frozen: frozen.has(`${u.txid}:${u.vout}`),
label: byAddr.get(u.address) ?? null,
}));
return json({ utxos: merged });
return json({ utxos: merged, sync: syncOf(backend) });
}
// ── invoices ─────────────────────────────────────────────────────────────────────────────────────