// Esplora REST client — the chain data source for the native on-chain wallet backend. // // Zeus's embedded backends (EmbeddedLND, LdkNode) get chain data from a node bound to a React Native // native module. Nothing about that ports to a Bun server process, so the server-side wallet reads the // chain over Esplora's HTTP API instead: mempool.space, blockstream.info, or a self-hosted esplora. // Every call is a plain unauthenticated GET, which is why the backend can serve watch-only requests // without ever touching key material. // // Two Esplora endpoints are NOT JSON and are handled explicitly below: // GET /tx/{txid}/hex → the raw transaction as a bare hex string // POST /tx → request body is bare hex, response body is the bare txid // Everything else is JSON, and every wire shape it returns is typed in this file. import { BackendError, type BitcoinNetwork, type FeeEstimates } from './types'; // ── configuration ──────────────────────────────────────────────────────────────────────────────── export type EsploraConfig = { /** Base URL of the Esplora API root, e.g. `https://mempool.space/api`. Trailing slashes are trimmed. */ baseUrl: string; network: BitcoinNetwork; timeoutMs?: number; }; const DEFAULT_TIMEOUT_MS = 20_000; // ── wire shapes ────────────────────────────────────────────────────────────────────────────────── /** Confirmation status shared by /tx, /address/{a}/txs and /address/{a}/utxo. */ export type EsploraTxStatus = { confirmed: boolean; block_height?: number; block_hash?: string; /** Unix seconds of the containing block. Absent while unconfirmed. */ block_time?: number; }; /** Aggregate funded/spent counters. Esplora reports one set for the chain and one for the mempool. */ export type EsploraAddressStats = { funded_txo_count: number; funded_txo_sum: number; spent_txo_count: number; spent_txo_sum: number; tx_count: number; }; export type EsploraAddress = { address: string; chain_stats: EsploraAddressStats; mempool_stats: EsploraAddressStats; }; export type EsploraVout = { /** scriptPubKey, hex. */ scriptpubkey: string; scriptpubkey_asm: string; /** Esplora's own classification: 'v0_p2wpkh' | 'v1_p2tr' | 'p2pkh' | 'p2sh' | 'op_return' | … */ scriptpubkey_type: string; scriptpubkey_address?: string; value: number; }; export type EsploraVin = { txid: string; vout: number; /** The output being spent. Null for coinbase inputs. */ prevout: EsploraVout | null; scriptsig: string; scriptsig_asm: string; witness?: string[]; is_coinbase: boolean; sequence: number; inner_redeemscript_asm?: string; inner_witnessscript_asm?: string; }; export type EsploraTx = { txid: string; version: number; locktime: number; vin: EsploraVin[]; vout: EsploraVout[]; size: number; weight: number; /** Absolute fee in sats. Zero for coinbase. */ fee: number; status: EsploraTxStatus; }; export type EsploraUtxo = { txid: string; vout: number; value: number; status: EsploraTxStatus; }; /** /fee-estimates: confirmation target (in blocks, as a string key) → sat/vB, as a float. */ export type EsploraFeeEstimates = Record; // ── fee mapping ────────────────────────────────────────────────────────────────────────────────── // Esplora publishes an estimate per confirmation target; FeeEstimates in types.ts is mempool.space's // five named tiers. These are the targets each tier maps onto. const FEE_TARGETS = { fastestFee: 1, halfHourFee: 3, hourFee: 6, economyFee: 144, minimumFee: 1008, } as const satisfies Record; /** Nothing below the default minimum relay fee will propagate, so 1 sat/vB is the hard floor. */ const MIN_SAT_VB = 1; /** * Pick the estimate for a confirmation target. Esplora only publishes some targets (1..25, then 144, * 504, 1008), so when the exact key is missing we take the largest published target *at or below* the * one asked for — a shorter target always quotes a higher rate, so this errs towards confirming. */ function pickForTarget(raw: EsploraFeeEstimates, target: number): number | null { const exact = raw[String(target)]; if (typeof exact === 'number' && Number.isFinite(exact)) return exact; let best: number | null = null; let bestKey = -1; for (const [key, rate] of Object.entries(raw)) { const k = Number(key); if (!Number.isFinite(k) || !Number.isFinite(rate)) continue; if (k <= target && k > bestKey) { bestKey = k; best = rate; } } return best; } /** * Map Esplora's target→rate map onto the five named tiers. Exported separately from the client so the * mapping can be unit-tested against a captured /fee-estimates payload with no network involved. */ export function mapFeeEstimates(raw: EsploraFeeEstimates): FeeEstimates { const at = (target: number): number => Math.max(MIN_SAT_VB, Math.ceil(pickForTarget(raw, target) ?? MIN_SAT_VB)); const fastestFee = at(FEE_TARGETS.fastestFee); // A tier must never quote more than a shorter-target tier: some esplora deployments serve a stale or // partial map where a longer target reads higher, and a UI that shows "1 hour" above "fastest" is a // bug report. Clamp each tier down to its faster neighbour. const halfHourFee = Math.min(fastestFee, at(FEE_TARGETS.halfHourFee)); const hourFee = Math.min(halfHourFee, at(FEE_TARGETS.hourFee)); const economyFee = Math.min(hourFee, at(FEE_TARGETS.economyFee)); const minimumFee = Math.min(economyFee, at(FEE_TARGETS.minimumFee)); return { fastestFee, halfHourFee, hourFee, economyFee, minimumFee }; } // ── the client ─────────────────────────────────────────────────────────────────────────────────── type RequestInitLite = { method?: 'GET' | 'POST'; /** Sent verbatim as the body — Esplora's POST /tx takes bare hex, not JSON. */ body?: string; contentType?: string; }; export class EsploraChain { readonly network: BitcoinNetwork; private readonly baseUrl: string; private readonly timeoutMs: number; constructor(cfg: EsploraConfig) { this.baseUrl = cfg.baseUrl.replace(/\/+$/, ''); this.network = cfg.network; this.timeoutMs = cfg.timeoutMs ?? DEFAULT_TIMEOUT_MS; } /** Every request goes through here so timeouts and upstream errors become one BackendError shape. */ private async text(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.contentType ? { 'Content-Type': init.contentType } : undefined, signal: controller.signal, }); } catch (err) { const msg = controller.signal.aborted ? `timed out after ${this.timeoutMs}ms` : String(err); throw new BackendError(`esplora request ${path} failed: ${msg}`, 502, 'UPSTREAM_UNREACHABLE'); } finally { clearTimeout(timer); } const body = await res.text(); if (!res.ok) { // Esplora reports every failure as plain text: 'Transaction not found', 'sendrawtransaction RPC // error: {"code":-26,...}'. There is no JSON envelope to unwrap, so the text is the message. throw new BackendError(`esplora ${res.status} on ${path}: ${body.slice(0, 300)}`, res.status, 'ESPLORA_ERROR'); } return body; } private async json(path: string): Promise { const body = await this.text(path); try { return JSON.parse(body) as T; } catch { throw new BackendError(`esplora returned non-JSON on ${path}: ${body.slice(0, 200)}`, 502, 'ESPLORA_ERROR'); } } // ── endpoints ────────────────────────────────────────────────────────────────────────────────── /** GET /address/{addr} — funded/spent counters for the chain and the mempool. */ getAddress(address: string): Promise { return this.json(`/address/${encodeURIComponent(address)}`); } /** * GET /address/{addr}/txs — the newest ~50 transactions (all mempool ones, then 25 confirmed). * Pass `afterTxid` to page further back through confirmed history via /txs/chain/{last_seen_txid}. */ getAddressTxs(address: string, afterTxid?: string): Promise { const addr = encodeURIComponent(address); const path = afterTxid ? `/address/${addr}/txs/chain/${encodeURIComponent(afterTxid)}` : `/address/${addr}/txs`; return this.json(path); } /** GET /address/{addr}/utxo — unspent outputs, confirmed and unconfirmed. */ getAddressUtxos(address: string): Promise { return this.json(`/address/${encodeURIComponent(address)}/utxo`); } /** GET /tx/{txid}. */ getTx(txid: string): Promise { return this.json(`/tx/${encodeURIComponent(txid)}`); } /** GET /tx/{txid}/hex — plain text, not JSON. Needed as `nonWitnessUtxo` for legacy p2pkh inputs. */ async getTxHex(txid: string): Promise { const hex = (await this.text(`/tx/${encodeURIComponent(txid)}/hex`)).trim(); if (!/^[0-9a-fA-F]+$/.test(hex)) { throw new BackendError(`esplora returned a non-hex body for tx ${txid}`, 502, 'ESPLORA_ERROR'); } return hex; } /** GET /blocks/tip/height — plain integer in the body. */ async getTipHeight(): Promise { const body = (await this.text('/blocks/tip/height')).trim(); const height = Number(body); if (!Number.isInteger(height) || height < 0) { throw new BackendError(`esplora returned a bad tip height: ${body.slice(0, 60)}`, 502, 'ESPLORA_ERROR'); } return height; } /** GET /fee-estimates, folded onto the five named tiers. */ async getFeeEstimates(): Promise { return mapFeeEstimates(await this.json('/fee-estimates')); } /** * POST /tx — the body is the raw transaction hex with no JSON wrapper, and the 200 response body is * the bare txid. A rejection comes back as a 400 with bitcoind's `sendrawtransaction RPC error` text, * which `text()` above surfaces verbatim; that message is the only diagnosis a caller gets. */ async broadcast(rawHex: string): Promise { const txid = (await this.text('/tx', { method: 'POST', body: rawHex, contentType: 'text/plain' })).trim(); if (!/^[0-9a-fA-F]{64}$/.test(txid)) { throw new BackendError(`broadcast did not return a txid: ${txid.slice(0, 200)}`, 502, 'ESPLORA_ERROR'); } return txid; } } /** Alias kept for call sites that read better as a client than as a chain source. */ export { EsploraChain as EsploraClient };