add an nbxplorer client to the wallet sidecar

The second chain source. Esplora is address-level, so finding a wallet's coins means
walking the gap limit ourselves — 43-160 requests per refresh. NBXplorer is scheme-level:
register the account xpub once and every question after that is a single call. So this is
deliberately NOT an implementation of EsploraChain's interface; there is no per-address
query worth emulating, and emulating one would throw the advantage away.

Verified end to end against the owner's live 2.6.9 instance: status, track, balance,
utxos, transactions, unused address and fee estimates all round-trip, and NBXplorer
derives bc1qwnvlm8... for BIP84 0/0 — byte-identical to what keys.ts derives.

Three things the upstream docs get wrong or leave out, all confirmed by hand:
  - single-sig taproot is `-[taproot]`, absent from NBXplorer's own scheme table
  - querying an UNTRACKED scheme returns 200 with every figure zeroed, which reads as a
    real empty wallet; track() is therefore on every read path, not just at setup
  - a rejected broadcast returns 200 with success:false, never an HTTP error

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-03 23:31:55 +00:00
co-authored by Claude Opus 5
parent ff73e86983
commit 2d71424d5a
2 changed files with 307 additions and 1 deletions
@@ -13,7 +13,7 @@ import { encryptSecret, decryptSecret } from '../crypto';
// moment someone forgot to strip it.
/** The services that keep a connection here. Extending it is a one-line change, not a migration. */
export type ServiceName = 'transmission' | 'slskd' | 'esplora';
export type ServiceName = 'transmission' | 'slskd' | 'esplora' | 'nbxplorer';
export type ServiceConnection = {
id: number;
+306
View File
@@ -0,0 +1,306 @@
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;
};
/** 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 raw(path: string, init: RequestInitLite = {}): Promise<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);
}
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 body;
}
private async json<T>(path: string, init?: RequestInitLite): Promise<T> {
const body = await this.raw(path, init);
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;
}
/** `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');
}
}
}