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:
2026-07-31 06:48:06 +00:00
co-authored by Claude Opus 5
parent 5ee56e736b
commit f8826e4c24
69 changed files with 11867 additions and 0 deletions
+16
View File
@@ -151,6 +151,22 @@ export {
clearVaultUnlockKey,
} from './queries/vault';
export type { VaultTokenSet } from './queries/vault';
export {
listWallets,
getWallet,
getActiveWallet,
getWalletSecrets,
getSealedSeed,
createWallet,
updateWallet,
setActiveWallet,
deleteWallet,
getWalletLabels,
setWalletLabel,
getFrozenOutpoints,
setUtxoFrozen,
} from './queries/wallet';
export type { WalletKind, WalletSummary, WalletSecrets, WalletLabel, CreateWalletParams } from './queries/wallet';
export { db } from './db';
export * as schema from './schema';
@@ -0,0 +1,266 @@
import { eq, and, desc } from 'drizzle-orm';
import { db } from '../db';
import { walletWallets, walletLabels, walletFrozenUtxos } from '../schema';
import { encryptSecret, decryptSecret } from '../crypto';
// Wallet access for the officer-wallet sidecar. Callers deal in PLAINTEXT — the VAULT_STORE_KEY layer is
// applied and stripped here, so route handlers never touch crypto. See ../crypto.ts, ../schema/wallet.ts.
//
// Note what "plaintext" means for `seedEnvelope`: it is the passphrase-sealed envelope, which is itself
// still ciphertext. This layer only removes the SECOND wrapping. Nothing in this file can read a mnemonic,
// and that is intentional — only servers/sidecar/wallet/keys.ts can, and only with the owner passphrase.
//
// THREE return types, and the projection is what enforces the separation:
// WalletSummary — safe to serialize to the browser. No config, no seed envelope, at all.
// WalletSecrets — decrypted config for the sidecar's own upstream calls. Never returned by a handler.
// SealedSeed — the sealed envelope, for keys.ts to open with the owner passphrase.
// A bare `select()` would leak both ciphertext columns into every list response the moment someone
// forgot to strip them, so no function here uses one.
export type WalletKind = 'onchain' | 'lnd' | 'cln-rest' | 'lndhub' | 'nwc';
export type WalletSummary = {
id: number;
name: string;
kind: WalletKind;
network: string;
fingerprint: string | null;
xpubs: Record<string, string> | null;
defaultBip: number;
isActive: boolean;
/** Whether this wallet holds a seed at all — i.e. whether unlock/lock apply to it. */
hasSeed: boolean;
createdAt: Date;
};
export type WalletSecrets = { id: number; kind: WalletKind; network: string; config: Record<string, unknown> | null };
const walletCols = {
id: walletWallets.id,
name: walletWallets.name,
kind: walletWallets.kind,
network: walletWallets.network,
fingerprint: walletWallets.fingerprint,
xpubs: walletWallets.xpubs,
defaultBip: walletWallets.defaultBip,
isActive: walletWallets.isActive,
createdAt: walletWallets.createdAt,
seedEnvelope: walletWallets.seedEnvelope,
};
function toSummary(row: {
id: number;
name: string;
kind: string;
network: string;
fingerprint: string | null;
xpubs: Record<string, string> | null;
defaultBip: number;
isActive: boolean;
createdAt: Date;
seedEnvelope: string | null;
}): WalletSummary {
return {
id: row.id,
name: row.name,
kind: row.kind as WalletKind,
network: row.network,
fingerprint: row.fingerprint,
xpubs: row.xpubs,
defaultBip: row.defaultBip,
isActive: row.isActive,
hasSeed: row.seedEnvelope !== null,
createdAt: row.createdAt,
};
}
/** Every wallet the owner has registered, active first then newest. Never includes secrets. */
export async function listWallets(userId: number): Promise<WalletSummary[]> {
const rows = await db
.select(walletCols)
.from(walletWallets)
.where(eq(walletWallets.userId, userId))
.orderBy(desc(walletWallets.isActive), desc(walletWallets.createdAt));
return rows.map(toSummary);
}
export async function getWallet(userId: number, id: number): Promise<WalletSummary | null> {
const [row] = await db
.select(walletCols)
.from(walletWallets)
.where(and(eq(walletWallets.userId, userId), eq(walletWallets.id, id)));
return row ? toSummary(row) : null;
}
export async function getActiveWallet(userId: number): Promise<WalletSummary | null> {
const [row] = await db
.select(walletCols)
.from(walletWallets)
.where(and(eq(walletWallets.userId, userId), eq(walletWallets.isActive, true)));
return row ? toSummary(row) : null;
}
/** Decrypted backend connection config, for the sidecar's upstream calls. Never leaves the sidecar. */
export async function getWalletSecrets(userId: number, id: number): Promise<WalletSecrets | null> {
const [row] = await db
.select({
id: walletWallets.id,
kind: walletWallets.kind,
network: walletWallets.network,
config: walletWallets.config,
})
.from(walletWallets)
.where(and(eq(walletWallets.userId, userId), eq(walletWallets.id, id)));
if (!row) return null;
return {
id: row.id,
kind: row.kind as WalletKind,
network: row.network,
config: row.config ? (JSON.parse(decryptSecret(row.config)) as Record<string, unknown>) : null,
};
}
/**
* The passphrase-sealed seed envelope, still sealed. Returns null for wallets that hold no seed.
* The ONLY caller should be the sidecar's unlock path.
*/
export async function getSealedSeed(userId: number, id: number): Promise<string | null> {
const [row] = await db
.select({ seedEnvelope: walletWallets.seedEnvelope })
.from(walletWallets)
.where(and(eq(walletWallets.userId, userId), eq(walletWallets.id, id)));
if (!row?.seedEnvelope) return null;
return decryptSecret(row.seedEnvelope);
}
export type CreateWalletParams = {
userId: number;
name: string;
kind: WalletKind;
network: string;
/** Plaintext; encrypted here. */
config?: Record<string, unknown> | null;
/** The already-sealed envelope as JSON; encrypted again here. */
sealedSeed?: string | null;
fingerprint?: string | null;
xpubs?: Record<string, string> | null;
defaultBip?: number;
makeActive?: boolean;
};
export async function createWallet(params: CreateWalletParams): Promise<WalletSummary> {
return db.transaction(async (tx) => {
if (params.makeActive) {
await tx
.update(walletWallets)
.set({ isActive: false })
.where(and(eq(walletWallets.userId, params.userId), eq(walletWallets.isActive, true)));
}
const [row] = await tx
.insert(walletWallets)
.values({
userId: params.userId,
name: params.name,
kind: params.kind,
network: params.network,
config: params.config ? encryptSecret(JSON.stringify(params.config)) : null,
seedEnvelope: params.sealedSeed ? encryptSecret(params.sealedSeed) : null,
fingerprint: params.fingerprint ?? null,
xpubs: params.xpubs ?? null,
defaultBip: params.defaultBip ?? 84,
isActive: params.makeActive ?? false,
})
.returning(walletCols);
return toSummary(row!);
});
}
export async function updateWallet(
userId: number,
id: number,
patch: { name?: string; config?: Record<string, unknown>; defaultBip?: number; sealedSeed?: string },
): Promise<WalletSummary | null> {
const set: Record<string, unknown> = { updatedAt: new Date() };
if (patch.name !== undefined) set.name = patch.name;
if (patch.defaultBip !== undefined) set.defaultBip = patch.defaultBip;
if (patch.config !== undefined) set.config = encryptSecret(JSON.stringify(patch.config));
if (patch.sealedSeed !== undefined) set.seedEnvelope = encryptSecret(patch.sealedSeed);
const [row] = await db
.update(walletWallets)
.set(set)
.where(and(eq(walletWallets.userId, userId), eq(walletWallets.id, id)))
.returning(walletCols);
return row ? toSummary(row) : null;
}
/** Exactly one active wallet per owner. Cleared and set in one transaction; the partial unique index is the backstop. */
export async function setActiveWallet(userId: number, id: number): Promise<void> {
await db.transaction(async (tx) => {
await tx
.update(walletWallets)
.set({ isActive: false })
.where(and(eq(walletWallets.userId, userId), eq(walletWallets.isActive, true)));
await tx
.update(walletWallets)
.set({ isActive: true, updatedAt: new Date() })
.where(and(eq(walletWallets.userId, userId), eq(walletWallets.id, id)));
});
}
export async function deleteWallet(userId: number, id: number): Promise<boolean> {
const rows = await db
.delete(walletWallets)
.where(and(eq(walletWallets.userId, userId), eq(walletWallets.id, id)))
.returning({ id: walletWallets.id });
return rows.length > 0;
}
// ── labels ───────────────────────────────────────────────────────────────────────────────────────
export type WalletLabel = { kind: 'address' | 'tx'; ref: string; label: string };
export async function getWalletLabels(walletId: number): Promise<WalletLabel[]> {
const rows = await db
.select({ kind: walletLabels.kind, ref: walletLabels.ref, label: walletLabels.label })
.from(walletLabels)
.where(eq(walletLabels.walletId, walletId));
return rows.map((r) => ({ kind: r.kind as 'address' | 'tx', ref: r.ref, label: r.label }));
}
export async function setWalletLabel(walletId: number, kind: 'address' | 'tx', ref: string, label: string): Promise<void> {
// An empty label is a delete — the UI clears a field rather than pressing a separate button.
if (!label.trim()) {
await db
.delete(walletLabels)
.where(and(eq(walletLabels.walletId, walletId), eq(walletLabels.kind, kind), eq(walletLabels.ref, ref)));
return;
}
await db
.insert(walletLabels)
.values({ walletId, kind, ref, label })
.onConflictDoUpdate({ target: [walletLabels.walletId, walletLabels.kind, walletLabels.ref], set: { label } });
}
// ── frozen UTXOs ─────────────────────────────────────────────────────────────────────────────────
export async function getFrozenOutpoints(walletId: number): Promise<string[]> {
const rows = await db
.select({ outpoint: walletFrozenUtxos.outpoint })
.from(walletFrozenUtxos)
.where(eq(walletFrozenUtxos.walletId, walletId));
return rows.map((r) => r.outpoint);
}
export async function setUtxoFrozen(walletId: number, outpoint: string, frozen: boolean, reason?: string): Promise<void> {
if (!frozen) {
await db
.delete(walletFrozenUtxos)
.where(and(eq(walletFrozenUtxos.walletId, walletId), eq(walletFrozenUtxos.outpoint, outpoint)));
return;
}
await db
.insert(walletFrozenUtxos)
.values({ walletId, outpoint, reason: reason ?? null })
.onConflictDoNothing();
}
@@ -10,3 +10,4 @@ export * from './music';
export * from './soulseek';
export * from './headscale';
export * from './vault';
export * from './wallet';
@@ -0,0 +1,102 @@
import { pgTable, serial, integer, text, boolean, timestamp, jsonb, unique, uniqueIndex } from 'drizzle-orm/pg-core';
import { sql } from 'drizzle-orm';
import { users } from './auth';
// Bitcoin wallets for the officer-wallet sidecar. The owner registers one or more wallets — either a
// self-custodial on-chain wallet whose seed lives here, or a connection to a node (LND / Core Lightning /
// LNDHub / NWC) exactly as Zeus models them — and switches between them.
//
// TWO COLUMNS HOLD SPENDING AUTHORITY AND THEY ARE PROTECTED DIFFERENTLY. This asymmetry is deliberate:
//
// `config` — node credentials (macaroon, rune, LNDHub password, NWC URI). Encrypted at rest with
// VAULT_STORE_KEY via ../crypto.ts, same as headscale_servers.api_key. It CANNOT be
// passphrase-protected: background balance polling needs it without the owner present.
//
// `seed_envelope` — a BIP39 mnemonic that is ALREADY sealed under an owner passphrase by the sidecar
// (see servers/sidecar/wallet/keys.ts) before it ever arrives here, and is then
// encrypted AGAIN with VAULT_STORE_KEY on the way into this column. Two independent
// secrets, neither sufficient alone. A database dump does not spend; a leaked .env
// does not spend.
//
// `xpubs` and `fingerprint` are stored in the CLEAR, on purpose. They are what lets the wallet show
// balances, history and fresh receive addresses while locked — the watch-only-when-locked property. An
// xpub leak costs privacy (an observer can enumerate the wallet's addresses), never funds.
//
// Every table is `wallet_`-prefixed and this file holds nothing else, so it can move into
// src/servers/sidecar/wallet/ wholesale when sidecars own their schema. Only officer-wallet reads these.
export const walletWallets = pgTable(
'wallet_wallets',
{
id: serial('id').primaryKey(),
userId: integer('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
name: text('name').notNull(),
// BackendKind: 'onchain' | 'lnd' | 'cln-rest' | 'lndhub' | 'nwc'. TS-only enum, no DB CHECK —
// matches how pipeline_jobs.mode is handled elsewhere in this schema.
kind: text('kind').notNull(),
// BitcoinNetwork: 'bitcoin' | 'testnet' | 'signet' | 'regtest'.
network: text('network').notNull().default('bitcoin'),
// Backend connection config as JSON, encrypted. Null for a pure on-chain wallet, which connects to
// nothing but the Esplora endpoint the sidecar is configured with.
config: text('config'),
// The passphrase-sealed SeedEnvelope, encrypted again. Null for every remote-node wallet — those
// hold no seed, the node does.
seedEnvelope: text('seed_envelope'),
// BIP32 master fingerprint (8 hex chars), for PSBT construction and hardware-wallet pairing.
fingerprint: text('fingerprint'),
// { "44": "xpub…", "49": "ypub…", "84": "zpub…", "86": "xpub…" } — plaintext, see header.
xpubs: jsonb('xpubs').$type<Record<string, string>>(),
// Which derivation standard new receive addresses use. 84 (native segwit) is the default.
defaultBip: integer('default_bip').notNull().default(84),
isActive: boolean('is_active').notNull().default(false),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
},
(t) => [
unique('uq_wallet_wallets_user_name').on(t.userId, t.name),
// At most one active wallet per owner, enforced by the DB rather than convention — a partial unique
// index over active rows only, mirroring uq_headscale_servers_one_active.
uniqueIndex('uq_wallet_wallets_one_active')
.on(t.userId)
.where(sql`${t.isActive}`),
],
);
// Owner-assigned labels for addresses and transactions. Zeus keeps these client-side; here they belong to
// the server so every client sees the same annotations.
export const walletLabels = pgTable(
'wallet_labels',
{
id: serial('id').primaryKey(),
walletId: integer('wallet_id')
.notNull()
.references(() => walletWallets.id, { onDelete: 'cascade' }),
// 'address' | 'tx'
kind: text('kind').notNull(),
// The address string or txid being labelled.
ref: text('ref').notNull(),
label: text('label').notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
},
(t) => [unique('uq_wallet_labels_wallet_kind_ref').on(t.walletId, t.kind, t.ref)],
);
// Frozen UTXOs, excluded from automatic coin selection. Separate from labels because this one affects
// what the wallet will actually spend — losing a label is cosmetic, losing a freeze spends a coin the
// owner meant to keep back.
export const walletFrozenUtxos = pgTable(
'wallet_frozen_utxos',
{
id: serial('id').primaryKey(),
walletId: integer('wallet_id')
.notNull()
.references(() => walletWallets.id, { onDelete: 'cascade' }),
// `txid:vout`
outpoint: text('outpoint').notNull(),
reason: text('reason'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
},
(t) => [unique('uq_wallet_frozen_utxos_wallet_outpoint').on(t.walletId, t.outpoint)],
);