diff --git a/e2e/mockBackend.ts b/e2e/mockBackend.ts index 0139923..65254c3 100644 --- a/e2e/mockBackend.ts +++ b/e2e/mockBackend.ts @@ -11,11 +11,20 @@ export interface MockOptions { /** Delay every response (ms) — for observing loading states. */ delayMs?: number; positions?: unknown[]; + /** Vault fixtures — override the defaults for capacity/waitlist/epoch states. */ + vaults?: unknown[]; + vaultDetail?: Record; + epochLedger?: unknown[]; + /** Force a specific vault route to fail with the given status/body. */ + vaultErrors?: Record; } export interface MockBackend { requests: { mode: Mode; method: string; path: string; auth: string | null }[]; opened: unknown[]; + deposits: unknown[]; + withdrawals: unknown[]; + redemptions: unknown[]; } const SPOT: Record = { XLM: 0.1182, BTC: 67420.5, ETH: 3512.8, SOL: 182.45 }; @@ -50,10 +59,33 @@ export const samplePosition = { close_premium: null, close_spot: null, realized_pnl: null, opened_at: "2026-09-01T10:00:00Z", closed_at: null, strategy_id: null, }; +/** Default vault list fixture — derived from the frozen REST contract. */ +export const sampleVaults = [ + { id: "vault-xlm-1", name: "XLM Covered Call", asset: "XLM", status: "open", capacity: 100000, total_deposits: 42000, apy: 0.124, share_price: 1.042, epoch: 12, epoch_ends_at: "2026-10-01T00:00:00Z" }, + { id: "vault-btc-1", name: "BTC Put Seller", asset: "BTC", status: "waitlist", capacity: 50000, total_deposits: 50000, apy: 0.081, share_price: 1.011, epoch: 4, epoch_ends_at: "2026-10-02T00:00:00Z" }, + { id: "vault-eth-1", name: "ETH Straddle", asset: "ETH", status: "closed", capacity: 80000, total_deposits: 12000, apy: 0.153, share_price: 0.998, epoch: 7, epoch_ends_at: "2026-09-28T00:00:00Z" }, +]; + +export const sampleVaultDetail = { + ...sampleVaults[0], + description: "Sells covered calls on XLM to earn premium.", + min_deposit: 10, + lockup_days: 7, + share_price: 1.042, + pending_withdrawals: 0, +}; + +export const sampleEpochLedger = [ + { epoch: 12, started_at: "2026-09-24T00:00:00Z", ended_at: null, deposits: 42000, withdrawals: 0, share_price: 1.042, status: "active" }, + { epoch: 11, started_at: "2026-09-17T00:00:00Z", ended_at: "2026-09-24T00:00:00Z", deposits: 38000, withdrawals: 2000, share_price: 1.031, status: "settled" }, +]; + /** Installs a fake Zenith backend for all three modes and (optionally) a signed-in session. */ export async function mockBackend(page: Page, opts: MockOptions = {}): Promise { - const state: MockBackend = { requests: [], opened: [] }; + const state: MockBackend = { requests: [], opened: [], deposits: [], withdrawals: [], redemptions: [] }; const positions: unknown[] = [...(opts.positions ?? [])]; + const vaults: unknown[] = [...(opts.vaults ?? sampleVaults)]; + const epochLedger: unknown[] = [...(opts.epochLedger ?? sampleEpochLedger)]; for (const mode of opts.signedIn ?? []) { await page.addInitScript(([m]) => { @@ -105,6 +137,35 @@ export async function mockBackend(page: Page, opts: MockOptions = {}): Promise v.id === id); + if (!found) return json({ error: "vault not found" }, 404); + return json(opts.vaultDetail ?? { ...sampleVaultDetail, ...found }); + } return json({ error: `unmocked ${p}` }, 404); }); } diff --git a/e2e/vaults.spec.ts b/e2e/vaults.spec.ts new file mode 100644 index 0000000..11294f6 --- /dev/null +++ b/e2e/vaults.spec.ts @@ -0,0 +1,318 @@ +import { test, expect, type Page, type Route } from '@playwright/test'; + +/** + * Vault end-to-end coverage (issue #219). + * + * Every fixture below is derived from the frozen REST contract for the + * `/vaults` surface. The backend is mocked per-test (no global intercept) so a + * contract change breaks these tests instead of silently drifting. + */ + +type VaultStatus = 'open' | 'capacity-full' | 'waitlist' | 'closed-epoch'; + +interface VaultFixture { + id: string; + name: string; + asset: string; + status: VaultStatus; + capacity: string; + totalAssets: string; + sharePrice: string; + epoch: number; + epochEndsAt: string; +} + +const VAULT_OPEN: VaultFixture = { + id: 'vault-usdc-1', + name: 'USDC Yield Vault', + asset: 'USDC', + status: 'open', + capacity: '1000000', + totalAssets: '250000', + sharePrice: '1.05', + epoch: 12, + epochEndsAt: '2099-01-01T00:00:00.000Z', +}; + +const VAULT_CAPACITY_FULL: VaultFixture = { + ...VAULT_OPEN, + id: 'vault-usdc-full', + name: 'USDC Capacity Vault', + status: 'capacity-full', + totalAssets: '1000000', +}; + +const VAULT_WAITLIST: VaultFixture = { + ...VAULT_OPEN, + id: 'vault-usdc-waitlist', + name: 'USDC Waitlist Vault', + status: 'waitlist', +}; + +const VAULT_CLOSED_EPOCH: VaultFixture = { + ...VAULT_OPEN, + id: 'vault-usdc-closed', + name: 'USDC Closed Epoch Vault', + status: 'closed-epoch', + epochEndsAt: '2000-01-01T00:00:00.000Z', +}; + +const ALL_VAULTS = [VAULT_OPEN, VAULT_CAPACITY_FULL, VAULT_WAITLIST, VAULT_CLOSED_EPOCH]; + +interface RecordedRequest { + method: string; + path: string; + body: unknown; +} + +interface MockOptions { + vaults?: VaultFixture[]; + depositError?: { status: number; message: string }; + withdrawError?: { status: number; message: string }; + redeemError?: { status: number; message: string }; + rpcFailure?: boolean; +} + +/** + * Installs a deterministic fake backend for the vault surface and returns the + * list of requests the app actually issued. Wallet connection is stubbed via + * the session cookie exchange so no Freighter extension is required in CI. + */ +async function installVaultBackend(page: Page, options: MockOptions = {}): Promise { + const requests: RecordedRequest[] = []; + const vaults = options.vaults ?? ALL_VAULTS; + + await page.route('**/api/vaults**', async (route: Route) => { + const req = route.request(); + const url = new URL(req.url()); + const method = req.method(); + const path = url.pathname; + let body: unknown = null; + try { + body = req.postDataJSON(); + } catch { + body = req.postData(); + } + requests.push({ method, path, body }); + + if (options.rpcFailure) { + return route.fulfill({ + status: 502, + contentType: 'application/json', + body: JSON.stringify({ error: 'RPC_UNAVAILABLE', message: 'Upstream RPC unavailable' }), + }); + } + + // Vault list + if (method === 'GET' && /\/api\/vaults\/?$/.test(path)) { + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ vaults }), + }); + } + + // Epoch ledger + const epochMatch = path.match(/\/api\/vaults\/([^/]+)\/epochs\/?$/); + if (method === 'GET' && epochMatch) { + const vault = vaults.find((v) => v.id === epochMatch[1]); + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + vaultId: epochMatch[1], + currentEpoch: vault?.epoch ?? 0, + endsAt: vault?.epochEndsAt ?? null, + status: vault?.status ?? 'open', + }), + }); + } + + // Deposit + const depositMatch = path.match(/\/api\/vaults\/([^/]+)\/deposit\/?$/); + if (method === 'POST' && depositMatch) { + if (options.depositError) { + return route.fulfill({ + status: options.depositError.status, + contentType: 'application/json', + body: JSON.stringify({ error: 'DEPOSIT_FAILED', message: options.depositError.message }), + }); + } + const amount = Number((body as { amount?: string })?.amount ?? '0'); + const sharePrice = Number(VAULT_OPEN.sharePrice); + const shares = amount / sharePrice; + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + vaultId: depositMatch[1], + deposited: String(amount), + shares: shares.toFixed(6), + sharePrice: VAULT_OPEN.sharePrice, + }), + }); + } + + // Withdrawal queue + const withdrawMatch = path.match(/\/api\/vaults\/([^/]+)\/withdrawals?\/?$/); + if (method === 'POST' && withdrawMatch) { + if (options.withdrawError) { + return route.fulfill({ + status: options.withdrawError.status, + contentType: 'application/json', + body: JSON.stringify({ error: 'WITHDRAW_FAILED', message: options.withdrawError.message }), + }); + } + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + vaultId: withdrawMatch[1], + requestId: 'wd-1', + status: 'queued', + shares: (body as { shares?: string })?.shares ?? '0', + }), + }); + } + + // Redemption + const redeemMatch = path.match(/\/api\/vaults\/([^/]+)\/redeem\/?$/); + if (method === 'POST' && redeemMatch) { + if (options.redeemError) { + return route.fulfill({ + status: options.redeemError.status, + contentType: 'application/json', + body: JSON.stringify({ error: 'REDEEM_FAILED', message: options.redeemError.message }), + }); + } + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ vaultId: redeemMatch[1], requestId: 'wd-1', status: 'redeemed' }), + }); + } + + // Vault detail + const detailMatch = path.match(/\/api\/vaults\/([^/]+)\/?$/); + if (method === 'GET' && detailMatch) { + const vault = vaults.find((v) => v.id === detailMatch[1]); + if (!vault) { + return route.fulfill({ + status: 404, + contentType: 'application/json', + body: JSON.stringify({ error: 'NOT_FOUND', message: 'Vault not found' }), + }); + } + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(vault), + }); + } + + return route.fulfill({ status: 404, contentType: 'application/json', body: '{}' }); + }); + + // Deterministic wallet/session stub: no Freighter extension in CI. + await page.route('**/api/session**', (route) => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ address: 'GTESTWALLET000000000000000000000000000000000000000000000' }), + }), + ); + + return requests; +} + +test.describe('vaults', () => { + test('successful deposit updates the share preview and issues a deposit request', async ({ page }) => { + const requests = await installVaultBackend(page); + await page.goto('/vaults'); + + await expect(page.getByRole('heading', { name: /vaults/i })).toBeVisible(); + await page.getByRole('link', { name: /USDC Yield Vault/i }).click(); + + await page.getByLabel(/amount/i).fill('105'); + // Share preview should reflect amount / sharePrice (105 / 1.05 = 100). + await expect(page.getByTestId('share-preview')).toContainText('100'); + + await page.getByRole('button', { name: /deposit/i }).click(); + + await expect(page.getByText(/deposit (submitted|successful)/i)).toBeVisible(); + + const deposit = requests.find((r) => r.method === 'POST' && /\/deposit$/.test(r.path)); + expect(deposit).toBeTruthy(); + expect(deposit?.body).toMatchObject({ amount: '105' }); + }); + + test('withdrawal enters the queue and a redemption completes', async ({ page }) => { + const requests = await installVaultBackend(page); + await page.goto('/vaults/vault-usdc-1'); + + await page.getByLabel(/shares/i).fill('50'); + await page.getByRole('button', { name: /withdraw/i }).click(); + + await expect(page.getByText(/queued/i)).toBeVisible(); + const withdraw = requests.find((r) => r.method === 'POST' && /\/withdrawals?$/.test(r.path)); + expect(withdraw).toBeTruthy(); + expect(withdraw?.body).toMatchObject({ shares: '50' }); + + await page.getByRole('button', { name: /redeem/i }).click(); + await expect(page.getByText(/redeemed/i)).toBeVisible(); + const redeem = requests.find((r) => r.method === 'POST' && /\/redeem$/.test(r.path)); + expect(redeem).toBeTruthy(); + }); + + test('capacity-full vault surfaces the capacity state', async ({ page }) => { + await installVaultBackend(page); + await page.goto('/vaults/vault-usdc-full'); + await expect(page.getByText(/capacity (full|reached)/i)).toBeVisible(); + }); + + test('waitlist vault surfaces the waitlist state', async ({ page }) => { + await installVaultBackend(page); + await page.goto('/vaults/vault-usdc-waitlist'); + await expect(page.getByText(/waitlist/i)).toBeVisible(); + }); + + test('closed-epoch vault surfaces the closed epoch state', async ({ page }) => { + await installVaultBackend(page); + await page.goto('/vaults/vault-usdc-closed'); + await expect(page.getByText(/epoch (closed|ended)/i)).toBeVisible(); + }); + + test('RPC failure shows a visible error and does not corrupt state', async ({ page }) => { + await installVaultBackend(page, { rpcFailure: true }); + await page.goto('/vaults'); + + await expect(page.getByRole('alert')).toBeVisible(); + await expect(page.getByRole('alert')).toContainText(/unavailable|error/i); + // No phantom vault rows rendered from a failed fetch. + await expect(page.getByRole('link', { name: /USDC Yield Vault/i })).toHaveCount(0); + }); + + test('deposit API failure surfaces the backend error message', async ({ page }) => { + await installVaultBackend(page, { + depositError: { status: 400, message: 'Insufficient balance' }, + }); + await page.goto('/vaults/vault-usdc-1'); + + await page.getByLabel(/amount/i).fill('105'); + await page.getByRole('button', { name: /deposit/i }).click(); + + await expect(page.getByRole('alert')).toContainText(/insufficient balance/i); + }); + + test('NEXT_PUBLIC_VAULTS disabled flag returns the documented fallback', async ({ page }) => { + // The flag is read at build time; when disabled the app renders the + // documented fallback instead of the vault surface. + await page.goto('/vaults'); + const fallback = page.getByTestId('vaults-disabled-fallback'); + if (await fallback.count()) { + await expect(fallback).toBeVisible(); + } else { + test.skip(true, 'NEXT_PUBLIC_VAULTS enabled in this build; fallback covered by unit test'); + } + }); +}); diff --git a/playwright.config.ts b/playwright.config.ts index 5291df2..b1e04b9 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -12,6 +12,20 @@ export default defineConfig({ video: 'retain-on-failure', ...devices['Desktop Chrome'], }, + projects: [ + { + name: 'Desktop Chrome', + use: { ...devices['Desktop Chrome'] }, + }, + { + name: 'iPhone 13', + use: { ...devices['iPhone 13'] }, + }, + { + name: 'Pixel 7', + use: { ...devices['Pixel 7'] }, + }, + ], webServer: { command: 'npm run storybook -- --ci --port 6006', url: 'http://127.0.0.1:6006', diff --git a/src/env.ts b/src/env.ts index b31c5db..3efd6ae 100644 --- a/src/env.ts +++ b/src/env.ts @@ -21,6 +21,11 @@ const parsed = parseEnv({ NEXT_PUBLIC_CONTRACT_TIMELOCK_MAINNET: process.env.NEXT_PUBLIC_CONTRACT_TIMELOCK_MAINNET, NEXT_PUBLIC_CONTRACT_DEPLOYER_TESTNET: process.env.NEXT_PUBLIC_CONTRACT_DEPLOYER_TESTNET, NEXT_PUBLIC_CONTRACT_DEPLOYER_MAINNET: process.env.NEXT_PUBLIC_CONTRACT_DEPLOYER_MAINNET, + VAULT_RATE_LIMIT_READ_PER_WALLET: process.env.VAULT_RATE_LIMIT_READ_PER_WALLET, + VAULT_RATE_LIMIT_READ_PER_IP: process.env.VAULT_RATE_LIMIT_READ_PER_IP, + VAULT_RATE_LIMIT_WRITE_PER_WALLET: process.env.VAULT_RATE_LIMIT_WRITE_PER_WALLET, + VAULT_RATE_LIMIT_WRITE_PER_IP: process.env.VAULT_RATE_LIMIT_WRITE_PER_IP, + VAULT_RATE_LIMIT_WINDOW_SECONDS: process.env.VAULT_RATE_LIMIT_WINDOW_SECONDS, SOROBAN_RPC_URLS: process.env.SOROBAN_RPC_URLS, SOROBAN_RPC_MAX_CONCURRENCY: process.env.SOROBAN_RPC_MAX_CONCURRENCY, SOROBAN_RPC_MAX_ATTEMPTS: process.env.SOROBAN_RPC_MAX_ATTEMPTS, @@ -101,6 +106,46 @@ export function contractEnvVarName(key: string): string { return `NEXT_PUBLIC_CONTRACT_${key.toUpperCase()}_${stellarNetworkSuffix}`; } +/** + * Vault API abuse-protection limits (issue #220). + * + * Per-environment configurable so staging can be relaxed while production + * stays strict. Limits are enforced per-wallet first (primary identity) with + * IP as a secondary dimension so shared NAT egress does not lock out + * legitimate users. Window is the sliding window length in seconds. + */ +export type VaultRateLimitConfig = { + /** Max read requests per wallet per window. */ + readPerWallet: number; + /** Max read requests per IP per window (secondary dimension). */ + readPerIp: number; + /** Max state-changing requests per wallet per window. */ + writePerWallet: number; + /** Max state-changing requests per IP per window (secondary dimension). */ + writePerIp: number; + /** Sliding window length in seconds. */ + windowSeconds: number; +}; + +function positiveInt(raw: string | undefined, fallback: number): number { + const value = Number(raw); + return Number.isFinite(value) && value > 0 ? Math.floor(value) : fallback; +} + +const isProduction = env.NODE_ENV === "production"; + +/** + * Documented vault rate limits. Defaults are strict in production and relaxed + * elsewhere; every value can be overridden per environment via env vars. + */ +export const vaultRateLimits: VaultRateLimitConfig = { + readPerWallet: positiveInt(parsed.VAULT_RATE_LIMIT_READ_PER_WALLET, isProduction ? 60 : 600), + readPerIp: positiveInt(parsed.VAULT_RATE_LIMIT_READ_PER_IP, isProduction ? 120 : 1200), + writePerWallet: positiveInt(parsed.VAULT_RATE_LIMIT_WRITE_PER_WALLET, isProduction ? 10 : 100), + writePerIp: positiveInt(parsed.VAULT_RATE_LIMIT_WRITE_PER_IP, isProduction ? 30 : 300), + windowSeconds: positiveInt(parsed.VAULT_RATE_LIMIT_WINDOW_SECONDS, 60), +}; + /** * Soroban RPC resilience configuration (issue #282). * @@ -151,8 +196,6 @@ function parseRpcUrls(raw: string | undefined, fallback: string): string[] { return urls; } -const isProduction = process.env.NODE_ENV === "production"; - if (isProduction && (parsed.SOROBAN_RPC_URLS === undefined || parsed.SOROBAN_RPC_URLS.trim() === "")) { throw new Error( "SOROBAN_RPC_URLS is required in production: configure a comma-separated list of Soroban RPC endpoints", diff --git a/src/lib/api/vaults.ts b/src/lib/api/vaults.ts index 181c811..4afa98a 100644 --- a/src/lib/api/vaults.ts +++ b/src/lib/api/vaults.ts @@ -36,6 +36,15 @@ import { submitTransactionXdr } from "./stellar"; * POST /{id}/deposit { amount } → { tx_id } * POST /{id}/withdraw { shares } → { tx_id, queued } * + * GET /api/v1/vaults?cursor=&limit= + * → { items: VaultSummary[]; nextCursor: string | null; cache: CacheMeta } + * + * GET /api/v1/vaults/{id} + * → VaultDetail + * + * GET /api/v1/vaults/{id}/epochs?cursor=&limit= + * → { items: EpochRecord[]; nextCursor: string | null; cache: CacheMeta } + * * GET /api/v1/vaults/{id}/analytics * → VaultAnalytics * @@ -78,6 +87,33 @@ import { submitTransactionXdr } from "./stellar"; * - Pause: not modeled in the mock. The intended rule is that a paused vault blocks new * requests but still processes queued ones. * + * Pagination & caching (issue #223): + * - List and epoch-history reads are cursor-paginated (never offset). The + * cursor is an opaque token over a stable sort key (vault id / epoch + * number), so inserting a new epoch mid-pagination neither skips nor + * duplicates rows. Page size is capped at VAULT_MAX_PAGE_SIZE. + * - Responses carry `cache` freshness metadata so the UI can show whether + * NAV values are live or served from cache. + * + * Security baseline (issue #220): + * - Writes require the `vault:deposit` / `vault:withdraw` scope; the BFF + * rejects with 403 when the session token lacks it. + * - Writes carry an `Idempotency-Key` header so a legitimately retried + * deposit is not double-submitted. + * - Amounts/shares are validated client-side (finite, positive, bounded + * precision) before they reach the contract layer. + * - Rate limits are per-wallet (primary) and per-IP (secondary); a 429 + * response includes a `retryAfter` hint in seconds. + * + * Loss recognition & insolvency (issue #225): + * - A realized loss larger than premium + reserves is allocated by a + * deterministic waterfall: fee reserves → first-loss buffer → pro-rata + * share write-down. Any residual beyond total NAV is recorded as explicit + * protocol bad debt (never an underflow). + * - NAV per share is clamped at zero and never reported negative. + * - While an unresolved loss event exists the vault is `impaired` and + * deposits are disabled. + * * NAV / share-price source of truth (issue #290): * * Share issuance is priced from the vault's internal book value @@ -286,6 +322,29 @@ export type VaultStrategy = z.infer; export type RiskRating = z.infer; export type EpochPhase = z.infer; +/** + * Operational state of a vault. `impaired` means an unresolved loss event + * exists: NAV has been written down and deposits are disabled until the loss + * is fully absorbed / the vault recovers. + */ +export type VaultState = "healthy" | "impaired" | "underwater"; + +/** Explicit scopes carried on the session/token for vault operations. */ +export type VaultScope = + | "vault:read" + | "vault:deposit" + | "vault:withdraw" + | "vault:operator"; + +/** Maximum decimal places accepted for deposit amounts / share quantities. */ +export const VAULT_AMOUNT_MAX_DECIMALS = 7; + +/** Hard cap on page size for cursor-paginated vault/epoch reads. */ +export const VAULT_MAX_PAGE_SIZE = 100; + +/** Default page size when the caller does not specify one. */ +export const VAULT_DEFAULT_PAGE_SIZE = 25; + /** * Execution mode of a vault strategy. * @@ -439,6 +498,10 @@ export interface VaultSummary { currentExpiry: string; // ISO date riskRating: RiskRating; description: string; + /** Current operational state; `impaired`/`underwater` disable deposits. */ + state: VaultState; + /** True while an unresolved loss event exists (deposits disabled). */ + depositsDisabled: boolean; /** Epoch length (tenor) in days. */ epochDays: number; /** Fees as whole percentages, e.g. performancePct: 10 = 10%. */ @@ -650,6 +713,33 @@ export interface EpochRecord { premiumCollected: number; // per share, in USD outcome: z.infer; pnlPct: number; // epoch return, positive = profit for depositors + /** Structured loss event recognized at this epoch's close, if any. */ + lossEvent: VaultLossEvent | null; +} + +/** + * Structured loss event emitted by the vault contract and persisted in the + * epoch ledger. Mirrors the on-chain `VaultLossEvent`. + */ +export interface VaultLossEvent { + /** Epoch the loss is attributed to. */ + epoch: number; + /** Total realized loss in USD recognized at this epoch close. */ + realizedLoss: number; + /** Portion absorbed by fee/performance-fee reserves. */ + absorbedByFees: number; + /** Portion absorbed by the first-loss buffer. */ + absorbedByBuffer: number; + /** Portion written down pro-rata across shareholders. */ + absorbedByShares: number; + /** Remaining first-loss buffer after the waterfall ran. */ + remainingBuffer: number; + /** NAV per share after the write-down (clamped at zero). */ + navPerShare: number; + /** Residual bad debt beyond total NAV (explicit, never an underflow). */ + badDebt: number; + /** ISO timestamp the loss was recognized. */ + recognizedAt: string; } /** The user's position in a vault. */ @@ -661,123 +751,4 @@ export interface UserVaultPosition { pendingWithdrawal: number; // shares escrowed in unprocessed withdrawal requests } -export type WithdrawalStatus = "queued" | "processed" | "cancelled"; - -/** One redemption request in a vault's withdrawal queue. */ -export interface WithdrawalRequest { - id: string; - vaultId: string; - /** Epoch the request was made in. Earlier epochs are served first. */ - requestEpoch: number; - /** Monotonic tie-break within an epoch, assigned at request time. */ - seq: number; - /** ISO time of the original request. Kept when unfilled requests carry forward. */ - requestedAt: string; - requestedShares: number; - /** Shares still escrowed and not yet redeemed. */ - escrowedShares: number; - filledShares: number; - /** filledShares / requestedShares, 0–1. Updated on every processing pass. */ - fillFraction: number; - /** USD paid out, summed over every pass at that pass's processing NAV. */ - payoutUsd: number; - status: WithdrawalStatus; -} - -/** State of the vault at the point where the withdrawal queue is processed. */ -export interface ProcessingPoint { - /** NAV per share at processing. Requests settle at this price. */ - navPerShare: number; - /** USD of liquid capital available to pay out. */ - liquidityUsd: number; -} - -/** - * One point in a hypothetical NAV-per-share series produced by a shadow - * run. Kept in its own namespace so it can never be read as live NAV. - */ -export interface ShadowNavPoint { - epochNumber: number; - /** Hypothetical NAV per share in USD (paper trading only). */ - navPerShare: number; - /** NAV per share the strategy model expected for this epoch. */ - expectedNavPerShare: number; - /** ISO timestamp the shadow epoch settled. */ - recordedAt: string; -} - -/** - * Side-by-side comparison of a shadow run against live and expected - * performance. All shadow figures are paper trading and must be labeled - * as such in the UI. - */ -export interface ShadowComparison { - /** Latest hypothetical NAV per share from the shadow run. */ - shadowNavPerShare: number; - /** Latest realized NAV per share of the live vault (null if none). */ - liveNavPerShare: number | null; - /** NAV per share the strategy model expected for the latest epoch. */ - expectedNavPerShare: number; - /** Shadow minus live NAV per share (null when no live history). */ - shadowVsLiveDelta: number | null; - /** Shadow minus expected NAV per share. */ - shadowVsExpectedDelta: number; - /** Number of complete shadow epochs recorded so far. */ - completedEpochs: number; -} - -/** - * Shadow (paper-trading) state for a vault strategy. Tracked in its own - * namespace, with its own hypothetical NAV series and epoch stream, so - * the registry, indexer, and this read model never conflate it with real - * balances. - */ -export interface ShadowState { - /** Always `shadow` — makes the paper-trading label explicit in payloads. */ - mode: "shadow"; - /** Hypothetical NAV-per-share series, one point per complete epoch. */ - navSeries: ShadowNavPoint[]; - /** Shadow epoch record stream (never merged into `historicalEpochs`). */ - epochs: EpochRecord[]; - /** Side-by-side comparison data for the operator console/analytics. */ - comparison: ShadowComparison; -} - -/** - * Promotion criteria that must be met before a shadow strategy can be - * moved to live allocation. Evaluated by the backend promotion gate; the - * adapter surfaces the thresholds so the operator console can display - * 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 +export type WithdrawalStatus