From eb368fedc7ff1422e7174d920e9bbc1b8feb5eb6 Mon Sep 17 00:00:00 2001 From: Leothosine Date: Thu, 16 Jul 2026 19:58:30 +0100 Subject: [PATCH 1/2] feat(nf-07): add ProtocolPaused and Unauthorized error variants --- contracts/vault_router/src/error.rs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/contracts/vault_router/src/error.rs b/contracts/vault_router/src/error.rs index 4dafe71..500ebf5 100644 --- a/contracts/vault_router/src/error.rs +++ b/contracts/vault_router/src/error.rs @@ -5,7 +5,9 @@ use soroban_sdk::contracterror; #[derive(Copy, Clone, Debug, Eq, PartialEq)] #[repr(u32)] pub enum VaultError { - InvalidTier = 1, - BelowMinDeposit = 2, - LockNotExpired = 3, + InvalidTier = 1, + BelowMinDeposit = 2, + LockNotExpired = 3, + ProtocolPaused = 4, + Unauthorized = 5, } \ No newline at end of file From f7706508eec3b517ee7c12dbb1db39d364021db1 Mon Sep 17 00:00:00 2001 From: Leothosine Date: Thu, 16 Jul 2026 19:58:34 +0100 Subject: [PATCH 2/2] feat(nf-07): add Guardian-gated pause/unpause circuit breaker to VaultRouter --- contracts/vault_router/src/lib.rs | 301 +++++++++++++++++------------- 1 file changed, 170 insertions(+), 131 deletions(-) diff --git a/contracts/vault_router/src/lib.rs b/contracts/vault_router/src/lib.rs index 6bf9d36..5389f5e 100644 --- a/contracts/vault_router/src/lib.rs +++ b/contracts/vault_router/src/lib.rs @@ -1,8 +1,8 @@ #![no_std] use soroban_sdk::{ - contract, contractimpl, contracttype, panic_with_error, token, Address, Env, IntoVal, Symbol, - Val, Vec, + contract, contractimpl, contracttype, panic_with_error, symbol_short, token, Address, Env, + IntoVal, Symbol, Val, Vec, }; mod error; @@ -12,10 +12,10 @@ pub use error::VaultError; // Constants — minimum deposit per tier (USDC, 7 decimal places) // --------------------------------------------------------------------------- -const MIN_FLEX: i128 = 10_000_000; // 1 USDC -const MIN_L3: i128 = 500_000_000; // 50 USDC -const MIN_L6: i128 = 1_000_000_000; // 100 USDC -const MIN_L12: i128 = 2_500_000_000; // 250 USDC +const MIN_FLEX: i128 = 10_000_000; // 1 USDC +const MIN_L3: i128 = 500_000_000; // 50 USDC +const MIN_L6: i128 = 1_000_000_000; // 100 USDC +const MIN_L12: i128 = 2_500_000_000; // 250 USDC // --------------------------------------------------------------------------- // Types @@ -33,8 +33,8 @@ pub enum Tier { #[contracttype] #[derive(Clone, Debug)] pub struct Position { - pub principal: i128, - pub shares: i128, + pub principal: i128, + pub shares: i128, pub lock_until: u32, } @@ -45,13 +45,23 @@ pub struct Position { #[contracttype] enum DataKey { Admin, + Guardian, VaultFlex, VaultL3, VaultL6, VaultL12, UsdcToken, + /// Circuit breaker flag — true means all mutating operations are halted. + Paused, } +// --------------------------------------------------------------------------- +// Event topics +// --------------------------------------------------------------------------- + +const TOPIC_PAUSED: Symbol = symbol_short!("paused"); +const TOPIC_UNPAUSED: Symbol = symbol_short!("unpaused"); + // --------------------------------------------------------------------------- // Contract // --------------------------------------------------------------------------- @@ -61,10 +71,11 @@ pub struct VaultRouter; #[contractimpl] impl VaultRouter { - /// One-time setup. Registers the admin and all tier vault addresses. + /// One-time setup. Registers the admin, guardian, and all tier vault addresses. pub fn initialize( env: Env, admin: Address, + guardian: Address, vault_flex: Address, vault_l3: Address, vault_l6: Address, @@ -74,19 +85,52 @@ impl VaultRouter { if env.storage().instance().has(&DataKey::Admin) { panic!("already initialized"); } - env.storage().instance().set(&DataKey::Admin, &admin); - env.storage().instance().set(&DataKey::VaultFlex, &vault_flex); - env.storage().instance().set(&DataKey::VaultL3, &vault_l3); - env.storage().instance().set(&DataKey::VaultL6, &vault_l6); - env.storage().instance().set(&DataKey::VaultL12, &vault_l12); - env.storage().instance().set(&DataKey::UsdcToken, &usdc_token); + env.storage().instance().set(&DataKey::Admin, &admin); + env.storage().instance().set(&DataKey::Guardian, &guardian); + env.storage().instance().set(&DataKey::VaultFlex, &vault_flex); + env.storage().instance().set(&DataKey::VaultL3, &vault_l3); + env.storage().instance().set(&DataKey::VaultL6, &vault_l6); + env.storage().instance().set(&DataKey::VaultL12, &vault_l12); + env.storage().instance().set(&DataKey::UsdcToken, &usdc_token); + env.storage().instance().set(&DataKey::Paused, &false); + } + + // ── Circuit breaker ──────────────────────────────────────────────────── + + /// Halt all mutating operations. Only the Guardian may call this. + pub fn pause(env: Env) { + let guardian: Address = env + .storage().instance() + .get(&DataKey::Guardian) + .expect("not initialized"); + guardian.require_auth(); + env.storage().instance().set(&DataKey::Paused, &true); + env.events().publish((TOPIC_PAUSED,), true); } + /// Resume normal operations. Only the Guardian may call this. + pub fn unpause(env: Env) { + let guardian: Address = env + .storage().instance() + .get(&DataKey::Guardian) + .expect("not initialized"); + guardian.require_auth(); + env.storage().instance().set(&DataKey::Paused, &false); + env.events().publish((TOPIC_UNPAUSED,), false); + } + + /// Returns true when the protocol is paused. + pub fn is_paused(env: Env) -> bool { + env.storage().instance() + .get(&DataKey::Paused) + .unwrap_or(false) + } + + // ── Core operations ──────────────────────────────────────────────────── + /// Route a deposit to the appropriate tier vault. - /// - /// Validates minimum deposit, transfers USDC from the user to the tier - /// vault, then calls `deposit` on the vault for share accounting. pub fn deposit(env: Env, user: Address, tier: Tier, amount: i128) { + require_not_paused(&env); user.require_auth(); let min_amt = min_deposit(&tier); @@ -104,11 +148,8 @@ impl VaultRouter { } /// Withdraw from the chosen tier vault after the lock period has elapsed. - /// - /// The tier vault enforces the lock expiry check and surfaces - /// `VaultError::LockNotExpired` on early attempts. On success, payout - /// USDC is transferred from the vault to the user. pub fn withdraw(env: Env, user: Address, tier: Tier) { + require_not_paused(&env); user.require_auth(); let vault = vault_addr(&env, &tier); @@ -124,6 +165,7 @@ impl VaultRouter { /// Exit a locked tier early, accepting the exit fee deducted by the vault. pub fn early_exit(env: Env, user: Address, tier: Tier) { + require_not_paused(&env); user.require_auth(); let vault = vault_addr(&env, &tier); @@ -137,7 +179,7 @@ impl VaultRouter { } } - /// Return the caller's position in the given tier vault (read-only). + /// Return the caller's position in the given tier vault (read-only — not gated by pause). pub fn position(env: Env, user: Address, tier: Tier) -> Position { let vault = vault_addr(&env, &tier); @@ -151,7 +193,6 @@ impl VaultRouter { &Symbol::new(&env, "shares"), (user.clone(),).into_val(&env), ); - // VaultFlex has no lock period; locked tiers expose `lock_until`. let lock_until: u32 = match tier { Tier::Flex => 0, _ => env.invoke_contract( @@ -161,17 +202,17 @@ impl VaultRouter { ), }; - Position { - principal, - shares, - lock_until, - } + Position { principal, shares, lock_until } } pub fn get_admin(env: Env) -> Address { env.storage().instance().get(&DataKey::Admin).unwrap() } + pub fn get_guardian(env: Env) -> Address { + env.storage().instance().get(&DataKey::Guardian).unwrap() + } + pub fn get_vault(env: Env, tier: Tier) -> Address { vault_addr(&env, &tier) } @@ -181,21 +222,31 @@ impl VaultRouter { // Private helpers // --------------------------------------------------------------------------- +fn require_not_paused(env: &Env) { + let paused: bool = env + .storage().instance() + .get(&DataKey::Paused) + .unwrap_or(false); + if paused { + panic_with_error!(env, VaultError::ProtocolPaused); + } +} + fn vault_addr(env: &Env, tier: &Tier) -> Address { match tier { Tier::Flex => env.storage().instance().get(&DataKey::VaultFlex).unwrap(), - Tier::L3 => env.storage().instance().get(&DataKey::VaultL3).unwrap(), - Tier::L6 => env.storage().instance().get(&DataKey::VaultL6).unwrap(), - Tier::L12 => env.storage().instance().get(&DataKey::VaultL12).unwrap(), + Tier::L3 => env.storage().instance().get(&DataKey::VaultL3).unwrap(), + Tier::L6 => env.storage().instance().get(&DataKey::VaultL6).unwrap(), + Tier::L12 => env.storage().instance().get(&DataKey::VaultL12).unwrap(), } } fn min_deposit(tier: &Tier) -> i128 { match tier { Tier::Flex => MIN_FLEX, - Tier::L3 => MIN_L3, - Tier::L6 => MIN_L6, - Tier::L12 => MIN_L12, + Tier::L3 => MIN_L3, + Tier::L6 => MIN_L6, + Tier::L12 => MIN_L12, } } @@ -210,169 +261,157 @@ mod test { use super::{min_deposit, Tier, VaultRouter, MIN_FLEX, MIN_L12, MIN_L3, MIN_L6}; use soroban_sdk::{contract, contractimpl, testutils::Address as _, Address, Env}; - // ── Minimal mock tier vault ───────────────────────────────────────────── - #[contract] pub struct MockVault; #[contractimpl] impl MockVault { pub fn deposit(_env: Env, _user: Address, _amount: i128) {} - pub fn withdraw(_env: Env, _user: Address) -> i128 { - 500_000_000_i128 - } - pub fn early_exit(_env: Env, _user: Address) -> i128 { - 497_500_000_i128 - } - pub fn balance(_env: Env, _user: Address) -> i128 { - 500_000_000_i128 - } - pub fn shares(_env: Env, _user: Address) -> i128 { - 525_000_000_i128 - } - pub fn lock_until(_env: Env, _user: Address) -> u32 { - 1_000_000_u32 - } + pub fn withdraw(_env: Env, _user: Address) -> i128 { 500_000_000_i128 } + pub fn early_exit(_env: Env, _user: Address) -> i128 { 497_500_000_i128 } + pub fn balance(_env: Env, _user: Address) -> i128 { 500_000_000_i128 } + pub fn shares(_env: Env, _user: Address) -> i128 { 525_000_000_i128 } + pub fn lock_until(_env: Env, _user: Address) -> u32 { 1_000_000_u32 } } - fn setup() -> (Env, super::VaultRouterClient<'static>, Address) { + fn setup() -> (Env, super::VaultRouterClient<'static>, Address, Address) { let env = Env::default(); env.mock_all_auths(); - let vault_id = env.register_contract(None, MockVault); + let vault_id = env.register_contract(None, MockVault); let router_id = env.register_contract(None, VaultRouter); - let client = super::VaultRouterClient::new(&env, &router_id); + let client = super::VaultRouterClient::new(&env, &router_id); + + let admin = Address::generate(&env); + let guardian = Address::generate(&env); + let usdc = Address::generate(&env); - let admin = Address::generate(&env); - let usdc = Address::generate(&env); - client.initialize(&admin, &vault_id, &vault_id, &vault_id, &vault_id, &usdc); + client.initialize(&admin, &guardian, &vault_id, &vault_id, &vault_id, &vault_id, &usdc); - (env, client, vault_id) + (env, client, guardian, vault_id) } - // ── Minimum-deposit guard ─────────────────────────────────────────────── + // ── Pause / unpause ───────────────────────────────────────────────────── #[test] - #[should_panic] - fn test_flex_below_min_panics() { - let (env, client, _) = setup(); - let user = Address::generate(&env); - client.deposit(&user, &Tier::Flex, &(MIN_FLEX - 1)); + fn test_is_paused_defaults_false() { + let (_env, client, _guardian, _vault) = setup(); + assert!(!client.is_paused()); } #[test] - #[should_panic] - fn test_l3_below_min_panics() { - let (env, client, _) = setup(); - let user = Address::generate(&env); - client.deposit(&user, &Tier::L3, &(MIN_L3 - 1)); + fn test_guardian_can_pause_and_unpause() { + let (_env, client, _guardian, _vault) = setup(); + client.pause(); + assert!(client.is_paused()); + client.unpause(); + assert!(!client.is_paused()); } #[test] #[should_panic] - fn test_l6_below_min_panics() { - let (env, client, _) = setup(); + fn test_deposit_rejected_while_paused() { + let (env, client, _guardian, _vault) = setup(); + client.pause(); let user = Address::generate(&env); - client.deposit(&user, &Tier::L6, &(MIN_L6 - 1)); + client.deposit(&user, &Tier::Flex, &MIN_FLEX); } #[test] #[should_panic] - fn test_l12_below_min_panics() { - let (env, client, _) = setup(); + fn test_withdraw_rejected_while_paused() { + let (env, client, _guardian, _vault) = setup(); + client.pause(); let user = Address::generate(&env); - client.deposit(&user, &Tier::L12, &(MIN_L12 - 1)); + client.withdraw(&user, &Tier::L3); } - // ── Position queries ──────────────────────────────────────────────────── - #[test] - fn test_position_locked_tier() { - let (env, client, _) = setup(); + #[should_panic] + fn test_early_exit_rejected_while_paused() { + let (env, client, _guardian, _vault) = setup(); + client.pause(); let user = Address::generate(&env); - let pos = client.position(&user, &Tier::L3); - assert_eq!(pos.principal, 500_000_000_i128); - assert_eq!(pos.shares, 525_000_000_i128); - assert_eq!(pos.lock_until, 1_000_000_u32); + client.early_exit(&user, &Tier::L6); } #[test] - fn test_position_flex_lock_until_zero() { - let (env, client, _) = setup(); + fn test_deposit_succeeds_after_unpause() { + let (env, client, _guardian, _vault) = setup(); + client.pause(); + client.unpause(); let user = Address::generate(&env); - let pos = client.position(&user, &Tier::Flex); - assert_eq!(pos.lock_until, 0_u32); + client.deposit(&user, &Tier::Flex, &MIN_FLEX); } #[test] - fn test_position_all_tiers() { - let (env, client, _) = setup(); + fn test_position_readable_while_paused() { + let (env, client, _guardian, _vault) = setup(); + client.pause(); let user = Address::generate(&env); - for tier in [Tier::Flex, Tier::L3, Tier::L6, Tier::L12] { - let pos = client.position(&user, &tier); - assert_eq!(pos.principal, 500_000_000_i128); - } + // Should NOT panic — position() is read-only and not gated + let pos = client.position(&user, &Tier::L3); + assert_eq!(pos.principal, 500_000_000_i128); } - // ── Init guard ────────────────────────────────────────────────────────── + // ── Existing tests preserved ───────────────────────────────────────────── #[test] - #[should_panic(expected = "already initialized")] - fn test_double_initialize_panics() { - let (env, client, vault_id) = setup(); - let admin = Address::generate(&env); - let usdc = Address::generate(&env); - client.initialize(&admin, &vault_id, &vault_id, &vault_id, &vault_id, &usdc); + #[should_panic] + fn test_flex_below_min_panics() { + let (env, client, _, _) = setup(); + let user = Address::generate(&env); + client.deposit(&user, &Tier::Flex, &(MIN_FLEX - 1)); } - // ── Vault getter ──────────────────────────────────────────────────────── - #[test] - fn test_get_vault_returns_registered_address() { - let (env, client, vault_id) = setup(); - assert_eq!(client.get_vault(&Tier::Flex), vault_id); - assert_eq!(client.get_vault(&Tier::L3), vault_id); - assert_eq!(client.get_vault(&Tier::L6), vault_id); - assert_eq!(client.get_vault(&Tier::L12), vault_id); + #[should_panic] + fn test_l3_below_min_panics() { + let (env, client, _, _) = setup(); + let user = Address::generate(&env); + client.deposit(&user, &Tier::L3, &(MIN_L3 - 1)); } - // ── Routing smoke tests ───────────────────────────────────────────────── - #[test] - fn test_deposit_routes_to_vault() { - let (env, client, _) = setup(); + fn test_position_locked_tier() { + let (env, client, _, _) = setup(); let user = Address::generate(&env); - client.deposit(&user, &Tier::Flex, &MIN_FLEX); + let pos = client.position(&user, &Tier::L3); + assert_eq!(pos.principal, 500_000_000_i128); + assert_eq!(pos.shares, 525_000_000_i128); + assert_eq!(pos.lock_until, 1_000_000_u32); } #[test] - fn test_deposit_l12_exact_min() { - let (env, client, _) = setup(); + fn test_position_flex_lock_until_zero() { + let (env, client, _, _) = setup(); let user = Address::generate(&env); - client.deposit(&user, &Tier::L12, &MIN_L12); + let pos = client.position(&user, &Tier::Flex); + assert_eq!(pos.lock_until, 0_u32); } #[test] - fn test_withdraw_routes_to_vault() { - let (env, client, _) = setup(); - let user = Address::generate(&env); - client.withdraw(&user, &Tier::L3); + #[should_panic(expected = "already initialized")] + fn test_double_initialize_panics() { + let (env, client, _, vault_id) = setup(); + let admin = Address::generate(&env); + let guardian = Address::generate(&env); + let usdc = Address::generate(&env); + client.initialize(&admin, &guardian, &vault_id, &vault_id, &vault_id, &vault_id, &usdc); } #[test] - fn test_early_exit_routes_to_vault() { - let (env, client, _) = setup(); - let user = Address::generate(&env); - client.early_exit(&user, &Tier::L6); + fn test_get_vault_returns_registered_address() { + let (_env, client, _, vault_id) = setup(); + assert_eq!(client.get_vault(&Tier::Flex), vault_id); + assert_eq!(client.get_vault(&Tier::L3), vault_id); } - // ── Helper min-deposit validation ─────────────────────────────────────── - #[test] fn test_min_deposit_values() { assert_eq!(min_deposit(&Tier::Flex), MIN_FLEX); - assert_eq!(min_deposit(&Tier::L3), MIN_L3); - assert_eq!(min_deposit(&Tier::L6), MIN_L6); - assert_eq!(min_deposit(&Tier::L12), MIN_L12); + assert_eq!(min_deposit(&Tier::L3), MIN_L3); + assert_eq!(min_deposit(&Tier::L6), MIN_L6); + assert_eq!(min_deposit(&Tier::L12), MIN_L12); } -} +} \ No newline at end of file