import type { ServiceConnectionState } from 'officerdev'; import { useEffect, useState } from 'react'; import { CheckCircle2, Loader2, RotateCcw, TriangleAlert } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { serviceErrorMessage, useServiceConnection, useServiceConnectionActions, } from 'officerdev'; // 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. 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. // // 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; network: string; }; type SaveResult = ChainState & { probe?: { blockHeight?: number | null; ms?: number } }; type KindMeta = { label: string; placeholder: string; hint: string }; const KINDS: Record = { 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 = () => { // 'bitcoin-wallet' is the MOUNT name, not a credential key: these hooks build `/${service}/_config`, // so this must match where the plugin is mounted. It said 'wallet' until the plugin was renamed on // 2026-08-19, after which every request here 404'd — and the symptom was not an error but a DISABLED // Save button, because `data` stayed undefined, the seeding effect returned early, and `url` never // left ''. The URL visible in the field was the placeholder. // // The credential rows are keyed on the PROTOCOL ('esplora' / 'nbxplorer'), written by the sidecar's // config.ts — those are untouched by any rename here. const { data, isLoading } = useServiceConnection('bitcoin-wallet'); const { save, forget } = useServiceConnectionActions<{ url: string; kind: ChainKind }, SaveResult>( 'bitcoin-wallet', ); const [url, setUrl] = useState(''); const [kind, setKind] = useState('esplora'); const [error, setError] = useState(null); const [height, setHeight] = useState(null); // 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) return; setUrl(data.esploraUrl); setKind(data.kind); }, [data?.esploraUrl, data?.kind]); const trimmed = url.trim(); const dirty = !!data && (trimmed !== data.esploraUrl || kind !== data.kind); const busy = save.isPending || forget.isPending; const meta = KINDS[kind]; const submit = async (ev?: React.FormEvent) => { ev?.preventDefault(); if (!trimmed || !dirty || busy) return; setError(null); setHeight(null); try { const result = await save.mutateAsync({ url: trimmed, kind }); setHeight(result.probe?.blockHeight ?? null); } catch (err) { setError(serviceErrorMessage(err)); } }; const reset = async () => { setError(null); setHeight(null); try { await forget.mutateAsync(); } catch (err) { setError(serviceErrorMessage(err)); } }; /** 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 ( // Typing a URL and pressing return is what anyone does; a div full of inputs swallows it silently.

Chain source

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.

{(Object.keys(KINDS) as ChainKind[]).map((k) => ( ))}
setUrl(ev.target.value)} placeholder={meta.placeholder} disabled={isLoading || busy} className="h-8 font-mono text-xs" aria-label={`${meta.label} API URL`} autoComplete="off" spellCheck={false} />
{data?.custom && ( )}
{error && (

{error}

)} {height != null && !error && (

Connected — block {height.toLocaleString()}

)}

{data?.custom ? `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.`}{' '} {meta.hint}

); };