diff --git a/quicklendx-contracts/src/contract.rs b/quicklendx-contracts/src/contract.rs index 55375d9ae..693c02a04 100644 --- a/quicklendx-contracts/src/contract.rs +++ b/quicklendx-contracts/src/contract.rs @@ -225,6 +225,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 @@ -246,6 +247,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 09c4742fb..1ea876144 100644 --- a/quicklendx-contracts/src/errors.rs +++ b/quicklendx-contracts/src/errors.rs @@ -31,6 +31,9 @@ pub enum QuickLendXError { /// this separates "who can configure the protocol" from "who can /// adjudicate a dispute". /// BREAKING: Do not renumber this variant. public ABI consumption. + InvalidFreezeReason = 1008, + /// BREAKING: Do not renumber this variant. public ABI consumption. + InvoiceLockExpired = 1009, NotArbiter = 1008, // Authorization (1100-1104) @@ -259,6 +262,10 @@ impl From for Symbol { QuickLendXError::Unauthorized => symbol_short!("UNAUTH"), QuickLendXError::NotBusinessOwner => symbol_short!("NOT_OWN"), QuickLendXError::NotInvestor => symbol_short!("NOT_INV"), + QuickLendXError::InvoiceFrozen => symbol_short!("INV_FRZ"), + 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"), QuickLendXError::SelfCallNotAllowed => symbol_short!("SELF_NA"), // Input validation diff --git a/quicklendx-contracts/src/lib.rs b/quicklendx-contracts/src/lib.rs index addf49234..50b619ee4 100644 --- a/quicklendx-contracts/src/lib.rs +++ b/quicklendx-contracts/src/lib.rs @@ -117,6 +117,7 @@ mod test_panic_handler; #[cfg(test)] mod test_due_date_guard; #[cfg(test)] +mod test_lock_time_limit; mod test_auto_resolution_boundary; #[cfg(test)] mod test_cancel_invoice_matrix; @@ -2361,6 +2362,10 @@ impl QuickLendXContract { // Validate invoice exists and is verified 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); + } require_no_active_freeze(&env, &invoice_id)?; if invoice.status != InvoiceStatus::Verified { return Err(QuickLendXError::InvalidStatus); @@ -2453,6 +2458,10 @@ impl QuickLendXContract { BidStorage::cleanup_expired_bids(&env, &invoice_id); 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); + } require_no_active_freeze(&env, &invoice_id)?; let bid = BidStorage::get_bid(&env, &bid_id).unwrap(); let invoice_id = bid.invoice_id.clone(); diff --git a/quicklendx-contracts/src/payments.rs b/quicklendx-contracts/src/payments.rs index 68d426d42..1fdc44ee3 100644 --- a/quicklendx-contracts/src/payments.rs +++ b/quicklendx-contracts/src/payments.rs @@ -734,6 +734,7 @@ 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(())`). + pub fn transfer_funds( env: &Env, currency: &Address, diff --git a/quicklendx-contracts/src/settlement.rs b/quicklendx-contracts/src/settlement.rs index 24511e9f6..b8a6c7557 100644 --- a/quicklendx-contracts/src/settlement.rs +++ b/quicklendx-contracts/src/settlement.rs @@ -266,6 +266,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); } @@ -410,6 +411,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 9ed04d85b..2f6e87463 100644 --- a/quicklendx-contracts/src/storage.rs +++ b/quicklendx-contracts/src/storage.rs @@ -10,10 +10,14 @@ use crate::types::{ BidStatus, BusinessFreezeReason, FreezeInfo, 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, @@ -318,6 +322,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 + ); +}