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:
@@ -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;
|
||||
|
||||
@@ -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 (43–160 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user