import { listWallets, getWallet, getSealedSeed, createWallet, updateWallet, replaceSealedSeed, setActiveWallet, deleteWallet, getActiveWallet, getWalletLabels, setWalletLabel, getFrozenOutpoints, setUtxoFrozen, type WalletKind, } from 'officerdb'; import { resolveBackend, invalidate } from './resolve'; import { changePassphrase, deriveAccountXpubs, exportMnemonic, generateSeed, sealSeed, sessionFor, verifyPassphrase, type SeedEnvelope, } from './keys'; import { getConfig, hasStoreKey } from './upstream'; import { asAddressType, BackendError, WalletLockedError, BIP_ADDRESS_TYPE, type BitcoinNetwork, type Capability, type SyncState, type Utxo, type WalletBackend, } from './types'; // The wallet sidecar's route surface. Every route is scoped to the authenticated owner via X-Officer-User, // which the platform proxy injects (src/servers/api/wallet/router.ts) and which is trustworthy because the // sidecar binds loopback only. // // Route ordering matters here: the seed/lock routes are matched BEFORE the generic wallet operations, so a // wallet named "unlock" can never shadow the unlock endpoint. // // SECURITY NOTES that apply to this whole file: // - No route ever returns a mnemonic, a sealed envelope, a node macaroon, or an unlock passphrase, // except /export-seed, which exists for backup and demands the passphrase every single time. // - Passphrases arrive in request bodies and are never logged. The catch-all handler at the bottom logs // the method and path only, deliberately not the body. // - Capability checks happen before dispatch so an unsupported operation is a clean 501. export type OfficerContext = { req: Request; url: URL; userId: number }; function json(data: unknown, status = 200): Response { return Response.json(data as Record, { status }); } function badRequest(message: string): Response { return json({ error: message }, 400); } async function body(req: Request): Promise { try { return (await req.json()) as T; } catch { throw new BackendError('expected a JSON body', 400, 'BAD_BODY'); } } /** * Freshness of the data a read just returned, or null from a backend that has no cache to be stale. * Attached to balances, transactions and utxos so the UI can distinguish an empty wallet from one it * could not reach — those rendered identically before, as a zero balance. * * Must be read AFTER the response it describes: it reports the snapshot that was actually served. */ function syncOf(backend: WalletBackend): SyncState | null { return backend.getSyncState?.() ?? null; } /** Guard a capability before dispatching, so callers get 501 rather than a confusing upstream error. */ function requireCap(backend: WalletBackend, cap: Capability, op: string): void { if (!backend.supports(cap)) { throw new BackendError(`this wallet does not support ${op}`, 501, 'NOT_SUPPORTED'); } } // ── entry point ────────────────────────────────────────────────────────────────────────────────── export async function handleOfficerRoute(req: Request, url: URL): Promise { const officerUser = req.headers.get('X-Officer-User'); if (!officerUser) return json({ error: 'missing X-Officer-User' }, 401); const userId = Number(officerUser); if (!Number.isInteger(userId) || userId <= 0) return badRequest('invalid X-Officer-User'); const segments = url.pathname.slice('/_officer/'.length).split('/').filter(Boolean); if (segments.length === 0) return null; const ctx: OfficerContext = { req, url, userId }; try { switch (segments[0]) { case 'config': return handleConfig(); case 'wallets': return await handleWallets(ctx, segments.slice(1)); default: return null; } } catch (err) { if (err instanceof BackendError) { return json({ error: err.message, code: err.code }, err.status); } throw err; } } /** * Non-secret deployment facts the UI needs before any wallet exists. * * No esplora URL: that is per-owner now and lives at `/_config`, which is also the only place that can * say whether it is the owner's endpoint or the fallback. Reporting the default here would have shown * the wrong URL to anyone who had set their own. */ function handleConfig(): Response { const cfg = getConfig(); return json({ network: cfg.network, unlockTtlSec: cfg.unlockTtlSec, // The UI blocks wallet creation on this rather than letting the first write fail on a crypto error. storeKeyConfigured: hasStoreKey(), }); } // ── /wallets ───────────────────────────────────────────────────────────────────────────────────── const KINDS: readonly WalletKind[] = ['onchain', 'lnd', 'cln-rest', 'lndhub', 'nwc']; async function handleWallets(ctx: OfficerContext, seg: string[]): Promise { const { req, userId } = ctx; // /_officer/wallets if (seg.length === 0) { if (req.method === 'GET') return json({ wallets: await listWallets(userId) }); if (req.method === 'POST') return await createWalletRoute(ctx); return json({ error: 'method not allowed' }, 405); } // /_officer/wallets/active — resolves to whichever wallet is currently selected if (seg[0] === 'active' && seg.length === 1 && req.method === 'GET') { const active = await getActiveWallet(userId); return json({ wallet: active }); } const walletId = Number(seg[0]); if (!Number.isInteger(walletId) || walletId <= 0) return badRequest('invalid wallet id'); const rest = seg.slice(1); // /_officer/wallets/:id if (rest.length === 0) { if (req.method === 'GET') { const wallet = await getWallet(userId, walletId); return wallet ? json({ wallet }) : json({ error: 'wallet not found' }, 404); } if (req.method === 'PATCH') { const patch = await body<{ name?: string; defaultBip?: number; config?: Record }>(req); // A rename goes through the same rule as a create: trimmed, and never blank. Without this a stray // empty string would leave a wallet with no name anywhere in the UI and no way to type one back. if (patch.name !== undefined) { const name = patch.name.trim(); if (!name) return badRequest('name cannot be empty'); if (name.length > 64) return badRequest('name is too long (64 characters max)'); patch.name = name; } // Rebuilt field by field rather than forwarded: `body` is a cast, so the parsed object holds // whatever the caller sent, not what the type says. updateWallet can no longer write the seed // either — that is the belt to this brace. const updated = await updateWallet(userId, walletId, { name: patch.name, defaultBip: patch.defaultBip, config: patch.config, }); invalidate(walletId); return updated ? json({ wallet: updated }) : json({ error: 'wallet not found' }, 404); } if (req.method === 'DELETE') return await deleteWalletRoute(ctx, walletId); return json({ error: 'method not allowed' }, 405); } // Seed / lock lifecycle — matched before the generic operations below. switch (rest[0]) { case 'activate': if (req.method !== 'POST') return json({ error: 'method not allowed' }, 405); await setActiveWallet(userId, walletId); return json({ ok: true }); case 'lock-state': { const wallet = await getWallet(userId, walletId); if (!wallet) return json({ error: 'wallet not found' }, 404); const session = sessionFor(walletId); return json({ hasSeed: wallet.hasSeed, unlocked: session.isUnlocked(), secondsRemaining: session.secondsRemaining(), }); } case 'unlock': return await unlockRoute(ctx, walletId); case 'lock': if (req.method !== 'POST') return json({ error: 'method not allowed' }, 405); sessionFor(walletId).lock(); return json({ ok: true, unlocked: false }); case 'passphrase': return await changePassphraseRoute(ctx, walletId); case 'export-seed': return await exportSeedRoute(ctx, walletId); } // Everything else needs a live backend. const { wallet, backend } = await resolveBackend(userId, walletId); switch (rest[0]) { case 'capabilities': { const all: Capability[] = [ 'onchainReceive', 'onchainSend', 'coinControl', 'psbt', 'bumpFee', 'sweep', 'accounts', 'lightningReceive', 'lightningSend', 'keysend', 'customPreimages', 'offers', 'channels', 'peers', 'routing', 'signMessage', ]; return json({ kind: wallet.kind, capabilities: all.filter((c) => backend.supports(c)) }); } case 'info': return json({ info: await backend.getInfo() }); case 'balances': return json({ balances: await backend.getBalances(), sync: syncOf(backend) }); case 'transactions': { const limit = Number(ctx.url.searchParams.get('limit') ?? 50); const txs = await backend.getTransactions({ limit }); // Overlay owner labels, which live in Officer's DB rather than any backend. const labels = await getWalletLabels(walletId); const byRef = new Map(labels.filter((l) => l.kind === 'tx').map((l) => [l.ref, l.label])); return json({ transactions: txs.map((t) => ({ ...t, label: byRef.get(t.txid) ?? t.label })), sync: syncOf(backend), }); } case 'address': { requireCap(backend, 'onchainReceive', 'receiving on-chain'); const peek = ctx.url.searchParams.get('peek') === 'true'; // An explicit ?type= wins; otherwise the wallet's defaultBip decides. Without this the on-chain // backend would always fall back to its own preference order (native segwit), silently ignoring an // owner who set the wallet to taproot or legacy. const asked = ctx.url.searchParams.get('type'); if (asked && !asAddressType(asked)) return badRequest(`unknown address type "${asked}"`); const type = asked ? asAddressType(asked) : BIP_ADDRESS_TYPE[String(wallet.defaultBip)]; return json(await backend.getNewAddress({ peek, type })); } case 'utxos': return await utxosRoute(ctx, walletId, backend, rest.slice(1)); // POST starts a deep rescan, GET reads how it is going. Both answer with the same `rescan` block that // rides on balances/transactions/utxos, so the UI has one shape to render and can poll whichever it // was already polling. A backend with nothing to rescan is a clean 501 rather than a silent no-op. case 'rescan': { if (ctx.req.method === 'GET') return json({ rescan: syncOf(backend)?.rescan ?? null }); if (ctx.req.method !== 'POST') return json({ error: 'method not allowed' }, 405); if (!backend.startRescan) { throw new BackendError('this wallet has nothing to rescan', 501, 'NOT_SUPPORTED'); } return json({ rescan: await backend.startRescan() }); } case 'fees': return json({ fees: await backend.estimateFees() }); case 'send': { if (ctx.req.method !== 'POST') return json({ error: 'method not allowed' }, 405); requireCap(backend, 'onchainSend', 'sending on-chain'); // The lock gate comes before body validation, not after. The backend checks it too, but only once // the request has already passed every field check here — so a locked wallet was answering "your // fee rate is wrong" instead of "unlock me first", which sends the UI down the wrong path. if (wallet.hasSeed && !sessionFor(walletId).isUnlocked()) throw new WalletLockedError(); const req2 = await body[0]>(ctx.req); if (!req2.address) return badRequest('address is required'); if (!req2.sendAll && !req2.amountSats) return badRequest('amountSats or sendAll is required'); if (!req2.satPerVbyte || req2.satPerVbyte < 1) return badRequest('satPerVbyte must be at least 1'); // Never spend a frozen coin, even if the caller passed no explicit outpoint list. const frozenList = await getFrozenOutpoints(walletId); const frozen = new Set(frozenList); if (req2.outpoints?.some((o) => frozen.has(o))) return badRequest('refusing to spend a frozen UTXO'); // Spread last so a caller cannot supply its own frozen list: the check above only ever covered // outpoints the caller named explicitly, which left automatic selection free to pick a frozen coin. const result = await backend.sendCoins({ ...req2, frozenOutpoints: frozenList }); if (req2.label) await setWalletLabel(walletId, 'tx', result.txid, req2.label); return json(result); } case 'invoices': return await invoicesRoute(ctx, backend, rest.slice(1)); case 'decode': { if (ctx.req.method !== 'POST') return json({ error: 'method not allowed' }, 405); const { bolt11 } = await body<{ bolt11?: string }>(ctx.req); if (!bolt11) return badRequest('bolt11 is required'); return json({ decoded: await backend.decodeInvoice(bolt11) }); } case 'payments': { const limit = Number(ctx.url.searchParams.get('limit') ?? 50); return json({ payments: await backend.getPayments({ limit }) }); } case 'pay': { if (ctx.req.method !== 'POST') return json({ error: 'method not allowed' }, 405); requireCap(backend, 'lightningSend', 'paying lightning invoices'); const payReq = await body[0]>(ctx.req); if (!payReq.bolt11) return badRequest('bolt11 is required'); if (payReq.feeLimitMsat && payReq.feeLimitPercent) { return badRequest('feeLimitMsat and feeLimitPercent are mutually exclusive'); } return json({ payment: await backend.payInvoice(payReq) }); } case 'keysend': { if (ctx.req.method !== 'POST') return json({ error: 'method not allowed' }, 405); requireCap(backend, 'keysend', 'keysend'); const ks = await body[0]>(ctx.req); if (!ks.destination || !ks.amountMsat) return badRequest('destination and amountMsat are required'); return json({ payment: await backend.sendKeysend(ks) }); } case 'channels': requireCap(backend, 'channels', 'channels'); return json({ channels: await backend.getChannels() }); case 'peers': requireCap(backend, 'peers', 'peers'); return json({ peers: await backend.getPeers() }); case 'sign': { if (ctx.req.method !== 'POST') return json({ error: 'method not allowed' }, 405); requireCap(backend, 'signMessage', 'message signing'); const { message } = await body<{ message?: string }>(ctx.req); if (!message) return badRequest('message is required'); return json(await backend.signMessage(message)); } case 'verify': { if (ctx.req.method !== 'POST') return json({ error: 'method not allowed' }, 405); requireCap(backend, 'signMessage', 'message verification'); const { message, signature } = await body<{ message?: string; signature?: string }>(ctx.req); if (!message || !signature) return badRequest('message and signature are required'); return json(await backend.verifyMessage(message, signature)); } case 'labels': { if (ctx.req.method === 'GET') return json({ labels: await getWalletLabels(walletId) }); if (ctx.req.method !== 'POST') return json({ error: 'method not allowed' }, 405); const { kind, ref, label } = await body<{ kind?: string; ref?: string; label?: string }>(ctx.req); if (kind !== 'address' && kind !== 'tx') return badRequest('kind must be "address" or "tx"'); if (!ref) return badRequest('ref is required'); await setWalletLabel(walletId, kind, ref, label ?? ''); return json({ ok: true }); } default: return null; } } // ── wallet lifecycle ───────────────────────────────────────────────────────────────────────────── type CreateBody = { name?: string; kind?: string; network?: string; /** onchain only: omit `mnemonic` to generate a fresh seed. */ mnemonic?: string; words?: 12 | 24; passphrase?: string; bip39Passphrase?: string; defaultBip?: number; config?: Record; makeActive?: boolean; }; async function createWalletRoute(ctx: OfficerContext): Promise { if (!hasStoreKey()) { throw new BackendError('VAULT_STORE_KEY is not configured; refusing to store wallet secrets', 503, 'NO_STORE_KEY'); } const b = await body(ctx.req); if (!b.name?.trim()) return badRequest('name is required'); // Same cap as the rename route, so a name you can create is always a name you can type back. if (b.name.trim().length > 64) return badRequest('name is too long (64 characters max)'); if (!b.kind || !(KINDS as readonly string[]).includes(b.kind)) { return badRequest(`kind must be one of ${KINDS.join(', ')}`); } const kind = b.kind as WalletKind; const network = (b.network ?? getConfig().network) as BitcoinNetwork; // Remote-node wallets: store the connection config, no seed involved. if (kind !== 'onchain') { if (!b.config) return badRequest(`${kind} wallets require a config object`); const wallet = await createWallet({ userId: ctx.userId, name: b.name.trim(), kind, network, config: b.config, makeActive: b.makeActive ?? true, }); return json({ wallet }, 201); } // Self-custodial on-chain wallet: seal a seed under the owner passphrase. if (!b.passphrase) return badRequest('passphrase is required for a seeded wallet'); const mnemonic = b.mnemonic?.trim() || generateSeed(b.words ?? 24); const envelope = await sealSeed(mnemonic, b.passphrase, b.bip39Passphrase); const { fingerprint, xpubs } = await deriveAccountXpubs(envelope, b.passphrase, network); const wallet = await createWallet({ userId: ctx.userId, name: b.name.trim(), kind, network, sealedSeed: JSON.stringify(envelope), fingerprint, xpubs: Object.fromEntries(Object.entries(xpubs).map(([k, v]) => [k, v])), defaultBip: b.defaultBip ?? 84, makeActive: b.makeActive ?? true, }); // An IMPORTED seed is the one case that needs the chain searched from scratch: an indexing upstream // only watches an account from the moment it is registered, so a wallet with a history would otherwise // show a confident, wrong zero. A freshly generated seed has no history to find, and a rescan for it // would burn several minutes of the node's CPU to confirm nothing. if (b.mnemonic) void kickOffRescan(ctx.userId, wallet.id); // Return the mnemonic exactly once, and ONLY when we generated it — the owner has to write it down and // has no other chance to see it without re-entering the passphrase. An imported mnemonic is never // echoed back: the caller already has it, and echoing would put it in a response log for no reason. return json({ wallet, mnemonic: b.mnemonic ? undefined : mnemonic }, 201); } /** * Start a rescan behind the response that created the wallet. * * Never throws: a chain source that cannot rescan, or an upstream that is down, must not turn a * successful wallet import into a failed one. The owner can trigger it by hand from Settings either way. */ async function kickOffRescan(userId: number, walletId: number): Promise { try { const { backend } = await resolveBackend(userId, walletId); await backend.startRescan?.(); } catch (err) { console.error('[wallet] rescan after import failed to start:', err instanceof Error ? err.message : err); } } async function deleteWalletRoute(ctx: OfficerContext, walletId: number): Promise { const wallet = await getWallet(ctx.userId, walletId); if (!wallet) return json({ error: 'wallet not found' }, 404); // Deleting a seeded wallet destroys the only copy of the key material Officer holds. Require the // passphrase, even when the wallet is already unlocked — an open session must not be enough. if (wallet.hasSeed) { const { passphrase } = await body<{ passphrase?: string }>(ctx.req); if (!passphrase) return badRequest('passphrase is required to delete a seeded wallet'); const sealed = await getSealedSeed(ctx.userId, walletId); if (!sealed) throw new BackendError('wallet seed is missing', 500, 'NO_SEED'); if (!(await verifyPassphrase(walletId, JSON.parse(sealed) as SeedEnvelope, passphrase))) { return json({ error: 'incorrect passphrase' }, 401); } } sessionFor(walletId).lock(); invalidate(walletId); const deleted = await deleteWallet(ctx.userId, walletId); return deleted ? json({ ok: true }) : json({ error: 'wallet not found' }, 404); } // ── lock lifecycle ─────────────────────────────────────────────────────────────────────────────── async function loadEnvelope(userId: number, walletId: number): Promise { const sealed = await getSealedSeed(userId, walletId); if (!sealed) throw new BackendError('this wallet holds no seed', 400, 'NO_SEED'); return JSON.parse(sealed) as SeedEnvelope; } async function unlockRoute(ctx: OfficerContext, walletId: number): Promise { if (ctx.req.method !== 'POST') return json({ error: 'method not allowed' }, 405); const { passphrase, ttlSec } = await body<{ passphrase?: string; ttlSec?: number }>(ctx.req); if (!passphrase) return badRequest('passphrase is required'); const env = await loadEnvelope(ctx.userId, walletId); const session = sessionFor(walletId); const max = getConfig().unlockTtlSec; // A caller may shorten the window but never extend it past the deployment's configured maximum. await session.unlock(env, passphrase, Math.min(ttlSec ?? max, max)); return json({ ok: true, unlocked: true, secondsRemaining: session.secondsRemaining() }); } async function changePassphraseRoute(ctx: OfficerContext, walletId: number): Promise { if (ctx.req.method !== 'POST') return json({ error: 'method not allowed' }, 405); const { oldPassphrase, newPassphrase } = await body<{ oldPassphrase?: string; newPassphrase?: string }>(ctx.req); if (!oldPassphrase || !newPassphrase) return badRequest('oldPassphrase and newPassphrase are required'); const env = await loadEnvelope(ctx.userId, walletId); const resealed = await changePassphrase(walletId, env, oldPassphrase, newPassphrase); await replaceSealedSeed(ctx.userId, walletId, JSON.stringify(resealed)); // Force a re-unlock under the new passphrase rather than leaving a session opened by the old one. sessionFor(walletId).lock(); return json({ ok: true }); } async function exportSeedRoute(ctx: OfficerContext, walletId: number): Promise { if (ctx.req.method !== 'POST') return json({ error: 'method not allowed' }, 405); const { passphrase } = await body<{ passphrase?: string }>(ctx.req); if (!passphrase) return badRequest('passphrase is required'); const env = await loadEnvelope(ctx.userId, walletId); const mnemonic = await exportMnemonic(walletId, env, passphrase); console.warn(`[wallet] seed exported for wallet ${walletId} by user ${ctx.userId}`); return json({ mnemonic, hasBip39Passphrase: env.hasBip39Passphrase }); } // ── utxos ──────────────────────────────────────────────────────────────────────────────────────── async function utxosRoute( ctx: OfficerContext, walletId: number, backend: WalletBackend, seg: string[], ): Promise { requireCap(backend, 'coinControl', 'coin control'); if (seg[0] === 'freeze') { if (ctx.req.method !== 'POST') return json({ error: 'method not allowed' }, 405); const { outpoint, frozen, reason } = await body<{ outpoint?: string; frozen?: boolean; reason?: string }>(ctx.req); if (!outpoint || !/^[0-9a-f]{64}:\d+$/i.test(outpoint)) return badRequest('outpoint must be "txid:vout"'); await setUtxoFrozen(walletId, outpoint, frozen !== false, reason); return json({ ok: true }); } const [utxos, frozenList, labels] = await Promise.all([ backend.getUtxos(), getFrozenOutpoints(walletId), getWalletLabels(walletId), ]); const frozen = new Set(frozenList); const byAddr = new Map(labels.filter((l) => l.kind === 'address').map((l) => [l.ref, l.label])); // The freeze flag is Officer's, not the backend's — overlay it here so coin control is consistent // across every backend, including ones with no freeze concept of their own. const merged: (Utxo & { label: string | null })[] = utxos.map((u) => ({ ...u, frozen: frozen.has(`${u.txid}:${u.vout}`), label: byAddr.get(u.address) ?? null, })); return json({ utxos: merged, sync: syncOf(backend) }); } // ── invoices ───────────────────────────────────────────────────────────────────────────────────── async function invoicesRoute(ctx: OfficerContext, backend: WalletBackend, seg: string[]): Promise { // /_officer/wallets/:id/invoices/:paymentHash if (seg.length === 1) { const invoice = await backend.lookupInvoice(seg[0]!); return invoice ? json({ invoice }) : json({ error: 'invoice not found' }, 404); } if (ctx.req.method === 'GET') { requireCap(backend, 'lightningReceive', 'lightning'); const limit = Number(ctx.url.searchParams.get('limit') ?? 50); return json({ invoices: await backend.getInvoices({ limit }) }); } if (ctx.req.method === 'POST') { requireCap(backend, 'lightningReceive', 'creating invoices'); const req = await body[0]>(ctx.req); if (req.preimage) requireCap(backend, 'customPreimages', 'custom preimages'); return json({ invoice: await backend.createInvoice(req) }, 201); } return json({ error: 'method not allowed' }, 405); }