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');