the gap limit was 1000, hardcoded, with the comment noting that raising it costs node CPU and not correctness — and no way to raise it. it is the one thing about a scan only the owner can know: how many addresses their old wallet handed out and never had paid. RescanOptions threads from the POST body through the backend and the source to utxos/scan, capped at 100k because past that the scan takes longer than anyone waits. the card gets a "search depth" field beside the button, blank meaning the default. deleting a wallet with no seed took one unconfirmed request. the dialog asked for the wallet's name and then threw the answer away, so the check existed only for whoever went through the dialog — a node wallet still holds the credential, the labels and the freezes. confirmName now travels with the request and the route enforces it. a bodyless DELETE is told which field is missing rather than that its JSON did not parse. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
173 lines
7.8 KiB
TypeScript
173 lines
7.8 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, 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<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.
|
|
*
|
|
* `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<RescanHandle | null>;
|
|
|
|
/** 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;
|
|
}
|