the gap limit was 1000, hardcoded, with the comment noting that raising it costs node CPU and not correctness — and no way to raise it. it is the one thing about a scan only the owner can know: how many addresses their old wallet handed out and never had paid. RescanOptions threads from the POST body through the backend and the source to utxos/scan, capped at 100k because past that the scan takes longer than anyone waits. the card gets a "search depth" field beside the button, blank meaning the default. deleting a wallet with no seed took one unconfirmed request. the dialog asked for the wallet's name and then threw the answer away, so the check existed only for whoever went through the dialog — a node wallet still holds the credential, the labels and the freezes. confirmName now travels with the request and the route enforces it. a bodyless DELETE is told which field is missing rather than that its JSON did not parse. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
659 lines
29 KiB
TypeScript
659 lines
29 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 Balances,
|
|
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'];
|
|
|
|
/** Highest gap limit a rescan may be asked for. Four variants at this width is already tens of minutes. */
|
|
const MAX_GAP_LIMIT = 100_000;
|
|
|
|
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 balancesWithFrozen(walletId, backend), 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': {
|
|
// No local state means either "never rescanned" or "this sidecar restarted while one was running",
|
|
// and only the upstream can tell those apart. Probing costs a request per account, so it happens
|
|
// only in that gap — once a scan is adopted or finished, the local state answers.
|
|
if (ctx.req.method === 'GET') {
|
|
const local = syncOf(backend)?.rescan ?? null;
|
|
return json({ rescan: local ?? (await backend.adoptRescan?.()) ?? 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');
|
|
}
|
|
// The gap limit is the one thing about a rescan only the owner can know — how many addresses their
|
|
// old wallet issued without ever being paid to. The ceiling is a node-CPU guard, not a correctness
|
|
// one: past it the scan takes longer than anyone will wait for it.
|
|
const { gapLimit } = await body<{ gapLimit?: number }>(ctx.req);
|
|
if (gapLimit !== undefined && (!Number.isInteger(gapLimit) || gapLimit < 1 || gapLimit > MAX_GAP_LIMIT)) {
|
|
return badRequest(`gapLimit must be a whole number between 1 and ${MAX_GAP_LIMIT}`);
|
|
}
|
|
return json({ rescan: await backend.startRescan({ gapLimit }) });
|
|
}
|
|
|
|
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);
|
|
|
|
// A bodyless DELETE is a caller that skipped the confirmation, not a malformed request — it should be
|
|
// told which field is missing rather than that its JSON did not parse.
|
|
type DeleteBody = { passphrase?: string; confirmName?: string };
|
|
const { passphrase, confirmName } = await body<DeleteBody>(ctx.req).catch((): DeleteBody => ({}));
|
|
|
|
// 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) {
|
|
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);
|
|
}
|
|
} else if (confirmName?.trim() !== wallet.name) {
|
|
// A wallet with no seed of its own still holds the node credential, the labels and the freezes, and
|
|
// it deleted on one unconfirmed request — the name check lived only in the dialog, so anything that
|
|
// was not the dialog skipped it. Naming the wallet is the same bar the UI already asks for, now
|
|
// enforced where it cannot be bypassed.
|
|
return badRequest('confirmName must match the wallet name');
|
|
}
|
|
|
|
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 });
|
|
}
|
|
|
|
/**
|
|
* Balances, plus how much of the confirmed figure the owner has frozen.
|
|
*
|
|
* Only asks the backend for its UTXOs when something is actually frozen, so a wallet that never uses the
|
|
* feature pays nothing for it — on LND that is a real extra RPC, and this runs on every balance poll.
|
|
*/
|
|
async function balancesWithFrozen(walletId: number, backend: WalletBackend): Promise<Balances> {
|
|
const balances = await backend.getBalances();
|
|
const frozenList = await getFrozenOutpoints(walletId);
|
|
if (frozenList.length === 0) return balances;
|
|
|
|
const frozen = new Set(frozenList);
|
|
const utxos = await backend.getUtxos();
|
|
// Confirmed only, to match what it is a component of. An unconfirmed frozen coin is not spendable for
|
|
// a reason that has nothing to do with freezing.
|
|
const sats = utxos
|
|
.filter((u) => frozen.has(`${u.txid}:${u.vout}`) && u.confirmations > 0)
|
|
.reduce((sum, u) => sum + u.amountSats, 0);
|
|
return { ...balances, onchainFrozen: sats };
|
|
}
|
|
|
|
// ── 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);
|
|
}
|