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
+23 -1
View File
@@ -1,4 +1,12 @@
import { getWallet, getWalletSecrets, type WalletSummary } from 'officerdb';
import type { ChainCacheStore } from './backends/onchain';
import {
getWallet,
getWalletSecrets,
getWalletChainCache,
saveWalletChainCache,
recordWalletChainError,
type WalletSummary,
} from 'officerdb';
import { EsploraChain } from './chain';
import { LndBackend } from './backends/lnd';
import { ClnRestBackend } from './backends/clnrest';
@@ -52,6 +60,19 @@ export async function resolveBackend(userId: number, walletId: number): Promise<
return { wallet, backend };
}
/**
* Binds the chain cache queries to one wallet id. This is the only thing that gives the on-chain
* backend a persistent identity — the backend itself never learns which wallet it is, exactly as it
* never learns which seed it derives from.
*/
function chainCacheFor(walletId: number): ChainCacheStore {
return {
load: () => getWalletChainCache(walletId),
save: (snapshot) => saveWalletChainCache(walletId, snapshot),
recordError: (message) => recordWalletChainError(walletId, message),
};
}
function required(config: Record<string, unknown> | null, key: string, kind: string): string {
const v = config?.[key];
if (typeof v !== 'string' || !v) {
@@ -110,6 +131,7 @@ function build(wallet: WalletSummary, config: Record<string, unknown> | null): W
// The session is the signer. While locked it holds no key material, so watch-only reads below
// still work and only sendCoins/signMessage will throw WalletLockedError.
signer: sessionFor(wallet.id),
cache: chainCacheFor(wallet.id),
});
}