Files
bitcoin-wallet/sidecar/chain-source-esplora.ts
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

239 lines
9.6 KiB
TypeScript

// The Esplora chain source: everything that used to be the on-chain backend's scanning half.
//
// Esplora answers about ONE address at a time and has no concept of a wallet, so finding the wallet is
// this file's job: derive addresses, ask about each, and stop after enough consecutive misses. That walk
// is why the file exists, and why it is the expensive source of the two.
import type { EsploraAddress, EsploraChain, EsploraTx } from './chain';
import type { SpendableUtxo } from './psbt';
import {
mapLimit,
MAX_SCAN_INDEX,
REQUEST_CONCURRENCY,
type AddressEntry,
type ChainIndex,
type ScanContext,
type ScannedAddress,
type ScanResult,
type WalletChainSource,
} from './chain-source';
import type { AddressType, FeeEstimates, OnchainTx } from './types';
/** BIP44's standard gap limit: 20 consecutive unused addresses ends the scan for a chain. */
const GAP_LIMIT = 20;
/** Cap on the history returned. A wallet with years of activity should not grow an unbounded blob. */
const MAX_CACHED_TXS = 500;
/** The scan proper, before the coins and history that hang off it. */
type AddressScan = {
tipHeight: number;
addresses: ScannedAddress[];
/** scriptPubKey hex → the entry that owns it. Used to score transactions as ours. */
byScript: Map<string, ScannedAddress>;
};
export type EsploraChainSourceOptions = { chain: EsploraChain; label: string };
export class EsploraChainSource implements WalletChainSource {
readonly label: string;
/** The walk already runs a full GAP_LIMIT past the last used address, and stops dead there. */
readonly issueAhead = 0;
private readonly chain: EsploraChain;
constructor(opts: EsploraChainSourceOptions) {
this.chain = opts.chain;
this.label = opts.label;
}
getTipHeight(): Promise<number> {
return this.chain.getTipHeight();
}
getFeeEstimates(): Promise<FeeEstimates> {
return this.chain.getFeeEstimates();
}
getTxHex(txid: string): Promise<string> {
return this.chain.getTxHex(txid);
}
broadcast(rawHex: string): Promise<string> {
return this.chain.broadcast(rawHex);
}
async scan(ctx: ScanContext): Promise<ScanResult> {
const scan = await this.runScan(ctx);
// Both fan out over the scanned address set and neither depends on the other, so they overlap.
const [utxos, txs] = await Promise.all([this.collectUtxos(scan), this.collectTxs(scan)]);
return { tipHeight: scan.tipHeight, addresses: scan.addresses, utxos, txs };
}
private async runScan(ctx: ScanContext): Promise<AddressScan> {
const tipHeight = await this.chain.getTipHeight();
const addresses: ScannedAddress[] = [];
// A wallet holds an account xpub for all four BIP purposes, but a full gap-limit walk of every one
// costs 4 types x 2 chains x 20 addresses = 160 requests, which trips the rate limit on every public
// Esplora instance. Only the wallet's own script type is walked unconditionally; the others are
// probed at receive index 0 first (one request each) and walked only if that address has ever been
// used. A freshly generated seed therefore costs 43 requests instead of 160, while a seed recovered
// from a wallet that used a different script type is still found rather than silently reported empty.
for (const account of ctx.accounts) {
if (account.type !== ctx.defaultType && !(await this.hasHistory(ctx, account.type))) continue;
for (const chain of [0, 1] as ChainIndex[]) {
addresses.push(...(await this.scanChain(ctx, account.type, chain)));
}
}
const byScript = new Map<string, ScannedAddress>();
for (const entry of addresses) byScript.set(entry.scriptPubKeyHex, entry);
return { tipHeight, addresses, byScript };
}
/**
* Has this script type ever been used at all? One request against receive index 0, which is the
* address any wallet hands out first — so a used account is essentially never missed, and an unused
* one costs a single call instead of a forty-address walk.
*/
private async hasHistory(ctx: ScanContext, type: AddressType): Promise<boolean> {
const entry = ctx.derive(type, 0, 0);
return toScannedAddress(entry, await this.chain.getAddress(entry.address)).used;
}
/** Walk one (type, chain) pair a gap-limit window at a time until GAP_LIMIT consecutive misses. */
private async scanChain(ctx: ScanContext, type: AddressType, chain: ChainIndex): Promise<ScannedAddress[]> {
const found: ScannedAddress[] = [];
let index = 0;
let gap = 0;
while (gap < GAP_LIMIT && index < MAX_SCAN_INDEX) {
const window = Array.from({ length: GAP_LIMIT }, (_, i) => ctx.derive(type, chain, index + i));
const stats = await mapLimit(window, REQUEST_CONCURRENCY, (entry) => this.chain.getAddress(entry.address));
for (let i = 0; i < window.length; i++) {
const entry = window[i];
const stat = stats[i];
if (!entry || !stat) continue;
const scanned = toScannedAddress(entry, stat);
found.push(scanned);
gap = scanned.used ? 0 : gap + 1;
if (gap >= GAP_LIMIT) break;
}
index += window.length;
}
return found;
}
/** Fetch UTXOs for every scanned address that still holds a balance. */
private async collectUtxos(scan: AddressScan): Promise<SpendableUtxo[]> {
// An address whose funded and spent counts match holds nothing; asking Esplora about it is a
// wasted round trip, and on a wallet with long history that is most of the address set.
const funded = scan.addresses.filter((a) => a.confirmedSats + a.unconfirmedSats > 0);
const sets = await mapLimit(funded, REQUEST_CONCURRENCY, async (entry) => {
const utxos = await this.chain.getAddressUtxos(entry.address);
return utxos.map<SpendableUtxo>((u) => ({
txid: u.txid,
vout: u.vout,
amountSats: u.value,
address: entry.address,
addressType: entry.type,
confirmations:
u.status.confirmed && u.status.block_height !== undefined
? Math.max(0, scan.tipHeight - u.status.block_height + 1)
: 0,
derivationPath: entry.path,
frozen: false,
scriptPubKeyHex: entry.scriptPubKeyHex,
pubkeyHex: entry.pubkeyHex,
}));
});
return sets.flat();
}
/** History for every address the scan found had been touched. Only those can appear in it. */
private async collectTxs(scan: AddressScan): Promise<OnchainTx[]> {
const touched = scan.addresses.filter((a) => a.used);
const pages = await mapLimit(touched, REQUEST_CONCURRENCY, (a) => this.chain.getAddressTxs(a.address));
const seen = new Map<string, EsploraTx>();
for (const page of pages) {
for (const tx of page) if (!seen.has(tx.txid)) seen.set(tx.txid, tx);
}
const txs = [...seen.values()].map((tx) => toOnchainTx(tx, scan));
txs.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 txs.slice(0, MAX_CACHED_TXS);
}
}
// ── helpers ──────────────────────────────────────────────────────────────────────────────────────
function toScannedAddress(entry: AddressEntry, stat: EsploraAddress): ScannedAddress {
const chainBalance = stat.chain_stats.funded_txo_sum - stat.chain_stats.spent_txo_sum;
// The mempool delta is signed: an unconfirmed spend of a confirmed coin reads negative here, which is
// exactly what the Balances contract wants in onchainUnconfirmed.
const mempoolBalance = stat.mempool_stats.funded_txo_sum - stat.mempool_stats.spent_txo_sum;
const txCount = stat.chain_stats.tx_count + stat.mempool_stats.tx_count;
return {
...entry,
confirmedSats: chainBalance,
unconfirmedSats: mempoolBalance,
txCount,
used: txCount > 0,
};
}
/** Score one Esplora transaction against the wallet's own scripts. */
function toOnchainTx(tx: EsploraTx, scan: AddressScan): OnchainTx {
let credit = 0;
let debit = 0;
const ours: string[] = [];
const theirs: string[] = [];
for (const out of tx.vout) {
const mine = scan.byScript.get(out.scriptpubkey);
if (mine) {
credit += out.value;
ours.push(mine.address);
} else if (out.scriptpubkey_address) {
theirs.push(out.scriptpubkey_address);
}
}
for (const input of tx.vin) {
const prevout = input.prevout;
if (prevout && scan.byScript.has(prevout.scriptpubkey)) debit += prevout.value;
}
const height = tx.status.confirmed ? (tx.status.block_height ?? null) : null;
const confirmations = height === null ? 0 : Math.max(0, scan.tipHeight - height + 1);
const amount = credit - debit;
return {
txid: tx.txid,
amount,
// The fee is only ours to report when we funded an input; for an incoming payment the sender
// paid it and attributing it to this wallet would be a lie.
feeSats: debit > 0 ? tx.fee : null,
blockHeight: height,
timestamp: tx.status.block_time ?? null,
confirmations,
// No label store in this backend; SendCoinsRequest.label is accepted and dropped.
label: null,
destAddresses: amount < 0 ? (theirs.length > 0 ? theirs : ours) : ours,
// Fetching /tx/{txid}/hex per transaction would double the request count for a list view. The
// contract permits null, and the raw hex is fetched on demand where it is actually needed.
rawHex: null,
};
}