/** * Admin crypto treasury endpoints. * * All endpoints require the admin token (X-Admin-Token header) — same * gate used by admin-filings and reseller-certs. Read-only endpoints * return 200; mutating endpoints audit to order_audit_log. */ import crypto from "node:crypto"; import { Router } from "express"; import type { Request, Response } from "express"; import { pool } from "../db.js"; const router = Router(); // ── Auth middleware ───────────────────────────────────────────────────── function requireAdminToken(req: Request, res: Response): boolean { const expected = process.env.ADMIN_API_TOKEN || ""; const supplied = (req.headers["x-admin-token"] || "").toString().trim(); if (!expected) { // If token not configured, reject — fail-closed. res.status(503).json({ error: "ADMIN_API_TOKEN not set" }); return false; } // Timing-safe compare. timingSafeEqual throws on length mismatch, so // guard with a length check first (a cheap length-disclosure trade-off // that's negligible compared to the attack surface of naïve ==). const sb = Buffer.from(supplied); const eb = Buffer.from(expected); const ok = sb.length === eb.length && crypto.timingSafeEqual(sb, eb); if (!ok) { res.status(403).json({ error: "forbidden" }); return false; } return true; } async function auditLog( actor: string, action: string, target: string, details?: unknown, ) { // order_audit_log schema requires: order_type IN ('formation','service','quote'), // order_id (integer, NOT NULL), action, actor_type IN // ('system','admin','worker','customer') + optional order_number, // actor_name, metadata. We use order_type='service' (closest match — // crypto treasury is an internal service action) and store the real // crypto-treasury target (order_number or 'sweep:N') in order_number. try { await pool.query( `INSERT INTO order_audit_log (order_type, order_id, order_number, action, actor_type, actor_name, metadata) VALUES ('service', 0, $1, $2, 'admin', $3, $4::jsonb)`, [target, action, actor, JSON.stringify(details ?? {})], ); } catch (err) { console.error("[admin-crypto] audit log failed:", err); // non-fatal } } // ── GET /api/v1/admin/crypto-payments ─────────────────────────────────── router.get( "/api/v1/admin/crypto-payments", async (req: Request, res: Response) => { if (!requireAdminToken(req, res)) return; const stateFilter = typeof req.query.state === "string" ? req.query.state : ""; const params: (string | number)[] = []; const where: string[] = []; if (stateFilter) { where.push(`j.state = $${params.length + 1}`); params.push(stateFilter); } const whereSql = where.length ? `WHERE ${where.join(" AND ")}` : ""; const r = await pool.query( ` SELECT j.order_id, j.order_type, j.state, j.coin, j.amount_coin, j.amount_usd_cents, j.needed_usd_cents, j.offramp_provider, j.offramp_ref, j.relay_deposit_id, j.target_card_id, j.last_error, j.attempt_count, j.next_retry_at, j.received_at, j.funds_at_relay_at, j.settled_at, j.created_at, j.updated_at, ( SELECT COALESCE(SUM(amount_usd_cents), 0) FROM vendor_obligations WHERE order_id = j.order_id ) AS total_obligations_cents, ( SELECT COUNT(*) FROM vendor_obligations WHERE order_id = j.order_id AND status = 'paid' ) AS obligations_paid FROM crypto_payment_jobs j ${whereSql} ORDER BY j.created_at DESC LIMIT 200 `, params, ); res.json({ jobs: r.rows, count: r.rows.length }); }, ); // NOTE: specific paths (sweeps, tax-export) are registered BELOW before // the /:order_id param route to avoid Express pattern-match conflicts // (order_id='sweeps' would otherwise match this handler). // ── GET /api/v1/admin/crypto-payments/:order_id ──────────────────────── // Registered AFTER the specific sub-paths below. Express matches in // registration order; putting this last ensures /sweeps and // /tax-export aren't interpreted as order_ids. // ── Detail view (must come AFTER specific sub-paths below) ──────────── // // We re-register this at the end of the file so Express pattern-matches // /sweeps and /tax-export first. // ── POST /api/v1/admin/crypto-payments/:order_id/retry-offramp ───────── router.post( "/api/v1/admin/crypto-payments/:order_id/retry-offramp", async (req: Request, res: Response) => { if (!requireAdminToken(req, res)) return; const orderId = req.params.order_id; const r = await pool.query( `UPDATE crypto_payment_jobs SET state = 'sizing', last_error = NULL, next_retry_at = NULL, updated_at = NOW() WHERE order_id = $1 AND state IN ('manual','failed','offramping') RETURNING state, attempt_count`, [orderId], ); if (r.rows.length === 0) { res.status(409).json({ error: "job not in a retry-able state (must be manual / failed / offramping)", }); return; } await auditLog( req.headers["x-admin-user"]?.toString() || "admin", "crypto_retry_offramp", orderId, { new_state: "sizing" }, ); res.json({ ok: true, state: r.rows[0].state }); }, ); // ── POST /api/v1/admin/crypto-payments/:order_id/mark-settled ────────── router.post( "/api/v1/admin/crypto-payments/:order_id/mark-settled", async (req: Request, res: Response) => { if (!requireAdminToken(req, res)) return; const orderId = req.params.order_id; const { note } = req.body ?? {}; const r = await pool.query( `UPDATE crypto_payment_jobs SET state = 'settled', settled_at = NOW(), last_error = NULL, updated_at = NOW() WHERE order_id = $1 RETURNING state`, [orderId], ); if (r.rows.length === 0) { res.status(404).json({ error: "job not found" }); return; } await auditLog( req.headers["x-admin-user"]?.toString() || "admin", "crypto_manual_settle", orderId, { note }, ); res.json({ ok: true }); }, ); // ── GET /api/v1/admin/crypto-payments/sweeps ─────────────────────────── router.get( "/api/v1/admin/crypto-payments/sweeps", async (req: Request, res: Response) => { if (!requireAdminToken(req, res)) return; const r = await pool.query( `SELECT * FROM cold_wallet_sweeps WHERE status IN ('pending','approved','broadcast') ORDER BY created_at DESC LIMIT 100`, ); res.json({ sweeps: r.rows }); }, ); // ── POST /api/v1/admin/crypto-payments/sweeps/:id/approve ────────────── router.post( "/api/v1/admin/crypto-payments/sweeps/:id/approve", async (req: Request, res: Response) => { if (!requireAdminToken(req, res)) return; const sweepId = Number(req.params.id); if (!Number.isFinite(sweepId)) { res.status(400).json({ error: "bad sweep id" }); return; } const actor = req.headers["x-admin-user"]?.toString() || "admin"; const r = await pool.query( `UPDATE cold_wallet_sweeps SET status = 'approved', approved_by = $2, approved_at = NOW(), updated_at = NOW() WHERE id = $1 AND status = 'pending' RETURNING coin, amount_coin`, [sweepId, actor], ); if (r.rows.length === 0) { res.status(409).json({ error: "sweep not in pending state" }); return; } await auditLog(actor, "crypto_sweep_approve", `sweep:${sweepId}`, { coin: r.rows[0].coin, amount_coin: r.rows[0].amount_coin }); res.json({ ok: true }); }, ); // ── GET /api/v1/admin/crypto-payments/tax-export?year=YYYY ───────────── // // IRS Form 8949 columns: description, date_acquired, date_sold, // proceeds, cost_basis, gain_loss. Covers all offramp/disposal rows // whose disposed_at is in the given tax year. router.get( "/api/v1/admin/crypto-payments/tax-export", async (req: Request, res: Response) => { if (!requireAdminToken(req, res)) return; const year = Number(req.query.year) || new Date().getUTCFullYear() - 1; const r = await pool.query( ` SELECT order_id, coin, amount_coin, fx_rate_usd, acquired_at, disposed_at, basis_usd_cents, proceeds_usd_cents, provider, provider_ref FROM crypto_payment_ledger WHERE movement_type = 'offramp' AND state IN ('pending','confirmed') AND disposed_at >= make_timestamptz($1, 1, 1, 0, 0, 0, 'UTC') AND disposed_at < make_timestamptz($1 + 1, 1, 1, 0, 0, 0, 'UTC') ORDER BY disposed_at ASC, id ASC `, [year], ); // Build the CSV const header = [ "Description", // e.g., "0.00873 BTC — Order CO-SMOKE02" "Date Acquired", // MM/DD/YYYY "Date Sold", "Proceeds (USD)", "Cost Basis (USD)", "Gain/(Loss) (USD)", "Provider Reference", ]; const lines: string[] = [header.join(",")]; const fmt = (d: Date) => `${String(d.getUTCMonth() + 1).padStart(2, "0")}/${String(d.getUTCDate()).padStart(2, "0")}/${d.getUTCFullYear()}`; for (const row of r.rows) { const amount = Number(row.amount_coin); const proceeds = Number(row.proceeds_usd_cents || 0) / 100; const basis = Number(row.basis_usd_cents || 0) / 100; const gain = proceeds - basis; const acquired = row.acquired_at ? fmt(new Date(row.acquired_at)) : ""; const sold = row.disposed_at ? fmt(new Date(row.disposed_at)) : ""; const desc = `${Math.abs(amount).toFixed(8)} ${row.coin} — Order ${row.order_id}`; lines.push([ `"${desc}"`, `"${acquired}"`, `"${sold}"`, proceeds.toFixed(2), basis.toFixed(2), gain.toFixed(2), `"${row.provider_ref || ""}"`, ].join(",")); } res.setHeader("Content-Type", "text/csv"); res.setHeader( "Content-Disposition", `attachment; filename="crypto-disposals-${year}.csv"`, ); res.send(lines.join("\n")); }, ); // ── GET /api/v1/admin/crypto-payments/:order_id (must be LAST) ───────── // Registered last so specific paths above match first. router.get( "/api/v1/admin/crypto-payments/:order_id", async (req: Request, res: Response) => { if (!requireAdminToken(req, res)) return; const orderId = req.params.order_id; const job = await pool.query( "SELECT * FROM crypto_payment_jobs WHERE order_id = $1", [orderId], ); if (job.rows.length === 0) { res.status(404).json({ error: "job not found" }); return; } const [ledger, obligations, deposit] = await Promise.all([ pool.query( `SELECT * FROM crypto_payment_ledger WHERE order_id = $1 ORDER BY created_at ASC`, [orderId], ), pool.query( `SELECT * FROM vendor_obligations WHERE order_id = $1 ORDER BY obligation_kind, id`, [orderId], ), job.rows[0].relay_deposit_id ? pool.query("SELECT * FROM relay_deposits WHERE id = $1", [job.rows[0].relay_deposit_id]) : Promise.resolve({ rows: [] }), ]); res.json({ job: job.rows[0], ledger: ledger.rows, obligations: obligations.rows, relay_deposit: deposit.rows[0] || null, }); }, ); export default router;