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
+12 -1
View File
@@ -165,8 +165,19 @@ export {
setWalletLabel,
getFrozenOutpoints,
setUtxoFrozen,
getWalletChainCache,
saveWalletChainCache,
recordWalletChainError,
} from './queries/wallet';
export type { WalletKind, WalletSummary, WalletSecrets, WalletLabel, CreateWalletParams } from './queries/wallet';
export type {
WalletKind,
WalletSummary,
WalletSecrets,
WalletLabel,
CreateWalletParams,
WalletChainCache,
} from './queries/wallet';
export type { WalletChainSnapshot } from './schema/wallet';
export { db } from './db';
export * as schema from './schema';
+64 -3
View File
@@ -1,6 +1,7 @@
import type { WalletChainSnapshot } from '../schema/wallet';
import { eq, and, desc } from 'drizzle-orm';
import { db } from '../db';
import { walletWallets, walletLabels, walletFrozenUtxos } from '../schema';
import { walletWallets, walletLabels, walletFrozenUtxos, walletChainCache } from '../schema';
import { encryptSecret, decryptSecret } from '../crypto';
// Wallet access for the officer-wallet sidecar. Callers deal in PLAINTEXT — the VAULT_STORE_KEY layer is
@@ -228,7 +229,12 @@ export async function getWalletLabels(walletId: number): Promise<WalletLabel[]>
return rows.map((r) => ({ kind: r.kind as 'address' | 'tx', ref: r.ref, label: r.label }));
}
export async function setWalletLabel(walletId: number, kind: 'address' | 'tx', ref: string, label: string): Promise<void> {
export async function setWalletLabel(
walletId: number,
kind: 'address' | 'tx',
ref: string,
label: string,
): Promise<void> {
// An empty label is a delete — the UI clears a field rather than pressing a separate button.
if (!label.trim()) {
await db
@@ -252,7 +258,12 @@ export async function getFrozenOutpoints(walletId: number): Promise<string[]> {
return rows.map((r) => r.outpoint);
}
export async function setUtxoFrozen(walletId: number, outpoint: string, frozen: boolean, reason?: string): Promise<void> {
export async function setUtxoFrozen(
walletId: number,
outpoint: string,
frozen: boolean,
reason?: string,
): Promise<void> {
if (!frozen) {
await db
.delete(walletFrozenUtxos)
@@ -264,3 +275,53 @@ export async function setUtxoFrozen(walletId: number, outpoint: string, frozen:
.values({ walletId, outpoint, reason: reason ?? null })
.onConflictDoNothing();
}
// ── chain cache ──────────────────────────────────────────────────────────────────────────────────
//
// Unlike everything above, none of this is authoritative — see the comment on the table. These three
// functions are deliberately total: a cache read or write must never be able to fail a wallet request,
// so the sidecar treats every one of them as best-effort.
export type WalletChainCache = {
snapshot: WalletChainSnapshot | null;
syncedAt: Date | null;
lastError: string | null;
lastErrorAt: Date | null;
};
export async function getWalletChainCache(walletId: number): Promise<WalletChainCache | null> {
const [row] = await db
.select({
snapshot: walletChainCache.snapshot,
syncedAt: walletChainCache.syncedAt,
lastError: walletChainCache.lastError,
lastErrorAt: walletChainCache.lastErrorAt,
})
.from(walletChainCache)
.where(eq(walletChainCache.walletId, walletId));
return row ?? null;
}
/** A successful refresh. Clears the error columns — the upstream just answered, so nothing is wrong now. */
export async function saveWalletChainCache(walletId: number, snapshot: WalletChainSnapshot): Promise<void> {
const syncedAt = new Date();
await db
.insert(walletChainCache)
.values({ walletId, snapshot, syncedAt, lastError: null, lastErrorAt: null })
.onConflictDoUpdate({
target: walletChainCache.walletId,
set: { snapshot, syncedAt, lastError: null, lastErrorAt: null },
});
}
/** A failed refresh. Touches only the error columns, so the last good snapshot and its age survive. */
export async function recordWalletChainError(walletId: number, message: string): Promise<void> {
const lastErrorAt = new Date();
// Bounded because this is an upstream message verbatim, and an HTML error page from a misconfigured
// proxy would otherwise land whole in the column.
const lastError = message.slice(0, 500);
await db
.insert(walletChainCache)
.values({ walletId, snapshot: null, syncedAt: null, lastError, lastErrorAt })
.onConflictDoUpdate({ target: walletChainCache.walletId, set: { lastError, lastErrorAt } });
}
@@ -86,6 +86,10 @@ export const walletLabels = pgTable(
// Frozen UTXOs, excluded from automatic coin selection. Separate from labels because this one affects
// what the wallet will actually spend — losing a label is cosmetic, losing a freeze spends a coin the
// owner meant to keep back.
//
// NOTE the contrast with wallet_chain_cache below: this table is POLICY (the owner's decision, only
// they can restate it) and that one is OBSERVATION (the chain's, refetchable at any time). That is why
// only one of them can be safely truncated.
export const walletFrozenUtxos = pgTable(
'wallet_frozen_utxos',
{
@@ -100,3 +104,71 @@ export const walletFrozenUtxos = pgTable(
},
(t) => [unique('uq_wallet_frozen_utxos_wallet_outpoint').on(t.walletId, t.outpoint)],
);
/**
* The last thing the chain told us about an on-chain wallet, so a wallet screen paints real numbers
* immediately instead of nothing while a gap-limit scan runs.
*
* Every field is DERIVED and rebuildable — it is recomputed from the account xpubs above on each
* successful refresh, so this table holds no secret and nothing authoritative. Truncating it costs one
* slow load and nothing else.
*
* Only the chain's own facts are stored. Addresses, scripts and pubkeys are NOT: they are pure EC
* derivation from the xpub, cheaper to recompute on load than to persist, and re-deriving them means a
* restored snapshot can never disagree with the wallet's actual keys.
*/
export type WalletChainSnapshot = {
/** Bumped when the shape below changes; a snapshot at any other version is discarded, not migrated. */
v: 1;
tipHeight: number;
/** One entry per address the scan visited, keyed by its derivation coordinates. */
addresses: {
/** AddressType: 'p2wpkh' | 'p2tr' | 'p2sh-p2wpkh' | 'p2pkh'. */
type: string;
/** BIP44 chain index: 0 receive, 1 change. */
chain: number;
index: number;
confirmed: number;
unconfirmed: number;
txCount: number;
}[];
utxos: {
txid: string;
vout: number;
value: number;
type: string;
chain: number;
index: number;
/** Null while unconfirmed. Confirmations are recomputed against tipHeight, never stored. */
blockHeight: number | null;
}[];
/** The OnchainTx shape from servers/sidecar/wallet/types.ts, restated here so this package imports nothing. */
transactions: {
txid: string;
amount: number;
feeSats: number | null;
blockHeight: number | null;
timestamp: number | null;
confirmations: number;
label: string | null;
destAddresses: string[];
rawHex: string | null;
}[];
};
export const walletChainCache = pgTable('wallet_chain_cache', {
// One row per wallet, so a refresh is a single atomic upsert with no partially-written window.
walletId: integer('wallet_id')
.primaryKey()
.references(() => walletWallets.id, { onDelete: 'cascade' }),
// Nullable: a wallet whose very first refresh failed has an error to report and no data to report it
// against, and that row still has to exist for the UI to say why the screen is empty.
snapshot: jsonb('snapshot').$type<WalletChainSnapshot>(),
/** When the chain was last read successfully — the age the UI shows. Not when the row was written. */
syncedAt: timestamp('synced_at', { withTimezone: true }),
// Kept ALONGSIDE the last good snapshot rather than replacing it. A failure that wiped the data would
// reproduce the exact bug this table exists to fix: an unreachable upstream rendering as a zero balance
// instead of as a stale one.
lastError: text('last_error'),
lastErrorAt: timestamp('last_error_at', { withTimezone: true }),
});