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:
2026-08-03 23:50:53 +00:00
co-authored by Claude Opus 5
parent 2d71424d5a
commit 5c38236b39
6 changed files with 944 additions and 231 deletions
+14 -1
View File
@@ -8,6 +8,7 @@ import {
type WalletSummary,
} from 'officerdb';
import { EsploraChain } from './chain';
import { EsploraChainSource } from './chain-source-esplora';
import { LndBackend } from './backends/lnd';
import { ClnRestBackend } from './backends/clnrest';
import { LndHubBackend } from './backends/lndhub';
@@ -127,7 +128,10 @@ function build(wallet: WalletSummary, config: Record<string, unknown> | null, es
throw new BackendError('wallet has no account xpubs', 500, 'BAD_CONFIG');
}
return new OnchainBackend({
chain: new EsploraChain({ baseUrl: esploraUrl, network }),
chain: new EsploraChainSource({
chain: new EsploraChain({ baseUrl: esploraUrl, network }),
label: `esplora(${hostOf(esploraUrl)})`,
}),
network,
accountXpub,
// The session is the signer. While locked it holds no key material, so watch-only reads below
@@ -141,3 +145,12 @@ function build(wallet: WalletSummary, config: Record<string, unknown> | null, es
throw new BackendError(`unknown wallet kind "${wallet.kind}"`, 400, 'BAD_CONFIG');
}
}
/** Host only, for a chain-source label. A malformed URL is labelled with itself rather than throwing. */
function hostOf(url: string): string {
try {
return new URL(url).host;
} catch {
return url;
}
}