diff --git a/src/lib/api/vaults.ts b/src/lib/api/vaults.ts index 181c811..e6a48fe 100644 --- a/src/lib/api/vaults.ts +++ b/src/lib/api/vaults.ts @@ -286,6 +286,85 @@ export type VaultStrategy = z.infer; export type RiskRating = z.infer; export type EpochPhase = z.infer; +// --------------------------------------------------------------------------- +// Multi-asset collateral support (issue #217) +// --------------------------------------------------------------------------- + +/** + * Canonical collateral asset descriptor for a vault. + * + * A vault declares exactly one collateral asset at creation and it is + * immutable afterwards. Classic Stellar assets use 7 decimals; Soroban + * token contracts may declare arbitrary decimals. All contract math works + * in base units; display units are derived at the API boundary via + * `normalizeAmount` / `formatAssetAmount`. + */ +export interface CollateralAsset { + /** Asset code, e.g. "USDC" or "AQUA". */ + code: string; + /** + * Classic Stellar issuer (G...) for classic assets, or the Soroban token + * contract id (C...) for token contracts. Null for native XLM. + */ + issuer: string | null; + /** Soroban token contract id (C...) when the asset is a token contract. */ + contractId: string | null; + /** Declared decimals for the asset (7 for classic Stellar assets). */ + decimals: number; +} + +/** Classic Stellar assets always use 7 decimals. */ +export const CLASSIC_ASSET_DECIMALS = 7; + +/** + * Per-network allowlist of supported collateral assets. Vault creation is + * validated against this list; assets without a healthy oracle are excluded. + */ +export interface CollateralAllowlist { + network: "mainnet" | "testnet" | "futurenet"; + assets: CollateralAsset[]; +} + +/** + * Single shared normalization helper: convert a base-unit integer amount + * into display units using the asset's declared decimals. Used for share + * pricing, NAV, capacity, and fee math so no module re-implements scaling. + */ +export function normalizeAmount(baseUnits: bigint | number, decimals: number): number { + const units = typeof baseUnits === "bigint" ? baseUnits : BigInt(Math.trunc(baseUnits)); + return Number(units) / 10 ** decimals; +} + +/** Inverse of `normalizeAmount`: display units → base-unit integer. */ +export function toBaseUnits(amount: number, decimals: number): bigint { + return BigInt(Math.round(amount * 10 ** decimals)); +} + +/** + * Validate that a display-unit amount does not exceed the asset's declared + * decimals. Returns an error message for over-precision amounts, or null. + */ +export function validateAssetPrecision(amount: number, decimals: number): string | null { + if (!Number.isFinite(amount) || amount < 0) return "Amount must be a non-negative number"; + const scaled = amount * 10 ** decimals; + if (Math.abs(scaled - Math.round(scaled)) > 1e-6) { + return `Amount exceeds ${decimals} decimal places for this asset`; + } + return null; +} + +/** + * Format a base-unit amount for display using the asset's decimals. + * Very small share prices are preserved (never rounded to zero) by + * falling back to significant-digit formatting. + */ +export function formatAssetAmount(baseUnits: bigint | number, decimals: number): string { + const value = normalizeAmount(baseUnits, decimals); + if (value === 0) return "0"; + if (Math.abs(value) < 0.01) return value.toPrecision(4); + return value.toLocaleString(undefined, { maximumFractionDigits: Math.min(decimals, 6) }); +} + /** * Execution mode of a vault strategy. * @@ -422,6 +501,8 @@ export interface VaultSummary { name: string; underlying: string; strategy: VaultStrategy; + /** Immutable collateral asset this vault operates in. */ + collateralAsset: CollateralAsset; /** Execution mode; `shadow` results are paper trading, not realized. */ mode: VaultMode; status: VaultStatus; @@ -686,98 +767,4 @@ export interface WithdrawalRequest { /** 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 + /** NAV per