move the wallet's chain source out of env and into the ui

WALLET_ESPLORA_URL was the one wallet setting an owner actually has to change — off a
public explorer that rate-limits and sees every address, onto their own indexer — and it
was the one they could only change with a shell and a restart. It now lives in
service_connections under 'esplora' and is edited at Wallet -> Settings -> Chain source,
probed against /blocks/tip/height before it is stored.

The URL joins the backend fingerprint, so re-pointing rebuilds every on-chain backend and
drops the gap-limit scan taken through the old endpoint. /_health probes what the wallets
actually use rather than the built-in default, and /_officer/config no longer reports a URL
it cannot know.

Also two receive-screen defects the Blockstream 429 exposed: a query error rendered as
"No address available", and the "new address" button called refetch() on the ?peek=true
query, so it re-fetched the same address instead of advancing the index.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-03 22:59:00 +00:00
co-authored by Claude Opus 5
parent f45bc9292b
commit ff73e86983
13 changed files with 396 additions and 41 deletions
+9 -7
View File
@@ -14,7 +14,7 @@ import { LndHubBackend } from './backends/lndhub';
import { NwcBackend } from './backends/nwc';
import { OnchainBackend } from './backends/onchain';
import { sessionFor } from './keys';
import { getConfig } from './upstream';
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
@@ -38,8 +38,8 @@ 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): string {
return JSON.stringify([wallet.kind, wallet.network, wallet.defaultBip, wallet.xpubs, config]);
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]);
}
export type Resolved = { wallet: WalletSummary; backend: WalletBackend };
@@ -50,12 +50,15 @@ export async function resolveBackend(userId: number, walletId: number): Promise<
const secrets = await getWalletSecrets(userId, walletId);
const config = secrets?.config ?? null;
const version = versionOf(wallet, config);
// Read per resolve rather than per build: the URL 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 hit = cache.get(walletId);
if (hit && hit.configVersion === version) return { wallet, backend: hit.backend };
const backend = build(wallet, config);
const backend = build(wallet, config, esploraUrl);
cache.set(walletId, { backend, configVersion: version });
return { wallet, backend };
}
@@ -81,7 +84,7 @@ function required(config: Record<string, unknown> | null, key: string, kind: str
return v;
}
function build(wallet: WalletSummary, config: Record<string, unknown> | null): WalletBackend {
function build(wallet: WalletSummary, config: Record<string, unknown> | null, esploraUrl: string): WalletBackend {
const network = wallet.network as BitcoinNetwork;
switch (wallet.kind) {
@@ -123,7 +126,6 @@ function build(wallet: WalletSummary, config: Record<string, unknown> | null): W
if (Object.keys(accountXpub).length === 0) {
throw new BackendError('wallet has no account xpubs', 500, 'BAD_CONFIG');
}
const { esploraUrl } = getConfig();
return new OnchainBackend({
chain: new EsploraChain({ baseUrl: esploraUrl, network }),
network,