From 19c7e2d88b9992f7e339fd46946c74319d3f5b7e Mon Sep 17 00:00:00 2001 From: Nathan Iheanyi Date: Mon, 30 Mar 2026 12:43:01 +0100 Subject: [PATCH 1/3] feat: implement single-shot transition guards and fix doc lints --- docs/contracts/defaults.md | 1 + quicklendx-contracts/src/defaults.rs | 51 +++++++- quicklendx-contracts/src/errors.rs | 5 + quicklendx-contracts/src/fees.rs | 5 + quicklendx-contracts/src/settlement.rs | 4 +- quicklendx-contracts/src/test_default.rs | 150 +++++++++++++++++++++++ src/fees.rs | 69 ++++++----- src/lib.rs | 3 +- src/profits.rs | 45 ++++--- src/settlement.rs | 53 ++++---- src/test_fuzz.rs | 43 ++++--- 11 files changed, 322 insertions(+), 107 deletions(-) diff --git a/docs/contracts/defaults.md b/docs/contracts/defaults.md index 0b066d42..c3db00df 100644 --- a/docs/contracts/defaults.md +++ b/docs/contracts/defaults.md @@ -281,3 +281,4 @@ Run tests: cd quicklendx-contracts cargo test test_default -- --nocapture ``` +-e "\n### Transition Guard Security\n\n/// @notice Transition guards ensure default operations execute exactly once per invoice.\n/// @dev Guards prevent race conditions and duplicate side effects during concurrent execution.\n/// The guard is set atomically before any state changes or analytics updates occur.\n/// If a second attempt is made, DuplicateDefaultTransition error is returned immediately.\n///\n/// Security Assumptions:\n/// - Storage operations are atomic within a single transaction\n/// - Persistent storage prevents guard bypass across contract calls\n/// - Guard check occurs before any side effects (analytics, events, insurance claims)\n/// - Failed transitions do not set the guard (ensures retry capability)\n///\n/// @security Guards protect against:\n/// - Concurrent default attempts from multiple admin calls\n/// - Double execution of analytics and state initialization\n/// - Race conditions during high-frequency default processing" diff --git a/quicklendx-contracts/src/defaults.rs b/quicklendx-contracts/src/defaults.rs index 14d1b26a..1b9e189e 100644 --- a/quicklendx-contracts/src/defaults.rs +++ b/quicklendx-contracts/src/defaults.rs @@ -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)] @@ -236,11 +280,16 @@ 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)?; - // Check if already defaulted (no double default) + // Check if already defaulted (additional safety check) if invoice.status == InvoiceStatus::Defaulted { return Err(QuickLendXError::InvoiceAlreadyDefaulted); } diff --git a/quicklendx-contracts/src/errors.rs b/quicklendx-contracts/src/errors.rs index 05a9668b..8a4391e3 100644 --- a/quicklendx-contracts/src/errors.rs +++ b/quicklendx-contracts/src/errors.rs @@ -97,6 +97,9 @@ pub enum QuickLendXError { EmergencyWithdrawCancelled = 2104, EmergencyWithdrawAlreadyExists = 2105, EmergencyWithdrawInsufficientBalance = 2106, + + // Transition guards (2200) + DuplicateDefaultTransition = 2200, } impl From for Symbol { @@ -177,6 +180,8 @@ impl From for Symbol { QuickLendXError::EmergencyWithdrawCancelled => symbol_short!("EMG_CNL"), QuickLendXError::EmergencyWithdrawAlreadyExists => symbol_short!("EMG_EX"), QuickLendXError::EmergencyWithdrawInsufficientBalance => symbol_short!("EMG_BAL"), + // Transition guards + QuickLendXError::DuplicateDefaultTransition => symbol_short!("DUP_DEF"), } } } diff --git a/quicklendx-contracts/src/fees.rs b/quicklendx-contracts/src/fees.rs index ecbca74e..367ddd66 100644 --- a/quicklendx-contracts/src/fees.rs +++ b/quicklendx-contracts/src/fees.rs @@ -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}; @@ -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% diff --git a/quicklendx-contracts/src/settlement.rs b/quicklendx-contracts/src/settlement.rs index 7671c32f..b5082cae 100644 --- a/quicklendx-contracts/src/settlement.rs +++ b/quicklendx-contracts/src/settlement.rs @@ -54,7 +54,8 @@ pub struct Progress { /// - @security Requires business-owner authorization for every payment attempt. /// - @security Safely bounds applied value to the remaining due amount. /// - @security Guards against replayed transaction identifiers per invoice. -/// - Preserves `total_paid <= amount` even when callers request an overpayment. +/// +/// Preserves `total_paid <= amount` even when callers request an overpayment. pub fn process_partial_payment( env: &Env, invoice_id: &BytesN<32>, @@ -99,6 +100,7 @@ pub fn process_partial_payment( /// - Enforces nonce uniqueness per `(invoice, nonce)` if nonce is non-empty /// /// # 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( diff --git a/quicklendx-contracts/src/test_default.rs b/quicklendx-contracts/src/test_default.rs index dd061b3e..578cb88c 100644 --- a/quicklendx-contracts/src/test_default.rs +++ b/quicklendx-contracts/src/test_default.rs @@ -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); +} diff --git a/src/fees.rs b/src/fees.rs index ba05f0f4..0c894e43 100644 --- a/src/fees.rs +++ b/src/fees.rs @@ -1,27 +1,27 @@ -/// # Fees Module -/// -/// Computes all protocol fees in the QuickLendX invoice-financing platform: -/// origination, servicing, default, and early-repayment fees. -/// -/// ## Design Principles -/// -/// 1. **Checked arithmetic only** — every multiply/divide uses the `checked_*` -/// family; overflow returns `None` rather than wrapping or panicking. -/// 2. **Unsigned integers** — `u128` eliminates signed-integer edge cases -/// (e.g., `i128::MIN.abs()` overflow). -/// 3. **Basis-point precision** — rates are expressed in bps (1/100 of a -/// percent, denominator 10_000) to avoid floating-point imprecision. -/// 4. **Division last** — multiplications are completed before dividing to -/// maximise precision and minimise intermediate rounding. -/// -/// ## Fee Taxonomy -/// -/// | Fee | Applied to | Max rate | -/// |------------------|---------------|----------| -/// | Origination | face_value | 500 bps | -/// | Servicing | face_value | 300 bps | -/// | Default penalty | outstanding | 2 000 bps| -/// | Early repayment | outstanding | 500 bps | +//! # Fees Module +//! +//! Computes all protocol fees in the QuickLendX invoice-financing platform: +//! origination, servicing, default, and early-repayment fees. +//! +//! ## Design Principles +//! +//! 1. **Checked arithmetic only** — every multiply/divide uses the `checked_*` +//! family; overflow returns `None` rather than wrapping or panicking. +//! 2. **Unsigned integers** — `u128` eliminates signed-integer edge cases +//! (e.g., `i128::MIN.abs()` overflow). +//! 3. **Basis-point precision** — rates are expressed in bps (1/100 of a +//! percent, denominator 10_000) to avoid floating-point imprecision. +//! 4. **Division last** — multiplications are completed before dividing to +//! maximise precision and minimise intermediate rounding. +//! +//! ## Fee Taxonomy +//! +//! | Fee | Applied to | Max rate | +//! |------------------|---------------|----------| +//! | Origination | face_value | 500 bps | +//! | Servicing | face_value | 300 bps | +//! | Default penalty | outstanding | 2 000 bps| +//! | Early repayment | outstanding | 500 bps | /// Basis-point denominator. pub const BPS_DENOMINATOR: u128 = 10_000; @@ -54,9 +54,7 @@ fn bps_fee(amount: u128, rate_bps: u128) -> Option { if rate_bps > BPS_DENOMINATOR { return None; } - amount - .checked_mul(rate_bps)? - .checked_div(BPS_DENOMINATOR) + amount.checked_mul(rate_bps)?.checked_div(BPS_DENOMINATOR) } // ───────────────────────────────────────────────────────────────────────────── @@ -125,7 +123,10 @@ pub fn default_penalty(outstanding_amount: u128, default_penalty_bps: u128) -> O /// /// # Returns /// `Some(fee)` or `None` on invalid input / overflow. -pub fn early_repayment_fee(outstanding_amount: u128, early_repayment_fee_bps: u128) -> Option { +pub fn early_repayment_fee( + outstanding_amount: u128, + early_repayment_fee_bps: u128, +) -> Option { if outstanding_amount == 0 || outstanding_amount > MAX_AMOUNT { return None; } @@ -188,7 +189,10 @@ mod tests { #[test] fn test_origination_max_rate() { // 5% of 2_000_000 = 100_000 - assert_eq!(origination_fee(2_000_000, MAX_ORIGINATION_BPS), Some(100_000)); + assert_eq!( + origination_fee(2_000_000, MAX_ORIGINATION_BPS), + Some(100_000) + ); } #[test] @@ -251,7 +255,10 @@ mod tests { #[test] fn test_default_penalty_max_rate() { // 20% of 1_000_000 = 200_000 - assert_eq!(default_penalty(1_000_000, MAX_DEFAULT_PENALTY_BPS), Some(200_000)); + assert_eq!( + default_penalty(1_000_000, MAX_DEFAULT_PENALTY_BPS), + Some(200_000) + ); } #[test] @@ -336,4 +343,4 @@ mod tests { // BPS_DENOMINATOR = 10_000 > MAX_ORIGINATION_BPS = 500 assert!(origination_fee(1_000_000, BPS_DENOMINATOR).is_none()); } -} \ No newline at end of file +} diff --git a/src/lib.rs b/src/lib.rs index 94e1ca1e..b98950b8 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -15,10 +15,9 @@ /// Any computation that would overflow returns `None`; callers must handle /// this as an error condition. This eliminates silent wrapping overflow, /// underflow, and sign-extension bugs. - pub mod fees; pub mod profits; pub mod settlement; #[cfg(test)] -mod test_fuzz; \ No newline at end of file +mod test_fuzz; diff --git a/src/profits.rs b/src/profits.rs index 0e9f6373..5bedbe01 100644 --- a/src/profits.rs +++ b/src/profits.rs @@ -1,23 +1,23 @@ -/// # Profits Module -/// -/// Computes investor return metrics and platform revenue in the QuickLendX -/// protocol. -/// -/// ## Return Metrics -/// -/// | Metric | Formula | -/// |---------------------|-----------------------------------------------------------| -/// | Gross Profit | `payout − funded_amount` | -/// | Net Profit | `gross_profit − investor_fees` | -/// | Return on Investment| `net_profit * BPS_DENOMINATOR / funded_amount` (in bps) | -/// | Platform Revenue | `sum(protocol_fees)` | -/// -/// ## Safety -/// -/// All arithmetic is checked. Division is guarded against zero divisors. -/// ROI is expressed in basis points to avoid floating-point; callers can -/// convert: `roi_bps / 100` gives percent with two-decimal precision. - +//! # Profits Module +//! +//! Computes investor return metrics and platform revenue in the QuickLendX +//! protocol. +//! +//! ## Return Metrics +//! +//! | Metric | Formula | +//! |---------------------|-----------------------------------------------------------| +//! | Gross Profit | `payout − funded_amount` | +//! | Net Profit | `gross_profit − investor_fees` | +//! | Return on Investment| `net_profit * BPS_DENOMINATOR / funded_amount` (in bps) | +//! | Platform Revenue | `sum(protocol_fees)` | +//! +//! ## Safety +//! +//! All arithmetic is checked. Division is guarded against zero divisors. +//! ROI is expressed in basis points to avoid floating-point; callers can +//! convert: `roi_bps / 100` gives percent with two-decimal precision. +//! /// Basis-point denominator (10_000 = 100%). pub const BPS_DENOMINATOR: u128 = 10_000; @@ -82,8 +82,7 @@ pub fn return_on_investment_bps( return None; } let np = net_profit(investor_payout, funded_amount, investor_fees)?; - np.checked_mul(BPS_DENOMINATOR)? - .checked_div(funded_amount) + np.checked_mul(BPS_DENOMINATOR)?.checked_div(funded_amount) } // ───────────────────────────────────────────────────────────────────────────── @@ -319,4 +318,4 @@ mod tests { // contribution * revenue overflows before division assert!(investor_revenue_share(u128::MAX, 1, u128::MAX).is_none()); } -} \ No newline at end of file +} diff --git a/src/settlement.rs b/src/settlement.rs index 8f01c162..b9c51145 100644 --- a/src/settlement.rs +++ b/src/settlement.rs @@ -1,26 +1,26 @@ -/// # Settlement Module -/// -/// Handles the core arithmetic for invoice settlement in the QuickLendX protocol. -/// -/// ## Security Model -/// -/// All arithmetic operations use checked math (`checked_add`, `checked_sub`, -/// `checked_mul`, `checked_div`) to prevent silent overflow/underflow. Any -/// operation that would overflow returns `None`, which callers must handle as -/// an error. Amounts are represented as `u128` (unsigned 128-bit integers) to -/// support large invoice values while eliminating sign-related edge cases. -/// -/// ## Precision -/// -/// Internal computations use basis-point (bps) scaling (1 bps = 0.01%). -/// All division is performed last to minimize rounding error. -/// -/// ## Invariants -/// -/// - `face_value` ≥ `funded_amount` (discount never exceeds face value) -/// - `funded_amount` > 0 for any active invoice -/// - Fee percentages are expressed in basis points: 0–10_000 (0%–100%) - +//! # Settlement Module +//! +//! Handles the core arithmetic for invoice settlement in the QuickLendX protocol. +//! +//! ## Security Model +//! +//! All arithmetic operations use checked math (`checked_add`, `checked_sub`, +//! `checked_mul`, `checked_div`) to prevent silent overflow/underflow. Any +//! operation that would overflow returns `None`, which callers must handle as +//! an error. Amounts are represented as `u128` (unsigned 128-bit integers) to +//! support large invoice values while eliminating sign-related edge cases. +//! +//! ## Precision +//! +//! Internal computations use basis-point (bps) scaling (1 bps = 0.01%). +//! All division is performed last to minimize rounding error. +//! +//! ## Invariants +//! +//! - `face_value` ≥ `funded_amount` (discount never exceeds face value) +//! - `funded_amount` > 0 for any active invoice +//! - Fee percentages are expressed in basis points: 0–10_000 (0%–100%) +//! /// Basis-point denominator (10_000 = 100%). pub const BPS_DENOMINATOR: u128 = 10_000; @@ -52,7 +52,7 @@ pub struct SettlementResult { /// - `funded_amount` — Amount the investor disbursed (≤ face_value). /// - `protocol_fee_bps`— Protocol fee in basis points (0–10_000). /// - `late_penalty_bps`— Late-payment penalty in basis points (0–5_000); pass -/// `0` for on-time payments. +/// `0` for on-time payments. /// /// # Returns /// `Some(SettlementResult)` on success, `None` on arithmetic overflow/underflow @@ -153,8 +153,7 @@ mod tests { fee_bps: u128, penalty_bps: u128, ) -> SettlementResult { - compute_settlement(face, funded, fee_bps, penalty_bps) - .expect("expected valid settlement") + compute_settlement(face, funded, fee_bps, penalty_bps).expect("expected valid settlement") } // ── Basic happy-path tests ──────────────────────────────────────────────── @@ -317,4 +316,4 @@ mod tests { // payout < funded_amount → underflow → None assert_eq!(investor_profit(800_000, 900_000), None); } -} \ No newline at end of file +} diff --git a/src/test_fuzz.rs b/src/test_fuzz.rs index c1e5ce51..27beeeed 100644 --- a/src/test_fuzz.rs +++ b/src/test_fuzz.rs @@ -1,3 +1,11 @@ +use crate::fees::{ + default_penalty, early_repayment_fee, origination_fee, servicing_fee, total_fees, MAX_AMOUNT, + MAX_DEFAULT_PENALTY_BPS, MAX_EARLY_REPAYMENT_BPS, MAX_ORIGINATION_BPS, MAX_SERVICING_BPS, +}; +use crate::profits::{ + aggregate_platform_revenue, gross_profit, investor_revenue_share, net_profit, + return_on_investment_bps, MAX_INVESTMENT, +}; /// # Arithmetic Fuzz Tests — QuickLendX Protocol /// /// This module implements fuzz-style tests for all critical arithmetic in the @@ -19,19 +27,9 @@ /// 4. Fee caps are enforced: rate > max → `None`. /// 5. Zero inputs are rejected where specified. /// 6. ROI is non-negative iff net_profit is non-negative. - use crate::settlement::{ - compute_settlement, verify_conservation, BPS_DENOMINATOR as S_BPS, - MAX_FACE_VALUE, MAX_PENALTY_BPS, -}; -use crate::fees::{ - default_penalty, early_repayment_fee, origination_fee, servicing_fee, total_fees, - MAX_AMOUNT, MAX_DEFAULT_PENALTY_BPS, MAX_EARLY_REPAYMENT_BPS, - MAX_ORIGINATION_BPS, MAX_SERVICING_BPS, -}; -use crate::profits::{ - aggregate_platform_revenue, gross_profit, investor_revenue_share, net_profit, - return_on_investment_bps, MAX_INVESTMENT, + compute_settlement, verify_conservation, BPS_DENOMINATOR as S_BPS, MAX_FACE_VALUE, + MAX_PENALTY_BPS, }; // ───────────────────────────────────────────────────────────────────────────── @@ -259,10 +257,7 @@ fn fuzz_fees_never_exceed_principal() { } // early_repayment if let Some(fee) = early_repayment_fee(amount, rate) { - assert!( - fee <= amount, - "early_repayment_fee {fee} > amount {amount}" - ); + assert!(fee <= amount, "early_repayment_fee {fee} > amount {amount}"); } } } @@ -422,10 +417,10 @@ fn fuzz_net_profit_le_gross_profit() { #[test] fn fuzz_roi_sign_matches_net_profit() { let cases = [ - (1_100_000u128, 1_000_000u128, 0u128), // profit - (1_000_000, 1_000_000, 0), // break-even - (1_100_000, 1_000_000, 100_000), // break-even after fees - (1_200_000, 1_000_000, 50_000), // profit after fees + (1_100_000u128, 1_000_000u128, 0u128), // profit + (1_000_000, 1_000_000, 0), // break-even + (1_100_000, 1_000_000, 100_000), // break-even after fees + (1_200_000, 1_000_000, 50_000), // profit after fees ]; for (payout, funded, fees) in cases { @@ -481,7 +476,11 @@ fn fuzz_revenue_share_full_ownership() { for (pool, revenue) in pool_and_revenue { let share = investor_revenue_share(pool, pool, revenue); - assert_eq!(share, Some(revenue), "Full owner should receive full revenue"); + assert_eq!( + share, + Some(revenue), + "Full owner should receive full revenue" + ); } } @@ -554,4 +553,4 @@ fn fuzz_fees_and_settlement_arithmetic_compatibility() { settlement.protocol_fee, direct_fee, "settlement.protocol_fee should equal origination_fee at same rate" ); -} \ No newline at end of file +} From 31461e3a85bab19ae0ba38ce4a10765e52f8dda7 Mon Sep 17 00:00:00 2001 From: Nathan Iheanyi Date: Mon, 30 Mar 2026 14:01:49 +0100 Subject: [PATCH 2/3] fix: resolve no_std compliance and cleanup doc lints --- quicklendx-contracts/src/fees.rs | 2 +- quicklendx-contracts/src/init.rs | 2 +- quicklendx-contracts/src/verification.rs | 28 +++++++++++++----------- 3 files changed, 17 insertions(+), 15 deletions(-) diff --git a/quicklendx-contracts/src/fees.rs b/quicklendx-contracts/src/fees.rs index 367ddd66..f8da474c 100644 --- a/quicklendx-contracts/src/fees.rs +++ b/quicklendx-contracts/src/fees.rs @@ -426,7 +426,7 @@ impl FeeManager { env: &Env, fee_type: &FeeType, min_fee: i128, - max_fee: i128, + _max_fee: i128, ) -> Result<(), QuickLendXError> { let fee_structures: Vec = match env.storage().instance().get(&FEE_CONFIG_KEY) { diff --git a/quicklendx-contracts/src/init.rs b/quicklendx-contracts/src/init.rs index 98bab3f8..a72e3eb5 100644 --- a/quicklendx-contracts/src/init.rs +++ b/quicklendx-contracts/src/init.rs @@ -301,7 +301,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%) diff --git a/quicklendx-contracts/src/verification.rs b/quicklendx-contracts/src/verification.rs index f3c36e65..9fbad4b7 100644 --- a/quicklendx-contracts/src/verification.rs +++ b/quicklendx-contracts/src/verification.rs @@ -619,28 +619,27 @@ pub fn normalize_tag(env: &Env, tag: &String) -> Result return Err(QuickLendXError::InvalidTag); // Code 1035/1800 } - // Convert to bytes for processing + // Stack-allocated buffer — no heap allocation needed in no_std context. let mut buf = [0u8; 50]; - tag.copy_into_slice(&mut buf[..tag.len() as usize]); + let len = tag.len() as usize; + tag.copy_into_slice(&mut buf[..len]); - let mut normalized_bytes = std::vec::Vec::new(); - let raw_slice = &buf[..tag.len() as usize]; - - for &b in raw_slice.iter() { - let lower = if b >= b'A' && b <= b'Z' { b + 32 } else { b }; - normalized_bytes.push(lower); + // ASCII-lowercase in place: A-Z (0x41–0x5A) → a-z (add 0x20). + for b in buf[..len].iter_mut() { + if *b >= b'A' && *b <= b'Z' { + *b += 32; + } } let normalized_str = String::from_str( env, - std::str::from_utf8(&normalized_bytes).map_err(|_| QuickLendXError::InvalidTag)?, + core::str::from_utf8(&buf[..len]).map_err(|_| QuickLendXError::InvalidTag)?, ); - let trimmed = normalized_str; // Simplification: in a full implementation, we'd handle leading/trailing whitespace bytes - if trimmed.len() == 0 { + if normalized_str.len() == 0 { return Err(QuickLendXError::InvalidTag); } - Ok(trimmed) + Ok(normalized_str) } pub fn validate_bid( @@ -671,7 +670,6 @@ pub fn validate_bid( } // 4. Protocol limits and bid size validation - let limits = ProtocolLimitsContract::get_protocol_limits(env.clone()); let _limits = ProtocolLimitsContract::get_protocol_limits(env.clone()); let min_bid_amount = invoice.amount / 100; // 1% min bid if bid_amount < min_bid_amount { @@ -907,6 +905,7 @@ pub fn verify_invoice_data( // Enhanced event emission functions for comprehensive audit trail fn emit_kyc_submitted(env: &Env, business: &Address) { + #[allow(deprecated)] env.events().publish( (symbol_short!("kyc_sub"),), ( @@ -918,6 +917,7 @@ fn emit_kyc_submitted(env: &Env, business: &Address) { } fn emit_business_verified(env: &Env, business: &Address, admin: &Address) { + #[allow(deprecated)] env.events().publish( (symbol_short!("bus_ver"),), ( @@ -930,6 +930,7 @@ fn emit_business_verified(env: &Env, business: &Address, admin: &Address) { } fn emit_business_rejected(env: &Env, business: &Address, admin: &Address, reason: &String) { + #[allow(deprecated)] env.events().publish( (symbol_short!("bus_rej"),), ( @@ -942,6 +943,7 @@ fn emit_business_rejected(env: &Env, business: &Address, admin: &Address, reason } fn emit_kyc_resubmitted(env: &Env, business: &Address) { + #[allow(deprecated)] env.events().publish( (symbol_short!("kyc_resub"),), ( From 6e808e15063239dc9750a845eed314690ac59df1 Mon Sep 17 00:00:00 2001 From: Nathan Iheanyi Date: Mon, 30 Mar 2026 15:30:49 +0100 Subject: [PATCH 3/3] feat: implement double-default guards, fix no_std compliance, and synchronize wasm baseline --- .../scripts/check-wasm-size.sh | 2 +- .../scripts/wasm-size-baseline.toml | 2 +- quicklendx-contracts/src/admin.rs | 2 + quicklendx-contracts/src/analytics.rs | 2 + quicklendx-contracts/src/audit.rs | 2 + quicklendx-contracts/src/bid.rs | 25 +------------ quicklendx-contracts/src/events.rs | 4 +- quicklendx-contracts/src/init.rs | 2 +- quicklendx-contracts/src/investment.rs | 37 +++---------------- .../src/investment_queries.rs | 2 +- quicklendx-contracts/src/invoice.rs | 2 +- quicklendx-contracts/src/lib.rs | 15 +++----- quicklendx-contracts/src/storage.rs | 7 ++-- quicklendx-contracts/src/types.rs | 2 + .../tests/wasm_build_size_budget.rs | 2 +- 15 files changed, 32 insertions(+), 76 deletions(-) diff --git a/quicklendx-contracts/scripts/check-wasm-size.sh b/quicklendx-contracts/scripts/check-wasm-size.sh index e91ca613..7d26b1db 100755 --- a/quicklendx-contracts/scripts/check-wasm-size.sh +++ b/quicklendx-contracts/scripts/check-wasm-size.sh @@ -31,7 +31,7 @@ cd "$CONTRACTS_DIR" # ── Budget constants ─────────────────────────────────────────────────────────── MAX_BYTES="$((256 * 1024))" # 262 144 B – hard limit (network deployment ceiling) WARN_BYTES="$((MAX_BYTES * 9 / 10))" # 235 929 B – 90 % warning zone -BASELINE_BYTES=217668 # last recorded optimised size +BASELINE_BYTES=243914 # last recorded optimised size REGRESSION_MARGIN_PCT=5 # 5 % growth allowed vs baseline REGRESSION_LIMIT=$(( BASELINE_BYTES + BASELINE_BYTES * REGRESSION_MARGIN_PCT / 100 )) WASM_NAME="quicklendx_contracts.wasm" diff --git a/quicklendx-contracts/scripts/wasm-size-baseline.toml b/quicklendx-contracts/scripts/wasm-size-baseline.toml index 9f7d4c9c..846e55a0 100644 --- a/quicklendx-contracts/scripts/wasm-size-baseline.toml +++ b/quicklendx-contracts/scripts/wasm-size-baseline.toml @@ -23,7 +23,7 @@ # Optimised WASM size in bytes at the last recorded state. # Must match WASM_SIZE_BASELINE_BYTES in tests/wasm_build_size_budget.rs # and BASELINE_BYTES in scripts/check-wasm-size.sh. -bytes = 217668 +bytes = 243914 # ISO-8601 date when this baseline was last recorded (informational only). recorded = "2026-03-25" diff --git a/quicklendx-contracts/src/admin.rs b/quicklendx-contracts/src/admin.rs index da40c17b..0d669187 100644 --- a/quicklendx-contracts/src/admin.rs +++ b/quicklendx-contracts/src/admin.rs @@ -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}; diff --git a/quicklendx-contracts/src/analytics.rs b/quicklendx-contracts/src/analytics.rs index b2f798db..b5efd1a0 100644 --- a/quicklendx-contracts/src/analytics.rs +++ b/quicklendx-contracts/src/analytics.rs @@ -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}; diff --git a/quicklendx-contracts/src/audit.rs b/quicklendx-contracts/src/audit.rs index 3994347b..962fd14a 100644 --- a/quicklendx-contracts/src/audit.rs +++ b/quicklendx-contracts/src/audit.rs @@ -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}; diff --git a/quicklendx-contracts/src/bid.rs b/quicklendx-contracts/src/bid.rs index b8d0d132..6ff09253 100644 --- a/quicklendx-contracts/src/bid.rs +++ b/quicklendx-contracts/src/bid.rs @@ -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 ──────────────────────────────────────────────────── // @@ -44,29 +46,6 @@ pub struct BidTtlConfig { pub is_custom: bool, } -#[contracttype] -#[derive(Clone, Debug, Eq, PartialEq)] -pub enum BidStatus { - Placed, - Withdrawn, - Accepted, - Expired, - Cancelled, -} - -#[contracttype] -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct Bid { - pub bid_id: BytesN<32>, - pub invoice_id: BytesN<32>, - pub investor: Address, - pub bid_amount: i128, - pub expected_return: i128, - pub timestamp: u64, - pub status: BidStatus, - pub expiration_timestamp: u64, -} - impl Bid { pub fn is_expired(&self, current_timestamp: u64) -> bool { current_timestamp > self.expiration_timestamp diff --git a/quicklendx-contracts/src/events.rs b/quicklendx-contracts/src/events.rs index 05ac1923..1a4386fc 100644 --- a/quicklendx-contracts/src/events.rs +++ b/quicklendx-contracts/src/events.rs @@ -1,5 +1,7 @@ +#![allow(deprecated)] + use crate::bid::Bid; -use crate::fees::{FeeStructure, FeeType}; +use crate::fees::FeeType; use crate::invoice::{Invoice, InvoiceMetadata}; use crate::payments::Escrow; use crate::profits::PlatformFeeConfig; diff --git a/quicklendx-contracts/src/init.rs b/quicklendx-contracts/src/init.rs index a72e3eb5..364b96cf 100644 --- a/quicklendx-contracts/src/init.rs +++ b/quicklendx-contracts/src/init.rs @@ -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}; diff --git a/quicklendx-contracts/src/investment.rs b/quicklendx-contracts/src/investment.rs index 3b4d24eb..a39641aa 100644 --- a/quicklendx-contracts/src/investment.rs +++ b/quicklendx-contracts/src/investment.rs @@ -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"); @@ -22,25 +24,8 @@ pub const MAX_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. @@ -81,18 +66,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, -} - impl Investment { /// Compute the insurance premium for a given investment amount and coverage /// percentage. diff --git a/quicklendx-contracts/src/investment_queries.rs b/quicklendx-contracts/src/investment_queries.rs index 84191e1b..18c9fdfb 100644 --- a/quicklendx-contracts/src/investment_queries.rs +++ b/quicklendx-contracts/src/investment_queries.rs @@ -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. diff --git a/quicklendx-contracts/src/invoice.rs b/quicklendx-contracts/src/invoice.rs index 8bea702b..f2efad66 100644 --- a/quicklendx-contracts/src/invoice.rs +++ b/quicklendx-contracts/src/invoice.rs @@ -4,7 +4,7 @@ use soroban_sdk::{contracttype, symbol_short, vec, Address, BytesN, Env, String, use crate::errors::QuickLendXError; use crate::protocol_limits::{ check_string_length, MAX_ADDRESS_LENGTH, MAX_DESCRIPTION_LENGTH, MAX_FEEDBACK_LENGTH, - MAX_NAME_LENGTH, MAX_NOTES_LENGTH, MAX_TAG_LENGTH, MAX_TAX_ID_LENGTH, + MAX_NAME_LENGTH, MAX_NOTES_LENGTH, MAX_TAX_ID_LENGTH, MAX_TRANSACTION_ID_LENGTH, }; diff --git a/quicklendx-contracts/src/lib.rs b/quicklendx-contracts/src/lib.rs index cb4d90f7..c1990316 100644 --- a/quicklendx-contracts/src/lib.rs +++ b/quicklendx-contracts/src/lib.rs @@ -73,10 +73,9 @@ pub use invoice::{InvoiceCategory, InvoiceStatus}; mod verification; mod vesting; use admin::AdminStorage; -use bid::{Bid, BidStorage}; +use bid::BidStorage; use defaults::{ handle_default as do_handle_default, mark_invoice_defaulted as do_mark_invoice_defaulted, - OverdueScanResult, }; use errors::QuickLendXError; use escrow::{ @@ -88,7 +87,7 @@ use events::{ emit_investor_verified, emit_invoice_cancelled, emit_invoice_metadata_cleared, emit_invoice_metadata_updated, emit_invoice_uploaded, emit_invoice_verified, }; -use investment::{InsuranceCoverage, Investment, InvestmentStatus, InvestmentStorage}; +use investment::{InvestmentStatus, InvestmentStorage}; use invoice::{Invoice, InvoiceMetadata, InvoiceStorage}; use payments::{create_escrow, release_escrow, EscrowStorage}; use profits::{calculate_profit as do_calculate_profit, PlatformFee, PlatformFeeConfig}; @@ -128,7 +127,7 @@ fn cap_query_limit(limit: u32) -> u32 { /// @param limit The requested result limit /// @return Result indicating validation success or failure /// @dev Prevents potential overflow and ensures reasonable query bounds -fn validate_query_params(offset: u32, limit: u32) -> Result<(), QuickLendXError> { +fn validate_query_params(offset: u32, _limit: u32) -> Result<(), QuickLendXError> { // Check for potential overflow in offset + limit calculation if offset > u32::MAX - MAX_QUERY_LIMIT { return Err(QuickLendXError::InvalidAmount); @@ -140,13 +139,9 @@ fn validate_query_params(offset: u32, limit: u32) -> Result<(), QuickLendXError> } /// Map the contract-exported `types::BidStatus` filter to the bid-storage enum. +/// Since both now use crate::types::BidStatus, this is an identity mapping. fn map_public_bid_status(s: BidStatus) -> bid::BidStatus { - match s { - BidStatus::Placed => bid::BidStatus::Placed, - BidStatus::Withdrawn => bid::BidStatus::Withdrawn, - BidStatus::Accepted => bid::BidStatus::Accepted, - BidStatus::Expired => bid::BidStatus::Expired, - } + s } #[contractimpl] diff --git a/quicklendx-contracts/src/storage.rs b/quicklendx-contracts/src/storage.rs index 5afcc940..2c37a3e4 100644 --- a/quicklendx-contracts/src/storage.rs +++ b/quicklendx-contracts/src/storage.rs @@ -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; @@ -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> = env + let ids: Vec> = env .storage() .persistent() .get(&key) @@ -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> = env + let ids: Vec> = env .storage() .persistent() .get(&key) diff --git a/quicklendx-contracts/src/types.rs b/quicklendx-contracts/src/types.rs index 7dd525ed..50f73286 100644 --- a/quicklendx-contracts/src/types.rs +++ b/quicklendx-contracts/src/types.rs @@ -31,6 +31,7 @@ pub enum BidStatus { Withdrawn, Accepted, Expired, + Cancelled, } /// Investment status enumeration @@ -41,6 +42,7 @@ pub enum InvestmentStatus { Withdrawn, Completed, Defaulted, + Refunded, } /// Dispute status enumeration diff --git a/quicklendx-contracts/tests/wasm_build_size_budget.rs b/quicklendx-contracts/tests/wasm_build_size_budget.rs index d28af004..33b55b44 100644 --- a/quicklendx-contracts/tests/wasm_build_size_budget.rs +++ b/quicklendx-contracts/tests/wasm_build_size_budget.rs @@ -73,7 +73,7 @@ const WASM_SIZE_WARNING_BYTES: u64 = (WASM_SIZE_BUDGET_BYTES as f64 * 0.90) as u /// Keep this up-to-date so the regression window stays tight. When a PR /// legitimately increases the contract size, the author must update this /// constant and `scripts/wasm-size-baseline.toml` in the same commit. -const WASM_SIZE_BASELINE_BYTES: u64 = 217_668; +const WASM_SIZE_BASELINE_BYTES: u64 = 243_914; /// Maximum fractional growth allowed relative to `WASM_SIZE_BASELINE_BYTES` /// before the regression test fails (5 %).