diff --git a/src/app/[locale]/vaults/[id]/page.tsx b/src/app/[locale]/vaults/[id]/page.tsx index 1bf648f..a2566ba 100644 --- a/src/app/[locale]/vaults/[id]/page.tsx +++ b/src/app/[locale]/vaults/[id]/page.tsx @@ -28,6 +28,7 @@ import { type DisclosureAcknowledgement, type PolicyReasonCode, } from "../../../../lib/api/vaults"; +import { VaultLiquidityPanel } from "../../../../components/VaultLiquidityPanel"; import { VaultAuditLog } from "../../../../components/VaultAuditLog"; import { VaultGovernancePanel } from "../../../../components/VaultGovernancePanel"; @@ -381,6 +382,9 @@ export default function VaultDetailPage({ params }: { params: Promise<{ id: stri + + + {/* Right sidebar: user position + deposit/withdraw */} @@ -599,6 +603,4 @@ export default function VaultDetailPage({ params }: { params: Promise<{ id: stri color: "var(--text-hi)", fontFamily: "var(--font-mono)", fontSize: 13, }} /> - {withdrawShares && !isNaN(parseFloat(withdrawShares)) && parseFloat(withdrawShares) > 0 && ( -
- ≈ ${(parseFloat(withdrawShares) * vault.sharePrice).toFixed(2)} USDC at current NAV + {withdrawShares && !isNaN(parseFloat(withdrawShares)) && diff --git a/src/components/VaultLiquidityPanel.tsx b/src/components/VaultLiquidityPanel.tsx new file mode 100644 index 0000000..277d279 --- /dev/null +++ b/src/components/VaultLiquidityPanel.tsx @@ -0,0 +1,448 @@ +"use client"; + +/** + * VaultLiquidityPanel — Issue #241 + * + * Operator-facing panel for redemption liquidity management. + * Shows the current cash buffer vs. target, queued redemption demand, + * the computed JIT unwind plan (if needed), and controls to adjust the + * cash buffer configuration or approve/execute individual unwind steps. + */ + +import React, { useCallback, useEffect, useState } from "react"; +import { + fetchCashBufferConfig, + fetchLiquidityStatus, + saveCashBufferConfig, + executeUnwindStep, + type CashBufferConfig, + type LiquidityNeedsResult, + type UnwindStep, + type UnwindPriority, +} from "../lib/api/vaultLiquidity"; +import { useWalletStore } from "../lib/store/wallet"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function fmtUsd(n: number) { + if (n >= 1_000_000) return `$${(n / 1_000_000).toFixed(2)}M`; + if (n >= 1_000) return `$${(n / 1_000).toFixed(1)}K`; + return `$${n.toFixed(2)}`; +} + +function fmtPct(n: number) { + return `${(n * 100).toFixed(2)}%`; +} + +const STATUS_COLOR: Record = { + healthy: "var(--call)", + buffer_draw: "var(--atm)", + jit_needed: "var(--put)", + insufficient: "var(--put)", + alert: "var(--put)", +}; + +const STATUS_BG: Record = { + healthy: "var(--call-dim)", + buffer_draw: "var(--atm-dim)", + jit_needed: "var(--put-dim)", + insufficient: "var(--put-dim)", + alert: "var(--put-dim)", +}; + +const STATUS_LABEL: Record = { + healthy: "Healthy", + buffer_draw: "Buffer Draw", + jit_needed: "JIT Unwind Needed", + insufficient: "Insufficient Liquidity", + alert: "⚠ Alert — Deposits Paused", +}; + +// --------------------------------------------------------------------------- +// Buffer configuration form +// --------------------------------------------------------------------------- + +interface ConfigFormProps { + initial: CashBufferConfig; + onSave: (c: CashBufferConfig) => Promise; +} + +function ConfigForm({ initial, onSave }: ConfigFormProps) { + const [cfg, setCfg] = useState(initial); + const [saving, setSaving] = useState(false); + const [saved, setSaved] = useState(false); + const [err, setErr] = useState(null); + + const update = (key: K, val: CashBufferConfig[K]) => + setCfg(c => ({ ...c, [key]: val })); + + const handleSave = async () => { + if (cfg.minBufferPct >= cfg.targetBufferPct) { + setErr("Minimum buffer must be less than target buffer."); + return; + } + setSaving(true); setErr(null); + try { + await onSave(cfg); + setSaved(true); + setTimeout(() => setSaved(false), 2000); + } catch (e) { + setErr(e instanceof Error ? e.message : "Save failed"); + } finally { + setSaving(false); + } + }; + + const field = (label: string, el: React.ReactNode) => ( +
+ + {el} +
+ ); + + const numInput = (val: number, setter: (v: number) => void, step = 0.01) => ( + setter(parseFloat(e.target.value) || 0)} + style={{ padding: "6px 8px", background: "var(--bg-overlay)", border: "1px solid var(--border-default)", color: "var(--text-hi)", fontSize: 12, fontFamily: "var(--font-mono)", width: "100%" }} + /> + ); + + return ( +
+
+ {field("Target buffer (% of TVL)", numInput(cfg.targetBufferPct, v => update("targetBufferPct", v)))} + {field("Min buffer (% of TVL)", numInput(cfg.minBufferPct, v => update("minBufferPct", v)))} + {field("Max slippage (%)", numInput(cfg.maxSlippagePct, v => update("maxSlippagePct", v)))} + {field("Unwind priority", ( + + ))} +
+ + {err &&
{err}
} + +
+ ); +} + +// --------------------------------------------------------------------------- +// Unwind plan table +// --------------------------------------------------------------------------- + +interface UnwindPlanTableProps { + steps: UnwindStep[]; + requiresApproval: boolean; + vaultId: string; +} + +function UnwindPlanTable({ steps, requiresApproval, vaultId }: UnwindPlanTableProps) { + const token = useWalletStore(s => s.token); + const [executing, setExecuting] = useState(null); + const [results, setResults] = useState>({}); + + const handleExecute = async (step: UnwindStep) => { + if (!token) return; + setExecuting(step.candidate.positionId); + try { + const r = await executeUnwindStep(vaultId, step.candidate.positionId, token); + setResults(prev => ({ ...prev, [step.candidate.positionId]: r.tx_id })); + } catch (e) { + setResults(prev => ({ ...prev, [step.candidate.positionId]: `Error: ${e instanceof Error ? e.message : "Failed"}` })); + } finally { + setExecuting(null); + } + }; + + if (!steps.length) return ( +
+ No unwind steps needed — buffer covers all queued redemptions. +
+ ); + + return ( +
+ + + + {["#", "Position", "Underlying", "Strike", "DTE", "Proceeds", "Est. Loss", "Slippage", "Cumulative", ""].map(h => ( + + ))} + + + + {steps.map(step => { + const c = step.candidate; + const txId = results[c.positionId]; + const isExec = executing === c.positionId; + return ( + + + + + + + + + + + + + ); + })} + +
+ {h} +
{step.rank} + {c.positionType.replace(/_/g, " ")} + {c.underlying} + {c.strike >= 1000 ? `$${c.strike.toLocaleString()}` : `$${c.strike.toFixed(4)}`} + + {c.daysToExpiry}d + {fmtUsd(c.proceedsUsd)} + {c.realisedPnlUsd < 0 ? "-" : "+"}{fmtUsd(Math.abs(c.realisedPnlUsd))} + 0.015 ? "var(--atm)" : "var(--text-mid)" }}> + {fmtPct(c.estimatedSlippage)} + + {fmtUsd(step.cumulativeProceedsUsd)} + {step.sufficientAtThisStep && ( + ✓ covers + )} + + {txId ? ( + + {txId.startsWith("Error") ? txId : `✓ ${txId.slice(0, 12)}…`} + + ) : ( + + )} +
+
+ ); +} + +// --------------------------------------------------------------------------- +// Main panel +// --------------------------------------------------------------------------- + +export interface VaultLiquidityPanelProps { + vaultId: string; + tvlUsd: number; + /** Current idle cash in the vault (USD). Pass 0 if unknown; mock fills it in. */ + bufferUsd?: number; + /** Current queued redemption demand in USD. */ + queuedRedemptionUsd?: number; +} + +export function VaultLiquidityPanel({ + vaultId, + tvlUsd, + bufferUsd: initialBuffer, + queuedRedemptionUsd: initialQueue, +}: VaultLiquidityPanelProps) { + // In mock mode derive plausible defaults from TVL. + const bufferUsd = initialBuffer ?? tvlUsd * 0.048; + const queuedRedemption = initialQueue ?? tvlUsd * 0.03; + + const [status, setStatus] = useState(null); + const [config, setConfig] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [showConfig, setShowConfig] = useState(false); + + const load = useCallback(async () => { + setLoading(true); setError(null); + try { + const [liq, cfg] = await Promise.all([ + fetchLiquidityStatus(vaultId, tvlUsd, bufferUsd, queuedRedemption), + fetchCashBufferConfig(vaultId), + ]); + setStatus(liq); + setConfig(cfg); + } catch (e) { + setError(e instanceof Error ? e.message : "Failed to load liquidity data"); + } finally { + setLoading(false); + } + }, [vaultId, tvlUsd, bufferUsd, queuedRedemption]); + + useEffect(() => { load(); }, [load]); + + const handleSaveConfig = async (cfg: CashBufferConfig) => { + await saveCashBufferConfig(cfg); + setConfig(cfg); + await load(); + }; + + return ( +
+ {/* Header */} +
+

Redemption Liquidity

+
+ + +
+
+ + {/* Config form */} + {showConfig && config && ( +
+
Cash Buffer Configuration
+ +
+ )} + + {loading && !status && ( +
+ {[1, 2].map(i =>
)} +
+ )} + + {error && ( +
+ {error} +
+ )} + + {status && ( + <> + {/* Status summary cards */} +
+ {[ + { label: "Status", value: ( + + {STATUS_LABEL[status.status]} + + )}, + { label: "Cash Buffer", value: `${fmtUsd(status.bufferUsd)} (${fmtPct(status.bufferPct)})` }, + { label: "Queue Demand", value: fmtUsd(status.queuedRedemptionUsd) }, + { label: "Shortfall", value: status.shortfallUsd > 0 ? fmtUsd(status.shortfallUsd) : "None" }, + ].map(card => ( +
+
{card.label}
+ {typeof card.value === "string" + ?
{card.value}
+ : card.value} +
+ ))} +
+ + {/* Message */} +
+ {status.message} +
+ + {/* Buffer bar */} + {config && ( +
+
+ Cash buffer: {fmtPct(status.bufferPct)} + Target: {fmtPct(config.targetBufferPct)} | Min: {fmtPct(config.minBufferPct)} +
+
+ {/* min line */} +
+ {/* target line */} +
+ {/* fill */} +
+
+
+ )} + + {/* JIT unwind plan */} + {status.plan && ( +
+
+ + JIT Unwind Plan — {status.plan.steps.length} step{status.plan.steps.length !== 1 ? "s" : ""} + +
+ + Est. loss: + {fmtUsd(Math.abs(status.plan.totalRealisedLossUsd))} + + + + Max slippage: {fmtPct(status.plan.maxStepSlippage)} + + {status.plan.requiresApproval && ( + + Approval Required + + )} +
+
+
+ +
+
+ )} + + )} +
+ ); +} diff --git a/src/lib/api/vaultLiquidity.ts b/src/lib/api/vaultLiquidity.ts new file mode 100644 index 0000000..7928804 --- /dev/null +++ b/src/lib/api/vaultLiquidity.ts @@ -0,0 +1,467 @@ +/** + * vaultLiquidity.ts — Issue #241 + * + * Redemption liquidity management: cash buffer configuration and + * just-in-time (JIT) position-unwind planning for the vault withdrawal queue. + * + * Concept + * ------- + * Each vault keeps a configurable fraction of TVL as idle cash (the "cash + * buffer"). When queued redemptions arrive, the engine first draws from the + * buffer. If the buffer is insufficient it builds a least-cost unwind plan — + * ordered by liquidity, realised-loss, and proximity to expiry — and surfaces + * it to the operator for review before execution. + * + * This module owns: + * 1. `CashBufferConfig` — per-vault buffer/policy settings + * 2. `UnwindCandidate` — a position eligible for unwind + * 3. `buildUnwindPlan` — ranks candidates by cost, returns a plan + * 4. `evaluateLiquidityNeeds` — compares buffer against queue, returns verdict + * 5. Mock data + adapter functions for the UI + */ + +// --------------------------------------------------------------------------- +// Types — buffer configuration +// --------------------------------------------------------------------------- + +export type UnwindPriority = "least_loss" | "nearest_expiry" | "largest_notional"; + +export interface CashBufferConfig { + /** Vault this config belongs to. */ + vaultId: string; + /** + * Target cash buffer as a fraction of TVL (e.g. 0.05 = 5%). + * The vault aims to keep at least this much idle at epoch start. + */ + targetBufferPct: number; + /** + * Hard minimum: if the buffer falls below this fraction an alert fires + * and new deposits are paused until the buffer is replenished. + */ + minBufferPct: number; + /** Primary sort criterion when choosing which positions to unwind first. */ + unwindPriority: UnwindPriority; + /** + * Maximum slippage the unwind engine will accept before escalating to the + * operator rather than executing automatically (0–1, e.g. 0.02 = 2%). + */ + maxSlippagePct: number; + /** Whether the JIT unwind may execute automatically or always requires + * operator sign-off. */ + requireOperatorApproval: boolean; +} + +// --------------------------------------------------------------------------- +// Types — unwind candidates and plan +// --------------------------------------------------------------------------- + +export type PositionType = "covered_call" | "cash_secured_put" | "iron_condor" | "wheel_put" | "wheel_call"; + +export interface UnwindCandidate { + positionId: string; + positionType: PositionType; + /** Underlying asset symbol, e.g. "XLM", "BTC". */ + underlying: string; + /** Strike price in USD. */ + strike: number; + /** ISO date of option expiry. */ + expiryDate: string; + /** Days until expiry. */ + daysToExpiry: number; + /** Notional value of the position in USD. */ + notionalUsd: number; + /** Estimated USD proceeds if unwound at current mid-market. */ + proceedsUsd: number; + /** Estimated realised loss vs. current mark (negative = loss). */ + realisedPnlUsd: number; + /** Estimated bid-ask slippage as a fraction of notional (0–1). */ + estimatedSlippage: number; + /** How easy it is to close this position (1 = easiest). */ + liquidityScore: number; +} + +/** A single unwind step in the ranked plan. */ +export interface UnwindStep { + rank: number; + candidate: UnwindCandidate; + /** Cumulative cash unlocked after executing up to and including this step. */ + cumulativeProceedsUsd: number; + /** Whether executing this step would satisfy the full redemption demand. */ + sufficientAtThisStep: boolean; +} + +/** Full JIT unwind plan for a redemption batch. */ +export interface UnwindPlan { + vaultId: string; + /** Total cash needed to satisfy all queued redemptions. */ + redemptionDemandUsd: number; + /** Cash available in the buffer before any unwinds. */ + bufferAvailableUsd: number; + /** Cash gap that must be covered by unwinds. */ + gapUsd: number; + /** Ranked list of unwind steps needed to close the gap. */ + steps: UnwindStep[]; + /** True when the plan can cover the full demand within slippage limits. */ + feasible: boolean; + /** Total realised loss from executing all steps in the plan. */ + totalRealisedLossUsd: number; + /** Maximum single-step slippage in the plan. */ + maxStepSlippage: number; + /** Whether operator approval is required before execution. */ + requiresApproval: boolean; + /** ISO timestamp when the plan was computed. */ + computedAt: string; +} + +// --------------------------------------------------------------------------- +// Types — liquidity verdict +// --------------------------------------------------------------------------- + +export type LiquidityStatus = + | "healthy" // buffer > target; no unwinds needed + | "buffer_draw" // buffer < target but can cover queue from buffer alone + | "jit_needed" // buffer insufficient; unwind plan must execute + | "insufficient" // even with full unwind the demand cannot be met + | "alert"; // buffer below hard minimum; halt deposits + +export interface LiquidityNeedsResult { + status: LiquidityStatus; + bufferUsd: number; + bufferPct: number; + queuedRedemptionUsd: number; + shortfallUsd: number; + plan: UnwindPlan | null; + /** Human summary for the operator dashboard. */ + message: string; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function isoToMs(iso: string): number { + return new Date(iso).getTime(); +} + +/** + * Score a candidate for sorting: lower score = unwind first. + * Weights: liquidity (highest), slippage, realised loss, expiry proximity. + */ +function candidateScore(c: UnwindCandidate, priority: UnwindPriority): number { + switch (priority) { + case "least_loss": + // Primary: lowest realised loss; secondary: highest liquidity. + return c.realisedPnlUsd * -1 + (1 - c.liquidityScore) * 1000; + case "nearest_expiry": + // Primary: shortest DTE so we close soon-to-expire positions first. + return c.daysToExpiry * 100 + (1 - c.liquidityScore) * 10; + case "largest_notional": + // Primary: largest position first (closes the gap fastest). + return -c.notionalUsd + (1 - c.liquidityScore) * 1000; + default: + return 0; + } +} + +// --------------------------------------------------------------------------- +// Core logic +// --------------------------------------------------------------------------- + +/** + * Build a JIT unwind plan: rank candidates by cost and greedily select until + * the gap is covered or candidates are exhausted. + */ +export function buildUnwindPlan( + vaultId: string, + redemptionDemandUsd: number, + bufferAvailableUsd: number, + candidates: UnwindCandidate[], + config: CashBufferConfig +): UnwindPlan { + const gapUsd = Math.max(0, redemptionDemandUsd - bufferAvailableUsd); + const computedAt = new Date().toISOString(); + + if (gapUsd === 0) { + return { + vaultId, redemptionDemandUsd, bufferAvailableUsd, gapUsd, + steps: [], feasible: true, totalRealisedLossUsd: 0, + maxStepSlippage: 0, requiresApproval: false, computedAt, + }; + } + + // Sort candidates by unwind priority score (ascending = cheapest first). + const ranked = [...candidates].sort( + (a, b) => candidateScore(a, config.unwindPriority) - candidateScore(b, config.unwindPriority) + ); + + const steps: UnwindStep[] = []; + let covered = 0; + let totalLoss = 0; + let maxSlippage = 0; + + for (const candidate of ranked) { + if (covered >= gapUsd) break; + covered += candidate.proceedsUsd; + totalLoss += Math.min(0, candidate.realisedPnlUsd); + maxSlippage = Math.max(maxSlippage, candidate.estimatedSlippage); + + steps.push({ + rank: steps.length + 1, + candidate, + cumulativeProceedsUsd: covered, + sufficientAtThisStep: covered >= gapUsd, + }); + } + + const feasible = covered >= gapUsd; + const requiresApproval = + config.requireOperatorApproval || maxSlippage > config.maxSlippagePct; + + return { + vaultId, redemptionDemandUsd, bufferAvailableUsd, gapUsd, + steps, feasible, + totalRealisedLossUsd: totalLoss, + maxStepSlippage: maxSlippage, + requiresApproval, + computedAt, + }; +} + +/** + * Evaluate whether the vault's current cash buffer and position portfolio + * can service the queued redemption demand. + */ +export function evaluateLiquidityNeeds( + tvlUsd: number, + bufferUsd: number, + queuedRedemptionUsd: number, + candidates: UnwindCandidate[], + config: CashBufferConfig +): LiquidityNeedsResult { + const bufferPct = tvlUsd > 0 ? bufferUsd / tvlUsd : 0; + + // Alert: buffer below hard minimum. + if (bufferPct < config.minBufferPct) { + return { + status: "alert", + bufferUsd, bufferPct, queuedRedemptionUsd, + shortfallUsd: queuedRedemptionUsd, + plan: null, + message: `Cash buffer (${(bufferPct * 100).toFixed(1)}%) is below the hard minimum (${(config.minBufferPct * 100).toFixed(1)}%). New deposits paused.`, + }; + } + + // Healthy: buffer exceeds target and covers the queue. + if (bufferUsd >= queuedRedemptionUsd && bufferPct >= config.targetBufferPct) { + return { + status: "healthy", + bufferUsd, bufferPct, queuedRedemptionUsd, + shortfallUsd: 0, + plan: null, + message: `Buffer healthy at ${(bufferPct * 100).toFixed(1)}% TVL. Redemption queue fully covered by cash.`, + }; + } + + // Buffer draw: below target but still sufficient for the queue. + if (bufferUsd >= queuedRedemptionUsd) { + return { + status: "buffer_draw", + bufferUsd, bufferPct, queuedRedemptionUsd, + shortfallUsd: 0, + plan: null, + message: `Buffer at ${(bufferPct * 100).toFixed(1)}% TVL (below ${(config.targetBufferPct * 100).toFixed(1)}% target). Queue covered; replenish after epoch settlement.`, + }; + } + + // JIT needed: build the unwind plan. + const plan = buildUnwindPlan( + config.vaultId, + queuedRedemptionUsd, + bufferUsd, + candidates, + config + ); + + if (!plan.feasible) { + return { + status: "insufficient", + bufferUsd, bufferPct, queuedRedemptionUsd, + shortfallUsd: queuedRedemptionUsd - bufferUsd - candidates.reduce((s, c) => s + c.proceedsUsd, 0), + plan, + message: `Insufficient liquidity: even a full portfolio unwind cannot satisfy redemption demand of $${queuedRedemptionUsd.toLocaleString()}. Operator intervention required.`, + }; + } + + return { + status: "jit_needed", + bufferUsd, bufferPct, queuedRedemptionUsd, + shortfallUsd: plan.gapUsd, + plan, + message: `JIT unwind required: ${plan.steps.length} position(s) must be closed to cover $${plan.gapUsd.toLocaleString()} shortfall. Est. loss: $${Math.abs(plan.totalRealisedLossUsd).toLocaleString()}.`, + }; +} + +// --------------------------------------------------------------------------- +// Default configs and mock data +// --------------------------------------------------------------------------- + +export const DEFAULT_CASH_BUFFER_CONFIGS: Record = { + "xlm-cc-v1": { + vaultId: "xlm-cc-v1", + targetBufferPct: 0.05, + minBufferPct: 0.02, + unwindPriority: "least_loss", + maxSlippagePct: 0.02, + requireOperatorApproval: false, + }, + "btc-csp-v1": { + vaultId: "btc-csp-v1", + targetBufferPct: 0.07, + minBufferPct: 0.03, + unwindPriority: "nearest_expiry", + maxSlippagePct: 0.015, + requireOperatorApproval: true, + }, + "eth-ic-v1": { + vaultId: "eth-ic-v1", + targetBufferPct: 0.06, + minBufferPct: 0.025, + unwindPriority: "least_loss", + maxSlippagePct: 0.02, + requireOperatorApproval: false, + }, + "sol-wheel-v1": { + vaultId: "sol-wheel-v1", + targetBufferPct: 0.08, + minBufferPct: 0.04, + unwindPriority: "largest_notional", + maxSlippagePct: 0.025, + requireOperatorApproval: true, + }, +}; + +const MOCK_CANDIDATES: Record = { + "xlm-cc-v1": [ + { + positionId: "xlm-cc-pos-001", + positionType: "covered_call", + underlying: "XLM", + strike: 0.13, + expiryDate: "2026-10-03", + daysToExpiry: 10, + notionalUsd: 18_400, + proceedsUsd: 17_200, + realisedPnlUsd: -1_200, + estimatedSlippage: 0.008, + liquidityScore: 0.82, + }, + { + positionId: "xlm-cc-pos-002", + positionType: "covered_call", + underlying: "XLM", + strike: 0.135, + expiryDate: "2026-10-10", + daysToExpiry: 17, + notionalUsd: 22_100, + proceedsUsd: 21_400, + realisedPnlUsd: -700, + estimatedSlippage: 0.009, + liquidityScore: 0.78, + }, + ], + "btc-csp-v1": [ + { + positionId: "btc-csp-pos-001", + positionType: "cash_secured_put", + underlying: "BTC", + strike: 60_000, + expiryDate: "2026-10-03", + daysToExpiry: 10, + notionalUsd: 120_000, + proceedsUsd: 116_000, + realisedPnlUsd: -4_000, + estimatedSlippage: 0.012, + liquidityScore: 0.91, + }, + ], +}; + +function defaultCandidates(): UnwindCandidate[] { + return []; +} + +// --------------------------------------------------------------------------- +// Async adapters (mock / live) +// --------------------------------------------------------------------------- + +export async function fetchCashBufferConfig(vaultId: string): Promise { + if (process.env.NEXT_PUBLIC_VAULTS === "live") { + const res = await fetch(`/api/v1/vaults/${vaultId}/liquidity/config`); + if (!res.ok) throw new Error(`Failed to fetch buffer config (${res.status})`); + return res.json() as Promise; + } + return Promise.resolve( + DEFAULT_CASH_BUFFER_CONFIGS[vaultId] ?? { + vaultId, + targetBufferPct: 0.05, + minBufferPct: 0.02, + unwindPriority: "least_loss" as UnwindPriority, + maxSlippagePct: 0.02, + requireOperatorApproval: false, + } + ); +} + +export async function saveCashBufferConfig(config: CashBufferConfig): Promise { + if (process.env.NEXT_PUBLIC_VAULTS === "live") { + const res = await fetch(`/api/v1/vaults/${config.vaultId}/liquidity/config`, { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify(config), + }); + if (!res.ok) throw new Error(`Failed to save buffer config (${res.status})`); + return; + } + DEFAULT_CASH_BUFFER_CONFIGS[config.vaultId] = config; + await new Promise(r => setTimeout(r, 300)); +} + +export async function fetchUnwindCandidates(vaultId: string): Promise { + if (process.env.NEXT_PUBLIC_VAULTS === "live") { + const res = await fetch(`/api/v1/vaults/${vaultId}/liquidity/candidates`); + if (!res.ok) throw new Error(`Failed to fetch unwind candidates (${res.status})`); + return res.json() as Promise; + } + return Promise.resolve(MOCK_CANDIDATES[vaultId] ?? defaultCandidates()); +} + +export async function fetchLiquidityStatus( + vaultId: string, + tvlUsd: number, + bufferUsd: number, + queuedRedemptionUsd: number +): Promise { + const [config, candidates] = await Promise.all([ + fetchCashBufferConfig(vaultId), + fetchUnwindCandidates(vaultId), + ]); + return evaluateLiquidityNeeds(tvlUsd, bufferUsd, queuedRedemptionUsd, candidates, config); +} + +export async function executeUnwindStep( + vaultId: string, + positionId: string, + _token: string +): Promise<{ tx_id: string }> { + if (process.env.NEXT_PUBLIC_VAULTS === "live") { + const res = await fetch(`/api/v1/vaults/${vaultId}/liquidity/unwind`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ positionId }), + }); + if (!res.ok) throw new Error(`Unwind execution failed (${res.status})`); + return res.json() as Promise<{ tx_id: string }>; + } + await new Promise(r => setTimeout(r, 800)); + return { tx_id: `mock-tx-unwind-${positionId}-${Date.now()}` }; +} diff --git a/src/lib/governance/actions.ts b/src/lib/governance/actions.ts index 8232452..2f8a235 100644 --- a/src/lib/governance/actions.ts +++ b/src/lib/governance/actions.ts @@ -281,6 +281,83 @@ export const GOVERNABLE_CONTRACTS: GovernableContract[] = [ dangerous: true, dangerNote: "Pausing the vault locks deposits and withdrawals until it is unpaused.", }, + // ── Issue #241: cash buffer and JIT unwind governance controls ──────── + { + name: "set_cash_buffer_bps", + label: "Target cash buffer", + summary: "Sets the target idle-cash buffer as a fraction of TVL (basis points).", + params: [ + { + name: "buffer_bps", + label: "Target buffer", + type: "u32", + placeholder: "500", + help: "Basis points of TVL to keep as idle cash. 500 = 5%.", + readMethod: "cash_buffer_bps", + unit: "%", + decimals: 2, + display: "bps", + suggested: "500", + }, + ], + }, + { + name: "set_min_buffer_bps", + label: "Minimum cash buffer", + summary: "Hard floor below which the vault halts new deposits and raises an alert.", + params: [ + { + name: "min_buffer_bps", + label: "Minimum buffer", + type: "u32", + placeholder: "200", + help: "Basis points of TVL. 200 = 2%.", + readMethod: "min_buffer_bps", + unit: "%", + decimals: 2, + display: "bps", + suggested: "200", + }, + ], + dangerous: true, + dangerNote: "Lowering the minimum buffer increases the risk of the vault being unable to service redemptions without a fire-sale unwind.", + }, + { + name: "set_unwind_priority", + label: "JIT unwind priority", + summary: "Sets the criterion used to rank positions for the JIT unwind plan.", + params: [ + { + name: "priority", + label: "Unwind priority", + type: "symbol", + placeholder: "least_loss", + help: "One of: least_loss, nearest_expiry, largest_notional.", + readMethod: "unwind_priority", + display: "symbol", + suggested: "least_loss", + }, + ], + }, + { + name: "set_max_unwind_slippage_bps", + label: "Max unwind slippage", + summary: "Maximum slippage the JIT engine will accept before escalating to the operator.", + params: [ + { + name: "slippage_bps", + label: "Max slippage", + type: "u32", + placeholder: "200", + help: "Basis points. 200 = 2%.", + readMethod: "max_unwind_slippage_bps", + unit: "%", + decimals: 2, + display: "bps", + suggested: "200", + }, + ], + }, // ── Issue #243: audit-logged parameter changes ──────────────────────── { name: "set_epoch_advance", @@ -1008,76 +1085,4 @@ export function describeAction(action: ProposalAction): string { /** Why an action is flagged dangerous, or null when it is safe. */ export function dangerNote(action: ProposalAction): string | null { const contract = findContract(action.contractKey); - const fn = contract?.functions.find((f) => f.name === action.functionName); - if (!fn) return null; - if (fn.dangerous) return fn.dangerNote ?? "This action can affect all protocol funds."; - const dangerousParam = fn.params.find((p) => p.dangerous); - return dangerousParam ? `“${dangerousParam.label}” cannot be changed back safely.` : null; -} - -export function isDangerous(action: ProposalAction): boolean { - return dangerNote(action) !== null; -} - -// --------------------------------------------------------------------------- -// Form generator -// --------------------------------------------------------------------------- - -/** A single rendered form field, produced from an `ActionParamSpec`. */ -export interface FormField { - /** DOM id — stable across renders so labels stay associated. */ - id: string; - name: string; - label: string; - type: ActionParamType; - value: string; - placeholder: string; - help?: string; - multiline: boolean; - /** Render as a select (enumerations) rather than a text input. */ - options?: { value: string; label: string }[]; - /** Controlled-component callback target. */ - inputMode?: "text" | "numeric"; -} - -const BOOL_OPTIONS = [ - { value: "false", label: "false" }, - { value: "true", label: "true" }, -]; - -/** - * Generate the form model for a governable function. This is the - * schema-driven form generator the wizard renders — no per-parameter JSX. - */ -export function buildFormFields( - action: Pick, - idPrefix = "action" -): FormField[] { - const fn = findFunction(action.contractKey, action.functionName); - if (!fn) return []; - - return fn.params.map((param) => { - const field: FormField = { - id: `${idPrefix}-${param.name}`, - name: param.name, - label: param.label, - type: param.type, - value: action.values[param.name] ?? param.suggested ?? defaultForParam(param), - placeholder: param.placeholder ?? "", - help: param.help, - multiline: param.multiline === true || param.type === "vec
", - }; - - if (param.type === "bool") field.options = BOOL_OPTIONS; - if (param.type === "u32" || param.type === "u64" || param.type === "u128" || param.type === "i128") { - field.inputMode = "numeric"; - } - - return field; - }); -} - -/** - * Fallback value when a param has neither a stored value nor a suggestion. - * - * A bool must resolve to "false" rather than "": a