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:
2026-08-04 21:00:24 +00:00
co-authored by Claude Opus 5
parent a61d5c3a91
commit ddc982ce5b
8 changed files with 112 additions and 27 deletions
+22 -2
View File
@@ -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<Response | null> {
const { req, userId } = ctx;
@@ -297,7 +300,14 @@ async function handleWallets(ctx: OfficerContext, seg: string[]): Promise<Respon
if (!backend.startRescan) {
throw new BackendError('this wallet has nothing to rescan', 501, 'NOT_SUPPORTED');
}
return json({ rescan: await backend.startRescan() });
// The gap limit is the one thing about a rescan only the owner can know — how many addresses their
// old wallet issued without ever being paid to. The ceiling is a node-CPU guard, not a correctness
// one: past it the scan takes longer than anyone will wait for it.
const { gapLimit } = await body<{ gapLimit?: number }>(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<DeleteBody>(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();