diff --git a/messages/en.json b/messages/en.json index ebc93e40..f08e31cd 100644 --- a/messages/en.json +++ b/messages/en.json @@ -158,6 +158,35 @@ "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." + } + }, "vaultDisclosure": { "title": "Vault Risk Disclosure", "checking": "Checking disclosure status…", diff --git a/messages/es.json b/messages/es.json index 88fabbe6..22cefc37 100644 --- a/messages/es.json +++ b/messages/es.json @@ -157,5 +157,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 44f46e76..186e0788 100644 --- a/messages/pt.json +++ b/messages/pt.json @@ -157,5 +157,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 c830b6be..4dcef452 100644 --- a/src/app/[locale]/vaults/[id]/page.tsx +++ b/src/app/[locale]/vaults/[id]/page.tsx @@ -17,6 +17,7 @@ import { fetchVaultAnalytics, depositToVault, withdrawFromVault, + deriveDepositState, fetchVaultExport, vaultStatusPolicy, acknowledgeDisclosure, @@ -24,6 +25,7 @@ import { hashDisclosureText, VaultPolicyError, VAULT_DISCLOSURE_VERSION, + type DepositState, type VaultAnalytics, type VaultDetail, type EpochPhase, @@ -60,19 +62,25 @@ 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 vaultsEnabled = useFlag("vaults"); const address = useWalletStore(s => s.address); + const t = useTranslations("vaults"); + const tDisclosure = useTranslations("vaultDisclosure"); const { keyFor, complete } = useIdempotencyKey(); - const t = useTranslations("vaultDisclosure"); const [vault, setVault] = useState(null); const [analytics, setAnalytics] = useState(null); const [analyticsError, setAnalyticsError] = 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(""); @@ -88,7 +96,7 @@ export default function VaultDetailPage({ params }: { params: Promise<{ id: stri const [acknowledging, setAcknowledging] = useState(false); const [policyReason, setPolicyReason] = useState(null); - const disclosureParagraphs = [t("p1"), t("p2"), t("p3"), t("p4"), t("p5")]; + const disclosureParagraphs = [tDisclosure("p1"), tDisclosure("p2"), tDisclosure("p3"), tDisclosure("p4"), tDisclosure("p5")]; const disclosureText = disclosureParagraphs.join("\n\n"); const currentAck = acks?.find(a => a.disclosureVersion === VAULT_DISCLOSURE_VERSION && a.jurisdictionAttested) ?? null; @@ -108,6 +116,12 @@ export default function VaultDetailPage({ params }: { params: Promise<{ id: stri .catch(e => { setAnalytics(null); setAnalyticsError(e.message); }); }, [id, token, vaultsEnabled]); + // Re-evaluate limit staleness while the page stays open. + useEffect(() => { + const timer = setInterval(() => setNow(Date.now()), 15_000); + return () => clearInterval(timer); + }, []); + const permittedActions = vault ? vaultStatusPolicy(vault.status).permittedActions : []; // Re-query acknowledgements whenever the connected wallet changes, and clear any @@ -149,10 +163,24 @@ export default function VaultDetailPage({ params }: { params: Promise<{ id: stri } }; + 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 || !address || !currentAck || gateReason || !disclosuresAcknowledged || !permittedActions.includes("deposit")) return; + if (!vault || !token || !address || !currentAck || gateReason || !disclosuresAcknowledged || !permittedActions.includes("deposit") || !depositState?.canSubmit || overMax) return; const amount = parseFloat(depositAmount); if (isNaN(amount) || amount <= 0) return; + const state = depositState; setTxStatus("pending"); setTxMessage(""); try { @@ -164,19 +192,24 @@ export default function VaultDetailPage({ params }: { params: Promise<{ id: stri enabled: vaultsEnabled, }); complete(); + 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(""); // Reconcile the displayed position with backend truth. fetchVaultDetail(id, token).then(v => v && setVault(v)).catch(() => {}); } catch (e) { - setTxStatus(null); if (e instanceof VaultPolicyError) { + setTxStatus(null); setPolicyReason(e.code); refreshAcks(); } else { + // 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")); } } }; @@ -450,6 +483,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 ? (
@@ -486,13 +548,13 @@ export default function VaultDetailPage({ params }: { params: Promise<{ id: stri {activeFlow === "deposit" && vaultsEnabled && permittedActions.includes("deposit") && (
{acks === null && ( -
{t("checking")}
+
{tDisclosure("checking")}
)} {acks !== null && !currentAck && (
-
{t("title")}
+
{tDisclosure("title")}
- {t("versionLabel", { version: VAULT_DISCLOSURE_VERSION })} + {tDisclosure("versionLabel", { version: VAULT_DISCLOSURE_VERSION })}
{disclosureParagraphs.map((p, i) => ( @@ -501,11 +563,11 @@ export default function VaultDetailPage({ params }: { params: Promise<{ id: stri
)} {currentAck && (
- {t("versionLabel", { version: VAULT_DISCLOSURE_VERSION })} · {t("acceptedAt", { date: fmtDate(currentAck.acceptedAt) })} + {tDisclosure("versionLabel", { version: VAULT_DISCLOSURE_VERSION })} · {tDisclosure("acceptedAt", { date: fmtDate(currentAck.acceptedAt) })}
)}
@@ -536,70 +598,4 @@ export default function VaultDetailPage({ params }: { params: Promise<{ id: stri type="number" min={0} value={depositAmount} - onChange={e => setDepositAmount(e.target.value)} - placeholder="0.00" - style={{ - width: "100%", padding: "8px 10px", - background: "var(--bg-overlay)", border: "1px solid var(--border-default)", - color: "var(--text-hi)", fontFamily: "var(--font-mono)", fontSize: 13, - }} - /> - {depositAmount && !isNaN(parseFloat(depositAmount)) && parseFloat(depositAmount) > 0 && ( -
- ≈ {(parseFloat(depositAmount) / vault.sharePrice).toFixed(4)} shares -
- )} -
- {/* Queued withdrawal notice if epoch is active */} - {vault.currentPhase === "active" && ( -
- Deposits during active epochs are queued for the next epoch start. -
- )} - {/* Depositor disclosures: shown and acknowledged before the deposit can be submitted */} -
-
Risk level: {vault.riskRating}
-
Strategy: {vault.description}
-
- Fees:{" "} - {vault.feeStructure.managementPct}% management annually, {vault.feeStructure.performancePct}% performance -
-
- Deposits are queued and redemptions are processed through the withdrawal queue at epoch end; - redemption at the final NAV is not guaranteed to match the current share price. -
- -
- {gateReason && ( -
- {t("depositBlocked")}: {t(`reason.${gateReason}`)} -
- )} - -
- )} - - {activeFlow === "withdraw" && permittedActions.includes("withdraw") && ( -
-
- Withdrawal requests are queued and processed at the end of the current epoch. - Shares are redeemed at + onChange={e => setDepositAmount(e diff --git a/src/app/[locale]/vaults/page.tsx b/src/app/[locale]/vaults/page.tsx index 417f4810..278dff7e 100644 --- a/src/app/[locale]/vaults/page.tsx +++ b/src/app/[locale]/vaults/page.tsx @@ -2,11 +2,13 @@ import { Suspense, useCallback, useEffect, useMemo, useState } from "react"; import Link from "next/link"; +import { useTranslations } from "next-intl"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { AppHeader } from "../../../components/AppHeader"; import { WalletConnect } from "../../../components/WalletConnect"; import { capacityUtilization, + deriveDepositState, effectiveStatus, filterVaults, fetchVaults, @@ -65,6 +67,15 @@ const TH_STYLE = { const TD_STYLE = { padding: "10px", fontSize: 12, color: "var(--text-mid)", borderBottom: "1px solid var(--border-subtle)", whiteSpace: "nowrap" } as const; +function fmtDateTime(iso: string) { + return new Date(iso).toLocaleString("en-US", { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }); +} + +function capacityPct(tvl: number, capacity: number) { + if (capacity <= 0) return 0; + return Math.min(100, (tvl / capacity) * 100); +} + function VaultsIndex() { const router = useRouter(); const pathname = usePathname(); @@ -74,6 +85,8 @@ function VaultsIndex() { const [error, setError] = useState(null); const [attempt, setAttempt] = useState(0); const [apyTooltip, setApyTooltip] = useState(null); + const [now, setNow] = useState(() => Date.now()); + const t = useTranslations("vaults"); const vaultsEnabled = useFlag("vaults"); useEffect(() => { @@ -122,6 +135,12 @@ function VaultsIndex() { const hasFilters = Boolean(query.strategy || query.underlying || query.status || query.risk) || query.tenor !== null; const loading = vaults === null && error === null; + // Re-evaluate limit staleness while the page stays open. + useEffect(() => { + const timer = setInterval(() => setNow(Date.now()), 15_000); + return () => clearInterval(timer); + }, []); + return (
@@ -289,6 +308,8 @@ function VaultsIndex() { const selected = query.ids.includes(vault.id); const disabled = !selected && query.ids.length >= VAULT_COMPARE_MAX; const status = effectiveStatus(vault); + const deposit = deriveDepositState(vault, now); + const statusColor = deposit.status === "open" ? "var(--call)" : deposit.status === "queued" || deposit.status === "waitlist" ? "var(--atm)" : "var(--put)"; return ( @@ -336,6 +357,14 @@ function VaultsIndex() { View → + {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 181c8110..fb0f67b2 100644 --- a/src/lib/api/vaults.ts +++ b/src/lib/api/vaults.ts @@ -112,7 +112,7 @@ import { submitTransactionXdr } from "./stellar"; * POST /api/v1/vaults/privacy/deletion-request { wallet: string; reason?: string } * → DeletionRequestReceipt * - * GET /api/v1/vaults/privacy/deletion-request/{id} + * GET /api/v1/vaults/privacy/deletion-request/{id} * → DeletionRequestReceipt * * Public read-only partner API (issue #233): @@ -416,6 +416,24 @@ export interface VaultRoleGrant { grantedAt: string; } +/** + * 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; @@ -439,6 +457,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; /** Epoch length (tenor) in days. */ epochDays: number; /** Fees as whole percentages, e.g. performancePct: 10 = 10%. */ @@ -750,34 +772,4 @@ export interface ShadowState { * progress and block promotion when unmet. */ export interface PromotionCriteria { - /** Minimum number of complete shadow epochs required. */ - minCompleteEpochs: number; - /** Max allowed |shadow − expected| NAV deviation per share (USD). */ - maxNavDeviation: number; - /** Whether any invariant violations were recorded during the run. */ - invariantViolations: number; -} - -export interface QueueState { - depositPendingUsd: number; - withdrawPendingShares: number; - settlesAt: string; // ISO -} - -export interface FeeAccrual { - managementAccruedUsd: number; - performanceAccruedUsd: number; - accruedThrough: string; // ISO -} - -export interface NavPoint { - at: string; // ISO - sharePrice: number; - totalShares: number; -} - -/** Full detail for a single vault. */ -export interface VaultDetail extends VaultSummary { - currentEpoch: number; - currentPhase: EpochPhase; - phaseEndsAt: string; // ISO — when the current phase + /**