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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user