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 { 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
// on-chain wallet the owner has, and it holds no secret, because Esplora is an unauthenticated read API.
// It went from an env var to a row for one reason — it is the setting most likely to need changing, and
// the reason to change it (a public explorer rate-limiting you, or seeing every address you own) shows
// up long after the deployment that set it.
// on-chain wallet the owner has, and it holds no secret, because both supported kinds are unauthenticated
// read APIs. It went from an env var to a row for one reason — it is the setting most likely to need
// changing, and the reason to change it (a public explorer rate-limiting you, or seeing every address you
// 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
// 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.
// TWO KINDS, ONE ROW. Esplora and NBXplorer are stored as separate services but are mutually exclusive:
// saving one deletes the other, so "which endpoint is in use" never depends on precedence. See
// 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 };
/** 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
* 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
* 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();
try {
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 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> {
const connection = await getServiceConnection(userId, 'esplora');
const source = await getChainSource(userId);
const connection = source.custom ? await getServiceConnection(userId, source.kind) : null;
return Response.json({
configured: !!connection,
connection,
kind: source.kind,
esploraUrl: source.baseUrl,
custom: source.custom,
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> {
const body = ((await req.json().catch(() => null)) as ConfigBody | null) ?? {};
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 (!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');
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);
// Every on-chain backend holds an EsploraChain built from the old URL, and a cached gap-limit scan
// taken through it. Both are now wrong, so drop the instances rather than trying to patch them.
// Every on-chain backend holds a chain source built from the old URL, and a cached gap-limit scan taken
// through it. Both are now wrong, so drop the instances rather than trying to patch them.
invalidateAll();
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 === '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);
invalidateAll();
return state(userId);
+19 -28
View File
@@ -1,6 +1,7 @@
import type { SidecarCommand, SidecarEvent } from '../protocol';
import type { ChainSource } from './upstream';
import { createSidecarConnector } from '../connect';
import { handleConfigRoute } from './config';
import { handleConfigRoute, probe } from './config';
import { handleOfficerRoute } from './routes';
import { getChainSource, getConfig, hasStoreKey } from './upstream';
import { lockAll } from './keys';
@@ -31,8 +32,8 @@ import { invalidateAll } from './resolve';
// HTTP CONTRACT — the platform strips its /api/wallet mount prefix before forwarding.
//
// GET /_health ours. Reports network, chain reachability, store key.
// GET /_config the owner's Esplora endpoint (or the built-in default)
// PUT /_config {url} — probed before it is stored
// GET /_config the owner's chain endpoint (or the built-in default)
// PUT /_config {url, kind} — probed as that kind before it is stored
// DELETE /_config fall back to the built-in default
// GET /_officer/config network + unlock TTL + storeKeyConfigured
//
@@ -128,42 +129,32 @@ const server = Bun.serve({
// 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.
const healthUser = Number(req.headers.get('X-Officer-User'));
const chain =
const chain: ChainSource =
Number.isInteger(healthUser) && healthUser > 0
? await getChainSource(healthUser)
: { baseUrl: cfg.esploraUrl, custom: false };
const started = Date.now();
try {
const res = await fetch(`${chain.baseUrl}/blocks/tip/height`, {
signal: AbortSignal.timeout(5_000),
});
const height = res.ok ? Number(await res.text()) : null;
return Response.json({
ok: res.ok,
: { kind: 'esplora', baseUrl: cfg.esploraUrl, custom: false };
// Same probe the config form runs, so "saved successfully" and "healthy" can never disagree about
// what a working endpoint is — including the network cross-check on NBXplorer.
const result = await probe(chain.baseUrl, chain.kind);
return Response.json(
{
ok: result.ok,
network: cfg.network,
kind: chain.kind,
esplora: chain.baseUrl,
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
// look like a bug rather than a missing config line.
storeKeyConfigured: hasStoreKey(),
ms: Date.now() - started,
});
} catch (err) {
return Response.json(
{
ok: false,
network: cfg.network,
esplora: chain.baseUrl,
custom: chain.custom,
error: String(err),
storeKeyConfigured: hasStoreKey(),
ms: Date.now() - started,
ms: result.ms,
},
{ status: 502 },
{ status: result.ok ? 200 : 502 },
);
}
}
if (url.pathname.startsWith('/_officer/')) {
try {
+41 -11
View File
@@ -1,4 +1,6 @@
import type { ChainCacheStore } from './backends/onchain';
import type { WalletChainSource } from './chain-source';
import type { ChainSource } from './upstream';
import {
getWallet,
getWalletSecrets,
@@ -9,6 +11,8 @@ import {
} 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';
@@ -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. */
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]);
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 };
@@ -51,15 +65,15 @@ export async function resolveBackend(userId: number, walletId: number): Promise<
const secrets = await getWalletSecrets(userId, walletId);
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.
const { baseUrl: esploraUrl } = await getChainSource(userId);
const version = versionOf(wallet, config, esploraUrl);
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, esploraUrl);
const backend = build(wallet, config, source);
cache.set(walletId, { backend, configVersion: version });
return { wallet, backend };
}
@@ -85,7 +99,7 @@ function required(config: Record<string, unknown> | null, key: string, kind: str
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;
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');
}
return new OnchainBackend({
chain: new EsploraChainSource({
chain: new EsploraChain({ baseUrl: esploraUrl, network }),
label: `esplora(${hostOf(esploraUrl)})`,
}),
chain: chainSourceFor(source, network),
network,
accountXpub,
// 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. */
function hostOf(url: string): string {
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 = {
/** Esplora API root, no trailing slash. */
kind: ChainKind;
/** API root, no trailing slash. */
baseUrl: string;
/** True when this is the owner's stored endpoint rather than the built-in public default. */
custom: boolean;
@@ -66,6 +75,10 @@ export const normalizeEsploraUrl = (url: string): string => url.trim().replace(/
/**
* 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
* 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.
@@ -74,15 +87,19 @@ export async function getChainSource(userId: number): Promise<ChainSource> {
const hit = chainCache.get(userId);
if (hit && Date.now() - hit.at < CHAIN_TTL_MS) return hit.source;
const creds = await getServiceCredentials(userId, 'esplora');
const source: ChainSource = creds?.url
? { baseUrl: normalizeEsploraUrl(creds.url), custom: true }
: { baseUrl: getConfig().esploraUrl, custom: false };
const source = await readChainSource(userId);
chainCache.set(userId, { source, at: Date.now() });
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 {
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,
// 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
// your own indexer is a real upgrade — but it never sees a key and cannot move a coin. That framing is
// on the card deliberately: the risk of getting it wrong is being watched, not being robbed.
// PRIVACY, NOT CUSTODY. A chain endpoint learns every address you ask it about, so pointing this at your
// own node is a real upgrade — but it never sees a key and cannot move a coin. That framing is on the
// 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
// running electrs, esplora or mempool.space alongside it and pointing this at that.
// TWO PROTOCOLS, AND THEY ARE NOT INTERCHANGEABLE. Esplora is the public-explorer API (electrs, esplora,
// 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 & {
kind: ChainKind;
esploraUrl: string;
custom: boolean;
defaultUrl: string;
@@ -28,29 +33,48 @@ type ChainState = ServiceConnectionState & {
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 = () => {
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 [kind, setKind] = useState<ChainKind>('esplora');
const [error, setError] = useState<string | 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
// owner edits it rather than retyping it. Keyed on the URL: a save or reset should re-seed.
// 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 both fields: a save or reset should re-seed.
useEffect(() => {
if (data) setUrl(data.esploraUrl);
}, [data?.esploraUrl]);
if (!data) return;
setUrl(data.esploraUrl);
setKind(data.kind);
}, [data?.esploraUrl, data?.kind]);
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 meta = KINDS[kind];
const submit = async () => {
setError(null);
setHeight(null);
try {
const result = await save.mutateAsync({ url: trimmed });
const result = await save.mutateAsync({ url: trimmed, kind });
setHeight(result.probe?.blockHeight ?? null);
} catch (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 (
<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>
<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,
so your own indexer is a privacy upgrade it never sees a key and cannot spend.
Where on-chain wallets read balances and history. It sees every address this wallet asks about, so your own node
is a privacy upgrade it never sees a key and cannot spend.
</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">
<Input
value={url}
@@ -82,10 +137,10 @@ export const ChainSourceCard = () => {
onKeyDown={(ev) => {
if (ev.key === 'Enter' && trimmed && dirty && !busy) void submit();
}}
placeholder={data?.defaultUrl ?? 'https://mempool.space/api'}
placeholder={meta.placeholder}
disabled={isLoading || busy}
className="h-8 font-mono text-xs"
aria-label="Esplora API URL"
aria-label={`${meta.label} API URL`}
autoComplete="off"
spellCheck={false}
/>
@@ -119,10 +174,9 @@ export const ChainSourceCard = () => {
<p className="mt-2 text-[11px] text-muted-foreground">
{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.`}{' '}
This must be an <span className="font-mono">Esplora</span> REST API electrs, esplora or a mempool.space
instance. bitcoin-core&apos;s own RPC port will not work.
{meta.hint}
</p>
</section>
);