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"));
+ }
+}