import type { WalletChainSnapshot } from './schema'; import { eq, and, desc } from 'drizzle-orm'; import { db } from 'officerdb/db'; import { walletWallets, walletLabels, walletFrozenUtxos, walletChainCache } from './schema'; import { encryptSecret, decryptSecret } from 'officerdb/crypto'; // Wallet access for the officer-wallet sidecar. Callers deal in PLAINTEXT — the at-rest layer ('wallet' // purpose in the secret store) 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 | 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 | 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 | 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 { 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 { 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 { 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 { 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('wallet', row.config)) as Record) : 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 { 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('wallet', row.seedEnvelope); } export type CreateWalletParams = { userId: number; name: string; kind: WalletKind; network: string; /** Plaintext; encrypted here. */ config?: Record | null; /** The already-sealed envelope as JSON; encrypted again here. */ sealedSeed?: string | null; fingerprint?: string | null; xpubs?: Record | null; defaultBip?: number; makeActive?: boolean; }; export async function createWallet(params: CreateWalletParams): Promise { 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('wallet', JSON.stringify(params.config)) : null, seedEnvelope: params.sealedSeed ? encryptSecret('wallet', params.sealedSeed) : null, fingerprint: params.fingerprint ?? null, xpubs: params.xpubs ?? null, defaultBip: params.defaultBip ?? 84, isActive: params.makeActive ?? false, }) .returning(walletCols); return toSummary(row!); }); } /** * Mutable wallet metadata. CANNOT TOUCH THE SEED — see replaceSealedSeed below for why that is a * separate function rather than one more optional field here. */ export async function updateWallet( userId: number, id: number, patch: { name?: string; config?: Record; defaultBip?: number }, ): Promise { const set: Record = { 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('wallet', JSON.stringify(patch.config)); 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; } /** * Overwrite the encrypted seed. The ONLY caller is the passphrase change, which reseals the same words * under a new passphrase and has already proved knowledge of the old one. * * It is a function of its own, rather than a fourth field on updateWallet, because it was one: the PATCH * route typed its request body as `{name, defaultBip, config}` and passed the parsed object straight * through, and a TypeScript annotation strips nothing at runtime. Any authenticated caller could send a * `sealedSeed` key and replace the seed — no passphrase, no unlock, no confirmation — and since * encryptSecret happily encrypts nonsense, the damage only surfaced at the next unlock attempt. Keeping * the two apart means the next field added to that body cannot re-open it. */ export async function replaceSealedSeed(userId: number, id: number, sealedSeed: string): Promise { await db .update(walletWallets) .set({ seedEnvelope: encryptSecret('wallet', sealedSeed), updatedAt: new Date() }) .where(and(eq(walletWallets.userId, userId), eq(walletWallets.id, id))); } /** 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 { 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 { 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 { 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 { // 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 { 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 { 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(); } // ── chain cache ────────────────────────────────────────────────────────────────────────────────── // // Unlike everything above, none of this is authoritative — see the comment on the table. These three // functions are deliberately total: a cache read or write must never be able to fail a wallet request, // so the sidecar treats every one of them as best-effort. export type WalletChainCache = { snapshot: WalletChainSnapshot | null; syncedAt: Date | null; lastError: string | null; lastErrorAt: Date | null; }; export async function getWalletChainCache(walletId: number): Promise { const [row] = await db .select({ snapshot: walletChainCache.snapshot, syncedAt: walletChainCache.syncedAt, lastError: walletChainCache.lastError, lastErrorAt: walletChainCache.lastErrorAt, }) .from(walletChainCache) .where(eq(walletChainCache.walletId, walletId)); return row ?? null; } /** A successful refresh. Clears the error columns — the upstream just answered, so nothing is wrong now. */ export async function saveWalletChainCache(walletId: number, snapshot: WalletChainSnapshot): Promise { const syncedAt = new Date(); await db .insert(walletChainCache) .values({ walletId, snapshot, syncedAt, lastError: null, lastErrorAt: null }) .onConflictDoUpdate({ target: walletChainCache.walletId, set: { snapshot, syncedAt, lastError: null, lastErrorAt: null }, }); } /** A failed refresh. Touches only the error columns, so the last good snapshot and its age survive. */ export async function recordWalletChainError(walletId: number, message: string): Promise { const lastErrorAt = new Date(); // Bounded because this is an upstream message verbatim, and an HTML error page from a misconfigured // proxy would otherwise land whole in the column. const lastError = message.slice(0, 500); await db .insert(walletChainCache) .values({ walletId, snapshot: null, syncedAt: null, lastError, lastErrorAt }) .onConflictDoUpdate({ target: walletChainCache.walletId, set: { lastError, lastErrorAt } }); }