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:
2026-08-04 20:06:10 +00:00
co-authored by Claude Opus 5
parent 82aa39a05f
commit 22c0e83b49
11 changed files with 502 additions and 21 deletions
@@ -61,6 +61,7 @@ import {
type NewAddressRequest,
type NodeInfo,
type OnchainTx,
type RescanState,
type SendCoinsRequest,
type SendCoinsResult,
type SignMessageResult,
@@ -312,9 +313,31 @@ export class OnchainBackend extends BaseBackend {
syncedAt: this.current?.at ?? null,
stale: this.current !== null && Date.now() - this.current.at >= SCAN_TTL_MS,
lastError: this.lastError,
rescan: this.chain.rescanState?.() ?? null,
};
}
/**
* Ask the chain source to go looking for this wallet's coins from scratch.
*
* Returns as soon as the search is queued — it takes minutes, and the caller is an HTTP route. The
* refresh chained onto its completion is the part that matters: a rescan that found coins has changed
* 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<RescanState> {
const start = this.chain.startRescan?.bind(this.chain);
if (!start) this.notSupported('rescanning the chain');
const { state, done } = start(this.scanContext().accounts);
void done
.then(() => this.refresh())
// The rescan's own failure is already on its state and reported through getSyncState; a failed
// refresh behind it lands on lastError the same way. Neither should surface as an unhandled
// rejection in the sidecar's log.
.catch(() => {});
return state;
}
/**
* The wallet's view of the chain, stale-while-revalidate.
*