Files
bitcoin-wallet/db/queries.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

350 lines
14 KiB
TypeScript

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<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('wallet', 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('wallet', 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('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<string, unknown>; defaultBip?: number },
): 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('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<void> {
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<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();
}
// ── 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<WalletChainCache | null> {
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<void> {
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<void> {
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 } });
}