Files
bitcoin-wallet/web/ChainSourceCard.tsx
2026-08-19 10:52:57 +00:00

201 lines
7.5 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<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 = () => {
// '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<ChainState>('bitcoin-wallet');
const { save, forget } = useServiceConnectionActions<{ url: string; kind: ChainKind }, SaveResult>(
'bitcoin-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 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.
<form onSubmit={submit} 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">
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}
onChange={(ev) => 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}
/>
<div className="flex shrink-0 gap-2">
<Button type="submit" size="sm" disabled={!trimmed || !dirty || busy}>
{save.isPending ? <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" /> : null}
{save.isPending ? 'Connecting…' : 'Save & connect'}
</Button>
{data?.custom && (
<Button
type="button"
size="sm"
variant="outline"
onClick={reset}
disabled={busy}
title="Back to the public default"
>
<RotateCcw className="mr-1.5 h-3.5 w-3.5" />
Reset
</Button>
)}
</div>
</div>
{error && (
<p className="mt-2 flex items-start gap-1.5 text-xs text-destructive">
<TriangleAlert className="mt-0.5 h-3.5 w-3.5 shrink-0" />
<span>{error}</span>
</p>
)}
{height != null && !error && (
<p className="mt-2 flex items-center gap-1.5 text-xs text-success">
<CheckCircle2 className="h-3.5 w-3.5 shrink-0" />
Connected — block {height.toLocaleString()}
</p>
)}
<p className="mt-2 text-[11px] text-muted-foreground">
{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}
</p>
</form>
);
};