diff --git a/src/databases/officer_db/src/queries/service-connections.ts b/src/databases/officer_db/src/queries/service-connections.ts index a05a68f9..ce277760 100644 --- a/src/databases/officer_db/src/queries/service-connections.ts +++ b/src/databases/officer_db/src/queries/service-connections.ts @@ -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; diff --git a/src/servers/sidecar/wallet/nbxplorer.ts b/src/servers/sidecar/wallet/nbxplorer.ts new file mode 100644 index 00000000..aa5d4381 --- /dev/null +++ b/src/servers/sidecar/wallet/nbxplorer.ts @@ -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 = { + 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; +}; + +/** 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 raw(path: string, init: RequestInitLite = {}): Promise { + 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(path: string, init?: RequestInitLite): Promise { + 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 { + 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; + } + + /** `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'); + } + } +}