let the owner pick which chain source the wallet reads from

esplora and nbxplorer are now both selectable from wallet settings. the two are
stored as separate service_connections rows but are mutually exclusive: saving
either retires the other, so "which endpoint is in use" is never decided by a
precedence rule.

the nbxplorer probe cross-checks the chain it reports indexing against the
configured network, so pointing a mainnet wallet at a testnet node is refused at
the form rather than discovered later as an unexplained zero balance. esplora
cannot report this, so there is nothing to check there.

/_health now runs the same probe the form does, instead of its own hardcoded
esplora path — the two can no longer disagree about what a working endpoint is.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-04 00:01:18 +00:00
co-authored by Claude Opus 5
parent 5c38236b39
commit 5b30aebb1d
5 changed files with 271 additions and 86 deletions
+41 -11
View File
@@ -1,4 +1,6 @@
import type { ChainCacheStore } from './backends/onchain';
import type { WalletChainSource } from './chain-source';
import type { ChainSource } from './upstream';
import {
getWallet,
getWalletSecrets,
@@ -9,6 +11,8 @@ import {
} from 'officerdb';
import { EsploraChain } from './chain';
import { EsploraChainSource } from './chain-source-esplora';
import { NbxplorerChain } from './nbxplorer';
import { NbxplorerChainSource } from './chain-source-nbxplorer';
import { LndBackend } from './backends/lnd';
import { ClnRestBackend } from './backends/clnrest';
import { LndHubBackend } from './backends/lndhub';
@@ -39,8 +43,18 @@ export function invalidateAll(): void {
}
/** A stable fingerprint of the inputs a backend was built from, so a stale instance is detected. */
function versionOf(wallet: WalletSummary, config: Record<string, unknown> | null, esploraUrl: string): string {
return JSON.stringify([wallet.kind, wallet.network, wallet.defaultBip, wallet.xpubs, config, esploraUrl]);
function versionOf(wallet: WalletSummary, config: Record<string, unknown> | null, source: ChainSource): string {
// The kind is in here as well as the URL: switching protocol at the same host is a different backend
// with a differently-shaped cache behind it, and a fingerprint on the URL alone would miss it.
return JSON.stringify([
wallet.kind,
wallet.network,
wallet.defaultBip,
wallet.xpubs,
config,
source.kind,
source.baseUrl,
]);
}
export type Resolved = { wallet: WalletSummary; backend: WalletBackend };
@@ -51,15 +65,15 @@ export async function resolveBackend(userId: number, walletId: number): Promise<
const secrets = await getWalletSecrets(userId, walletId);
const config = secrets?.config ?? null;
// Read per resolve rather than per build: the URL is in the fingerprint, so pointing the owner at a
// Read per resolve rather than per build: the source is in the fingerprint, so pointing the owner at a
// different indexer rebuilds every backend and drops the scan taken through the old one.
const { baseUrl: esploraUrl } = await getChainSource(userId);
const version = versionOf(wallet, config, esploraUrl);
const source = await getChainSource(userId);
const version = versionOf(wallet, config, source);
const hit = cache.get(walletId);
if (hit && hit.configVersion === version) return { wallet, backend: hit.backend };
const backend = build(wallet, config, esploraUrl);
const backend = build(wallet, config, source);
cache.set(walletId, { backend, configVersion: version });
return { wallet, backend };
}
@@ -85,7 +99,7 @@ function required(config: Record<string, unknown> | null, key: string, kind: str
return v;
}
function build(wallet: WalletSummary, config: Record<string, unknown> | null, esploraUrl: string): WalletBackend {
function build(wallet: WalletSummary, config: Record<string, unknown> | null, source: ChainSource): WalletBackend {
const network = wallet.network as BitcoinNetwork;
switch (wallet.kind) {
@@ -128,10 +142,7 @@ 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 EsploraChainSource({
chain: new EsploraChain({ baseUrl: esploraUrl, network }),
label: `esplora(${hostOf(esploraUrl)})`,
}),
chain: chainSourceFor(source, network),
network,
accountXpub,
// The session is the signer. While locked it holds no key material, so watch-only reads below
@@ -146,6 +157,25 @@ function build(wallet: WalletSummary, config: Record<string, unknown> | null, es
}
}
/**
* The one place the owner's chosen protocol turns into an implementation. Both satisfy the same
* interface, and OnchainBackend cannot tell which it was handed — that is the entire point of the seam
* (chain-source.ts). Adding a third kind is a case here and a file next to it, and nothing else.
*/
function chainSourceFor(source: ChainSource, network: BitcoinNetwork): WalletChainSource {
const label = `${source.kind}(${hostOf(source.baseUrl)})`;
if (source.kind === 'nbxplorer') {
return new NbxplorerChainSource({
chain: new NbxplorerChain({ baseUrl: source.baseUrl, network }),
network,
label,
});
}
return new EsploraChainSource({ chain: new EsploraChain({ baseUrl: source.baseUrl, network }), label });
}
/** Host only, for a chain-source label. A malformed URL is labelled with itself rather than throwing. */
function hostOf(url: string): string {
try {