diff --git a/quicklendx-contracts/src/events.rs b/quicklendx-contracts/src/events.rs index c3056db8..a49eb8d5 100644 --- a/quicklendx-contracts/src/events.rs +++ b/quicklendx-contracts/src/events.rs @@ -652,6 +652,27 @@ pub struct ProtocolInitialized { pub timestamp: u64, } +// ============================================================================ +// Pause Control Events + +#[contractevent] +pub struct Paused { + pub admin: Address, +} + +#[contractevent] +pub struct Unpaused { + pub admin: Address, +} + +pub fn emit_paused(env: &Env, admin: &Address) { + Paused { admin: admin.clone() }.publish(env); +} + +pub fn emit_unpaused(env: &Env, admin: &Address) { + Unpaused { admin: admin.clone() }.publish(env); +} + // ============================================================================ // Invoice Event Emitters // ============================================================================ diff --git a/quicklendx-contracts/src/pause.rs b/quicklendx-contracts/src/pause.rs index 8229b157..64307b68 100644 --- a/quicklendx-contracts/src/pause.rs +++ b/quicklendx-contracts/src/pause.rs @@ -1,4 +1,4 @@ -use crate::admin::AdminStorage; +use crate::admin::AdminStorage; use crate::errors::QuickLendXError; use soroban_sdk::{symbol_short, Address, Env, Symbol}; @@ -24,7 +24,18 @@ impl PauseControl { pub fn set_paused(env: &Env, admin: &Address, paused: bool) -> Result<(), QuickLendXError> { admin.require_auth(); AdminStorage::require_admin(env, admin)?; + // Check current paused state to ensure idempotency + let current: bool = Self::is_paused(env); + if current == paused { + return Ok(()); + } Self::apply_paused(env, paused); + // Emit appropriate event based on state transition + if paused { + crate::events::emit_paused(env, admin); + } else { + crate::events::emit_unpaused(env, admin); + } Ok(()) } diff --git a/quicklendx-contracts/src/test_pause.rs b/quicklendx-contracts/src/test_pause.rs index f6e8a452..f0a14715 100644 --- a/quicklendx-contracts/src/test_pause.rs +++ b/quicklendx-contracts/src/test_pause.rs @@ -5,7 +5,7 @@ use crate::errors::QuickLendXError; use crate::invoice::InvoiceCategory; use crate::{QuickLendXContract, QuickLendXContractClient}; use soroban_sdk::testutils::{Address as _, Ledger, MockAuth, MockAuthInvoke}; -use soroban_sdk::{token, Address, Env, IntoVal, String, Vec}; +use soroban_sdk::{token, Address, Env, IntoVal, String, Vec, Symbol, xdr}; /// Standard test setup: registers contract, initializes admin, generates test addresses. pub fn setup_contract_with_admin() -> (Env, QuickLendXContractClient<'static>, Address, Address) { @@ -184,6 +184,39 @@ fn test_pause_allows_admin_rotation_and_new_admin_unpause() { assert!(!client.is_paused()); } +#[test] +fn test_pause_idempotent_no_duplicate_events() { + let env = Env::default(); + let (client, admin, _business, _investor, _currency) = setup(&env); + // First pause should emit one Paused event + client.pause(&admin); + assert!(client.is_paused()); + // Count Paused events + let paused_topic = Symbol::new(&env, "paused"); + let topic_xdr = xdr::ScVal::try_from_val(&env, &paused_topic).unwrap(); + let count1 = env.events() + .all() + .events() + .iter() + .filter(|e| match &e.body { + soroban_sdk::xdr::ContractEventBody::V0(body) => body.topics.first() == Some(&topic_xdr), + }) + .count(); + assert_eq!(count1, 1); + // Second pause should not emit another event + client.pause(&admin); + let count2 = env.events() + .all() + .events() + .iter() + .filter(|e| match &e.body { + soroban_sdk::xdr::ContractEventBody::V0(body) => body.topics.first() == Some(&topic_xdr), + }) + .count(); + assert_eq!(count2, 1); + assert!(client.is_paused()); +} + #[test] fn test_pause_allows_emergency_withdraw_lifecycle() { let env = Env::default();