From dd1512b768d30719b25d62fe3ad9fca9eeff1d5a Mon Sep 17 00:00:00 2001 From: only1dreamgene Date: Mon, 19 Oct 2026 15:37:03 -0700 Subject: [PATCH] feat(vaults): derive deposit limit state and surface capacity, epoch, and waitlist states - Add VaultLimits (paused, waitlist, per-epoch cap, next window, asOf) to VaultSummary and move epoch fields onto the summary so the listing can derive state too. - Add deriveDepositState selector shared by listing and detail; limits older than 60s degrade to a read-only stale state. - Capacity panel, status notices, and epoch/share-timing copy on the detail page; listing shows headroom and the same status. - Post-submission message states the pricing epoch and when shares appear; a capacity rejection shows actionable copy instead of the raw error. - Add vaults.* copy to en, es, and pt catalogues. Co-Authored-By: Claude Haiku 5.5 --- messages/en.json | 29 +++++++ messages/es.json | 29 +++++++ messages/pt.json | 29 +++++++ src/app/[locale]/vaults/[id]/page.tsx | 105 +++++++++++++++++++++++--- src/app/[locale]/vaults/page.tsx | 29 ++++++- src/lib/api/vaults.ts | 92 ++++++++++++++++++++-- 6 files changed, 294 insertions(+), 19 deletions(-) diff --git a/messages/en.json b/messages/en.json index 1b8695fe..93770fed 100644 --- a/messages/en.json +++ b/messages/en.json @@ -140,5 +140,34 @@ "notFound": { "title": "Page not found", "back": "Back to the terminal" + }, + "vaults": { + "capacity": "Capacity", + "remaining": "{amount} remaining", + "utilised": "{percent}% utilised", + "limitsAsOf": "Limits as of {time}", + "status": { + "open": "Deposits are open for epoch #{epoch}.", + "queued": "Deposits are closed this epoch. A deposit now is queued for epoch #{epoch}.", + "waitlist": "Vault is full. Join the waitlist; entry is not guaranteed.", + "closed": "Deposits are closed this epoch. The next deposit window opens {date}.", + "full": "Vault is full. No capacity remains.", + "paused": "Deposits are paused by the operator.", + "stale": "Limits are out of date. Deposits are read-only until they refresh." + }, + "form": { + "submit": "Deposit", + "submitQueued": "Queue deposit for epoch #{epoch}", + "submitWaitlist": "Join waitlist", + "maxHint": "Up to {amount} per deposit", + "overMax": "Amount is above the {amount} limit for this vault.", + "pricedIn": "Priced in epoch #{epoch}.", + "pricedWaitlist": "Epoch is assigned when you are admitted from the waitlist.", + "sharesOpen": "Shares appear when this deposit window closes, {date}.", + "sharesQueued": "Shares appear when the next deposit window closes.", + "sharesWaitlist": "Shares appear once you are admitted and that window closes.", + "capacityChanged": "The vault filled while your deposit was in flight, so it was not accepted. Lower the amount or join the waitlist.", + "submitFailed": "Deposit was not submitted. Try again." + } } } diff --git a/messages/es.json b/messages/es.json index 3f58c329..2940ae34 100644 --- a/messages/es.json +++ b/messages/es.json @@ -140,5 +140,34 @@ "notFound": { "title": "Página no encontrada", "back": "Volver a la terminal" + }, + "vaults": { + "capacity": "Capacidad", + "remaining": "{amount} disponibles", + "utilised": "{percent}% utilizado", + "limitsAsOf": "Límites al {time}", + "status": { + "open": "Los depósitos están abiertos para la época #{epoch}.", + "queued": "Los depósitos están cerrados en esta época. Un depósito ahora se encola para la época #{epoch}.", + "waitlist": "La bóveda está llena. Únete a la lista de espera; la entrada no está garantizada.", + "closed": "Los depósitos están cerrados en esta época. La siguiente ventana de depósito abre el {date}.", + "full": "La bóveda está llena. No queda capacidad.", + "paused": "El operador ha pausado los depósitos.", + "stale": "Los límites están desactualizados. Los depósitos son solo de lectura hasta que se actualicen." + }, + "form": { + "submit": "Depositar", + "submitQueued": "Encolar depósito para la época #{epoch}", + "submitWaitlist": "Unirse a la lista de espera", + "maxHint": "Hasta {amount} por depósito", + "overMax": "El importe supera el límite de {amount} de esta bóveda.", + "pricedIn": "Valorado en la época #{epoch}.", + "pricedWaitlist": "La época se asigna cuando seas admitido desde la lista de espera.", + "sharesOpen": "Las participaciones aparecen cuando cierre esta ventana de depósito, {date}.", + "sharesQueued": "Las participaciones aparecen cuando cierre la siguiente ventana de depósito.", + "sharesWaitlist": "Las participaciones aparecen una vez admitido y cerrada esa ventana.", + "capacityChanged": "La bóveda se llenó mientras tu depósito estaba en curso, así que no fue aceptado. Reduce el importe o únete a la lista de espera.", + "submitFailed": "El depósito no se envió. Inténtalo de nuevo." + } } } diff --git a/messages/pt.json b/messages/pt.json index 25ec32a5..d8a2bf1e 100644 --- a/messages/pt.json +++ b/messages/pt.json @@ -140,5 +140,34 @@ "notFound": { "title": "Página não encontrada", "back": "Voltar ao terminal" + }, + "vaults": { + "capacity": "Capacidade", + "remaining": "{amount} restantes", + "utilised": "{percent}% utilizado", + "limitsAsOf": "Limites em {time}", + "status": { + "open": "Os depósitos estão abertos para a época #{epoch}.", + "queued": "Os depósitos estão fechados nesta época. Um depósito agora entra na fila para a época #{epoch}.", + "waitlist": "O cofre está cheio. Entre na lista de espera; a entrada não é garantida.", + "closed": "Os depósitos estão fechados nesta época. A próxima janela de depósito abre em {date}.", + "full": "O cofre está cheio. Não há capacidade restante.", + "paused": "Os depósitos estão pausados pelo operador.", + "stale": "Os limites estão desatualizados. Os depósitos são somente leitura até que sejam atualizados." + }, + "form": { + "submit": "Depositar", + "submitQueued": "Enfileirar depósito para a época #{epoch}", + "submitWaitlist": "Entrar na lista de espera", + "maxHint": "Até {amount} por depósito", + "overMax": "O valor está acima do limite de {amount} deste cofre.", + "pricedIn": "Precificado na época #{epoch}.", + "pricedWaitlist": "A época é atribuída quando você for admitido pela lista de espera.", + "sharesOpen": "As cotas aparecem quando esta janela de depósito fechar, {date}.", + "sharesQueued": "As cotas aparecem quando a próxima janela de depósito fechar.", + "sharesWaitlist": "As cotas aparecem assim que você for admitido e essa janela fechar.", + "capacityChanged": "O cofre encheu enquanto seu depósito estava em andamento, então ele não foi aceito. Reduza o valor ou entre na lista de espera.", + "submitFailed": "O depósito não foi enviado. Tente novamente." + } } } diff --git a/src/app/[locale]/vaults/[id]/page.tsx b/src/app/[locale]/vaults/[id]/page.tsx index be22a66f..196fc72a 100644 --- a/src/app/[locale]/vaults/[id]/page.tsx +++ b/src/app/[locale]/vaults/[id]/page.tsx @@ -5,11 +5,14 @@ import Link from "next/link"; import { use } from "react"; import { AppHeader } from "../../../../components/AppHeader"; import { WalletConnect } from "../../../../components/WalletConnect"; +import { useTranslations } from "next-intl"; import { useWalletStore } from "../../../../lib/store/wallet"; import { fetchVaultDetail, depositToVault, withdrawFromVault, + deriveDepositState, + type DepositState, type VaultDetail, type EpochPhase, } from "../../../../lib/api/vaults"; @@ -40,13 +43,19 @@ function fmtDateShort(iso: string) { return new Date(iso).toLocaleDateString("en-US", { month: "short", day: "numeric" }); } +function fmtDateTime(iso: string) { + return new Date(iso).toLocaleString("en-US", { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }); +} + export default function VaultDetailPage({ params }: { params: Promise<{ id: string }> }) { const { id } = use(params); const token = useWalletStore(s => s.session); + const t = useTranslations("vaults"); const [vault, setVault] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const [now, setNow] = useState(() => Date.now()); const [depositAmount, setDepositAmount] = useState(""); const [withdrawShares, setWithdrawShares] = useState(""); @@ -60,20 +69,45 @@ export default function VaultDetailPage({ params }: { params: Promise<{ id: stri .catch(e => { setError(e.message); setLoading(false); }); }, [id, token]); + // Re-evaluate limit staleness while the page stays open. + useEffect(() => { + const timer = setInterval(() => setNow(Date.now()), 15_000); + return () => clearInterval(timer); + }, []); + + const depositState = vault ? deriveDepositState(vault, now) : null; + const depositAmountNum = parseFloat(depositAmount); + const overMax = !!depositState && depositState.status !== "waitlist" && depositAmountNum > depositState.maxDeposit; + const statusColor = depositState?.status === "open" + ? "var(--call)" + : depositState?.status === "queued" || depositState?.status === "waitlist" + ? "var(--atm)" + : "var(--put)"; + const sharesLine = (s: DepositState, phaseEndsAt: string) => + s.status === "open" + ? t("form.sharesOpen", { date: fmtDateTime(phaseEndsAt) }) + : s.status === "queued" ? t("form.sharesQueued") : t("form.sharesWaitlist"); + const handleDeposit = async () => { - if (!vault || !token) return; + if (!vault || !token || !depositState?.canSubmit || overMax) return; const amount = parseFloat(depositAmount); if (isNaN(amount) || amount <= 0) return; + const state = depositState; setTxStatus("pending"); setTxMessage(""); try { const result = await depositToVault(vault.id, amount, token); + const pricing = state.pricedInEpoch !== null + ? t("form.pricedIn", { epoch: state.pricedInEpoch }) + : t("form.pricedWaitlist"); setTxStatus("success"); - setTxMessage(`Deposit confirmed. TX: ${result.tx_id}`); + setTxMessage(`${pricing} ${sharesLine(state, vault.phaseEndsAt)} TX: ${result.tx_id}`); setDepositAmount(""); } catch (e) { + // Backend rejections while the vault filled mid-flight get actionable copy, never the raw error. + const message = e instanceof Error ? e.message : ""; setTxStatus("error"); - setTxMessage(e instanceof Error ? e.message : "Deposit failed"); + setTxMessage(/capacity|full/i.test(message) ? t("form.capacityChanged") : t("form.submitFailed")); } }; @@ -305,6 +339,35 @@ export default function VaultDetailPage({ params }: { params: Promise<{ id: stri + {/* Capacity and deposit limits, derived by the same selector as the listing */} + {depositState && ( +
+
+ {t("capacity")} + {fmtUsd(vault.tvl)} / {fmtUsd(vault.capacity)} +
+
+
+
+
+ {t("remaining", { amount: fmtUsd(depositState.remainingCapacity) })} + {t("utilised", { percent: Math.round(depositState.utilisation) })} +
+
+ {t(`status.${depositState.status}`, { + epoch: depositState.pricedInEpoch ?? vault.currentEpoch + 1, + date: fmtDateTime(vault.limits.nextDepositStart), + })} +
+
+ {t("limitsAsOf", { time: fmtDateTime(depositState.limitsAsOf) })} +
+
+ )} + {/* Deposit / Withdraw flows */} {!token ? (
@@ -356,23 +419,43 @@ export default function VaultDetailPage({ params }: { params: Promise<{ id: stri
)}
- {/* Queued withdrawal notice if epoch is active */} - {vault.currentPhase === "active" && ( -
- Deposits during active epochs are queued for the next epoch start. + {depositState?.canSubmit && ( +
+
+ {depositState.pricedInEpoch !== null + ? t("form.pricedIn", { epoch: depositState.pricedInEpoch }) + : t("form.pricedWaitlist")}{" "} + {sharesLine(depositState, vault.phaseEndsAt)} +
+ {depositState.status !== "waitlist" && ( +
{t("form.maxHint", { amount: fmtUsd(depositState.maxDeposit) })}
+ )} +
+ )} + {overMax && depositState && ( +
+ {t("form.overMax", { amount: fmtUsd(depositState.maxDeposit) })}
)}
)} diff --git a/src/app/[locale]/vaults/page.tsx b/src/app/[locale]/vaults/page.tsx index efe5f483..c376737a 100644 --- a/src/app/[locale]/vaults/page.tsx +++ b/src/app/[locale]/vaults/page.tsx @@ -2,9 +2,10 @@ import { useEffect, useState } from "react"; import Link from "next/link"; +import { useTranslations } from "next-intl"; import { AppHeader } from "../../../components/AppHeader"; import { WalletConnect } from "../../../components/WalletConnect"; -import { fetchVaults, type VaultSummary, type VaultStrategy, type RiskRating } from "../../../lib/api/vaults"; +import { deriveDepositState, fetchVaults, type VaultSummary, type VaultStrategy, type RiskRating } from "../../../lib/api/vaults"; const STRATEGY_LABELS: Record = { covered_call: "Covered Call", @@ -39,11 +40,17 @@ function fmtUsd(n: number) { return `$${n.toFixed(2)}`; } +function fmtDateTime(iso: string) { + return new Date(iso).toLocaleString("en-US", { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }); +} + export default function VaultsPage() { const [vaults, setVaults] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [apyTooltip, setApyTooltip] = useState(null); + const [now, setNow] = useState(() => Date.now()); + const t = useTranslations("vaults"); useEffect(() => { fetchVaults() @@ -51,6 +58,12 @@ export default function VaultsPage() { .catch(e => { setError(e.message); setLoading(false); }); }, []); + // Re-evaluate limit staleness while the page stays open. + useEffect(() => { + const timer = setInterval(() => setNow(Date.now()), 15_000); + return () => clearInterval(timer); + }, []); + return (
@@ -125,6 +138,8 @@ export default function VaultsPage() { {vaults.map(vault => { const cap = capacityPct(vault.tvl, vault.capacity); const capColor = cap >= 90 ? "var(--put)" : cap >= 70 ? "var(--atm)" : "var(--call)"; + const deposit = deriveDepositState(vault, now); + const statusColor = deposit.status === "open" ? "var(--call)" : deposit.status === "queued" || deposit.status === "waitlist" ? "var(--atm)" : "var(--put)"; return (
+
+ {t("remaining", { amount: fmtUsd(deposit.remainingCapacity) })} +
{/* Current epoch strike */}
@@ -224,8 +242,13 @@ export default function VaultsPage() { }}> View Vault → - {cap >= 100 && ( -
Vault full
+ {deposit.status !== "open" && ( +
+ {t(`status.${deposit.status}`, { + epoch: deposit.pricedInEpoch ?? vault.currentEpoch + 1, + date: fmtDateTime(vault.limits.nextDepositStart), + })} +
)}
diff --git a/src/lib/api/vaults.ts b/src/lib/api/vaults.ts index d22c3211..e275afad 100644 --- a/src/lib/api/vaults.ts +++ b/src/lib/api/vaults.ts @@ -22,6 +22,24 @@ export type VaultStrategy = "covered_call" | "cash_secured_put" | "iron_condor" export type RiskRating = "low" | "medium" | "high"; export type EpochPhase = "deposit" | "active" | "settlement"; +/** + * Backend-supplied deposit limits. Every value here is authoritative only as of + * `asOf`; past LIMITS_STALE_AFTER_MS the UI derives a read-only "stale" state + * instead of trusting the numbers. + */ +export interface VaultLimits { + /** Deposits are paused by the operator. */ + paused: boolean; + /** Deposits that cannot be accepted now join the waitlist for the next epoch. */ + waitlistEnabled: boolean; + /** Maximum USD a single depositor may add in one epoch. */ + perEpochDepositLimit: number; + /** ISO start of the next deposit window. */ + nextDepositStart: string; + /** ISO time the backend took this snapshot. */ + asOf: string; +} + /** Summary shown on the /vaults listing page. */ export interface VaultSummary { id: string; @@ -40,6 +58,10 @@ export interface VaultSummary { currentExpiry: string; // ISO date riskRating: RiskRating; description: string; + currentEpoch: number; + currentPhase: EpochPhase; + phaseEndsAt: string; // ISO — when the current phase ends + limits: VaultLimits; } /** One historical epoch record. */ @@ -66,9 +88,6 @@ export interface UserVaultPosition { /** Full detail for a single vault. */ export interface VaultDetail extends VaultSummary { - currentEpoch: number; - currentPhase: EpochPhase; - phaseEndsAt: string; // ISO — when the current phase ends sharePrice: number; // current NAV per share in USD totalShares: number; feeStructure: { managementPct: number; performancePct: number }; @@ -77,6 +96,59 @@ export interface VaultDetail extends VaultSummary { userPosition: UserVaultPosition | null; } +// --------------------------------------------------------------------------- +// Deposit-limit selector +// --------------------------------------------------------------------------- + +/** Limits older than this degrade the vault to read-only. */ +export const LIMITS_STALE_AFTER_MS = 60_000; + +export type DepositStatus = "open" | "queued" | "waitlist" | "closed" | "full" | "paused" | "stale"; + +export interface DepositState { + status: DepositStatus; + /** Whether the deposit form may be submitted in this state. */ + canSubmit: boolean; + /** Epoch the deposit will be priced in; null when it is not yet known. */ + pricedInEpoch: number | null; + /** Remaining vault capacity in USD. */ + remainingCapacity: number; + /** TVL as a percentage of capacity (0–100). */ + utilisation: number; + /** Maximum USD accepted in one deposit (capacity or per-epoch limit, whichever is lower). */ + maxDeposit: number; + limitsAsOf: string; +} + +/** + * Single source of truth for deposit eligibility. Both the listing and the + * detail view call this so they cannot disagree. + */ +export function deriveDepositState( + vault: Pick, + now: number = Date.now() +): DepositState { + const { limits } = vault; + const remainingCapacity = Math.max(0, vault.capacity - vault.tvl); + const base = { + remainingCapacity, + utilisation: vault.capacity > 0 ? Math.min(100, (vault.tvl / vault.capacity) * 100) : 100, + maxDeposit: Math.min(remainingCapacity, limits.perEpochDepositLimit), + limitsAsOf: limits.asOf, + }; + const reject = (status: DepositStatus): DepositState => ({ ...base, status, canSubmit: false, pricedInEpoch: null }); + + if (limits.paused) return reject("paused"); + if (now - Date.parse(limits.asOf) > LIMITS_STALE_AFTER_MS) return reject("stale"); + if (remainingCapacity <= 0) return limits.waitlistEnabled + ? { ...base, status: "waitlist", canSubmit: true, pricedInEpoch: null } + : reject("full"); + if (vault.currentPhase !== "deposit") return limits.waitlistEnabled + ? { ...base, status: "queued", canSubmit: true, pricedInEpoch: vault.currentEpoch + 1 } + : reject("closed"); + return { ...base, status: "open", canSubmit: true, pricedInEpoch: vault.currentEpoch }; +} + // --------------------------------------------------------------------------- // Mock data // --------------------------------------------------------------------------- @@ -98,6 +170,7 @@ const MOCK_VAULTS: VaultDetail[] = [ currentEpoch: 47, currentPhase: "active", phaseEndsAt: "2026-10-03T16:00:00Z", + limits: { paused: false, waitlistEnabled: false, perEpochDepositLimit: 25000, nextDepositStart: "2026-10-10T12:00:00Z", asOf: "" }, sharePrice: 1.042, totalShares: 272552, feeStructure: { managementPct: 0, performancePct: 10 }, @@ -127,6 +200,7 @@ const MOCK_VAULTS: VaultDetail[] = [ currentEpoch: 18, currentPhase: "active", phaseEndsAt: "2026-10-17T20:00:00Z", + limits: { paused: false, waitlistEnabled: true, perEpochDepositLimit: 100000, nextDepositStart: "2026-11-14T12:00:00Z", asOf: "" }, sharePrice: 1.088, totalShares: 1700368, feeStructure: { managementPct: 0.5, performancePct: 15 }, @@ -154,6 +228,7 @@ const MOCK_VAULTS: VaultDetail[] = [ currentEpoch: 11, currentPhase: "deposit", phaseEndsAt: "2026-09-28T12:00:00Z", + limits: { paused: false, waitlistEnabled: false, perEpochDepositLimit: 50000, nextDepositStart: "2026-10-26T12:00:00Z", asOf: "" }, sharePrice: 1.021, totalShares: 607247, feeStructure: { managementPct: 0, performancePct: 10 }, @@ -180,6 +255,7 @@ const MOCK_VAULTS: VaultDetail[] = [ currentEpoch: 28, currentPhase: "settlement", phaseEndsAt: "2026-09-28T16:00:00Z", + limits: { paused: false, waitlistEnabled: false, perEpochDepositLimit: 10000, nextDepositStart: "2026-10-05T16:00:00Z", asOf: "" }, sharePrice: 1.194, totalShares: 82076, feeStructure: { managementPct: 0, performancePct: 20 }, @@ -196,12 +272,17 @@ const MOCK_VAULTS: VaultDetail[] = [ // Public adapter // --------------------------------------------------------------------------- +/** Mock limits are stamped at read time so the mock never reads as stale. */ +function withFreshLimits(v: T): T { + return { ...v, limits: { ...v.limits, asOf: new Date().toISOString() } }; +} + export async function fetchVaults(): Promise { if (process.env.NEXT_PUBLIC_VAULTS === "live") { // TODO: return apiGet("/api/v1/vaults"); throw new Error("Live vaults API not yet implemented"); } - return Promise.resolve(MOCK_VAULTS.map(({ historicalEpochs, userPosition, ...summary }) => summary)); + return Promise.resolve(MOCK_VAULTS.map(({ historicalEpochs, userPosition, ...summary }) => withFreshLimits(summary))); } export async function fetchVaultDetail(id: string, _token?: string | null): Promise { @@ -209,7 +290,8 @@ export async function fetchVaultDetail(id: string, _token?: string | null): Prom // TODO: return apiGet(`/api/v1/vaults/${id}`, token); throw new Error("Live vaults API not yet implemented"); } - return Promise.resolve(MOCK_VAULTS.find(v => v.id === id) ?? null); + const vault = MOCK_VAULTS.find(v => v.id === id); + return Promise.resolve(vault ? withFreshLimits(vault) : null); } export async function depositToVault(