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