// What a chain source owes the on-chain wallet, and the vocabulary the two halves speak. // // THE SPLIT IS AT THE SCAN, NOT AT THE HTTP CLIENT. This is the whole point of the file and it is not // obvious, so: Esplora is address-level — you ask about one script at a time, and finding a wallet means // walking the gap limit yourself, 43-160 requests a refresh. NBXplorer is wallet-level — you register the // account xpub once and then every question is a single call. There is no common denominator at the // request layer, because NBXplorer simply has no "tell me about this one address" endpoint to implement. // // So the seam is drawn one level up, at `scan()`: "read this wallet off the chain and hand back what you // found". Esplora satisfies it with a gap-limit walk; NBXplorer satisfies it with three calls and no walk // at all. Everything downstream — balances, coin selection, PSBT construction, signing, broadcast — works // off the ScanResult and does not know or care which one answered. // // Deriving addresses is NOT a source's job. The keys belong to the backend, and `ScanContext.derive` is // how a source asks for one. A source that could derive independently could disagree with the wallet // about which addresses are the wallet's, which is the kind of bug that loses coins rather than failing. import type { SpendableUtxo } from './psbt'; import type { AddressType, FeeEstimates, OnchainTx, RescanOptions, RescanState } from './types'; /** BIP44 chain index: 0 is the receive chain, 1 the internal (change) chain. */ export type ChainIndex = 0 | 1; /** Hard stop on a runaway scan — a misconfigured xpub against a busy chain must not loop forever. */ export const MAX_SCAN_INDEX = 1_000; /** Parallel upstream requests. Public Esplora instances rate-limit, so this stays modest. */ export const REQUEST_CONCURRENCY = 6; /** One address the wallet owns, fully derived. Produced by the backend, consumed by the source. */ export type AddressEntry = { type: AddressType; chain: ChainIndex; index: number; address: string; /** Compressed 33-byte pubkey, hex. */ pubkeyHex: string; scriptPubKeyHex: string; /** Full path from the wallet root — what the signer derives with. */ path: string; /** Path relative to the account xpub, which is what the public Utxo type carries. */ relPath: string; }; /** An address the source looked up, with what the chain says about it. */ export type ScannedAddress = AddressEntry & { confirmedSats: number; unconfirmedSats: number; txCount: number; used: boolean; }; /** * The account keys, in the form a wallet-level source needs them. * * `accountXpub` is the string as stored, which for every BIP this wallet supports is plain `xpub`-version * bytes — NBXplorer's derivation schemes are built straight from it with no SLIP-132 conversion. */ export type ScanAccount = { type: AddressType; accountXpub: string }; /** * Everything a source is handed to perform one scan. Passed per call rather than held at construction so * a source stays stateless with respect to the wallet — the same source instance is safe to share. */ export type ScanContext = { accounts: readonly ScanAccount[]; /** The wallet's own script type. Address-level sources walk this one unconditionally. */ defaultType: AddressType; derive: (type: AddressType, chain: ChainIndex, index: number) => AddressEntry; }; /** * A rescan in flight. * * `state` is the SAME object the source keeps and mutates as the scan progresses, so a caller that holds * it sees the counters move without asking again. `done` is how the wallet knows to re-read the chain: * a rescan that found coins has changed nothing until the snapshot behind it is rebuilt. */ export type RescanHandle = { state: RescanState; done: Promise }; /** One coherent read of the chain — balances, coins and history as of the same moment. */ export type ScanResult = { tipHeight: number; addresses: ScannedAddress[]; utxos: SpendableUtxo[]; txs: OnchainTx[]; }; /** * The chain, as the on-chain wallet needs it. * * Five methods, and only `scan` is interesting. The other four are point lookups that every backend in * existence offers in some form, so they map one-to-one onto whatever is upstream. */ export interface WalletChainSource { /** Human-readable, for logs and the sync-state surface. e.g. `esplora(mempool.space)`. */ readonly label: string; /** * How many indices past the highest one `scan` reported this source will still find a payment on. * * Issuance must not outrun the search that finds the money again. `nextUnused` advances a mark every * time an address is handed out, so a run of addresses issued and never paid walks the mark forward * with nothing marking those indices used — and a later payment to one of them lands beyond the * window the next scan covers. On a source that cannot rescan, that is an invisible, unrecoverable * balance, from nothing worse than clicking "new address" too many times. * * Esplora is 0: its walk already extends a full gap limit past the last used address, so every index * `scan` returned is covered and none beyond it is. NBXplorer reports only up to its own unused mark * but watches a gap beyond it, so a modest overrun is still seen. */ readonly issueAhead: number; /** Current best block height. The cheapest liveness probe the wallet has. */ getTipHeight(): Promise; getFeeEstimates(): Promise; /** * Raw hex of one transaction. Called only for `p2pkh` inputs, which commit to the whole previous * transaction and so need it in the PSBT; segwit inputs carry their own value and script. */ getTxHex(txid: string): Promise; /** Publish a signed transaction. Resolves to the txid, throws if the network rejected it. */ broadcast(rawHex: string): Promise; /** Read the whole wallet off the chain. */ scan(ctx: ScanContext): Promise; /** * Search the chain for this wallet's coins from scratch, rather than from whenever the upstream * started watching it. Returns immediately — a rescan takes minutes, and the caller is an HTTP route. * * `opts.gapLimit` widens the search past the source's own default, for a wallet restored from one that * issued addresses in bulk. Ignored by a source with no such knob. * * Optional, and Esplora does not implement it — it has nothing to rescan, because a gap-limit walk * already asks about every address every time. This exists for an upstream that *indexes*, where a * newly registered account starts empty and stays empty until told to go and look. */ startRescan?(accounts: readonly ScanAccount[], opts?: RescanOptions): RescanHandle; /** * Find a scan already in flight upstream and return a handle to it, without starting one. * * Same handle contract as `startRescan`, so the caller chains its refresh onto `done` identically — * which is the point: an adopted scan has to end in a snapshot rebuild too, or the coins it recovered * stay invisible. Null when nothing is running. */ adoptRescan?(accounts: readonly ScanAccount[]): Promise; /** The rescan in flight, or the last one's outcome. Null when this source has never run one. */ rescanState?(): RescanState | null; } /** Bounded-concurrency map that preserves input order. Address-level sources fan out wide. */ export async function mapLimit(items: T[], limit: number, fn: (item: T) => Promise): Promise { const out = new Array(items.length); let cursor = 0; const worker = async (): Promise => { for (;;) { const i = cursor++; if (i >= items.length) return; const item = items[i]; if (item === undefined) continue; out[i] = await fn(item); } }; await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker)); return out; }