Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 9 additions & 4 deletions contracts/vault/src/deposit_withdraw_props.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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);

Expand Down
4 changes: 2 additions & 2 deletions contracts/vault/src/event_tests.rs
Original file line number Diff line number Diff line change
@@ -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> {
Expand Down Expand Up @@ -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);
Expand Down
95 changes: 88 additions & 7 deletions contracts/vault/src/feature_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"
);
}

Expand Down Expand Up @@ -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);
Expand All @@ -332,31 +332,112 @@ 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();
assert!(vault.is_paused());
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)));
}
3 changes: 2 additions & 1 deletion contracts/vault/src/formal_verification_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Address> = (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;
Expand Down
82 changes: 64 additions & 18 deletions contracts/vault/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;

Expand Down Expand Up @@ -237,6 +240,9 @@ pub enum DataKeyExt {
PendingFeeBps,
PendingTreasury,
PendingPriceOracle,

// Issue #1174: gate for the contract telemetry / debugging hook
DiagnosticsEnabled,
}

#[contracttype]
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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::VaultDiagnostics, VaultError> {
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);
Expand Down
Loading
Loading