Files
platform/src/servers/sidecar/wallet/nbxplorer.ts
T
pastilhasandClaude Opus 5 22c0e83b49 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>
2026-08-04 20:06:10 +00:00

402 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { AddressType, BitcoinNetwork } from './types';
import { BackendError } from './types';
// NBXplorer client — the second chain source, alongside EsploraChain.
//
// THE MODEL IS DIFFERENT, AND THAT IS THE POINT. Esplora is address-level: you ask about one address at a
// time, so finding a wallet's coins means walking the gap limit yourself (43160 requests per refresh, see
// backends/onchain.ts). NBXplorer is scheme-level: you register the account xpub once, it indexes the
// wallet server-side, and every question after that — balance, utxos, history, next unused address — is a
// single call. That is why this is not an implementation of EsploraChain's interface: there is no sensible
// per-address query to implement, and emulating one would throw away the entire advantage.
//
// It talks to a bitcoind that the owner runs. Everything below was verified against NBXplorer 2.6.9.
/**
* NBXplorer names an account by its xpub plus a suffix for the script type. Native segwit is the bare
* xpub; the rest carry a bracketed tag.
*
* Taproot is `-[taproot]`, which is NOT in NBXplorer's own README table — verified against a live 2.6.9
* instance, which accepts it and derives the same addresses we do.
*/
const SCHEME_SUFFIX: Record<AddressType, string> = {
p2wpkh: '',
'p2sh-p2wpkh': '-[p2sh]',
p2pkh: '-[legacy]',
p2tr: '-[taproot]',
};
/**
* The account identifier NBXplorer indexes under.
*
* The xpub must carry `xpub` version bytes for every script type — NOT ypub/zpub. That is already what
* keys.ts::deriveAccountXpubs stores, and changing it would silently point NBXplorer at a different
* account than the one we derive from.
*/
export const derivationScheme = (accountXpub: string, type: AddressType): string =>
`${accountXpub}${SCHEME_SUFFIX[type]}`;
/** NBXplorer's two address roles. `Deposit` is the receive chain, `Change` is internal. */
export type DerivationFeature = 'Deposit' | 'Change';
export type NbxStatus = {
chainHeight: number;
syncHeight: number;
isFullySynched: boolean;
networkType: string;
cryptoCode: string;
version: string;
bitcoinStatus?: { blocks: number; headers: number; verificationProgress: number; isSynched: boolean } | null;
};
export type NbxBalance = {
/** Satoshis. NBXplorer emits plain numbers here, not strings. */
unconfirmed: number;
confirmed: number;
total: number;
immature: number;
available: number;
};
export type NbxUtxo = {
feature: DerivationFeature;
/** `"<txid>-<vout>"`. */
outpoint: string;
index: number;
transactionHash: string;
scriptPubKey: string;
address: string;
/** Relative to the account, e.g. `"0/7"` — chain then index. */
keyPath: string;
keyIndex: number;
value: number;
confirmations: number;
timestamp: number;
redeem?: string | null;
};
type NbxUtxoGroup = { utxOs: NbxUtxo[]; spentOutpoints: string[] };
export type NbxUtxoChanges = {
currentHeight: number;
unconfirmed: NbxUtxoGroup;
confirmed: NbxUtxoGroup;
spentUnconfirmed: NbxUtxo[];
};
export type NbxTransaction = {
transactionId: string;
/** Raw hex. Present on wallet transactions, which is the only kind NBXplorer stores. */
transaction?: string | null;
blockHash?: string | null;
confirmations: number;
height?: number | null;
timestamp: number;
/** Signed satoshis: negative when the wallet spent. */
balanceChange: number;
replaceable?: boolean;
};
export type NbxTransactions = {
height: number;
confirmedTransactions: { transactions: NbxTransaction[] };
unconfirmedTransactions: { transactions: NbxTransaction[] };
replacedTransactions: { transactions: NbxTransaction[] };
};
export type NbxAddress = {
feature: DerivationFeature;
keyPath: string;
scriptPubKey: string;
address: string;
index: number;
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;
rpcCode?: number | null;
rpcCodeMessage?: string | null;
rpcMessage?: string | null;
};
const DEFAULT_TIMEOUT_MS = 20_000;
export type NbxplorerConfig = {
baseUrl: string;
network: BitcoinNetwork;
/** Only ever 'BTC' here, but NBXplorer is multi-chain and the route carries it. */
cryptoCode?: string;
timeoutMs?: number;
};
type RequestInitLite = { method?: 'GET' | 'POST'; body?: string };
export class NbxplorerChain {
readonly network: BitcoinNetwork;
private readonly baseUrl: string;
private readonly cryptoCode: string;
private readonly timeoutMs: number;
/** Schemes already registered this process. Tracking is idempotent but not free — see `track`. */
private readonly tracked = new Set<string>();
constructor(cfg: NbxplorerConfig) {
this.baseUrl = cfg.baseUrl.replace(/\/+$/, '');
this.network = cfg.network;
this.cryptoCode = cfg.cryptoCode ?? 'BTC';
this.timeoutMs = cfg.timeoutMs ?? DEFAULT_TIMEOUT_MS;
}
private get root(): string {
return `/v1/cryptos/${this.cryptoCode}`;
}
/** Every request funnels through here, so a timeout and an upstream failure share one error shape. */
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);
let res: Response;
try {
res = await fetch(url, {
method: init.method ?? 'GET',
body: init.body,
headers: init.body ? { 'Content-Type': 'application/json' } : undefined,
signal: controller.signal,
});
} catch (err) {
const msg = controller.signal.aborted ? `timed out after ${this.timeoutMs}ms` : String(err);
throw new BackendError(`nbxplorer request ${path} failed: ${msg}`, 502, 'UPSTREAM_UNREACHABLE');
} finally {
clearTimeout(timer);
}
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 {
throw new BackendError(`nbxplorer returned non-JSON on ${path}: ${body.slice(0, 200)}`, 502, 'NBXPLORER_ERROR');
}
}
private scheme(accountXpub: string, type: AddressType): string {
return encodeURIComponent(derivationScheme(accountXpub, type));
}
// ── endpoints ──────────────────────────────────────────────────────────────────────────────────
/** `GET /v1/cryptos/BTC/status` — the liveness probe, and the only call that needs no scheme. */
getStatus(): Promise<NbxStatus> {
return this.json<NbxStatus>(`${this.root}/status`);
}
/**
* `POST …/derivations/{scheme}` — register an account so NBXplorer starts indexing it.
*
* THIS IS NOT OPTIONAL AND ITS ABSENCE IS SILENT. Querying an untracked scheme returns HTTP 200 with
* every figure zeroed, which is indistinguishable from a real empty wallet — a registration that never
* happened would read as "you have no coins". So every read path calls this first, and the in-process
* set only skips the repeat calls, never the first one after a restart.
*/
async track(accountXpub: string, type: AddressType): Promise<void> {
const key = derivationScheme(accountXpub, type);
if (this.tracked.has(key)) return;
await this.raw(`${this.root}/derivations/${this.scheme(accountXpub, type)}`, { method: 'POST' });
this.tracked.add(key);
}
/** `GET …/balance` — confirmed/unconfirmed/total for the whole account, in one call. */
async getBalance(accountXpub: string, type: AddressType): Promise<NbxBalance> {
await this.track(accountXpub, type);
return this.json<NbxBalance>(`${this.root}/derivations/${this.scheme(accountXpub, type)}/balance`);
}
/** `GET …/utxos` — every unspent output with its keyPath, which is what the PSBT builder needs. */
async getUtxos(accountXpub: string, type: AddressType): Promise<NbxUtxoChanges> {
await this.track(accountXpub, type);
return this.json<NbxUtxoChanges>(`${this.root}/derivations/${this.scheme(accountXpub, type)}/utxos`);
}
/** `GET …/transactions` — wallet history, already grouped by confirmation state. */
async getTransactions(accountXpub: string, type: AddressType): Promise<NbxTransactions> {
await this.track(accountXpub, type);
return this.json<NbxTransactions>(`${this.root}/derivations/${this.scheme(accountXpub, type)}/transactions`);
}
/**
* `GET …/addresses/unused` — the next address NBXplorer believes is unused.
*
* `reserve=true` makes it hand out the next one on the following call, which is the server-side
* equivalent of the issued-index mark onchain.ts keeps. Leave it false to peek.
*/
async getUnusedAddress(
accountXpub: string,
type: AddressType,
feature: DerivationFeature = 'Deposit',
reserve = false,
): Promise<NbxAddress> {
await this.track(accountXpub, type);
const q = `feature=${feature}&reserve=${reserve ? 'true' : 'false'}`;
return this.json<NbxAddress>(`${this.root}/derivations/${this.scheme(accountXpub, type)}/addresses/unused?${q}`);
}
/**
* `GET …/transactions/{txid}` — the raw hex of ONE transaction.
*
* Only wallet-relevant transactions exist here; an arbitrary mainnet txid 404s, verified. That is
* enough for our only caller, which wants `nonWitnessUtxo` for a legacy input — the transaction that
* funded our own address is by definition a wallet transaction.
*/
async getTxHex(accountXpub: string, type: AddressType, txid: string): Promise<string> {
await this.track(accountXpub, type);
const info = await this.json<NbxTransaction>(
`${this.root}/derivations/${this.scheme(accountXpub, type)}/transactions/${encodeURIComponent(txid)}`,
);
const hex = info.transaction?.trim();
if (!hex || !/^[0-9a-f]+$/i.test(hex)) {
throw new BackendError(`nbxplorer has no raw hex for tx ${txid}`, 502, 'NBXPLORER_ERROR');
}
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}`);
if (!Number.isFinite(res.feeRate) || res.feeRate <= 0) {
throw new BackendError(`nbxplorer returned a bad fee rate for ${blockCount} blocks`, 502, 'NBXPLORER_ERROR');
}
return res.feeRate;
}
/**
* `POST /v1/cryptos/BTC/transactions` — broadcast.
*
* The body is `{hex}`: the handler accepts raw bytes, a bare JSON string or this object, and the object
* is the only one of the three with no parsing ambiguity. A REJECTED broadcast still returns HTTP 200
* with `success: false`, so the status code alone would report a bounced transaction as sent.
*/
async broadcast(rawHex: string): Promise<void> {
const res = await this.json<NbxBroadcastResult>(`${this.root}/transactions`, {
method: 'POST',
body: JSON.stringify({ hex: rawHex }),
});
if (!res.success) {
const detail = res.rpcCodeMessage || res.rpcMessage || 'the node rejected the transaction';
throw new BackendError(`broadcast failed: ${detail}`, 502, 'BROADCAST_REJECTED');
}
}
}