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>
187 lines
7.4 KiB
TypeScript
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;
|
|
}
|
|
}
|