Files
bitcoin-wallet/sidecar/chain.ts
T
officer a4cf939be7 bitcoin-wallet, extracted from the platform as a plugin
Tenth plugin out, and the one whose extraction turned on a permission decision
rather than a mechanical move.

The wallet permission was kind: 'admin' — owner only, not grantable, invisible
to every other account. Plugin permissions are ALWAYS kind: 'app': mount.ts
forces it and the manifest has no field for anything else, deliberately, so
that "can a plugin escalate itself" is a question which cannot be asked. So
extraction could not preserve admin, and that was raised as a blocker before
any code moved.

It was the right change anyway, and the objection inverts. Every wallet query
is scoped by user_id, and that id comes from X-Officer-User which the platform
injects from the authenticated session — a caller cannot name someone else's.
So granting this gives an account ITS OWN wallet. Not through the API and not
with root either does it hand over anyone else's, because their seed is sealed
under a passphrase the server never stores. kind: 'admin' was single-user
thinking; grantable is how a second person gets a wallet at all.

Named bitcoin-wallet rather than wallet: it is a bitcoin wallet specifically,
and the appName drives the route, the API prefix, the table prefix and the
install key together. Tables renamed to bitcoin_wallet_* accordingly — the
prefix guard normalises the hyphen. Free only because no wallet table existed
on any server, which was checked before deciding: db:push treats a rename as a
drop plus a create.

The three custody test files moved with it and still run — 72 tests over
scrypt sealing, envelope open and bolt11. bunfig.toml's test root was widened
on 2026-08-15 after music took lyrics.test.ts and the suite silently shrank by
nine, so that trap was already closed.

68 palette classes converted, five status vocabularies hoisted into shared.ts.
Two deliberate flattenings recorded in PLUGIN.md: testnet/signet/regtest all
read as warning, and the bitcoin and lightning rails no longer differ by hue.

Deferred on purpose: watch-only data (xpubs, balances, history) stays
unencrypted so a locked wallet still shows a balance. Anyone with the database
can therefore SEE any user's balances; they can never move a coin.
2026-08-19 05:18:29 +00:00

282 lines
11 KiB
TypeScript

// 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<string, number>;
// ── 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<keyof FeeEstimates, number>;
/** 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<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.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<T>(path: string): Promise<T> {
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<EsploraAddress> {
return this.json<EsploraAddress>(`/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<EsploraTx[]> {
const addr = encodeURIComponent(address);
const path = afterTxid ? `/address/${addr}/txs/chain/${encodeURIComponent(afterTxid)}` : `/address/${addr}/txs`;
return this.json<EsploraTx[]>(path);
}
/** GET /address/{addr}/utxo — unspent outputs, confirmed and unconfirmed. */
getAddressUtxos(address: string): Promise<EsploraUtxo[]> {
return this.json<EsploraUtxo[]>(`/address/${encodeURIComponent(address)}/utxo`);
}
/** GET /tx/{txid}. */
getTx(txid: string): Promise<EsploraTx> {
return this.json<EsploraTx>(`/tx/${encodeURIComponent(txid)}`);
}
/** GET /tx/{txid}/hex — plain text, not JSON. Needed as `nonWitnessUtxo` for legacy p2pkh inputs. */
async getTxHex(txid: string): Promise<string> {
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<number> {
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<FeeEstimates> {
return mapFeeEstimates(await this.json<EsploraFeeEstimates>('/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<string> {
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 };