123 lines
5.3 KiB
TypeScript
123 lines
5.3 KiB
TypeScript
import { useState } from 'react';
|
||
import { CheckCircle2, Loader2, Search, TriangleAlert } from 'lucide-react';
|
||
import { Button } from '@/components/ui/button';
|
||
import { Input } from '@/components/ui/input';
|
||
import { toneText } from '@/components/Data/tone';
|
||
import { ADDRESS_TYPE_LABELS, CHAIN_SYNC_TONE } 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);
|
||
const [gapLimit, setGapLimit] = useState('');
|
||
|
||
// `sync` null is a node backend, which owns its own coins and has nothing to look for. `canRescan`
|
||
// false is Esplora. Note this cannot key off `sync.rescan` being null — that is equally what NBXplorer
|
||
// reports before its first scan, which is exactly when the card is most needed.
|
||
if (!sync?.canRescan) return null;
|
||
|
||
const state = sync.rescan;
|
||
const running = state?.running === true;
|
||
const parsed = Number(gapLimit);
|
||
const depth = gapLimit.trim() && Number.isInteger(parsed) && parsed > 0 ? parsed : null;
|
||
|
||
const submit = (ev?: React.FormEvent) => {
|
||
ev?.preventDefault();
|
||
if (running || rescan.isPending) return;
|
||
rescan.mutate(depth ? { gapLimit: depth } : {});
|
||
};
|
||
|
||
return (
|
||
<form onSubmit={submit} 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>
|
||
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<Button type="submit" size="sm" variant="outline" 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>
|
||
|
||
{/* The one thing about a scan only the owner can know: how many addresses their old wallet handed
|
||
out and never had paid. 1000 covers anyone who clicked "new address" by hand; a wallet that
|
||
issued them in bulk needs more, and no amount of looking from here can tell which it was. */}
|
||
<label className="flex items-center gap-1.5 text-[11px] text-muted-foreground">
|
||
search depth
|
||
<Input
|
||
type="number"
|
||
min={1}
|
||
max={100000}
|
||
value={gapLimit}
|
||
placeholder="1000"
|
||
disabled={running}
|
||
onChange={(ev) => setGapLimit(ev.target.value)}
|
||
className="h-7 w-24 text-xs"
|
||
/>
|
||
</label>
|
||
</div>
|
||
|
||
{state && <Status state={state} />}
|
||
</form>
|
||
);
|
||
};
|
||
|
||
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 ${toneText[CHAIN_SYNC_TONE.scanning]}`}>
|
||
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 ${toneText[CHAIN_SYNC_TONE.failed]}`}>
|
||
<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 ${toneText[CHAIN_SYNC_TONE.ok]}`}>
|
||
<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>
|
||
);
|
||
};
|