diff --git a/contracts/vault/src/deposit_withdraw_props.rs b/contracts/vault/src/deposit_withdraw_props.rs index 992891b0..9ff03ba6 100644 --- a/contracts/vault/src/deposit_withdraw_props.rs +++ b/contracts/vault/src/deposit_withdraw_props.rs @@ -235,8 +235,13 @@ proptest! { let user = Address::generate(&env); let treasury = Address::generate(&env); - client.set_fee_bps(&fee_bps); - client.set_treasury(&treasury); + // Fee bps and treasury are behind the sensitive-parameter timelock + // (#969): queue the change, then execute it immediately — the default + // delay is zero until an admin configures one. + client.queue_fee_bps_change(&fee_bps); + client.execute_fee_bps_change(); + client.queue_treasury_change(&treasury); + client.execute_treasury_change(); mint(&env, &token, &user, deposit_amount); match client.try_deposit(&user, &deposit_amount) { @@ -301,11 +306,11 @@ proptest! { amount_a in 100i128..=500_000i128, amount_b in 100i128..=500_000i128, ) { - use crate::{DepositEntry, VaultError}; + use crate::DepositEntry; use soroban_sdk::Vec; // ── Vault A: individual deposits ────────────────────────────────────── - let (env_a, client_a, admin_a, token_a) = setup(); + let (env_a, client_a, _admin_a, token_a) = setup(); let user_a1 = Address::generate(&env_a); let user_a2 = Address::generate(&env_a); diff --git a/contracts/vault/src/event_tests.rs b/contracts/vault/src/event_tests.rs index c1e000b3..359ea6f4 100644 --- a/contracts/vault/src/event_tests.rs +++ b/contracts/vault/src/event_tests.rs @@ -1,5 +1,5 @@ use super::*; -use soroban_sdk::testutils::Address as _; +use soroban_sdk::testutils::{Address as _, Events as _}; use soroban_sdk::{token, Address, Env}; fn create_token_contract<'a>(env: &Env, admin: &Address) -> token::Client<'a> { @@ -213,7 +213,7 @@ fn test_pause_and_unpause_emit_state_transition_events() { let admin = Address::generate(&env); let token_admin = Address::generate(&env); - let usdc = create_token(&env, &token_admin); + let usdc = create_token_contract(&env, &token_admin); let vault_id = env.register(YieldVault, ()); let vault = YieldVaultClient::new(&env, &vault_id); diff --git a/contracts/vault/src/feature_tests.rs b/contracts/vault/src/feature_tests.rs index 687ee78d..f98251a5 100644 --- a/contracts/vault/src/feature_tests.rs +++ b/contracts/vault/src/feature_tests.rs @@ -101,8 +101,8 @@ fn test_emergency_proposal_rejects_non_primary() { ); assert_eq!( result.unwrap_err().unwrap(), - VaultError::UnauthorizedCaller, - "non-primary approver must be rejected with UnauthorizedCaller" + VaultError::RescueUnauthorized, + "non-primary approver must be rejected with RescueUnauthorized" ); } @@ -323,7 +323,7 @@ fn test_role_restricted_pausability_controls() { let vault_id = env.register(crate::YieldVault, ()); let vault = crate::YieldVaultClient::new(&env, &vault_id); - vault.initialize(&admin, &usdc).unwrap(); + vault.initialize(&admin, &usdc); let pauser = Address::generate(&env); let unauthorized = Address::generate(&env); @@ -332,10 +332,11 @@ fn test_role_restricted_pausability_controls() { assert_eq!(vault.pauser(), None); // Admin configures pauser role - vault.set_pauser(&Some(pauser.clone())).unwrap(); + vault.set_pauser(&Some(pauser.clone())); assert_eq!(vault.pauser(), Some(pauser.clone())); // Designated pauser can pause with role + vault.pause_with_role(&pauser, &PauseReason::SecurityIncident); vault .pause_with_role(&pauser, &PauseReason::SecurityIncident) .unwrap(); @@ -343,20 +344,100 @@ fn test_role_restricted_pausability_controls() { assert_eq!(vault.pause_reason(), Some(PauseReason::SecurityIncident)); // Designated pauser can unpause with role - vault.unpause_with_role(&pauser).unwrap(); + vault.unpause_with_role(&pauser); assert!(!vault.is_paused()); assert_eq!(vault.pause_reason(), None); // Admin can also pause and unpause with role + vault.pause_with_role(&admin, &PauseReason::Maintenance); vault .pause_with_role(&admin, &PauseReason::Maintenance) .unwrap(); assert!(vault.is_paused()); - vault.unpause_with_role(&admin).unwrap(); + vault.unpause_with_role(&admin); assert!(!vault.is_paused()); // Admin clears pauser role - vault.set_pauser(&None).unwrap(); + vault.set_pauser(&None); assert_eq!(vault.pauser(), None); } + +// ── Telemetry & debugging hooks (Issue #1174) ─────────────────────────────── + +#[test] +fn test_diagnostics_are_gated_off_by_default() { + let env = Env::default(); + env.mock_all_auths(); + + let (vault, _, _, _) = setup_vault(&env); + + assert!( + !vault.diagnostics_enabled(), + "the debug hook must not be open on a freshly initialised vault" + ); + assert_eq!( + vault.try_diagnostics(), + Err(Ok(VaultError::ContractPaused)), + "a disabled diagnostics hook must refuse to answer" + ); +} + +#[test] +fn test_diagnostics_snapshot_reports_live_vault_state() { + let env = Env::default(); + env.mock_all_auths(); + + let (vault, _, usdc_sa, _) = setup_vault(&env); + let user = Address::generate(&env); + usdc_sa.mint(&user, &10_000); + vault.deposit(&user, &10_000); + + vault.set_diagnostics_enabled(&true); + assert!(vault.diagnostics_enabled()); + + let snap = vault.diagnostics(); + assert_eq!(snap.total_shares, vault.total_shares()); + assert_eq!(snap.idle_assets, 10_000); + assert_eq!(snap.share_price, vault.share_price()); + assert_eq!(snap.fee_bps, vault.fee_bps()); + assert_eq!(snap.storage_version, vault.storage_version()); + assert_eq!(snap.withdrawal_queue_length, 0); + assert!(!snap.paused); + assert_eq!(snap.health, crate::telemetry::VaultHealth::Nominal); + assert_eq!(snap.ledger_sequence, env.ledger().sequence()); +} + +#[test] +fn test_diagnostics_report_halted_while_paused() { + let env = Env::default(); + env.mock_all_auths(); + + let (vault, _, usdc_sa, _) = setup_vault(&env); + let user = Address::generate(&env); + usdc_sa.mint(&user, &5_000); + vault.deposit(&user, &5_000); + + vault.set_diagnostics_enabled(&true); + vault.pause(&PauseReason::SecurityIncident); + + // The hook must keep answering while the vault is halted — that is exactly + // when an operator needs it. + let snap = vault.diagnostics(); + assert!(snap.paused); + assert_eq!(snap.health, crate::telemetry::VaultHealth::Halted); +} + +#[test] +fn test_diagnostics_can_be_disabled_again() { + let env = Env::default(); + env.mock_all_auths(); + + let (vault, _, _, _) = setup_vault(&env); + + vault.set_diagnostics_enabled(&true); + assert!(vault.diagnostics().total_shares == 0); + + vault.set_diagnostics_enabled(&false); + assert_eq!(vault.try_diagnostics(), Err(Ok(VaultError::ContractPaused))); +} diff --git a/contracts/vault/src/formal_verification_tests.rs b/contracts/vault/src/formal_verification_tests.rs index e857d92c..85b348bb 100644 --- a/contracts/vault/src/formal_verification_tests.rs +++ b/contracts/vault/src/formal_verification_tests.rs @@ -71,7 +71,8 @@ fn test_formal_theorem_2_solvency_and_balance_conservation() { env.mock_all_auths(); let (vault, usdc_sa, _) = setup_formal_vault(&env); - let users: Vec
= (0..5).map(|_| Address::generate(&env)).collect(); + // Fixed-size array keeps this `no_std` test module free of an `alloc` dependency. + let users: [Address; 5] = core::array::from_fn(|_| Address::generate(&env)); for (i, user) in users.iter().enumerate() { let amount = ((i + 1) * 2000) as i128; diff --git a/contracts/vault/src/lib.rs b/contracts/vault/src/lib.rs index 94223a74..0fee880c 100644 --- a/contracts/vault/src/lib.rs +++ b/contracts/vault/src/lib.rs @@ -80,11 +80,13 @@ pub mod strategy_validation; mod deposit_withdraw_props; #[cfg(test)] mod invariant_tests; +pub mod liquidation_safeguards; pub mod math; #[cfg(test)] mod oracle_tests; pub mod packed_storage; pub mod permissions; +pub mod recovery_sequence; #[cfg(test)] pub mod proxy_tests; pub mod storage_registry; @@ -99,6 +101,7 @@ pub mod withdrawal_queue_safety; pub mod oracle; pub mod strategy_heartbeat; pub mod strategy_registration; +pub mod telemetry; pub mod timelock; pub mod whitelist; @@ -237,6 +240,9 @@ pub enum DataKeyExt { PendingFeeBps, PendingTreasury, PendingPriceOracle, + + // Issue #1174: gate for the contract telemetry / debugging hook + DiagnosticsEnabled, } #[contracttype] @@ -2565,24 +2571,6 @@ impl YieldVault { .unwrap_or(0) } - /// Test helper: appends a synthetic queue entry for `process_withdrawal_queue` tests. - /// Only compiled and callable in test builds — not available on mainnet WASM. - #[cfg(test)] - #[doc(hidden)] - pub fn test_seed_withdrawal_queue_entry(env: Env, user: Address, shares: i128, assets: i128) { - let tail = Self::withdrawal_queue_tail(&env); - let entry = WithdrawalQueueEntry { - user, - shares, - assets, - enqueued_at: env.ledger().timestamp(), - }; - env.storage() - .instance() - .set(&DataKey::WithdrawalQueueEntry(tail), &entry); - Self::set_withdrawal_queue_tail(&env, tail.checked_add(1).expect("queue overflow")); - } - /// Process queued withdrawals in deterministic FIFO order while liquidity allows. pub fn process_withdrawal_queue(env: Env, max_entries: u32) -> u32 { if max_entries == 0 { @@ -3895,6 +3883,64 @@ impl YieldVault { } } + // ── Telemetry & debugging hooks (Issue #1174) ─────────────────────────── + + /// Enables or disables the diagnostics hook. Admin-only. + /// + /// Diagnostics are off by default, so turning them on is an explicit, + /// auditable admin action rather than a permanently open entry point. + pub fn set_diagnostics_enabled(env: Env, enabled: bool) -> Result<(), VaultError> { + let admin: Address = get_admin(&env).ok_or(VaultError::RescueUnauthorized)?; + admin.require_auth(); + env.storage() + .instance() + .set(&DataKeyExt::DiagnosticsEnabled, &enabled); + env.events() + .publish((symbol_short!("diagset"),), (enabled,)); + Ok(()) + } + + /// Whether the diagnostics hook is currently enabled. + pub fn diagnostics_enabled(env: Env) -> bool { + env.storage() + .instance() + .get(&DataKeyExt::DiagnosticsEnabled) + .unwrap_or(false) + } + + /// Returns a consistent, aggregate-only snapshot of vault state. + /// + /// Gated behind [`Self::set_diagnostics_enabled`]. The snapshot contains no + /// addresses, per-user balances, or credentials — see [`telemetry`] for the + /// field policy and the tests that enforce it. + /// + /// Reads only vault-local storage: unlike [`Self::total_assets`] it never + /// calls the strategy or the oracle, so it stays callable while an external + /// dependency is exactly what is broken. + /// + /// # Errors + /// - [`VaultError::ContractPaused`] — diagnostics are disabled. + pub fn diagnostics(env: Env) -> Result { + telemetry::require_enabled(Self::diagnostics_enabled(env.clone()))?; + + let state = Self::get_state(&env); + let queue_length = Self::withdrawal_queue_length(env.clone()); + let inputs = telemetry::DiagnosticInputs { + ledger_sequence: env.ledger().sequence(), + timestamp: env.ledger().timestamp(), + storage_version: Self::storage_version(env.clone()), + total_shares: state.total_shares, + idle_assets: state.total_assets, + share_price: Self::share_price(env.clone()), + treasury_balance: Self::treasury_balance(env.clone()), + fee_bps: Self::fee_bps(env.clone()), + withdrawal_queue_length: queue_length, + paused: state.is_paused, + min_liquidity_buffer: Self::min_liquidity_buffer(env.clone()), + }; + Ok(telemetry::build_snapshot(&inputs)) + } + /// Read-only: returns contract metadata such as version and simple config flags. pub fn metadata(env: Env) -> ContractMetadata { let state = Self::get_state(&env); diff --git a/contracts/vault/src/liquidation_safeguards.rs b/contracts/vault/src/liquidation_safeguards.rs new file mode 100644 index 00000000..6584d27b --- /dev/null +++ b/contracts/vault/src/liquidation_safeguards.rs @@ -0,0 +1,501 @@ +//! Liquidation and recovery safeguards for strategy shortfalls (Issue #1165). +//! +//! A strategy can report back less value than the vault deployed into it — +//! through a defaulted RWA tranche, an oracle-reported markdown, or a bug in the +//! strategy adapter. Left unhandled, the vault keeps quoting a share price that +//! its assets no longer back, and the first movers redeem at par while the last +//! movers absorb the entire loss. +//! +//! This module is a *pure* decision layer evaluated **before** any state is +//! mutated, mirroring [`crate::withdrawal_queue_safety`]: it classifies the +//! shortfall, gates recovery execution behind explicit preconditions, and +//! computes the loss socialisation that keeps the share price honest. +//! +//! ## Responsibilities +//! +//! - [`assess_shortfall`] — classify a strategy position as healthy, degraded, +//! or impaired against an operator-configured tolerance. +//! - [`check_recovery_preconditions`] — reject recovery attempts made under +//! invalid conditions (live vault, missing governance approvals, no measured +//! shortfall, over-sized recovery amount). +//! - [`socialize_loss`] — apply an impairment to the vault's asset base with +//! saturating, non-negative arithmetic. +//! +//! Operator and governance responsibilities are documented in +//! `docs/runbooks/VAULT_LIQUIDATION_RECOVERY.md`. +//! +//! ## Error-code reuse +//! +//! The Soroban error enum is capped at 50 cases (see [`crate::errors`]), so this +//! module deliberately reuses existing codes rather than defining new ones: +//! +//! | Condition | Code | +//! |---|---| +//! | No strategy configured | [`VaultError::StrategyNotConfigured`] | +//! | Non-positive / corrupt amounts | [`VaultError::InvalidAmount`] | +//! | `tolerance_bps` outside `0..=10_000` | [`VaultError::InvalidRiskThreshold`] | +//! | Fewer approvals than required | [`VaultError::GovernanceThresholdNotMet`] | +//! | Vault still live (not paused) | [`VaultError::RescueUnauthorized`] | +//! | Recovery amount exceeds the measured shortfall | [`VaultError::ExceedsRiskThreshold`] | +//! | Arithmetic overflow | [`VaultError::MathOverflow`] | + +use crate::errors::VaultError; + +/// Basis-point denominator. +pub const BPS_DENOMINATOR: i128 = 10_000; + +/// Classification of a strategy's reported value against the vault's expectation. +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum StrategyHealth { + /// Reported value meets or exceeds what the vault deployed. + Healthy, + /// Reported value is short, but within the configured tolerance — the vault + /// keeps operating and the position is watched, not liquidated. + Degraded, + /// Reported value is short beyond tolerance. The position is treated as bad + /// debt: recovery may be executed and the loss socialised. + Impaired, +} + +/// Outcome of a shortfall assessment. +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub struct ShortfallReport { + /// Health classification for the position. + pub health: StrategyHealth, + /// Absolute shortfall in underlying asset units (never negative). + pub shortfall: i128, + /// Shortfall as a share of `expected_value`, in basis points, rounded **up** + /// so a partially-lost basis point never reads as zero loss. + pub shortfall_bps: i128, +} + +impl ShortfallReport { + /// Whether the position carries bad debt that recovery may act on. + pub fn is_impaired(&self) -> bool { + matches!(self.health, StrategyHealth::Impaired) + } +} + +/// A strategy position as the vault sees it at assessment time. +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub struct StrategyPosition { + /// Value the vault believes is deployed — the strategy high-water mark. + pub expected_value: i128, + /// Value the strategy currently reports via `total_value()`. + pub reported_value: i128, + /// Shortfall the vault tolerates before declaring the position impaired. + pub tolerance_bps: u32, +} + +/// Classifies `position` as healthy, degraded, or impaired. +/// +/// A position with `expected_value == 0` is always [`StrategyHealth::Healthy`]: +/// nothing was deployed, so nothing can be lost. This matters because a naive +/// percentage would divide by zero on a freshly registered strategy. +/// +/// # Errors +/// - [`VaultError::InvalidAmount`] — negative expected or reported value. +/// - [`VaultError::InvalidRiskThreshold`] — tolerance outside `0..=10_000` bps. +/// - [`VaultError::MathOverflow`] — the basis-point scaling would overflow. +pub fn assess_shortfall(position: &StrategyPosition) -> Result { + if position.expected_value < 0 || position.reported_value < 0 { + return Err(VaultError::InvalidAmount); + } + if position.tolerance_bps as i128 > BPS_DENOMINATOR { + return Err(VaultError::InvalidRiskThreshold); + } + + if position.expected_value == 0 || position.reported_value >= position.expected_value { + return Ok(ShortfallReport { + health: StrategyHealth::Healthy, + shortfall: 0, + shortfall_bps: 0, + }); + } + + let shortfall = position + .expected_value + .checked_sub(position.reported_value) + .ok_or(VaultError::MathOverflow)?; + + // Round up: a 0.004% loss must not be reported as a 0 bps loss. + let scaled = shortfall + .checked_mul(BPS_DENOMINATOR) + .ok_or(VaultError::MathOverflow)?; + let shortfall_bps = scaled + .checked_add(position.expected_value - 1) + .ok_or(VaultError::MathOverflow)? + / position.expected_value; + + let health = if shortfall_bps > position.tolerance_bps as i128 { + StrategyHealth::Impaired + } else { + StrategyHealth::Degraded + }; + + Ok(ShortfallReport { + health, + shortfall, + shortfall_bps, + }) +} + +/// Conditions under which a recovery is being attempted. +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub struct RecoveryRequest { + /// Asset amount the operator wants to claw back / write down. + pub amount: i128, + /// Whether a strategy is currently configured on the vault. + pub strategy_configured: bool, + /// Whether the vault is paused. Recovery mutates the share price, so it must + /// never run while deposits and withdrawals are live. + pub vault_paused: bool, + /// Governance approvals collected for this recovery. + pub approvals: u32, + /// Governance approvals required. + pub required_approvals: u32, +} + +/// Rejects a recovery attempt made under invalid conditions. +/// +/// Checks run cheapest-and-most-structural first so an operator sees the most +/// actionable failure rather than a downstream symptom. +/// +/// # Errors +/// See the error-code table in the [module docs](self). +pub fn check_recovery_preconditions( + request: &RecoveryRequest, + report: &ShortfallReport, +) -> Result<(), VaultError> { + if !request.strategy_configured { + return Err(VaultError::StrategyNotConfigured); + } + if request.amount <= 0 || report.shortfall < 0 { + return Err(VaultError::InvalidAmount); + } + // A live vault would let depositors mint at the pre-write-down share price. + if !request.vault_paused { + return Err(VaultError::RescueUnauthorized); + } + if request.approvals < request.required_approvals { + return Err(VaultError::GovernanceThresholdNotMet); + } + // Nothing to recover: refuse rather than silently writing down a solvent + // position. `Degraded` is inside tolerance and is explicitly not actionable. + if !report.is_impaired() { + return Err(VaultError::InvalidAmount); + } + if request.amount > report.shortfall { + return Err(VaultError::ExceedsRiskThreshold); + } + Ok(()) +} + +/// Applies a realised loss to the vault's asset base. +/// +/// Returns the post-write-down total assets. The result is floored at zero: a +/// loss larger than the asset base wipes the vault out rather than wrapping into +/// a negative balance that would corrupt every downstream share-price read. +/// +/// `total_shares` is not mutated — socialising a loss means every share is worth +/// proportionally less, not that shares are burned. +/// +/// # Errors +/// - [`VaultError::InvalidAmount`] — negative assets, shares, or loss. +pub fn socialize_loss( + total_assets: i128, + total_shares: i128, + loss: i128, +) -> Result { + if total_assets < 0 || total_shares < 0 || loss < 0 { + return Err(VaultError::InvalidAmount); + } + Ok(total_assets.saturating_sub(loss).max(0)) +} + +/// Largest loss that can be socialised without driving the share price to zero. +/// +/// Operators use this to size a partial write-down when a full one would leave +/// outstanding shares backed by nothing. +pub fn max_socializable_loss(total_assets: i128, total_shares: i128) -> Result { + if total_assets < 0 || total_shares < 0 { + return Err(VaultError::InvalidAmount); + } + // With no shares outstanding there is nobody to socialise the loss to. + if total_shares == 0 { + return Ok(total_assets); + } + // Leave at least one asset unit backing the outstanding shares. + Ok(if total_assets > 0 { + total_assets - 1 + } else { + 0 + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn position(expected: i128, reported: i128, tolerance_bps: u32) -> StrategyPosition { + StrategyPosition { + expected_value: expected, + reported_value: reported, + tolerance_bps, + } + } + + fn request(amount: i128) -> RecoveryRequest { + RecoveryRequest { + amount, + strategy_configured: true, + vault_paused: true, + approvals: 2, + required_approvals: 2, + } + } + + fn impaired(shortfall: i128) -> ShortfallReport { + ShortfallReport { + health: StrategyHealth::Impaired, + shortfall, + shortfall_bps: 10_000, + } + } + + // ── assess_shortfall ──────────────────────────────────────────────────── + + #[test] + fn healthy_when_strategy_reports_at_or_above_expectation() { + assert_eq!( + assess_shortfall(&position(1_000, 1_000, 100)) + .unwrap() + .health, + StrategyHealth::Healthy + ); + assert_eq!( + assess_shortfall(&position(1_000, 1_500, 100)) + .unwrap() + .health, + StrategyHealth::Healthy + ); + } + + #[test] + fn undeployed_strategy_is_healthy_not_a_division_by_zero() { + let report = assess_shortfall(&position(0, 0, 100)).unwrap(); + assert_eq!(report.health, StrategyHealth::Healthy); + assert_eq!(report.shortfall_bps, 0); + } + + #[test] + fn shortfall_within_tolerance_is_degraded() { + // 1% loss against a 5% tolerance. + let report = assess_shortfall(&position(10_000, 9_900, 500)).unwrap(); + assert_eq!(report.health, StrategyHealth::Degraded); + assert_eq!(report.shortfall, 100); + assert_eq!(report.shortfall_bps, 100); + } + + #[test] + fn shortfall_at_exact_tolerance_boundary_is_still_degraded() { + let report = assess_shortfall(&position(10_000, 9_500, 500)).unwrap(); + assert_eq!(report.health, StrategyHealth::Degraded); + assert_eq!(report.shortfall_bps, 500); + } + + #[test] + fn shortfall_one_bp_beyond_tolerance_is_impaired() { + let report = assess_shortfall(&position(10_000, 9_499, 500)).unwrap(); + assert_eq!(report.health, StrategyHealth::Impaired); + assert_eq!(report.shortfall, 501); + assert_eq!(report.shortfall_bps, 501); + } + + #[test] + fn sub_basis_point_loss_rounds_up_and_is_never_reported_as_zero() { + // 1 unit lost out of 1_000_000 == 0.01 bps, which floors to 0. + let report = assess_shortfall(&position(1_000_000, 999_999, 0)).unwrap(); + assert_eq!(report.shortfall, 1); + assert_eq!(report.shortfall_bps, 1, "loss must round up, not vanish"); + assert_eq!(report.health, StrategyHealth::Impaired); + } + + #[test] + fn total_loss_reports_full_basis_points() { + let report = assess_shortfall(&position(1_000, 0, 0)).unwrap(); + assert_eq!(report.shortfall, 1_000); + assert_eq!(report.shortfall_bps, BPS_DENOMINATOR); + assert!(report.is_impaired()); + } + + #[test] + fn rejects_negative_values() { + assert_eq!( + assess_shortfall(&position(-1, 0, 0)), + Err(VaultError::InvalidAmount) + ); + assert_eq!( + assess_shortfall(&position(0, -1, 0)), + Err(VaultError::InvalidAmount) + ); + } + + #[test] + fn rejects_tolerance_outside_basis_point_range() { + assert_eq!( + assess_shortfall(&position(1_000, 500, 10_001)), + Err(VaultError::InvalidRiskThreshold) + ); + } + + #[test] + fn rejects_basis_point_scaling_overflow() { + assert_eq!( + assess_shortfall(&position(i128::MAX, 0, 0)), + Err(VaultError::MathOverflow) + ); + } + + // ── check_recovery_preconditions ──────────────────────────────────────── + + #[test] + fn accepts_a_fully_authorised_recovery() { + assert_eq!( + check_recovery_preconditions(&request(500), &impaired(500)), + Ok(()) + ); + } + + #[test] + fn rejects_recovery_without_a_configured_strategy() { + let mut req = request(500); + req.strategy_configured = false; + assert_eq!( + check_recovery_preconditions(&req, &impaired(500)), + Err(VaultError::StrategyNotConfigured) + ); + } + + #[test] + fn rejects_non_positive_recovery_amount() { + assert_eq!( + check_recovery_preconditions(&request(0), &impaired(500)), + Err(VaultError::InvalidAmount) + ); + assert_eq!( + check_recovery_preconditions(&request(-1), &impaired(500)), + Err(VaultError::InvalidAmount) + ); + } + + #[test] + fn rejects_recovery_while_the_vault_is_live() { + let mut req = request(500); + req.vault_paused = false; + assert_eq!( + check_recovery_preconditions(&req, &impaired(500)), + Err(VaultError::RescueUnauthorized) + ); + } + + #[test] + fn rejects_recovery_below_the_governance_threshold() { + let mut req = request(500); + req.approvals = 1; + req.required_approvals = 2; + assert_eq!( + check_recovery_preconditions(&req, &impaired(500)), + Err(VaultError::GovernanceThresholdNotMet) + ); + } + + #[test] + fn rejects_recovery_against_a_healthy_position() { + let healthy = ShortfallReport { + health: StrategyHealth::Healthy, + shortfall: 0, + shortfall_bps: 0, + }; + assert_eq!( + check_recovery_preconditions(&request(1), &healthy), + Err(VaultError::InvalidAmount) + ); + } + + #[test] + fn rejects_recovery_against_a_merely_degraded_position() { + let degraded = ShortfallReport { + health: StrategyHealth::Degraded, + shortfall: 100, + shortfall_bps: 100, + }; + assert_eq!( + check_recovery_preconditions(&request(100), °raded), + Err(VaultError::InvalidAmount), + "a position inside tolerance must not be liquidated" + ); + } + + #[test] + fn rejects_recovery_larger_than_the_measured_shortfall() { + assert_eq!( + check_recovery_preconditions(&request(501), &impaired(500)), + Err(VaultError::ExceedsRiskThreshold) + ); + } + + #[test] + fn accepts_a_partial_recovery() { + assert_eq!( + check_recovery_preconditions(&request(1), &impaired(500)), + Ok(()) + ); + } + + // ── socialize_loss ────────────────────────────────────────────────────── + + #[test] + fn socializes_a_partial_loss() { + assert_eq!(socialize_loss(1_000, 1_000, 250), Ok(750)); + } + + #[test] + fn loss_larger_than_the_asset_base_floors_at_zero() { + assert_eq!(socialize_loss(1_000, 1_000, 5_000), Ok(0)); + assert_eq!(socialize_loss(0, 1_000, i128::MAX), Ok(0)); + } + + #[test] + fn socialize_loss_rejects_negative_inputs() { + assert_eq!(socialize_loss(-1, 0, 0), Err(VaultError::InvalidAmount)); + assert_eq!(socialize_loss(0, -1, 0), Err(VaultError::InvalidAmount)); + assert_eq!(socialize_loss(0, 0, -1), Err(VaultError::InvalidAmount)); + } + + #[test] + fn max_socializable_loss_leaves_a_unit_backing_outstanding_shares() { + assert_eq!(max_socializable_loss(1_000, 500), Ok(999)); + let remaining = socialize_loss(1_000, 500, max_socializable_loss(1_000, 500).unwrap()); + assert_eq!(remaining, Ok(1), "share price must stay non-zero"); + } + + #[test] + fn max_socializable_loss_with_no_shares_is_the_whole_asset_base() { + assert_eq!(max_socializable_loss(1_000, 0), Ok(1_000)); + assert_eq!(max_socializable_loss(0, 0), Ok(0)); + } + + #[test] + fn assessment_feeds_recovery_end_to_end() { + let report = assess_shortfall(&position(10_000, 6_000, 500)).unwrap(); + assert!(report.is_impaired()); + assert_eq!(report.shortfall, 4_000); + + assert_eq!( + check_recovery_preconditions(&request(report.shortfall), &report), + Ok(()) + ); + assert_eq!(socialize_loss(10_000, 10_000, report.shortfall), Ok(6_000)); + } +} diff --git a/contracts/vault/src/recovery_sequence.rs b/contracts/vault/src/recovery_sequence.rs new file mode 100644 index 00000000..260d8067 --- /dev/null +++ b/contracts/vault/src/recovery_sequence.rs @@ -0,0 +1,538 @@ +//! Transactional accounting model and recovery regression tests (Issue #1172). +//! +//! Complex operator sequences — a deposit, then a withdrawal, then a fee change, +//! then a retry of the step that failed — are exactly where a vault ends up in an +//! inconsistent state. A Soroban transaction rolls back on panic, but a call that +//! returns `Err` after *partially* mutating storage does not: the vault keeps the +//! half-applied write. +//! +//! This module encodes the vault's accounting as an explicit state machine where +//! [`apply`] is **transactional by construction**: it validates every +//! precondition, computes the whole next state, and only then returns it. A +//! failing step returns `Err` and hands back nothing, so the caller's state is +//! provably untouched. [`apply_sequence`] runs a list of steps under that rule +//! and reports where the sequence stopped. +//! +//! The tests below are the regression suite the issue asks for: they interleave +//! deposits, withdrawals, yield accrual, and fee changes with injected failures, +//! then assert that +//! +//! 1. the state after an interrupted step is byte-identical to the state before, +//! 2. [`check_invariants`] still holds at every point in the sequence, and +//! 3. re-running the failed step after the operator fixes the input succeeds and +//! lands on the same state as if the failure had never happened. +//! +//! The developer-facing recovery procedure is documented in +//! `docs/runbooks/VAULT_LIQUIDATION_RECOVERY.md`. + +use crate::errors::VaultError; + +/// Basis-point denominator. +pub const BPS_DENOMINATOR: i128 = 10_000; + +/// The subset of vault storage that deposits, withdrawals, yield, and fee +/// changes can mutate. +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub struct AccountingState { + /// Total shares outstanding. + pub total_shares: i128, + /// Assets held by the vault itself, backing `total_shares`. + pub total_assets: i128, + /// Protocol fees accrued but not yet claimed. + pub treasury_balance: i128, + /// Protocol fee rate applied to accrued yield. + pub fee_bps: i128, +} + +impl AccountingState { + /// An initialised, empty vault. + pub fn empty() -> Self { + Self { + total_shares: 0, + total_assets: 0, + treasury_balance: 0, + fee_bps: 0, + } + } +} + +/// A single operator- or user-initiated step in a sequence. +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum Step { + /// A user deposits `assets` and receives shares at the current share price. + Deposit { assets: i128 }, + /// A user burns `shares` and receives assets at the current share price. + Withdraw { shares: i128 }, + /// Admin books `amount` of yield, net of the protocol fee. + AccrueYield { amount: i128 }, + /// Admin changes the protocol fee rate. + SetFeeBps { bps: i128 }, +} + +/// Where a sequence stopped, and the state it stopped in. +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub struct SequenceOutcome { + /// State after the last **successful** step. + pub state: AccountingState, + /// Number of steps that committed. + pub applied: u32, + /// The error that halted the sequence, if any. + pub halted_with: Option, +} + +/// Structural invariants that must hold after every committed step. +/// +/// # Errors +/// - [`VaultError::InvalidAmount`] — a negative balance or an out-of-range fee. +/// - [`VaultError::InsufficientShares`] — shares outstanding with no assets +/// backing them, which would make the share price zero for live holders. +pub fn check_invariants(state: &AccountingState) -> Result<(), VaultError> { + if state.total_shares < 0 || state.total_assets < 0 || state.treasury_balance < 0 { + return Err(VaultError::InvalidAmount); + } + if state.fee_bps < 0 || state.fee_bps > BPS_DENOMINATOR { + return Err(VaultError::InvalidFeeBps); + } + if state.total_shares > 0 && state.total_assets == 0 { + return Err(VaultError::InsufficientShares); + } + if state.total_shares == 0 && state.total_assets != 0 { + // Assets with no shares outstanding are unattributable — every holder + // exited, so the vault must have drained with them. + return Err(VaultError::InvalidAmount); + } + Ok(()) +} + +/// Converts `assets` to shares at the current price, rounding **down** so a +/// depositor can never mint more value than they contributed. +pub fn shares_for_assets(state: &AccountingState, assets: i128) -> Result { + if state.total_shares == 0 || state.total_assets == 0 { + return Ok(assets); + } + assets + .checked_mul(state.total_shares) + .ok_or(VaultError::MathOverflow) + .map(|v| v / state.total_assets) +} + +/// Converts `shares` to assets at the current price, rounding **down** so the +/// vault never pays out more than the shares are worth. +pub fn assets_for_shares(state: &AccountingState, shares: i128) -> Result { + if state.total_shares == 0 { + return Ok(0); + } + shares + .checked_mul(state.total_assets) + .ok_or(VaultError::MathOverflow) + .map(|v| v / state.total_shares) +} + +/// Applies one step, returning the next state. +/// +/// This function never mutates its input. On `Err` the caller keeps the state it +/// already had, which is the property the recovery tests assert. +/// +/// # Errors +/// - [`VaultError::InvalidAmount`] — non-positive amount. +/// - [`VaultError::InvalidFeeBps`] — fee outside `0..=10_000`. +/// - [`VaultError::InsufficientShares`] — withdrawal exceeds shares outstanding. +/// - [`VaultError::InsufficientLiquidity`] — withdrawal exceeds assets held. +/// - [`VaultError::MathOverflow`] — any intermediate product overflows. +pub fn apply(state: &AccountingState, step: Step) -> Result { + match step { + Step::Deposit { assets } => { + if assets <= 0 { + return Err(VaultError::InvalidAmount); + } + let minted = shares_for_assets(state, assets)?; + if minted <= 0 { + // The deposit is worth less than one share at the current price; + // accepting it would take the assets and mint nothing. + return Err(VaultError::InvalidAmount); + } + Ok(AccountingState { + total_shares: state + .total_shares + .checked_add(minted) + .ok_or(VaultError::MathOverflow)?, + total_assets: state + .total_assets + .checked_add(assets) + .ok_or(VaultError::MathOverflow)?, + ..*state + }) + } + Step::Withdraw { shares } => { + if shares <= 0 { + return Err(VaultError::InvalidAmount); + } + if shares > state.total_shares { + return Err(VaultError::InsufficientShares); + } + let owed = assets_for_shares(state, shares)?; + if owed > state.total_assets { + return Err(VaultError::InsufficientLiquidity); + } + let remaining_shares = state.total_shares - shares; + let remaining_assets = state.total_assets - owed; + // A full exit must drain the vault; dust left behind with no shares + // outstanding would be unattributable (see `check_invariants`). + let remaining_assets = if remaining_shares == 0 { + 0 + } else { + remaining_assets + }; + Ok(AccountingState { + total_shares: remaining_shares, + total_assets: remaining_assets, + ..*state + }) + } + Step::AccrueYield { amount } => { + if amount <= 0 { + return Err(VaultError::InvalidYieldAmount); + } + if state.total_shares == 0 { + // Nobody to accrue to; booking yield here would create assets + // with no shares behind them. + return Err(VaultError::InsufficientShares); + } + let fee = amount + .checked_mul(state.fee_bps) + .ok_or(VaultError::MathOverflow)? + / BPS_DENOMINATOR; + let net = amount - fee; + Ok(AccountingState { + total_assets: state + .total_assets + .checked_add(net) + .ok_or(VaultError::MathOverflow)?, + treasury_balance: state + .treasury_balance + .checked_add(fee) + .ok_or(VaultError::MathOverflow)?, + ..*state + }) + } + Step::SetFeeBps { bps } => { + if !(0..=BPS_DENOMINATOR).contains(&bps) { + return Err(VaultError::InvalidFeeBps); + } + Ok(AccountingState { + fee_bps: bps, + ..*state + }) + } + } +} + +/// Applies `steps` in order, stopping at the first failure. +/// +/// The returned [`SequenceOutcome`] carries the state after the last committed +/// step — never a partially applied one — so an operator can inspect exactly +/// where a batch stopped and retry from there. +pub fn apply_sequence(initial: &AccountingState, steps: &[Step]) -> SequenceOutcome { + let mut state = *initial; + let mut applied = 0u32; + + for step in steps { + match apply(&state, *step) { + Ok(next) => { + state = next; + applied += 1; + } + Err(err) => { + return SequenceOutcome { + state, + applied, + halted_with: Some(err), + } + } + } + } + + SequenceOutcome { + state, + applied, + halted_with: None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn funded(shares: i128, assets: i128) -> AccountingState { + AccountingState { + total_shares: shares, + total_assets: assets, + treasury_balance: 0, + fee_bps: 0, + } + } + + /// Runs `step` against `before` and asserts it failed with `expected` + /// **and** left `before` untouched — the core recovery property. + fn assert_rejected_without_mutation(before: AccountingState, step: Step, expected: VaultError) { + let snapshot = before; + assert_eq!(apply(&before, step), Err(expected)); + assert_eq!(before, snapshot, "a failed step must not mutate state"); + assert_eq!(check_invariants(&before), Ok(())); + } + + // ── Happy-path baselines ──────────────────────────────────────────────── + + #[test] + fn first_deposit_mints_one_to_one() { + let state = apply(&AccountingState::empty(), Step::Deposit { assets: 1_000 }).unwrap(); + assert_eq!(state, funded(1_000, 1_000)); + assert_eq!(check_invariants(&state), Ok(())); + } + + #[test] + fn full_exit_drains_the_vault() { + let state = funded(1_000, 1_000); + let state = apply(&state, Step::Withdraw { shares: 1_000 }).unwrap(); + assert_eq!(state, AccountingState::empty()); + assert_eq!(check_invariants(&state), Ok(())); + } + + #[test] + fn yield_accrual_splits_between_holders_and_treasury() { + let mut state = funded(1_000, 1_000); + state.fee_bps = 500; // 5% + let state = apply(&state, Step::AccrueYield { amount: 200 }).unwrap(); + assert_eq!(state.total_assets, 1_190); + assert_eq!(state.treasury_balance, 10); + assert_eq!(state.total_shares, 1_000, "yield must not mint shares"); + assert_eq!(check_invariants(&state), Ok(())); + } + + // ── Failed steps leave no partial state ───────────────────────────────── + + #[test] + fn over_withdrawal_is_rejected_without_mutation() { + assert_rejected_without_mutation( + funded(1_000, 1_000), + Step::Withdraw { shares: 1_001 }, + VaultError::InsufficientShares, + ); + } + + #[test] + fn non_positive_deposit_is_rejected_without_mutation() { + assert_rejected_without_mutation( + funded(1_000, 1_000), + Step::Deposit { assets: 0 }, + VaultError::InvalidAmount, + ); + assert_rejected_without_mutation( + funded(1_000, 1_000), + Step::Deposit { assets: -5 }, + VaultError::InvalidAmount, + ); + } + + #[test] + fn out_of_range_fee_change_is_rejected_without_mutation() { + assert_rejected_without_mutation( + funded(1_000, 1_000), + Step::SetFeeBps { bps: 10_001 }, + VaultError::InvalidFeeBps, + ); + assert_rejected_without_mutation( + funded(1_000, 1_000), + Step::SetFeeBps { bps: -1 }, + VaultError::InvalidFeeBps, + ); + } + + #[test] + fn yield_on_an_empty_vault_is_rejected_without_mutation() { + assert_rejected_without_mutation( + AccountingState::empty(), + Step::AccrueYield { amount: 100 }, + VaultError::InsufficientShares, + ); + } + + #[test] + fn dust_deposit_that_would_mint_zero_shares_is_rejected() { + // Share price is 1_000 assets per share: a 999-asset deposit rounds to 0. + let state = funded(1, 1_000); + assert_rejected_without_mutation( + state, + Step::Deposit { assets: 999 }, + VaultError::InvalidAmount, + ); + } + + #[test] + fn overflowing_deposit_is_rejected_without_mutation() { + let state = funded(i128::MAX, 2); + assert_rejected_without_mutation( + state, + Step::Deposit { assets: i128::MAX }, + VaultError::MathOverflow, + ); + } + + // ── Sequences interrupted mid-flight ──────────────────────────────────── + + #[test] + fn sequence_halts_at_the_failing_step_and_keeps_prior_commits() { + let outcome = apply_sequence( + &AccountingState::empty(), + &[ + Step::Deposit { assets: 1_000 }, + Step::SetFeeBps { bps: 500 }, + Step::AccrueYield { amount: 200 }, + Step::Withdraw { shares: 5_000 }, // fails: only 1_000 outstanding + Step::SetFeeBps { bps: 100 }, // never reached + ], + ); + + assert_eq!(outcome.applied, 3); + assert_eq!(outcome.halted_with, Some(VaultError::InsufficientShares)); + assert_eq!(outcome.state.total_shares, 1_000); + assert_eq!(outcome.state.total_assets, 1_190); + assert_eq!(outcome.state.treasury_balance, 10); + assert_eq!( + outcome.state.fee_bps, 500, + "the fee change committed before the failure must survive" + ); + assert_eq!(check_invariants(&outcome.state), Ok(())); + } + + #[test] + fn interrupted_sequence_state_is_valid_and_the_retry_succeeds() { + let interrupted = apply_sequence( + &AccountingState::empty(), + &[ + Step::Deposit { assets: 1_000 }, + Step::Withdraw { shares: 2_000 }, // operator typo + ], + ); + assert_eq!( + interrupted.halted_with, + Some(VaultError::InsufficientShares) + ); + assert_eq!(check_invariants(&interrupted.state), Ok(())); + + // Operator corrects the amount and retries from where it stopped. + let retried = apply(&interrupted.state, Step::Withdraw { shares: 400 }).unwrap(); + assert_eq!(check_invariants(&retried), Ok(())); + + // Identical to a run where the bad step never happened. + let clean = apply_sequence( + &AccountingState::empty(), + &[ + Step::Deposit { assets: 1_000 }, + Step::Withdraw { shares: 400 }, + ], + ); + assert_eq!(clean.halted_with, None); + assert_eq!(retried, clean.state); + } + + #[test] + fn retrying_the_same_failing_step_is_idempotent() { + let state = funded(1_000, 1_000); + for _ in 0..5 { + assert_eq!( + apply(&state, Step::Withdraw { shares: 9_999 }), + Err(VaultError::InsufficientShares) + ); + } + assert_eq!(state, funded(1_000, 1_000)); + } + + #[test] + fn fee_change_between_accruals_does_not_retroactively_reprice() { + let outcome = apply_sequence( + &AccountingState::empty(), + &[ + Step::Deposit { assets: 10_000 }, + Step::AccrueYield { amount: 1_000 }, // fee 0% -> all to holders + Step::SetFeeBps { bps: 1_000 }, // 10% + Step::AccrueYield { amount: 1_000 }, // 100 to treasury + ], + ); + assert_eq!(outcome.halted_with, None); + assert_eq!(outcome.state.treasury_balance, 100); + assert_eq!(outcome.state.total_assets, 10_000 + 1_000 + 900); + } + + #[test] + fn invariants_hold_after_every_step_of_a_long_mixed_sequence() { + let steps = [ + Step::Deposit { assets: 5_000 }, + Step::SetFeeBps { bps: 250 }, + Step::AccrueYield { amount: 400 }, + Step::Deposit { assets: 2_500 }, + Step::Withdraw { shares: 1_000 }, + Step::AccrueYield { amount: 100 }, + Step::Withdraw { shares: 100_000 }, // fails + ]; + + let mut state = AccountingState::empty(); + for (i, step) in steps.iter().enumerate() { + match apply(&state, *step) { + Ok(next) => { + state = next; + assert_eq!( + check_invariants(&state), + Ok(()), + "invariant broke at step {i}" + ); + } + Err(_) => { + assert_eq!( + check_invariants(&state), + Ok(()), + "invariant broke after failed step {i}" + ); + } + } + } + } + + #[test] + fn round_trip_never_returns_more_than_was_deposited() { + let deposited = 7_777; + let after_deposit = apply( + &AccountingState::empty(), + Step::Deposit { assets: deposited }, + ) + .unwrap(); + let returned = assets_for_shares(&after_deposit, after_deposit.total_shares).unwrap(); + assert!( + returned <= deposited, + "round trip minted value: {returned} > {deposited}" + ); + } + + // ── Invariant checker itself ──────────────────────────────────────────── + + #[test] + fn invariant_checker_rejects_corrupt_states() { + assert_eq!( + check_invariants(&funded(-1, 0)), + Err(VaultError::InvalidAmount) + ); + assert_eq!( + check_invariants(&funded(1_000, 0)), + Err(VaultError::InsufficientShares), + "shares must always be backed by assets" + ); + assert_eq!( + check_invariants(&funded(0, 1_000)), + Err(VaultError::InvalidAmount), + "assets with no shares outstanding are unattributable" + ); + let mut bad_fee = funded(1, 1); + bad_fee.fee_bps = 10_001; + assert_eq!(check_invariants(&bad_fee), Err(VaultError::InvalidFeeBps)); + } +} diff --git a/contracts/vault/src/telemetry.rs b/contracts/vault/src/telemetry.rs new file mode 100644 index 00000000..6c59a7bf --- /dev/null +++ b/contracts/vault/src/telemetry.rs @@ -0,0 +1,307 @@ +//! Gated contract telemetry and debugging hooks (Issue #1174). +//! +//! Diagnosing a vault incident from the outside means reconstructing state from +//! a dozen separate getter calls, each a round trip, none of them consistent +//! with one another. This module exposes a single consistent snapshot of the +//! vault's high-value accounting plus a derived health classification, so an +//! operator can answer "what is the vault doing right now" in one call. +//! +//! ## Gating +//! +//! Diagnostics are **off by default** and are turned on by the admin via +//! `set_diagnostics_enabled`. When disabled, [`require_enabled`] rejects the +//! read. This keeps the hook out of the default attack surface and makes +//! enabling it an auditable, admin-authorised action. +//! +//! ## What is deliberately *not* exposed +//! +//! The snapshot carries **aggregates only**. It contains no addresses, no +//! per-user balances, no pending-proposal contents, and no oracle credentials — +//! nothing that is not already derivable from the vault's public getters. See +//! [`DIAGNOSTIC_FIELD_POLICY`] and the `redacts_*` tests below, which exist to +//! fail loudly if a future field breaks that rule. +//! +//! Operator usage is documented in `docs/runbooks/VAULT_DIAGNOSTICS.md`. + +use crate::errors::VaultError; +use soroban_sdk::contracttype; + +/// The contract of this module, asserted by tests rather than left to prose. +pub const DIAGNOSTIC_FIELD_POLICY: &str = + "aggregates only: no addresses, no per-user balances, no secrets"; + +/// Coarse health classification derived from a snapshot. +/// +/// Mirrors what an operator would conclude from the raw numbers, so alerting can +/// key off one field instead of re-deriving thresholds in every consumer. +#[contracttype] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[repr(u32)] +pub enum VaultHealth { + /// Operating normally. + Nominal = 0, + /// Withdrawals are queueing or idle liquidity is thin — degraded service, + /// but the accounting is sound. + LiquidityStressed = 1, + /// The vault is paused. No user-facing flows are running. + Halted = 2, + /// Accounting is internally inconsistent — shares outstanding with no assets + /// behind them, or a negative aggregate. Page someone. + Inconsistent = 3, +} + +/// A consistent, aggregate-only snapshot of vault state. +/// +/// Every field is a protocol-level total. Adding a field that identifies a user +/// or an external system is a policy violation — see the module docs. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct VaultDiagnostics { + /// Ledger sequence the snapshot was taken at. + pub ledger_sequence: u32, + /// Ledger timestamp the snapshot was taken at. + pub timestamp: u64, + /// Storage layout version currently deployed. + pub storage_version: u32, + /// Total shares outstanding. + pub total_shares: i128, + /// Idle assets held by the vault itself. + pub idle_assets: i128, + /// Share price scaled to 1e18, or `0` when no shares are outstanding. + pub share_price: i128, + /// Unclaimed protocol fees. + pub treasury_balance: i128, + /// Current protocol fee rate in basis points. + pub fee_bps: i128, + /// Entries waiting in the FIFO withdrawal queue. + pub withdrawal_queue_length: u64, + /// Whether the vault is paused. + pub paused: bool, + /// Derived health classification. + pub health: VaultHealth, +} + +/// Rejects a diagnostics read when the hook has not been enabled by the admin. +/// +/// # Errors +/// - [`VaultError::ContractPaused`] — diagnostics are disabled. The code is +/// reused rather than adding a 51st variant (the Soroban error enum is capped +/// at 50 cases); it reads as "this entry point is not currently open". +pub fn require_enabled(enabled: bool) -> Result<(), VaultError> { + if enabled { + Ok(()) + } else { + Err(VaultError::ContractPaused) + } +} + +/// Raw aggregates a caller collects before building a snapshot. +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub struct DiagnosticInputs { + pub ledger_sequence: u32, + pub timestamp: u64, + pub storage_version: u32, + pub total_shares: i128, + pub idle_assets: i128, + pub share_price: i128, + pub treasury_balance: i128, + pub fee_bps: i128, + pub withdrawal_queue_length: u64, + pub paused: bool, + /// Idle assets that must stay in the vault, used to detect liquidity stress. + pub min_liquidity_buffer: i128, +} + +/// Classifies vault health from raw aggregates. +/// +/// Ordering matters: inconsistency outranks a pause, because a paused vault with +/// broken accounting is still broken and must not be reported as merely halted. +pub fn classify_health(inputs: &DiagnosticInputs) -> VaultHealth { + let negative = inputs.total_shares < 0 + || inputs.idle_assets < 0 + || inputs.treasury_balance < 0 + || inputs.share_price < 0; + let unbacked = inputs.total_shares > 0 && inputs.share_price == 0; + if negative || unbacked { + return VaultHealth::Inconsistent; + } + if inputs.paused { + return VaultHealth::Halted; + } + if inputs.withdrawal_queue_length > 0 || inputs.idle_assets < inputs.min_liquidity_buffer { + return VaultHealth::LiquidityStressed; + } + VaultHealth::Nominal +} + +/// Builds a snapshot from raw aggregates, classifying health along the way. +/// +/// Pure and total: it never reads storage and never fails, so a diagnostics call +/// cannot itself become an incident. +pub fn build_snapshot(inputs: &DiagnosticInputs) -> VaultDiagnostics { + VaultDiagnostics { + ledger_sequence: inputs.ledger_sequence, + timestamp: inputs.timestamp, + storage_version: inputs.storage_version, + total_shares: inputs.total_shares, + idle_assets: inputs.idle_assets, + share_price: inputs.share_price, + treasury_balance: inputs.treasury_balance, + fee_bps: inputs.fee_bps, + withdrawal_queue_length: inputs.withdrawal_queue_length, + paused: inputs.paused, + health: classify_health(inputs), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn nominal() -> DiagnosticInputs { + DiagnosticInputs { + ledger_sequence: 42, + timestamp: 1_700_000_000, + storage_version: 3, + total_shares: 1_000, + idle_assets: 1_000, + share_price: 1_000_000_000_000_000_000, + treasury_balance: 25, + fee_bps: 500, + withdrawal_queue_length: 0, + paused: false, + min_liquidity_buffer: 0, + } + } + + // ── Gating ────────────────────────────────────────────────────────────── + + #[test] + fn diagnostics_are_rejected_when_the_hook_is_disabled() { + assert_eq!(require_enabled(false), Err(VaultError::ContractPaused)); + } + + #[test] + fn diagnostics_are_allowed_once_enabled() { + assert_eq!(require_enabled(true), Ok(())); + } + + // ── Health classification ─────────────────────────────────────────────── + + #[test] + fn healthy_vault_reports_nominal() { + assert_eq!(classify_health(&nominal()), VaultHealth::Nominal); + } + + #[test] + fn paused_vault_reports_halted() { + let mut inputs = nominal(); + inputs.paused = true; + assert_eq!(classify_health(&inputs), VaultHealth::Halted); + } + + #[test] + fn queued_withdrawals_report_liquidity_stress() { + let mut inputs = nominal(); + inputs.withdrawal_queue_length = 3; + assert_eq!(classify_health(&inputs), VaultHealth::LiquidityStressed); + } + + #[test] + fn idle_below_the_buffer_reports_liquidity_stress() { + let mut inputs = nominal(); + inputs.min_liquidity_buffer = 5_000; + assert_eq!(classify_health(&inputs), VaultHealth::LiquidityStressed); + } + + #[test] + fn shares_with_a_zero_share_price_report_inconsistent() { + let mut inputs = nominal(); + inputs.share_price = 0; + assert_eq!(classify_health(&inputs), VaultHealth::Inconsistent); + } + + #[test] + fn negative_aggregates_report_inconsistent() { + for mutate in [ + (|i: &mut DiagnosticInputs| i.total_shares = -1) as fn(&mut DiagnosticInputs), + |i: &mut DiagnosticInputs| i.idle_assets = -1, + |i: &mut DiagnosticInputs| i.treasury_balance = -1, + |i: &mut DiagnosticInputs| i.share_price = -1, + ] { + let mut inputs = nominal(); + mutate(&mut inputs); + assert_eq!(classify_health(&inputs), VaultHealth::Inconsistent); + } + } + + #[test] + fn inconsistency_outranks_a_pause() { + let mut inputs = nominal(); + inputs.paused = true; + inputs.total_shares = -1; + assert_eq!( + classify_health(&inputs), + VaultHealth::Inconsistent, + "a paused vault with broken accounting is still broken" + ); + } + + #[test] + fn an_empty_vault_is_nominal_not_inconsistent() { + let mut inputs = nominal(); + inputs.total_shares = 0; + inputs.idle_assets = 0; + inputs.share_price = 0; // defined as zero with no shares outstanding + assert_eq!(classify_health(&inputs), VaultHealth::Nominal); + } + + // ── Snapshot construction ─────────────────────────────────────────────── + + #[test] + fn snapshot_carries_every_input_through_unchanged() { + let inputs = nominal(); + let snap = build_snapshot(&inputs); + assert_eq!(snap.ledger_sequence, inputs.ledger_sequence); + assert_eq!(snap.timestamp, inputs.timestamp); + assert_eq!(snap.storage_version, inputs.storage_version); + assert_eq!(snap.total_shares, inputs.total_shares); + assert_eq!(snap.idle_assets, inputs.idle_assets); + assert_eq!(snap.share_price, inputs.share_price); + assert_eq!(snap.treasury_balance, inputs.treasury_balance); + assert_eq!(snap.fee_bps, inputs.fee_bps); + assert_eq!(snap.withdrawal_queue_length, inputs.withdrawal_queue_length); + assert_eq!(snap.paused, inputs.paused); + assert_eq!(snap.health, VaultHealth::Nominal); + } + + #[test] + fn snapshot_is_deterministic_for_identical_inputs() { + let inputs = nominal(); + assert_eq!(build_snapshot(&inputs), build_snapshot(&inputs)); + } + + /// Guards [`DIAGNOSTIC_FIELD_POLICY`]. If a future change adds an + /// `Address`, a per-user balance, or an oracle endpoint to the snapshot, + /// the struct will no longer round-trip through this aggregate-only + /// construction and this test will stop compiling — which is the point. + #[test] + fn snapshot_exposes_aggregates_only() { + let inputs = nominal(); + let snap = build_snapshot(&inputs); + let VaultDiagnostics { + ledger_sequence: _, + timestamp: _, + storage_version: _, + total_shares: _, + idle_assets: _, + share_price: _, + treasury_balance: _, + fee_bps: _, + withdrawal_queue_length: _, + paused: _, + health: _, + } = snap; + assert!(DIAGNOSTIC_FIELD_POLICY.contains("no addresses")); + } +}