Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions quicklendx-contracts/src/contract.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)?;
Expand Down
7 changes: 7 additions & 0 deletions quicklendx-contracts/src/errors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -259,6 +262,10 @@ impl From<QuickLendXError> 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
Expand Down
9 changes: 9 additions & 0 deletions quicklendx-contracts/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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();
Expand Down
1 change: 1 addition & 0 deletions quicklendx-contracts/src/payments.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions quicklendx-contracts/src/settlement.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down Expand Up @@ -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);
}

Expand Down
26 changes: 26 additions & 0 deletions quicklendx-contracts/src/storage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>(env: &Env, key: &T)
where
T: soroban_sdk::IntoVal<soroban_sdk::Env, soroban_sdk::Val>,
Expand Down Expand Up @@ -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);
Expand Down
74 changes: 74 additions & 0 deletions quicklendx-contracts/src/test_lock_time_limit.rs
Original file line number Diff line number Diff line change
@@ -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
);
}
Loading