diff --git a/src/servers/sidecar/wallet/backends/onchain.ts b/src/servers/sidecar/wallet/backends/onchain.ts
index 32902a41..1f86e329 100644
--- a/src/servers/sidecar/wallet/backends/onchain.ts
+++ b/src/servers/sidecar/wallet/backends/onchain.ts
@@ -61,6 +61,7 @@ import {
type NewAddressRequest,
type NodeInfo,
type OnchainTx,
+ type RescanOptions,
type RescanState,
type SendCoinsRequest,
type SendCoinsResult,
@@ -326,10 +327,10 @@ export class OnchainBackend extends BaseBackend {
* 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 {
+ async startRescan(opts: RescanOptions = {}): Promise {
const start = this.chain.startRescan?.bind(this.chain);
if (!start) this.notSupported('rescanning the chain');
- const { state, done } = start(this.scanContext().accounts);
+ const { state, done } = start(this.scanContext().accounts, opts);
void done
.then(() => this.refresh())
// The rescan's own failure is already on its state and reported through getSyncState; a failed
diff --git a/src/servers/sidecar/wallet/chain-source-nbxplorer.ts b/src/servers/sidecar/wallet/chain-source-nbxplorer.ts
index 3f26a4fc..0757ee49 100644
--- a/src/servers/sidecar/wallet/chain-source-nbxplorer.ts
+++ b/src/servers/sidecar/wallet/chain-source-nbxplorer.ts
@@ -36,6 +36,7 @@ import {
type BitcoinNetwork,
type FeeEstimates,
type OnchainTx,
+ type RescanOptions,
type RescanState,
} from './types';
@@ -184,7 +185,7 @@ export class NbxplorerChainSource implements WalletChainSource {
*
* A second call while one is running returns the same handle rather than queueing a duplicate.
*/
- startRescan(accounts: readonly ScanAccount[]): RescanHandle {
+ startRescan(accounts: readonly ScanAccount[], opts: RescanOptions = {}): RescanHandle {
const existing = this.rescan;
if (existing?.running && this.rescanRun) return { state: existing, done: this.rescanRun };
@@ -199,7 +200,7 @@ export class NbxplorerChainSource implements WalletChainSource {
error: null,
};
this.rescan = state;
- this.rescanRun = this.runRescan(accounts, state);
+ this.rescanRun = this.runRescan(accounts, state, opts);
return { state, done: this.rescanRun };
}
@@ -239,15 +240,20 @@ export class NbxplorerChainSource implements WalletChainSource {
error: null,
};
this.rescan = state;
- this.rescanRun = this.runRescan(accounts, state, false);
+ this.rescanRun = this.runRescan(accounts, state, null);
return { state, done: this.rescanRun };
}
- private async runRescan(accounts: readonly ScanAccount[], state: RescanState, start = true): Promise {
+ /** `opts` null means adopt: poll the variants without ever queueing one. */
+ private async runRescan(
+ accounts: readonly ScanAccount[],
+ state: RescanState,
+ opts: RescanOptions | null,
+ ): Promise {
try {
for (const account of accounts) {
state.current = account.type;
- state.found += start ? await this.scanVariant(account) : await this.awaitVariant(account);
+ state.found += opts ? await this.scanVariant(account, opts) : await this.awaitVariant(account);
state.done += 1;
}
} catch (err) {
@@ -263,8 +269,8 @@ export class NbxplorerChainSource implements WalletChainSource {
}
/** Queue one variant's scan and wait it out. Resolves with how many UTXOs it pulled in. */
- private async scanVariant(account: ScanAccount): Promise {
- await this.chain.startUtxoScan(account.accountXpub, account.type);
+ private async scanVariant(account: ScanAccount, opts: RescanOptions): Promise {
+ await this.chain.startUtxoScan(account.accountXpub, account.type, { gapLimit: opts.gapLimit });
return this.awaitVariant(account);
}
diff --git a/src/servers/sidecar/wallet/chain-source.ts b/src/servers/sidecar/wallet/chain-source.ts
index f20f10c0..681bb578 100644
--- a/src/servers/sidecar/wallet/chain-source.ts
+++ b/src/servers/sidecar/wallet/chain-source.ts
@@ -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, RescanState } from './types';
+import type { AddressType, FeeEstimates, OnchainTx, RescanOptions, RescanState } from './types';
/** BIP44 chain index: 0 is the receive chain, 1 the internal (change) chain. */
export type ChainIndex = 0 | 1;
@@ -132,11 +132,14 @@ export interface WalletChainSource {
* 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.
*
+ * `opts.gapLimit` widens the search past the source's own default, for a wallet restored from one that
+ * issued addresses in bulk. Ignored by a source with no such knob.
+ *
* 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;
+ startRescan?(accounts: readonly ScanAccount[], opts?: RescanOptions): RescanHandle;
/**
* Find a scan already in flight upstream and return a handle to it, without starting one.
diff --git a/src/servers/sidecar/wallet/routes.ts b/src/servers/sidecar/wallet/routes.ts
index 0f189c78..91749d54 100644
--- a/src/servers/sidecar/wallet/routes.ts
+++ b/src/servers/sidecar/wallet/routes.ts
@@ -139,6 +139,9 @@ function handleConfig(): Response {
const KINDS: readonly WalletKind[] = ['onchain', 'lnd', 'cln-rest', 'lndhub', 'nwc'];
+/** Highest gap limit a rescan may be asked for. Four variants at this width is already tens of minutes. */
+const MAX_GAP_LIMIT = 100_000;
+
async function handleWallets(ctx: OfficerContext, seg: string[]): Promise {
const { req, userId } = ctx;
@@ -297,7 +300,14 @@ async function handleWallets(ctx: OfficerContext, seg: string[]): Promise(ctx.req);
+ if (gapLimit !== undefined && (!Number.isInteger(gapLimit) || gapLimit < 1 || gapLimit > MAX_GAP_LIMIT)) {
+ return badRequest(`gapLimit must be a whole number between 1 and ${MAX_GAP_LIMIT}`);
+ }
+ return json({ rescan: await backend.startRescan({ gapLimit }) });
}
case 'fees':
@@ -491,16 +501,26 @@ async function deleteWalletRoute(ctx: OfficerContext, walletId: number): Promise
const wallet = await getWallet(ctx.userId, walletId);
if (!wallet) return json({ error: 'wallet not found' }, 404);
+ // A bodyless DELETE is a caller that skipped the confirmation, not a malformed request — it should be
+ // told which field is missing rather than that its JSON did not parse.
+ type DeleteBody = { passphrase?: string; confirmName?: string };
+ const { passphrase, confirmName } = await body(ctx.req).catch((): DeleteBody => ({}));
+
// Deleting a seeded wallet destroys the only copy of the key material Officer holds. Require the
// passphrase, even when the wallet is already unlocked — an open session must not be enough.
if (wallet.hasSeed) {
- const { passphrase } = await body<{ passphrase?: string }>(ctx.req);
if (!passphrase) return badRequest('passphrase is required to delete a seeded wallet');
const sealed = await getSealedSeed(ctx.userId, walletId);
if (!sealed) throw new BackendError('wallet seed is missing', 500, 'NO_SEED');
if (!(await verifyPassphrase(walletId, JSON.parse(sealed) as SeedEnvelope, passphrase))) {
return json({ error: 'incorrect passphrase' }, 401);
}
+ } else if (confirmName?.trim() !== wallet.name) {
+ // A wallet with no seed of its own still holds the node credential, the labels and the freezes, and
+ // it deleted on one unconfirmed request — the name check lived only in the dialog, so anything that
+ // was not the dialog skipped it. Naming the wallet is the same bar the UI already asks for, now
+ // enforced where it cannot be bypassed.
+ return badRequest('confirmName must match the wallet name');
}
sessionFor(walletId).lock();
diff --git a/src/servers/sidecar/wallet/types.ts b/src/servers/sidecar/wallet/types.ts
index 3e9f6f58..cf04561f 100644
--- a/src/servers/sidecar/wallet/types.ts
+++ b/src/servers/sidecar/wallet/types.ts
@@ -324,7 +324,7 @@ export interface WalletBackend {
* 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;
+ startRescan?(opts?: RescanOptions): Promise;
/**
* Re-attach to a rescan that is running upstream but was not started by this process.
@@ -364,6 +364,17 @@ export type SyncState = {
rescan: RescanState | null;
};
+/**
+ * How far a deep rescan should look, when the default is not far enough.
+ *
+ * The default gap limit is 1000 consecutive unused addresses, which covers any wallet a person has used
+ * by hand. It does not cover one restored from a wallet that issued addresses in bulk — and there is no
+ * way to detect that case, because the whole problem is that the coins beyond the gap are invisible.
+ * Raising it costs the node CPU on one scan; leaving it too low costs the owner the coins. So the knob is
+ * exposed rather than tuned, and the caller who knows their history is the one who sets it.
+ */
+export type RescanOptions = { gapLimit?: number };
+
/**
* 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.
diff --git a/src/workspaces/officerdev/src/apps/Wallet/RescanCard.tsx b/src/workspaces/officerdev/src/apps/Wallet/RescanCard.tsx
index cb0ee806..0bfe01a0 100644
--- a/src/workspaces/officerdev/src/apps/Wallet/RescanCard.tsx
+++ b/src/workspaces/officerdev/src/apps/Wallet/RescanCard.tsx
@@ -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 (
@@ -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.
-
+
+
+
+ {/* 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. */}
+
+
{state && }
diff --git a/src/workspaces/officerdev/src/apps/Wallet/dialogs/DeleteWalletDialog.tsx b/src/workspaces/officerdev/src/apps/Wallet/dialogs/DeleteWalletDialog.tsx
index c3440800..c8822ee8 100644
--- a/src/workspaces/officerdev/src/apps/Wallet/dialogs/DeleteWalletDialog.tsx
+++ b/src/workspaces/officerdev/src/apps/Wallet/dialogs/DeleteWalletDialog.tsx
@@ -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 {
diff --git a/src/workspaces/officerdev/src/apps/Wallet/useWalletData.ts b/src/workspaces/officerdev/src/apps/Wallet/useWalletData.ts
index ee18f572..731ad089 100644
--- a/src/workspaces/officerdev/src/apps/Wallet/useWalletData.ts
+++ b/src/workspaces/officerdev/src/apps/Wallet/useWalletData.ts
@@ -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');