// 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; }; 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 { return this.chain.getTipHeight(); } getFeeEstimates(): Promise { return this.chain.getFeeEstimates(); } getTxHex(txid: string): Promise { return this.chain.getTxHex(txid); } broadcast(rawHex: string): Promise { return this.chain.broadcast(rawHex); } async scan(ctx: ScanContext): Promise { 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 { 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(); 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 { 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 { 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 { // 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((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 { 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(); 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, }; }