import type { SidecarCommand, SidecarEvent } from '@@/sidecar/protocol'; import type { ChainSource } from './upstream'; import { createSidecarConnector } from '@@/sidecar/connect'; import { handleConfigRoute, probe } from './config'; import { handleOfficerRoute } from './routes'; import { getChainSource, getConfig, hasStoreKey } from './upstream'; import { lockAll } from './keys'; import { invalidateAll } from './resolve'; import { API_URL } from '@@/officer-url.mjs'; // The officer-wallet sidecar. A bitcoin wallet in the shape Zeus models one — several interchangeable // backends behind one interface — but server-side, with the key material held here and nowhere else. // // WHY THIS PROCESS EXISTS SEPARATELY. Seeds and node credentials never enter the main Officer process. // The platform is a thin auth proxy (src/servers/api/wallet/router.ts) that forwards to this port and // holds nothing: no seed, no macaroon, no xpub. Compromising `officer` gets an attacker the ability to // *call* this sidecar as the authenticated owner — it does not get them a key, and it cannot spend from a // locked wallet, because a locked wallet has no key material in memory at all. // // BACKENDS (mirroring _references/zeus/backends/): // onchain — self-custodial. BIP39 seed sealed under an owner passphrase (keys.ts), BIP84/86/49 // derivation, Esplora for chain data, bitcoinjs-lib for PSBT construction. This is the // server analogue of Zeus's EmbeddedLND/LdkNode, which are native-module-bound and cannot // be ported. Watch-only while locked; unlock only to sign. // lnd — LND REST + macaroon (ported from backends/LND.ts) // cln-rest — Core Lightning CLNRest + rune (ported from backends/CLNRest.ts) // lndhub — custodial LNDHub/BlueWallet REST (ported from backends/LndHub.ts) // nwc — Nostr Wallet Connect, NIP-47 (ported from backends/NostrWalletConnect.ts) // // When the owner runs their own node, it registers as an `lnd` or `cln-rest` wallet — no code change. // // ───────────────────────────────────────────────────────────────────────────────────────────────── // 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 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 // // GET /_officer/wallets list. Never includes secrets. // POST /_officer/wallets create. Seeded wallets return the mnemonic ONCE, // and only when the sidecar generated it. // GET /_officer/wallets/active the currently selected wallet // GET /_officer/wallets/:id one wallet // PATCH /_officer/wallets/:id rename / defaultBip / config // DELETE /_officer/wallets/:id {passphrase} required when the wallet holds a seed // POST /_officer/wallets/:id/activate // // GET /_officer/wallets/:id/lock-state {hasSeed, unlocked, secondsRemaining} // POST /_officer/wallets/:id/unlock {passphrase, ttlSec?} // POST /_officer/wallets/:id/lock // POST /_officer/wallets/:id/passphrase {oldPassphrase, newPassphrase} // POST /_officer/wallets/:id/export-seed {passphrase} → the mnemonic. Logged as a warning. // // GET /_officer/wallets/:id/capabilities what this backend can actually do // GET /_officer/wallets/:id/info node/chain identity + sync state // GET /_officer/wallets/:id/balances on-chain confirmed/unconfirmed + lightning local/inbound // GET /_officer/wallets/:id/transactions?limit on-chain history, owner labels overlaid // GET /_officer/wallets/:id/address?peek fresh receive address // GET /_officer/wallets/:id/utxos coin control view, freeze flags + labels overlaid // POST /_officer/wallets/:id/utxos/freeze {outpoint, frozen, reason?} // GET /_officer/wallets/:id/fees sat/vB estimates // POST /_officer/wallets/:id/send on-chain spend. Requires an unlocked wallet. // GET /_officer/wallets/:id/invoices?limit // POST /_officer/wallets/:id/invoices create // GET /_officer/wallets/:id/invoices/:hash lookup // POST /_officer/wallets/:id/decode {bolt11} // GET /_officer/wallets/:id/payments?limit // POST /_officer/wallets/:id/pay {bolt11, amountMsat?, feeLimit…} // POST /_officer/wallets/:id/keysend {destination, amountMsat} // GET /_officer/wallets/:id/channels // GET /_officer/wallets/:id/peers // POST /_officer/wallets/:id/sign {message} // POST /_officer/wallets/:id/verify {message, signature} // GET|POST /_officer/wallets/:id/labels owner annotations for addresses and txids // // anything else 404 // // Operations a backend cannot perform return 501 with code NOT_SUPPORTED, checked against its declared // capability set before dispatch — never a confusing upstream error. A locked wallet returns 423 // WALLET_LOCKED from signing paths only; every read above keeps working. // ───────────────────────────────────────────────────────────────────────────────────────────────── /** Grab an ephemeral free port by briefly binding one and releasing it. */ function getFreePort(): number { const probeServer = Bun.serve({ port: 0, hostname: '127.0.0.1', fetch: () => new Response('') }); const p = probeServer.port; probeServer.stop(true); if (p == null) throw new Error('failed to acquire a free port'); return p; } const port = getFreePort(); const server = Bun.serve({ port, hostname: '127.0.0.1', // Wallet payloads are small — PSBTs and invoices, never file uploads. A tight cap is free hardening. maxRequestBodySize: 1 * 1024 * 1024, // Bun's default is 10s, which is SHORTER than the Esplora client's own 20s per-request timeout. That // inversion is why a slow or unreachable upstream showed up as a wallet with no data at all rather than // as an error: Bun killed the response before the scan could either finish or report why it hadn't. // A gap-limit scan is many sequential rounds, so the ceiling has to sit above the whole walk, not one // request. Bun caps this at 255s; every other sidecar already uses that. idleTimeout: 255, async fetch(req) { const url = new URL(req.url); // `/_config` is the chain endpoint, which is per-owner — so unlike /_health it needs the header. if (url.pathname === '/_config' || url.pathname.startsWith('/_config/')) { const officerUser = req.headers.get('X-Officer-User'); const userId = Number(officerUser); if (!officerUser || !Number.isInteger(userId) || userId <= 0) { return Response.json({ error: 'missing or invalid X-Officer-User' }, { status: 401 }); } try { return await handleConfigRoute(req, userId, url.pathname.slice('/_config'.length)); } catch (err) { console.error(`[wallet] ${req.method} ${url.pathname} failed`, err instanceof Error ? err.message : err); return Response.json({ error: 'internal error' }, { status: 500 }); } } if (url.pathname === '/_health') { const cfg = getConfig(); // Health has to probe the endpoint the wallets ACTUALLY use, not the built-in default — reporting // 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: ChainSource = Number.isInteger(healthUser) && healthUser > 0 ? await getChainSource(healthUser) : { 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: 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: result.ms, }, { status: result.ok ? 200 : 502 }, ); } if (url.pathname.startsWith('/_officer/')) { try { const res = await handleOfficerRoute(req, url); if (res) return res; return Response.json({ error: 'not found' }, { status: 404 }); } catch (err) { // Method and path only. Bodies on this sidecar carry passphrases and mnemonics. console.error(`[wallet] ${req.method} ${url.pathname} failed`, err instanceof Error ? err.message : err); return Response.json({ error: 'internal error' }, { status: 500 }); } } return Response.json({ error: 'not found' }, { status: 404 }); }, }); // No esplora URL in the banner: it is per-owner state read from the database now, not a boot constant. console.log(`[wallet] listening on 127.0.0.1:${port} — network=${getConfig().network}`); if (!hasStoreKey()) { console.warn('[wallet] the secret store is unreachable — wallet creation will be refused until it is'); } type ReplyFn = (msg: SidecarEvent) => void; function handleCommand(cmd: SidecarCommand, reply: ReplyFn) { switch (cmd.type) { case 'ping': reply({ type: 'pong', id: cmd.id }); break; default: reply({ type: 'error', id: (cmd as SidecarCommand).id, error: `Unknown command type: ${(cmd as Record).type}`, }); } } const connection = createSidecarConnector({ apiUrl: `${API_URL}/api/sidecar/register`, name: 'wallet', handles: ['wallet'], onCommand(cmd, reply) { handleCommand(cmd as SidecarCommand, reply as ReplyFn); }, onConnected() { connection.send({ type: 'wallet:server', port }); console.log(`[wallet] reported server port ${port} to API`); }, }); function shutdown(signal: string) { console.log(`[wallet] ${signal} received, locking all wallets and shutting down...`); // Wipe key material before anything else. This is best-effort — see the caveat in keys.ts — but it // costs nothing and closes the obvious window on a graceful restart. lockAll(); invalidateAll(); try { server.stop(true); } catch { /* already stopped */ } connection.destroy(); process.exit(0); } process.on('SIGTERM', () => shutdown('SIGTERM')); process.on('SIGINT', () => shutdown('SIGINT'));