diff --git a/src/servers/sidecar/wallet/config.ts b/src/servers/sidecar/wallet/config.ts index 4b7322a5..de68dc0c 100644 --- a/src/servers/sidecar/wallet/config.ts +++ b/src/servers/sidecar/wallet/config.ts @@ -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 = { + 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 { + 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 { +async function probeEsplora(baseUrl: string): Promise { const started = Date.now(); try { const res = await fetch(`${baseUrl}/blocks/tip/height`, { @@ -50,17 +70,80 @@ export async function probe(baseUrl: string): Promise { } } +/** + * `/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 { + 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 { - 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 { }); } -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 { 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); diff --git a/src/servers/sidecar/wallet/index.ts b/src/servers/sidecar/wallet/index.ts index cba512e6..6581ee5b 100644 --- a/src/servers/sidecar/wallet/index.ts +++ b/src/servers/sidecar/wallet/index.ts @@ -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,41 +129,31 @@ 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, - }, - { status: 502 }, - ); - } + ms: result.ms, + }, + { status: result.ok ? 200 : 502 }, + ); } if (url.pathname.startsWith('/_officer/')) { diff --git a/src/servers/sidecar/wallet/resolve.ts b/src/servers/sidecar/wallet/resolve.ts index 09c8cf3e..7f38356a 100644 --- a/src/servers/sidecar/wallet/resolve.ts +++ b/src/servers/sidecar/wallet/resolve.ts @@ -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 | null, esploraUrl: string): string { - return JSON.stringify([wallet.kind, wallet.network, wallet.defaultBip, wallet.xpubs, config, esploraUrl]); +function versionOf(wallet: WalletSummary, config: Record | 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 | null, key: string, kind: str return v; } -function build(wallet: WalletSummary, config: Record | null, esploraUrl: string): WalletBackend { +function build(wallet: WalletSummary, config: Record | null, source: ChainSource): WalletBackend { const network = wallet.network as BitcoinNetwork; switch (wallet.kind) { @@ -128,10 +142,7 @@ function build(wallet: WalletSummary, config: Record | 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 | 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 { diff --git a/src/servers/sidecar/wallet/upstream.ts b/src/servers/sidecar/wallet/upstream.ts index 104ac988..59246d64 100644 --- a/src/servers/sidecar/wallet/upstream.ts +++ b/src/servers/sidecar/wallet/upstream.ts @@ -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 { 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 { + 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); } diff --git a/src/workspaces/officerdev/src/apps/Wallet/ChainSourceCard.tsx b/src/workspaces/officerdev/src/apps/Wallet/ChainSourceCard.tsx index fd3a0f90..47bfd664 100644 --- a/src/workspaces/officerdev/src/apps/Wallet/ChainSourceCard.tsx +++ b/src/workspaces/officerdev/src/apps/Wallet/ChainSourceCard.tsx @@ -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 = { + 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('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('esplora'); const [error, setError] = useState(null); const [height, setHeight] = useState(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 (

Chain source

- 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.

+
+ {(Object.keys(KINDS) as ChainKind[]).map((k) => ( + + ))} +
+
{ 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 = () => {

{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 Esplora REST API — electrs, esplora or a mempool.space - instance. bitcoin-core's own RPC port will not work. + {meta.hint}

);