Files
platform/src/servers/sidecar/wallet/resolve.ts
T
pastilhasandClaude Opus 5 5b30aebb1d 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>
2026-08-04 00:01:18 +00:00

187 lines
7.4 KiB
TypeScript

import type { ChainCacheStore } from './backends/onchain';
import type { WalletChainSource } from './chain-source';
import type { ChainSource } from './upstream';
import {
getWallet,
getWalletSecrets,
getWalletChainCache,
saveWalletChainCache,
recordWalletChainError,
type WalletSummary,
} 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';
import { NwcBackend } from './backends/nwc';
import { OnchainBackend } from './backends/onchain';
import { sessionFor } from './keys';
import { getChainSource } from './upstream';
import { BackendError, BIP_ADDRESS_TYPE, type AddressType, type BitcoinNetwork, type WalletBackend } from './types';
// Turns a stored wallet row into a live backend instance. This is the one place that knows the mapping
// from `kind` to a class, and the one place node credentials are decrypted — getWalletSecrets() is
// called here and the plaintext never travels further than the constructor it is handed to.
//
// Instances are cached per wallet id. Backends hold connection state worth reusing (LNDHub's access
// token, NWC's relay socket, the on-chain gap-limit scan), and rebuilding one per request would both
// re-authenticate constantly and defeat the address-scan cache. The cache is invalidated whenever the
// wallet's config changes — see `invalidate()`, called from the update/delete routes.
type Cached = { backend: WalletBackend; configVersion: string };
const cache = new Map<number, Cached>();
export function invalidate(walletId: number): void {
cache.delete(walletId);
}
export function invalidateAll(): void {
cache.clear();
}
/** 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, 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 };
export async function resolveBackend(userId: number, walletId: number): Promise<Resolved> {
const wallet = await getWallet(userId, walletId);
if (!wallet) throw new BackendError('wallet not found', 404, 'NOT_FOUND');
const secrets = await getWalletSecrets(userId, walletId);
const config = secrets?.config ?? null;
// 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 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, source);
cache.set(walletId, { backend, configVersion: version });
return { wallet, backend };
}
/**
* Binds the chain cache queries to one wallet id. This is the only thing that gives the on-chain
* backend a persistent identity — the backend itself never learns which wallet it is, exactly as it
* never learns which seed it derives from.
*/
function chainCacheFor(walletId: number): ChainCacheStore {
return {
load: () => getWalletChainCache(walletId),
save: (snapshot) => saveWalletChainCache(walletId, snapshot),
recordError: (message) => recordWalletChainError(walletId, message),
};
}
function required(config: Record<string, unknown> | null, key: string, kind: string): string {
const v = config?.[key];
if (typeof v !== 'string' || !v) {
throw new BackendError(`${kind} wallet is missing required config "${key}"`, 400, 'BAD_CONFIG');
}
return v;
}
function build(wallet: WalletSummary, config: Record<string, unknown> | null, source: ChainSource): WalletBackend {
const network = wallet.network as BitcoinNetwork;
switch (wallet.kind) {
case 'lnd':
return new LndBackend({
url: required(config, 'url', 'lnd'),
macaroonHex: required(config, 'macaroonHex', 'lnd'),
allowSelfSigned: config?.allowSelfSigned === true,
});
case 'cln-rest':
return new ClnRestBackend({
url: required(config, 'url', 'cln-rest'),
rune: required(config, 'rune', 'cln-rest'),
allowSelfSigned: config?.allowSelfSigned === true,
});
case 'lndhub':
return new LndHubBackend({
url: required(config, 'url', 'lndhub'),
login: required(config, 'login', 'lndhub'),
password: required(config, 'password', 'lndhub'),
});
case 'nwc':
return new NwcBackend({ connectionUri: required(config, 'connectionUri', 'nwc') });
case 'onchain': {
// Every xpub the wallet holds is handed over, not just the default BIP's. A seed derives all four
// accounts (keys.ts::deriveAccountXpubs), and coins can legitimately sit on any of them — a
// recovered seed may have been used with a p2tr wallet before, or received to a legacy address.
// Scanning only the default account would silently under-report the balance and leave those UTXOs
// unspendable. `defaultBip` then means only "which script type new receive addresses use".
const accountXpub: Partial<Record<AddressType, string>> = {};
for (const [bip, type] of Object.entries(BIP_ADDRESS_TYPE)) {
const xpub = wallet.xpubs?.[bip];
if (xpub) accountXpub[type] = xpub;
}
if (Object.keys(accountXpub).length === 0) {
throw new BackendError('wallet has no account xpubs', 500, 'BAD_CONFIG');
}
return new OnchainBackend({
chain: chainSourceFor(source, network),
network,
accountXpub,
// The session is the signer. While locked it holds no key material, so watch-only reads below
// still work and only sendCoins/signMessage will throw WalletLockedError.
signer: sessionFor(wallet.id),
cache: chainCacheFor(wallet.id),
});
}
default:
throw new BackendError(`unknown wallet kind "${wallet.kind}"`, 400, 'BAD_CONFIG');
}
}
/**
* 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 {
return new URL(url).host;
} catch {
return url;
}
}