split the wallet's chain reads from its signing half
OnchainBackend depended on the concrete EsploraChain class, so the only wallet it could ever have was an Esplora-backed one. The seam is now WalletChainSource, and it is drawn at the scan rather than at the HTTP client: Esplora is address-level and has to walk the gap limit, NBXplorer is wallet-level and has no per-address endpoint at all, so there is nothing to share one level down. The backend keeps the keys and the money — derivation, snapshot cache, coin selection, PSBT construction, signing — and owns no HTTP. Which indexer answers is a constructor argument. Also adds the NBXplorer implementation of the seam, verified end to end against the owner's own pruned node, and the first tests over any of this: a stub Esplora drives a real backend through the gap-limit walk, balance summation, UTXO mapping, transaction scoring and address issuance. Nothing covered the scan before it was moved, which is the wrong time to have no tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,123 @@
|
||||
// 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 } 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;
|
||||
};
|
||||
|
||||
/** 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;
|
||||
|
||||
/** Current best block height. The cheapest liveness probe the wallet has. */
|
||||
getTipHeight(): Promise<number>;
|
||||
|
||||
getFeeEstimates(): Promise<FeeEstimates>;
|
||||
|
||||
/**
|
||||
* 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<string>;
|
||||
|
||||
/** Publish a signed transaction. Resolves to the txid, throws if the network rejected it. */
|
||||
broadcast(rawHex: string): Promise<string>;
|
||||
|
||||
/** Read the whole wallet off the chain. */
|
||||
scan(ctx: ScanContext): Promise<ScanResult>;
|
||||
}
|
||||
|
||||
/** Bounded-concurrency map that preserves input order. Address-level sources fan out wide. */
|
||||
export async function mapLimit<T, R>(items: T[], limit: number, fn: (item: T) => Promise<R>): Promise<R[]> {
|
||||
const out = new Array<R>(items.length);
|
||||
let cursor = 0;
|
||||
const worker = async (): Promise<void> => {
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user