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:
@@ -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 bitcoind’s 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's own RPC port will not work.
|
||||
{meta.hint}
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user