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
632 changes: 316 additions & 316 deletions docs/contracts/defaults.md

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions quicklendx-contracts/src/admin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@
//! - `ADMIN_INITIALIZED_KEY`: Initialization flag (prevents re-initialization)
//! - `ADMIN_TRANSFER_LOCK_KEY`: Transfer lock (prevents concurrent transfers)

#![allow(dead_code)]

use crate::errors::QuickLendXError;
use soroban_sdk::{symbol_short, Address, Env, Symbol};

Expand Down
2 changes: 2 additions & 0 deletions quicklendx-contracts/src/analytics.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
#![allow(dead_code)]

use crate::errors::QuickLendXError;
use crate::invoice::{InvoiceCategory, InvoiceStatus};
use soroban_sdk::{contracttype, symbol_short, Address, Bytes, BytesN, Env, String, Vec};
Expand Down
2 changes: 2 additions & 0 deletions quicklendx-contracts/src/audit.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
#![allow(dead_code)]

use crate::errors::QuickLendXError;
use crate::invoice::{Invoice, InvoiceStatus};
use soroban_sdk::{contracttype, symbol_short, Address, BytesN, Env, String, Vec};
Expand Down
2 changes: 2 additions & 0 deletions quicklendx-contracts/src/bid.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ use soroban_sdk::{contracttype, symbol_short, Address, BytesN, Env, Symbol, Vec}
use crate::admin::AdminStorage;
use crate::errors::QuickLendXError;
use crate::events::{emit_bid_expired, emit_bid_ttl_updated};
// Re-export from crate::types so other modules can continue to import from crate::bid.
pub use crate::types::{Bid, BidStatus};

// ─── Bid TTL configuration ────────────────────────────────────────────────────
//
Expand Down
49 changes: 49 additions & 0 deletions quicklendx-contracts/src/defaults.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,50 @@ pub const MAX_OVERDUE_SCAN_BATCH_LIMIT: u32 = 100;

const OVERDUE_SCAN_CURSOR_KEY: soroban_sdk::Symbol = symbol_short!("ovd_scan");

/// Storage key for default transition guards.
/// Format: (symbol_short!("def_guard"), invoice_id) -> bool
const DEFAULT_TRANSITION_GUARD_KEY: soroban_sdk::Symbol = symbol_short!("def_guard");

/// Transition guard to ensure default transitions are atomic and idempotent.
/// Tracks whether a default transition has been initiated for a specific invoice.
#[contracttype]
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct TransitionGuard {
/// Whether the default transition has been triggered
pub triggered: bool,
}

/// @notice Checks if a default transition guard exists for the given invoice.
/// @dev Returns true if the guard is set (transition already attempted), false otherwise.
/// @param env The contract environment.
/// @param invoice_id The invoice ID to check.
/// @return true if default transition has been guarded, false otherwise.
fn is_default_transition_guarded(env: &Env, invoice_id: &BytesN<32>) -> bool {
env.storage()
.persistent()
.has(&(DEFAULT_TRANSITION_GUARD_KEY, invoice_id))
}

/// @notice Atomically checks and sets the default transition guard.
/// @dev This ensures that only one default transition can be initiated per invoice.
/// If the guard is already set, returns DuplicateDefaultTransition error.
/// Otherwise, sets the guard and returns Ok(()).
/// @param env The contract environment.
/// @param invoice_id The invoice ID to guard.
/// @return Ok(()) if guard was successfully set, Err(DuplicateDefaultTransition) if already guarded.
fn check_and_set_default_guard(env: &Env, invoice_id: &BytesN<32>) -> Result<(), QuickLendXError> {
let key = (DEFAULT_TRANSITION_GUARD_KEY, invoice_id);

// Check if guard is already set
if env.storage().persistent().has(&key) {
return Err(QuickLendXError::DuplicateDefaultTransition);
}

// Set the guard atomically
env.storage().persistent().set(&key, &true);
Ok(())
}

/// Result metadata returned by the bounded overdue invoice scanner.
#[contracttype]
#[derive(Clone, Debug, Eq, PartialEq)]
Expand Down Expand Up @@ -227,7 +271,12 @@ pub fn scan_funded_invoice_expirations(
/// @notice Applies the default transition after all time and status checks have passed.
/// @dev This helper does not re-check the grace-period cutoff and must only be reached from
/// validated call sites such as `mark_invoice_defaulted` or `check_and_handle_expiration`.
/// The transition guard ensures atomicity and idempotency of default operations.
/// @security The guard prevents race conditions and duplicate side effects (analytics, state initialization).
pub fn handle_default(env: &Env, invoice_id: &BytesN<32>) -> Result<(), QuickLendXError> {
// Atomically check and set the transition guard to prevent duplicate defaults
check_and_set_default_guard(env, invoice_id)?;

let mut invoice =
InvoiceStorage::get_invoice(env, invoice_id).ok_or(QuickLendXError::InvoiceNotFound)?;

Expand Down
2 changes: 2 additions & 0 deletions quicklendx-contracts/src/events.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
#![allow(deprecated)]

use crate::bid::Bid;
use crate::fees::FeeType;
use crate::invoice::{Invoice, InvoiceMetadata};
Expand Down
7 changes: 6 additions & 1 deletion quicklendx-contracts/src/fees.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
//! Fee management module for the QuickLendX protocol.
//!
//! Handles platform fee configuration, revenue tracking, volume-tier discounts,
//! and treasury routing for all fee types supported by the protocol.
use crate::errors::QuickLendXError;
use crate::events;
use soroban_sdk::{contracttype, symbol_short, vec, Address, Env, Map, Symbol, Vec};
Expand All @@ -6,6 +10,7 @@ use soroban_sdk::{contracttype, symbol_short, vec, Address, Env, Map, Symbol, Ve
const MAX_FEE_BPS: u32 = 1000; // 10% hard cap for all fees
#[allow(dead_code)]
const MIN_FEE_BPS: u32 = 0;
/// Basis-point denominator for percentage calculations (100% = 10,000 bps).
const BPS_DENOMINATOR: i128 = 10_000;
const DEFAULT_PLATFORM_FEE_BPS: u32 = 200; // 2%
const MAX_PLATFORM_FEE_BPS: u32 = 1000; // 10%
Expand Down Expand Up @@ -419,7 +424,7 @@ impl FeeManager {
env: &Env,
fee_type: &FeeType,
min_fee: i128,
max_fee: i128,
_max_fee: i128,
) -> Result<(), QuickLendXError> {
let fee_structures: Vec<FeeStructure> = match env.storage().instance().get(&FEE_CONFIG_KEY)
{
Expand Down
4 changes: 2 additions & 2 deletions quicklendx-contracts/src/init.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
//! - `set_treasury()` - Update treasury address
//! - Currency whitelist management functions

use crate::admin::{AdminStorage, ADMIN_INITIALIZED_KEY, ADMIN_KEY};
use crate::admin::AdminStorage;
use crate::errors::QuickLendXError;
use soroban_sdk::{contracttype, symbol_short, Address, Env, Symbol, Vec};

Expand Down Expand Up @@ -298,7 +298,7 @@ impl ProtocolInitializer {
/// * `Ok(())` if all parameters are valid
/// * `Err(QuickLendXError)` with specific error for invalid parameters
fn validate_initialization_params(
env: &Env,
_env: &Env,
params: &InitializationParams,
) -> Result<(), QuickLendXError> {
// VALIDATION: Fee basis points (0% to 10%)
Expand Down
37 changes: 5 additions & 32 deletions quicklendx-contracts/src/investment.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
use crate::errors::QuickLendXError;
use soroban_sdk::{contracttype, symbol_short, Address, BytesN, Env, Symbol, Vec};
// Re-export from crate::types so other modules can continue to import from crate::investment.
pub use crate::types::{InsuranceCoverage, Investment, InvestmentStatus};
use soroban_sdk::{symbol_short, Address, BytesN, Env, Symbol, Vec};

// ─── Storage key for the global active-investment index ───────────────────────
const ACTIVE_INDEX_KEY: Symbol = symbol_short!("act_inv");
Expand Down Expand Up @@ -27,25 +29,8 @@ pub const MAX_TOTAL_COVERAGE_PERCENTAGE: u32 = 100;
/// with no economic cost to the insured party.
pub const MIN_PREMIUM_AMOUNT: i128 = 1;

#[contracttype]
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct InsuranceCoverage {
pub provider: Address,
pub coverage_amount: i128,
pub premium_amount: i128,
pub coverage_percentage: u32,
pub active: bool,
}

#[contracttype]
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum InvestmentStatus {
Active,
Withdrawn,
Completed,
Defaulted,
Refunded,
}
// Local type definitions removed — InsuranceCoverage, InvestmentStatus, and
// Investment are now imported from crate::types (the single source of truth).

impl InvestmentStatus {
/// Validate that a status transition is legal.
Expand Down Expand Up @@ -86,18 +71,6 @@ impl InvestmentStatus {
}
}

#[contracttype]
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Investment {
pub investment_id: BytesN<32>,
pub invoice_id: BytesN<32>,
pub investor: Address,
pub amount: i128,
pub funded_at: u64,
pub status: InvestmentStatus,
pub insurance: Vec<InsuranceCoverage>,
}

impl Investment {
/// Compute the insurance premium for a given investment amount and coverage
/// percentage.
Expand Down
2 changes: 1 addition & 1 deletion quicklendx-contracts/src/investment_queries.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use crate::investment::{Investment, InvestmentStatus, InvestmentStorage};
use crate::investment::{InvestmentStatus, InvestmentStorage};
use soroban_sdk::{symbol_short, Address, BytesN, Env, Vec};

/// Maximum number of records returned by paginated query endpoints.
Expand Down
1 change: 1 addition & 0 deletions quicklendx-contracts/src/settlement.rs
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ pub fn process_partial_payment(
/// - Rejects if payment count has reached MAX_PAYMENT_COUNT
///
/// # Security
///
/// - The payer must be the verified invoice business and must authorize the call.
/// - Stored payment records always reflect the applied amount, never the requested excess.
pub fn record_payment(
Expand Down
7 changes: 3 additions & 4 deletions quicklendx-contracts/src/storage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,7 @@
use soroban_sdk::{contracttype, symbol_short, Address, BytesN, Env, String, Symbol, Vec};
// Removed ToString import; not needed in Soroban environment.

use crate::bid::{Bid, BidStatus};
use crate::investment::{Investment, InvestmentStatus};
use crate::types::{Bid, BidStatus, Investment, InvestmentStatus};
use crate::invoice::{Invoice, InvoiceStatus};
use crate::profits::PlatformFeeConfig;

Expand Down Expand Up @@ -278,7 +277,7 @@ impl InvoiceStorage {

pub fn remove_from_customer_index(env: &Env, customer_name: &String, invoice_id: &BytesN<32>) {
let key = Indexes::invoices_by_customer(customer_name);
let mut ids: Vec<BytesN<32>> = env
let ids: Vec<BytesN<32>> = env
.storage()
.persistent()
.get(&key)
Expand Down Expand Up @@ -307,7 +306,7 @@ impl InvoiceStorage {

pub fn remove_from_tax_id_index(env: &Env, tax_id: &String, invoice_id: &BytesN<32>) {
let key = Indexes::invoices_by_tax_id(tax_id);
let mut ids: Vec<BytesN<32>> = env
let ids: Vec<BytesN<32>> = env
.storage()
.persistent()
.get(&key)
Expand Down
150 changes: 150 additions & 0 deletions quicklendx-contracts/src/test_default.rs
Original file line number Diff line number Diff line change
Expand Up @@ -877,3 +877,153 @@ fn test_check_overdue_invoices_propagates_grace_period_error() {
// Should succeed with default protocol config (returns count)
assert!(result >= 0); // Just verify it returns a value without error
}

#[test]
fn test_transition_guard_prevents_duplicate_default() {
let (env, client, admin) = setup();
let business = create_verified_business(&env, &client, &admin);
let investor = create_verified_investor(&env, &client, &admin, 10000);

let amount = 1000;
let due_date = env.ledger().timestamp() + 86400;
let invoice_id = create_and_fund_invoice(
&env, &client, &admin, &business, &investor, amount, due_date,
);

let invoice = client.get_invoice(&invoice_id);
let grace_period = 7 * 24 * 60 * 60;

// Move time past grace period
let default_time = invoice.due_date + grace_period + 1;
env.ledger().set_timestamp(default_time);

// First attempt should succeed
client.mark_invoice_defaulted(&invoice_id, &Some(grace_period));
assert_eq!(
client.get_invoice(&invoice_id).status,
InvoiceStatus::Defaulted
);

// Second attempt should fail with DuplicateDefaultTransition
let result = client.try_mark_invoice_defaulted(&invoice_id, &Some(grace_period));
assert!(result.is_err());
let err = result.err().unwrap();
let contract_err = err.expect("expected contract error");
assert_eq!(contract_err, QuickLendXError::DuplicateDefaultTransition);
}

#[test]
fn test_transition_guard_persists_across_calls() {
let (env, client, admin) = setup();
let business = create_verified_business(&env, &client, &admin);
let investor = create_verified_investor(&env, &client, &admin, 10000);

let amount = 1000;
let due_date = env.ledger().timestamp() + 86400;
let invoice_id = create_and_fund_invoice(
&env, &client, &admin, &business, &investor, amount, due_date,
);

let invoice = client.get_invoice(&invoice_id);
let grace_period = 7 * 24 * 60 * 60;

// Move time past grace period
let default_time = invoice.due_date + grace_period + 1;
env.ledger().set_timestamp(default_time);

// First default should succeed
client.mark_invoice_defaulted(&invoice_id, &Some(grace_period));
assert_eq!(
client.get_invoice(&invoice_id).status,
InvoiceStatus::Defaulted
);

// Simulate multiple calls - all should fail
for _ in 0..3 {
let result = client.try_mark_invoice_defaulted(&invoice_id, &Some(grace_period));
assert!(result.is_err());
let err = result.err().unwrap();
let contract_err = err.expect("expected contract error");
assert_eq!(contract_err, QuickLendXError::DuplicateDefaultTransition);
}
}

#[test]
fn test_transition_guard_atomicity_during_partial_failure() {
let (env, client, admin) = setup();
let business = create_verified_business(&env, &client, &admin);
let investor = create_verified_investor(&env, &client, &admin, 10000);

let amount = 1000;
let due_date = env.ledger().timestamp() + 86400;
let invoice_id = create_and_fund_invoice(
&env, &client, &admin, &business, &investor, amount, due_date,
);

let invoice = client.get_invoice(&invoice_id);
let grace_period = 7 * 24 * 60 * 60;

// Move time past grace period
let default_time = invoice.due_date + grace_period + 1;
env.ledger().set_timestamp(default_time);

// First attempt should succeed and set the guard
client.mark_invoice_defaulted(&invoice_id, &Some(grace_period));
assert_eq!(
client.get_invoice(&invoice_id).status,
InvoiceStatus::Defaulted
);

// Even if we try to call handle_default directly, it should fail due to guard
let result = env.as_contract(&client.address, || {
crate::defaults::handle_default(&env, &invoice_id)
});
assert!(result.is_err());
assert_eq!(
result.unwrap_err(),
QuickLendXError::DuplicateDefaultTransition
);
}

#[test]
fn test_transition_guard_different_invoices_independent() {
let (env, client, admin) = setup();
let business = create_verified_business(&env, &client, &admin);
let investor = create_verified_investor(&env, &client, &admin, 20000);

let amount = 1000;
let due_date = env.ledger().timestamp() + 86400;

// Create two invoices
let invoice1_id = create_and_fund_invoice(
&env, &client, &admin, &business, &investor, amount, due_date,
);
let invoice2_id = create_and_fund_invoice(
&env, &client, &admin, &business, &investor, amount, due_date,
);

let grace_period = 7 * 24 * 60 * 60;
let default_time = due_date + grace_period + 1;
env.ledger().set_timestamp(default_time);

// Default first invoice
client.mark_invoice_defaulted(&invoice1_id, &Some(grace_period));
assert_eq!(
client.get_invoice(&invoice1_id).status,
InvoiceStatus::Defaulted
);

// Second invoice should still be defaultable
client.mark_invoice_defaulted(&invoice2_id, &Some(grace_period));
assert_eq!(
client.get_invoice(&invoice2_id).status,
InvoiceStatus::Defaulted
);

// But first invoice still guarded
let result = client.try_mark_invoice_defaulted(&invoice1_id, &Some(grace_period));
assert!(result.is_err());
let err = result.err().unwrap();
let contract_err = err.expect("expected contract error");
assert_eq!(contract_err, QuickLendXError::DuplicateDefaultTransition);
}
Loading
Loading