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:
@@ -113,6 +113,40 @@ export type NbxAddress = {
|
||||
redeem?: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* `GET …/utxos/scan` — how a `scantxoutset` sweep is going.
|
||||
*
|
||||
* Every field below `status` is optional on purpose: NBXplorer fills `progress` only once the node has
|
||||
* actually started, and a queued scan reports nothing but its place in the queue.
|
||||
*/
|
||||
export type NbxScanStatus = {
|
||||
status: 'Queued' | 'Pending' | 'Complete' | 'Error';
|
||||
error?: string | null;
|
||||
queuedAt?: string;
|
||||
progress?: {
|
||||
startedAt?: string;
|
||||
completedAt?: string | null;
|
||||
/** UTXOs pulled in. This is the number that answers "did the scan find my coins". */
|
||||
found?: number;
|
||||
batchNumber?: number;
|
||||
remainingBatches?: number;
|
||||
currentBatchProgress?: number;
|
||||
overallProgress?: number;
|
||||
remainingSeconds?: number;
|
||||
highestKeyIndexFound?: Partial<Record<DerivationFeature, number | null>>;
|
||||
} | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* `POST …/utxos/scan` parameters.
|
||||
*
|
||||
* The defaults are the values the node operator verified live. `gapLimit` 1000 is generous for any
|
||||
* wallet that has not used addresses beyond index 1000; raising it costs node CPU, not correctness.
|
||||
*/
|
||||
export type UtxoScanOptions = { batchSize?: number; gapLimit?: number; from?: number };
|
||||
|
||||
const SCAN_DEFAULTS = { batchSize: 1000, gapLimit: 1000, from: 0 } as const;
|
||||
|
||||
/** A failed broadcast comes back as HTTP 200 with `success: false` — never as an HTTP error. */
|
||||
export type NbxBroadcastResult = {
|
||||
success: boolean;
|
||||
@@ -153,7 +187,7 @@ export class NbxplorerChain {
|
||||
}
|
||||
|
||||
/** Every request funnels through here, so a timeout and an upstream failure share one error shape. */
|
||||
private async raw(path: string, init: RequestInitLite = {}): Promise<string> {
|
||||
private async send(path: string, init: RequestInitLite): Promise<{ status: number; body: string }> {
|
||||
const url = `${this.baseUrl}${path.startsWith('/') ? path : `/${path}`}`;
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), this.timeoutMs);
|
||||
@@ -173,23 +207,48 @@ export class NbxplorerChain {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
|
||||
const body = await res.text();
|
||||
if (!res.ok) {
|
||||
// NBXplorer answers with {"code","message"} where it can, and bare text where it cannot.
|
||||
let message = body.slice(0, 300);
|
||||
try {
|
||||
const parsed = JSON.parse(body) as { message?: unknown };
|
||||
if (typeof parsed.message === 'string' && parsed.message) message = parsed.message;
|
||||
} catch {
|
||||
/* not its JSON envelope */
|
||||
}
|
||||
throw new BackendError(`nbxplorer ${res.status} on ${path}: ${message}`, res.status, 'NBXPLORER_ERROR');
|
||||
return { status: res.status, body: await res.text() };
|
||||
}
|
||||
|
||||
private fail(path: string, status: number, body: string): never {
|
||||
// NBXplorer answers with {"code","message"} where it can, and bare text where it cannot.
|
||||
let message = body.slice(0, 300);
|
||||
try {
|
||||
const parsed = JSON.parse(body) as { message?: unknown };
|
||||
if (typeof parsed.message === 'string' && parsed.message) message = parsed.message;
|
||||
} catch {
|
||||
/* not its JSON envelope */
|
||||
}
|
||||
throw new BackendError(`nbxplorer ${status} on ${path}: ${message}`, status, 'NBXPLORER_ERROR');
|
||||
}
|
||||
|
||||
private async raw(path: string, init: RequestInitLite = {}): Promise<string> {
|
||||
const { status, body } = await this.send(path, init);
|
||||
if (status < 200 || status >= 300) this.fail(path, status, body);
|
||||
return body;
|
||||
}
|
||||
|
||||
private async json<T>(path: string, init?: RequestInitLite): Promise<T> {
|
||||
const body = await this.raw(path, init);
|
||||
return this.parse<T>(path, body);
|
||||
}
|
||||
|
||||
/**
|
||||
* Like `json`, but a 404 is an answer rather than a failure.
|
||||
*
|
||||
* Only the scan-status endpoint needs this, and it needs it badly: NBXplorer 404s both when no scan is
|
||||
* running and once a finished scan's result has expired. Treating that as an error would turn the two
|
||||
* most ordinary moments of a rescan — before it starts, and a while after it ends — into upstream
|
||||
* failures on the wallet screen.
|
||||
*/
|
||||
private async jsonOrNull<T>(path: string, init?: RequestInitLite): Promise<T | null> {
|
||||
const { status, body } = await this.send(path, init ?? {});
|
||||
if (status === 404) return null;
|
||||
if (status < 200 || status >= 300) this.fail(path, status, body);
|
||||
return this.parse<T>(path, body);
|
||||
}
|
||||
|
||||
private parse<T>(path: string, body: string): T {
|
||||
try {
|
||||
return JSON.parse(body) as T;
|
||||
} catch {
|
||||
@@ -277,6 +336,42 @@ export class NbxplorerChain {
|
||||
return hex;
|
||||
}
|
||||
|
||||
/**
|
||||
* `POST …/utxos/scan` — sweep the node's whole UTXO set for this account's coins.
|
||||
*
|
||||
* WHY THIS EXISTS AT ALL. Registering a scheme only makes NBXplorer index it *from now on*. An
|
||||
* imported xpub with a history therefore reads as a real, quiet, zero-balance wallet: every call
|
||||
* succeeds, nothing errors, and the coins are simply not there. This endpoint is the fix — it runs
|
||||
* bitcoind's `scantxoutset`, which walks the current UTXO set directly and finds coins regardless of
|
||||
* when the account was registered or how far back the node is pruned.
|
||||
*
|
||||
* It finds spendable coins, NOT history. Spent-transaction history predating registration stays
|
||||
* missing; recovering that is a block rescan, which is a different and much heavier thing.
|
||||
*
|
||||
* Returns as soon as the scan is queued. `scantxoutset` is single-threaded and IO-heavy on the node,
|
||||
* so concurrent scans queue and run one after another — poll `getUtxoScanStatus` for the outcome.
|
||||
*/
|
||||
async startUtxoScan(accountXpub: string, type: AddressType, opts: UtxoScanOptions = {}): Promise<void> {
|
||||
await this.track(accountXpub, type);
|
||||
const q = new URLSearchParams({
|
||||
batchSize: String(opts.batchSize ?? SCAN_DEFAULTS.batchSize),
|
||||
gapLimit: String(opts.gapLimit ?? SCAN_DEFAULTS.gapLimit),
|
||||
from: String(opts.from ?? SCAN_DEFAULTS.from),
|
||||
});
|
||||
await this.raw(`${this.root}/derivations/${this.scheme(accountXpub, type)}/utxos/scan?${q}`, { method: 'POST' });
|
||||
}
|
||||
|
||||
/**
|
||||
* `GET …/utxos/scan` — the running scan, or null.
|
||||
*
|
||||
* Null means "nothing to report": no scan is running, or one finished long enough ago that NBXplorer
|
||||
* has dropped the result. Both are 404s and neither is a failure, so poll promptly and read a null
|
||||
* after a `Complete` as "it is over", not as "it vanished".
|
||||
*/
|
||||
getUtxoScanStatus(accountXpub: string, type: AddressType): Promise<NbxScanStatus | null> {
|
||||
return this.jsonOrNull<NbxScanStatus>(`${this.root}/derivations/${this.scheme(accountXpub, type)}/utxos/scan`);
|
||||
}
|
||||
|
||||
/** `GET /v1/cryptos/BTC/fees/{blockCount}` — one target per call, sat/vB as a float. */
|
||||
async getFeeRate(blockCount: number): Promise<number> {
|
||||
const res = await this.json<{ feeRate: number; blockCount: number }>(`${this.root}/fees/${blockCount}`);
|
||||
|
||||
Reference in New Issue
Block a user