// The NBXplorer chain source: the owner's own bitcoind, read through NBXplorer's wallet-level index. // // The contrast with chain-source-esplora.ts is the whole reason the WalletChainSource seam exists. There // is no gap-limit walk here and no address fan-out, because NBXplorer already knows what the wallet owns: // the account xpub is registered once, and after that a scan is three calls per account regardless of how // many addresses have been used. What that costs instead is reassembly — NBXplorer answers wallet-wide, // so the per-address view the rest of the wallet expects has to be rebuilt from UTXO key paths. // // REGISTRATION IS NOT RECOVERY, and this is the trap: a newly tracked xpub only picks up activity from // the moment it is registered. An imported seed therefore reads as a real, quiet, empty wallet — every // call succeeds, nothing errors, and the coins are simply not in the index. `startRescan` below is the // answer for balance and spending: `scantxoutset` walks the node's current UTXO set directly, so it finds // coins regardless of when the account was registered or how far back the node is pruned. // // What a rescan does NOT recover is spent-transaction history predating registration; that is a block // rescan, a much heavier thing, and this source will keep reporting it as absent rather than as an error. // Esplora stays available for the owner who wants the full history back. import * as bitcoin from 'bitcoinjs-lib'; import { type AddressEntry, type ChainIndex, type RescanHandle, type ScanAccount, type ScanContext, type ScannedAddress, type ScanResult, type WalletChainSource, } from './chain-source'; import { derivationScheme, type NbxplorerChain, type NbxTransaction, type NbxUtxo } from './nbxplorer'; import type { SpendableUtxo } from './psbt'; import { networkFor } from './psbt'; import { BackendError, type AddressType, type BitcoinNetwork, type FeeEstimates, type OnchainTx, type RescanOptions, type RescanState, } from './types'; /** Confirmation targets, in blocks, behind the five fee tiers the wallet reports. */ const FEE_TARGETS = { fastestFee: 1, halfHourFee: 3, hourFee: 6, economyFee: 25, minimumFee: 144 } as const; /** How often to ask how a `scantxoutset` is going. Frequent enough that a finished result is never missed. */ const SCAN_POLL_MS = 2_000; /** Per-variant ceiling. A scan of a large UTXO set is minutes; anything past this is a wedged node. */ const SCAN_TIMEOUT_MS = 10 * 60_000; /** Consecutive 404s tolerated before a variant has ever reported a status — see `scanVariant`. */ const SCAN_NULL_GRACE = 5; const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); export type NbxplorerChainSourceOptions = { chain: NbxplorerChain; network: BitcoinNetwork; label: string }; export class NbxplorerChainSource implements WalletChainSource { readonly label: string; /** * A scan reports up to NBXplorer's unused mark, but NBXplorer itself watches a gap beyond it * (`MinGapSize`, 20 by default) and extends as addresses are used, so an address a little past the * mark is still seen. Deliberately below that default rather than equal to it, because the setting * belongs to the node operator and is not readable from here — and unlike Esplora, a miss on this * source is recoverable: `utxos/scan` walks the node's whole UTXO set to a gap limit of 1000. */ readonly issueAhead = 10; private readonly chain: NbxplorerChain; private readonly btcNetwork: bitcoin.Network; /** * Accounts seen by the most recent scan. * * `getTxHex` is the reason this exists: NBXplorer has no global transaction lookup, only a per-scheme * one, but the WalletChainSource signature carries a txid and nothing else. Every caller of getTxHex * runs inside sendCoins, which forces a fresh scan before it selects a single coin, so the map is * always populated by the time it is read. It is a memo of the last scan, never a source of truth. */ private accounts: readonly ScanAccount[] = []; /** The rescan in flight, or the last one's outcome. Mutated in place as the scan progresses. */ private rescan: RescanState | null = null; private rescanRun: Promise | null = null; constructor(opts: NbxplorerChainSourceOptions) { this.chain = opts.chain; this.btcNetwork = networkFor(opts.network); this.label = opts.label; } async getTipHeight(): Promise { const status = await this.chain.getStatus(); return status.chainHeight; } /** * NBXplorer answers one confirmation target per call, so the five tiers are five requests. * * In a quiet mempool every target returns the same number and the tiers collapse to one value. That is * the node telling the truth, not a bug — there is genuinely no fee premium worth paying when the next * block will clear the queue. The clamp only enforces that a slower target is never quoted higher than * a faster one, which estimatesmartfee can otherwise produce at the boundaries. */ async getFeeEstimates(): Promise { const entries = Object.entries(FEE_TARGETS) as [keyof FeeEstimates, number][]; const rates = await Promise.all(entries.map(([, blocks]) => this.chain.getFeeRate(blocks))); const out = {} as FeeEstimates; let ceiling = Number.POSITIVE_INFINITY; for (let i = 0; i < entries.length; i++) { const entry = entries[i]; const rate = rates[i]; if (!entry || rate === undefined) continue; ceiling = Math.min(ceiling, rate); out[entry[0]] = ceiling; } return out; } async getTxHex(txid: string): Promise { if (this.accounts.length === 0) { throw new BackendError('nbxplorer source was asked for a transaction before any scan', 500, 'NO_SCAN'); } // Which account funded the input is not known here, and asking the wrong one 404s rather than // returning something wrong, so trying each in turn is safe. In practice the first one answers. let last: unknown = null; for (const account of this.accounts) { try { return await this.chain.getTxHex(account.accountXpub, account.type, txid); } catch (err) { last = err; } } throw last instanceof Error ? last : new BackendError(`no account knows transaction ${txid}`, 502); } async broadcast(rawHex: string): Promise { await this.chain.broadcast(rawHex); // NBXplorer's broadcast returns only success/failure, so the txid is computed locally. It is a hash // of the bytes we just sent, so it cannot disagree with what was accepted. return bitcoin.Transaction.fromHex(rawHex).getId(); } async scan(ctx: ScanContext): Promise { this.accounts = ctx.accounts; const perAccount = await Promise.all(ctx.accounts.map((account) => this.scanAccount(ctx, account))); const tipHeight = Math.max(0, ...perAccount.map((r) => r.tipHeight)); const addresses = perAccount.flatMap((r) => r.addresses); const utxos = perAccount.flatMap((r) => r.utxos); const byScript = new Map(); for (const entry of addresses) byScript.set(entry.scriptPubKeyHex, entry); const txs = perAccount .flatMap((r) => r.txs) .map((tx) => this.toOnchainTx(tx, byScript, tipHeight)) .sort((a, b) => { // Unconfirmed first (they have no height), then newest block, then newest timestamp. const ah = a.blockHeight ?? Number.MAX_SAFE_INTEGER; const bh = b.blockHeight ?? Number.MAX_SAFE_INTEGER; if (ah !== bh) return bh - ah; return (b.timestamp ?? 0) - (a.timestamp ?? 0); }); return { tipHeight, addresses, utxos, txs }; } rescanState(): RescanState | null { return this.rescan; } /** * Run `scantxoutset` over every account variant, one at a time. * * ALL FOUR VARIANTS, ALWAYS. A recovered seed's coins can sit on any script type, and nothing here can * tell which until the node has looked — the p2sh account of a wallet that only ever used native segwit * costs one wasted scan, whereas skipping it costs the owner their money. The node serialises * `scantxoutset` anyway, so scanning them sequentially is what happens regardless of what we ask for; * doing it explicitly is what makes `done`/`total` mean something on screen. * * A second call while one is running returns the same handle rather than queueing a duplicate. */ startRescan(accounts: readonly ScanAccount[], opts: RescanOptions = {}): RescanHandle { const existing = this.rescan; if (existing?.running && this.rescanRun) return { state: existing, done: this.rescanRun }; const state: RescanState = { running: true, done: 0, total: accounts.length, current: accounts[0]?.type ?? null, found: 0, startedAt: Date.now(), finishedAt: null, error: null, }; this.rescan = state; this.rescanRun = this.runRescan(accounts, state, opts); return { state, done: this.rescanRun }; } /** * Pick up a scan that survived a restart of this sidecar. * * `scantxoutset` runs on the node and outlives us; only the handle to it was ever in memory. So the * upstream is asked directly, and if anything is Queued or Pending the run is rebuilt around the same * polling loop with starting suppressed — a duplicate POST would sit behind the real scan for its whole * duration, since the node runs one at a time. * * `found` restarts from zero and a variant that finished before the restart contributes nothing to it: * its result has expired upstream and there is no way to recover the count. The number under-reports * for that one run, which is the honest answer — and the refresh chained on the end is what actually * puts the coins on screen. */ async adoptRescan(accounts: readonly ScanAccount[]): Promise { if (this.rescan?.running && this.rescanRun) return { state: this.rescan, done: this.rescanRun }; const live = await Promise.all( accounts.map(async (account) => { // A probe failing is not an answer either way, and must not be reported as "a scan is running". const status = await this.chain.getUtxoScanStatus(account.accountXpub, account.type).catch(() => null); return status?.status === 'Queued' || status?.status === 'Pending'; }), ); if (!live.some(Boolean)) return null; const state: RescanState = { running: true, done: 0, total: accounts.length, current: accounts[0]?.type ?? null, found: 0, startedAt: Date.now(), finishedAt: null, error: null, }; this.rescan = state; this.rescanRun = this.runRescan(accounts, state, null); return { state, done: this.rescanRun }; } /** `opts` null means adopt: poll the variants without ever queueing one. */ private async runRescan( accounts: readonly ScanAccount[], state: RescanState, opts: RescanOptions | null, ): Promise { try { for (const account of accounts) { state.current = account.type; state.found += opts ? await this.scanVariant(account, opts) : await this.awaitVariant(account); state.done += 1; } } catch (err) { // One variant failing stops the run, and the partial `found` stays on the state: coins already // pulled in by an earlier variant are really there, and reporting zero would understate the wallet. state.error = err instanceof Error ? err.message : String(err); } finally { state.running = false; state.current = null; state.finishedAt = Date.now(); } return state; } /** Queue one variant's scan and wait it out. Resolves with how many UTXOs it pulled in. */ private async scanVariant(account: ScanAccount, opts: RescanOptions): Promise { await this.chain.startUtxoScan(account.accountXpub, account.type, { gapLimit: opts.gapLimit }); return this.awaitVariant(account); } /** * Wait out a scan that is already queued upstream, without starting one. * * Split out of scanVariant for adoption: a scan this process did not start must never be re-queued, * because scantxoutset is single-threaded node-wide and a duplicate would sit behind the real one for * its whole duration. An account that already finished answers 404 or Complete and returns at once. */ private async awaitVariant(account: ScanAccount): Promise { const { accountXpub, type } = account; // The timeout bounds how long a scan may RUN, not how long it may wait its turn. scantxoutset is // single-threaded node-wide, so four variants queue behind each other and the last one could spend // most of its ten minutes not started — timing out while the node was never asked to do anything // wrong. The deadline is therefore pushed forward for as long as the status reads Queued. let deadline = Date.now() + SCAN_TIMEOUT_MS; let found = 0; let seen = false; let nulls = 0; while (Date.now() < deadline) { await sleep(SCAN_POLL_MS); const status = await this.chain.getUtxoScanStatus(accountXpub, type); // 404 means "no scan is running" — which before the first sighting is a race with the queue, and // after it means the result has expired. Only the second reading is a finish, hence the flag; the // grace bounds the first so a scan the node silently dropped does not hold the run for ten minutes. if (!status) { if (seen) return found; if (++nulls >= SCAN_NULL_GRACE) return found; continue; } seen = true; nulls = 0; found = status.progress?.found ?? found; if (status.status === 'Queued') deadline = Date.now() + SCAN_TIMEOUT_MS; if (status.status === 'Complete') return found; if (status.status === 'Error') { throw new BackendError(`utxo scan of the ${type} account failed: ${status.error ?? 'unknown'}`, 502, 'SCAN'); } } throw new BackendError(`utxo scan of the ${type} account did not finish in time`, 504, 'SCAN_TIMEOUT'); } private async scanAccount(ctx: ScanContext, account: ScanAccount) { const { accountXpub, type } = account; // track() is idempotent and every one of these calls makes it first, so registration cannot be // skipped — which matters because an unregistered scheme answers 200-with-zeros, not 404. const [utxoChanges, history, unusedDeposit, unusedChange] = await Promise.all([ this.chain.getUtxos(accountXpub, type), this.chain.getTransactions(accountXpub, type), this.chain.getUnusedAddress(accountXpub, type, 'Deposit'), this.chain.getUnusedAddress(accountXpub, type, 'Change'), ]); const tipHeight = utxoChanges.currentHeight; // A coin already being spent by something in the mempool is not spendable again. NBXplorer still // lists it as a confirmed UTXO, so it has to be excluded explicitly or coin selection would build a // transaction that double-spends the owner's own unconfirmed one. const spentUnconfirmed = new Set(utxoChanges.spentUnconfirmed.map((u) => u.outpoint)); const confirmed = utxoChanges.confirmed.utxOs.filter((u) => !spentUnconfirmed.has(u.outpoint)); const unconfirmed = utxoChanges.unconfirmed.utxOs.filter((u) => !spentUnconfirmed.has(u.outpoint)); // The per-address view the rest of the wallet works in. NBXplorer's own answer to "what is unused" is // taken as authoritative for where each chain ends — everything below that index has been seen, which // is what makes it unsafe to hand out again. const highest: Record = { 0: indexOf(unusedDeposit.keyPath), 1: indexOf(unusedChange.keyPath), }; for (const u of [...confirmed, ...unconfirmed, ...utxoChanges.spentUnconfirmed]) { const at = parseKeyPath(u.keyPath); if (at) highest[at.chain] = Math.max(highest[at.chain], at.index); } const sats = new Map(); const bump = (u: NbxUtxo, field: 'confirmed' | 'unconfirmed', sign: 1 | -1) => { const cur = sats.get(u.keyPath) ?? { confirmed: 0, unconfirmed: 0 }; cur[field] += sign * u.value; sats.set(u.keyPath, cur); }; for (const u of confirmed) bump(u, 'confirmed', 1); for (const u of unconfirmed) bump(u, 'unconfirmed', 1); // Matches Esplora's signed mempool delta: an unconfirmed spend of a confirmed coin reads negative, // which is exactly what the Balances contract wants in onchainUnconfirmed. for (const u of utxoChanges.spentUnconfirmed) bump(u, 'unconfirmed', -1); const addresses: ScannedAddress[] = []; for (const chain of [0, 1] as ChainIndex[]) { for (let index = 0; index <= highest[chain]; index++) { const entry = ctx.derive(type, chain, index); const held = sats.get(`${chain}/${index}`); addresses.push({ ...entry, confirmedSats: held?.confirmed ?? 0, unconfirmedSats: held?.unconfirmed ?? 0, // Below the unused mark means NBXplorer has seen this address; the mark itself has not. txCount: index < highest[chain] ? 1 : 0, used: index < highest[chain], }); } } const utxos = [...confirmed, ...unconfirmed].flatMap((u) => { const at = parseKeyPath(u.keyPath); if (!at) return []; const entry = ctx.derive(type, at.chain, at.index); return [ { txid: u.transactionHash, vout: u.index, amountSats: u.value, address: entry.address, addressType: entry.type, confirmations: Math.max(0, u.confirmations), derivationPath: entry.path, frozen: false, scriptPubKeyHex: entry.scriptPubKeyHex, pubkeyHex: entry.pubkeyHex, }, ]; }); const txs = [...history.confirmedTransactions.transactions, ...history.unconfirmedTransactions.transactions]; return { tipHeight, addresses, utxos, txs }; } /** * NBXplorer already scores the transaction for us — `balanceChange` is signed and wallet-wide, which is * precisely what the Esplora source computes by hand from credits and debits. * * The fee is NOT recoverable here, and reporting a wrong one would be worse than reporting none: the * response carries no input values, and fetching every prevout to derive it would reintroduce the * per-transaction fan-out this source exists to avoid. The contract permits null. */ private toOnchainTx(tx: NbxTransaction, byScript: Map, tipHeight: number): OnchainTx { const amount = tx.balanceChange; const ours: string[] = []; const theirs: string[] = []; const hex = tx.transaction?.trim(); if (hex) { try { for (const out of bitcoin.Transaction.fromHex(hex).outs) { const mine = byScript.get(out.script.toString('hex')); if (mine) { ours.push(mine.address); continue; } try { theirs.push(bitcoin.address.fromOutputScript(out.script, this.btcNetwork)); } catch { // OP_RETURN and other unspendable scripts have no address. Not an error, just not a payee. } } } catch { // A transaction we cannot parse still has a correct amount and height; only the payee list is // lost, and an empty one renders as "unknown" rather than as something false. } } const height = tx.confirmations > 0 ? (tx.height ?? null) : null; return { txid: tx.transactionId, amount, feeSats: null, blockHeight: height, timestamp: tx.timestamp, confirmations: Math.max(0, tx.confirmations), label: null, destAddresses: amount < 0 ? (theirs.length > 0 ? theirs : ours) : ours, rawHex: null, }; } } // ── helpers ────────────────────────────────────────────────────────────────────────────────────── /** `"0/7"` → `{ chain: 0, index: 7 }`. Anything else is not an address this wallet derives. */ function parseKeyPath(keyPath: string): { chain: ChainIndex; index: number } | null { const parts = keyPath.split('/'); const chain = Number(parts.at(-2)); const index = Number(parts.at(-1)); if ((chain !== 0 && chain !== 1) || !Number.isInteger(index) || index < 0) return null; return { chain, index }; } /** The address index out of a key path, or 0 when it is unreadable. */ function indexOf(keyPath: string): number { return parseKeyPath(keyPath)?.index ?? 0; }