Files
bitcoin-wallet/sidecar/index.ts
T
officer a4cf939be7 bitcoin-wallet, extracted from the platform as a plugin
Tenth plugin out, and the one whose extraction turned on a permission decision
rather than a mechanical move.

The wallet permission was kind: 'admin' — owner only, not grantable, invisible
to every other account. Plugin permissions are ALWAYS kind: 'app': mount.ts
forces it and the manifest has no field for anything else, deliberately, so
that "can a plugin escalate itself" is a question which cannot be asked. So
extraction could not preserve admin, and that was raised as a blocker before
any code moved.

It was the right change anyway, and the objection inverts. Every wallet query
is scoped by user_id, and that id comes from X-Officer-User which the platform
injects from the authenticated session — a caller cannot name someone else's.
So granting this gives an account ITS OWN wallet. Not through the API and not
with root either does it hand over anyone else's, because their seed is sealed
under a passphrase the server never stores. kind: 'admin' was single-user
thinking; grantable is how a second person gets a wallet at all.

Named bitcoin-wallet rather than wallet: it is a bitcoin wallet specifically,
and the appName drives the route, the API prefix, the table prefix and the
install key together. Tables renamed to bitcoin_wallet_* accordingly — the
prefix guard normalises the hyphen. Free only because no wallet table existed
on any server, which was checked before deciding: db:push treats a rename as a
drop plus a create.

The three custody test files moved with it and still run — 72 tests over
scrypt sealing, envelope open and bolt11. bunfig.toml's test root was widened
on 2026-08-15 after music took lyrics.test.ts and the suite silently shrank by
nine, so that trap was already closed.

68 palette classes converted, five status vocabularies hoisted into shared.ts.
Two deliberate flattenings recorded in PLUGIN.md: testnet/signet/regtest all
read as warning, and the bitcoin and lightning rails no longer differ by hue.

Deferred on purpose: watch-only data (xpubs, balances, history) stays
unencrypted so a locked wallet still shows a balance. Anyone with the database
can therefore SEE any user's balances; they can never move a coin.
2026-08-19 05:18:29 +00:00

226 lines
12 KiB
TypeScript

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<string, unknown>).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'));