From 2eda855551e39b7a23c6d8d4b6ecf7f3a7a81a98 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Fri, 31 Jul 2026 15:12:57 +0000 Subject: [PATCH] persist the wallet's view of the chain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/databases/officer_db/src/index.ts | 13 +- .../officer_db/src/queries/wallet.ts | 67 +++- src/databases/officer_db/src/schema/wallet.ts | 72 +++++ .../sidecar/wallet/backends/onchain.ts | 298 +++++++++++++++--- src/servers/sidecar/wallet/resolve.ts | 24 +- src/servers/sidecar/wallet/routes.ts | 21 +- src/servers/sidecar/wallet/types.ts | 21 ++ .../officerdev/src/apps/Wallet/CoinsView.tsx | 13 +- .../src/apps/Wallet/OverviewView.tsx | 8 +- .../officerdev/src/apps/Wallet/SyncBadge.tsx | 78 +++++ .../src/apps/Wallet/TransactionsView.tsx | 31 +- .../officerdev/src/apps/Wallet/shared.ts | 14 + .../src/apps/Wallet/useWalletData.ts | 36 ++- 13 files changed, 626 insertions(+), 70 deletions(-) create mode 100644 src/workspaces/officerdev/src/apps/Wallet/SyncBadge.tsx diff --git a/src/databases/officer_db/src/index.ts b/src/databases/officer_db/src/index.ts index 035f4276..5f0d7148 100644 --- a/src/databases/officer_db/src/index.ts +++ b/src/databases/officer_db/src/index.ts @@ -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'; diff --git a/src/databases/officer_db/src/queries/wallet.ts b/src/databases/officer_db/src/queries/wallet.ts index 9f3e1956..b5ae1139 100644 --- a/src/databases/officer_db/src/queries/wallet.ts +++ b/src/databases/officer_db/src/queries/wallet.ts @@ -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 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 { +export async function setWalletLabel( + walletId: number, + kind: 'address' | 'tx', + ref: string, + label: string, +): Promise { // 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 { return rows.map((r) => r.outpoint); } -export async function setUtxoFrozen(walletId: number, outpoint: string, frozen: boolean, reason?: string): Promise { +export async function setUtxoFrozen( + walletId: number, + outpoint: string, + frozen: boolean, + reason?: string, +): Promise { 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 { + 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 { + 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 { + 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 } }); +} diff --git a/src/databases/officer_db/src/schema/wallet.ts b/src/databases/officer_db/src/schema/wallet.ts index a756fac8..d2346744 100644 --- a/src/databases/officer_db/src/schema/wallet.ts +++ b/src/databases/officer_db/src/schema/wallet.ts @@ -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(), + /** 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 }), +}); diff --git a/src/servers/sidecar/wallet/backends/onchain.ts b/src/servers/sidecar/wallet/backends/onchain.ts index eaa6b5bf..ab481925 100644 --- a/src/servers/sidecar/wallet/backends/onchain.ts +++ b/src/servers/sidecar/wallet/backends/onchain.ts @@ -16,6 +16,7 @@ // There is no lightning here at all: the capability set omits every lightning flag, so invoices, // payments, channels and peers all fall through to BaseBackend's 501. +import type { WalletChainSnapshot } from 'officerdb'; import { HDKey } from '@scure/bip32'; import * as bitcoin from 'bitcoinjs-lib'; import { toXOnly } from 'bitcoinjs-lib/src/psbt/bip371'; @@ -49,6 +50,7 @@ import { type SendCoinsRequest, type SendCoinsResult, type SignMessageResult, + type SyncState, type Utxo, type VerifyMessageResult, } from '../types'; @@ -71,6 +73,22 @@ export interface WalletSigner { withRoot(fn: (root: HDKey) => T): T; } +// ── the persistence boundary ───────────────────────────────────────────────────────────────────── + +/** + * Durable storage for the last snapshot this backend read off the chain. Injected the same way the + * signer is, and for the same reason: this file stays a pure wallet with no idea which database it is + * attached to, or that it has a wallet id at all. resolve.ts binds the real implementation. + * + * Every method is best-effort by contract. A cache that is unreachable, corrupt or empty must degrade + * to today's behaviour — a slow first load — and must never fail a wallet request. + */ +export interface ChainCacheStore { + load(): Promise<{ snapshot: WalletChainSnapshot | null; syncedAt: Date | null; lastError: string | null } | null>; + save(snapshot: WalletChainSnapshot): Promise; + recordError(message: string): Promise; +} + // ── extended key parsing ───────────────────────────────────────────────────────────────────────── /** @@ -143,12 +161,24 @@ type ScannedAddress = AddressEntry & { used: boolean; }; -type WalletScan = { +/** + * One coherent read of the chain: the address scan and everything derived from it, taken together. + * + * Balances, UTXOs and history used to be fetched by three separate endpoints on top of a shared scan, + * which meant the three queries a wallet screen fires on mount could each observe a different moment. + * Building them as one snapshot costs the same requests — collectUtxos only asks about funded addresses + * and collectTxs only about touched ones — and makes the whole thing a single unit to cache, serve stale + * and persist. + */ +type Snapshot = { + /** Unix ms the chain was read. Survives a restart: a restored snapshot carries its original time. */ at: number; tipHeight: number; addresses: ScannedAddress[]; - /** scriptPubKey hex → the entry that owns it. Used to score transactions as ours. */ + /** scriptPubKey hex → the entry that owns it. Used to score transactions as ours. Never persisted. */ byScript: Map; + utxos: SpendableUtxo[]; + txs: OnchainTx[]; }; // ── tuning ─────────────────────────────────────────────────────────────────────────────────────── @@ -156,10 +186,12 @@ type WalletScan = { /** BIP44's standard gap limit: 20 consecutive unused addresses ends the scan for a chain. */ const GAP_LIMIT = 20; -/** How long a discovery scan stays fresh. Short enough to feel live, long enough that a dashboard - * polling getInfo/getBalances/getTransactions together costs one scan rather than three. */ +/** How long a snapshot is served without kicking off a refresh behind it. */ const SCAN_TTL_MS = 30_000; +/** Cap on the persisted history. A wallet with years of activity should not grow an unbounded jsonb blob. */ +const MAX_CACHED_TXS = 500; + /** Parallel Esplora requests. Public instances rate-limit, so this stays modest. */ const REQUEST_CONCURRENCY = 6; @@ -179,6 +211,8 @@ export type OnchainBackendOptions = { */ accountXpub: string | Partial>; signer: WalletSigner; + /** Omit for a backend that should hold its chain view in memory only — tests do. */ + cache?: ChainCacheStore; }; export class OnchainBackend extends BaseBackend { @@ -208,8 +242,13 @@ export class OnchainBackend extends BaseBackend { * which is correct as soon as an issued address has actually been paid. */ private readonly issued = new Map(); - private scanCache: WalletScan | null = null; - private scanInflight: Promise | null = null; + private readonly cache: ChainCacheStore | null; + + private current: Snapshot | null = null; + private inflight: Promise | null = null; + /** Memoised so concurrent first reads do one cache load between them, not one each. */ + private hydration: Promise | null = null; + private lastError: string | null = null; constructor(opts: OnchainBackendOptions) { super(); @@ -218,6 +257,7 @@ export class OnchainBackend extends BaseBackend { this.network = opts.network; this.btcNetwork = networkFor(opts.network); this.signer = opts.signer; + this.cache = opts.cache ?? null; const coin = coinTypeFor(opts.network); const raw = typeof opts.accountXpub === 'string' ? { p2wpkh: opts.accountXpub } : opts.accountXpub; @@ -286,39 +326,169 @@ export class OnchainBackend extends BaseBackend { return entry; } - // ── discovery scan ───────────────────────────────────────────────────────────────────────────── + // ── the snapshot: cache, refresh, persist ────────────────────────────────────────────────────── + + getSyncState(): SyncState { + return { + syncedAt: this.current?.at ?? null, + stale: this.current !== null && Date.now() - this.current.at >= SCAN_TTL_MS, + lastError: this.lastError, + }; + } /** - * Gap-limit scan of every configured account across both chains, memoised for SCAN_TTL_MS. Concurrent - * callers share one in-flight scan rather than each starting their own — without that, the three - * queries a wallet screen fires on mount would triple the request count against Esplora. + * The wallet's view of the chain, stale-while-revalidate. + * + * A snapshot within SCAN_TTL_MS is served as-is. An older one is served IMMEDIATELY with a refresh + * started behind it — that is the property the persistence exists for: after a restart, or while the + * Esplora upstream is slow or unreachable, the screen shows the last real numbers with their age + * rather than a blank wallet. Only a wallet that has genuinely never been read blocks on the network, + * and only that case can surface an error to the caller. + * + * `fresh` opts out for the two paths where stale data would be wrong rather than merely old. */ - private async scan(): Promise { - const fresh = this.scanCache; - if (fresh && Date.now() - fresh.at < SCAN_TTL_MS) return fresh; - if (this.scanInflight) return this.scanInflight; + private async snapshot(opts?: { fresh?: boolean }): Promise { + if (opts?.fresh) return this.refresh(); - const run = this.runScan().then( - (result) => { - this.scanCache = result; - this.scanInflight = null; - return result; + await this.hydrate(); + + const have = this.current; + if (have && Date.now() - have.at < SCAN_TTL_MS) return have; + + const running = this.refresh(); + if (!have) return running; + + // The refresh outlives this response. Its failure is recorded on `lastError` and reported through + // getSyncState, so it is handled here only to keep it from surfacing as an unhandled rejection. + void running.catch(() => {}); + return have; + } + + /** Load the persisted snapshot into memory. Runs at most once, and never throws. */ + private hydrate(): Promise { + this.hydration ??= (async () => { + const store = this.cache; + if (!store || this.current) return; + try { + const row = await store.load(); + if (!row) return; + this.lastError = row.lastError; + // A refresh may have completed while this load was in flight; live data always wins. A snapshot + // written by an older shape is ignored, not migrated — it is a cache, and the next refresh + // replaces it for free. + if (row.snapshot?.v === 1 && row.syncedAt && !this.current) { + this.current = this.fromPersisted(row.snapshot, row.syncedAt.getTime()); + } + } catch (err) { + // A cache failure costs a slow load, nothing more. It must not become a wallet failure. + console.error('[wallet] chain cache load failed:', err instanceof Error ? err.message : err); + } + })(); + return this.hydration; + } + + /** Read the chain. Concurrent callers share one walk rather than each starting their own. */ + private refresh(): Promise { + this.inflight ??= this.buildSnapshot().then( + (snap) => { + this.inflight = null; + this.current = snap; + this.lastError = null; + void this.persist(snap); + return snap; }, (err: unknown) => { - this.scanInflight = null; + this.inflight = null; + const message = err instanceof Error ? err.message : String(err); + this.lastError = message; + void this.cache?.recordError(message).catch((e: unknown) => { + console.error('[wallet] chain cache error write failed:', e instanceof Error ? e.message : e); + }); throw err; }, ); - this.scanInflight = run; - return run; + return this.inflight; } - /** Drop the cache after a send, so the spent coins disappear from the next read immediately. */ + private async persist(snap: Snapshot): Promise { + if (!this.cache) return; + try { + await this.cache.save(toPersisted(snap)); + } catch (err) { + console.error('[wallet] chain cache write failed:', err instanceof Error ? err.message : err); + } + } + + /** + * Rebuild a snapshot from what was stored. Only the chain's own facts were persisted; every address, + * script and pubkey is re-derived here from the account xpub, so a restored snapshot cannot disagree + * with the wallet's actual keys. An entry naming a script type this wallet no longer has an xpub for + * is dropped rather than faked. + */ + private fromPersisted(stored: WalletChainSnapshot, at: number): Snapshot { + const addresses: ScannedAddress[] = []; + for (const a of stored.addresses) { + const entry = this.tryDerive(a.type, a.chain, a.index); + if (!entry) continue; + addresses.push({ + ...entry, + confirmedSats: a.confirmed, + unconfirmedSats: a.unconfirmed, + txCount: a.txCount, + used: a.txCount > 0, + }); + } + + const utxos: SpendableUtxo[] = []; + for (const u of stored.utxos) { + const entry = this.tryDerive(u.type, u.chain, u.index); + if (!entry) continue; + utxos.push({ + txid: u.txid, + vout: u.vout, + amountSats: u.value, + address: entry.address, + addressType: entry.type, + confirmations: u.blockHeight === null ? 0 : Math.max(0, stored.tipHeight - u.blockHeight + 1), + derivationPath: entry.path, + frozen: false, + scriptPubKeyHex: entry.scriptPubKeyHex, + pubkeyHex: entry.pubkeyHex, + }); + } + + const byScript = new Map(); + for (const entry of addresses) byScript.set(entry.scriptPubKeyHex, entry); + + return { at, tipHeight: stored.tipHeight, addresses, byScript, utxos, txs: stored.transactions }; + } + + /** Derive from untrusted stored coordinates — returns null rather than throwing on anything unusable. */ + private tryDerive(type: string, chain: number, index: number): AddressEntry | null { + if (!isAddressType(type) || (chain !== 0 && chain !== 1)) return null; + if (!Number.isInteger(index) || index < 0 || index >= MAX_SCAN_INDEX) return null; + if (!this.accounts.has(type)) return null; + try { + return this.derive(type, chain, index); + } catch { + return null; + } + } + + /** One full read of the chain: scan, then everything derived from it, all at the same moment. */ + private async buildSnapshot(): Promise { + const scan = await this.runScan(); + // Both fan out over the scanned address set and neither depends on the other, so they overlap. + const [utxos, txs] = await Promise.all([this.collectUtxos(scan), this.collectTxs(scan)]); + return { ...scan, utxos, txs }; + } + + /** Drop everything after a send, so the spent coins cannot reappear from cache on the next read. */ private invalidateScan(): void { - this.scanCache = null; + this.current = null; } - private async runScan(): Promise { + private async runScan(): Promise> { const tipHeight = await this.chain.getTipHeight(); const addresses: ScannedAddress[] = []; @@ -394,10 +564,10 @@ export class OnchainBackend extends BaseBackend { } override async getBalances(): Promise { - const scan = await this.scan(); + const snap = await this.snapshot(); let confirmed = 0; let unconfirmed = 0; - for (const entry of scan.addresses) { + for (const entry of snap.addresses) { confirmed += entry.confirmedSats; unconfirmed += entry.unconfirmedSats; } @@ -415,9 +585,8 @@ export class OnchainBackend extends BaseBackend { } override async getUtxos(): Promise { - const scan = await this.scan(); - const utxos = await this.collectUtxos(scan); - return utxos.map((u) => ({ + const snap = await this.snapshot(); + return snap.utxos.map((u) => ({ txid: u.txid, vout: u.vout, amountSats: u.amountSats, @@ -440,10 +609,12 @@ export class OnchainBackend extends BaseBackend { } override async getTransactions(opts?: { limit?: number }): Promise { - const scan = await this.scan(); - const limit = opts?.limit ?? DEFAULT_TX_LIMIT; + const snap = await this.snapshot(); + return snap.txs.slice(0, opts?.limit ?? DEFAULT_TX_LIMIT); + } - // Only addresses that have ever been touched can appear in history. + /** History for every address the scan found had been touched. Only those can appear in it. */ + private async collectTxs(scan: Omit): Promise { const touched = scan.addresses.filter((a) => a.used); const pages = await mapLimit(touched, REQUEST_CONCURRENCY, (a) => this.chain.getAddressTxs(a.address)); @@ -460,11 +631,11 @@ export class OnchainBackend extends BaseBackend { if (ah !== bh) return bh - ah; return (b.timestamp ?? 0) - (a.timestamp ?? 0); }); - return txs.slice(0, limit); + return txs.slice(0, MAX_CACHED_TXS); } /** Score one Esplora transaction against the wallet's own scripts. */ - private toOnchainTx(tx: EsploraTx, scan: WalletScan): OnchainTx { + private toOnchainTx(tx: EsploraTx, scan: Omit): OnchainTx { let credit = 0; let debit = 0; const ours: string[] = []; @@ -518,11 +689,12 @@ export class OnchainBackend extends BaseBackend { const recipientScript = outputScriptFor(req.address, this.btcNetwork); const recipientType = scriptType(recipientScript); - const scan = await this.scan(); - const spendable = await this.collectUtxos(scan); + // Never stale. Selecting coins from a cached UTXO set would build a transaction spending outputs + // that may already be gone, and the failure would arrive as a broadcast rejection after signing. + const snap = await this.snapshot({ fresh: true }); const selection = selectCoins({ - utxos: spendable, + utxos: snap.utxos, targetSats: req.amountSats ?? 0, sendAll: req.sendAll === true, satPerVbyte: req.satPerVbyte, @@ -621,7 +793,7 @@ export class OnchainBackend extends BaseBackend { // ── shared internals ─────────────────────────────────────────────────────────────────────────── /** Fetch UTXOs for every scanned address that still holds a balance. */ - private async collectUtxos(scan: WalletScan): Promise { + private async collectUtxos(scan: Omit): Promise { // An address whose funded and spent counts match holds nothing; asking Esplora about it is a // wasted round trip, and on a wallet with long history that is most of the address set. const funded = scan.addresses.filter((a) => a.confirmedSats + a.unconfirmedSats > 0); @@ -652,13 +824,17 @@ export class OnchainBackend extends BaseBackend { * different address even before the current one is paid. */ private async nextUnused(type: AddressType, chain: ChainIndex, peek: boolean): Promise { - const scan = await this.scan(); + // Deliberately never served from cache. Handing out an address whose stale record says "unused" but + // which has been paid since is silent address reuse — a privacy leak the owner cannot see and cannot + // undo. Failing loudly while the upstream is down is the better of the two, so this is the one read + // that stops working offline, and on purpose. + const snap = await this.snapshot({ fresh: true }); const key = `${type}:${chain}`; const hint = this.issued.get(key) ?? 0; const used = new Set(); let highest = -1; - for (const entry of scan.addresses) { + for (const entry of snap.addresses) { if (entry.type !== type || entry.chain !== chain) continue; highest = Math.max(highest, entry.index); if (entry.used) used.add(entry.index); @@ -675,6 +851,46 @@ export class OnchainBackend extends BaseBackend { // ── helpers ────────────────────────────────────────────────────────────────────────────────────── +function isAddressType(value: string): value is AddressType { + return value === 'p2wpkh' || value === 'p2tr' || value === 'p2sh-p2wpkh' || value === 'p2pkh'; +} + +/** + * Reduce a snapshot to the chain facts worth storing. Addresses, scripts and pubkeys are dropped: they + * are pure derivation from the account xpub, so re-deriving them on load is both cheaper than storing + * them and the only way a restored snapshot is guaranteed to match the wallet's real keys. + */ +function toPersisted(snap: Snapshot): WalletChainSnapshot { + return { + v: 1, + tipHeight: snap.tipHeight, + addresses: snap.addresses.map((a) => ({ + type: a.type, + chain: a.chain, + index: a.index, + confirmed: a.confirmedSats, + unconfirmed: a.unconfirmedSats, + txCount: a.txCount, + })), + utxos: snap.utxos.map((u) => { + // The last two path elements are the BIP44 chain and address index — the same tail getUtxos + // reports as the relative path. + const tail = u.derivationPath.split('/'); + return { + txid: u.txid, + vout: u.vout, + value: u.amountSats, + type: u.addressType, + chain: Number(tail.at(-2)), + index: Number(tail.at(-1)), + // Stored as a height, not a count, so confirmations stay correct as the tip moves on. + blockHeight: u.confirmations > 0 ? snap.tipHeight - u.confirmations + 1 : null, + }; + }), + transactions: snap.txs, + }; +} + function toScannedAddress(entry: AddressEntry, stat: EsploraAddress): ScannedAddress { const chainBalance = stat.chain_stats.funded_txo_sum - stat.chain_stats.spent_txo_sum; // The mempool delta is signed: an unconfirmed spend of a confirmed coin reads negative here, which is diff --git a/src/servers/sidecar/wallet/resolve.ts b/src/servers/sidecar/wallet/resolve.ts index be7752d5..1b5fa4e3 100644 --- a/src/servers/sidecar/wallet/resolve.ts +++ b/src/servers/sidecar/wallet/resolve.ts @@ -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 | 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 | 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), }); } diff --git a/src/servers/sidecar/wallet/routes.ts b/src/servers/sidecar/wallet/routes.ts index 1e0396f8..dc947dfd 100644 --- a/src/servers/sidecar/wallet/routes.ts +++ b/src/servers/sidecar/wallet/routes.ts @@ -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(req: Request): Promise { } } +/** + * 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 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 ───────────────────────────────────────────────────────────────────────────────────── diff --git a/src/servers/sidecar/wallet/types.ts b/src/servers/sidecar/wallet/types.ts index 053aa396..6fb487ce 100644 --- a/src/servers/sidecar/wallet/types.ts +++ b/src/servers/sidecar/wallet/types.ts @@ -291,8 +291,29 @@ export interface WalletBackend { signMessage(message: string): Promise; verifyMessage(message: string, signature: string): Promise; + + /** + * How old the data this backend just served is. Optional because it is only meaningful for a backend + * that caches — a node backend answers from its own live state, and reporting a sync age for it would + * be inventing one. + */ + getSyncState?(): SyncState; } +/** + * Freshness of a cached view of the chain, so the UI can say "3 minutes ago" and, more importantly, tell + * "this wallet is empty" apart from "we could not ask". Those two rendered identically before this + * existed, which is the whole reason it does. + */ +export type SyncState = { + /** Unix ms of the last successful chain read. Null when the chain has never been read successfully. */ + syncedAt: number | null; + /** The served data is past its refresh interval; a refresh is already running behind this response. */ + stale: boolean; + /** The last refresh failure, still reported while the previous good data is being served. */ + lastError: string | null; +}; + // ── errors ─────────────────────────────────────────────────────────────────────────────────────── export class BackendError extends Error { diff --git a/src/workspaces/officerdev/src/apps/Wallet/CoinsView.tsx b/src/workspaces/officerdev/src/apps/Wallet/CoinsView.tsx index 0136b568..37122bf2 100644 --- a/src/workspaces/officerdev/src/apps/Wallet/CoinsView.tsx +++ b/src/workspaces/officerdev/src/apps/Wallet/CoinsView.tsx @@ -5,6 +5,7 @@ import { formatConfirmations, truncateMiddle } from './format'; import { walletSectionPath } from './shared'; import { Amount } from './Amount'; import { EmptyWallet, UnsupportedSection } from './EmptyWallet'; +import { SyncBadge } from './SyncBadge'; import { useSelectedWallet } from './useSelectedWallet'; import { useCoinSelection } from './useCoinSelection'; import { useCapabilities, useUtxos, useWalletOperations } from './useWalletData'; @@ -19,7 +20,7 @@ export const CoinsView = () => { const { capabilities } = useCapabilities(walletId); const supported = capabilities.includes('coinControl'); - const { utxos, isLoading: utxosLoading } = useUtxos(walletId, supported); + const { utxos, sync, isLoading: utxosLoading } = useUtxos(walletId, supported); const { selected, toggle, clear } = useCoinSelection(); if (!walletId) return ; @@ -37,6 +38,7 @@ export const CoinsView = () => { {utxos.length} coin{utxos.length === 1 ? '' : 's'} · spendable + {selected.length > 0 && ( <> @@ -63,8 +65,13 @@ export const CoinsView = () => { ) : utxos.length === 0 ? (
-

No coins

-

Receive something and it will show up here as a UTXO.

+ {/* "No coins" is only true if the chain was actually read. Never-synced is a different fact. */} +

{sync && sync.syncedAt === null ? 'Coins unavailable' : 'No coins'}

+

+ {sync && sync.syncedAt === null + ? (sync.lastError ?? 'The chain source could not be reached.') + : 'Receive something and it will show up here as a UTXO.'} +

) : (
    diff --git a/src/workspaces/officerdev/src/apps/Wallet/OverviewView.tsx b/src/workspaces/officerdev/src/apps/Wallet/OverviewView.tsx index 03584577..41c854f2 100644 --- a/src/workspaces/officerdev/src/apps/Wallet/OverviewView.tsx +++ b/src/workspaces/officerdev/src/apps/Wallet/OverviewView.tsx @@ -5,6 +5,7 @@ import { KIND_LABELS, walletSectionPath } from './shared'; import { formatSats, formatTimestamp, truncateMiddle } from './format'; import { Amount, UnitToggle } from './Amount'; import { EmptyWallet } from './EmptyWallet'; +import { SyncBadge } from './SyncBadge'; import { useSelectedWallet } from './useSelectedWallet'; import { useBalances, useCapabilities, useTransactions, useWalletInfo } from './useWalletData'; @@ -16,7 +17,7 @@ import { useBalances, useCapabilities, useTransactions, useWalletInfo } from './ export const OverviewView = () => { const { wallet, walletId, isLoading } = useSelectedWallet(); const { capabilities } = useCapabilities(walletId); - const { balances, isLoading: balancesLoading } = useBalances(walletId); + const { balances, sync, isLoading: balancesLoading } = useBalances(walletId); const { info } = useWalletInfo(walletId); const { transactions } = useTransactions(walletId, 5); @@ -37,7 +38,10 @@ export const OverviewView = () => { {info?.version && ` · ${info.version}`}

    - +
    + + +
    {balancesLoading && !balances ? ( diff --git a/src/workspaces/officerdev/src/apps/Wallet/SyncBadge.tsx b/src/workspaces/officerdev/src/apps/Wallet/SyncBadge.tsx new file mode 100644 index 00000000..915cbb4e --- /dev/null +++ b/src/workspaces/officerdev/src/apps/Wallet/SyncBadge.tsx @@ -0,0 +1,78 @@ +import type { SyncState } from './shared'; +import { useEffect, useState } from 'react'; +import { AlertTriangle, Check, RefreshCw } from 'lucide-react'; + +// How old the numbers on screen are. +// +// This exists because "empty wallet" and "we could not reach the chain" used to render identically — as a +// zero balance — and the second one is alarming while the first is not. The sidecar now serves the last +// snapshot it managed to read and refreshes behind the response, so the number is almost always real; what +// the owner needs is its age, and a plain statement when the upstream is failing. +// +// Nothing here is a control. Refreshing is the data layer's job and it is already happening. + +/** Re-render on a timer so "2 minutes ago" does not sit frozen on an idle screen. */ +const TICK_MS = 15_000; + +function relativeAge(syncedAt: number, now: number): string { + const seconds = Math.max(0, Math.round((now - syncedAt) / 1000)); + if (seconds < 45) return 'just now'; + const minutes = Math.round(seconds / 60); + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.round(minutes / 60); + if (hours < 24) return `${hours}h ago`; + return `${Math.round(hours / 24)}d ago`; +} + +type SyncBadgeProps = { sync: SyncState | null; className?: string }; + +export const SyncBadge = ({ sync, className }: SyncBadgeProps) => { + const [now, setNow] = useState(() => Date.now()); + + useEffect(() => { + const timer = setInterval(() => setNow(Date.now()), TICK_MS); + return () => clearInterval(timer); + }, []); + + // Null from a node backend, which reads live and has no age to report. Rendering "just now" for it + // would be inventing a fact. + if (!sync) return null; + + const failing = sync.lastError !== null; + + // Never read successfully AND failing: the only case where the numbers beside this are not real. Say so + // loudly, because a zero here means "unknown", not "empty". + if (sync.syncedAt === null) { + if (!failing) return null; + return ( + + + Could not reach the chain + + ); + } + + const age = relativeAge(sync.syncedAt, now); + + if (failing) { + return ( + + + {age} · chain unreachable + + ); + } + + return ( + + {sync.stale ? : } + {sync.stale ? `${age} · refreshing` : age} + + ); +}; diff --git a/src/workspaces/officerdev/src/apps/Wallet/TransactionsView.tsx b/src/workspaces/officerdev/src/apps/Wallet/TransactionsView.tsx index bd1422bd..25a0358c 100644 --- a/src/workspaces/officerdev/src/apps/Wallet/TransactionsView.tsx +++ b/src/workspaces/officerdev/src/apps/Wallet/TransactionsView.tsx @@ -3,6 +3,7 @@ import { ArrowDownLeft, ArrowUpRight, Loader2 } from 'lucide-react'; import { formatConfirmations, formatSats, formatTimestamp, truncateMiddle } from './format'; import { Amount } from './Amount'; import { EmptyWallet } from './EmptyWallet'; +import { SyncBadge } from './SyncBadge'; import { useSelectedWallet } from './useSelectedWallet'; import { useTransactions } from './useWalletData'; @@ -10,7 +11,7 @@ import { useTransactions } from './useWalletData'; export const TransactionsView = () => { const { walletId, isLoading } = useSelectedWallet(); - const { transactions, isLoading: txLoading } = useTransactions(walletId); + const { transactions, sync, isLoading: txLoading } = useTransactions(walletId); if (!walletId) return ; @@ -24,21 +25,33 @@ export const TransactionsView = () => { } if (transactions.length === 0) { + // An empty list means "no transactions" only if we actually managed to look. When the chain has never + // been read, the same screen would otherwise assert a fact nobody established. + const neverRead = sync !== null && sync.syncedAt === null; return (
    -

    Nothing here yet

    -

    Transactions appear as soon as they hit the mempool.

    +

    {neverRead ? 'History unavailable' : 'Nothing here yet'}

    +

    + {neverRead + ? (sync.lastError ?? 'The chain source could not be reached.') + : 'Transactions appear as soon as they hit the mempool.'} +

    ); } return ( -
    -
      - {transactions.map((tx) => ( - - ))} -
    +
    +
    + +
    +
    +
      + {transactions.map((tx) => ( + + ))} +
    +
    ); }; diff --git a/src/workspaces/officerdev/src/apps/Wallet/shared.ts b/src/workspaces/officerdev/src/apps/Wallet/shared.ts index a3f855a4..98e0060a 100644 --- a/src/workspaces/officerdev/src/apps/Wallet/shared.ts +++ b/src/workspaces/officerdev/src/apps/Wallet/shared.ts @@ -112,6 +112,20 @@ export type Balances = { lightningInbound: number | null; }; +/** + * How old the numbers alongside this are, from a backend that caches its view of the chain. Null from a + * node backend, which answers from its own live state and has no age to report. + * + * `syncedAt: null` with a `lastError` is the case worth rendering carefully: the wallet has never been + * read successfully, so a zero balance means "unknown", not "empty". + */ +export type SyncState = { + /** Unix ms of the last successful chain read. */ + syncedAt: number | null; + stale: boolean; + lastError: string | null; +}; + export type AddressType = 'p2wpkh' | 'p2tr' | 'p2sh-p2wpkh' | 'p2pkh'; export type OnchainTx = { diff --git a/src/workspaces/officerdev/src/apps/Wallet/useWalletData.ts b/src/workspaces/officerdev/src/apps/Wallet/useWalletData.ts index b5a126cc..42cf3b26 100644 --- a/src/workspaces/officerdev/src/apps/Wallet/useWalletData.ts +++ b/src/workspaces/officerdev/src/apps/Wallet/useWalletData.ts @@ -12,6 +12,7 @@ import type { Payment, Peer, SendCoinsResult, + SyncState, Utxo, WalletConfig, WalletSummary, @@ -138,18 +139,28 @@ export function useWalletInfo(walletId: number | null) { return { info: query.data?.info ?? null, isLoading: query.isLoading, error: query.error }; } +// The three reads below carry a `sync` block from the sidecar. An on-chain wallet serves the last +// snapshot it managed to read and refreshes behind the response, so `isLoading` no longer means "we know +// nothing" — after the first successful read it is never true again, and the honest signal for "these +// numbers are old" or "we could not reach the chain" is `sync`, not the query state. + export function useBalances(walletId: number | null) { const { get } = useClient(); const query = useQuery({ queryKey: [...ROOT_KEY, 'balances', walletId] as const, - queryFn: () => get<{ balances: Balances }>(`${base(walletId!)}/balances`), + queryFn: () => get<{ balances: Balances; sync: SyncState | null }>(`${base(walletId!)}/balances`), enabled: walletId != null, refetchInterval: BALANCE_POLL_MS, staleTime: BALANCE_POLL_MS - 1_000, }); - return { balances: query.data?.balances ?? null, isLoading: query.isLoading, error: query.error }; + return { + balances: query.data?.balances ?? null, + sync: query.data?.sync ?? null, + isLoading: query.isLoading, + error: query.error, + }; } export function useTransactions(walletId: number | null, limit = 50) { @@ -157,13 +168,19 @@ export function useTransactions(walletId: number | null, limit = 50) { const query = useQuery({ queryKey: [...ROOT_KEY, 'transactions', walletId, limit] as const, - queryFn: () => get<{ transactions: OnchainTx[] }>(`${base(walletId!)}/transactions?limit=${limit}`), + queryFn: () => + get<{ transactions: OnchainTx[]; sync: SyncState | null }>(`${base(walletId!)}/transactions?limit=${limit}`), enabled: walletId != null, refetchInterval: HISTORY_POLL_MS, staleTime: 15_000, }); - return { transactions: query.data?.transactions ?? EMPTY_TXS, isLoading: query.isLoading, error: query.error }; + return { + transactions: query.data?.transactions ?? EMPTY_TXS, + sync: query.data?.sync ?? null, + isLoading: query.isLoading, + error: query.error, + }; } export function useUtxos(walletId: number | null, enabled = true) { @@ -171,13 +188,18 @@ export function useUtxos(walletId: number | null, enabled = true) { const query = useQuery({ queryKey: [...ROOT_KEY, 'utxos', walletId] as const, - queryFn: () => get<{ utxos: Utxo[] }>(`${base(walletId!)}/utxos`), + queryFn: () => get<{ utxos: Utxo[]; sync: SyncState | null }>(`${base(walletId!)}/utxos`), enabled: walletId != null && enabled, refetchInterval: HISTORY_POLL_MS, staleTime: 15_000, }); - return { utxos: query.data?.utxos ?? EMPTY_UTXOS, isLoading: query.isLoading, error: query.error }; + return { + utxos: query.data?.utxos ?? EMPTY_UTXOS, + sync: query.data?.sync ?? null, + isLoading: query.isLoading, + error: query.error, + }; } export function useFees(walletId: number | null, enabled = true) { @@ -472,7 +494,7 @@ export function useWalletOperations(walletId: number | null) { * "[object Object]". Never called with anything that could contain a passphrase: the sidecar's error * bodies are messages and codes only. */ -function errorMessage(err: unknown, fallback: string): string { +export function errorMessage(err: unknown, fallback: string): string { const raw = typeof err === 'object' && err !== null && 'message' in err ? String(err.message) : ''; if (!raw) return fallback; try {