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:
@@ -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 ─────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user