recover an imported wallet's coins with an nbxplorer utxo scan

registering an xpub only indexes it from that moment on, so an imported
seed with history read as a confident zero: every call succeeded, the
coins were simply absent. scantxoutset walks the node's current utxo set
directly and finds them regardless of when the account was registered.

runs all four script variants sequentially — the funds could be on any
one — and surfaces progress through the existing SyncState channel so
the balance says "scanning" rather than nothing. auto-fires on an
imported mnemonic only; a generated seed has no history to look for.

recovers spendable coins, not spent history.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-04 20:06:10 +00:00
co-authored by Claude Opus 5
parent 82aa39a05f
commit 22c0e83b49
11 changed files with 502 additions and 21 deletions
@@ -61,6 +61,7 @@ import {
type NewAddressRequest,
type NodeInfo,
type OnchainTx,
type RescanState,
type SendCoinsRequest,
type SendCoinsResult,
type SignMessageResult,
@@ -312,9 +313,31 @@ export class OnchainBackend extends BaseBackend {
syncedAt: this.current?.at ?? null,
stale: this.current !== null && Date.now() - this.current.at >= SCAN_TTL_MS,
lastError: this.lastError,
rescan: this.chain.rescanState?.() ?? null,
};
}
/**
* Ask the chain source to go looking for this wallet's coins from scratch.
*
* Returns as soon as the search is queued — it takes minutes, and the caller is an HTTP route. The
* refresh chained onto its completion is the part that matters: a rescan that found coins has changed
* nothing visible until the snapshot in front of it is rebuilt, and without this the owner would be
* staring at the same zero until the TTL happened to expire.
*/
async startRescan(): Promise<RescanState> {
const start = this.chain.startRescan?.bind(this.chain);
if (!start) this.notSupported('rescanning the chain');
const { state, done } = start(this.scanContext().accounts);
void done
.then(() => this.refresh())
// The rescan's own failure is already on its state and reported through getSyncState; a failed
// refresh behind it lands on lastError the same way. Neither should surface as an unhandled
// rejection in the sidecar's log.
.catch(() => {});
return state;
}
/**
* The wallet's view of the chain, stale-while-revalidate.
*
@@ -6,16 +6,21 @@
// many addresses have been used. What that costs instead is reassembly — NBXplorer answers wallet-wide,
// so the per-address view the rest of the wallet expects has to be rebuilt from UTXO key paths.
//
// PRUNED-NODE CAVEAT, and it is a real one: this node is pruned to 25 GB, so a newly tracked xpub only
// picks up activity from the moment it is registered. History older than the prune horizon will not
// backfill and this source will report it as absent, not as an error. That is fine for a wallet created
// here and wrong for one being recovered from an old seed — which is exactly why Esplora stays available
// rather than being replaced.
// REGISTRATION IS NOT RECOVERY, and this is the trap: a newly tracked xpub only picks up activity from
// the moment it is registered. An imported seed therefore reads as a real, quiet, empty wallet — every
// call succeeds, nothing errors, and the coins are simply not in the index. `startRescan` below is the
// answer for balance and spending: `scantxoutset` walks the node's current UTXO set directly, so it finds
// coins regardless of when the account was registered or how far back the node is pruned.
//
// What a rescan does NOT recover is spent-transaction history predating registration; that is a block
// rescan, a much heavier thing, and this source will keep reporting it as absent rather than as an error.
// Esplora stays available for the owner who wants the full history back.
import * as bitcoin from 'bitcoinjs-lib';
import {
type AddressEntry,
type ChainIndex,
type RescanHandle,
type ScanAccount,
type ScanContext,
type ScannedAddress,
@@ -25,11 +30,29 @@ import {
import { derivationScheme, type NbxplorerChain, type NbxTransaction, type NbxUtxo } from './nbxplorer';
import type { SpendableUtxo } from './psbt';
import { networkFor } from './psbt';
import { BackendError, type AddressType, type BitcoinNetwork, type FeeEstimates, type OnchainTx } from './types';
import {
BackendError,
type AddressType,
type BitcoinNetwork,
type FeeEstimates,
type OnchainTx,
type RescanState,
} from './types';
/** Confirmation targets, in blocks, behind the five fee tiers the wallet reports. */
const FEE_TARGETS = { fastestFee: 1, halfHourFee: 3, hourFee: 6, economyFee: 25, minimumFee: 144 } as const;
/** How often to ask how a `scantxoutset` is going. Frequent enough that a finished result is never missed. */
const SCAN_POLL_MS = 2_000;
/** Per-variant ceiling. A scan of a large UTXO set is minutes; anything past this is a wedged node. */
const SCAN_TIMEOUT_MS = 10 * 60_000;
/** Consecutive 404s tolerated before a variant has ever reported a status — see `scanVariant`. */
const SCAN_NULL_GRACE = 5;
const sleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
export type NbxplorerChainSourceOptions = { chain: NbxplorerChain; network: BitcoinNetwork; label: string };
export class NbxplorerChainSource implements WalletChainSource {
@@ -47,6 +70,10 @@ export class NbxplorerChainSource implements WalletChainSource {
*/
private accounts: readonly ScanAccount[] = [];
/** The rescan in flight, or the last one's outcome. Mutated in place as the scan progresses. */
private rescan: RescanState | null = null;
private rescanRun: Promise<RescanState> | null = null;
constructor(opts: NbxplorerChainSourceOptions) {
this.chain = opts.chain;
this.btcNetwork = networkFor(opts.network);
@@ -132,6 +159,95 @@ export class NbxplorerChainSource implements WalletChainSource {
return { tipHeight, addresses, utxos, txs };
}
rescanState(): RescanState | null {
return this.rescan;
}
/**
* Run `scantxoutset` over every account variant, one at a time.
*
* ALL FOUR VARIANTS, ALWAYS. A recovered seed's coins can sit on any script type, and nothing here can
* tell which until the node has looked — the p2sh account of a wallet that only ever used native segwit
* costs one wasted scan, whereas skipping it costs the owner their money. The node serialises
* `scantxoutset` anyway, so scanning them sequentially is what happens regardless of what we ask for;
* doing it explicitly is what makes `done`/`total` mean something on screen.
*
* A second call while one is running returns the same handle rather than queueing a duplicate.
*/
startRescan(accounts: readonly ScanAccount[]): RescanHandle {
const existing = this.rescan;
if (existing?.running && this.rescanRun) return { state: existing, done: this.rescanRun };
const state: RescanState = {
running: true,
done: 0,
total: accounts.length,
current: accounts[0]?.type ?? null,
found: 0,
startedAt: Date.now(),
finishedAt: null,
error: null,
};
this.rescan = state;
this.rescanRun = this.runRescan(accounts, state);
return { state, done: this.rescanRun };
}
private async runRescan(accounts: readonly ScanAccount[], state: RescanState): Promise<RescanState> {
try {
for (const account of accounts) {
state.current = account.type;
state.found += await this.scanVariant(account);
state.done += 1;
}
} catch (err) {
// One variant failing stops the run, and the partial `found` stays on the state: coins already
// pulled in by an earlier variant are really there, and reporting zero would understate the wallet.
state.error = err instanceof Error ? err.message : String(err);
} finally {
state.running = false;
state.current = null;
state.finishedAt = Date.now();
}
return state;
}
/** Queue one variant's scan and wait it out. Resolves with how many UTXOs it pulled in. */
private async scanVariant(account: ScanAccount): Promise<number> {
const { accountXpub, type } = account;
await this.chain.startUtxoScan(accountXpub, type);
const deadline = Date.now() + SCAN_TIMEOUT_MS;
let found = 0;
let seen = false;
let nulls = 0;
while (Date.now() < deadline) {
await sleep(SCAN_POLL_MS);
const status = await this.chain.getUtxoScanStatus(accountXpub, type);
// 404 means "no scan is running" — which before the first sighting is a race with the queue, and
// after it means the result has expired. Only the second reading is a finish, hence the flag; the
// grace bounds the first so a scan the node silently dropped does not hold the run for ten minutes.
if (!status) {
if (seen) return found;
if (++nulls >= SCAN_NULL_GRACE) return found;
continue;
}
seen = true;
nulls = 0;
found = status.progress?.found ?? found;
if (status.status === 'Complete') return found;
if (status.status === 'Error') {
throw new BackendError(`utxo scan of the ${type} account failed: ${status.error ?? 'unknown'}`, 502, 'SCAN');
}
}
throw new BackendError(`utxo scan of the ${type} account did not finish in time`, 504, 'SCAN_TIMEOUT');
}
private async scanAccount(ctx: ScanContext, account: ScanAccount) {
const { accountXpub, type } = account;
// track() is idempotent and every one of these calls makes it first, so registration cannot be
+23 -1
View File
@@ -16,7 +16,7 @@
// about which addresses are the wallet's, which is the kind of bug that loses coins rather than failing.
import type { SpendableUtxo } from './psbt';
import type { AddressType, FeeEstimates, OnchainTx } from './types';
import type { AddressType, FeeEstimates, OnchainTx, RescanState } from './types';
/** BIP44 chain index: 0 is the receive chain, 1 the internal (change) chain. */
export type ChainIndex = 0 | 1;
@@ -69,6 +69,15 @@ export type ScanContext = {
derive: (type: AddressType, chain: ChainIndex, index: number) => AddressEntry;
};
/**
* A rescan in flight.
*
* `state` is the SAME object the source keeps and mutates as the scan progresses, so a caller that holds
* it sees the counters move without asking again. `done` is how the wallet knows to re-read the chain:
* a rescan that found coins has changed nothing until the snapshot behind it is rebuilt.
*/
export type RescanHandle = { state: RescanState; done: Promise<RescanState> };
/** One coherent read of the chain — balances, coins and history as of the same moment. */
export type ScanResult = {
tipHeight: number;
@@ -103,6 +112,19 @@ export interface WalletChainSource {
/** Read the whole wallet off the chain. */
scan(ctx: ScanContext): Promise<ScanResult>;
/**
* Search the chain for this wallet's coins from scratch, rather than from whenever the upstream
* started watching it. Returns immediately — a rescan takes minutes, and the caller is an HTTP route.
*
* Optional, and Esplora does not implement it — it has nothing to rescan, because a gap-limit walk
* already asks about every address every time. This exists for an upstream that *indexes*, where a
* newly registered account starts empty and stays empty until told to go and look.
*/
startRescan?(accounts: readonly ScanAccount[]): RescanHandle;
/** The rescan in flight, or the last one's outcome. Null when this source has never run one. */
rescanState?(): RescanState | null;
}
/** Bounded-concurrency map that preserves input order. Address-level sources fan out wide. */
+107 -12
View File
@@ -113,6 +113,40 @@ export type NbxAddress = {
redeem?: string | null;
};
/**
* `GET …/utxos/scan` — how a `scantxoutset` sweep is going.
*
* Every field below `status` is optional on purpose: NBXplorer fills `progress` only once the node has
* actually started, and a queued scan reports nothing but its place in the queue.
*/
export type NbxScanStatus = {
status: 'Queued' | 'Pending' | 'Complete' | 'Error';
error?: string | null;
queuedAt?: string;
progress?: {
startedAt?: string;
completedAt?: string | null;
/** UTXOs pulled in. This is the number that answers "did the scan find my coins". */
found?: number;
batchNumber?: number;
remainingBatches?: number;
currentBatchProgress?: number;
overallProgress?: number;
remainingSeconds?: number;
highestKeyIndexFound?: Partial<Record<DerivationFeature, number | null>>;
} | null;
};
/**
* `POST …/utxos/scan` parameters.
*
* The defaults are the values the node operator verified live. `gapLimit` 1000 is generous for any
* wallet that has not used addresses beyond index 1000; raising it costs node CPU, not correctness.
*/
export type UtxoScanOptions = { batchSize?: number; gapLimit?: number; from?: number };
const SCAN_DEFAULTS = { batchSize: 1000, gapLimit: 1000, from: 0 } as const;
/** A failed broadcast comes back as HTTP 200 with `success: false` — never as an HTTP error. */
export type NbxBroadcastResult = {
success: boolean;
@@ -153,7 +187,7 @@ export class NbxplorerChain {
}
/** Every request funnels through here, so a timeout and an upstream failure share one error shape. */
private async raw(path: string, init: RequestInitLite = {}): Promise<string> {
private async send(path: string, init: RequestInitLite): Promise<{ status: number; body: string }> {
const url = `${this.baseUrl}${path.startsWith('/') ? path : `/${path}`}`;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), this.timeoutMs);
@@ -173,23 +207,48 @@ export class NbxplorerChain {
clearTimeout(timer);
}
const body = await res.text();
if (!res.ok) {
// NBXplorer answers with {"code","message"} where it can, and bare text where it cannot.
let message = body.slice(0, 300);
try {
const parsed = JSON.parse(body) as { message?: unknown };
if (typeof parsed.message === 'string' && parsed.message) message = parsed.message;
} catch {
/* not its JSON envelope */
}
throw new BackendError(`nbxplorer ${res.status} on ${path}: ${message}`, res.status, 'NBXPLORER_ERROR');
return { status: res.status, body: await res.text() };
}
private fail(path: string, status: number, body: string): never {
// NBXplorer answers with {"code","message"} where it can, and bare text where it cannot.
let message = body.slice(0, 300);
try {
const parsed = JSON.parse(body) as { message?: unknown };
if (typeof parsed.message === 'string' && parsed.message) message = parsed.message;
} catch {
/* not its JSON envelope */
}
throw new BackendError(`nbxplorer ${status} on ${path}: ${message}`, status, 'NBXPLORER_ERROR');
}
private async raw(path: string, init: RequestInitLite = {}): Promise<string> {
const { status, body } = await this.send(path, init);
if (status < 200 || status >= 300) this.fail(path, status, body);
return body;
}
private async json<T>(path: string, init?: RequestInitLite): Promise<T> {
const body = await this.raw(path, init);
return this.parse<T>(path, body);
}
/**
* Like `json`, but a 404 is an answer rather than a failure.
*
* Only the scan-status endpoint needs this, and it needs it badly: NBXplorer 404s both when no scan is
* running and once a finished scan's result has expired. Treating that as an error would turn the two
* most ordinary moments of a rescan — before it starts, and a while after it ends — into upstream
* failures on the wallet screen.
*/
private async jsonOrNull<T>(path: string, init?: RequestInitLite): Promise<T | null> {
const { status, body } = await this.send(path, init ?? {});
if (status === 404) return null;
if (status < 200 || status >= 300) this.fail(path, status, body);
return this.parse<T>(path, body);
}
private parse<T>(path: string, body: string): T {
try {
return JSON.parse(body) as T;
} catch {
@@ -277,6 +336,42 @@ export class NbxplorerChain {
return hex;
}
/**
* `POST …/utxos/scan` — sweep the node's whole UTXO set for this account's coins.
*
* WHY THIS EXISTS AT ALL. Registering a scheme only makes NBXplorer index it *from now on*. An
* imported xpub with a history therefore reads as a real, quiet, zero-balance wallet: every call
* succeeds, nothing errors, and the coins are simply not there. This endpoint is the fix — it runs
* bitcoind's `scantxoutset`, which walks the current UTXO set directly and finds coins regardless of
* when the account was registered or how far back the node is pruned.
*
* It finds spendable coins, NOT history. Spent-transaction history predating registration stays
* missing; recovering that is a block rescan, which is a different and much heavier thing.
*
* Returns as soon as the scan is queued. `scantxoutset` is single-threaded and IO-heavy on the node,
* so concurrent scans queue and run one after another — poll `getUtxoScanStatus` for the outcome.
*/
async startUtxoScan(accountXpub: string, type: AddressType, opts: UtxoScanOptions = {}): Promise<void> {
await this.track(accountXpub, type);
const q = new URLSearchParams({
batchSize: String(opts.batchSize ?? SCAN_DEFAULTS.batchSize),
gapLimit: String(opts.gapLimit ?? SCAN_DEFAULTS.gapLimit),
from: String(opts.from ?? SCAN_DEFAULTS.from),
});
await this.raw(`${this.root}/derivations/${this.scheme(accountXpub, type)}/utxos/scan?${q}`, { method: 'POST' });
}
/**
* `GET …/utxos/scan` — the running scan, or null.
*
* Null means "nothing to report": no scan is running, or one finished long enough ago that NBXplorer
* has dropped the result. Both are 404s and neither is a failure, so poll promptly and read a null
* after a `Complete` as "it is over", not as "it vanished".
*/
getUtxoScanStatus(accountXpub: string, type: AddressType): Promise<NbxScanStatus | null> {
return this.jsonOrNull<NbxScanStatus>(`${this.root}/derivations/${this.scheme(accountXpub, type)}/utxos/scan`);
}
/** `GET /v1/cryptos/BTC/fees/{blockCount}` — one target per call, sat/vB as a float. */
async getFeeRate(blockCount: number): Promise<number> {
const res = await this.json<{ feeRate: number; blockCount: number }>(`${this.root}/fees/${blockCount}`);
+33
View File
@@ -273,6 +273,18 @@ async function handleWallets(ctx: OfficerContext, seg: string[]): Promise<Respon
case 'utxos':
return await utxosRoute(ctx, walletId, backend, rest.slice(1));
// POST starts a deep rescan, GET reads how it is going. Both answer with the same `rescan` block that
// rides on balances/transactions/utxos, so the UI has one shape to render and can poll whichever it
// was already polling. A backend with nothing to rescan is a clean 501 rather than a silent no-op.
case 'rescan': {
if (ctx.req.method === 'GET') return json({ rescan: syncOf(backend)?.rescan ?? null });
if (ctx.req.method !== 'POST') return json({ error: 'method not allowed' }, 405);
if (!backend.startRescan) {
throw new BackendError('this wallet has nothing to rescan', 501, 'NOT_SUPPORTED');
}
return json({ rescan: await backend.startRescan() });
}
case 'fees':
return json({ fees: await backend.estimateFees() });
@@ -430,12 +442,33 @@ async function createWalletRoute(ctx: OfficerContext): Promise<Response> {
makeActive: b.makeActive ?? true,
});
// An IMPORTED seed is the one case that needs the chain searched from scratch: an indexing upstream
// only watches an account from the moment it is registered, so a wallet with a history would otherwise
// show a confident, wrong zero. A freshly generated seed has no history to find, and a rescan for it
// would burn several minutes of the node's CPU to confirm nothing.
if (b.mnemonic) void kickOffRescan(ctx.userId, wallet.id);
// Return the mnemonic exactly once, and ONLY when we generated it — the owner has to write it down and
// has no other chance to see it without re-entering the passphrase. An imported mnemonic is never
// echoed back: the caller already has it, and echoing would put it in a response log for no reason.
return json({ wallet, mnemonic: b.mnemonic ? undefined : mnemonic }, 201);
}
/**
* Start a rescan behind the response that created the wallet.
*
* Never throws: a chain source that cannot rescan, or an upstream that is down, must not turn a
* successful wallet import into a failed one. The owner can trigger it by hand from Settings either way.
*/
async function kickOffRescan(userId: number, walletId: number): Promise<void> {
try {
const { backend } = await resolveBackend(userId, walletId);
await backend.startRescan?.();
} catch (err) {
console.error('[wallet] rescan after import failed to start:', err instanceof Error ? err.message : err);
}
}
async function deleteWalletRoute(ctx: OfficerContext, walletId: number): Promise<Response> {
const wallet = await getWallet(ctx.userId, walletId);
if (!wallet) return json({ error: 'wallet not found' }, 404);
+37
View File
@@ -298,6 +298,15 @@ export interface WalletBackend {
* be inventing one.
*/
getSyncState?(): SyncState;
/**
* Ask the chain to search for this wallet's coins from scratch. Resolves as soon as the search is
* queued, not when it finishes — progress is read back through `getSyncState().rescan`.
*
* Optional because only a wallet-level chain source can do it. Absent means the backend has nothing to
* rescan (a node backend already knows its own coins) or the source has no such endpoint.
*/
startRescan?(): Promise<RescanState>;
}
/**
@@ -312,6 +321,34 @@ export type SyncState = {
stale: boolean;
/** The last refresh failure, still reported while the previous good data is being served. */
lastError: string | null;
/** A deep rescan in flight, or the outcome of the last one. Null from a source that cannot rescan. */
rescan: RescanState | null;
};
/**
* A deep chain rescan — the upstream searching the whole UTXO set for a wallet's coins, rather than
* indexing forward from the moment the wallet was registered.
*
* This exists for exactly one situation, and it is not a rare one: an imported seed on a node that only
* started watching the xpub today. Every read succeeds, nothing errors, and the balance is zero — which
* is indistinguishable from an empty wallet unless the UI can say "still looking". So the state is
* carried on SyncState beside `syncedAt`, on the same responses, for the same reason.
*
* It runs one account variant at a time because the node runs `scantxoutset` one at a time; four
* variants is minutes, not seconds.
*/
export type RescanState = {
running: boolean;
/** Account variants finished, out of how many were queued. */
done: number;
total: number;
/** The variant the node is chewing on right now. Null when nothing is running. */
current: AddressType | null;
/** UTXOs the finished variants pulled in. */
found: number;
startedAt: number;
finishedAt: number | null;
error: string | null;
};
// ── errors ───────────────────────────────────────────────────────────────────────────────────────
@@ -0,0 +1,91 @@
import { CheckCircle2, Loader2, Search, TriangleAlert } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { ADDRESS_TYPE_LABELS } from './shared';
import { useBalances, useWalletOperations } from './useWalletData';
// Search the chain for coins this wallet already owns.
//
// THIS IS THE FIX FOR AN IMPORTED WALLET THAT READS ZERO, and that symptom is why the card exists at all.
// An indexing chain source (NBXplorer in front of your own node) only watches an account from the moment
// you register it, so a seed with a history arrives at a balance of nothing: every call succeeds, no error
// is raised, the coins are simply not in the index yet. A scan walks the node's current UTXO set directly
// and finds them regardless of when the account was registered or how far back the node is pruned.
//
// It recovers SPENDABLE COINS, not history. Transactions spent before the account was registered stay
// missing — that needs a full block rescan, which is a heavier thing this does not do.
//
// Hidden entirely when the chain source cannot rescan: Esplora asks about every address on every refresh,
// so it has nothing to catch up on, and offering a button that 501s would invent a problem.
type RescanCardProps = { walletId: number };
export const RescanCard = ({ walletId }: RescanCardProps) => {
const { sync } = useBalances(walletId);
const { rescan } = useWalletOperations(walletId);
// `sync.rescan` is null from a source with no rescan endpoint AND from one that has simply never run
// one, so the card has to stay visible in the second case. `sync` itself being null is a node backend,
// which owns its own coins and has nothing to look for.
if (!sync) return null;
const state = sync.rescan;
const running = state?.running === true;
return (
<section className="max-w-2xl rounded-xl border border-border p-4">
<h3 className="mb-2 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
Find missing coins
</h3>
<p className="mb-3 text-xs text-muted-foreground">
Ask the node to search its whole UTXO set for this wallets coins. Run this after importing a seed that already
had funds an indexing node only watches an account from the moment it is added, so an older balance shows as
zero until it has looked. It scans all four address types, one at a time, and takes a few minutes.
</p>
<Button size="sm" variant="outline" onClick={() => rescan.mutate()} disabled={running || rescan.isPending}>
{running || rescan.isPending ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
) : (
<Search className="mr-1.5 h-3.5 w-3.5" />
)}
{running ? 'Scanning…' : 'Scan the chain'}
</Button>
{state && <Status state={state} />}
</section>
);
};
type StatusProps = { state: NonNullable<ReturnType<typeof useBalances>['sync']>['rescan'] };
const Status = ({ state }: StatusProps) => {
if (!state) return null;
if (state.running) {
const label = state.current ? ADDRESS_TYPE_LABELS[state.current] : 'queued';
return (
<p className="mt-2 text-xs text-amber-600 dark:text-amber-500">
Scanning {label} {state.done} of {state.total} done
{state.found > 0 && `, ${state.found} coin${state.found === 1 ? '' : 's'} found so far`}.
</p>
);
}
if (state.error) {
return (
<p className="mt-2 flex items-start gap-1.5 text-xs text-destructive">
<TriangleAlert className="mt-0.5 h-3.5 w-3.5 shrink-0" />
<span>
Stopped after {state.done} of {state.total}: {state.error}
</span>
</p>
);
}
return (
<p className="mt-2 flex items-center gap-1.5 text-xs text-emerald-500">
<CheckCircle2 className="h-3.5 w-3.5 shrink-0" />
Last scan found {state.found} coin{state.found === 1 ? '' : 's'} across {state.total} address types.
</p>
);
};
@@ -38,6 +38,22 @@ export const SyncBadge = ({ sync, className }: SyncBadgeProps) => {
// would be inventing a fact.
if (!sync) return null;
// A rescan outranks everything else here, including a stale age and an upstream error. While the node
// is searching, the number beside this badge is not "your balance" — it is "your balance so far", and
// that difference is the entire reason an imported wallet showing zero is alarming rather than boring.
const { rescan } = sync;
if (rescan?.running) {
return (
<span
className={`inline-flex items-center gap-1 text-[11px] text-amber-600 dark:text-amber-500 ${className ?? ''}`}
title="The node is searching its whole UTXO set for this wallets coins. Balances stay incomplete until it finishes."
>
<RefreshCw className="h-3 w-3 animate-spin" />
Scanning the chain · {Math.min(rescan.done + 1, rescan.total)} of {rescan.total}
</span>
);
}
const failing = sync.lastError !== null;
// Never read successfully AND failing: the only case where the numbers beside this are not real. Say so
@@ -9,6 +9,7 @@ import { ChainSourceCard } from './ChainSourceCard';
import { CopyField } from './CopyField';
import { EmptyWallet } from './EmptyWallet';
import { LockBadge } from './LockBadge';
import { RescanCard } from './RescanCard';
import { useSelectedWallet } from './useSelectedWallet';
import { useLockCountdown } from './useLockCountdown';
import { useCapabilities, useWalletConfig, useWalletLifecycle } from './useWalletData';
@@ -132,6 +133,8 @@ export const WalletSettingsView = () => {
<ChainSourceCard />
<RescanCard walletId={walletId} />
{config && (
<section className="max-w-2xl rounded-xl border border-border p-4">
<h3 className="mb-2 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">Deployment</h3>
@@ -123,10 +123,38 @@ export type SyncState = {
syncedAt: number | null;
stale: boolean;
lastError: string | null;
/** A deep rescan in flight, or the last one's outcome. Null from a chain source that cannot rescan. */
rescan: RescanState | null;
};
/**
* The node searching its whole UTXO set for this wallet's coins.
*
* Only ever non-null on NBXplorer, and it exists because an imported wallet on a freshly-tracked xpub
* reads as a confident zero: nothing errors, the balance is just empty until the node has looked. Four
* script variants, one at a time, minutes not seconds — which is precisely why it has to be on screen.
*/
export type RescanState = {
running: boolean;
done: number;
total: number;
current: AddressType | null;
found: number;
startedAt: number;
finishedAt: number | null;
error: string | null;
};
export type AddressType = 'p2wpkh' | 'p2tr' | 'p2sh-p2wpkh' | 'p2pkh';
/** What each script type is actually called out loud, for anything the owner reads rather than greps. */
export const ADDRESS_TYPE_LABELS: Record<AddressType, string> = {
p2wpkh: 'native segwit',
'p2sh-p2wpkh': 'wrapped segwit',
p2pkh: 'legacy',
p2tr: 'taproot',
};
export type OnchainTx = {
txid: string;
/** Net effect on this wallet in sats — negative for a spend. */
@@ -11,6 +11,7 @@ import type {
OnchainTx,
Payment,
Peer,
RescanState,
SendCoinsResult,
SyncState,
Utxo,
@@ -42,6 +43,8 @@ const BALANCE_POLL_MS = 20_000;
const LOCK_POLL_MS = 10_000;
/** History and coins are cheaper to refresh on demand than to poll hard. */
const HISTORY_POLL_MS = 60_000;
/** While a rescan runs, the balance response doubles as its progress feed — poll it like one. */
const RESCAN_POLL_MS = 4_000;
const EMPTY_WALLETS: WalletSummary[] = [];
const EMPTY_CAPS: Capability[] = [];
@@ -151,7 +154,9 @@ export function useBalances(walletId: number | null) {
queryKey: [...ROOT_KEY, 'balances', walletId] as const,
queryFn: () => get<{ balances: Balances; sync: SyncState | null }>(`${base(walletId!)}/balances`),
enabled: walletId != null,
refetchInterval: BALANCE_POLL_MS,
// Faster while the node is rescanning: this response carries the rescan's progress, and a counter
// that moves once every twenty seconds reads as a hung one. Back to the ordinary poll when it ends.
refetchInterval: (query) => (query.state.data?.sync?.rescan?.running ? RESCAN_POLL_MS : BALANCE_POLL_MS),
staleTime: BALANCE_POLL_MS - 1_000,
});
@@ -510,7 +515,19 @@ export function useWalletOperations(walletId: number | null) {
onError: (err) => toast.error(errorMessage(err, 'Could not save the label')),
});
return { send, freeze, createInvoice, decode, pay, keysend, sign, verify, setLabel };
// Returns as soon as the scan is queued — it takes minutes. Progress arrives on the `sync.rescan` block
// of the balances poll, which speeds up on its own while one is running, so the invalidation here is
// only to put the first "scanning…" on screen without waiting out the current interval.
const rescan = useMutation({
mutationFn: () => post<{ rescan: RescanState }>(`${base(walletId!)}/rescan`, {}),
onSuccess: () => {
qc.invalidateQueries({ queryKey: [...ROOT_KEY, 'balances', walletId] });
toast.success('Scanning the chain for this wallets coins — this takes a few minutes');
},
onError: (err) => toast.error(errorMessage(err, 'Could not start the scan')),
});
return { send, freeze, createInvoice, decode, pay, keysend, sign, verify, setLabel, rescan };
}
/**