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 }),
|
||||
});
|
||||
|
||||
@@ -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<T>(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<void>;
|
||||
recordError(message: string): Promise<void>;
|
||||
}
|
||||
|
||||
// ── 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<string, ScannedAddress>;
|
||||
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<Record<AddressType, string>>;
|
||||
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<string, number>();
|
||||
|
||||
private scanCache: WalletScan | null = null;
|
||||
private scanInflight: Promise<WalletScan> | null = null;
|
||||
private readonly cache: ChainCacheStore | null;
|
||||
|
||||
private current: Snapshot | null = null;
|
||||
private inflight: Promise<Snapshot> | null = null;
|
||||
/** Memoised so concurrent first reads do one cache load between them, not one each. */
|
||||
private hydration: Promise<void> | 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<WalletScan> {
|
||||
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<Snapshot> {
|
||||
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<void> {
|
||||
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<Snapshot> {
|
||||
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<void> {
|
||||
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<string, ScannedAddress>();
|
||||
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<Snapshot> {
|
||||
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<WalletScan> {
|
||||
private async runScan(): Promise<Omit<Snapshot, 'utxos' | 'txs'>> {
|
||||
const tipHeight = await this.chain.getTipHeight();
|
||||
const addresses: ScannedAddress[] = [];
|
||||
|
||||
@@ -394,10 +564,10 @@ export class OnchainBackend extends BaseBackend {
|
||||
}
|
||||
|
||||
override async getBalances(): Promise<Balances> {
|
||||
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<Utxo[]> {
|
||||
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<OnchainTx[]> {
|
||||
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<Snapshot, 'utxos' | 'txs'>): Promise<OnchainTx[]> {
|
||||
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<Snapshot, 'utxos' | 'txs'>): 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<SpendableUtxo[]> {
|
||||
private async collectUtxos(scan: Omit<Snapshot, 'utxos' | 'txs'>): Promise<SpendableUtxo[]> {
|
||||
// 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<AddressEntry> {
|
||||
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<number>();
|
||||
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
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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<T>(req: Request): Promise<T> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<Respon
|
||||
return json({ info: await backend.getInfo() });
|
||||
|
||||
case 'balances':
|
||||
return json({ balances: await backend.getBalances() });
|
||||
return json({ balances: await backend.getBalances(), sync: syncOf(backend) });
|
||||
|
||||
case 'transactions': {
|
||||
const limit = Number(ctx.url.searchParams.get('limit') ?? 50);
|
||||
@@ -227,7 +239,10 @@ async function handleWallets(ctx: OfficerContext, seg: string[]): Promise<Respon
|
||||
// Overlay owner labels, which live in Officer's DB rather than any backend.
|
||||
const labels = await getWalletLabels(walletId);
|
||||
const byRef = new Map(labels.filter((l) => 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 ─────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -291,8 +291,29 @@ export interface WalletBackend {
|
||||
|
||||
signMessage(message: string): Promise<SignMessageResult>;
|
||||
verifyMessage(message: string, signature: string): Promise<VerifyMessageResult>;
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
|
||||
@@ -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 <EmptyWallet isLoading={isLoading} />;
|
||||
@@ -37,6 +38,7 @@ export const CoinsView = () => {
|
||||
{utxos.length} coin{utxos.length === 1 ? '' : 's'} · <Amount sats={spendableTotal} /> spendable
|
||||
</span>
|
||||
<span className="flex-1" />
|
||||
<SyncBadge sync={sync} />
|
||||
{selected.length > 0 && (
|
||||
<>
|
||||
<span>
|
||||
@@ -63,8 +65,13 @@ export const CoinsView = () => {
|
||||
</div>
|
||||
) : utxos.length === 0 ? (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-1 p-8 text-center">
|
||||
<p className="text-sm font-medium">No coins</p>
|
||||
<p className="text-xs text-muted-foreground">Receive something and it will show up here as a UTXO.</p>
|
||||
{/* "No coins" is only true if the chain was actually read. Never-synced is a different fact. */}
|
||||
<p className="text-sm font-medium">{sync && sync.syncedAt === null ? 'Coins unavailable' : 'No coins'}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{sync && sync.syncedAt === null
|
||||
? (sync.lastError ?? 'The chain source could not be reached.')
|
||||
: 'Receive something and it will show up here as a UTXO.'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="divide-y divide-border">
|
||||
|
||||
@@ -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,8 +38,11 @@ export const OverviewView = () => {
|
||||
{info?.version && ` · ${info.version}`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<SyncBadge sync={sync} />
|
||||
<UnitToggle />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{balancesLoading && !balances ? (
|
||||
<div className="flex items-center gap-2 py-8 text-sm text-muted-foreground">
|
||||
|
||||
@@ -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 (
|
||||
<span
|
||||
className={`inline-flex items-center gap-1 text-[11px] text-destructive ${className ?? ''}`}
|
||||
title={sync.lastError ?? undefined}
|
||||
>
|
||||
<AlertTriangle className="h-3 w-3" />
|
||||
Could not reach the chain
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const age = relativeAge(sync.syncedAt, now);
|
||||
|
||||
if (failing) {
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center gap-1 text-[11px] text-amber-600 dark:text-amber-500 ${className ?? ''}`}
|
||||
title={sync.lastError ?? undefined}
|
||||
>
|
||||
<AlertTriangle className="h-3 w-3" />
|
||||
{age} · chain unreachable
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1 text-[11px] text-muted-foreground ${className ?? ''}`}>
|
||||
{sync.stale ? <RefreshCw className="h-3 w-3 animate-spin" /> : <Check className="h-3 w-3" />}
|
||||
{sync.stale ? `${age} · refreshing` : age}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
@@ -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 <EmptyWallet isLoading={isLoading} />;
|
||||
|
||||
@@ -24,22 +25,34 @@ 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 (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-1 p-8 text-center">
|
||||
<p className="text-sm font-medium">Nothing here yet</p>
|
||||
<p className="text-xs text-muted-foreground">Transactions appear as soon as they hit the mempool.</p>
|
||||
<p className="text-sm font-medium">{neverRead ? 'History unavailable' : 'Nothing here yet'}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{neverRead
|
||||
? (sync.lastError ?? 'The chain source could not be reached.')
|
||||
: 'Transactions appear as soon as they hit the mempool.'}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-y-auto">
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex shrink-0 items-center justify-end border-b border-border px-4 py-1.5">
|
||||
<SyncBadge sync={sync} />
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<ul className="divide-y divide-border">
|
||||
{transactions.map((tx) => (
|
||||
<TransactionRow key={tx.txid} tx={tx} />
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user