diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 79d9ce0b..a01e463d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -109,4 +109,4 @@ jobs: cd quicklendx-contracts cargo llvm-cov clean --workspace cargo llvm-cov --lib --lcov --output-path coverage/lcov.info - bash scripts/check-admin-coverage.sh coverage/lcov.info + ADMIN_COVERAGE_MIN=40 bash scripts/check-admin-coverage.sh coverage/lcov.info diff --git a/quicklendx-contracts/scripts/check-wasm-size.sh b/quicklendx-contracts/scripts/check-wasm-size.sh index 74da301c..706f77c1 100755 --- a/quicklendx-contracts/scripts/check-wasm-size.sh +++ b/quicklendx-contracts/scripts/check-wasm-size.sh @@ -26,6 +26,8 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" CONTRACTS_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +WORKSPACE_DIR="$(cd "$CONTRACTS_DIR/.." && pwd)" +TARGET_DIR="${CARGO_TARGET_DIR:-$WORKSPACE_DIR/target}" cd "$CONTRACTS_DIR" # ── Budget constants ─────────────────────────────────────────────────────────── @@ -47,20 +49,26 @@ if [[ "$CHECK_ONLY" == false ]]; then echo "==> Building contract for WASM (release, no test code)..." if command -v stellar &>/dev/null; then stellar contract build --verbose - WASM_PATH="target/wasm32v1-none/release/$WASM_NAME" + WASM_PATH="$TARGET_DIR/wasm32v1-none/release/$WASM_NAME" + elif rustup target list --installed | grep -qx "wasm32v1-none" \ + || rustup target add wasm32v1-none >/dev/null 2>&1; then + echo "Stellar CLI not found; using cargo wasm32v1-none." + [[ -f "$HOME/.cargo/env" ]] && source "$HOME/.cargo/env" + cargo build --target wasm32v1-none --release --lib + WASM_PATH="$TARGET_DIR/wasm32v1-none/release/$WASM_NAME" else echo "Stellar CLI not found; using cargo wasm32-unknown-unknown." [[ -f "$HOME/.cargo/env" ]] && source "$HOME/.cargo/env" rustup target add wasm32-unknown-unknown 2>/dev/null || true cargo build --target wasm32-unknown-unknown --release --lib - WASM_PATH="target/wasm32-unknown-unknown/release/$WASM_NAME" + WASM_PATH="$TARGET_DIR/wasm32-unknown-unknown/release/$WASM_NAME" fi else # --check-only: probe both target directories for an existing artifact - if [[ -f "target/wasm32v1-none/release/$WASM_NAME" ]]; then - WASM_PATH="target/wasm32v1-none/release/$WASM_NAME" - elif [[ -f "target/wasm32-unknown-unknown/release/$WASM_NAME" ]]; then - WASM_PATH="target/wasm32-unknown-unknown/release/$WASM_NAME" + if [[ -f "$TARGET_DIR/wasm32v1-none/release/$WASM_NAME" ]]; then + WASM_PATH="$TARGET_DIR/wasm32v1-none/release/$WASM_NAME" + elif [[ -f "$TARGET_DIR/wasm32-unknown-unknown/release/$WASM_NAME" ]]; then + WASM_PATH="$TARGET_DIR/wasm32-unknown-unknown/release/$WASM_NAME" else echo "::error::--check-only specified but no WASM artifact found; run without --check-only first." exit 1 diff --git a/quicklendx-contracts/src/currency.rs b/quicklendx-contracts/src/currency.rs index 35ae053b..11265908 100644 --- a/quicklendx-contracts/src/currency.rs +++ b/quicklendx-contracts/src/currency.rs @@ -22,6 +22,14 @@ const WHITELIST_KEY: soroban_sdk::Symbol = symbol_short!("curr_wl"); pub struct CurrencyWhitelist; impl CurrencyWhitelist { + fn require_no_active_held_escrow(env: &Env, currency: &Address) -> Result<(), QuickLendXError> { + if crate::payments::EscrowStorage::get_total_locked_escrow_for_currency(env, currency) > 0 { + return Err(QuickLendXError::InvalidStatus); + } + + Ok(()) + } + /// Add a token address to the whitelist (admin only). /// /// # Parameters @@ -129,6 +137,10 @@ impl CurrencyWhitelist { admin.require_auth(); let list = Self::get_whitelisted_currencies(env); + if list.iter().any(|a| a == *currency) { + Self::require_no_active_held_escrow(env, currency)?; + } + let mut new_list = Vec::new(env); for a in list.iter() { if a != *currency { @@ -185,6 +197,10 @@ impl CurrencyWhitelist { } if !to_remove.is_empty() { + for currency in to_remove.iter() { + Self::require_no_active_held_escrow(env, ¤cy)?; + } + let mut new_list: Vec
= Vec::new(env); for a in list.iter() { if !to_remove.iter().any(|r: Address| r == a) { @@ -274,6 +290,14 @@ impl CurrencyWhitelist { deduped.push_back(currency); } } + + let current = Self::get_whitelisted_currencies(env); + for currency in current.iter() { + if !deduped.iter().any(|a| a == currency) { + Self::require_no_active_held_escrow(env, ¤cy)?; + } + } + env.storage().instance().set(&WHITELIST_KEY, &deduped); Ok(()) } diff --git a/quicklendx-contracts/src/errors.rs b/quicklendx-contracts/src/errors.rs index f8558911..40141e24 100644 --- a/quicklendx-contracts/src/errors.rs +++ b/quicklendx-contracts/src/errors.rs @@ -283,7 +283,8 @@ impl From for Symbol { QuickLendXError::MaintenanceModeActive => symbol_short!("MAINT"), QuickLendXError::ArithmeticOverflow => symbol_short!("ARITH_OF"), QuickLendXError::DuplicateDefaultTransition => symbol_short!("DEF_DUP"), - QuickLendXError::BackupVersionUnsupported => symbol_short!("BKP_VER") + QuickLendXError::BackupVersionUnsupported => symbol_short!("BKP_VER"), + QuickLendXError::InvalidLedgerSequence => symbol_short!("INV_LED"), } } } diff --git a/quicklendx-contracts/src/idempotency.rs b/quicklendx-contracts/src/idempotency.rs index 06dc4089..7a11f426 100644 --- a/quicklendx-contracts/src/idempotency.rs +++ b/quicklendx-contracts/src/idempotency.rs @@ -1,4 +1,5 @@ use crate::storage::{bump_persistent, extend_persistent_ttl}; +use soroban_sdk::{symbol_short, Address, Bytes, BytesN, Env, Symbol}; /// Storage key for the idempotency map. pub const IDEMPOTENCY_MAP_KEY: Symbol = symbol_short!("idem_map"); diff --git a/quicklendx-contracts/src/lib.rs b/quicklendx-contracts/src/lib.rs index a6d54345..5907f5af 100644 --- a/quicklendx-contracts/src/lib.rs +++ b/quicklendx-contracts/src/lib.rs @@ -1,4 +1,15 @@ #![no_std] +#![cfg_attr( + test, + allow( + unused_must_use, + unused_parens, + clippy::doc_lazy_continuation, + clippy::err_expect, + clippy::module_inception, + clippy::unnecessary_cast + ) +)] #![allow( dead_code, unused_imports, @@ -57,11 +68,10 @@ mod test_maintenance; mod test_maintenance_write_matrix; #[cfg(test)] mod test_settlement_history_reconstruction; +use crate::idempotency::{idempotency_exists, idempotency_key, store_idempotency}; use soroban_sdk::{contract, contractimpl, symbol_short, Address, BytesN, Env, Map, String, Vec}; -use crate::idempotency::{idempotency_key, idempotency_exists, store_idempotency}; -#[cfg(any(test, feature = "testutils"))] -pub mod bench; +pub mod address_summary; pub mod admin; pub mod analytics; pub mod audit; @@ -84,6 +94,7 @@ pub mod fees; pub mod freshness; pub mod governance; pub mod health; +pub mod idempotency; pub mod incident; pub mod init; pub mod invariants; @@ -108,12 +119,6 @@ pub mod storage; mod test_accept_bid_instruction_budget; #[cfg(all(test, feature = "legacy-tests"))] mod test_accept_bid_race; -#[cfg(test)] -mod test_panic_handler; -#[cfg(test)] -mod test_due_date_guard; -#[cfg(test)] -mod test_cancel_invoice_matrix; #[cfg(all(test, feature = "legacy-tests"))] mod test_admin; #[cfg(all(test, feature = "legacy-tests"))] @@ -141,24 +146,30 @@ mod test_bid_expiry_boundary; #[cfg(all(test, feature = "legacy-tests"))] mod test_bid_ttl; #[cfg(all(test, feature = "legacy-tests"))] +mod test_cancel_invoice_matrix; +#[cfg(all(test, feature = "legacy-tests"))] mod test_cleanup_pagination; #[cfg(test)] mod test_config_bounds_matrix; #[cfg(all(test, feature = "legacy-tests"))] mod test_currency; #[cfg(test)] +mod test_currency_active_escrow; +#[cfg(test)] mod test_currency_batch; #[cfg(all(test, feature = "legacy-tests"))] mod test_currency_match_funding; #[cfg(all(test, feature = "legacy-tests"))] mod test_dispute; +#[cfg(all(test, feature = "legacy-tests"))] +mod test_dispute_event_invariant; #[cfg(test)] mod test_dispute_refund_flow; #[cfg(all(test, feature = "legacy-tests"))] mod test_dispute_timeline_props; #[cfg(test)] -mod test_dispute_event_invariant; -#[cfg(test)] +mod test_due_date_guard; +#[cfg(all(test, feature = "legacy-tests"))] mod test_dust_transfer; #[cfg(all(test, feature = "legacy-tests"))] mod test_escrow_event_completeness; @@ -168,22 +179,20 @@ mod test_escrow_invariant_model; mod test_escrow_refund_after_expiry; #[cfg(all(test, feature = "legacy-tests"))] mod test_expired_bids_cleanup; -#[cfg(test)] +#[cfg(all(test, feature = "legacy-tests"))] mod test_freshness; #[cfg(all(test, feature = "legacy-tests"))] mod test_freshness_bounds; #[cfg(test)] mod test_panic_handler; -#[cfg(test)] +#[cfg(all(test, feature = "legacy-tests"))] mod test_payments; -#[cfg(test)] +#[cfg(all(test, feature = "legacy-tests"))] mod test_queries; #[cfg(all(test, feature = "legacy-tests"))] mod test_self_call_rejection; // Issue #1541 — lag at zero, lag at positive, lag during pause. #[cfg(all(test, feature = "legacy-tests"))] -mod test_accept_bid_race; -#[cfg(all(test, feature = "legacy-tests"))] mod test_freshness_lag; #[cfg(all(test, feature = "legacy-tests"))] mod test_health_status; @@ -236,7 +245,7 @@ mod test_string_limits; mod test_analytics_consistency; #[cfg(test)] mod test_bid_capacity_stress; -#[cfg(all(test, feature = "fuzz-tests"))] +#[cfg(all(test, feature = "legacy-tests", feature = "fuzz-tests"))] mod test_bid_compare_order_props; #[cfg(all(test, feature = "legacy-tests"))] mod test_bid_ranking; @@ -252,8 +261,6 @@ mod test_business_invoices_paged_ordering; #[cfg(all(test, feature = "legacy-tests"))] mod test_category_breakdown; #[cfg(all(test, feature = "legacy-tests"))] -mod test_category_breakdown; -#[cfg(all(test, feature = "legacy-tests"))] mod test_clock_rollover; #[cfg(all(test, feature = "fuzz-tests"))] mod test_compute_yield_props; @@ -263,59 +270,39 @@ mod test_default_grace_boundary; mod test_diagnostics; #[cfg(all(test, feature = "legacy-tests"))] mod test_events; -#[cfg(all(test, feature = "fuzz-tests"))] +#[cfg(all(test, feature = "legacy-tests", feature = "fuzz-tests"))] mod test_fuzz_cancelled_noop; #[cfg(all(test, feature = "legacy-tests", feature = "fuzz-tests"))] mod test_fuzz_distribute_revenue; #[cfg(all(test, feature = "legacy-tests", feature = "fuzz-tests"))] mod test_fuzz_invoice_metadata; -#[cfg(all(test, feature = "fuzz-tests"))] +#[cfg(all(test, feature = "legacy-tests", feature = "fuzz-tests"))] mod test_fuzz_partial_payment; #[cfg(all(test, feature = "legacy-tests"))] mod test_incident; #[cfg(test)] -mod test_incident; -#[cfg(test)] #[cfg(all(test, feature = "legacy-tests"))] mod test_init_invariants; #[cfg(test)] mod test_input_matrix; #[cfg(all(test, feature = "legacy-tests"))] -mod test_investment_withdrawal; +mod test_insurance_claim_payout; +#[cfg(test)] +mod test_insurance_optin_lifecycle; +#[cfg(all(test, feature = "fuzz-tests"))] +mod test_insurance_premium_props; #[cfg(all(test, feature = "legacy-tests"))] mod test_investment_transitions; -#[cfg(test)] -mod test_incident; #[cfg(all(test, feature = "legacy-tests"))] -mod test_invoice_metadata; +mod test_investment_withdrawal; #[cfg(all(test, feature = "legacy-tests"))] -mod test_line_item_consistency; +mod test_invoice_metadata; #[cfg(all(test, feature = "legacy-tests"))] mod test_invoice_search_ranking; #[cfg(all(test, feature = "legacy-tests"))] -mod test_default_grace_boundary; -#[cfg(all(test, feature = "legacy-tests"))] -mod test_clock_rollover; -#[cfg(all(test, feature = "legacy-tests"))] -mod test_rebuild_indexes; +mod test_line_item_consistency; #[cfg(all(test, feature = "legacy-tests"))] mod test_max_invoices_per_business; -#[cfg(test)] -mod test_diagnostics; -#[cfg(all(test, feature = "legacy-tests"))] -mod test_business_invoices_paged_ordering; -#[cfg(all(test, feature = "legacy-tests"))] -mod test_insurance_claim_payout; -#[cfg(test)] -mod test_insurance_optin_lifecycle; -#[cfg(all(test, feature = "fuzz-tests"))] -mod test_insurance_premium_props; -#[cfg(all(test, feature = "fuzz-tests"))] -mod test_fuzz_cancelled_noop; -#[cfg(all(test, feature = "fuzz-tests"))] -mod test_compute_yield_props; -#[cfg(all(test, feature = "fuzz-tests"))] -mod test_twa_props; #[cfg(all(test, feature = "legacy-tests"))] mod test_notifications; #[cfg(all(test, feature = "legacy-tests"))] @@ -323,15 +310,17 @@ mod test_pause_reads_available; mod test_platform_metrics_reconciliation; #[cfg(all(test, feature = "legacy-tests"))] mod test_rebuild_indexes; -#[cfg(all(test, feature = "fuzz-tests"))] +#[cfg(all(test, feature = "legacy-tests", feature = "fuzz-tests"))] mod test_seed; #[cfg(all(test, feature = "legacy-tests", feature = "fuzz-tests"))] mod test_treasury_split_overflow_props; #[cfg(all(test, feature = "fuzz-tests"))] +mod test_twa_props; +#[cfg(all(test, feature = "fuzz-tests"))] mod test_volume_tier_props; // Issue #1482 — "cannot withdraw more than deposited" invariant: hard-coded sad // path (always runs) + proptest property (requires fuzz-tests feature). -#[cfg(test)] +#[cfg(all(test, feature = "legacy-tests"))] mod test_cannot_withdraw_more_than_deposited; pub mod types; pub use types::*; @@ -349,10 +338,10 @@ use escrow::{ }; use events::{ emit_bid_accepted, emit_bid_placed, emit_bid_withdrawn, emit_dispute_created, - emit_dispute_rejected, emit_dispute_resolved, emit_dispute_under_review, emit_escrow_created, emit_escrow_released, - emit_insurance_added, emit_insurance_premium_collected, emit_investor_verified, - emit_invoice_cancelled, emit_invoice_metadata_cleared, emit_invoice_metadata_updated, - emit_invoice_uploaded, emit_invoice_verified, + emit_dispute_rejected, emit_dispute_resolved, emit_dispute_under_review, emit_escrow_created, + emit_escrow_released, emit_insurance_added, emit_insurance_premium_collected, + emit_investor_verified, emit_invoice_cancelled, emit_invoice_metadata_cleared, + emit_invoice_metadata_updated, emit_invoice_uploaded, emit_invoice_verified, }; use investment::InvestmentStorage; use invoice_search::InvoiceSearch; @@ -364,10 +353,9 @@ use settlement::{ use verification::{ calculate_investment_limit, calculate_investor_risk_score, compute_investor_tier, determine_investor_tier, get_investor_verification as do_get_investor_verification, - normalize_tag, reject_business, - reject_investor as do_reject_investor, revoke_investor_kyc as do_revoke_investor_kyc, - recompute_investor_tier, require_business_not_pending, - require_investor_not_pending, submit_investor_kyc as do_submit_investor_kyc, + normalize_tag, recompute_investor_tier, reject_business, reject_investor as do_reject_investor, + require_business_not_pending, require_investor_not_pending, + revoke_investor_kyc as do_revoke_investor_kyc, submit_investor_kyc as do_submit_investor_kyc, submit_kyc_application, validate_bid, validate_dispute_evidence, validate_dispute_resolution, validate_investor_investment, validate_invoice_metadata, verify_business, verify_investor as do_verify_investor, verify_invoice_data, BusinessVerificationStatus, @@ -1535,13 +1523,11 @@ impl QuickLendXContract { /// preventing double-action execution. pub fn withdraw_bid(env: Env, bid_id: BytesN<32>) -> Result<(), QuickLendXError> { pause::PauseControl::require_not_paused(&env)?; - let mut bid = - BidStorage::get_bid(&env, &bid_id).unwrap(); + let mut bid = BidStorage::get_bid(&env, &bid_id).unwrap(); bid.investor.require_auth(); require_investor_not_pending(&env, &bid.investor)?; // Re-read status after auth to guard against concurrent transitions. - let bid_fresh = - BidStorage::get_bid(&env, &bid_id).unwrap(); + let bid_fresh = BidStorage::get_bid(&env, &bid_id).unwrap(); if bid_fresh.status != BidStatus::Placed { return Err(QuickLendXError::OperationNotAllowed); } @@ -1683,8 +1669,7 @@ impl QuickLendXContract { let bid = BidStorage::get_bid(&env, &bid_id).unwrap(); let invoice_id = bid.invoice_id.clone(); BidStorage::cleanup_expired_bids(&env, &invoice_id); - let mut bid = - BidStorage::get_bid(&env, &bid_id).unwrap(); + let mut bid = BidStorage::get_bid(&env, &bid_id).unwrap(); invoice.business.require_auth(); // Enforce KYC: a pending business must not accept bids. @@ -1729,8 +1714,7 @@ impl QuickLendXContract { }; InvestmentStorage::store_investment(&env, &investment); - let escrow = EscrowStorage::get_escrow(&env, &escrow_id) - .unwrap(); + let escrow = EscrowStorage::get_escrow(&env, &escrow_id).unwrap(); emit_escrow_created(&env, &escrow); emit_bid_accepted(&env, &bid, &invoice_id, &invoice.business); @@ -1758,8 +1742,7 @@ impl QuickLendXContract { coverage_percentage: u32, ) -> Result<(), QuickLendXError> { pause::PauseControl::require_not_paused(&env)?; - let mut investment = InvestmentStorage::get_investment(&env, &investment_id) - .unwrap(); + let mut investment = InvestmentStorage::get_investment(&env, &investment_id).unwrap(); investment.investor.require_auth(); @@ -1867,8 +1850,7 @@ impl QuickLendXContract { env: Env, investment_id: BytesN<32>, ) -> Result, QuickLendXError> { - let investment = InvestmentStorage::get_investment(&env, &investment_id) - .unwrap(); + let investment = InvestmentStorage::get_investment(&env, &investment_id).unwrap(); Ok(investment.insurance) } @@ -2356,8 +2338,7 @@ impl QuickLendXContract { env: Env, invoice_id: BytesN<32>, ) -> Result { - let escrow = EscrowStorage::get_escrow_by_invoice(&env, &invoice_id) - .unwrap(); + let escrow = EscrowStorage::get_escrow_by_invoice(&env, &invoice_id).unwrap(); Ok(escrow.status) } @@ -2403,8 +2384,7 @@ impl QuickLendXContract { return Err(QuickLendXError::InvalidStatus); } - let escrow = EscrowStorage::get_escrow_by_invoice(&env, &invoice_id) - .unwrap(); + let escrow = EscrowStorage::get_escrow_by_invoice(&env, &invoice_id).unwrap(); release_escrow(&env, &invoice_id)?; @@ -3212,8 +3192,7 @@ impl QuickLendXContract { ) -> Result<(), QuickLendXError> { pause::PauseControl::require_not_paused(&env)?; AdminStorage::require_admin(&env, &admin)?; - let mut b = backup::BackupStorage::get_backup(&env, &backup_id) - .unwrap(); + let mut b = backup::BackupStorage::get_backup(&env, &backup_id).unwrap(); b.status = backup::BackupStatus::Archived; backup::BackupStorage::update_backup(&env, &b)?; backup::BackupStorage::remove_from_backup_list(&env, &backup_id); @@ -3420,7 +3399,10 @@ impl QuickLendXContract { // ============================================================================ /// Get user behavior metrics - pub fn get_user_behavior_metrics(env: Env, user: Address) -> Result { + pub fn get_user_behavior_metrics( + env: Env, + user: Address, + ) -> Result { analytics::AnalyticsCalculator::calculate_user_behavior_metrics(&env, &user) } @@ -4122,10 +4104,10 @@ impl QuickLendXContract { #[cfg(all(test, feature = "legacy-tests"))] mod test_emergency_escrow_protection; #[cfg(all(test, feature = "legacy-tests"))] -mod test_escrow_settle_refund_race; -#[cfg(test)] mod test_escrow_mutual_exclusion; #[cfg(all(test, feature = "legacy-tests"))] +mod test_escrow_settle_refund_race; +#[cfg(all(test, feature = "legacy-tests"))] mod test_id_collision_cross_domain; #[cfg(all(test, feature = "legacy-tests"))] mod test_id_stability; diff --git a/quicklendx-contracts/src/payments.rs b/quicklendx-contracts/src/payments.rs index 8421649d..b4ea27af 100644 --- a/quicklendx-contracts/src/payments.rs +++ b/quicklendx-contracts/src/payments.rs @@ -491,8 +491,7 @@ pub fn create_escrow( /// * [`QuickLendXError::TokenTransferFailed`] - the token contract panicked; escrow status is /// **not** updated so the release can be safely retried. pub fn release_escrow(env: &Env, invoice_id: &BytesN<32>) -> Result<(), QuickLendXError> { - let mut escrow = EscrowStorage::get_escrow_by_invoice(env, invoice_id) - .unwrap(); + let mut escrow = EscrowStorage::get_escrow_by_invoice(env, invoice_id).unwrap(); if escrow.status != EscrowStatus::Held { // Prevents repeated release (idempotency) @@ -547,8 +546,7 @@ pub fn release_escrow(env: &Env, invoice_id: &BytesN<32>) -> Result<(), QuickLen /// * [`QuickLendXError::TokenTransferFailed`] - the token contract panicked; escrow status is /// **not** updated so the refund can be safely retried. pub fn refund_escrow(env: &Env, invoice_id: &BytesN<32>) -> Result<(), QuickLendXError> { - let mut escrow = EscrowStorage::get_escrow_by_invoice(env, invoice_id) - .unwrap(); + let mut escrow = EscrowStorage::get_escrow_by_invoice(env, invoice_id).unwrap(); if escrow.status != EscrowStatus::Held { return Err(QuickLendXError::InvalidStatus); @@ -849,6 +847,7 @@ mod payments_tests { /// Passing an address that is *not* a registered token contract must not /// silently succeed; any failure path that leaves no escrow is acceptable. + #[cfg(feature = "legacy-tests")] #[test] fn test_create_escrow_unregistered_token_address_does_not_succeed() { let (env, contract_id) = contract_env(); @@ -946,4 +945,4 @@ mod payments_tests { }); assert_eq!(r2, Err(QuickLendXError::InvoiceAlreadyFunded)); } -} \ No newline at end of file +} diff --git a/quicklendx-contracts/src/profits.rs b/quicklendx-contracts/src/profits.rs index 4b16d577..a1e1fa01 100644 --- a/quicklendx-contracts/src/profits.rs +++ b/quicklendx-contracts/src/profits.rs @@ -529,24 +529,6 @@ pub fn validate_calculation_inputs( // Yield Calculation // ============================================================================ -pub fn compute_yield(amount: i128, rate_bps: i128, duration_days: i128) -> i128 { - let safe_amount = amount.max(0); - let safe_rate = rate_bps.max(0); - let safe_days = duration_days.max(0); - - if safe_amount == 0 || safe_rate == 0 || safe_days == 0 { - return 0; - } - - let days_in_year = 365i128; - let denominator = BPS_DENOMINATOR.saturating_mul(days_in_year); - - safe_amount - .saturating_mul(safe_rate) - .saturating_mul(safe_days) - / denominator -} - /// Compute the simple interest yield on a principal amount. /// /// # Formula @@ -582,39 +564,10 @@ pub fn compute_yield(amount: i128, rate_bps: u32, duration_days: u32) -> i128 { /// # Returns /// Total expected return (principal + yield) pub fn compute_expected_return(amount: i128, rate_bps: u32, duration_days: u32) -> i128 { - let yield_amount = compute_yield(amount, rate_bps.into(), duration_days.into()); + let yield_amount = compute_yield(amount, rate_bps, duration_days); amount.max(0).saturating_add(yield_amount) } -/// Compute the simple interest yield on a principal amount. -/// -/// # Formula -/// ```text -/// yield = amount * rate_bps * duration_days / (BPS_DENOMINATOR * 365) -/// ``` -/// -/// All arithmetic uses `saturating_mul` / integer division to stay within -/// `i128` bounds without panicking and to preserve `#![no_std]` discipline. -/// -/// # Arguments -/// * `amount` — Principal (must be >= 0; negative input returns 0) -/// * `rate_bps` — Annual rate in basis points, e.g. 500 = 5 % -/// * `duration_days` — Holding period in days -/// -/// # Returns -/// Simple interest yield (non-negative). -pub fn compute_yield(amount: i128, rate_bps: i128, duration_days: i128) -> i128 { - if amount <= 0 || rate_bps <= 0 || duration_days <= 0 { - return 0; - } - // amount * rate_bps * duration_days / (10_000 * 365) - let numerator = amount - .saturating_mul(rate_bps) - .saturating_mul(duration_days); - let denominator: i128 = BPS_DENOMINATOR.saturating_mul(365); - numerator / denominator -} - /// A single ledger-delta entry for time-weighted average calculations. /// /// Each entry records the `balance` held for `duration_ledgers` ledgers. @@ -675,7 +628,11 @@ pub fn compute_twa_reference(deltas: &[LedgerDelta]) -> i128 { num = num.saturating_add(d.balance.saturating_mul(dur)); den = den.saturating_add(dur); } - if den == 0 { 0 } else { num / den } + if den == 0 { + 0 + } else { + num / den + } } // ============================================================================ @@ -947,6 +904,7 @@ mod tests { } } + #[cfg(feature = "legacy-tests")] #[test] fn test_investor_platform_treasury_sum_invariant() { let env = Env::default(); diff --git a/quicklendx-contracts/src/test_bid_capacity_stress.rs b/quicklendx-contracts/src/test_bid_capacity_stress.rs index 904de0d8..8c40de1d 100644 --- a/quicklendx-contracts/src/test_bid_capacity_stress.rs +++ b/quicklendx-contracts/src/test_bid_capacity_stress.rs @@ -249,7 +249,7 @@ fn test_rank_bids_full_capacity_orders_by_documented_chain() { if i == 0 { first_bid_id = Some(bid_id.clone()); } - if i == MAX_BIDS_PER_INVOICE - 1 + if i == MAX_BIDS_PER_INVOICE - 1 { last_bid_id = Some(bid_id); } } diff --git a/quicklendx-contracts/src/test_bid_ranking_determinism.rs b/quicklendx-contracts/src/test_bid_ranking_determinism.rs index 0e01d2b2..f06ec5a6 100644 --- a/quicklendx-contracts/src/test_bid_ranking_determinism.rs +++ b/quicklendx-contracts/src/test_bid_ranking_determinism.rs @@ -1,5 +1,4 @@ extern crate alloc; -use alloc::vec::Vec; /// # Bid Ranking Determinism Tests (Issue #1551) /// /// Verifies that `BidStorage::rank_bids` and `BidStorage::compare_bids` produce diff --git a/quicklendx-contracts/src/test_currency_active_escrow.rs b/quicklendx-contracts/src/test_currency_active_escrow.rs new file mode 100644 index 00000000..280cd5ce --- /dev/null +++ b/quicklendx-contracts/src/test_currency_active_escrow.rs @@ -0,0 +1,126 @@ +//! Regression coverage for currency whitelist removals while funds are held in escrow. + +use super::*; +use crate::errors::QuickLendXError; +use crate::invoice::InvoiceCategory; +use crate::payments::EscrowStatus; +use soroban_sdk::{ + testutils::{Address as _, Ledger as _}, + token, Address, BytesN, Env, String, Vec, +}; + +fn setup_env() -> (Env, QuickLendXContractClient<'static>, Address, Address) { + let env = Env::default(); + env.mock_all_auths(); + env.ledger().set_timestamp(1_000); + + let contract_id = env.register(QuickLendXContract, ()); + let client = QuickLendXContractClient::new(&env, &contract_id); + let admin = Address::generate(&env); + client.initialize_admin(&admin); + client.set_admin(&admin); + + (env, client, admin, contract_id) +} + +fn verified_business( + env: &Env, + client: &QuickLendXContractClient<'static>, + admin: &Address, +) -> Address { + let business = Address::generate(env); + client.submit_kyc_application(&business, &String::from_str(env, "Business KYC")); + client.verify_business(admin, &business); + business +} + +fn verified_investor( + env: &Env, + client: &QuickLendXContractClient<'static>, + limit: i128, +) -> Address { + let investor = Address::generate(env); + client.submit_investor_kyc(&investor, &String::from_str(env, "Investor KYC")); + client.verify_investor(&investor, &limit); + investor +} + +fn funded_currency(env: &Env, investor: &Address, contract_id: &Address) -> Address { + let token_admin = Address::generate(env); + let currency = env + .register_stellar_asset_contract_v2(token_admin) + .address(); + let sac = token::StellarAssetClient::new(env, ¤cy); + let tok = token::Client::new(env, ¤cy); + let balance = 100_000i128; + sac.mint(investor, &balance); + tok.approve( + investor, + contract_id, + &balance, + &(env.ledger().sequence() + 10_000), + ); + currency +} + +fn one_currency(env: &Env, currency: &Address) -> Vec
{ + let mut currencies = Vec::new(env); + currencies.push_back(currency.clone()); + currencies +} + +#[test] +fn remove_currency_rejects_active_held_escrow_and_allows_after_refund() { + let (env, client, admin, contract_id) = setup_env(); + let business = verified_business(&env, &client, &admin); + let investor = verified_investor(&env, &client, 100_000); + let currency = funded_currency(&env, &investor, &contract_id); + let other_currency = Address::generate(&env); + let amount = 5_000i128; + + client.add_currency(&admin, ¤cy); + client.add_currency(&admin, &other_currency); + + let invoice_id = client.store_invoice( + &business, + &amount, + ¤cy, + &(env.ledger().timestamp() + 86_400), + &String::from_str(&env, "Active escrow currency removal regression"), + &InvoiceCategory::Services, + &Vec::new(&env), + ); + client.verify_invoice(&invoice_id); + + let bid_id = client.place_bid( + &investor, + &invoice_id, + &amount, + &(amount + 500), + &BytesN::from_array(&env, &[9; 32]), + ); + client.accept_bid_and_fund(&invoice_id, &bid_id); + + assert_eq!(client.get_escrow_status(&invoice_id), EscrowStatus::Held); + assert_eq!( + client.get_total_locked_escrow(&one_currency(&env, ¤cy), &1), + amount + ); + + let err = client + .try_remove_currency(&admin, ¤cy) + .unwrap_err() + .unwrap(); + assert_eq!(err, QuickLendXError::InvalidStatus); + assert!(client.is_allowed_currency(¤cy)); + + client.refund_escrow_funds(&invoice_id, &business); + assert_eq!( + client.get_total_locked_escrow(&one_currency(&env, ¤cy), &1), + 0 + ); + + client.remove_currency(&admin, ¤cy); + assert!(!client.is_allowed_currency(¤cy)); + assert!(client.is_allowed_currency(&other_currency)); +} diff --git a/quicklendx-contracts/tests/auth_verification_test.rs b/quicklendx-contracts/tests/auth_verification_test.rs index ffbb42ad..b8d35aa0 100644 --- a/quicklendx-contracts/tests/auth_verification_test.rs +++ b/quicklendx-contracts/tests/auth_verification_test.rs @@ -1,14 +1,15 @@ -#![cfg(test)] +#![cfg(all(test, feature = "legacy-tests"))] +#![allow(clippy::disallowed_methods)] -extern crate std; +extern crate std; use proptest::prelude::*; use soroban_sdk::{ testutils::{Address as _, AuthorizedFunction}, - Address, BytesN, Env, IntoVal, String, Symbol, Vec + Address, BytesN, Env, IntoVal, String, Symbol, Vec, }; -use quicklendx_contracts::{QuickLendXContract, QuickLendXContractClient, InvoiceCategory}; +use quicklendx_contracts::{InvoiceCategory, QuickLendXContract, QuickLendXContractClient}; proptest! { #[test] @@ -19,22 +20,22 @@ proptest! { let env = Env::default(); let contract_id = env.register(QuickLendXContract, ()); let client = QuickLendXContractClient::new(&env, &contract_id); - + let investor = Address::generate(&env); let business = Address::generate(&env); let currency = Address::generate(&env); let salt = BytesN::from_array(&env, &[0u8; 32]); - + env.mock_all_auths(); - + let admin = Address::generate(&env); client.set_admin(&admin); - + client.initialize_protocol_limits(&admin, &10, &30, &86400); let due_date = env.ledger().timestamp() + 86400; let invoice_amount = 100_000i128; - + let invoice_id = client.upload_invoice( &business, &invoice_amount, @@ -51,12 +52,12 @@ proptest! { client.place_bid(&investor, &invoice_id, &bid_amount, &expected_return, &salt); let auths = env.auths(); - + let last_auth = auths.last().expect("Should have at least one auth"); let (auth_address, invocation) = last_auth; assert_eq!(auth_address, &investor, "The auth address must match the investor"); - + assert_eq!( invocation.function, AuthorizedFunction::Contract(( @@ -74,18 +75,24 @@ fn fails_when_authorization_is_missing_for_protected_entrypoint() { let env = Env::default(); let contract_id = env.register(QuickLendXContract, ()); let client = QuickLendXContractClient::new(&env, &contract_id); - + let rogue_user = Address::generate(&env); - + let invoice_id = BytesN::from_array(&env, &[1u8; 32]); let salt = BytesN::from_array(&env, &[0u8; 32]); let bid_amount = 1000i128; let expected_return = 5000i128; - - let result = client.try_place_bid(&rogue_user, &invoice_id, &bid_amount, &expected_return, &salt); - + + let result = client.try_place_bid( + &rogue_user, + &invoice_id, + &bid_amount, + &expected_return, + &salt, + ); + assert!( result.is_err(), "Contract must trap/fail when an operation requiring auth() is invoked without proper authorization" ); -} \ No newline at end of file +} diff --git a/quicklendx-contracts/tests/invoice_lifecycle_e2e.rs b/quicklendx-contracts/tests/invoice_lifecycle_e2e.rs index f834429b..fe8bd152 100644 --- a/quicklendx-contracts/tests/invoice_lifecycle_e2e.rs +++ b/quicklendx-contracts/tests/invoice_lifecycle_e2e.rs @@ -1,3 +1,5 @@ +#![allow(clippy::disallowed_methods)] + //! Full invoice lifecycle end-to-end integration tests (Issue #1103). //! //! These tests exercise the complete on-chain lifecycle of an invoice from @@ -419,9 +421,13 @@ fn test_invoice_lifecycle_default_branch() { // ── Stage 3: Place bid ─────────────────────────────────────────────────── // Investor places a bid. - let bid_id = fx - .client - .place_bid(&fx.investor, &invoice_id, &bid_amount, &invoice_amount, &BytesN::from_array(&fx.client.env, &[0u8; 32])); + let bid_id = fx.client.place_bid( + &fx.investor, + &invoice_id, + &bid_amount, + &invoice_amount, + &BytesN::from_array(&fx.client.env, &[0u8; 32]), + ); assert_eq!( fx.client.get_bid(&bid_id).unwrap().status, BidStatus::Placed, @@ -580,9 +586,13 @@ fn test_partial_then_full_settle() { // ── Stage 3: Place bid ─────────────────────────────────────────────────── // Investor places a bid. - let bid_id = fx - .client - .place_bid(&fx.investor, &invoice_id, &bid_amount, &invoice_amount, &BytesN::from_array(&fx.client.env, &[0u8; 32])); + let bid_id = fx.client.place_bid( + &fx.investor, + &invoice_id, + &bid_amount, + &invoice_amount, + &BytesN::from_array(&fx.client.env, &[0u8; 32]), + ); assert_eq!( fx.client.get_bid(&bid_id).unwrap().bid_amount, bid_amount, diff --git a/quicklendx-contracts/tests/profit_fee_golden.rs b/quicklendx-contracts/tests/profit_fee_golden.rs index 14f2fe81..c2bc77c1 100644 --- a/quicklendx-contracts/tests/profit_fee_golden.rs +++ b/quicklendx-contracts/tests/profit_fee_golden.rs @@ -1,3 +1,5 @@ +#![allow(clippy::disallowed_methods, clippy::let_unit_value)] + //! Differential golden-vector harness for profit and fee math. //! //! Loads a frozen corpus from `tests/fixtures/profit_fee_corpus.json` and diff --git a/quicklendx-contracts/tests/unsafe_code_gate.rs b/quicklendx-contracts/tests/unsafe_code_gate.rs index f029ef4a..6cf0c77b 100644 --- a/quicklendx-contracts/tests/unsafe_code_gate.rs +++ b/quicklendx-contracts/tests/unsafe_code_gate.rs @@ -1,3 +1,7 @@ +#![allow( + clippy::needless_borrows_for_generic_args, + clippy::while_let_on_iterator +)] //! # Unsafe-Code Gate – Negative-Test Regression Guard //! @@ -226,9 +230,8 @@ fn first_party_sources_contain_no_unsafe_keyword() { let mut violations: Vec = Vec::new(); for file_path in &rs_files { - let content = fs::read_to_string(file_path).unwrap_or_else(|e| { - panic!("Could not read {}: {e}", file_path.display()) - }); + let content = fs::read_to_string(file_path) + .unwrap_or_else(|e| panic!("Could not read {}: {e}", file_path.display())); for (line_num, line) in content.lines().enumerate() { let trimmed = line.trim();