From c5e5828c8f6c2e7697ef24ec564035d831ebe543 Mon Sep 17 00:00:00 2001 From: dot-enny Date: Fri, 26 Jun 2026 23:08:53 +0100 Subject: [PATCH 1/2] feat: two-phase MultisigManager threshold rotation --- contracts/predictify-hybrid/src/admin.rs | 87 ++++++++++++-- contracts/predictify-hybrid/src/events.rs | 61 ++++++++++ .../src/multi_admin_multisig_tests.rs | 107 ++++++++++++++---- 3 files changed, 228 insertions(+), 27 deletions(-) diff --git a/contracts/predictify-hybrid/src/admin.rs b/contracts/predictify-hybrid/src/admin.rs index 6cf8b536..c5a1cb4a 100644 --- a/contracts/predictify-hybrid/src/admin.rs +++ b/contracts/predictify-hybrid/src/admin.rs @@ -1657,30 +1657,103 @@ impl AdminManager { // ===== MULTISIG MANAGER ===== /// Manages multisig/threshold approval for sensitive admin operations +pub const THRESHOLD_ROTATION_DELAY: u64 = 86400; // 24 hours + +/// Pending threshold update for two-phase commit +#[derive(Clone, Debug)] +#[contracttype] +pub struct PendingThresholdUpdate { + pub new_threshold: u32, + pub proposed_at: u64, + pub proposed_by: Address, +} + pub struct MultisigManager; impl MultisigManager { - /// Set the multisig threshold (M-of-N) - pub fn set_threshold(env: &Env, admin: &Address, threshold: u32) -> Result<(), Error> { + /// Propose a new multisig threshold (M-of-N) + pub fn propose_threshold(env: &Env, admin: &Address, new_threshold: u32) -> Result<(), Error> { AdminAccessControl::validate_permission(env, admin, &AdminPermission::Emergency)?; let total_admins = Self::count_active_admins(env); - if threshold == 0 || threshold > total_admins { + if new_threshold == 0 || new_threshold > total_admins { return Err(Error::InvalidInput); } - let config = MultisigConfig { - threshold, - total_admins, - enabled: threshold > 1, + let pending = PendingThresholdUpdate { + new_threshold, + proposed_at: env.ledger().timestamp(), + proposed_by: admin.clone(), }; + env.storage() + .persistent() + .set(&Symbol::new(env, "PendingThreshold"), &pending); + + let config = Self::get_config(env); + EventEmitter::emit_threshold_proposed( + env, + admin, + config.threshold, + new_threshold, + env.ledger().timestamp() + THRESHOLD_ROTATION_DELAY, + ); + + Ok(()) + } + + /// Confirm a pending threshold update after the delay + pub fn confirm_threshold(env: &Env) -> Result<(), Error> { + let pending: PendingThresholdUpdate = env + .storage() + .persistent() + .get(&Symbol::new(env, "PendingThreshold")) + .ok_or(Error::InvalidState)?; + + if env.ledger().timestamp() < pending.proposed_at + THRESHOLD_ROTATION_DELAY { + return Err(Error::InvalidState); + } + + let total_admins = Self::count_active_admins(env); + if pending.new_threshold == 0 || pending.new_threshold > total_admins { + env.storage().persistent().remove(&Symbol::new(env, "PendingThreshold")); + return Err(Error::InvalidInput); + } + + let mut config = Self::get_config(env); + let old_threshold = config.threshold; + config.threshold = pending.new_threshold; + config.enabled = pending.new_threshold > 1; + config.total_admins = total_admins; + env.storage() .persistent() .set(&Symbol::new(env, "MultisigConfig"), &config); + + env.storage().persistent().remove(&Symbol::new(env, "PendingThreshold")); + + EventEmitter::emit_threshold_confirmed( + env, + &pending.proposed_by, + old_threshold, + pending.new_threshold, + ); + Ok(()) } + /// Cancel a pending threshold proposal + pub fn cancel_threshold_proposal(env: &Env, admin: &Address) -> Result<(), Error> { + AdminAccessControl::validate_permission(env, admin, &AdminPermission::Emergency)?; + + if env.storage().persistent().has(&Symbol::new(env, "PendingThreshold")) { + env.storage().persistent().remove(&Symbol::new(env, "PendingThreshold")); + Ok(()) + } else { + Err(Error::InvalidState) + } + } + /// Get current multisig configuration pub fn get_config(env: &Env) -> MultisigConfig { env.storage() diff --git a/contracts/predictify-hybrid/src/events.rs b/contracts/predictify-hybrid/src/events.rs index ee6b1de4..96de1f48 100644 --- a/contracts/predictify-hybrid/src/events.rs +++ b/contracts/predictify-hybrid/src/events.rs @@ -725,6 +725,25 @@ pub struct FeeWithdrawnEvent { pub timestamp: u64, } +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MultisigThresholdProposedEvent { + pub admin: Address, + pub old_threshold: u32, + pub new_threshold: u32, + pub confirm_after: u64, + pub timestamp: u64, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MultisigThresholdConfirmedEvent { + pub admin: Address, + pub old_threshold: u32, + pub new_threshold: u32, + pub timestamp: u64, +} + // ===== ORACLE RESULT VERIFICATION EVENTS ===== /// Event emitted when oracle result verification is initiated for a market. @@ -4282,6 +4301,48 @@ pub fn emit_manual_resolution_required(env: &Env, market_id: &Symbol, reason: &S } impl EventEmitter { + pub fn emit_threshold_proposed( + env: &Env, + admin: &Address, + old_threshold: u32, + new_threshold: u32, + confirm_after: u64, + ) { + let event = MultisigThresholdProposedEvent { + admin: admin.clone(), + old_threshold, + new_threshold, + confirm_after, + timestamp: env.ledger().timestamp(), + }; + + Self::store_event(env, &symbol_short!("thld_prop"), &event); + env.events().publish( + (symbol_short!("thld_prop"), admin.clone()), + event, + ); + } + + pub fn emit_threshold_confirmed( + env: &Env, + admin: &Address, + old_threshold: u32, + new_threshold: u32, + ) { + let event = MultisigThresholdConfirmedEvent { + admin: admin.clone(), + old_threshold, + new_threshold, + timestamp: env.ledger().timestamp(), + }; + + Self::store_event(env, &symbol_short!("thld_conf"), &event); + env.events().publish( + (symbol_short!("thld_conf"), admin.clone()), + event, + ); + } + /// Emit oracle callback event for oracle data updates pub fn emit_oracle_callback( env: &Env, diff --git a/contracts/predictify-hybrid/src/multi_admin_multisig_tests.rs b/contracts/predictify-hybrid/src/multi_admin_multisig_tests.rs index 3e131cea..96791c63 100644 --- a/contracts/predictify-hybrid/src/multi_admin_multisig_tests.rs +++ b/contracts/predictify-hybrid/src/multi_admin_multisig_tests.rs @@ -39,6 +39,73 @@ fn setup_contract() -> (Env, Address, Address) { (env, contract_id, admin) } +fn set_threshold_helper(env: &Env, admin: &Address, threshold: u32) -> Result<(), Error> { + MultisigManager::propose_threshold(env, admin, threshold)?; + // Needs warp_time which is defined later. Let's just define it here or move it. + // Actually, warp_time is defined at line 731. We can just duplicate the logic or use it if it's visible. + // Wait, let's just duplicate the warp_time logic here since Rust allows out of order function declarations. + warp_time(env, 86400); + MultisigManager::confirm_threshold(env) +} + +// ===== THRESHOLD ROTATION TWO-PHASE COMMIT TESTS ===== +#[test] +fn test_threshold_confirm_before_delay() { + let (env, contract_id, admin) = setup_contract(); + env.as_contract(&contract_id, || { + let admin2 = Address::generate(&env); + AdminManager::add_admin(&env, &admin, &admin2, AdminRole::SuperAdmin).unwrap(); + + MultisigManager::propose_threshold(&env, &admin, 2).unwrap(); + let res = MultisigManager::confirm_threshold(&env); + assert_eq!(res, Err(Error::InvalidState)); + + warp_time(&env, 40000); + let res = MultisigManager::confirm_threshold(&env); + assert_eq!(res, Err(Error::InvalidState)); + + warp_time(&env, 50000); + let res = MultisigManager::confirm_threshold(&env); + assert!(res.is_ok()); + }); +} + +#[test] +fn test_threshold_double_propose() { + let (env, contract_id, admin) = setup_contract(); + env.as_contract(&contract_id, || { + let admin2 = Address::generate(&env); + let admin3 = Address::generate(&env); + AdminManager::add_admin(&env, &admin, &admin2, AdminRole::SuperAdmin).unwrap(); + AdminManager::add_admin(&env, &admin, &admin3, AdminRole::SuperAdmin).unwrap(); + + MultisigManager::propose_threshold(&env, &admin, 2).unwrap(); + MultisigManager::propose_threshold(&env, &admin, 3).unwrap(); + + warp_time(&env, 86400); + MultisigManager::confirm_threshold(&env).unwrap(); + let config = MultisigManager::get_config(&env); + assert_eq!(config.threshold, 3); + }); +} + +#[test] +fn test_threshold_cancel_propose() { + let (env, contract_id, admin) = setup_contract(); + env.as_contract(&contract_id, || { + let admin2 = Address::generate(&env); + AdminManager::add_admin(&env, &admin, &admin2, AdminRole::SuperAdmin).unwrap(); + + MultisigManager::propose_threshold(&env, &admin, 2).unwrap(); + let res = MultisigManager::cancel_threshold_proposal(&env, &admin); + assert!(res.is_ok()); + + warp_time(&env, 86400); + let res = MultisigManager::confirm_threshold(&env); + assert_eq!(res, Err(Error::InvalidState)); + }); +} + // ===== SINGLE ADMIN TESTS (THRESHOLD 1) ===== #[test] @@ -121,7 +188,7 @@ fn test_set_threshold_2_of_3() { AdminManager::add_admin(&env, &admin, &admin2, AdminRole::SuperAdmin).unwrap(); AdminManager::add_admin(&env, &admin, &admin3, AdminRole::SuperAdmin).unwrap(); - let result = MultisigManager::set_threshold(&env, &admin, 2); + let result = set_threshold_helper(&env, &admin, 2); assert!(result.is_ok()); let config = MultisigManager::get_config(&env); @@ -135,7 +202,7 @@ fn test_set_threshold_invalid_zero() { let (env, contract_id, admin) = setup_contract(); env.as_contract(&contract_id, || { - let result = MultisigManager::set_threshold(&env, &admin, 0); + let result = set_threshold_helper(&env, &admin, 0); assert_eq!(result, Err(Error::InvalidInput)); }); } @@ -145,7 +212,7 @@ fn test_set_threshold_exceeds_admin_count() { let (env, contract_id, admin) = setup_contract(); env.as_contract(&contract_id, || { - let result = MultisigManager::set_threshold(&env, &admin, 5); + let result = set_threshold_helper(&env, &admin, 5); assert_eq!(result, Err(Error::InvalidInput)); }); } @@ -155,7 +222,7 @@ fn test_threshold_1_disables_multisig() { let (env, contract_id, admin) = setup_contract(); env.as_contract(&contract_id, || { - MultisigManager::set_threshold(&env, &admin, 1).unwrap(); + set_threshold_helper(&env, &admin, 1).unwrap(); let config = MultisigManager::get_config(&env); assert_eq!(config.threshold, 1); @@ -200,7 +267,7 @@ fn test_approve_pending_action() { env.as_contract(&contract_id, || { AdminManager::add_admin(&env, &admin, &admin2, AdminRole::SuperAdmin).unwrap(); - MultisigManager::set_threshold(&env, &admin, 2).unwrap(); + set_threshold_helper(&env, &admin, 2).unwrap(); let data = Map::new(&env); let action_type = String::from_str(&env, "add_admin"); @@ -252,7 +319,7 @@ fn test_execute_action_threshold_met() { env.as_contract(&contract_id, || { AdminManager::add_admin(&env, &admin, &admin2, AdminRole::SuperAdmin).unwrap(); - MultisigManager::set_threshold(&env, &admin, 2).unwrap(); + set_threshold_helper(&env, &admin, 2).unwrap(); let data = Map::new(&env); let action_type = String::from_str(&env, "add_admin"); @@ -278,7 +345,7 @@ fn test_execute_action_threshold_not_met() { env.as_contract(&contract_id, || { AdminManager::add_admin(&env, &admin, &admin2, AdminRole::SuperAdmin).unwrap(); - MultisigManager::set_threshold(&env, &admin, 2).unwrap(); + set_threshold_helper(&env, &admin, 2).unwrap(); let data = Map::new(&env); let action_type = String::from_str(&env, "add_admin"); @@ -325,7 +392,7 @@ fn test_2_of_3_multisig_workflow() { AdminManager::add_admin(&env, &admin1, &admin3, AdminRole::SuperAdmin).unwrap(); // Set threshold to 2 - MultisigManager::set_threshold(&env, &admin1, 2).unwrap(); + set_threshold_helper(&env, &admin1, 2).unwrap(); // Create pending action let data = Map::new(&env); @@ -370,7 +437,7 @@ fn test_3_of_5_multisig_workflow() { AdminManager::add_admin(&env, &admin1, &admin5, AdminRole::SuperAdmin).unwrap(); // Set threshold to 3 - MultisigManager::set_threshold(&env, &admin1, 3).unwrap(); + set_threshold_helper(&env, &admin1, 3).unwrap(); let config = MultisigManager::get_config(&env); assert_eq!(config.threshold, 3); @@ -409,7 +476,7 @@ fn test_sensitive_operation_requires_threshold() { env.as_contract(&contract_id, || { AdminManager::add_admin(&env, &admin1, &admin2, AdminRole::SuperAdmin).unwrap(); - MultisigManager::set_threshold(&env, &admin1, 2).unwrap(); + set_threshold_helper(&env, &admin1, 2).unwrap(); assert!(MultisigManager::requires_multisig(&env)); }); @@ -423,7 +490,7 @@ fn test_add_admin_with_multisig_enabled() { env.as_contract(&contract_id, || { AdminManager::add_admin(&env, &admin1, &admin2, AdminRole::SuperAdmin).unwrap(); - MultisigManager::set_threshold(&env, &admin1, 2).unwrap(); + set_threshold_helper(&env, &admin1, 2).unwrap(); // When multisig is enabled, direct admin operations should still work // but in production, you'd want to enforce multisig workflow @@ -497,7 +564,7 @@ fn test_unauthorized_set_threshold() { let unauthorized = Address::generate(&env); env.as_contract(&contract_id, || { - let result = MultisigManager::set_threshold(&env, &unauthorized, 2); + let result = set_threshold_helper(&env, &unauthorized, 2); assert_eq!(result, Err(Error::Unauthorized)); }); } @@ -583,7 +650,7 @@ fn test_multisig_config_persistence() { env.as_contract(&contract_id, || { AdminManager::add_admin(&env, &admin, &admin2, AdminRole::SuperAdmin).unwrap(); - MultisigManager::set_threshold(&env, &admin, 2).unwrap(); + set_threshold_helper(&env, &admin, 2).unwrap(); let config1 = MultisigManager::get_config(&env); assert_eq!(config1.threshold, 2); @@ -604,7 +671,7 @@ fn test_requires_multisig_check() { let admin2 = Address::generate(&env); AdminManager::add_admin(&env, &admin, &admin2, AdminRole::SuperAdmin).unwrap(); - MultisigManager::set_threshold(&env, &admin, 2).unwrap(); + set_threshold_helper(&env, &admin, 2).unwrap(); assert_eq!(MultisigManager::requires_multisig(&env), true); }); @@ -656,7 +723,7 @@ fn test_complete_multisig_lifecycle() { // Setup AdminManager::add_admin(&env, &admin1, &admin2, AdminRole::SuperAdmin).unwrap(); AdminManager::add_admin(&env, &admin1, &admin3, AdminRole::SuperAdmin).unwrap(); - MultisigManager::set_threshold(&env, &admin1, 2).unwrap(); + set_threshold_helper(&env, &admin1, 2).unwrap(); // Create action let mut data = Map::new(&env); @@ -715,7 +782,7 @@ fn setup_multisig_2_of_3(env: &Env) -> (Address, Address, Address) { let admin3 = Address::generate(env); AdminManager::add_admin(env, &admin1, &admin2, AdminRole::SuperAdmin).unwrap(); AdminManager::add_admin(env, &admin1, &admin3, AdminRole::SuperAdmin).unwrap(); - MultisigManager::set_threshold(env, &admin1, 2).unwrap(); + set_threshold_helper(env, &admin1, 2).unwrap(); (admin1, admin2, admin3) } @@ -908,7 +975,7 @@ fn test_remove_admin_can_leave_threshold_above_count() { // A fresh set_threshold > count is rejected, confirming guard exists // on write path but not on admin-count shrink. assert_eq!( - MultisigManager::set_threshold(&env, &admin1, 5), + set_threshold_helper(&env, &admin1, 5), Err(Error::InvalidInput) ); }); @@ -924,7 +991,7 @@ fn test_add_admin_does_not_retroactively_approve_pending_action() { env.as_contract(&contract_id, || { let admin2 = Address::generate(&env); AdminManager::add_admin(&env, &admin1, &admin2, AdminRole::SuperAdmin).unwrap(); - MultisigManager::set_threshold(&env, &admin1, 2).unwrap(); + set_threshold_helper(&env, &admin1, 2).unwrap(); let action_id = MultisigManager::create_pending_action( &env, @@ -956,7 +1023,7 @@ fn test_lower_threshold_after_approvals_permits_execution() { let admin3 = Address::generate(&env); AdminManager::add_admin(&env, &admin1, &admin2, AdminRole::SuperAdmin).unwrap(); AdminManager::add_admin(&env, &admin1, &admin3, AdminRole::SuperAdmin).unwrap(); - MultisigManager::set_threshold(&env, &admin1, 3).unwrap(); + set_threshold_helper(&env, &admin1, 3).unwrap(); let action_id = MultisigManager::create_pending_action( &env, @@ -975,7 +1042,7 @@ fn test_lower_threshold_after_approvals_permits_execution() { ); // Lower threshold to 2, now execution succeeds. - MultisigManager::set_threshold(&env, &admin1, 2).unwrap(); + set_threshold_helper(&env, &admin1, 2).unwrap(); MultisigManager::execute_action(&env, action_id).unwrap(); }); } From 3cc6d62d3ca88e76242d1a7611b831d2706c5d04 Mon Sep 17 00:00:00 2001 From: dot-enny Date: Sat, 27 Jun 2026 00:29:33 +0100 Subject: [PATCH 2/2] test: fix missing Option argument in require_auth_coverage_tests --- .../predictify-hybrid/src/require_auth_coverage_tests.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/contracts/predictify-hybrid/src/require_auth_coverage_tests.rs b/contracts/predictify-hybrid/src/require_auth_coverage_tests.rs index 47c5fcae..4cdb610b 100644 --- a/contracts/predictify-hybrid/src/require_auth_coverage_tests.rs +++ b/contracts/predictify-hybrid/src/require_auth_coverage_tests.rs @@ -1005,7 +1005,7 @@ fn test_set_event_bet_limits_forged_admin_rejected() { #[test] fn test_set_oracle_val_cfg_global_authorized_admin_succeeds() { let (env, cid, admin) = setup(); - let result = client(&env, &cid).try_set_oracle_val_cfg_global(&admin, &300u64, &9500u32); + let result = client(&env, &cid).try_set_oracle_val_cfg_global(&admin, &300u64, &9500u32, &None); assert_auth_ok_contract!(result, "set_oracle_val_cfg_global rejected authorized admin"); } @@ -1014,7 +1014,7 @@ fn test_set_oracle_val_cfg_global_authorized_admin_succeeds() { fn test_set_oracle_val_cfg_global_forged_admin_rejected() { let (env, cid, _admin) = setup(); let attacker = Address::generate(&env); - let result = client(&env, &cid).try_set_oracle_val_cfg_global(&attacker, &300u64, &9500u32); + let result = client(&env, &cid).try_set_oracle_val_cfg_global(&attacker, &300u64, &9500u32, &None); assert_unauthorized_contract!(result); } @@ -1026,7 +1026,7 @@ fn test_set_oracle_val_cfg_event_authorized_admin_succeeds() { let (env, cid, admin) = setup(); let market_id = make_market(&env, &cid, &admin); let result = client(&env, &cid).try_set_oracle_val_cfg_event( - &admin, &market_id, &300u64, &9500u32, + &admin, &market_id, &300u64, &9500u32, &None, ); assert_auth_ok_contract!(result, "set_oracle_val_cfg_event rejected authorized admin"); } @@ -1038,7 +1038,7 @@ fn test_set_oracle_val_cfg_event_forged_admin_rejected() { let market_id = make_market(&env, &cid, &admin); let attacker = Address::generate(&env); let result = client(&env, &cid).try_set_oracle_val_cfg_event( - &attacker, &market_id, &300u64, &9500u32, + &attacker, &market_id, &300u64, &9500u32, &None, ); assert_unauthorized_contract!(result); }