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:
@@ -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 wallet’s 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 wallet’s 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 wallet’s 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 };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user