From 4a02e76c9ab7ba8bef80e978ee0368291b7ea2ab Mon Sep 17 00:00:00 2001 From: AugistineCreates Date: Fri, 26 Jun 2026 15:59:30 +0100 Subject: [PATCH 1/2] feat: cap max invoices per business (Closes #1063) --- quicklendx-contracts/src/contract.rs | 2 ++ quicklendx-contracts/src/protocol_limits.rs | 5 +++ .../src/test_max_invoices_per_business.rs | 32 +++++++++++++++++++ 3 files changed, 39 insertions(+) diff --git a/quicklendx-contracts/src/contract.rs b/quicklendx-contracts/src/contract.rs index 97994a30..4c7d2b03 100644 --- a/quicklendx-contracts/src/contract.rs +++ b/quicklendx-contracts/src/contract.rs @@ -134,6 +134,8 @@ impl QuickLendXContract { // This is the primary anti-spam control: only vetted businesses may write // invoice data to on-chain storage. crate::verification::require_business_not_pending(&env, &business)?; + // Enforce per-business invoice cap. + ProtocolLimitsContract::check_invoice_limit(&env, &business)?; let invoice_id: BytesN<32> = env .crypto() diff --git a/quicklendx-contracts/src/protocol_limits.rs b/quicklendx-contracts/src/protocol_limits.rs index 8d4496d7..f59e5cf5 100644 --- a/quicklendx-contracts/src/protocol_limits.rs +++ b/quicklendx-contracts/src/protocol_limits.rs @@ -226,6 +226,11 @@ impl ProtocolLimitsContract { }) } + /// Get the maximum active invoices per business. + pub fn get_max_invoices_per_business(env: Env) -> u32 { + Self::get_protocol_limits(env).max_invoices_per_business + } + /// @notice Validate invoice amount and due date against configured limits. pub fn validate_invoice(env: Env, amount: i128, due_date: u64) -> Result<(), QuickLendXError> { let current_time = env.ledger().timestamp(); diff --git a/quicklendx-contracts/src/test_max_invoices_per_business.rs b/quicklendx-contracts/src/test_max_invoices_per_business.rs index b5bb1916..f6471d29 100644 --- a/quicklendx-contracts/src/test_max_invoices_per_business.rs +++ b/quicklendx-contracts/src/test_max_invoices_per_business.rs @@ -3,6 +3,9 @@ mod test_max_invoices_per_business { use crate::errors::QuickLendXError; use crate::protocol_limits::is_active_status; use crate::types::InvoiceStatus; + use crate::{QuickLendXContract, QuickLendXContractClient}; + use soroban_sdk::{Address, Env, String, Vec}; + use crate::types::InvoiceCategory; // Core logic test extracted from check_invoice_limit architecture fn enforce_limit_logic(active_count: u32, limit: u32) -> Result<(), QuickLendXError> { @@ -53,4 +56,33 @@ mod test_max_invoices_per_business { assert_eq!(is_active_status(&InvoiceStatus::Cancelled), false); assert_eq!(is_active_status(&InvoiceStatus::Refunded), false); } + + #[test] + fn test_store_invoice_respects_cap() { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register(QuickLendXContract, ()); + let client = QuickLendXContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + client.set_admin(&admin); + // set low invoice cap = 2 + client.set_protocol_limits(&admin, 1_000i128, 1_000i128, 0u32, 365u64, 86400u64, 2).unwrap(); + + let business = Address::generate(&env); + let currency = Address::generate(&env); + let due_date = env.ledger().timestamp() + 86_400; + // verify business + client.submit_kyc_application(&business, &String::from_str(&env, "biz")); + client.verify_business(&admin, &business); + + // store two invoices successfully + for _ in 0..2 { + client.store_invoice(&business, 1_000i128, ¤cy, &due_date, &String::from_str(&env, "inv"), &InvoiceCategory::Services, &Vec::new(&env)).unwrap(); + } + + // third invoice should fail + let result = client.try_store_invoice(&business, 1_000i128, ¤cy, &due_date, &String::from_str(&env, "inv3"), &InvoiceCategory::Services, &Vec::new(&env)); + assert_eq!(result, Err(Ok(QuickLendXError::MaxInvoicesPerBusinessExceeded))); + } } From 23b564644a52269ddddae78111e2eec903101d73 Mon Sep 17 00:00:00 2001 From: AugistineCreates Date: Fri, 26 Jun 2026 16:46:56 +0100 Subject: [PATCH 2/2] feat: add idempotent pause test and pause events --- quicklendx-contracts/src/events.rs | 21 ++++++++++++++++ quicklendx-contracts/src/pause.rs | 13 +++++++++- quicklendx-contracts/src/test_pause.rs | 35 +++++++++++++++++++++++++- 3 files changed, 67 insertions(+), 2 deletions(-) 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();