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:
2026-08-04 00:01:18 +00:00
co-authored by Claude Opus 5
parent 5c38236b39
commit 5b30aebb1d
5 changed files with 271 additions and 86 deletions
+113 -20
View File
@@ -1,30 +1,50 @@
import { deleteServiceConnection, getServiceConnection, saveServiceConnection } from 'officerdb'; import { deleteServiceConnection, getServiceConnection, saveServiceConnection } from 'officerdb';
import { invalidateAll } from './resolve'; import { invalidateAll } from './resolve';
import { getChainSource, getConfig, invalidateChainSource, normalizeEsploraUrl } from './upstream'; import type { ChainKind } from './upstream';
import { CHAIN_KINDS, getChainSource, getConfig, invalidateChainSource, normalizeEsploraUrl } from './upstream';
import type { BitcoinNetwork } from './types';
// `/_config` — which Esplora endpoint this owner's on-chain wallets read the chain from. // `/_config` — which endpoint this owner's on-chain wallets read the chain from, and which protocol it
// speaks.
// //
// This is the one wallet setting that is NOT per-wallet and NOT key material: an endpoint serves every // This is the one wallet setting that is NOT per-wallet and NOT key material: an endpoint serves every
// on-chain wallet the owner has, and it holds no secret, because Esplora is an unauthenticated read API. // on-chain wallet the owner has, and it holds no secret, because both supported kinds are unauthenticated
// It went from an env var to a row for one reason — it is the setting most likely to need changing, and // read APIs. It went from an env var to a row for one reason — it is the setting most likely to need
// the reason to change it (a public explorer rate-limiting you, or seeing every address you own) shows // changing, and the reason to change it (a public explorer rate-limiting you, or seeing every address you
// up long after the deployment that set it. // own) shows up long after the deployment that set it.
// //
// PRIVACY, NOT CUSTODY. An Esplora endpoint learns which addresses you ask about, so pointing this at // TWO KINDS, ONE ROW. Esplora and NBXplorer are stored as separate services but are mutually exclusive:
// your own indexer is a real privacy upgrade. It is never a custody risk: the endpoint sees no key, and // saving one deletes the other, so "which endpoint is in use" never depends on precedence. See
// a hostile one can lie about balances but cannot move a coin. // upstream.ts::getChainSource.
//
// PRIVACY, NOT CUSTODY. A chain endpoint learns which addresses you ask about, so pointing this at your
// own indexer is a real privacy upgrade. It is never a custody risk: the endpoint sees no key, and a
// hostile one can lie about balances but cannot move a coin.
export type ChainProbe = { ok: boolean; blockHeight?: number | null; error?: string; ms?: number }; export type ChainProbe = { ok: boolean; blockHeight?: number | null; error?: string; ms?: number };
/** What each kind reports itself as, versus what this deployment is configured for. */
const NETWORK_ALIASES: Record<BitcoinNetwork, readonly string[]> = {
bitcoin: ['mainnet', 'main', 'bitcoin'],
testnet: ['testnet', 'test'],
signet: ['signet'],
regtest: ['regtest'],
};
/** Does this endpoint answer, does it speak the protocol we think, and is it on OUR chain? */
export async function probe(baseUrl: string, kind: ChainKind): Promise<ChainProbe> {
return kind === 'nbxplorer' ? probeNbxplorer(baseUrl) : probeEsplora(baseUrl);
}
/** /**
* Is there an Esplora API at this URL, and does it know the tip?
*
* `/blocks/tip/height` is the cheapest endpoint Esplora has and the one every implementation has — a * `/blocks/tip/height` is the cheapest endpoint Esplora has and the one every implementation has — a
* bare number in the body. Anything else answering (a bitcoind RPC port, a mempool.space *web* root, an * bare number in the body. Anything else answering (a bitcoind RPC port, a mempool.space *web* root, an
* nginx default page) fails here rather than at the first address scan, which is the whole point: the * nginx default page) fails here rather than at the first address scan, which is the whole point: the
* owner finds out while looking at the form. * owner finds out while looking at the form.
*
* Esplora does not say which chain it serves, so unlike NBXplorer there is nothing to cross-check here.
*/ */
export async function probe(baseUrl: string): Promise<ChainProbe> { async function probeEsplora(baseUrl: string): Promise<ChainProbe> {
const started = Date.now(); const started = Date.now();
try { try {
const res = await fetch(`${baseUrl}/blocks/tip/height`, { const res = await fetch(`${baseUrl}/blocks/tip/height`, {
@@ -50,17 +70,80 @@ export async function probe(baseUrl: string): Promise<ChainProbe> {
} }
} }
/**
* `/v1/cryptos/BTC/status` — NBXplorer's own liveness endpoint, and richer than Esplora's.
*
* It names the chain it is indexing, so a testnet NBXplorer accepted for a mainnet wallet is caught HERE
* rather than by the owner wondering why their balance is zero. That mistake is easy to make (one docker
* flag) and expensive to discover late, so the mismatch is a hard failure, not a warning.
*
* A node still catching up is allowed through with its height reported: it will be correct shortly, and
* refusing to save a config because of a temporary sync state would be its own kind of wrong.
*/
async function probeNbxplorer(baseUrl: string): Promise<ChainProbe> {
const started = Date.now();
try {
const res = await fetch(`${baseUrl}/v1/cryptos/BTC/status`, {
headers: { Accept: 'application/json' },
signal: AbortSignal.timeout(8_000),
redirect: 'manual',
});
const body = (await res.text()).trim();
if (!res.ok) {
return { ok: false, error: `endpoint returned ${res.status}: ${body.slice(0, 200)}`, ms: Date.now() - started };
}
let status: { chainHeight?: unknown; networkType?: unknown; isFullySynched?: unknown };
try {
status = JSON.parse(body) as typeof status;
} catch {
return {
ok: false,
error: `that URL answered, but not like NBXplorer (expected JSON, got "${body.slice(0, 60)}")`,
ms: Date.now() - started,
};
}
const height = Number(status.chainHeight);
if (!Number.isFinite(height) || height <= 0) {
return { ok: false, error: 'that endpoint answered but reported no chain height', ms: Date.now() - started };
}
const want = getConfig().network;
const got = String(status.networkType ?? '').toLowerCase();
if (got && !NETWORK_ALIASES[want].includes(got)) {
return {
ok: false,
error: `that NBXplorer is indexing ${got}, but this wallet is on ${want}. Refusing to point a ${want} wallet at a ${got} node.`,
ms: Date.now() - started,
};
}
return { ok: true, blockHeight: height, ms: Date.now() - started };
} catch (err) {
return { ok: false, error: `could not reach the endpoint (${String(err)})`, ms: Date.now() - started };
}
}
const bad = (error: string, status = 400) => Response.json({ error }, { status }); const bad = (error: string, status = 400) => Response.json({ error }, { status });
const isHttpUrl = (url: string): boolean => /^https?:\/\//i.test(url); const isHttpUrl = (url: string): boolean => /^https?:\/\//i.test(url);
/** The URL in use plus whether it is the owner's or the built-in default. No secrets exist here at all. */ /**
* The endpoint in use, which protocol it speaks, and whether it is the owner's or the built-in default.
* No secrets exist here at all.
*
* `esploraUrl` is kept as a field name despite now possibly holding an NBXplorer URL: it is the URL in
* use, and renaming it would break the stored dashboard state of anything already reading it. `kind`
* says what it actually is.
*/
async function state(userId: number): Promise<Response> { async function state(userId: number): Promise<Response> {
const connection = await getServiceConnection(userId, 'esplora');
const source = await getChainSource(userId); const source = await getChainSource(userId);
const connection = source.custom ? await getServiceConnection(userId, source.kind) : null;
return Response.json({ return Response.json({
configured: !!connection, configured: !!connection,
connection, connection,
kind: source.kind,
esploraUrl: source.baseUrl, esploraUrl: source.baseUrl,
custom: source.custom, custom: source.custom,
defaultUrl: getConfig().esploraUrl, defaultUrl: getConfig().esploraUrl,
@@ -68,22 +151,31 @@ async function state(userId: number): Promise<Response> {
}); });
} }
type ConfigBody = { url?: unknown }; type ConfigBody = { url?: unknown; kind?: unknown };
const isChainKind = (v: unknown): v is ChainKind =>
typeof v === 'string' && (CHAIN_KINDS as readonly string[]).includes(v);
async function save(req: Request, userId: number): Promise<Response> { async function save(req: Request, userId: number): Promise<Response> {
const body = ((await req.json().catch(() => null)) as ConfigBody | null) ?? {}; const body = ((await req.json().catch(() => null)) as ConfigBody | null) ?? {};
const url = typeof body.url === 'string' ? normalizeEsploraUrl(body.url) : ''; const url = typeof body.url === 'string' ? normalizeEsploraUrl(body.url) : '';
// Absent kind means a client written before the picker existed; those only ever spoke Esplora.
const kind: ChainKind = isChainKind(body.kind) ? body.kind : 'esplora';
if (!url) return bad('url is required'); if (!url) return bad('url is required');
if (!isHttpUrl(url)) return bad('url must start with http:// or https://'); if (!isHttpUrl(url)) return bad('url must start with http:// or https://');
const result = await probe(url); const result = await probe(url, kind);
if (!result.ok) return bad(result.error ?? 'that endpoint did not answer'); if (!result.ok) return bad(result.error ?? 'that endpoint did not answer');
await saveServiceConnection({ userId, service: 'esplora', url }); await saveServiceConnection({ userId, service: kind, url });
// Exactly one chain source exists at a time. Retiring the other kind here is what lets getChainSource
// answer without a precedence rule, and what stops a stale row from resurfacing if this one is deleted.
for (const other of CHAIN_KINDS) if (other !== kind) await deleteServiceConnection(userId, other);
invalidateChainSource(userId); invalidateChainSource(userId);
// Every on-chain backend holds an EsploraChain built from the old URL, and a cached gap-limit scan // Every on-chain backend holds a chain source built from the old URL, and a cached gap-limit scan taken
// taken through it. Both are now wrong, so drop the instances rather than trying to patch them. // through it. Both are now wrong, so drop the instances rather than trying to patch them.
invalidateAll(); invalidateAll();
return Response.json({ ...(await state(userId).then((r) => r.json())), probe: result }); return Response.json({ ...(await state(userId).then((r) => r.json())), probe: result });
@@ -97,7 +189,8 @@ export async function handleConfigRoute(req: Request, userId: number, subpath: s
if (req.method === 'POST' || req.method === 'PUT') return save(req, userId); if (req.method === 'POST' || req.method === 'PUT') return save(req, userId);
if (req.method === 'DELETE') { if (req.method === 'DELETE') {
await deleteServiceConnection(userId, 'esplora'); // Both, not just the active one: "reset to default" has to mean it regardless of which row exists.
for (const kind of CHAIN_KINDS) await deleteServiceConnection(userId, kind);
invalidateChainSource(userId); invalidateChainSource(userId);
invalidateAll(); invalidateAll();
return state(userId); return state(userId);
+21 -30
View File
@@ -1,6 +1,7 @@
import type { SidecarCommand, SidecarEvent } from '../protocol'; import type { SidecarCommand, SidecarEvent } from '../protocol';
import type { ChainSource } from './upstream';
import { createSidecarConnector } from '../connect'; import { createSidecarConnector } from '../connect';
import { handleConfigRoute } from './config'; import { handleConfigRoute, probe } from './config';
import { handleOfficerRoute } from './routes'; import { handleOfficerRoute } from './routes';
import { getChainSource, getConfig, hasStoreKey } from './upstream'; import { getChainSource, getConfig, hasStoreKey } from './upstream';
import { lockAll } from './keys'; import { lockAll } from './keys';
@@ -31,8 +32,8 @@ import { invalidateAll } from './resolve';
// HTTP CONTRACT — the platform strips its /api/wallet mount prefix before forwarding. // HTTP CONTRACT — the platform strips its /api/wallet mount prefix before forwarding.
// //
// GET /_health ours. Reports network, chain reachability, store key. // GET /_health ours. Reports network, chain reachability, store key.
// GET /_config the owner's Esplora endpoint (or the built-in default) // GET /_config the owner's chain endpoint (or the built-in default)
// PUT /_config {url} — probed before it is stored // PUT /_config {url, kind} — probed as that kind before it is stored
// DELETE /_config fall back to the built-in default // DELETE /_config fall back to the built-in default
// GET /_officer/config network + unlock TTL + storeKeyConfigured // GET /_officer/config network + unlock TTL + storeKeyConfigured
// //
@@ -128,41 +129,31 @@ const server = Bun.serve({
// the default green while the owner's own indexer is down would be worse than no health at all. // the default green while the owner's own indexer is down would be worse than no health at all.
// The header is optional here (health predates it), so fall back rather than 401. // The header is optional here (health predates it), so fall back rather than 401.
const healthUser = Number(req.headers.get('X-Officer-User')); const healthUser = Number(req.headers.get('X-Officer-User'));
const chain = const chain: ChainSource =
Number.isInteger(healthUser) && healthUser > 0 Number.isInteger(healthUser) && healthUser > 0
? await getChainSource(healthUser) ? await getChainSource(healthUser)
: { baseUrl: cfg.esploraUrl, custom: false }; : { kind: 'esplora', baseUrl: cfg.esploraUrl, custom: false };
const started = Date.now();
try { // Same probe the config form runs, so "saved successfully" and "healthy" can never disagree about
const res = await fetch(`${chain.baseUrl}/blocks/tip/height`, { // what a working endpoint is — including the network cross-check on NBXplorer.
signal: AbortSignal.timeout(5_000), const result = await probe(chain.baseUrl, chain.kind);
});
const height = res.ok ? Number(await res.text()) : null; return Response.json(
return Response.json({ {
ok: res.ok, ok: result.ok,
network: cfg.network, network: cfg.network,
kind: chain.kind,
esplora: chain.baseUrl, esplora: chain.baseUrl,
custom: chain.custom, custom: chain.custom,
blockHeight: Number.isFinite(height) ? height : null, blockHeight: result.blockHeight ?? null,
...(result.error ? { error: result.error } : {}),
// Surfaced because wallet creation is refused without it, and that failure would otherwise // Surfaced because wallet creation is refused without it, and that failure would otherwise
// look like a bug rather than a missing config line. // look like a bug rather than a missing config line.
storeKeyConfigured: hasStoreKey(), storeKeyConfigured: hasStoreKey(),
ms: Date.now() - started, ms: result.ms,
}); },
} catch (err) { { status: result.ok ? 200 : 502 },
return Response.json( );
{
ok: false,
network: cfg.network,
esplora: chain.baseUrl,
custom: chain.custom,
error: String(err),
storeKeyConfigured: hasStoreKey(),
ms: Date.now() - started,
},
{ status: 502 },
);
}
} }
if (url.pathname.startsWith('/_officer/')) { if (url.pathname.startsWith('/_officer/')) {
+41 -11
View File
@@ -1,4 +1,6 @@
import type { ChainCacheStore } from './backends/onchain'; import type { ChainCacheStore } from './backends/onchain';
import type { WalletChainSource } from './chain-source';
import type { ChainSource } from './upstream';
import { import {
getWallet, getWallet,
getWalletSecrets, getWalletSecrets,
@@ -9,6 +11,8 @@ import {
} from 'officerdb'; } from 'officerdb';
import { EsploraChain } from './chain'; import { EsploraChain } from './chain';
import { EsploraChainSource } from './chain-source-esplora'; import { EsploraChainSource } from './chain-source-esplora';
import { NbxplorerChain } from './nbxplorer';
import { NbxplorerChainSource } from './chain-source-nbxplorer';
import { LndBackend } from './backends/lnd'; import { LndBackend } from './backends/lnd';
import { ClnRestBackend } from './backends/clnrest'; import { ClnRestBackend } from './backends/clnrest';
import { LndHubBackend } from './backends/lndhub'; 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. */ /** 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 { function versionOf(wallet: WalletSummary, config: Record<string, unknown> | null, source: ChainSource): string {
return JSON.stringify([wallet.kind, wallet.network, wallet.defaultBip, wallet.xpubs, config, esploraUrl]); // 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 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 secrets = await getWalletSecrets(userId, walletId);
const config = secrets?.config ?? null; 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. // different indexer rebuilds every backend and drops the scan taken through the old one.
const { baseUrl: esploraUrl } = await getChainSource(userId); const source = await getChainSource(userId);
const version = versionOf(wallet, config, esploraUrl); const version = versionOf(wallet, config, source);
const hit = cache.get(walletId); const hit = cache.get(walletId);
if (hit && hit.configVersion === version) return { wallet, backend: hit.backend }; 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 }); cache.set(walletId, { backend, configVersion: version });
return { wallet, backend }; return { wallet, backend };
} }
@@ -85,7 +99,7 @@ function required(config: Record<string, unknown> | null, key: string, kind: str
return v; 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; const network = wallet.network as BitcoinNetwork;
switch (wallet.kind) { 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'); throw new BackendError('wallet has no account xpubs', 500, 'BAD_CONFIG');
} }
return new OnchainBackend({ return new OnchainBackend({
chain: new EsploraChainSource({ chain: chainSourceFor(source, network),
chain: new EsploraChain({ baseUrl: esploraUrl, network }),
label: `esplora(${hostOf(esploraUrl)})`,
}),
network, network,
accountXpub, accountXpub,
// The session is the signer. While locked it holds no key material, so watch-only reads below // 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. */ /** Host only, for a chain-source label. A malformed URL is labelled with itself rather than throwing. */
function hostOf(url: string): string { function hostOf(url: string): string {
try { try {
+23 -6
View File
@@ -50,8 +50,17 @@ export function getConfig(): WalletConfig {
}; };
} }
/**
* Which protocol the endpoint speaks. Not interchangeable — see chain-source.ts for why the two are
* different shapes rather than two URLs for the same thing.
*/
export const CHAIN_KINDS = ['nbxplorer', 'esplora'] as const;
export type ChainKind = (typeof CHAIN_KINDS)[number];
export type ChainSource = { export type ChainSource = {
/** Esplora API root, no trailing slash. */ kind: ChainKind;
/** API root, no trailing slash. */
baseUrl: string; baseUrl: string;
/** True when this is the owner's stored endpoint rather than the built-in public default. */ /** True when this is the owner's stored endpoint rather than the built-in public default. */
custom: boolean; custom: boolean;
@@ -66,6 +75,10 @@ export const normalizeEsploraUrl = (url: string): string => url.trim().replace(/
/** /**
* Where to read the chain, for this owner. * Where to read the chain, for this owner.
* *
* AT MOST ONE of the two rows exists: saving either kind retires the other (config.ts::save), because a
* wallet reads from one place and storing two would leave the answer to precedence rather than to the
* owner. The order below is therefore a tiebreak that should never be needed, not a preference.
*
* Falls back to the public default rather than failing: a wallet with no stored endpoint is still a * Falls back to the public default rather than failing: a wallet with no stored endpoint is still a
* working watch-only wallet, just one pointed at an explorer that rate-limits and can see every address * working watch-only wallet, just one pointed at an explorer that rate-limits and can see every address
* it asks about. The default is a starting point, not a destination. * it asks about. The default is a starting point, not a destination.
@@ -74,15 +87,19 @@ export async function getChainSource(userId: number): Promise<ChainSource> {
const hit = chainCache.get(userId); const hit = chainCache.get(userId);
if (hit && Date.now() - hit.at < CHAIN_TTL_MS) return hit.source; if (hit && Date.now() - hit.at < CHAIN_TTL_MS) return hit.source;
const creds = await getServiceCredentials(userId, 'esplora'); const source = await readChainSource(userId);
const source: ChainSource = creds?.url
? { baseUrl: normalizeEsploraUrl(creds.url), custom: true }
: { baseUrl: getConfig().esploraUrl, custom: false };
chainCache.set(userId, { source, at: Date.now() }); chainCache.set(userId, { source, at: Date.now() });
return source; return source;
} }
async function readChainSource(userId: number): Promise<ChainSource> {
for (const kind of CHAIN_KINDS) {
const creds = await getServiceCredentials(userId, kind);
if (creds?.url) return { kind, baseUrl: normalizeEsploraUrl(creds.url), custom: true };
}
return { kind: 'esplora', baseUrl: getConfig().esploraUrl, custom: false };
}
export function invalidateChainSource(userId: number): void { export function invalidateChainSource(userId: number): void {
chainCache.delete(userId); chainCache.delete(userId);
} }
@@ -12,14 +12,19 @@ import {
// Where on-chain wallets read the chain from. One endpoint serves every on-chain wallet the owner has, // Where on-chain wallets read the chain from. One endpoint serves every on-chain wallet the owner has,
// which is why it sits here and not on a wallet. // which is why it sits here and not on a wallet.
// //
// PRIVACY, NOT CUSTODY. An Esplora endpoint learns every address you ask it about, so pointing this at // PRIVACY, NOT CUSTODY. A chain endpoint learns every address you ask it about, so pointing this at your
// your own indexer is a real upgrade — but it never sees a key and cannot move a coin. That framing is // own node is a real upgrade — but it never sees a key and cannot move a coin. That framing is on the
// on the card deliberately: the risk of getting it wrong is being watched, not being robbed. // card deliberately: the risk of getting it wrong is being watched, not being robbed.
// //
// It must speak the Esplora REST API. bitcoind's own RPC is NOT that — running your own node means // TWO PROTOCOLS, AND THEY ARE NOT INTERCHANGEABLE. Esplora is the public-explorer API (electrs, esplora,
// running electrs, esplora or mempool.space alongside it and pointing this at that. // mempool.space); NBXplorer is what you run in front of your own bitcoind. The picker is not cosmetic —
// it selects a different client, so a URL saved under the wrong one fails the probe rather than silently
// misbehaving. bitcoin-core's own RPC port is neither.
type ChainKind = 'esplora' | 'nbxplorer';
type ChainState = ServiceConnectionState & { type ChainState = ServiceConnectionState & {
kind: ChainKind;
esploraUrl: string; esploraUrl: string;
custom: boolean; custom: boolean;
defaultUrl: string; defaultUrl: string;
@@ -28,29 +33,48 @@ type ChainState = ServiceConnectionState & {
type SaveResult = ChainState & { probe?: { blockHeight?: number | null; ms?: number } }; type SaveResult = ChainState & { probe?: { blockHeight?: number | null; ms?: number } };
type KindMeta = { label: string; placeholder: string; hint: string };
const KINDS: Record<ChainKind, KindMeta> = {
esplora: {
label: 'Esplora',
placeholder: 'https://mempool.space/api',
hint: 'An Esplora REST API — electrs, esplora, or a mempool.space instance.',
},
nbxplorer: {
label: 'NBXplorer',
placeholder: 'http://127.0.0.1:32838',
hint: 'NBXplorer in front of your own bitcoind. Its API root, not bitcoinds RPC port.',
},
};
export const ChainSourceCard = () => { export const ChainSourceCard = () => {
const { data, isLoading } = useServiceConnection<ChainState>('wallet'); const { data, isLoading } = useServiceConnection<ChainState>('wallet');
const { save, forget } = useServiceConnectionActions<{ url: string }, SaveResult>('wallet'); const { save, forget } = useServiceConnectionActions<{ url: string; kind: ChainKind }, SaveResult>('wallet');
const [url, setUrl] = useState(''); const [url, setUrl] = useState('');
const [kind, setKind] = useState<ChainKind>('esplora');
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [height, setHeight] = useState<number | null>(null); const [height, setHeight] = useState<number | null>(null);
// Seeded from whatever is in use, so the field starts as the answer to "what is this pointed at" and the // Seeded from whatever is in use, so the form starts as the answer to "what is this pointed at" and the
// owner edits it rather than retyping it. Keyed on the URL: a save or reset should re-seed. // owner edits it rather than retyping it. Keyed on both fields: a save or reset should re-seed.
useEffect(() => { useEffect(() => {
if (data) setUrl(data.esploraUrl); if (!data) return;
}, [data?.esploraUrl]); setUrl(data.esploraUrl);
setKind(data.kind);
}, [data?.esploraUrl, data?.kind]);
const trimmed = url.trim(); const trimmed = url.trim();
const dirty = !!data && trimmed !== data.esploraUrl; const dirty = !!data && (trimmed !== data.esploraUrl || kind !== data.kind);
const busy = save.isPending || forget.isPending; const busy = save.isPending || forget.isPending;
const meta = KINDS[kind];
const submit = async () => { const submit = async () => {
setError(null); setError(null);
setHeight(null); setHeight(null);
try { try {
const result = await save.mutateAsync({ url: trimmed }); const result = await save.mutateAsync({ url: trimmed, kind });
setHeight(result.probe?.blockHeight ?? null); setHeight(result.probe?.blockHeight ?? null);
} catch (err) { } catch (err) {
setError(serviceErrorMessage(err)); setError(serviceErrorMessage(err));
@@ -67,14 +91,45 @@ export const ChainSourceCard = () => {
} }
}; };
/** Switching protocol clears a URL that belongs to the other one — it would only fail the probe. */
const pick = (next: ChainKind) => {
if (next === kind) return;
setKind(next);
setError(null);
setHeight(null);
if (data && trimmed === data.esploraUrl && next !== data.kind) setUrl('');
};
return ( return (
<section className="max-w-2xl rounded-xl border border-border p-4"> <section className="max-w-2xl rounded-xl border border-border p-4">
<h3 className="mb-2 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">Chain source</h3> <h3 className="mb-2 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">Chain source</h3>
<p className="mb-3 text-xs text-muted-foreground"> <p className="mb-3 text-xs text-muted-foreground">
The Esplora API on-chain wallets read balances and history from. It sees every address this wallet asks about, Where on-chain wallets read balances and history. It sees every address this wallet asks about, so your own node
so your own indexer is a privacy upgrade it never sees a key and cannot spend. is a privacy upgrade it never sees a key and cannot spend.
</p> </p>
<div
role="radiogroup"
aria-label="Chain source protocol"
className="mb-2 inline-flex rounded-lg border border-border p-0.5"
>
{(Object.keys(KINDS) as ChainKind[]).map((k) => (
<button
key={k}
type="button"
role="radio"
aria-checked={kind === k}
onClick={() => pick(k)}
disabled={isLoading || busy}
className={`rounded-md px-3 py-1 text-xs font-medium transition-colors disabled:opacity-50 ${
kind === k ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:text-foreground'
}`}
>
{KINDS[k].label}
</button>
))}
</div>
<div className="flex flex-col gap-2 sm:flex-row"> <div className="flex flex-col gap-2 sm:flex-row">
<Input <Input
value={url} value={url}
@@ -82,10 +137,10 @@ export const ChainSourceCard = () => {
onKeyDown={(ev) => { onKeyDown={(ev) => {
if (ev.key === 'Enter' && trimmed && dirty && !busy) void submit(); if (ev.key === 'Enter' && trimmed && dirty && !busy) void submit();
}} }}
placeholder={data?.defaultUrl ?? 'https://mempool.space/api'} placeholder={meta.placeholder}
disabled={isLoading || busy} disabled={isLoading || busy}
className="h-8 font-mono text-xs" className="h-8 font-mono text-xs"
aria-label="Esplora API URL" aria-label={`${meta.label} API URL`}
autoComplete="off" autoComplete="off"
spellCheck={false} spellCheck={false}
/> />
@@ -119,10 +174,9 @@ export const ChainSourceCard = () => {
<p className="mt-2 text-[11px] text-muted-foreground"> <p className="mt-2 text-[11px] text-muted-foreground">
{data?.custom {data?.custom
? 'Using your endpoint.' ? `Using your ${KINDS[data.kind].label} endpoint.`
: `Using the built-in default (${data?.defaultUrl ?? '—'}), which rate-limits and can see every address you look up.`}{' '} : `Using the built-in default (${data?.defaultUrl ?? '—'}), which rate-limits and can see every address you look up.`}{' '}
This must be an <span className="font-mono">Esplora</span> REST API electrs, esplora or a mempool.space {meta.hint}
instance. bitcoin-core&apos;s own RPC port will not work.
</p> </p>
</section> </section>
); );