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:
@@ -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)],
|
||||
);
|
||||
Reference in New Issue
Block a user