recover an imported wallet's coins with an nbxplorer utxo scan
registering an xpub only indexes it from that moment on, so an imported seed with history read as a confident zero: every call succeeded, the coins were simply absent. scantxoutset walks the node's current utxo set directly and finds them regardless of when the account was registered. runs all four script variants sequentially — the funds could be on any one — and surfaces progress through the existing SyncState channel so the balance says "scanning" rather than nothing. auto-fires on an imported mnemonic only; a generated seed has no history to look for. recovers spendable coins, not spent history. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -273,6 +273,18 @@ async function handleWallets(ctx: OfficerContext, seg: string[]): Promise<Respon
|
||||
case 'utxos':
|
||||
return await utxosRoute(ctx, walletId, backend, rest.slice(1));
|
||||
|
||||
// POST starts a deep rescan, GET reads how it is going. Both answer with the same `rescan` block that
|
||||
// rides on balances/transactions/utxos, so the UI has one shape to render and can poll whichever it
|
||||
// was already polling. A backend with nothing to rescan is a clean 501 rather than a silent no-op.
|
||||
case 'rescan': {
|
||||
if (ctx.req.method === 'GET') return json({ rescan: syncOf(backend)?.rescan ?? null });
|
||||
if (ctx.req.method !== 'POST') return json({ error: 'method not allowed' }, 405);
|
||||
if (!backend.startRescan) {
|
||||
throw new BackendError('this wallet has nothing to rescan', 501, 'NOT_SUPPORTED');
|
||||
}
|
||||
return json({ rescan: await backend.startRescan() });
|
||||
}
|
||||
|
||||
case 'fees':
|
||||
return json({ fees: await backend.estimateFees() });
|
||||
|
||||
@@ -430,12 +442,33 @@ async function createWalletRoute(ctx: OfficerContext): Promise<Response> {
|
||||
makeActive: b.makeActive ?? true,
|
||||
});
|
||||
|
||||
// An IMPORTED seed is the one case that needs the chain searched from scratch: an indexing upstream
|
||||
// only watches an account from the moment it is registered, so a wallet with a history would otherwise
|
||||
// show a confident, wrong zero. A freshly generated seed has no history to find, and a rescan for it
|
||||
// would burn several minutes of the node's CPU to confirm nothing.
|
||||
if (b.mnemonic) void kickOffRescan(ctx.userId, wallet.id);
|
||||
|
||||
// Return the mnemonic exactly once, and ONLY when we generated it — the owner has to write it down and
|
||||
// has no other chance to see it without re-entering the passphrase. An imported mnemonic is never
|
||||
// echoed back: the caller already has it, and echoing would put it in a response log for no reason.
|
||||
return json({ wallet, mnemonic: b.mnemonic ? undefined : mnemonic }, 201);
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a rescan behind the response that created the wallet.
|
||||
*
|
||||
* Never throws: a chain source that cannot rescan, or an upstream that is down, must not turn a
|
||||
* successful wallet import into a failed one. The owner can trigger it by hand from Settings either way.
|
||||
*/
|
||||
async function kickOffRescan(userId: number, walletId: number): Promise<void> {
|
||||
try {
|
||||
const { backend } = await resolveBackend(userId, walletId);
|
||||
await backend.startRescan?.();
|
||||
} catch (err) {
|
||||
console.error('[wallet] rescan after import failed to start:', err instanceof Error ? err.message : err);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteWalletRoute(ctx: OfficerContext, walletId: number): Promise<Response> {
|
||||
const wallet = await getWallet(ctx.userId, walletId);
|
||||
if (!wallet) return json({ error: 'wallet not found' }, 404);
|
||||
|
||||
Reference in New Issue
Block a user