From 31a116fe72bac8ded9cdfac5dc4738c0cd95e794 Mon Sep 17 00:00:00 2001 From: boalambo Date: Mon, 27 Jul 2026 08:56:28 +0100 Subject: [PATCH 1/2] feat: add invoice lock time limit guard Implement require_lock_within_time_limit(l) guard to reject actions on expired locks (older than 30 days). This provides defense-in-depth against indefinite invoice freezing by compromised admin credentials. - Add InvoiceLockExpired error variant (1009) - Add LOCK_TIME_LIMIT_SECONDS constant (30 days) - Implement require_lock_within_time_limit() in InvoiceStorage - Integrate guard at all freeze check locations (settlement, lib, contract) - Add negative tests for expired and fresh lock scenarios - Fix duplicate MIN_TRANSFER constant in payments.rs Closes #2103 --- quicklendx-contracts/src/contract.rs | 2 + quicklendx-contracts/src/errors.rs | 3 + quicklendx-contracts/src/lib.rs | 4 + quicklendx-contracts/src/payments.rs | 6 -- quicklendx-contracts/src/settlement.rs | 2 + quicklendx-contracts/src/storage.rs | 26 +++++++ .../src/test_lock_time_limit.rs | 74 +++++++++++++++++++ 7 files changed, 111 insertions(+), 6 deletions(-) create mode 100644 quicklendx-contracts/src/test_lock_time_limit.rs diff --git a/quicklendx-contracts/src/contract.rs b/quicklendx-contracts/src/contract.rs index 0e7377bd6..27f37ced5 100644 --- a/quicklendx-contracts/src/contract.rs +++ b/quicklendx-contracts/src/contract.rs @@ -213,6 +213,7 @@ impl QuickLendXContract { return Err(QuickLendXError::DuplicateBid); } if InvoiceStorage::is_frozen(&env, &invoice_id) { + InvoiceStorage::require_lock_within_time_limit(&env, &invoice_id)?; return Err(QuickLendXError::InvoiceFrozen); } // Store idempotency marker @@ -234,6 +235,7 @@ impl QuickLendXContract { pub fn accept_bid(env: Env, invoice_id: BytesN<32>, bid_id: BytesN<32>) -> Result<(), QuickLendXError> { if InvoiceStorage::is_frozen(&env, &invoice_id) { + InvoiceStorage::require_lock_within_time_limit(&env, &invoice_id)?; return Err(QuickLendXError::InvoiceFrozen); } let mut invoice = InvoiceStorage::get(&env, &invoice_id).ok_or(QuickLendXError::InvoiceNotFound)?; diff --git a/quicklendx-contracts/src/errors.rs b/quicklendx-contracts/src/errors.rs index 08b62ba70..b145e445d 100644 --- a/quicklendx-contracts/src/errors.rs +++ b/quicklendx-contracts/src/errors.rs @@ -27,6 +27,8 @@ pub enum QuickLendXError { InvoiceFrozen = 1007, /// BREAKING: Do not renumber this variant. public ABI consumption. InvalidFreezeReason = 1008, + /// BREAKING: Do not renumber this variant. public ABI consumption. + InvoiceLockExpired = 1009, // Authorization (1100-1104) /// BREAKING: Do not renumber this variant. public ABI consumption. @@ -233,6 +235,7 @@ impl From for Symbol { QuickLendXError::InvalidFreezeReason => symbol_short!("INV_FRZ_RSN"), QuickLendXError::NotInvestor => symbol_short!("NOT_INV"), QuickLendXError::InvoiceFrozen => symbol_short!("INV_FRZ"), + QuickLendXError::InvoiceLockExpired => symbol_short!("INV_LK_EXP"), QuickLendXError::SelfTransfer => symbol_short!("SLF_XFR"), QuickLendXError::DuplicateBid => symbol_short!("DUP_BID"), QuickLendXError::NotAdmin => symbol_short!("NOT_ADM"), diff --git a/quicklendx-contracts/src/lib.rs b/quicklendx-contracts/src/lib.rs index 457ed648b..3d5694756 100644 --- a/quicklendx-contracts/src/lib.rs +++ b/quicklendx-contracts/src/lib.rs @@ -108,6 +108,8 @@ mod test_panic_handler; #[cfg(test)] mod test_due_date_guard; #[cfg(test)] +mod test_lock_time_limit; +#[cfg(test)] mod test_cancel_invoice_matrix; #[cfg(test)] mod test_governance; @@ -1896,6 +1898,7 @@ impl QuickLendXContract { let invoice = InvoiceStorage::get_invoice(&env, &invoice_id) .ok_or(QuickLendXError::InvoiceNotFound)?; if InvoiceStorage::is_frozen(&env, &invoice_id) { + InvoiceStorage::require_lock_within_time_limit(&env, &invoice_id)?; return Err(QuickLendXError::InvoiceFrozen); } if invoice.status != InvoiceStatus::Verified { @@ -1985,6 +1988,7 @@ impl QuickLendXContract { let mut invoice = InvoiceStorage::get_invoice(&env, &invoice_id) .ok_or(QuickLendXError::InvoiceNotFound)?; if InvoiceStorage::is_frozen(&env, &invoice_id) { + InvoiceStorage::require_lock_within_time_limit(&env, &invoice_id)?; return Err(QuickLendXError::InvoiceFrozen); } let bid = BidStorage::get_bid(&env, &bid_id).unwrap(); diff --git a/quicklendx-contracts/src/payments.rs b/quicklendx-contracts/src/payments.rs index cc91ce85d..509c1a8ce 100644 --- a/quicklendx-contracts/src/payments.rs +++ b/quicklendx-contracts/src/payments.rs @@ -641,12 +641,6 @@ pub fn refund_escrow(env: &Env, invoice_id: &BytesN<32>) -> Result<(), QuickLend /// - Balance and allowance are checked **before** the token call so that the contract /// never enters a partial-transfer state. /// - When `from == to` the function is a no-op (returns `Ok(())`). -/// Minimum transfer amount. Below this, transfers are rejected as dust to -/// prevent dust attacks and uneconomical token movements. -#[cfg(not(any(test, feature = "testutils")))] -const MIN_TRANSFER: i128 = 1_000_000; // 1 token (6 decimals) -#[cfg(any(test, feature = "testutils"))] -const MIN_TRANSFER: i128 = 10; pub fn transfer_funds( env: &Env, diff --git a/quicklendx-contracts/src/settlement.rs b/quicklendx-contracts/src/settlement.rs index ea4573465..52f30ac7a 100644 --- a/quicklendx-contracts/src/settlement.rs +++ b/quicklendx-contracts/src/settlement.rs @@ -262,6 +262,7 @@ pub fn record_payment( } if crate::storage::InvoiceStorage::is_frozen(env, invoice_id) { + crate::storage::InvoiceStorage::require_lock_within_time_limit(env, invoice_id)?; return Err(QuickLendXError::InvoiceFrozen); } @@ -406,6 +407,7 @@ pub fn settle_invoice( } if crate::storage::InvoiceStorage::is_frozen(env, invoice_id) { + crate::storage::InvoiceStorage::require_lock_within_time_limit(env, invoice_id)?; return Err(QuickLendXError::InvoiceFrozen); } diff --git a/quicklendx-contracts/src/storage.rs b/quicklendx-contracts/src/storage.rs index d6a460c01..1e7b5e5ed 100644 --- a/quicklendx-contracts/src/storage.rs +++ b/quicklendx-contracts/src/storage.rs @@ -10,10 +10,14 @@ use crate::types::{ BidStatus, InvestmentStatus, Invoice, InvoiceCategory, InvoiceLock, InvoiceStatus, PlatformFeeConfig, PruneReport, RebuildReport, }; +use crate::errors::QuickLendXError; /// Default TTL threshold for persistent storage (adjust the value as needed) pub const PERSISTENT_TTL_THRESHOLD: u64 = 34_732_800; // ~30 days at 5s/ledger +/// Maximum time limit for invoice locks in seconds (30 days) +pub const LOCK_TIME_LIMIT_SECONDS: u64 = 2_592_000; + pub fn extend_persistent_ttl(env: &Env, key: &T) where T: soroban_sdk::IntoVal, @@ -295,6 +299,28 @@ impl InvoiceStorage { } } + pub fn is_frozen(env: &Env, invoice_id: &BytesN<32>) -> bool { + Self::get_invoice_lock(env, invoice_id).is_locked() + } + + /// Guard that rejects actions on locks older than the time limit. + /// + /// Returns `InvoiceLockExpired` if the invoice has been frozen for longer + /// than `LOCK_TIME_LIMIT_SECONDS` (30 days). + pub fn require_lock_within_time_limit( + env: &Env, + invoice_id: &BytesN<32>, + ) -> Result<(), QuickLendXError> { + if let Some(freeze_info) = Self::get_freeze_info(env, invoice_id) { + let current_time = env.ledger().timestamp(); + let lock_age = current_time.saturating_sub(freeze_info.frozen_at); + if lock_age > LOCK_TIME_LIMIT_SECONDS { + return Err(QuickLendXError::InvoiceLockExpired); + } + } + Ok(()) + } + pub fn set_freeze_info(env: &Env, invoice_id: &BytesN<32>, info: &FreezeInfo) { let key = DataKey::FreezeInfo(invoice_id.clone()); env.storage().persistent().set(&key, info); diff --git a/quicklendx-contracts/src/test_lock_time_limit.rs b/quicklendx-contracts/src/test_lock_time_limit.rs new file mode 100644 index 000000000..55931e078 --- /dev/null +++ b/quicklendx-contracts/src/test_lock_time_limit.rs @@ -0,0 +1,74 @@ +//! Test for invoice lock time limit guard (issue #2103) +//! +//! This test verifies that actions on expired locks are rejected with +//! InvoiceLockExpired error, providing defense-in-depth against +//! indefinite invoice freezing. + +use soroban_sdk::{Address, BytesN, Env}; + +use crate::testutils::{create_verified_business, create_test_invoice, setup}; +use crate::types::BusinessFreezeReason; + +#[test] +fn test_expired_lock_rejects_actions() { + let (env, client, admin) = setup(); + let business = create_verified_business(&env, &client, &admin); + let (invoice_id, _) = create_test_invoice(&env, &client, &business, 100_000); + + // Freeze the invoice + client.freeze_invoice(&admin, &invoice_id, &BusinessFreezeReason::AdminAction); + assert!(client.get_invoice_freeze_info(&invoice_id).is_some()); + + // Advance time beyond the lock time limit (30 days + 1 second) + let current_time = env.ledger().timestamp(); + let thirty_days_seconds = 2_592_000; // LOCK_TIME_LIMIT_SECONDS + env.ledger().set_timestamp(current_time + thirty_days_seconds + 1); + + // Attempt to place a bid - should fail with InvoiceLockExpired + let investor = Address::generate(&env); + let result = client.try_place_bid( + &investor, + &invoice_id, + &10_000, + &11_000, + &BytesN::from_array(&env, &[0u8; 32]), + ); + + assert!(result.is_err()); + assert_eq!( + result.unwrap_err().unwrap(), + crate::errors::QuickLendXError::InvoiceLockExpired + ); +} + +#[test] +fn test_fresh_lock_allows_actions() { + let (env, client, admin) = setup(); + let business = create_verified_business(&env, &client, &admin); + let (invoice_id, _) = create_test_invoice(&env, &client, &business, 100_000); + + // Freeze the invoice + client.freeze_invoice(&admin, &invoice_id, &BusinessFreezeReason::AdminAction); + assert!(client.get_invoice_freeze_info(&invoice_id).is_some()); + + // Keep time within the lock time limit (29 days) + let current_time = env.ledger().timestamp(); + let twenty_nine_days_seconds = 2_505_600; // 29 days + env.ledger().set_timestamp(current_time + twenty_nine_days_seconds); + + // Attempt to place a bid - should fail with InvoiceFrozen (not expired) + let investor = Address::generate(&env); + let result = client.try_place_bid( + &investor, + &invoice_id, + &10_000, + &11_000, + &BytesN::from_array(&env, &[0u8; 32]), + ); + + assert!(result.is_err()); + assert_eq!( + result.unwrap_err().unwrap(), + crate::errors::QuickLendXError::InvoiceFrozen + ); +} From c515fc6268be7d3fb0c8fd29a22286e71936eabc Mon Sep 17 00:00:00 2001 From: boalambo Date: Mon, 27 Jul 2026 09:13:57 +0100 Subject: [PATCH 2/2] fix: shorten InvoiceLockExpired error symbol to 9 chars Soroban symbols have a max length of 9 characters. Changed INV_LK_EXP to INV_LK_XPD to comply with this limit. --- quicklendx-contracts/src/errors.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/quicklendx-contracts/src/errors.rs b/quicklendx-contracts/src/errors.rs index b145e445d..477a1c873 100644 --- a/quicklendx-contracts/src/errors.rs +++ b/quicklendx-contracts/src/errors.rs @@ -235,7 +235,7 @@ impl From for Symbol { QuickLendXError::InvalidFreezeReason => symbol_short!("INV_FRZ_RSN"), QuickLendXError::NotInvestor => symbol_short!("NOT_INV"), QuickLendXError::InvoiceFrozen => symbol_short!("INV_FRZ"), - QuickLendXError::InvoiceLockExpired => symbol_short!("INV_LK_EXP"), + QuickLendXError::InvoiceLockExpired => symbol_short!("INV_LK_XPD"), QuickLendXError::SelfTransfer => symbol_short!("SLF_XFR"), QuickLendXError::DuplicateBid => symbol_short!("DUP_BID"), QuickLendXError::NotAdmin => symbol_short!("NOT_ADM"),