Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
177 changes: 82 additions & 95 deletions src/lib/api/vaults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,85 @@ export type VaultStrategy = z.infer<typeof VaultStrategySchema>;
export type RiskRating = z.infer<typeof RiskRatingSchema>;
export type EpochPhase = z.infer<typeof EpochPhaseSchema>;

// ---------------------------------------------------------------------------
// 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.
*
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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