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 = { 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; /** `"-"`. */ 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>; } | 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(); 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 { const { status, body } = await this.send(path, init); if (status < 200 || status >= 300) this.fail(path, status, body); return body; } private async json(path: string, init?: RequestInitLite): Promise { const body = await this.raw(path, init); return this.parse(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(path: string, init?: RequestInitLite): Promise { 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(path, body); } private parse(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 { return this.json(`${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 { 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 { await this.track(accountXpub, type); return this.json(`${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 { await this.track(accountXpub, type); return this.json(`${this.root}/derivations/${this.scheme(accountXpub, type)}/utxos`); } /** `GET …/transactions` — wallet history, already grouped by confirmation state. */ async getTransactions(accountXpub: string, type: AddressType): Promise { await this.track(accountXpub, type); return this.json(`${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 { await this.track(accountXpub, type); const q = `feature=${feature}&reserve=${reserve ? 'true' : 'false'}`; return this.json(`${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 { await this.track(accountXpub, type); const info = await this.json( `${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 { 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 { return this.jsonOrNull(`${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 { 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 { const res = await this.json(`${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'); } } }