two holes found auditing the wallet sidecar after the frozen-utxo fix. the brute-force backoff lived inside UnlockSession.unlock alone, so /unlock capped at five guesses a minute while export-seed — the one endpoint that returns the words in the clear — took unlimited ones. every passphrase check now goes through the same guard. verifyPassphrase rethrows LOCKED_OUT rather than folding it into `false`, so a caller can tell "wrong" from "stop". nextUnused advanced its mark on every issuance, paid or not, so a run of unpaid addresses walked it past the end of the window the next scan covers; a payment there would never be found again, and esplora has no rescan to go looking. sources now declare how far past a scan's last index they can still see, and issuance clamps to it — re-offering a virgin address rather than handing out one that could lose money. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
611 lines
26 KiB
TypeScript
611 lines
26 KiB
TypeScript
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<string, unknown>, { status });
|
|
}
|
|
|
|
function badRequest(message: string): Response {
|
|
return json({ error: message }, 400);
|
|
}
|
|
|
|
async function body<T>(req: Request): Promise<T> {
|
|
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<Response | null> {
|
|
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<Response | null> {
|
|
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<string, unknown> }>(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<T>` 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<Parameters<WalletBackend['sendCoins']>[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<Parameters<WalletBackend['payInvoice']>[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<Parameters<WalletBackend['sendKeysend']>[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<string, unknown>;
|
|
makeActive?: boolean;
|
|
};
|
|
|
|
async function createWalletRoute(ctx: OfficerContext): Promise<Response> {
|
|
if (!hasStoreKey()) {
|
|
throw new BackendError('VAULT_STORE_KEY is not configured; refusing to store wallet secrets', 503, 'NO_STORE_KEY');
|
|
}
|
|
const b = await body<CreateBody>(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<void> {
|
|
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<Response> {
|
|
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<SeedEnvelope> {
|
|
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<Response> {
|
|
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<Response> {
|
|
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<Response> {
|
|
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<Response> {
|
|
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<Response> {
|
|
// /_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<Parameters<WalletBackend['createInvoice']>[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);
|
|
}
|