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:
@@ -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),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user