a rescan can be told how deep to look, and a node wallet cannot be deleted blind
the gap limit was 1000, hardcoded, with the comment noting that raising it costs node CPU and not correctness — and no way to raise it. it is the one thing about a scan only the owner can know: how many addresses their old wallet handed out and never had paid. RescanOptions threads from the POST body through the backend and the source to utxos/scan, capped at 100k because past that the scan takes longer than anyone waits. the card gets a "search depth" field beside the button, blank meaning the default. deleting a wallet with no seed took one unconfirmed request. the dialog asked for the wallet's name and then threw the answer away, so the check existed only for whoever went through the dialog — a node wallet still holds the credential, the labels and the freezes. confirmName now travels with the request and the route enforces it. a bodyless DELETE is told which field is missing rather than that its JSON did not parse. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
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 { ADDRESS_TYPE_LABELS } from './shared';
|
||||
import { useBalances, useWalletOperations } from './useWalletData';
|
||||
|
||||
@@ -22,6 +24,7 @@ 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
|
||||
@@ -30,6 +33,8 @@ export const RescanCard = ({ walletId }: RescanCardProps) => {
|
||||
|
||||
const state = sync.rescan;
|
||||
const running = state?.running === true;
|
||||
const parsed = Number(gapLimit);
|
||||
const depth = gapLimit.trim() && Number.isInteger(parsed) && parsed > 0 ? parsed : null;
|
||||
|
||||
return (
|
||||
<section className="max-w-2xl rounded-xl border border-border p-4">
|
||||
@@ -42,14 +47,38 @@ export const RescanCard = ({ walletId }: RescanCardProps) => {
|
||||
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>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => rescan.mutate(depth ? { gapLimit: depth } : {})}
|
||||
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} />}
|
||||
</section>
|
||||
|
||||
@@ -16,7 +16,9 @@ import { useWalletLifecycle } from '../useWalletData';
|
||||
|
||||
// Delete a wallet. For a seeded wallet this destroys the only copy of the key material Officer holds, so
|
||||
// the sidecar demands the passphrase even when the wallet is currently unlocked — an open session must not
|
||||
// be enough to erase a seed. Typing the wallet's name is the second, local check against the wrong row.
|
||||
// be enough to erase a seed. Typing the wallet's name is the second check against the wrong row, and it is
|
||||
// sent rather than merely enforced here: a wallet with no seed has no passphrase gate, so without it the
|
||||
// only thing standing between a node wallet and deletion was a dialog anything could route around.
|
||||
|
||||
type DeleteWalletDialogProps = {
|
||||
open: boolean;
|
||||
@@ -44,7 +46,11 @@ export const DeleteWalletDialog = ({ open, onOpenChange, wallet, onDeleted }: De
|
||||
ev.preventDefault();
|
||||
if (!canSubmit) return;
|
||||
try {
|
||||
await remove.mutateAsync({ walletId: wallet.id, passphrase: wallet.hasSeed ? passphrase : undefined });
|
||||
await remove.mutateAsync({
|
||||
walletId: wallet.id,
|
||||
confirmName: confirmName.trim(),
|
||||
passphrase: wallet.hasSeed ? passphrase : undefined,
|
||||
});
|
||||
close();
|
||||
onDeleted?.();
|
||||
} finally {
|
||||
|
||||
@@ -369,8 +369,16 @@ export function useWalletLifecycle() {
|
||||
const remove = useMutation({
|
||||
// The passphrase is required for a seeded wallet even when it is already unlocked — an open session
|
||||
// must not be enough to destroy the only copy of the key material.
|
||||
mutationFn: ({ walletId, passphrase }: { walletId: number; passphrase?: string }) =>
|
||||
del<{ ok: true }>(`/wallet/_officer/wallets/${walletId}`, passphrase ? { passphrase } : {}),
|
||||
mutationFn: ({
|
||||
walletId,
|
||||
passphrase,
|
||||
confirmName,
|
||||
}: {
|
||||
walletId: number;
|
||||
passphrase?: string;
|
||||
confirmName: string;
|
||||
}) =>
|
||||
del<{ ok: true }>(`/wallet/_officer/wallets/${walletId}`, { confirmName, ...(passphrase ? { passphrase } : {}) }),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ROOT_KEY });
|
||||
toast.success('Wallet deleted');
|
||||
@@ -519,7 +527,8 @@ export function useWalletOperations(walletId: number | null) {
|
||||
// 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`, {}),
|
||||
mutationFn: (input: { gapLimit?: number } = {}) =>
|
||||
post<{ rescan: RescanState }>(`${base(walletId!)}/rescan`, input),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: [...ROOT_KEY, 'balances', walletId] });
|
||||
toast.success('Scanning the chain for this wallet’s coins — this takes a few minutes');
|
||||
|
||||
Reference in New Issue
Block a user