import { deleteServiceConnection, getServiceConnection, saveServiceConnection } from 'officerdb'; import { invalidateAll } from './resolve'; import type { ChainKind } from './upstream'; import { CHAIN_KINDS, getChainSource, getConfig, invalidateChainSource, normalizeEsploraUrl } from './upstream'; import type { BitcoinNetwork } from './types'; // `/_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 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. // // 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); } /** * `/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. */ async function probeEsplora(baseUrl: string): Promise { const started = Date.now(); try { const res = await fetch(`${baseUrl}/blocks/tip/height`, { headers: { Accept: 'text/plain' }, 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 }; } const height = Number(body); if (!Number.isFinite(height) || height <= 0) { return { ok: false, error: `that URL answered, but not like an Esplora API (expected a block height, got "${body.slice(0, 60)}")`, 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 }; } } /** * `/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 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 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, network: getConfig().network, }); } 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, kind); if (!result.ok) return bad(result.error ?? 'that endpoint did not answer'); 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 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 }); } /** `/_config` — GET what is in use, PUT to change it, DELETE to fall back to the built-in default. */ export async function handleConfigRoute(req: Request, userId: number, subpath: string): Promise { if (subpath && subpath !== '/') return bad('not found', 404); if (req.method === 'GET') return state(userId); if (req.method === 'POST' || req.method === 'PUT') return save(req, userId); if (req.method === 'DELETE') { // 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); } return bad('method not allowed', 405); }