import type { BitcoinNetwork } from './types'; import { getServiceCredentials } from 'officerdb'; import { getKey } from 'officerdb/secret-store'; // The ONLY reader of WALLET_* env in the tree. Everything else — node URLs, macaroons, runes, LNDHub // credentials, NWC URIs — is per-wallet configuration the owner enters at runtime and lives encrypted in // Postgres (databases/officer_db/src/wallet/schema.ts), not here. Env holds only what is genuinely // deployment-wide: which chain we're on. // // WHERE CHAIN DATA COMES FROM IS NOT ENV. It used to be WALLET_ESPLORA_URL, which meant the one setting // an owner actually has to change — off the public explorer, onto their own indexer — was the one // setting they could only change with a shell and a restart. It now lives in `service_connections` // under 'esplora' and is edited from Wallet → Settings; see getChainSource below. const NETWORKS: readonly BitcoinNetwork[] = ['bitcoin', 'testnet', 'signet', 'regtest']; // mempool.space's public API. Fine to start on; swap it for your own electrs/esplora when the node is up // — an Esplora endpoint sees every address in the wallet, so the public one is a privacy leak, not a // custody one. It never sees a private key and cannot authorize anything. const DEFAULT_ESPLORA: Record = { bitcoin: 'https://mempool.space/api', testnet: 'https://mempool.space/testnet/api', signet: 'https://mempool.space/signet/api', regtest: 'http://127.0.0.1:3002', }; export type WalletConfig = { network: BitcoinNetwork; esploraUrl: string; unlockTtlSec: number; }; let warned = false; export function getConfig(): WalletConfig { const raw = process.env.WALLET_NETWORK?.trim() ?? 'bitcoin'; const network = (NETWORKS as readonly string[]).includes(raw) ? (raw as BitcoinNetwork) : 'bitcoin'; if (raw && network !== raw && !warned) { console.warn(`[wallet] WALLET_NETWORK="${raw}" is not a known network, falling back to bitcoin`); warned = true; } const ttl = Number(process.env.WALLET_UNLOCK_TTL_SEC ?? 900); return { network, /** The built-in default, used until the owner stores one. `getChainSource` is the real answer. */ esploraUrl: DEFAULT_ESPLORA[network], // Clamp: a zero TTL makes the wallet unusable, and an unbounded one defeats auto-lock entirely. unlockTtlSec: Number.isFinite(ttl) ? Math.min(Math.max(ttl, 30), 86_400) : 900, }; } /** * 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 = { 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; }; /** Short TTL: a save invalidates explicitly, so this only bounds how stale a *concurrent* reader can be. */ const CHAIN_TTL_MS = 60_000; const chainCache = new Map(); 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. */ 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 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); } /** * Whether the wallet's at-rest key is usable. The sidecar can serve a locked, watch-only view without * one, but every write path touching an encrypted column throws, so /_health reports it explicitly * rather than letting the first wallet creation fail with a confusing crypto error. * * This tested `VAULT_STORE_KEY` in the environment until 2026-08-13. The key now lives in the secret * store under the `wallet` purpose and is CREATED ON FIRST USE — so the honest question is no longer * "did somebody set a variable" but "can this process open the store at all". A false here means the * file is unreachable or unwritable, which is an install problem rather than a configuration one. */ export function hasStoreKey(): boolean { try { return getKey('wallet').length > 0; } catch { return false; } }