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
@@ -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>
);