diff --git a/contracts/src/errors.rs b/contracts/src/errors.rs index bbab83b9..e49c40d4 100644 --- a/contracts/src/errors.rs +++ b/contracts/src/errors.rs @@ -210,15 +210,24 @@ pub enum SavingsError { /// Config initialization can only happen once. ConfigAlreadyInitialized = 91, - // ========== Strategy Errors (92-99) ========== - /// Returned when a yield strategy is not found in the registry. - StrategyNotFound = 92, + /// Returned when attempting to operate on a disabled strategy. + /// + /// Emergency withdraw has been executed and strategy is now disabled. + StrategyDisabled = 92, - /// Returned when attempting to register a strategy that already exists. - StrategyAlreadyRegistered = 93, + /// Returned when the specified strategy does not exist. + /// + /// This occurs when querying a non-existent strategy. + StrategyNotFound = 93, - /// Returned when attempting to deposit into a disabled strategy. - StrategyDisabled = 94, + /// Returned when attempting to withdraw from a plan that has already been withdrawn. + AlreadyWithdrawn = 94, + + /// Returned when the specified lock plan does not exist. + LockNotFound = 95, + + /// Returned when attempting to register a strategy that already exists. + StrategyAlreadyRegistered = 96, } #[cfg(test)] diff --git a/contracts/src/lib.rs b/contracts/src/lib.rs index 03cac8a7..cb9bca3f 100644 --- a/contracts/src/lib.rs +++ b/contracts/src/lib.rs @@ -508,6 +508,177 @@ impl NesteraContract { Ok(()) } + // ========== Emergency Functions ========== + + /// Emergency withdraw - allows governance to force withdraw all funds from a strategy + /// and disable it for security. This bypasses normal withdrawal restrictions. + /// + /// # Arguments + /// * `admin` - The admin address (must be governance) + /// * `user` - The user who owns the strategy + /// * `plan_type` - The type of plan (Flexi, Lock, Goal, Group) + /// * `plan_id` - The ID of the plan to withdraw from + /// + /// # Returns + /// * The amount withdrawn + pub fn emergency_withdraw( + env: Env, + admin: Address, + user: Address, + plan_type: PlanType, + plan_id: u64, + ) -> Result { + // 1. Verify admin authorization + admin.require_auth(); + let stored_admin: Option
= env.storage().instance().get(&DataKey::Admin); + if stored_admin != Some(admin) { + return Err(SavingsError::Unauthorized); + } + + // 2. Check if strategy is already disabled + let disabled_key = DataKey::DisabledStrategy(plan_type.clone(), plan_id); + if env.storage().persistent().has(&disabled_key) { + return Err(SavingsError::StrategyDisabled); + } + + // 3. Perform withdrawal based on plan type + let withdrawn_amount = match plan_type { + PlanType::Flexi => { + // For Flexi, withdraw the entire balance + let flexi_key = DataKey::FlexiBalance(user.clone()); + let balance: i128 = env.storage().persistent().get(&flexi_key).unwrap_or(0); + + if balance > 0 { + // Update flexi balance to 0 + env.storage().persistent().set(&flexi_key, &0i128); + + // Update user total balance + let user_key = DataKey::User(user.clone()); + if let Some(mut user_data) = + env.storage().persistent().get::(&user_key) + { + user_data.total_balance = user_data.total_balance.saturating_sub(balance); + env.storage().persistent().set(&user_key, &user_data); + } + } + balance + } + PlanType::Lock(_) => { + // For Lock, get the lock save and withdraw if exists + let lock_key = DataKey::LockSave(plan_id); + let lock_opt: Option = env.storage().persistent().get(&lock_key); + + if let Some(mut lock) = lock_opt { + if lock.is_withdrawn { + return Err(SavingsError::AlreadyWithdrawn); + } + let amount = lock.amount; + lock.is_withdrawn = true; + env.storage().persistent().set(&lock_key, &lock); + + // Update user total balance + let user_key = DataKey::User(user.clone()); + if let Some(mut user_data) = + env.storage().persistent().get::(&user_key) + { + user_data.total_balance = user_data.total_balance.saturating_sub(amount); + env.storage().persistent().set(&user_key, &user_data); + } + amount + } else { + return Err(SavingsError::LockNotFound); + } + } + PlanType::Goal(_, _, _) => { + // For Goal, get the goal save and withdraw + let goal_key = DataKey::GoalSave(plan_id); + let goal_opt: Option = env.storage().persistent().get(&goal_key); + + if let Some(mut goal) = goal_opt { + if goal.is_withdrawn { + return Err(SavingsError::AlreadyWithdrawn); + } + let amount = goal.current_amount; + goal.is_withdrawn = true; + env.storage().persistent().set(&goal_key, &goal); + + // Update user total balance + let user_key = DataKey::User(user.clone()); + if let Some(mut user_data) = + env.storage().persistent().get::(&user_key) + { + user_data.total_balance = user_data.total_balance.saturating_sub(amount); + env.storage().persistent().set(&user_key, &user_data); + } + amount + } else { + return Err(SavingsError::PlanNotFound); + } + } + PlanType::Group(_, _, _, _) => { + // For Group, get the group save and process emergency break + let group_key = DataKey::GroupSave(plan_id); + let group_opt: Option = env.storage().persistent().get(&group_key); + + if let Some(mut group) = group_opt { + if group.is_completed { + return Err(SavingsError::PlanCompleted); + } + // Return current amount for the user + let contribution_key = DataKey::GroupMemberContribution(plan_id, user.clone()); + let contribution: i128 = env + .storage() + .persistent() + .get(&contribution_key) + .unwrap_or(0); + + if contribution > 0 { + // Clear user contribution + env.storage().persistent().set(&contribution_key, &0i128); + + // Update group current amount + group.current_amount = group.current_amount.saturating_sub(contribution); + env.storage().persistent().set(&group_key, &group); + + // Update user total balance + let user_key = DataKey::User(user.clone()); + if let Some(mut user_data) = + env.storage().persistent().get::(&user_key) + { + user_data.total_balance = + user_data.total_balance.saturating_sub(contribution); + env.storage().persistent().set(&user_key, &user_data); + } + } + contribution + } else { + return Err(SavingsError::PlanNotFound); + } + } + }; + + // 4. Mark strategy as disabled + env.storage().persistent().set(&disabled_key, &true); + ttl::extend_config_ttl(&env, &disabled_key); + + // 5. Emit event + env.events().publish( + (Symbol::new(&env, "emergency_withdraw"), user, plan_id), + withdrawn_amount, + ); + + Ok(withdrawn_amount) + } + + /// Checks if a strategy is disabled + pub fn is_strategy_disabled(env: Env, plan_type: PlanType, plan_id: u64) -> bool { + let disabled_key = DataKey::DisabledStrategy(plan_type, plan_id); + env.storage() + .persistent() + .get(&disabled_key) + .unwrap_or(false) + } + // --- Remaining views and utilities --- pub fn get_savings_plan(env: Env, user: Address, plan_id: u64) -> Option { env.storage() diff --git a/contracts/src/storage_types.rs b/contracts/src/storage_types.rs index b9dab41b..abdd1e36 100644 --- a/contracts/src/storage_types.rs +++ b/contracts/src/storage_types.rs @@ -93,6 +93,10 @@ pub enum SavingsError { LockNotMatured = 5, AlreadyWithdrawn = 6, Unauthorized = 7, + /// Returned when attempting to operate on a disabled strategy + StrategyDisabled = 8, + /// Returned when the specified strategy does not exist + StrategyNotFound = 9, } /// Represents a Goal Save plan with target amount @@ -190,6 +194,8 @@ pub enum DataKey { GroupRate, /// Maps duration (days) to interest rate LockRate(u64), + /// Maps (plan_type, plan_id) to disabled status + DisabledStrategy(PlanType, u64), } /// Payload structure that the admin signs off-chain