diff --git a/contracts/access_control/src/access_control.rs b/contracts/access_control/src/access_control.rs index 0d76967..4933e85 100644 --- a/contracts/access_control/src/access_control.rs +++ b/contracts/access_control/src/access_control.rs @@ -6,7 +6,7 @@ use soroban_sdk::{contracttype, symbol_short, Address, Env, IntoVal, Symbol, Vec use crate::errors::{AccessControlError, AccessControlResult}; use crate::types::{ AccessControlConfig, MembershipInfo, MultiSigConfig, PendingAdminTransfer, PendingProposal, - ProposalAction, SubscriptionTierLevel, UserRole, UserSubscriptionStatus, + ProposalAction, ProposalStats, SubscriptionTierLevel, UserRole, UserSubscriptionStatus, }; /// Storage keys for the access control module @@ -27,6 +27,11 @@ pub enum DataKey { // Tier-based access control keys UserTierLevel(Address), RequiredTierForRole(UserRole), + // Enhanced multisig keys + ProposalStats, + PendingProposalsList, + TimeLockExpiry(u64), + EmergencyMode, } pub struct AccessControlModule; @@ -79,11 +84,33 @@ impl AccessControlModule { return Err(AccessControlError::InvalidAddress); } + // Validate no duplicate admins + for i in 0..admins.len() { + for j in (i + 1)..admins.len() { + if admins.get(i).unwrap() == admins.get(j).unwrap() { + return Err(AccessControlError::DuplicateAdmin); + } + } + } + + // Set reasonable defaults for thresholds + let critical_threshold = (required_signatures + 1).min(admins.len()); + let emergency_threshold = (critical_threshold + 1).min(admins.len()); + let multisig_config = MultiSigConfig { admins: admins.clone(), required_signatures, + critical_threshold, + emergency_threshold, + time_lock_duration: 86400, // 24 hours default + max_pending_proposals: 50, + proposal_expiry_duration: 604800, // 7 days default }; + if !multisig_config.validate() { + return Err(AccessControlError::InvalidMultisigConfig); + } + env.storage() .persistent() .set(&DataKey::MultiSigConfig, &multisig_config); @@ -105,6 +132,24 @@ impl AccessControlModule { .persistent() .set(&DataKey::ProposalCounter, &0u64); + // Initialize proposal stats + let stats = ProposalStats { + total_created: 0, + total_executed: 0, + total_rejected: 0, + total_expired: 0, + pending_count: 0, + }; + env.storage() + .persistent() + .set(&DataKey::ProposalStats, &stats); + + // Initialize pending proposals list + let pending_list: Vec = Vec::new(env); + env.storage() + .persistent() + .set(&DataKey::PendingProposalsList, &pending_list); + // Emit multisig initialization event env.events().publish( (symbol_short!("ms_init"), required_signatures), @@ -607,22 +652,65 @@ impl AccessControlModule { ) -> AccessControlResult { Self::require_admin(env, &proposer)?; + let multisig_config = + Self::get_multisig_config(env).ok_or(AccessControlError::MultisigNotEnabled)?; + + // Check max pending proposals limit + let mut stats: ProposalStats = env + .storage() + .persistent() + .get(&DataKey::ProposalStats) + .unwrap_or(ProposalStats { + total_created: 0, + total_executed: 0, + total_rejected: 0, + total_expired: 0, + pending_count: 0, + }); + + if stats.pending_count >= multisig_config.max_pending_proposals { + return Err(AccessControlError::MaxProposalsReached); + } + let proposal_id: u64 = env .storage() .persistent() .get(&DataKey::ProposalCounter) .unwrap_or(0); + // Classify proposal type + let proposal_type = action.classify_type(); + + // Determine required signatures based on proposal type + let required_signatures = multisig_config.get_required_signatures(&proposal_type); + let mut approvals = Vec::new(env); approvals.push_back(proposer.clone()); // Proposer automatically approves + let rejections = Vec::new(env); + + let current_time = env.ledger().timestamp(); + let expiry = current_time + multisig_config.proposal_expiry_duration; + + // Calculate time-lock if required + let time_lock_until = if proposal_type.requires_time_lock() { + Some(current_time + multisig_config.time_lock_duration) + } else { + None + }; + let new_proposal = PendingProposal { id: proposal_id, proposer: proposer.clone(), - action, + action: action.clone(), + proposal_type: proposal_type.clone(), approvals, + rejections, executed: false, - expiry: env.ledger().timestamp() + 86400, // 24 hours + created_at: current_time, + expiry, + time_lock_until, + required_signatures, }; env.storage() @@ -633,14 +721,36 @@ impl AccessControlModule { .persistent() .set(&DataKey::ProposalCounter, &(proposal_id + 1)); - env.events() - .publish((symbol_short!("proposal"), proposal_id), proposer.clone()); + // Add to pending proposals list + let mut pending_list: Vec = env + .storage() + .persistent() + .get(&DataKey::PendingProposalsList) + .unwrap_or_else(|| Vec::new(env)); + pending_list.push_back(proposal_id); + env.storage() + .persistent() + .set(&DataKey::PendingProposalsList, &pending_list); - // Check if proposal can be executed immediately - if let Some(multisig_config) = Self::get_multisig_config(env) { - if new_proposal.approvals.len() >= multisig_config.required_signatures { - Self::execute_proposal(env, proposal_id)?; - } + // Update stats + stats.total_created += 1; + stats.pending_count += 1; + env.storage() + .persistent() + .set(&DataKey::ProposalStats, &stats); + + env.events().publish( + ( + symbol_short!("proposal"), + proposal_id, + proposal_type.clone(), + ), + proposer.clone(), + ); + + // Check if proposal can be executed immediately (only for non-time-locked proposals) + if time_lock_until.is_none() && new_proposal.approvals.len() >= required_signatures { + Self::execute_proposal(env, proposal_id)?; } Ok(proposal_id) @@ -657,18 +767,24 @@ impl AccessControlModule { .storage() .persistent() .get(&DataKey::Proposal(proposal_id)) - .ok_or(AccessControlError::InvalidAddress)?; + .ok_or(AccessControlError::ProposalNotFound)?; if proposal.executed { - return Err(AccessControlError::InvalidAddress); + return Err(AccessControlError::ProposalAlreadyExecuted); } if env.ledger().timestamp() > proposal.expiry { - return Err(AccessControlError::InvalidAddress); + // Clean up expired proposal + Self::cleanup_expired_proposal(env, proposal_id)?; + return Err(AccessControlError::ProposalExpired); } if proposal.approvals.contains(&approver) { - return Err(AccessControlError::InvalidAddress); + return Err(AccessControlError::AlreadyApproved); + } + + if proposal.rejections.contains(&approver) { + return Err(AccessControlError::AlreadyRejected); } proposal.approvals.push_back(approver.clone()); @@ -680,10 +796,18 @@ impl AccessControlModule { env.events() .publish((symbol_short!("approve"), proposal_id), approver.clone()); - if let Some(multisig_config) = Self::get_multisig_config(env) { - if proposal.approvals.len() >= multisig_config.required_signatures { - Self::execute_proposal(env, proposal_id)?; - } + // Check if we have enough approvals to execute + let can_execute = proposal.approvals.len() >= proposal.required_signatures; + + // Check time-lock + let time_lock_passed = if let Some(time_lock_until) = proposal.time_lock_until { + env.ledger().timestamp() >= time_lock_until + } else { + true + }; + + if can_execute && time_lock_passed { + Self::execute_proposal(env, proposal_id)?; } Ok(()) @@ -694,18 +818,30 @@ impl AccessControlModule { .storage() .persistent() .get(&DataKey::Proposal(proposal_id)) - .ok_or(AccessControlError::InvalidAddress)?; + .ok_or(AccessControlError::ProposalNotFound)?; if proposal.executed { - return Err(AccessControlError::InvalidAddress); + return Err(AccessControlError::ProposalAlreadyExecuted); } - if let Some(multisig_config) = Self::get_multisig_config(env) { - if proposal.approvals.len() < multisig_config.required_signatures { - return Err(AccessControlError::AdminRequired); + // Check if expired + if env.ledger().timestamp() > proposal.expiry { + Self::cleanup_expired_proposal(env, proposal_id)?; + return Err(AccessControlError::ProposalExpired); + } + + // Check if time-lock has passed + if let Some(time_lock_until) = proposal.time_lock_until { + if env.ledger().timestamp() < time_lock_until { + return Err(AccessControlError::TimeLockActive); } } + // Validate signatures + if proposal.approvals.len() < proposal.required_signatures { + return Err(AccessControlError::InsufficientApprovals); + } + proposal.executed = true; env.storage() .persistent() @@ -746,9 +882,110 @@ impl AccessControlModule { proposal.proposer.clone(), ); } - _ => return Err(AccessControlError::InvalidAddress), + ProposalAction::UpdateMultisigConfig(new_config) => { + if !new_config.validate() { + return Err(AccessControlError::InvalidMultisigConfig); + } + env.storage() + .persistent() + .set(&DataKey::MultiSigConfig, &new_config); + + env.events().publish( + (symbol_short!("ms_upd"), new_config.clone()), + proposal.proposer.clone(), + ); + } + ProposalAction::EmergencyPause(reason) => { + env.storage().persistent().set(&DataKey::Paused, &true); + env.storage() + .persistent() + .set(&DataKey::EmergencyMode, &true); + + env.events().publish( + (symbol_short!("emrg_pse"), reason), + proposal.proposer.clone(), + ); + } + ProposalAction::BatchBlacklist(users) => { + for user in users.iter() { + env.storage() + .persistent() + .set(&DataKey::Blacklisted(user.clone()), &true); + } + + env.events().publish( + (symbol_short!("batch_bl"), users.len()), + proposal.proposer.clone(), + ); + } + ProposalAction::AddAdmin(new_admin) => { + if let Some(mut multisig_config) = Self::get_multisig_config(env) { + if multisig_config.admins.contains(&new_admin) { + return Err(AccessControlError::DuplicateAdmin); + } + multisig_config.admins.push_back(new_admin.clone()); + env.storage() + .persistent() + .set(&DataKey::MultiSigConfig, &multisig_config); + env.storage() + .persistent() + .set(&DataKey::UserRole(new_admin.clone()), &UserRole::Admin); + + env.events().publish( + (symbol_short!("add_adm"), new_admin), + proposal.proposer.clone(), + ); + } + } + ProposalAction::RemoveAdmin(admin_to_remove) => { + if let Some(mut multisig_config) = Self::get_multisig_config(env) { + if multisig_config.admins.len() <= multisig_config.emergency_threshold { + return Err(AccessControlError::CannotRemoveLastAdmin); + } + + let mut new_admins = Vec::new(env); + for admin in multisig_config.admins.iter() { + if admin != admin_to_remove { + new_admins.push_back(admin); + } + } + multisig_config.admins = new_admins; + env.storage() + .persistent() + .set(&DataKey::MultiSigConfig, &multisig_config); + env.storage().persistent().set( + &DataKey::UserRole(admin_to_remove.clone()), + &UserRole::Guest, + ); + + env.events().publish( + (symbol_short!("rm_adm"), admin_to_remove), + proposal.proposer.clone(), + ); + } + } + _ => return Err(AccessControlError::InvalidProposalType), } + // Remove from pending list and update stats + Self::remove_from_pending_list(env, proposal_id); + let mut stats: ProposalStats = env + .storage() + .persistent() + .get(&DataKey::ProposalStats) + .unwrap_or(ProposalStats { + total_created: 0, + total_executed: 0, + total_rejected: 0, + total_expired: 0, + pending_count: 0, + }); + stats.total_executed += 1; + stats.pending_count = stats.pending_count.saturating_sub(1); + env.storage() + .persistent() + .set(&DataKey::ProposalStats, &stats); + env.events().publish( (symbol_short!("executed"), proposal_id), proposal.proposer.clone(), @@ -757,6 +994,285 @@ impl AccessControlModule { Ok(()) } + // ============================================================================ + // Enhanced Multisig Helper Functions + // ============================================================================ + + /// Reject a proposal (vote against it) + pub fn reject_proposal( + env: &Env, + rejecter: Address, + proposal_id: u64, + ) -> AccessControlResult<()> { + Self::require_admin(env, &rejecter)?; + + let mut proposal: PendingProposal = env + .storage() + .persistent() + .get(&DataKey::Proposal(proposal_id)) + .ok_or(AccessControlError::ProposalNotFound)?; + + if proposal.executed { + return Err(AccessControlError::ProposalAlreadyExecuted); + } + + if env.ledger().timestamp() > proposal.expiry { + Self::cleanup_expired_proposal(env, proposal_id)?; + return Err(AccessControlError::ProposalExpired); + } + + if proposal.rejections.contains(&rejecter) { + return Err(AccessControlError::AlreadyRejected); + } + + if proposal.approvals.contains(&rejecter) { + return Err(AccessControlError::AlreadyApproved); + } + + proposal.rejections.push_back(rejecter.clone()); + + // Check if rejection threshold reached (e.g., if more than 1/3 reject, proposal fails) + let multisig_config = + Self::get_multisig_config(env).ok_or(AccessControlError::MultisigNotEnabled)?; + let rejection_threshold = (multisig_config.admins.len() / 3).max(1); + + if proposal.rejections.len() > rejection_threshold { + // Proposal rejected - clean it up + Self::remove_from_pending_list(env, proposal_id); + env.storage() + .persistent() + .remove(&DataKey::Proposal(proposal_id)); + + let mut stats: ProposalStats = env + .storage() + .persistent() + .get(&DataKey::ProposalStats) + .unwrap_or(ProposalStats { + total_created: 0, + total_executed: 0, + total_rejected: 0, + total_expired: 0, + pending_count: 0, + }); + stats.total_rejected += 1; + stats.pending_count = stats.pending_count.saturating_sub(1); + env.storage() + .persistent() + .set(&DataKey::ProposalStats, &stats); + + env.events() + .publish((symbol_short!("rejected"), proposal_id), rejecter.clone()); + + return Err(AccessControlError::ProposalRejected); + } + + env.storage() + .persistent() + .set(&DataKey::Proposal(proposal_id), &proposal); + + env.events() + .publish((symbol_short!("reject"), proposal_id), rejecter.clone()); + + Ok(()) + } + + /// Cancel a proposal (proposer only) + pub fn cancel_proposal( + env: &Env, + proposer: Address, + proposal_id: u64, + ) -> AccessControlResult<()> { + let proposal: PendingProposal = env + .storage() + .persistent() + .get(&DataKey::Proposal(proposal_id)) + .ok_or(AccessControlError::ProposalNotFound)?; + + if proposal.proposer != proposer { + return Err(AccessControlError::Unauthorized); + } + + if proposal.executed { + return Err(AccessControlError::ProposalAlreadyExecuted); + } + + Self::remove_from_pending_list(env, proposal_id); + env.storage() + .persistent() + .remove(&DataKey::Proposal(proposal_id)); + + let mut stats: ProposalStats = env + .storage() + .persistent() + .get(&DataKey::ProposalStats) + .unwrap_or(ProposalStats { + total_created: 0, + total_executed: 0, + total_rejected: 0, + total_expired: 0, + pending_count: 0, + }); + stats.pending_count = stats.pending_count.saturating_sub(1); + env.storage() + .persistent() + .set(&DataKey::ProposalStats, &stats); + + env.events() + .publish((symbol_short!("cancelled"), proposal_id), proposer.clone()); + + Ok(()) + } + + /// Get proposal details + pub fn get_proposal(env: &Env, proposal_id: u64) -> Option { + env.storage() + .persistent() + .get(&DataKey::Proposal(proposal_id)) + } + + /// Get all pending proposal IDs + pub fn get_pending_proposals(env: &Env) -> Vec { + env.storage() + .persistent() + .get(&DataKey::PendingProposalsList) + .unwrap_or_else(|| Vec::new(env)) + } + + /// Get proposal statistics + pub fn get_proposal_stats(env: &Env) -> ProposalStats { + env.storage() + .persistent() + .get(&DataKey::ProposalStats) + .unwrap_or(ProposalStats { + total_created: 0, + total_executed: 0, + total_rejected: 0, + total_expired: 0, + pending_count: 0, + }) + } + + /// Clean up expired proposals (can be called by anyone) + pub fn cleanup_expired_proposals(env: &Env) -> AccessControlResult { + let pending_list: Vec = env + .storage() + .persistent() + .get(&DataKey::PendingProposalsList) + .unwrap_or_else(|| Vec::new(env)); + + let current_time = env.ledger().timestamp(); + let mut cleaned_count = 0u32; + + for proposal_id in pending_list.iter() { + if let Some(proposal) = env + .storage() + .persistent() + .get::(&DataKey::Proposal(proposal_id)) + { + if current_time > proposal.expiry && !proposal.executed { + Self::cleanup_expired_proposal(env, proposal_id)?; + cleaned_count += 1; + } + } + } + + Ok(cleaned_count) + } + + fn cleanup_expired_proposal(env: &Env, proposal_id: u64) -> AccessControlResult<()> { + Self::remove_from_pending_list(env, proposal_id); + env.storage() + .persistent() + .remove(&DataKey::Proposal(proposal_id)); + + let mut stats: ProposalStats = env + .storage() + .persistent() + .get(&DataKey::ProposalStats) + .unwrap_or(ProposalStats { + total_created: 0, + total_executed: 0, + total_rejected: 0, + total_expired: 0, + pending_count: 0, + }); + stats.total_expired += 1; + stats.pending_count = stats.pending_count.saturating_sub(1); + env.storage() + .persistent() + .set(&DataKey::ProposalStats, &stats); + + env.events() + .publish((symbol_short!("expired"), proposal_id), ()); + + Ok(()) + } + + fn remove_from_pending_list(env: &Env, proposal_id: u64) { + let pending_list: Vec = env + .storage() + .persistent() + .get(&DataKey::PendingProposalsList) + .unwrap_or_else(|| Vec::new(env)); + + let mut new_list = Vec::new(env); + for id in pending_list.iter() { + if id != proposal_id { + new_list.push_back(id); + } + } + + env.storage() + .persistent() + .set(&DataKey::PendingProposalsList, &new_list); + } + + /// Update multisig configuration (requires proposal in multisig mode) + pub fn update_multisig_config( + env: &Env, + caller: Address, + new_config: MultiSigConfig, + ) -> AccessControlResult<()> { + Self::require_admin(env, &caller)?; + + if !Self::is_multisig_enabled(env) { + return Err(AccessControlError::MultisigNotEnabled); + } + + if !new_config.validate() { + return Err(AccessControlError::InvalidMultisigConfig); + } + + // This should be done via proposal + Err(AccessControlError::AdminRequired) + } + + /// Check if emergency mode is active + pub fn is_emergency_mode(env: &Env) -> bool { + env.storage() + .persistent() + .get(&DataKey::EmergencyMode) + .unwrap_or(false) + } + + /// Deactivate emergency mode (requires proposal) + pub fn deactivate_emergency_mode(env: &Env, caller: Address) -> AccessControlResult<()> { + Self::require_admin(env, &caller)?; + + if Self::is_multisig_enabled(env) { + return Err(AccessControlError::AdminRequired); + } + + env.storage() + .persistent() + .set(&DataKey::EmergencyMode, &false); + + env.events() + .publish((symbol_short!("emrg_off"), false), caller.clone()); + + Ok(()) + } + // ============================================================================ // Tier-Based Access Control Functions // ============================================================================ diff --git a/contracts/access_control/src/access_control_tests.rs b/contracts/access_control/src/access_control_tests.rs index 657a13c..51ee17b 100644 --- a/contracts/access_control/src/access_control_tests.rs +++ b/contracts/access_control/src/access_control_tests.rs @@ -1,8 +1,8 @@ use crate::access_control::AccessControlModule; use crate::errors::AccessControlError; -use crate::types::{AccessControlConfig, ProposalAction, UserRole}; +use crate::types::{AccessControlConfig, ProposalAction, ProposalType, UserRole}; use soroban_sdk::{ - testutils::{Address as _, Events}, + testutils::{Address as _, Events, Ledger, LedgerInfo}, Address, Env, Vec, }; @@ -756,3 +756,607 @@ fn test_proposal_events_emitted() { // Verify role was set after approval assert_eq!(client.get_role(&user), UserRole::Member); } + +// ==================== Enhanced Multisig Tests ==================== + +#[test] +fn test_enhanced_multisig_with_thresholds() { + let env = Env::default(); + let contract_id = env.register(crate::AccessControl, ()); + let admin1 = Address::generate(&env); + let admin2 = Address::generate(&env); + let admin3 = Address::generate(&env); + let admin4 = Address::generate(&env); + + env.as_contract(&contract_id, || { + let admins = Vec::from_array( + &env, + [ + admin1.clone(), + admin2.clone(), + admin3.clone(), + admin4.clone(), + ], + ); + AccessControlModule::initialize_multisig(&env, admins, 2, None).unwrap(); + + let config = AccessControlModule::get_multisig_config(&env).unwrap(); + assert_eq!(config.required_signatures, 2); + assert_eq!(config.critical_threshold, 3); + assert_eq!(config.emergency_threshold, 4); + assert_eq!(config.time_lock_duration, 86400); + assert_eq!(config.max_pending_proposals, 50); + }); +} + +#[test] +fn test_proposal_type_classification() { + let env = Env::default(); + + let user = Address::generate(&env); + let config = AccessControlConfig::default(); + + assert_eq!( + ProposalAction::SetRole(user.clone(), UserRole::Member).classify_type(), + ProposalType::Standard + ); + + assert_eq!( + ProposalAction::UpdateConfig(config).classify_type(), + ProposalType::Critical + ); + + assert_eq!( + ProposalAction::EmergencyPause(soroban_sdk::String::from_str(&env, "reason")) + .classify_type(), + ProposalType::Emergency + ); +} + +#[test] +fn test_critical_proposal_requires_higher_threshold() { + let env = Env::default(); + let contract_id = env.register(crate::AccessControl, ()); + let admin1 = Address::generate(&env); + let admin2 = Address::generate(&env); + let admin3 = Address::generate(&env); + let admin4 = Address::generate(&env); + + env.as_contract(&contract_id, || { + let admins = Vec::from_array( + &env, + [ + admin1.clone(), + admin2.clone(), + admin3.clone(), + admin4.clone(), + ], + ); + AccessControlModule::initialize_multisig(&env, admins, 2, None).unwrap(); + + // Create a critical proposal (Pause) + let action = ProposalAction::Pause; + let proposal_id = + AccessControlModule::create_proposal(&env, admin1.clone(), action).unwrap(); + + let proposal = AccessControlModule::get_proposal(&env, proposal_id).unwrap(); + assert_eq!(proposal.proposal_type, ProposalType::Critical); + assert_eq!(proposal.required_signatures, 3); // Critical threshold + + // Fast forward time past time-lock (24 hours + 1 second) + env.ledger().set(LedgerInfo { + timestamp: env.ledger().timestamp() + 86401, + protocol_version: 23, + sequence_number: 10, + network_id: [0; 32], + base_reserve: 10, + min_temp_entry_ttl: 10, + min_persistent_entry_ttl: 10, + max_entry_ttl: 6312000, + }); + + // 2 approvals should not be enough (proposer already approved) + AccessControlModule::approve_proposal(&env, admin2.clone(), proposal_id).unwrap(); + + // Proposal should still be pending + let proposal = AccessControlModule::get_proposal(&env, proposal_id).unwrap(); + assert!(!proposal.executed); + + // 3rd approval should execute it + AccessControlModule::approve_proposal(&env, admin3.clone(), proposal_id).unwrap(); + + let proposal = AccessControlModule::get_proposal(&env, proposal_id).unwrap(); + assert!(proposal.executed); + assert!(AccessControlModule::is_paused(&env)); + }); +} + +#[test] +fn test_emergency_proposal_requires_all_signatures() { + let env = Env::default(); + let contract_id = env.register(crate::AccessControl, ()); + let admin1 = Address::generate(&env); + let admin2 = Address::generate(&env); + let admin3 = Address::generate(&env); + let admin4 = Address::generate(&env); + + env.as_contract(&contract_id, || { + let admins = Vec::from_array( + &env, + [ + admin1.clone(), + admin2.clone(), + admin3.clone(), + admin4.clone(), + ], + ); + AccessControlModule::initialize_multisig(&env, admins, 2, None).unwrap(); + + // Create an emergency proposal + let action = + ProposalAction::EmergencyPause(soroban_sdk::String::from_str(&env, "Security breach")); + let proposal_id = + AccessControlModule::create_proposal(&env, admin1.clone(), action).unwrap(); + + let proposal = AccessControlModule::get_proposal(&env, proposal_id).unwrap(); + assert_eq!(proposal.proposal_type, ProposalType::Emergency); + assert_eq!(proposal.required_signatures, 4); // Emergency threshold = all admins + + // Need all 4 approvals + AccessControlModule::approve_proposal(&env, admin2.clone(), proposal_id).unwrap(); + AccessControlModule::approve_proposal(&env, admin3.clone(), proposal_id).unwrap(); + + let proposal = AccessControlModule::get_proposal(&env, proposal_id).unwrap(); + assert!(!proposal.executed); + + AccessControlModule::approve_proposal(&env, admin4.clone(), proposal_id).unwrap(); + + let proposal = AccessControlModule::get_proposal(&env, proposal_id).unwrap(); + assert!(proposal.executed); + assert!(AccessControlModule::is_paused(&env)); + assert!(AccessControlModule::is_emergency_mode(&env)); + }); +} + +#[test] +fn test_proposal_rejection() { + let env = Env::default(); + let contract_id = env.register(crate::AccessControl, ()); + let admin1 = Address::generate(&env); + let admin2 = Address::generate(&env); + let admin3 = Address::generate(&env); + + env.as_contract(&contract_id, || { + let admins = Vec::from_array(&env, [admin1.clone(), admin2.clone(), admin3.clone()]); + AccessControlModule::initialize_multisig(&env, admins, 2, None).unwrap(); + + let user = Address::generate(&env); + let action = ProposalAction::SetRole(user.clone(), UserRole::Member); + let proposal_id = + AccessControlModule::create_proposal(&env, admin1.clone(), action).unwrap(); + + // Reject proposal + let result = AccessControlModule::reject_proposal(&env, admin2.clone(), proposal_id); + assert!(result.is_ok()); + + let proposal = AccessControlModule::get_proposal(&env, proposal_id).unwrap(); + assert_eq!(proposal.rejections.len(), 1); + + // Another rejection should trigger rejection threshold + let result = AccessControlModule::reject_proposal(&env, admin3.clone(), proposal_id); + // This should fail with ProposalRejected and clean up the proposal + assert_eq!(result.unwrap_err(), AccessControlError::ProposalRejected); + + // Proposal should be removed + assert!(AccessControlModule::get_proposal(&env, proposal_id).is_none()); + }); +} + +#[test] +fn test_proposal_cancellation() { + let env = Env::default(); + let contract_id = env.register(crate::AccessControl, ()); + let admin1 = Address::generate(&env); + let admin2 = Address::generate(&env); + + env.as_contract(&contract_id, || { + let admins = Vec::from_array(&env, [admin1.clone(), admin2.clone()]); + AccessControlModule::initialize_multisig(&env, admins, 2, None).unwrap(); + + let user = Address::generate(&env); + let action = ProposalAction::SetRole(user.clone(), UserRole::Member); + let proposal_id = + AccessControlModule::create_proposal(&env, admin1.clone(), action).unwrap(); + + // Proposer can cancel + AccessControlModule::cancel_proposal(&env, admin1.clone(), proposal_id).unwrap(); + + // Proposal should be removed + assert!(AccessControlModule::get_proposal(&env, proposal_id).is_none()); + }); +} + +#[test] +fn test_non_proposer_cannot_cancel() { + let env = Env::default(); + let contract_id = env.register(crate::AccessControl, ()); + let admin1 = Address::generate(&env); + let admin2 = Address::generate(&env); + + env.as_contract(&contract_id, || { + let admins = Vec::from_array(&env, [admin1.clone(), admin2.clone()]); + AccessControlModule::initialize_multisig(&env, admins, 2, None).unwrap(); + + let user = Address::generate(&env); + let action = ProposalAction::SetRole(user.clone(), UserRole::Member); + let proposal_id = + AccessControlModule::create_proposal(&env, admin1.clone(), action).unwrap(); + + // Non-proposer cannot cancel + let result = AccessControlModule::cancel_proposal(&env, admin2.clone(), proposal_id); + assert_eq!(result.unwrap_err(), AccessControlError::Unauthorized); + }); +} + +#[test] +fn test_proposal_expiration_cleanup() { + let env = Env::default(); + env.ledger().set(LedgerInfo { + timestamp: 1000, + protocol_version: 23, + sequence_number: 10, + network_id: [0; 32], + base_reserve: 10, + min_temp_entry_ttl: 10, + min_persistent_entry_ttl: 10, + max_entry_ttl: 6312000, + }); + + let contract_id = env.register(crate::AccessControl, ()); + let admin1 = Address::generate(&env); + let admin2 = Address::generate(&env); + + env.as_contract(&contract_id, || { + let admins = Vec::from_array(&env, [admin1.clone(), admin2.clone()]); + AccessControlModule::initialize_multisig(&env, admins, 2, None).unwrap(); + + let user = Address::generate(&env); + let action = ProposalAction::SetRole(user.clone(), UserRole::Member); + let proposal_id = + AccessControlModule::create_proposal(&env, admin1.clone(), action).unwrap(); + + // Fast forward time past expiry (7 days + 1) + env.ledger().set(LedgerInfo { + timestamp: 1000 + 604801, + protocol_version: 23, + sequence_number: 10, + network_id: [0; 32], + base_reserve: 10, + min_temp_entry_ttl: 10, + min_persistent_entry_ttl: 10, + max_entry_ttl: 6312000, + }); + + // Try to approve expired proposal + let result = AccessControlModule::approve_proposal(&env, admin2.clone(), proposal_id); + assert_eq!(result.unwrap_err(), AccessControlError::ProposalExpired); + + // Proposal should be cleaned up + assert!(AccessControlModule::get_proposal(&env, proposal_id).is_none()); + }); +} + +#[test] +fn test_cleanup_multiple_expired_proposals() { + let env = Env::default(); + env.ledger().set(LedgerInfo { + timestamp: 1000, + protocol_version: 23, + sequence_number: 10, + network_id: [0; 32], + base_reserve: 10, + min_temp_entry_ttl: 10, + min_persistent_entry_ttl: 10, + max_entry_ttl: 6312000, + }); + + let contract_id = env.register(crate::AccessControl, ()); + let admin1 = Address::generate(&env); + let admin2 = Address::generate(&env); + + env.as_contract(&contract_id, || { + let admins = Vec::from_array(&env, [admin1.clone(), admin2.clone()]); + AccessControlModule::initialize_multisig(&env, admins, 2, None).unwrap(); + + // Create multiple proposals + let user = Address::generate(&env); + let action = ProposalAction::SetRole(user.clone(), UserRole::Member); + + let _proposal_id1 = + AccessControlModule::create_proposal(&env, admin1.clone(), action.clone()).unwrap(); + let _proposal_id2 = + AccessControlModule::create_proposal(&env, admin1.clone(), action.clone()).unwrap(); + let _proposal_id3 = + AccessControlModule::create_proposal(&env, admin1.clone(), action.clone()).unwrap(); + + let stats = AccessControlModule::get_proposal_stats(&env); + assert_eq!(stats.pending_count, 3); + + // Fast forward time past expiry + env.ledger().set(LedgerInfo { + timestamp: 1000 + 604801, + protocol_version: 23, + sequence_number: 10, + network_id: [0; 32], + base_reserve: 10, + min_temp_entry_ttl: 10, + min_persistent_entry_ttl: 10, + max_entry_ttl: 6312000, + }); + + // Clean up expired proposals + let cleaned = AccessControlModule::cleanup_expired_proposals(&env).unwrap(); + assert_eq!(cleaned, 3); + + let stats = AccessControlModule::get_proposal_stats(&env); + assert_eq!(stats.pending_count, 0); + assert_eq!(stats.total_expired, 3); + }); +} + +#[test] +fn test_proposal_stats_tracking() { + let env = Env::default(); + let contract_id = env.register(crate::AccessControl, ()); + let admin1 = Address::generate(&env); + let admin2 = Address::generate(&env); + + env.as_contract(&contract_id, || { + let admins = Vec::from_array(&env, [admin1.clone(), admin2.clone()]); + AccessControlModule::initialize_multisig(&env, admins, 2, None).unwrap(); + + let user = Address::generate(&env); + let action = ProposalAction::SetRole(user.clone(), UserRole::Member); + + // Create proposal + let proposal_id = + AccessControlModule::create_proposal(&env, admin1.clone(), action).unwrap(); + + let stats = AccessControlModule::get_proposal_stats(&env); + assert_eq!(stats.total_created, 1); + assert_eq!(stats.pending_count, 1); + assert_eq!(stats.total_executed, 0); + + // Execute proposal + AccessControlModule::approve_proposal(&env, admin2.clone(), proposal_id).unwrap(); + + let stats = AccessControlModule::get_proposal_stats(&env); + assert_eq!(stats.total_created, 1); + assert_eq!(stats.pending_count, 0); + assert_eq!(stats.total_executed, 1); + }); +} + +#[test] +fn test_max_pending_proposals_limit() { + let env = Env::default(); + let contract_id = env.register(crate::AccessControl, ()); + let admin1 = Address::generate(&env); + let admin2 = Address::generate(&env); + + env.as_contract(&contract_id, || { + let admins = Vec::from_array(&env, [admin1.clone(), admin2.clone()]); + + // Create config with low max pending proposals for testing + let ms_config = crate::types::MultiSigConfig { + admins: admins.clone(), + required_signatures: 2, + critical_threshold: 2, + emergency_threshold: 2, + time_lock_duration: 86400, + max_pending_proposals: 3, + proposal_expiry_duration: 604800, + }; + + AccessControlModule::initialize_multisig(&env, ms_config.admins.clone(), 2, None).unwrap(); + + // Update to set lower limit + env.storage() + .persistent() + .set(&crate::access_control::DataKey::MultiSigConfig, &ms_config); + + let user = Address::generate(&env); + let action = ProposalAction::SetRole(user.clone(), UserRole::Member); + + // Create 3 proposals (should succeed) + AccessControlModule::create_proposal(&env, admin1.clone(), action.clone()).unwrap(); + AccessControlModule::create_proposal(&env, admin1.clone(), action.clone()).unwrap(); + AccessControlModule::create_proposal(&env, admin1.clone(), action.clone()).unwrap(); + + // 4th proposal should fail + let result = AccessControlModule::create_proposal(&env, admin1.clone(), action.clone()); + assert_eq!(result.unwrap_err(), AccessControlError::MaxProposalsReached); + }); +} + +#[test] +fn test_cannot_approve_twice() { + let env = Env::default(); + let contract_id = env.register(crate::AccessControl, ()); + let admin1 = Address::generate(&env); + let admin2 = Address::generate(&env); + let admin3 = Address::generate(&env); + + env.as_contract(&contract_id, || { + let admins = Vec::from_array(&env, [admin1.clone(), admin2.clone(), admin3.clone()]); + AccessControlModule::initialize_multisig(&env, admins, 2, None).unwrap(); + + let user = Address::generate(&env); + let action = ProposalAction::SetRole(user.clone(), UserRole::Member); + let proposal_id = + AccessControlModule::create_proposal(&env, admin1.clone(), action).unwrap(); + + // Proposer already approved, try to approve again + let result = AccessControlModule::approve_proposal(&env, admin1.clone(), proposal_id); + assert_eq!(result.unwrap_err(), AccessControlError::AlreadyApproved); + }); +} + +#[test] +fn test_cannot_approve_after_rejection() { + let env = Env::default(); + let contract_id = env.register(crate::AccessControl, ()); + let admin1 = Address::generate(&env); + let admin2 = Address::generate(&env); + + env.as_contract(&contract_id, || { + let admins = Vec::from_array(&env, [admin1.clone(), admin2.clone()]); + AccessControlModule::initialize_multisig(&env, admins, 2, None).unwrap(); + + let user = Address::generate(&env); + let action = ProposalAction::SetRole(user.clone(), UserRole::Member); + let proposal_id = + AccessControlModule::create_proposal(&env, admin1.clone(), action).unwrap(); + + // Reject proposal + AccessControlModule::reject_proposal(&env, admin2.clone(), proposal_id).unwrap(); + + // Try to approve after rejecting + let result = AccessControlModule::approve_proposal(&env, admin2.clone(), proposal_id); + assert_eq!(result.unwrap_err(), AccessControlError::AlreadyRejected); + }); +} + +#[test] +fn test_batch_blacklist_proposal() { + let env = Env::default(); + let contract_id = env.register(crate::AccessControl, ()); + let admin1 = Address::generate(&env); + let admin2 = Address::generate(&env); + let admin3 = Address::generate(&env); + + env.as_contract(&contract_id, || { + let admins = Vec::from_array(&env, [admin1.clone(), admin2.clone(), admin3.clone()]); + AccessControlModule::initialize_multisig(&env, admins, 2, None).unwrap(); + + let user1 = Address::generate(&env); + let user2 = Address::generate(&env); + let user3 = Address::generate(&env); + + let users_to_blacklist = + Vec::from_array(&env, [user1.clone(), user2.clone(), user3.clone()]); + let action = ProposalAction::BatchBlacklist(users_to_blacklist); + + let proposal_id = + AccessControlModule::create_proposal(&env, admin1.clone(), action).unwrap(); + + // Fast forward time past time-lock (24 hours + 1 second) + env.ledger().set(LedgerInfo { + timestamp: env.ledger().timestamp() + 86401, + protocol_version: 23, + sequence_number: 10, + network_id: [0; 32], + base_reserve: 10, + min_temp_entry_ttl: 10, + min_persistent_entry_ttl: 10, + max_entry_ttl: 6312000, + }); + + // This is a critical operation, needs critical_threshold (3) + AccessControlModule::approve_proposal(&env, admin2.clone(), proposal_id).unwrap(); + AccessControlModule::approve_proposal(&env, admin3.clone(), proposal_id).unwrap(); + + // All users should be blacklisted + assert!(AccessControlModule::is_blacklisted(&env, &user1)); + assert!(AccessControlModule::is_blacklisted(&env, &user2)); + assert!(AccessControlModule::is_blacklisted(&env, &user3)); + }); +} + +#[test] +fn test_add_remove_admin_via_proposal() { + let env = Env::default(); + let contract_id = env.register(crate::AccessControl, ()); + let admin1 = Address::generate(&env); + let admin2 = Address::generate(&env); + let admin3 = Address::generate(&env); + + env.as_contract(&contract_id, || { + let admins = Vec::from_array(&env, [admin1.clone(), admin2.clone()]); + AccessControlModule::initialize_multisig(&env, admins, 2, None).unwrap(); + + // Add new admin + let action = ProposalAction::AddAdmin(admin3.clone()); + let proposal_id = + AccessControlModule::create_proposal(&env, admin1.clone(), action).unwrap(); + + // Fast forward time past time-lock (24 hours + 1 second) + env.ledger().set(LedgerInfo { + timestamp: env.ledger().timestamp() + 86401, + protocol_version: 23, + sequence_number: 10, + network_id: [0; 32], + base_reserve: 10, + min_temp_entry_ttl: 10, + min_persistent_entry_ttl: 10, + max_entry_ttl: 6312000, + }); + + // Critical operation + AccessControlModule::approve_proposal(&env, admin2.clone(), proposal_id).unwrap(); + + // Verify admin3 was added + let config = AccessControlModule::get_multisig_config(&env).unwrap(); + assert!(config.admins.contains(&admin3)); + assert_eq!( + AccessControlModule::get_role(&env, admin3.clone()), + UserRole::Admin + ); + }); +} + +#[test] +fn test_duplicate_admin_prevented() { + let env = Env::default(); + let contract_id = env.register(crate::AccessControl, ()); + let admin1 = Address::generate(&env); + + env.as_contract(&contract_id, || { + let admins = Vec::from_array(&env, [admin1.clone(), admin1.clone()]); + let result = AccessControlModule::initialize_multisig(&env, admins, 2, None); + assert_eq!(result.unwrap_err(), AccessControlError::DuplicateAdmin); + }); +} + +#[test] +fn test_get_pending_proposals_list() { + let env = Env::default(); + let contract_id = env.register(crate::AccessControl, ()); + let admin1 = Address::generate(&env); + let admin2 = Address::generate(&env); + + env.as_contract(&contract_id, || { + let admins = Vec::from_array(&env, [admin1.clone(), admin2.clone()]); + AccessControlModule::initialize_multisig(&env, admins, 2, None).unwrap(); + + let user = Address::generate(&env); + let action = ProposalAction::SetRole(user.clone(), UserRole::Member); + + let id1 = + AccessControlModule::create_proposal(&env, admin1.clone(), action.clone()).unwrap(); + let id2 = + AccessControlModule::create_proposal(&env, admin1.clone(), action.clone()).unwrap(); + + let pending = AccessControlModule::get_pending_proposals(&env); + assert_eq!(pending.len(), 2); + assert!(pending.contains(id1)); + assert!(pending.contains(id2)); + + // Execute one + AccessControlModule::approve_proposal(&env, admin2.clone(), id1).unwrap(); + + let pending = AccessControlModule::get_pending_proposals(&env); + assert_eq!(pending.len(), 1); + assert!(pending.contains(id2)); + }); +} diff --git a/contracts/access_control/src/errors.rs b/contracts/access_control/src/errors.rs index b9ea76e..534ff88 100644 --- a/contracts/access_control/src/errors.rs +++ b/contracts/access_control/src/errors.rs @@ -37,6 +37,42 @@ pub enum AccessControlError { MaxRolesExceeded = 114, /// Contract is paused ContractPaused = 115, + /// Multisig not enabled for this operation + MultisigNotEnabled = 116, + /// Insufficient approvals for proposal execution + InsufficientApprovals = 117, + /// Proposal not found + ProposalNotFound = 118, + /// Proposal already executed + ProposalAlreadyExecuted = 119, + /// Proposal has expired + ProposalExpired = 120, + /// Time-lock not yet passed + TimeLockActive = 121, + /// Already approved this proposal + AlreadyApproved = 122, + /// Already rejected this proposal + AlreadyRejected = 123, + /// Cannot execute proposal yet + CannotExecuteProposal = 124, + /// Maximum pending proposals reached + MaxProposalsReached = 125, + /// Invalid proposal type for this action + InvalidProposalType = 126, + /// Invalid multisig configuration + InvalidMultisigConfig = 127, + /// Threshold too high for number of admins + ThresholdTooHigh = 128, + /// Threshold too low for security requirements + ThresholdTooLow = 129, + /// Cannot remove last admin + CannotRemoveLastAdmin = 130, + /// Duplicate admin address + DuplicateAdmin = 131, + /// Not authorized as multisig admin + NotMultisigAdmin = 132, + /// Proposal rejection threshold reached + ProposalRejected = 133, } impl AccessControlError { @@ -63,6 +99,26 @@ impl AccessControlError { AccessControlError::RoleHierarchyViolation => "Role hierarchy violation", AccessControlError::MaxRolesExceeded => "Maximum roles per user exceeded", AccessControlError::ContractPaused => "Contract is currently paused", + AccessControlError::MultisigNotEnabled => "Multisig not enabled for this operation", + AccessControlError::InsufficientApprovals => { + "Insufficient approvals for proposal execution" + } + AccessControlError::ProposalNotFound => "Proposal not found", + AccessControlError::ProposalAlreadyExecuted => "Proposal already executed", + AccessControlError::ProposalExpired => "Proposal has expired", + AccessControlError::TimeLockActive => "Time-lock period not yet passed", + AccessControlError::AlreadyApproved => "Already approved this proposal", + AccessControlError::AlreadyRejected => "Already rejected this proposal", + AccessControlError::CannotExecuteProposal => "Cannot execute proposal yet", + AccessControlError::MaxProposalsReached => "Maximum pending proposals reached", + AccessControlError::InvalidProposalType => "Invalid proposal type for this action", + AccessControlError::InvalidMultisigConfig => "Invalid multisig configuration", + AccessControlError::ThresholdTooHigh => "Threshold too high for number of admins", + AccessControlError::ThresholdTooLow => "Threshold too low for security requirements", + AccessControlError::CannotRemoveLastAdmin => "Cannot remove the last admin", + AccessControlError::DuplicateAdmin => "Duplicate admin address", + AccessControlError::NotMultisigAdmin => "Not authorized as multisig admin", + AccessControlError::ProposalRejected => "Proposal rejection threshold reached", } } diff --git a/contracts/access_control/src/lib.rs b/contracts/access_control/src/lib.rs index ca7bd0b..e3e5d96 100644 --- a/contracts/access_control/src/lib.rs +++ b/contracts/access_control/src/lib.rs @@ -11,7 +11,10 @@ mod access_control_tests; pub use access_control::AccessControlModule; pub use errors::{AccessControlError, AccessControlResult}; -pub use types::{AccessControlConfig, MembershipInfo, MultiSigConfig, ProposalAction, UserRole}; +pub use types::{ + AccessControlConfig, MembershipInfo, MultiSigConfig, PendingProposal, ProposalAction, + ProposalStats, ProposalType, UserRole, +}; #[contract] pub struct AccessControl; @@ -127,4 +130,56 @@ impl AccessControl { }; AccessControlModule::check_access(&env, caller, role).unwrap_or(false) } + + // ============================================================================ + // Enhanced Multisig Endpoints + // ============================================================================ + + pub fn reject_proposal(env: Env, rejecter: Address, proposal_id: u64) { + AccessControlModule::reject_proposal(&env, rejecter, proposal_id).unwrap() + } + + pub fn cancel_proposal(env: Env, proposer: Address, proposal_id: u64) { + AccessControlModule::cancel_proposal(&env, proposer, proposal_id).unwrap() + } + + pub fn get_proposal(env: Env, proposal_id: u64) -> Option { + AccessControlModule::get_proposal(&env, proposal_id) + } + + pub fn get_pending_proposals(env: Env) -> Vec { + AccessControlModule::get_pending_proposals(&env) + } + + pub fn get_proposal_stats(env: Env) -> ProposalStats { + AccessControlModule::get_proposal_stats(&env) + } + + pub fn cleanup_expired_proposals(env: Env) -> u32 { + AccessControlModule::cleanup_expired_proposals(&env).unwrap_or(0) + } + + pub fn is_emergency_mode(env: Env) -> bool { + AccessControlModule::is_emergency_mode(&env) + } + + pub fn deactivate_emergency_mode(env: Env, caller: Address) { + AccessControlModule::deactivate_emergency_mode(&env, caller).unwrap() + } + + #[allow(clippy::too_many_arguments)] + pub fn update_multisig_config_full( + _env: Env, + _admins: Vec
, + _required_signatures: u32, + _critical_threshold: u32, + _emergency_threshold: u32, + _time_lock_duration: u64, + _max_pending_proposals: u32, + _proposal_expiry_duration: u64, + ) -> u64 { + // This is a helper function - actual usage should call create_proposal directly + // Return 0 as placeholder - this function should not be used in production + 0 + } } diff --git a/contracts/access_control/src/types.rs b/contracts/access_control/src/types.rs index 34adedd..407fd53 100644 --- a/contracts/access_control/src/types.rs +++ b/contracts/access_control/src/types.rs @@ -1,4 +1,4 @@ -use soroban_sdk::{contracttype, Address, Vec}; +use soroban_sdk::{contracttype, Address, String, Vec}; /// User roles in the access control system /// Implements a hierarchical role system where Admin > Member > Guest @@ -113,6 +113,16 @@ pub struct UserSubscriptionStatus { pub struct MultiSigConfig { pub admins: Vec
, pub required_signatures: u32, + /// Higher threshold for critical operations + pub critical_threshold: u32, + /// Even higher threshold for emergency operations + pub emergency_threshold: u32, + /// Default time-lock duration in seconds (e.g., 24 hours) + pub time_lock_duration: u64, + /// Maximum number of pending proposals + pub max_pending_proposals: u32, + /// Proposal expiration duration in seconds + pub proposal_expiry_duration: u64, } #[contracttype] @@ -121,9 +131,29 @@ pub struct PendingProposal { pub id: u64, pub proposer: Address, pub action: ProposalAction, + pub proposal_type: ProposalType, pub approvals: Vec
, + pub rejections: Vec
, pub executed: bool, + pub created_at: u64, pub expiry: u64, + /// For time-locked proposals: earliest execution time + pub time_lock_until: Option, + /// Number of signatures required (can override default based on type) + pub required_signatures: u32, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum ProposalType { + /// Regular operations requiring standard approval + Standard, + /// Critical operations with higher security requirements + Critical, + /// Emergency operations with special override procedures + Emergency, + /// Time-locked operations with mandatory delay + TimeLocked, } #[contracttype] @@ -136,6 +166,16 @@ pub enum ProposalAction { Pause, Unpause, TransferAdmin(Address), + /// Critical operation: Update multisig configuration + UpdateMultisigConfig(MultiSigConfig), + /// Critical operation: Emergency pause with reason + EmergencyPause(String), + /// Critical operation: Blacklist multiple users + BatchBlacklist(Vec
), + /// Time-locked operation: Schedule contract upgrade + ScheduleUpgrade(Address, u64), + /// Emergency operation: Force admin transfer + EmergencyAdminTransfer(Address), } #[contracttype] @@ -146,6 +186,102 @@ pub struct PendingAdminTransfer { pub expiry: u64, } +/// Proposal statistics for tracking and analytics +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ProposalStats { + pub total_created: u64, + pub total_executed: u64, + pub total_rejected: u64, + pub total_expired: u64, + pub pending_count: u32, +} + +impl ProposalType { + /// Determine if this proposal type requires time-lock + pub fn requires_time_lock(&self) -> bool { + matches!(self, ProposalType::TimeLocked | ProposalType::Critical) + } + + /// Get the required threshold multiplier for this proposal type + pub fn get_threshold_multiplier(&self) -> u32 { + match self { + ProposalType::Standard => 1, + ProposalType::Critical => 2, + ProposalType::Emergency => 3, + ProposalType::TimeLocked => 1, + } + } +} + +impl ProposalAction { + /// Classify the action type based on its security implications + pub fn classify_type(&self) -> ProposalType { + match self { + ProposalAction::SetRole(_, _) => ProposalType::Standard, + ProposalAction::UpdateConfig(_) => ProposalType::Critical, + ProposalAction::AddAdmin(_) => ProposalType::Critical, + ProposalAction::RemoveAdmin(_) => ProposalType::Critical, + ProposalAction::Pause => ProposalType::Critical, + ProposalAction::Unpause => ProposalType::Standard, + ProposalAction::TransferAdmin(_) => ProposalType::Critical, + ProposalAction::UpdateMultisigConfig(_) => ProposalType::Critical, + ProposalAction::EmergencyPause(_) => ProposalType::Emergency, + ProposalAction::BatchBlacklist(_) => ProposalType::Critical, + ProposalAction::ScheduleUpgrade(_, _) => ProposalType::TimeLocked, + ProposalAction::EmergencyAdminTransfer(_) => ProposalType::Emergency, + } + } + + /// Check if this action is reversible + pub fn is_reversible(&self) -> bool { + matches!( + self, + ProposalAction::SetRole(_, _) + | ProposalAction::Pause + | ProposalAction::Unpause + | ProposalAction::BatchBlacklist(_) + ) + } +} + +impl MultiSigConfig { + /// Create a default configuration for testing + pub fn default_config() -> Self { + MultiSigConfig { + admins: Vec::new(&soroban_sdk::Env::default()), + required_signatures: 2, + critical_threshold: 3, + emergency_threshold: 4, + time_lock_duration: 86400, // 24 hours + max_pending_proposals: 50, + proposal_expiry_duration: 604800, // 7 days + } + } + + /// Validate configuration parameters + pub fn validate(&self) -> bool { + !self.admins.is_empty() + && self.required_signatures > 0 + && self.required_signatures <= self.admins.len() + && self.critical_threshold >= self.required_signatures + && self.emergency_threshold >= self.critical_threshold + && self.emergency_threshold <= self.admins.len() + && self.time_lock_duration > 0 + && self.max_pending_proposals > 0 + && self.proposal_expiry_duration > 0 + } + + /// Get required signatures for a specific proposal type + pub fn get_required_signatures(&self, proposal_type: &ProposalType) -> u32 { + match proposal_type { + ProposalType::Standard => self.required_signatures, + ProposalType::Critical | ProposalType::TimeLocked => self.critical_threshold, + ProposalType::Emergency => self.emergency_threshold, + } + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/contracts/manage_hub/src/lib.rs b/contracts/manage_hub/src/lib.rs index 0036bbe..f59bd01 100644 --- a/contracts/manage_hub/src/lib.rs +++ b/contracts/manage_hub/src/lib.rs @@ -1,4 +1,64 @@ #![no_std] +//! # ManageHub Contract +//! +//! ## Multisig Integration for Critical Operations +//! +//! This contract integrates with the access_control contract for multi-signature +//! operations on critical functions. Critical operations that should require +//! multisig approval include: +//! +//! - `set_admin`: Changing admin privileges +//! - `set_usdc_contract`: Updating payment contracts +//! - `set_pause_config`: Modifying pause configuration +//! - `pause_subscription_admin`: Admin-level subscription actions +//! +//! ### Example Integration: +//! +//! ```rust,ignore +//! use access_control::{AccessControl, ProposalAction, UserRole}; +//! +//! // Instead of direct admin operations, create a proposal: +//! pub fn set_admin_multisig(env: Env, proposer: Address, new_admin: Address) -> u64 { +//! let access_control = AccessControl::new(&env, &ACCESS_CONTROL_CONTRACT); +//! access_control.create_proposal( +//! &proposer, +//! &ProposalAction::SetRole(new_admin, UserRole::Admin) +//! ) +//! } +//! +//! // Critical operations can check if multisig is required: +//! fn require_admin_or_multisig(env: &Env, caller: &Address) -> Result<(), Error> { +//! let access_control = AccessControl::new(env, &ACCESS_CONTROL_CONTRACT); +//! +//! // Check if multisig is enabled +//! if access_control.is_multisig_enabled() { +//! // For multisig mode, require proposal-based execution +//! if !access_control.check_access(caller, &UserRole::Admin) { +//! return Err(Error::Unauthorized); +//! } +//! } else { +//! // Single admin mode +//! if !access_control.is_admin(caller) { +//! return Err(Error::Unauthorized); +//! } +//! } +//! Ok(()) +//! } +//! ``` +//! +//! ### Time-Locked Operations: +//! +//! High-value operations like contract upgrades should use time-locked proposals: +//! +//! ```rust,ignore +//! let proposal_id = access_control.create_proposal( +//! &proposer, +//! &ProposalAction::ScheduleUpgrade(new_contract_address, execution_time) +//! ); +//! // This proposal will require critical_threshold approvals +//! // and will be executable only after time_lock_duration +//! ``` +//! use soroban_sdk::{contract, contractimpl, vec, Address, BytesN, Env, Map, String, Vec}; mod attendance_log;