From 3ea2a3fd86ff8d39de49519496e0e8c8c172deec Mon Sep 17 00:00:00 2001 From: Sandijigs Date: Wed, 28 Jan 2026 10:37:30 +0100 Subject: [PATCH 1/8] Implemented Multi-Signature Wallet Support --- .../access_control/src/access_control.rs | 560 +++++++++++++++++- .../src/access_control_tests.rs | 494 ++++++++++++++- contracts/access_control/src/errors.rs | 54 ++ contracts/access_control/src/lib.rs | 56 +- contracts/access_control/src/types.rs | 138 ++++- contracts/manage_hub/src/lib.rs | 60 ++ 6 files changed, 1335 insertions(+), 27 deletions(-) diff --git a/contracts/access_control/src/access_control.rs b/contracts/access_control/src/access_control.rs index 0d76967..bd03dd7 100644 --- a/contracts/access_control/src/access_control.rs +++ b/contracts/access_control/src/access_control.rs @@ -6,7 +6,8 @@ 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 +28,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 +85,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 +133,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 +653,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 +722,32 @@ 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 +764,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 +793,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 +815,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 +879,107 @@ 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 +988,287 @@ 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 + return 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..2eecdce 100644 --- a/contracts/access_control/src/access_control_tests.rs +++ b/contracts/access_control/src/access_control_tests.rs @@ -1,6 +1,6 @@ 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}, Address, Env, Vec, @@ -756,3 +756,495 @@ 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 + + // 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().with_mut(|li| { + li.timestamp = 1000; + }); + + 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().with_mut(|li| { + li.timestamp = 1000 + 604801; + }); + + // 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().with_mut(|li| { + li.timestamp = 1000; + }); + + 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().with_mut(|li| { + li.timestamp = 1000 + 604801; + }); + + // 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 mut admins = Vec::from_array(&env, [admin1.clone(), admin2.clone()]); + + // Create config with low max pending proposals for testing + let mut 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(); + + // 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(); + + // 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..36a5a27 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,24 @@ 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..123f33a 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,55 @@ 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() + } + + 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..60fa82c 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; From 35b70e18c0a879f15db7fd6b0b3447b30e267a0d Mon Sep 17 00:00:00 2001 From: Sandijigs Date: Thu, 29 Jan 2026 10:54:45 +0100 Subject: [PATCH 2/8] Fix formatting and clippy lint issues --- .../access_control/src/access_control.rs | 38 ++--- .../src/access_control_tests.rs | 138 ++++++++++++------ contracts/access_control/src/errors.rs | 4 +- contracts/access_control/src/types.rs | 2 +- 4 files changed, 118 insertions(+), 64 deletions(-) diff --git a/contracts/access_control/src/access_control.rs b/contracts/access_control/src/access_control.rs index bd03dd7..4933e85 100644 --- a/contracts/access_control/src/access_control.rs +++ b/contracts/access_control/src/access_control.rs @@ -6,8 +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, ProposalStats, SubscriptionTierLevel, UserRole, - UserSubscriptionStatus, + ProposalAction, ProposalStats, SubscriptionTierLevel, UserRole, UserSubscriptionStatus, }; /// Storage keys for the access control module @@ -103,7 +102,7 @@ impl AccessControlModule { required_signatures, critical_threshold, emergency_threshold, - time_lock_duration: 86400, // 24 hours default + time_lock_duration: 86400, // 24 hours default max_pending_proposals: 50, proposal_expiry_duration: 604800, // 7 days default }; @@ -653,8 +652,8 @@ impl AccessControlModule { ) -> AccessControlResult { Self::require_admin(env, &proposer)?; - let multisig_config = Self::get_multisig_config(env) - .ok_or(AccessControlError::MultisigNotEnabled)?; + let multisig_config = + Self::get_multisig_config(env).ok_or(AccessControlError::MultisigNotEnabled)?; // Check max pending proposals limit let mut stats: ProposalStats = env @@ -741,7 +740,11 @@ impl AccessControlModule { .set(&DataKey::ProposalStats, &stats); env.events().publish( - (symbol_short!("proposal"), proposal_id, proposal_type.clone()), + ( + symbol_short!("proposal"), + proposal_id, + proposal_type.clone(), + ), proposer.clone(), ); @@ -894,7 +897,9 @@ impl AccessControlModule { } ProposalAction::EmergencyPause(reason) => { env.storage().persistent().set(&DataKey::Paused, &true); - env.storage().persistent().set(&DataKey::EmergencyMode, &true); + env.storage() + .persistent() + .set(&DataKey::EmergencyMode, &true); env.events().publish( (symbol_short!("emrg_pse"), reason), @@ -948,9 +953,10 @@ impl AccessControlModule { env.storage() .persistent() .set(&DataKey::MultiSigConfig, &multisig_config); - env.storage() - .persistent() - .set(&DataKey::UserRole(admin_to_remove.clone()), &UserRole::Guest); + env.storage().persistent().set( + &DataKey::UserRole(admin_to_remove.clone()), + &UserRole::Guest, + ); env.events().publish( (symbol_short!("rm_adm"), admin_to_remove), @@ -1026,8 +1032,8 @@ impl AccessControlModule { 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 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 { @@ -1054,10 +1060,8 @@ impl AccessControlModule { .persistent() .set(&DataKey::ProposalStats, &stats); - env.events().publish( - (symbol_short!("rejected"), proposal_id), - rejecter.clone(), - ); + env.events() + .publish((symbol_short!("rejected"), proposal_id), rejecter.clone()); return Err(AccessControlError::ProposalRejected); } @@ -1240,7 +1244,7 @@ impl AccessControlModule { } // This should be done via proposal - return Err(AccessControlError::AdminRequired); + Err(AccessControlError::AdminRequired) } /// Check if emergency mode is active diff --git a/contracts/access_control/src/access_control_tests.rs b/contracts/access_control/src/access_control_tests.rs index 2eecdce..635f29b 100644 --- a/contracts/access_control/src/access_control_tests.rs +++ b/contracts/access_control/src/access_control_tests.rs @@ -769,7 +769,15 @@ fn test_enhanced_multisig_with_thresholds() { let admin4 = Address::generate(&env); env.as_contract(&contract_id, || { - let admins = Vec::from_array(&env, [admin1.clone(), admin2.clone(), admin3.clone(), admin4.clone()]); + 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(); @@ -784,22 +792,23 @@ fn test_enhanced_multisig_with_thresholds() { #[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(), + ProposalAction::EmergencyPause(soroban_sdk::String::from_str(&env, "reason")) + .classify_type(), ProposalType::Emergency ); } @@ -814,12 +823,21 @@ fn test_critical_proposal_requires_higher_threshold() { let admin4 = Address::generate(&env); env.as_contract(&contract_id, || { - let admins = Vec::from_array(&env, [admin1.clone(), admin2.clone(), admin3.clone(), admin4.clone()]); + 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_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); @@ -827,14 +845,14 @@ fn test_critical_proposal_requires_higher_threshold() { // 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)); @@ -851,12 +869,22 @@ fn test_emergency_proposal_requires_all_signatures() { let admin4 = Address::generate(&env); env.as_contract(&contract_id, || { - let admins = Vec::from_array(&env, [admin1.clone(), admin2.clone(), admin3.clone(), admin4.clone()]); + 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 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); @@ -865,12 +893,12 @@ fn test_emergency_proposal_requires_all_signatures() { // 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)); @@ -892,7 +920,8 @@ fn test_proposal_rejection() { let user = Address::generate(&env); let action = ProposalAction::SetRole(user.clone(), UserRole::Member); - let proposal_id = AccessControlModule::create_proposal(&env, admin1.clone(), action).unwrap(); + let proposal_id = + AccessControlModule::create_proposal(&env, admin1.clone(), action).unwrap(); // Reject proposal let result = AccessControlModule::reject_proposal(&env, admin2.clone(), proposal_id); @@ -924,7 +953,8 @@ fn test_proposal_cancellation() { let user = Address::generate(&env); let action = ProposalAction::SetRole(user.clone(), UserRole::Member); - let proposal_id = AccessControlModule::create_proposal(&env, admin1.clone(), action).unwrap(); + let proposal_id = + AccessControlModule::create_proposal(&env, admin1.clone(), action).unwrap(); // Proposer can cancel AccessControlModule::cancel_proposal(&env, admin1.clone(), proposal_id).unwrap(); @@ -947,7 +977,8 @@ fn test_non_proposer_cannot_cancel() { let user = Address::generate(&env); let action = ProposalAction::SetRole(user.clone(), UserRole::Member); - let proposal_id = AccessControlModule::create_proposal(&env, admin1.clone(), action).unwrap(); + 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); @@ -961,7 +992,7 @@ fn test_proposal_expiration_cleanup() { env.ledger().with_mut(|li| { li.timestamp = 1000; }); - + let contract_id = env.register(crate::AccessControl, ()); let admin1 = Address::generate(&env); let admin2 = Address::generate(&env); @@ -972,7 +1003,8 @@ fn test_proposal_expiration_cleanup() { let user = Address::generate(&env); let action = ProposalAction::SetRole(user.clone(), UserRole::Member); - let proposal_id = AccessControlModule::create_proposal(&env, admin1.clone(), action).unwrap(); + let proposal_id = + AccessControlModule::create_proposal(&env, admin1.clone(), action).unwrap(); // Fast forward time past expiry (7 days + 1) env.ledger().with_mut(|li| { @@ -994,7 +1026,7 @@ fn test_cleanup_multiple_expired_proposals() { env.ledger().with_mut(|li| { li.timestamp = 1000; }); - + let contract_id = env.register(crate::AccessControl, ()); let admin1 = Address::generate(&env); let admin2 = Address::generate(&env); @@ -1006,10 +1038,13 @@ fn test_cleanup_multiple_expired_proposals() { // 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 _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); @@ -1042,9 +1077,10 @@ fn test_proposal_stats_tracking() { 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 proposal_id = + AccessControlModule::create_proposal(&env, admin1.clone(), action).unwrap(); let stats = AccessControlModule::get_proposal_stats(&env); assert_eq!(stats.total_created, 1); @@ -1070,7 +1106,7 @@ fn test_max_pending_proposals_limit() { env.as_contract(&contract_id, || { let mut admins = Vec::from_array(&env, [admin1.clone(), admin2.clone()]); - + // Create config with low max pending proposals for testing let mut ms_config = crate::types::MultiSigConfig { admins: admins.clone(), @@ -1081,15 +1117,17 @@ fn test_max_pending_proposals_limit() { 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); + 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(); @@ -1115,7 +1153,8 @@ fn test_cannot_approve_twice() { let user = Address::generate(&env); let action = ProposalAction::SetRole(user.clone(), UserRole::Member); - let proposal_id = AccessControlModule::create_proposal(&env, admin1.clone(), action).unwrap(); + 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); @@ -1136,7 +1175,8 @@ fn test_cannot_approve_after_rejection() { let user = Address::generate(&env); let action = ProposalAction::SetRole(user.clone(), UserRole::Member); - let proposal_id = AccessControlModule::create_proposal(&env, admin1.clone(), action).unwrap(); + let proposal_id = + AccessControlModule::create_proposal(&env, admin1.clone(), action).unwrap(); // Reject proposal AccessControlModule::reject_proposal(&env, admin2.clone(), proposal_id).unwrap(); @@ -1162,12 +1202,14 @@ fn test_batch_blacklist_proposal() { 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 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(); - + + let proposal_id = + AccessControlModule::create_proposal(&env, admin1.clone(), action).unwrap(); + // 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(); @@ -1193,15 +1235,19 @@ fn test_add_remove_admin_via_proposal() { // Add new admin let action = ProposalAction::AddAdmin(admin3.clone()); - let proposal_id = AccessControlModule::create_proposal(&env, admin1.clone(), action).unwrap(); - + let proposal_id = + AccessControlModule::create_proposal(&env, admin1.clone(), action).unwrap(); + // 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); + assert_eq!( + AccessControlModule::get_role(&env, admin3.clone()), + UserRole::Admin + ); }); } @@ -1231,9 +1277,11 @@ fn test_get_pending_proposals_list() { 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 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); diff --git a/contracts/access_control/src/errors.rs b/contracts/access_control/src/errors.rs index 36a5a27..534ff88 100644 --- a/contracts/access_control/src/errors.rs +++ b/contracts/access_control/src/errors.rs @@ -100,7 +100,9 @@ impl AccessControlError { 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::InsufficientApprovals => { + "Insufficient approvals for proposal execution" + } AccessControlError::ProposalNotFound => "Proposal not found", AccessControlError::ProposalAlreadyExecuted => "Proposal already executed", AccessControlError::ProposalExpired => "Proposal has expired", diff --git a/contracts/access_control/src/types.rs b/contracts/access_control/src/types.rs index 60fa82c..407fd53 100644 --- a/contracts/access_control/src/types.rs +++ b/contracts/access_control/src/types.rs @@ -253,7 +253,7 @@ impl MultiSigConfig { required_signatures: 2, critical_threshold: 3, emergency_threshold: 4, - time_lock_duration: 86400, // 24 hours + time_lock_duration: 86400, // 24 hours max_pending_proposals: 50, proposal_expiry_duration: 604800, // 7 days } From dfca6f9d9234fa1ca20399dd3d3249bfb60a5e0c Mon Sep 17 00:00:00 2001 From: Sandijigs Date: Thu, 29 Jan 2026 11:38:26 +0100 Subject: [PATCH 3/8] Fix CI lint and test failures - Add missing Ledger trait import to access_control_tests - Remove unused mut keywords on admins and ms_config variables - Add clippy allow attribute for update_multisig_config_full function --- .../src/access_control_tests.rs | 50 +++++++++++++++---- contracts/access_control/src/lib.rs | 1 + 2 files changed, 40 insertions(+), 11 deletions(-) diff --git a/contracts/access_control/src/access_control_tests.rs b/contracts/access_control/src/access_control_tests.rs index 635f29b..33b3651 100644 --- a/contracts/access_control/src/access_control_tests.rs +++ b/contracts/access_control/src/access_control_tests.rs @@ -2,7 +2,7 @@ use crate::access_control::AccessControlModule; use crate::errors::AccessControlError; use crate::types::{AccessControlConfig, ProposalAction, ProposalType, UserRole}; use soroban_sdk::{ - testutils::{Address as _, Events}, + testutils::{Address as _, Events, Ledger}, Address, Env, Vec, }; @@ -989,8 +989,15 @@ fn test_non_proposer_cannot_cancel() { #[test] fn test_proposal_expiration_cleanup() { let env = Env::default(); - env.ledger().with_mut(|li| { - li.timestamp = 1000; + env.ledger().set(soroban_sdk::ledger::LedgerInfo { + timestamp: 1000, + protocol_version: 20, + 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, ()); @@ -1007,8 +1014,15 @@ fn test_proposal_expiration_cleanup() { AccessControlModule::create_proposal(&env, admin1.clone(), action).unwrap(); // Fast forward time past expiry (7 days + 1) - env.ledger().with_mut(|li| { - li.timestamp = 1000 + 604801; + env.ledger().set(soroban_sdk::ledger::LedgerInfo { + timestamp: 1000 + 604801, + protocol_version: 20, + 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 @@ -1023,8 +1037,15 @@ fn test_proposal_expiration_cleanup() { #[test] fn test_cleanup_multiple_expired_proposals() { let env = Env::default(); - env.ledger().with_mut(|li| { - li.timestamp = 1000; + env.ledger().set(soroban_sdk::ledger::LedgerInfo { + timestamp: 1000, + protocol_version: 20, + 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, ()); @@ -1050,8 +1071,15 @@ fn test_cleanup_multiple_expired_proposals() { assert_eq!(stats.pending_count, 3); // Fast forward time past expiry - env.ledger().with_mut(|li| { - li.timestamp = 1000 + 604801; + env.ledger().set(soroban_sdk::ledger::LedgerInfo { + timestamp: 1000 + 604801, + protocol_version: 20, + 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 @@ -1105,10 +1133,10 @@ fn test_max_pending_proposals_limit() { let admin2 = Address::generate(&env); env.as_contract(&contract_id, || { - let mut admins = Vec::from_array(&env, [admin1.clone(), admin2.clone()]); + let admins = Vec::from_array(&env, [admin1.clone(), admin2.clone()]); // Create config with low max pending proposals for testing - let mut ms_config = crate::types::MultiSigConfig { + let ms_config = crate::types::MultiSigConfig { admins: admins.clone(), required_signatures: 2, critical_threshold: 2, diff --git a/contracts/access_control/src/lib.rs b/contracts/access_control/src/lib.rs index 123f33a..e3e5d96 100644 --- a/contracts/access_control/src/lib.rs +++ b/contracts/access_control/src/lib.rs @@ -167,6 +167,7 @@ impl AccessControl { AccessControlModule::deactivate_emergency_mode(&env, caller).unwrap() } + #[allow(clippy::too_many_arguments)] pub fn update_multisig_config_full( _env: Env, _admins: Vec
, From 73afa0b2cf3daece4d854793fd2d668a86de8fd5 Mon Sep 17 00:00:00 2001 From: Sandijigs Date: Thu, 29 Jan 2026 11:49:36 +0100 Subject: [PATCH 4/8] Fix LedgerInfo import path - Import LedgerInfo from soroban_sdk::testutils instead of soroban_sdk::ledger - Update all LedgerInfo references to use the correct import --- contracts/access_control/src/access_control_tests.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/contracts/access_control/src/access_control_tests.rs b/contracts/access_control/src/access_control_tests.rs index 33b3651..025c23f 100644 --- a/contracts/access_control/src/access_control_tests.rs +++ b/contracts/access_control/src/access_control_tests.rs @@ -2,7 +2,7 @@ use crate::access_control::AccessControlModule; use crate::errors::AccessControlError; use crate::types::{AccessControlConfig, ProposalAction, ProposalType, UserRole}; use soroban_sdk::{ - testutils::{Address as _, Events, Ledger}, + testutils::{Address as _, Events, Ledger, LedgerInfo}, Address, Env, Vec, }; @@ -989,7 +989,7 @@ fn test_non_proposer_cannot_cancel() { #[test] fn test_proposal_expiration_cleanup() { let env = Env::default(); - env.ledger().set(soroban_sdk::ledger::LedgerInfo { + env.ledger().set(LedgerInfo { timestamp: 1000, protocol_version: 20, sequence_number: 10, @@ -1014,7 +1014,7 @@ fn test_proposal_expiration_cleanup() { AccessControlModule::create_proposal(&env, admin1.clone(), action).unwrap(); // Fast forward time past expiry (7 days + 1) - env.ledger().set(soroban_sdk::ledger::LedgerInfo { + env.ledger().set(LedgerInfo { timestamp: 1000 + 604801, protocol_version: 20, sequence_number: 10, @@ -1037,7 +1037,7 @@ fn test_proposal_expiration_cleanup() { #[test] fn test_cleanup_multiple_expired_proposals() { let env = Env::default(); - env.ledger().set(soroban_sdk::ledger::LedgerInfo { + env.ledger().set(LedgerInfo { timestamp: 1000, protocol_version: 20, sequence_number: 10, @@ -1071,7 +1071,7 @@ fn test_cleanup_multiple_expired_proposals() { assert_eq!(stats.pending_count, 3); // Fast forward time past expiry - env.ledger().set(soroban_sdk::ledger::LedgerInfo { + env.ledger().set(LedgerInfo { timestamp: 1000 + 604801, protocol_version: 20, sequence_number: 10, From 27e4373550d0eab8d129265ae1aed0d25197c589 Mon Sep 17 00:00:00 2001 From: Sandijigs Date: Thu, 29 Jan 2026 13:28:58 +0100 Subject: [PATCH 5/8] Fix needless borrows in test assertions Remove unnecessary reference operators for generic args in contains() calls --- contracts/access_control/src/access_control_tests.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/contracts/access_control/src/access_control_tests.rs b/contracts/access_control/src/access_control_tests.rs index 025c23f..d1781a8 100644 --- a/contracts/access_control/src/access_control_tests.rs +++ b/contracts/access_control/src/access_control_tests.rs @@ -1313,14 +1313,14 @@ fn test_get_pending_proposals_list() { let pending = AccessControlModule::get_pending_proposals(&env); assert_eq!(pending.len(), 2); - assert!(pending.contains(&id1)); - assert!(pending.contains(&id2)); + 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)); + assert!(pending.contains(id2)); }); } From 5f9952a19c27f879798f392a61a068096169c0c0 Mon Sep 17 00:00:00 2001 From: Sandijigs Date: Thu, 29 Jan 2026 13:58:16 +0100 Subject: [PATCH 6/8] Fix failing tests by adding time-lock advancement and correct protocol version - Add time advancement past time-lock for critical proposals (AddAdmin, BatchBlacklist, Pause) - Fix protocol version from 20 to 22 to resolve InternalError - Critical proposals require both critical_threshold AND time-lock to pass before execution --- .../src/access_control_tests.rs | 44 +++++++++++++++++-- 1 file changed, 40 insertions(+), 4 deletions(-) diff --git a/contracts/access_control/src/access_control_tests.rs b/contracts/access_control/src/access_control_tests.rs index d1781a8..7d00464 100644 --- a/contracts/access_control/src/access_control_tests.rs +++ b/contracts/access_control/src/access_control_tests.rs @@ -843,6 +843,18 @@ fn test_critical_proposal_requires_higher_threshold() { 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: 22, + 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(); @@ -991,7 +1003,7 @@ fn test_proposal_expiration_cleanup() { let env = Env::default(); env.ledger().set(LedgerInfo { timestamp: 1000, - protocol_version: 20, + protocol_version: 22, sequence_number: 10, network_id: [0; 32], base_reserve: 10, @@ -1016,7 +1028,7 @@ fn test_proposal_expiration_cleanup() { // Fast forward time past expiry (7 days + 1) env.ledger().set(LedgerInfo { timestamp: 1000 + 604801, - protocol_version: 20, + protocol_version: 22, sequence_number: 10, network_id: [0; 32], base_reserve: 10, @@ -1039,7 +1051,7 @@ fn test_cleanup_multiple_expired_proposals() { let env = Env::default(); env.ledger().set(LedgerInfo { timestamp: 1000, - protocol_version: 20, + protocol_version: 22, sequence_number: 10, network_id: [0; 32], base_reserve: 10, @@ -1073,7 +1085,7 @@ fn test_cleanup_multiple_expired_proposals() { // Fast forward time past expiry env.ledger().set(LedgerInfo { timestamp: 1000 + 604801, - protocol_version: 20, + protocol_version: 22, sequence_number: 10, network_id: [0; 32], base_reserve: 10, @@ -1238,6 +1250,18 @@ fn test_batch_blacklist_proposal() { 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: 22, + 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(); @@ -1266,6 +1290,18 @@ fn test_add_remove_admin_via_proposal() { 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: 22, + 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(); From a076c2e11fb2286b1ae2e660c8d5037ba8f37d29 Mon Sep 17 00:00:00 2001 From: Sandijigs Date: Thu, 29 Jan 2026 14:21:40 +0100 Subject: [PATCH 7/8] Update protocol version to 24 Previous version 22 was still too old for the host --- .../access_control/src/access_control_tests.rs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/contracts/access_control/src/access_control_tests.rs b/contracts/access_control/src/access_control_tests.rs index 7d00464..7a3e88d 100644 --- a/contracts/access_control/src/access_control_tests.rs +++ b/contracts/access_control/src/access_control_tests.rs @@ -846,7 +846,7 @@ fn test_critical_proposal_requires_higher_threshold() { // Fast forward time past time-lock (24 hours + 1 second) env.ledger().set(LedgerInfo { timestamp: env.ledger().timestamp() + 86401, - protocol_version: 22, + protocol_version: 24, sequence_number: 10, network_id: [0; 32], base_reserve: 10, @@ -1003,7 +1003,7 @@ fn test_proposal_expiration_cleanup() { let env = Env::default(); env.ledger().set(LedgerInfo { timestamp: 1000, - protocol_version: 22, + protocol_version: 24, sequence_number: 10, network_id: [0; 32], base_reserve: 10, @@ -1028,7 +1028,7 @@ fn test_proposal_expiration_cleanup() { // Fast forward time past expiry (7 days + 1) env.ledger().set(LedgerInfo { timestamp: 1000 + 604801, - protocol_version: 22, + protocol_version: 24, sequence_number: 10, network_id: [0; 32], base_reserve: 10, @@ -1051,7 +1051,7 @@ fn test_cleanup_multiple_expired_proposals() { let env = Env::default(); env.ledger().set(LedgerInfo { timestamp: 1000, - protocol_version: 22, + protocol_version: 24, sequence_number: 10, network_id: [0; 32], base_reserve: 10, @@ -1085,7 +1085,7 @@ fn test_cleanup_multiple_expired_proposals() { // Fast forward time past expiry env.ledger().set(LedgerInfo { timestamp: 1000 + 604801, - protocol_version: 22, + protocol_version: 24, sequence_number: 10, network_id: [0; 32], base_reserve: 10, @@ -1253,7 +1253,7 @@ fn test_batch_blacklist_proposal() { // Fast forward time past time-lock (24 hours + 1 second) env.ledger().set(LedgerInfo { timestamp: env.ledger().timestamp() + 86401, - protocol_version: 22, + protocol_version: 24, sequence_number: 10, network_id: [0; 32], base_reserve: 10, @@ -1293,7 +1293,7 @@ fn test_add_remove_admin_via_proposal() { // Fast forward time past time-lock (24 hours + 1 second) env.ledger().set(LedgerInfo { timestamp: env.ledger().timestamp() + 86401, - protocol_version: 22, + protocol_version: 24, sequence_number: 10, network_id: [0; 32], base_reserve: 10, From ae202f83e613f8ed23e5062507ef3f64548d3f01 Mon Sep 17 00:00:00 2001 From: Sandijigs Date: Thu, 29 Jan 2026 14:25:23 +0100 Subject: [PATCH 8/8] Set protocol version to 23 (exact version required by host) --- .../access_control/src/access_control_tests.rs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/contracts/access_control/src/access_control_tests.rs b/contracts/access_control/src/access_control_tests.rs index 7a3e88d..51ee17b 100644 --- a/contracts/access_control/src/access_control_tests.rs +++ b/contracts/access_control/src/access_control_tests.rs @@ -846,7 +846,7 @@ fn test_critical_proposal_requires_higher_threshold() { // Fast forward time past time-lock (24 hours + 1 second) env.ledger().set(LedgerInfo { timestamp: env.ledger().timestamp() + 86401, - protocol_version: 24, + protocol_version: 23, sequence_number: 10, network_id: [0; 32], base_reserve: 10, @@ -1003,7 +1003,7 @@ fn test_proposal_expiration_cleanup() { let env = Env::default(); env.ledger().set(LedgerInfo { timestamp: 1000, - protocol_version: 24, + protocol_version: 23, sequence_number: 10, network_id: [0; 32], base_reserve: 10, @@ -1028,7 +1028,7 @@ fn test_proposal_expiration_cleanup() { // Fast forward time past expiry (7 days + 1) env.ledger().set(LedgerInfo { timestamp: 1000 + 604801, - protocol_version: 24, + protocol_version: 23, sequence_number: 10, network_id: [0; 32], base_reserve: 10, @@ -1051,7 +1051,7 @@ fn test_cleanup_multiple_expired_proposals() { let env = Env::default(); env.ledger().set(LedgerInfo { timestamp: 1000, - protocol_version: 24, + protocol_version: 23, sequence_number: 10, network_id: [0; 32], base_reserve: 10, @@ -1085,7 +1085,7 @@ fn test_cleanup_multiple_expired_proposals() { // Fast forward time past expiry env.ledger().set(LedgerInfo { timestamp: 1000 + 604801, - protocol_version: 24, + protocol_version: 23, sequence_number: 10, network_id: [0; 32], base_reserve: 10, @@ -1253,7 +1253,7 @@ fn test_batch_blacklist_proposal() { // Fast forward time past time-lock (24 hours + 1 second) env.ledger().set(LedgerInfo { timestamp: env.ledger().timestamp() + 86401, - protocol_version: 24, + protocol_version: 23, sequence_number: 10, network_id: [0; 32], base_reserve: 10, @@ -1293,7 +1293,7 @@ fn test_add_remove_admin_via_proposal() { // Fast forward time past time-lock (24 hours + 1 second) env.ledger().set(LedgerInfo { timestamp: env.ledger().timestamp() + 86401, - protocol_version: 24, + protocol_version: 23, sequence_number: 10, network_id: [0; 32], base_reserve: 10,