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 => (
+ |
+ {h}
+ |
+ ))}
+
+
+
+ {steps.map(step => {
+ const c = step.candidate;
+ const txId = results[c.positionId];
+ const isExec = executing === c.positionId;
+ return (
+
+ | {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 && (
+
+ )}
+
+ {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