three input sites lived in a div instead of a form, so pressing enter did nothing and the button was the only way in. chain source url, rescan search depth, and the inline wallet-rename field all get a <form onSubmit>, with every non-submit button in each marked type="button" (the rescan card's reset button was previously untyped, which would have submitted the form once wrapped). CoinsView.tsx has no text inputs (checkbox + buttons only) and SeedPhraseInput.tsx deliberately hijacks enter to advance between word boxes rather than submit, so both are left alone. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
194 lines
7.4 KiB
TypeScript
194 lines
7.4 KiB
TypeScript
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>
|
||
);
|
||
};
|