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:
@@ -1,30 +1,50 @@
|
||||
import { deleteServiceConnection, getServiceConnection, saveServiceConnection } from 'officerdb';
|
||||
import { invalidateAll } from './resolve';
|
||||
import { getChainSource, getConfig, invalidateChainSource, normalizeEsploraUrl } from './upstream';
|
||||
import type { ChainKind } from './upstream';
|
||||
import { CHAIN_KINDS, getChainSource, getConfig, invalidateChainSource, normalizeEsploraUrl } from './upstream';
|
||||
import type { BitcoinNetwork } from './types';
|
||||
|
||||
// `/_config` — which Esplora endpoint this owner's on-chain wallets read the chain from.
|
||||
// `/_config` — which endpoint this owner's on-chain wallets read the chain from, and which protocol it
|
||||
// speaks.
|
||||
//
|
||||
// This is the one wallet setting that is NOT per-wallet and NOT key material: an endpoint serves every
|
||||
// on-chain wallet the owner has, and it holds no secret, because Esplora is an unauthenticated read API.
|
||||
// It went from an env var to a row for one reason — it is the setting most likely to need changing, and
|
||||
// the reason to change it (a public explorer rate-limiting you, or seeing every address you own) shows
|
||||
// up long after the deployment that set it.
|
||||
// on-chain wallet the owner has, and it holds no secret, because both supported kinds are unauthenticated
|
||||
// read APIs. It went from an env var to a row for one reason — it is the setting most likely to need
|
||||
// changing, and the reason to change it (a public explorer rate-limiting you, or seeing every address you
|
||||
// own) shows up long after the deployment that set it.
|
||||
//
|
||||
// PRIVACY, NOT CUSTODY. An Esplora endpoint learns which addresses you ask about, so pointing this at
|
||||
// your own indexer is a real privacy upgrade. It is never a custody risk: the endpoint sees no key, and
|
||||
// a hostile one can lie about balances but cannot move a coin.
|
||||
// TWO KINDS, ONE ROW. Esplora and NBXplorer are stored as separate services but are mutually exclusive:
|
||||
// saving one deletes the other, so "which endpoint is in use" never depends on precedence. See
|
||||
// upstream.ts::getChainSource.
|
||||
//
|
||||
// PRIVACY, NOT CUSTODY. A chain endpoint learns which addresses you ask about, so pointing this at your
|
||||
// own indexer is a real privacy upgrade. It is never a custody risk: the endpoint sees no key, and a
|
||||
// hostile one can lie about balances but cannot move a coin.
|
||||
|
||||
export type ChainProbe = { ok: boolean; blockHeight?: number | null; error?: string; ms?: number };
|
||||
|
||||
/** What each kind reports itself as, versus what this deployment is configured for. */
|
||||
const NETWORK_ALIASES: Record<BitcoinNetwork, readonly string[]> = {
|
||||
bitcoin: ['mainnet', 'main', 'bitcoin'],
|
||||
testnet: ['testnet', 'test'],
|
||||
signet: ['signet'],
|
||||
regtest: ['regtest'],
|
||||
};
|
||||
|
||||
/** Does this endpoint answer, does it speak the protocol we think, and is it on OUR chain? */
|
||||
export async function probe(baseUrl: string, kind: ChainKind): Promise<ChainProbe> {
|
||||
return kind === 'nbxplorer' ? probeNbxplorer(baseUrl) : probeEsplora(baseUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Is there an Esplora API at this URL, and does it know the tip?
|
||||
*
|
||||
* `/blocks/tip/height` is the cheapest endpoint Esplora has and the one every implementation has — a
|
||||
* bare number in the body. Anything else answering (a bitcoind RPC port, a mempool.space *web* root, an
|
||||
* nginx default page) fails here rather than at the first address scan, which is the whole point: the
|
||||
* owner finds out while looking at the form.
|
||||
*
|
||||
* Esplora does not say which chain it serves, so unlike NBXplorer there is nothing to cross-check here.
|
||||
*/
|
||||
export async function probe(baseUrl: string): Promise<ChainProbe> {
|
||||
async function probeEsplora(baseUrl: string): Promise<ChainProbe> {
|
||||
const started = Date.now();
|
||||
try {
|
||||
const res = await fetch(`${baseUrl}/blocks/tip/height`, {
|
||||
@@ -50,17 +70,80 @@ export async function probe(baseUrl: string): Promise<ChainProbe> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `/v1/cryptos/BTC/status` — NBXplorer's own liveness endpoint, and richer than Esplora's.
|
||||
*
|
||||
* It names the chain it is indexing, so a testnet NBXplorer accepted for a mainnet wallet is caught HERE
|
||||
* rather than by the owner wondering why their balance is zero. That mistake is easy to make (one docker
|
||||
* flag) and expensive to discover late, so the mismatch is a hard failure, not a warning.
|
||||
*
|
||||
* A node still catching up is allowed through with its height reported: it will be correct shortly, and
|
||||
* refusing to save a config because of a temporary sync state would be its own kind of wrong.
|
||||
*/
|
||||
async function probeNbxplorer(baseUrl: string): Promise<ChainProbe> {
|
||||
const started = Date.now();
|
||||
try {
|
||||
const res = await fetch(`${baseUrl}/v1/cryptos/BTC/status`, {
|
||||
headers: { Accept: 'application/json' },
|
||||
signal: AbortSignal.timeout(8_000),
|
||||
redirect: 'manual',
|
||||
});
|
||||
const body = (await res.text()).trim();
|
||||
if (!res.ok) {
|
||||
return { ok: false, error: `endpoint returned ${res.status}: ${body.slice(0, 200)}`, ms: Date.now() - started };
|
||||
}
|
||||
|
||||
let status: { chainHeight?: unknown; networkType?: unknown; isFullySynched?: unknown };
|
||||
try {
|
||||
status = JSON.parse(body) as typeof status;
|
||||
} catch {
|
||||
return {
|
||||
ok: false,
|
||||
error: `that URL answered, but not like NBXplorer (expected JSON, got "${body.slice(0, 60)}")`,
|
||||
ms: Date.now() - started,
|
||||
};
|
||||
}
|
||||
|
||||
const height = Number(status.chainHeight);
|
||||
if (!Number.isFinite(height) || height <= 0) {
|
||||
return { ok: false, error: 'that endpoint answered but reported no chain height', ms: Date.now() - started };
|
||||
}
|
||||
|
||||
const want = getConfig().network;
|
||||
const got = String(status.networkType ?? '').toLowerCase();
|
||||
if (got && !NETWORK_ALIASES[want].includes(got)) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `that NBXplorer is indexing ${got}, but this wallet is on ${want}. Refusing to point a ${want} wallet at a ${got} node.`,
|
||||
ms: Date.now() - started,
|
||||
};
|
||||
}
|
||||
|
||||
return { ok: true, blockHeight: height, ms: Date.now() - started };
|
||||
} catch (err) {
|
||||
return { ok: false, error: `could not reach the endpoint (${String(err)})`, ms: Date.now() - started };
|
||||
}
|
||||
}
|
||||
|
||||
const bad = (error: string, status = 400) => Response.json({ error }, { status });
|
||||
|
||||
const isHttpUrl = (url: string): boolean => /^https?:\/\//i.test(url);
|
||||
|
||||
/** The URL in use plus whether it is the owner's or the built-in default. No secrets exist here at all. */
|
||||
/**
|
||||
* The endpoint in use, which protocol it speaks, and whether it is the owner's or the built-in default.
|
||||
* No secrets exist here at all.
|
||||
*
|
||||
* `esploraUrl` is kept as a field name despite now possibly holding an NBXplorer URL: it is the URL in
|
||||
* use, and renaming it would break the stored dashboard state of anything already reading it. `kind`
|
||||
* says what it actually is.
|
||||
*/
|
||||
async function state(userId: number): Promise<Response> {
|
||||
const connection = await getServiceConnection(userId, 'esplora');
|
||||
const source = await getChainSource(userId);
|
||||
const connection = source.custom ? await getServiceConnection(userId, source.kind) : null;
|
||||
return Response.json({
|
||||
configured: !!connection,
|
||||
connection,
|
||||
kind: source.kind,
|
||||
esploraUrl: source.baseUrl,
|
||||
custom: source.custom,
|
||||
defaultUrl: getConfig().esploraUrl,
|
||||
@@ -68,22 +151,31 @@ async function state(userId: number): Promise<Response> {
|
||||
});
|
||||
}
|
||||
|
||||
type ConfigBody = { url?: unknown };
|
||||
type ConfigBody = { url?: unknown; kind?: unknown };
|
||||
|
||||
const isChainKind = (v: unknown): v is ChainKind =>
|
||||
typeof v === 'string' && (CHAIN_KINDS as readonly string[]).includes(v);
|
||||
|
||||
async function save(req: Request, userId: number): Promise<Response> {
|
||||
const body = ((await req.json().catch(() => null)) as ConfigBody | null) ?? {};
|
||||
const url = typeof body.url === 'string' ? normalizeEsploraUrl(body.url) : '';
|
||||
// Absent kind means a client written before the picker existed; those only ever spoke Esplora.
|
||||
const kind: ChainKind = isChainKind(body.kind) ? body.kind : 'esplora';
|
||||
|
||||
if (!url) return bad('url is required');
|
||||
if (!isHttpUrl(url)) return bad('url must start with http:// or https://');
|
||||
|
||||
const result = await probe(url);
|
||||
const result = await probe(url, kind);
|
||||
if (!result.ok) return bad(result.error ?? 'that endpoint did not answer');
|
||||
|
||||
await saveServiceConnection({ userId, service: 'esplora', url });
|
||||
await saveServiceConnection({ userId, service: kind, url });
|
||||
// Exactly one chain source exists at a time. Retiring the other kind here is what lets getChainSource
|
||||
// answer without a precedence rule, and what stops a stale row from resurfacing if this one is deleted.
|
||||
for (const other of CHAIN_KINDS) if (other !== kind) await deleteServiceConnection(userId, other);
|
||||
|
||||
invalidateChainSource(userId);
|
||||
// Every on-chain backend holds an EsploraChain built from the old URL, and a cached gap-limit scan
|
||||
// taken through it. Both are now wrong, so drop the instances rather than trying to patch them.
|
||||
// Every on-chain backend holds a chain source built from the old URL, and a cached gap-limit scan taken
|
||||
// through it. Both are now wrong, so drop the instances rather than trying to patch them.
|
||||
invalidateAll();
|
||||
|
||||
return Response.json({ ...(await state(userId).then((r) => r.json())), probe: result });
|
||||
@@ -97,7 +189,8 @@ export async function handleConfigRoute(req: Request, userId: number, subpath: s
|
||||
if (req.method === 'POST' || req.method === 'PUT') return save(req, userId);
|
||||
|
||||
if (req.method === 'DELETE') {
|
||||
await deleteServiceConnection(userId, 'esplora');
|
||||
// Both, not just the active one: "reset to default" has to mean it regardless of which row exists.
|
||||
for (const kind of CHAIN_KINDS) await deleteServiceConnection(userId, kind);
|
||||
invalidateChainSource(userId);
|
||||
invalidateAll();
|
||||
return state(userId);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { SidecarCommand, SidecarEvent } from '../protocol';
|
||||
import type { ChainSource } from './upstream';
|
||||
import { createSidecarConnector } from '../connect';
|
||||
import { handleConfigRoute } from './config';
|
||||
import { handleConfigRoute, probe } from './config';
|
||||
import { handleOfficerRoute } from './routes';
|
||||
import { getChainSource, getConfig, hasStoreKey } from './upstream';
|
||||
import { lockAll } from './keys';
|
||||
@@ -31,8 +32,8 @@ import { invalidateAll } from './resolve';
|
||||
// HTTP CONTRACT — the platform strips its /api/wallet mount prefix before forwarding.
|
||||
//
|
||||
// GET /_health ours. Reports network, chain reachability, store key.
|
||||
// GET /_config the owner's Esplora endpoint (or the built-in default)
|
||||
// PUT /_config {url} — probed before it is stored
|
||||
// GET /_config the owner's chain endpoint (or the built-in default)
|
||||
// PUT /_config {url, kind} — probed as that kind before it is stored
|
||||
// DELETE /_config fall back to the built-in default
|
||||
// GET /_officer/config network + unlock TTL + storeKeyConfigured
|
||||
//
|
||||
@@ -128,42 +129,32 @@ const server = Bun.serve({
|
||||
// the default green while the owner's own indexer is down would be worse than no health at all.
|
||||
// The header is optional here (health predates it), so fall back rather than 401.
|
||||
const healthUser = Number(req.headers.get('X-Officer-User'));
|
||||
const chain =
|
||||
const chain: ChainSource =
|
||||
Number.isInteger(healthUser) && healthUser > 0
|
||||
? await getChainSource(healthUser)
|
||||
: { baseUrl: cfg.esploraUrl, custom: false };
|
||||
const started = Date.now();
|
||||
try {
|
||||
const res = await fetch(`${chain.baseUrl}/blocks/tip/height`, {
|
||||
signal: AbortSignal.timeout(5_000),
|
||||
});
|
||||
const height = res.ok ? Number(await res.text()) : null;
|
||||
return Response.json({
|
||||
ok: res.ok,
|
||||
: { kind: 'esplora', baseUrl: cfg.esploraUrl, custom: false };
|
||||
|
||||
// Same probe the config form runs, so "saved successfully" and "healthy" can never disagree about
|
||||
// what a working endpoint is — including the network cross-check on NBXplorer.
|
||||
const result = await probe(chain.baseUrl, chain.kind);
|
||||
|
||||
return Response.json(
|
||||
{
|
||||
ok: result.ok,
|
||||
network: cfg.network,
|
||||
kind: chain.kind,
|
||||
esplora: chain.baseUrl,
|
||||
custom: chain.custom,
|
||||
blockHeight: Number.isFinite(height) ? height : null,
|
||||
blockHeight: result.blockHeight ?? null,
|
||||
...(result.error ? { error: result.error } : {}),
|
||||
// Surfaced because wallet creation is refused without it, and that failure would otherwise
|
||||
// look like a bug rather than a missing config line.
|
||||
storeKeyConfigured: hasStoreKey(),
|
||||
ms: Date.now() - started,
|
||||
});
|
||||
} catch (err) {
|
||||
return Response.json(
|
||||
{
|
||||
ok: false,
|
||||
network: cfg.network,
|
||||
esplora: chain.baseUrl,
|
||||
custom: chain.custom,
|
||||
error: String(err),
|
||||
storeKeyConfigured: hasStoreKey(),
|
||||
ms: Date.now() - started,
|
||||
ms: result.ms,
|
||||
},
|
||||
{ status: 502 },
|
||||
{ status: result.ok ? 200 : 502 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (url.pathname.startsWith('/_officer/')) {
|
||||
try {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import type { ChainCacheStore } from './backends/onchain';
|
||||
import type { WalletChainSource } from './chain-source';
|
||||
import type { ChainSource } from './upstream';
|
||||
import {
|
||||
getWallet,
|
||||
getWalletSecrets,
|
||||
@@ -9,6 +11,8 @@ import {
|
||||
} from 'officerdb';
|
||||
import { EsploraChain } from './chain';
|
||||
import { EsploraChainSource } from './chain-source-esplora';
|
||||
import { NbxplorerChain } from './nbxplorer';
|
||||
import { NbxplorerChainSource } from './chain-source-nbxplorer';
|
||||
import { LndBackend } from './backends/lnd';
|
||||
import { ClnRestBackend } from './backends/clnrest';
|
||||
import { LndHubBackend } from './backends/lndhub';
|
||||
@@ -39,8 +43,18 @@ export function invalidateAll(): void {
|
||||
}
|
||||
|
||||
/** A stable fingerprint of the inputs a backend was built from, so a stale instance is detected. */
|
||||
function versionOf(wallet: WalletSummary, config: Record<string, unknown> | null, esploraUrl: string): string {
|
||||
return JSON.stringify([wallet.kind, wallet.network, wallet.defaultBip, wallet.xpubs, config, esploraUrl]);
|
||||
function versionOf(wallet: WalletSummary, config: Record<string, unknown> | null, source: ChainSource): string {
|
||||
// The kind is in here as well as the URL: switching protocol at the same host is a different backend
|
||||
// with a differently-shaped cache behind it, and a fingerprint on the URL alone would miss it.
|
||||
return JSON.stringify([
|
||||
wallet.kind,
|
||||
wallet.network,
|
||||
wallet.defaultBip,
|
||||
wallet.xpubs,
|
||||
config,
|
||||
source.kind,
|
||||
source.baseUrl,
|
||||
]);
|
||||
}
|
||||
|
||||
export type Resolved = { wallet: WalletSummary; backend: WalletBackend };
|
||||
@@ -51,15 +65,15 @@ export async function resolveBackend(userId: number, walletId: number): Promise<
|
||||
|
||||
const secrets = await getWalletSecrets(userId, walletId);
|
||||
const config = secrets?.config ?? null;
|
||||
// Read per resolve rather than per build: the URL is in the fingerprint, so pointing the owner at a
|
||||
// Read per resolve rather than per build: the source is in the fingerprint, so pointing the owner at a
|
||||
// different indexer rebuilds every backend and drops the scan taken through the old one.
|
||||
const { baseUrl: esploraUrl } = await getChainSource(userId);
|
||||
const version = versionOf(wallet, config, esploraUrl);
|
||||
const source = await getChainSource(userId);
|
||||
const version = versionOf(wallet, config, source);
|
||||
|
||||
const hit = cache.get(walletId);
|
||||
if (hit && hit.configVersion === version) return { wallet, backend: hit.backend };
|
||||
|
||||
const backend = build(wallet, config, esploraUrl);
|
||||
const backend = build(wallet, config, source);
|
||||
cache.set(walletId, { backend, configVersion: version });
|
||||
return { wallet, backend };
|
||||
}
|
||||
@@ -85,7 +99,7 @@ function required(config: Record<string, unknown> | null, key: string, kind: str
|
||||
return v;
|
||||
}
|
||||
|
||||
function build(wallet: WalletSummary, config: Record<string, unknown> | null, esploraUrl: string): WalletBackend {
|
||||
function build(wallet: WalletSummary, config: Record<string, unknown> | null, source: ChainSource): WalletBackend {
|
||||
const network = wallet.network as BitcoinNetwork;
|
||||
|
||||
switch (wallet.kind) {
|
||||
@@ -128,10 +142,7 @@ function build(wallet: WalletSummary, config: Record<string, unknown> | null, es
|
||||
throw new BackendError('wallet has no account xpubs', 500, 'BAD_CONFIG');
|
||||
}
|
||||
return new OnchainBackend({
|
||||
chain: new EsploraChainSource({
|
||||
chain: new EsploraChain({ baseUrl: esploraUrl, network }),
|
||||
label: `esplora(${hostOf(esploraUrl)})`,
|
||||
}),
|
||||
chain: chainSourceFor(source, network),
|
||||
network,
|
||||
accountXpub,
|
||||
// The session is the signer. While locked it holds no key material, so watch-only reads below
|
||||
@@ -146,6 +157,25 @@ function build(wallet: WalletSummary, config: Record<string, unknown> | null, es
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The one place the owner's chosen protocol turns into an implementation. Both satisfy the same
|
||||
* interface, and OnchainBackend cannot tell which it was handed — that is the entire point of the seam
|
||||
* (chain-source.ts). Adding a third kind is a case here and a file next to it, and nothing else.
|
||||
*/
|
||||
function chainSourceFor(source: ChainSource, network: BitcoinNetwork): WalletChainSource {
|
||||
const label = `${source.kind}(${hostOf(source.baseUrl)})`;
|
||||
|
||||
if (source.kind === 'nbxplorer') {
|
||||
return new NbxplorerChainSource({
|
||||
chain: new NbxplorerChain({ baseUrl: source.baseUrl, network }),
|
||||
network,
|
||||
label,
|
||||
});
|
||||
}
|
||||
|
||||
return new EsploraChainSource({ chain: new EsploraChain({ baseUrl: source.baseUrl, network }), label });
|
||||
}
|
||||
|
||||
/** Host only, for a chain-source label. A malformed URL is labelled with itself rather than throwing. */
|
||||
function hostOf(url: string): string {
|
||||
try {
|
||||
|
||||
@@ -50,8 +50,17 @@ export function getConfig(): WalletConfig {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Which protocol the endpoint speaks. Not interchangeable — see chain-source.ts for why the two are
|
||||
* different shapes rather than two URLs for the same thing.
|
||||
*/
|
||||
export const CHAIN_KINDS = ['nbxplorer', 'esplora'] as const;
|
||||
|
||||
export type ChainKind = (typeof CHAIN_KINDS)[number];
|
||||
|
||||
export type ChainSource = {
|
||||
/** Esplora API root, no trailing slash. */
|
||||
kind: ChainKind;
|
||||
/** API root, no trailing slash. */
|
||||
baseUrl: string;
|
||||
/** True when this is the owner's stored endpoint rather than the built-in public default. */
|
||||
custom: boolean;
|
||||
@@ -66,6 +75,10 @@ export const normalizeEsploraUrl = (url: string): string => url.trim().replace(/
|
||||
/**
|
||||
* Where to read the chain, for this owner.
|
||||
*
|
||||
* AT MOST ONE of the two rows exists: saving either kind retires the other (config.ts::save), because a
|
||||
* wallet reads from one place and storing two would leave the answer to precedence rather than to the
|
||||
* owner. The order below is therefore a tiebreak that should never be needed, not a preference.
|
||||
*
|
||||
* Falls back to the public default rather than failing: a wallet with no stored endpoint is still a
|
||||
* working watch-only wallet, just one pointed at an explorer that rate-limits and can see every address
|
||||
* it asks about. The default is a starting point, not a destination.
|
||||
@@ -74,15 +87,19 @@ export async function getChainSource(userId: number): Promise<ChainSource> {
|
||||
const hit = chainCache.get(userId);
|
||||
if (hit && Date.now() - hit.at < CHAIN_TTL_MS) return hit.source;
|
||||
|
||||
const creds = await getServiceCredentials(userId, 'esplora');
|
||||
const source: ChainSource = creds?.url
|
||||
? { baseUrl: normalizeEsploraUrl(creds.url), custom: true }
|
||||
: { baseUrl: getConfig().esploraUrl, custom: false };
|
||||
|
||||
const source = await readChainSource(userId);
|
||||
chainCache.set(userId, { source, at: Date.now() });
|
||||
return source;
|
||||
}
|
||||
|
||||
async function readChainSource(userId: number): Promise<ChainSource> {
|
||||
for (const kind of CHAIN_KINDS) {
|
||||
const creds = await getServiceCredentials(userId, kind);
|
||||
if (creds?.url) return { kind, baseUrl: normalizeEsploraUrl(creds.url), custom: true };
|
||||
}
|
||||
return { kind: 'esplora', baseUrl: getConfig().esploraUrl, custom: false };
|
||||
}
|
||||
|
||||
export function invalidateChainSource(userId: number): void {
|
||||
chainCache.delete(userId);
|
||||
}
|
||||
|
||||
@@ -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