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(); 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 | 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 { 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 | 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 | 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> = {}; 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; } }