add the bitcoin wallet sidecar and ui
the owner's work, committed as one unit rather than split: the registration
files (App.tsx, Dock, AppRegistry, hono.ts, the schema and db barrels,
ecosystem.config.cjs) all reference modules under src/servers/{api,sidecar}/wallet
and src/workspaces/officerdev/src/apps/Wallet, so committing the shared plumbing
on its own would leave a commit that does not build.
officer-wallet is a new pm2 peer holding seed material sealed under an owner
passphrase on top of VAULT_STORE_KEY, with an unlock ttl after which the root key
is wiped from memory. five backends: on-chain via esplora, and lnd, clnrest,
lndhub and nwc for lightning. bolt11 encode/decode is implemented in-tree.
no secrets in the diff — the key-shaped literals under sidecar/wallet are the
bolt11 spec vectors and the bip39 "abandon … about" vector. .env.example gains
placeholders only. bun test src/servers/sidecar/wallet: 38 pass, 0 fail.
not reviewed line by line; assembled and verified to build, not audited.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,200 @@
|
||||
import type { SidecarCommand, SidecarEvent } from '../protocol';
|
||||
import { createSidecarConnector } from '../connect';
|
||||
import { handleOfficerRoute } from './routes';
|
||||
import { getConfig, hasStoreKey } from './upstream';
|
||||
import { lockAll } from './keys';
|
||||
import { invalidateAll } from './resolve';
|
||||
|
||||
// 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 /_officer/config network + esplora + 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.
|
||||
// ─────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const API_URL = process.env.API_URL ?? `ws://127.0.0.1:${process.env.PORT ?? '5000'}`;
|
||||
|
||||
/** 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,
|
||||
async fetch(req) {
|
||||
const url = new URL(req.url);
|
||||
|
||||
if (url.pathname === '/_health') {
|
||||
const cfg = getConfig();
|
||||
const started = Date.now();
|
||||
try {
|
||||
const res = await fetch(`${cfg.esploraUrl}/blocks/tip/height`, {
|
||||
signal: AbortSignal.timeout(5_000),
|
||||
});
|
||||
const height = res.ok ? Number(await res.text()) : null;
|
||||
return Response.json({
|
||||
ok: res.ok,
|
||||
network: cfg.network,
|
||||
esplora: cfg.esploraUrl,
|
||||
blockHeight: Number.isFinite(height) ? height : null,
|
||||
// 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: cfg.esploraUrl,
|
||||
error: String(err),
|
||||
storeKeyConfigured: hasStoreKey(),
|
||||
ms: Date.now() - started,
|
||||
},
|
||||
{ status: 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 });
|
||||
},
|
||||
});
|
||||
|
||||
const cfg = getConfig();
|
||||
console.log(`[wallet] listening on 127.0.0.1:${port} — network=${cfg.network} esplora=${cfg.esploraUrl}`);
|
||||
if (!hasStoreKey()) {
|
||||
console.warn('[wallet] VAULT_STORE_KEY is unset — wallet creation will be refused until it is configured');
|
||||
}
|
||||
|
||||
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',
|
||||
capabilities: ['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'));
|
||||
Reference in New Issue
Block a user