Files
platform/src/servers/sidecar/wallet/chain-source.ts
T
pastilhasandClaude Opus 5 e0f6a469aa rate-limit every passphrase check, and stop address issuance outrunning the scan
two holes found auditing the wallet sidecar after the frozen-utxo fix.

the brute-force backoff lived inside UnlockSession.unlock alone, so /unlock capped
at five guesses a minute while export-seed — the one endpoint that returns the words
in the clear — took unlimited ones. every passphrase check now goes through the same
guard. verifyPassphrase rethrows LOCKED_OUT rather than folding it into `false`, so a
caller can tell "wrong" from "stop".

nextUnused advanced its mark on every issuance, paid or not, so a run of unpaid
addresses walked it past the end of the window the next scan covers; a payment there
would never be found again, and esplora has no rescan to go looking. sources now
declare how far past a scan's last index they can still see, and issuance clamps to
it — re-offering a virgin address rather than handing out one that could lose money.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 20:41:46 +00:00

161 lines
7.2 KiB
TypeScript

// 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, 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<RescanState> };
/** 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<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>;
/**
* 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.
*
* 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[]): RescanHandle;
/** 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<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;
}