diff --git a/docs/contracts/investment-insurance.md b/docs/contracts/investment-insurance.md index e6cbae21..24d201c3 100644 --- a/docs/contracts/investment-insurance.md +++ b/docs/contracts/investment-insurance.md @@ -20,6 +20,7 @@ math. | `DEFAULT_INSURANCE_PREMIUM_BPS` | `200` | 2 % premium rate in basis points (1/10 000) | | `MIN_COVERAGE_PERCENTAGE` | `1` | Lowest valid coverage percentage (inclusive) | | `MAX_COVERAGE_PERCENTAGE` | `100` | Highest valid coverage percentage (inclusive) | +| `MAX_TOTAL_COVERAGE_PERCENTAGE` | `100` | Highest cumulative percentage across all active policies | | `MIN_PREMIUM_AMOUNT` | `1` | Minimum acceptable premium in base currency units | #### `InsuranceCoverage` structure @@ -107,8 +108,10 @@ pub fn add_investment_insurance( → `InvalidCoveragePercentage` 5. Computed premium must be ≥ `MIN_PREMIUM_AMOUNT` (investment too small otherwise) → `InvalidAmount` -6. `add_insurance` re-validates all bounds independently (defense-in-depth) -7. No currently active coverage may exist → `OperationNotAllowed` +6. Existing active policies must remain at or below `MAX_TOTAL_COVERAGE_PERCENTAGE` + and the new policy must not push the active total above the cap + → `OperationNotAllowed` +7. `add_insurance` re-validates all bounds independently (defense-in-depth) **On success:** @@ -127,7 +130,7 @@ pub fn add_investment_insurance( | `InvalidStatus` | Investment is not in `Active` state | | `InvalidCoveragePercentage` | `coverage_percentage < 1` or `> 100` | | `InvalidAmount` | Computed premium is zero (investment amount too small), investment principal ≤ 0, coverage amount exceeds principal, or premium > coverage amount | -| `OperationNotAllowed` | An active insurance record already exists on this investment | +| `OperationNotAllowed` | Existing active coverage is malformed or the new policy would make cumulative active coverage exceed `MAX_TOTAL_COVERAGE_PERCENTAGE` | --- @@ -153,10 +156,10 @@ No authorization required — read-only operation. Uninsured Investment (Active) │ ▼ add_investment_insurance(…) -Insured Investment (Active + insurance[n].active = true) +Insured Investment (Active + one or more `insurance[n].active = true`) │ - ▼ process_insurance_claim() on default/settlement -Claimed (insurance[n].active = false, provider/amount preserved) + ▼ process_all_insurance_claims() on default +Claimed (all active records become `false`, provider/amount preserved) │ ▼ add_investment_insurance(…) [optional — new policy] Re-insured (Active + insurance[n+1].active = true) @@ -168,8 +171,9 @@ Re-insured (Active + insurance[n+1].active = true) |---|---|---|---| | 1 | Investment created | 0 | N/A | | 2 | Insurance added | 1 | `true` | -| 3 | Insurance claimed | 1 | `false` | -| 4 | Second policy added (after claim) | 2 | `true` (index 1) | +| 3 | Second active policy added within cap | 2 | `true` on both entries | +| 4 | Invoice defaulted / claims processed | 2 | `false` on both entries | +| 5 | Re-insured later | 3 | `true` on the new entry | --- @@ -213,13 +217,16 @@ Re-insured (Active + insurance[n+1].active = true) Guaranteed analytically when `coverage_percentage ≤ 100`, but explicitly re-checked after arithmetic as a defense-in-depth safeguard. -### Single active insurance +### Cumulative active insurance cap ``` -✓ Can add: When no active insurance record exists -✗ Cannot add: When any insurance record has active = true → OperationNotAllowed +✓ Can add: When active coverage total + new policy ≤ MAX_TOTAL_COVERAGE_PERCENTAGE (100) +✗ Cannot add: When cumulative active coverage would exceed 100 % → OperationNotAllowed ``` +Inactive historical policies are excluded from the cumulative check, so a new +policy can be added after earlier policies are claimed or manually deactivated. + --- ## Security Considerations @@ -248,7 +255,8 @@ re-checked after arithmetic as a defense-in-depth safeguard. | Unauthorized insurance addition | `investor.require_auth()` | | Over-coverage exploit (`coverage_percentage > 100`) | Explicit range check in `lib.rs` and `add_insurance` before any multiplication | | Zero-premium free insurance | `MIN_PREMIUM_AMOUNT` floor in `calculate_premium`; `premium < MIN_PREMIUM_AMOUNT` check in `add_insurance` | -| Double-coverage / concurrent active policies | `has_active_insurance()` check; `OperationNotAllowed` on duplicate | +| Aggregate over-coverage across stacked policies | `total_active_coverage_percentage()` + `MAX_TOTAL_COVERAGE_PERCENTAGE` check before insertion | +| Residual active coverage after default | `process_all_insurance_claims()` deactivates every active policy before persistence | | Insurance on settled/defaulted investment | Status check → `InvalidStatus` | | Negative or zero investment principal | Explicit `self.amount > 0` guard | | Economic inversion (premium > coverage) | `premium > coverage_amount` guard | @@ -256,6 +264,30 @@ re-checked after arithmetic as a defense-in-depth safeguard. --- +## Security Notes + +- The cumulative cap is enforced on active percentages, not record count. This + keeps storage append-only for auditability while still preventing aggregate + coverage from exceeding principal. +- The cap check uses saturating addition as defense in depth. Malformed stored + state cannot wrap into a smaller total and bypass validation. +- Default handling now drains every active policy in one pass before the + investment is persisted. This prevents stale active coverage from remaining on + a defaulted investment. + +--- + +## Verification + +Targeted regression coverage should include: + +- Multiple active policies that sum to exactly 100 % +- Rejection when a new policy would push active total above 100 % +- Historical inactive policies excluded from the active-cap calculation +- Default processing deactivating all active policies, not only the first one + +--- + ## Storage Schema ### Investment record (insurance embedded) diff --git a/quicklendx-contracts/src/defaults.rs b/quicklendx-contracts/src/defaults.rs index c866992a..e8bef943 100644 --- a/quicklendx-contracts/src/defaults.rs +++ b/quicklendx-contracts/src/defaults.rs @@ -251,26 +251,20 @@ pub fn handle_default(env: &Env, invoice_id: &BytesN<32>) -> Result<(), QuickLen if let Some(mut investment) = InvestmentStorage::get_investment_by_invoice(env, invoice_id) { investment.status = InvestmentStatus::Defaulted; - let claim_details = investment - .process_insurance_claim() - .and_then(|(provider, amount)| { - if amount > 0 { - Some((provider, amount)) - } else { - None - } - }); + let claim_details = investment.process_all_insurance_claims(env); InvestmentStorage::update_investment(env, &investment); - if let Some((provider, coverage_amount)) = claim_details { - emit_insurance_claimed( - env, - &investment.investment_id, - &investment.invoice_id, - &provider, - coverage_amount, - ); + for (provider, coverage_amount) in claim_details.iter() { + if coverage_amount > 0 { + emit_insurance_claimed( + env, + &investment.investment_id, + &investment.invoice_id, + &provider, + coverage_amount, + ); + } } } diff --git a/quicklendx-contracts/src/investment.rs b/quicklendx-contracts/src/investment.rs index 3b4d24eb..1f31ac76 100644 --- a/quicklendx-contracts/src/investment.rs +++ b/quicklendx-contracts/src/investment.rs @@ -17,6 +17,11 @@ pub const MIN_COVERAGE_PERCENTAGE: u32 = 1; /// over-coverage exploit where a claimant could receive more than was invested. pub const MAX_COVERAGE_PERCENTAGE: u32 = 100; +/// Maximum cumulative coverage percentage across all active policies on a +/// single investment. Keeping the active total at or below 100% prevents +/// stacked policies from producing aggregate coverage greater than principal. +pub const MAX_TOTAL_COVERAGE_PERCENTAGE: u32 = 100; + /// Minimum acceptable premium in base currency units. A zero-premium policy /// would represent free insurance — an unbounded liability for the provider /// with no economic cost to the insured party. @@ -176,7 +181,11 @@ impl Investment { /// minimum, `coverage_amount` is zero or /// exceeds principal, or premium exceeds /// coverage amount. - /// * [`OperationNotAllowed`] – An active coverage entry already exists. + /// * [`OperationNotAllowed`] – Existing active policies already meet or + /// exceed the cumulative cap, or adding the + /// requested policy would push total active + /// coverage above + /// [`MAX_TOTAL_COVERAGE_PERCENTAGE`]. /// /// # Security /// All arithmetic bounds are re-checked inside this method so that it is @@ -208,13 +217,21 @@ impl Investment { return Err(QuickLendXError::InvalidAmount); } - // Only one active insurance policy is permitted per investment at a - // time. Multiple concurrent active policies would complicate claim - // settlement and open double-coverage exploits. - for coverage in self.insurance.iter() { - if coverage.active { - return Err(QuickLendXError::OperationNotAllowed); - } + let active_coverage_percentage = self.total_active_coverage_percentage(); + + // Existing active coverage must already respect the aggregate cap. + // Refusing to add further policies if stored state is malformed avoids + // compounding an over-covered position. + if active_coverage_percentage > MAX_TOTAL_COVERAGE_PERCENTAGE { + return Err(QuickLendXError::OperationNotAllowed); + } + + // Enforce the aggregate active-coverage cap so stacked policies can + // never exceed the investment principal. + if active_coverage_percentage.saturating_add(coverage_percentage) + > MAX_TOTAL_COVERAGE_PERCENTAGE + { + return Err(QuickLendXError::OperationNotAllowed); } let coverage_amount = self @@ -248,13 +265,24 @@ impl Investment { Ok(coverage_amount) } - pub fn has_active_insurance(&self) -> bool { + /// Return the cumulative percentage across all active insurance policies. + /// + /// # Security + /// This total is the authoritative input for the cumulative-cap check in + /// [`Investment::add_insurance`]. Saturating addition prevents malformed + /// stored state from wrapping back into an apparently safe value. + pub fn total_active_coverage_percentage(&self) -> u32 { + let mut total = 0u32; for coverage in self.insurance.iter() { if coverage.active { - return true; + total = total.saturating_add(coverage.coverage_percentage); } } - false + total + } + + pub fn has_active_insurance(&self) -> bool { + self.total_active_coverage_percentage() > 0 } pub fn process_insurance_claim(&mut self) -> Option<(Address, i128)> { @@ -272,6 +300,29 @@ impl Investment { } None } + + /// Deactivate every active insurance policy and return all claim payouts. + /// + /// # Security + /// This method is used by default handling so that every active provider is + /// claimed exactly once. It prevents residual active coverage from being + /// left behind when multiple policies protect the same investment. + pub fn process_all_insurance_claims(&mut self, env: &Env) -> Vec<(Address, i128)> { + let mut claims = Vec::new(env); + let len = self.insurance.len(); + for idx in 0..len { + if let Some(mut coverage) = self.insurance.get(idx) { + if coverage.active { + coverage.active = false; + let provider = coverage.provider.clone(); + let amount = coverage.coverage_amount; + self.insurance.set(idx, coverage); + claims.push_back((provider, amount)); + } + } + } + claims + } } pub struct InvestmentStorage; diff --git a/quicklendx-contracts/src/lib.rs b/quicklendx-contracts/src/lib.rs index 9ab14a6b..f2269fab 100644 --- a/quicklendx-contracts/src/lib.rs +++ b/quicklendx-contracts/src/lib.rs @@ -33,4 +33,4 @@ mod test_fuzz; mod test_business_kyc; #[cfg(test)] -mod test_investor_kyc; \ No newline at end of file +mod test_investor_kyc; diff --git a/quicklendx-contracts/src/test.rs b/quicklendx-contracts/src/test.rs index 52561873..74c34630 100644 --- a/quicklendx-contracts/src/test.rs +++ b/quicklendx-contracts/src/test.rs @@ -3955,30 +3955,46 @@ fn test_investment_insurance_lifecycle() { let contract_error = err.expect("expected contract invoke error"); assert_eq!(contract_error, QuickLendXError::InvalidCoveragePercentage); - let coverage_percentage = 60u32; - client.add_investment_insurance(&investment_id, &provider, &coverage_percentage); + let coverage_percentage_a = 60u32; + client.add_investment_insurance(&investment_id, &provider, &coverage_percentage_a); + + let second_provider = Address::generate(&env); + let coverage_percentage_b = 40u32; + client.add_investment_insurance(&investment_id, &second_provider, &coverage_percentage_b); - let duplicate_provider = Address::generate(&env); + let excess_provider = Address::generate(&env); let duplicate_attempt = - client.try_add_investment_insurance(&investment_id, &duplicate_provider, &30u32); + client.try_add_investment_insurance(&investment_id, &excess_provider, &1u32); let err = duplicate_attempt.err().expect("expected contract error"); let contract_error = err.expect("expected contract invoke error"); assert_eq!(contract_error, QuickLendXError::OperationNotAllowed); let insured_investment = client.get_invoice_investment(&invoice_id); let investment_amount = insured_investment.amount; - assert_eq!(insured_investment.insurance.len(), 1); + assert_eq!(insured_investment.insurance.len(), 2); let insurance = insured_investment .insurance .get(0) .expect("expected insurance entry"); assert!(insurance.active); assert_eq!(insurance.provider, provider); - assert_eq!(insurance.coverage_percentage, coverage_percentage); - let expected_coverage = investment_amount * coverage_percentage as i128 / 100; - assert_eq!(insurance.coverage_amount, expected_coverage); - let expected_premium = Investment::calculate_premium(investment_amount, coverage_percentage); - assert_eq!(insurance.premium_amount, expected_premium); + assert_eq!(insurance.coverage_percentage, coverage_percentage_a); + let expected_coverage_a = investment_amount * coverage_percentage_a as i128 / 100; + assert_eq!(insurance.coverage_amount, expected_coverage_a); + let expected_premium_a = Investment::calculate_premium(investment_amount, coverage_percentage_a); + assert_eq!(insurance.premium_amount, expected_premium_a); + + let second_insurance = insured_investment + .insurance + .get(1) + .expect("expected second insurance entry"); + assert!(second_insurance.active); + assert_eq!(second_insurance.provider, second_provider); + assert_eq!(second_insurance.coverage_percentage, coverage_percentage_b); + let expected_coverage_b = investment_amount * coverage_percentage_b as i128 / 100; + assert_eq!(second_insurance.coverage_amount, expected_coverage_b); + let expected_premium_b = Investment::calculate_premium(investment_amount, coverage_percentage_b); + assert_eq!(second_insurance.premium_amount, expected_premium_b); let stored_invoice = client.get_invoice(&invoice_id); env.ledger().set_timestamp(stored_invoice.due_date + 1); @@ -3987,13 +4003,19 @@ fn test_investment_insurance_lifecycle() { let after_default = client.get_invoice_investment(&invoice_id); assert_eq!(after_default.status, InvestmentStatus::Defaulted); - assert_eq!(after_default.insurance.len(), 1); + assert_eq!(after_default.insurance.len(), 2); let insurance_after = after_default .insurance .get(0) .expect("expected insurance entry after claim"); assert!(!insurance_after.active); - assert_eq!(insurance_after.coverage_amount, expected_coverage); + assert_eq!(insurance_after.coverage_amount, expected_coverage_a); + let second_after_default = after_default + .insurance + .get(1) + .expect("expected second insurance entry after claim"); + assert!(!second_after_default.active); + assert_eq!(second_after_default.coverage_amount, expected_coverage_b); } #[test] diff --git a/quicklendx-contracts/src/test_insurance.rs b/quicklendx-contracts/src/test_insurance.rs index 15fa5c07..e316d463 100644 --- a/quicklendx-contracts/src/test_insurance.rs +++ b/quicklendx-contracts/src/test_insurance.rs @@ -7,18 +7,20 @@ /// 4. State validation – insurance is only allowed on Active investments. /// 5. Coverage-percentage validation – below min, above max, exact boundaries. /// 6. Investment-amount validation – zero, negative, tiny (premium rounds to 0). -/// 7. Active-insurance guard – only one active policy per investment. +/// 7. Cumulative active-coverage cap – multiple active policies may coexist, +/// but their total percentage can never exceed the configured cap. /// 8. Premium-vs-coverage invariant – premium must not exceed coverage amount. /// 9. Over-coverage exploit prevention – coverage_amount never exceeds principal. /// 10. Query correctness – `query_investment_insurance` returns all historical entries. -/// 11. Claim / process_insurance_claim – deactivates coverage, returns correct amounts. +/// 11. Claim handling – single-claim compatibility and multi-policy deactivation. /// 12. Cross-investment isolation – operations on one investment do not affect another. extern crate alloc; use super::*; use crate::errors::QuickLendXError; use crate::investment::{ Investment, InvestmentStatus, InvestmentStorage, DEFAULT_INSURANCE_PREMIUM_BPS, - MAX_COVERAGE_PERCENTAGE, MIN_COVERAGE_PERCENTAGE, MIN_PREMIUM_AMOUNT, + MAX_COVERAGE_PERCENTAGE, MAX_TOTAL_COVERAGE_PERCENTAGE, MIN_COVERAGE_PERCENTAGE, + MIN_PREMIUM_AMOUNT, }; use soroban_sdk::{ testutils::{Address as _, MockAuth, MockAuthInvoke}, @@ -91,6 +93,7 @@ fn test_constants_have_expected_values() { assert_eq!(DEFAULT_INSURANCE_PREMIUM_BPS, 200); assert_eq!(MIN_COVERAGE_PERCENTAGE, 1); assert_eq!(MAX_COVERAGE_PERCENTAGE, 100); + assert_eq!(MAX_TOTAL_COVERAGE_PERCENTAGE, 100); assert_eq!(MIN_PREMIUM_AMOUNT, 1); } @@ -454,11 +457,11 @@ fn test_add_insurance_rejects_tiny_amount_where_premium_rounds_to_zero() { } // ============================================================================ -// 7. Active-insurance guard (OperationNotAllowed) +// 7. Cumulative active-coverage cap (OperationNotAllowed) // ============================================================================ #[test] -fn test_add_insurance_rejects_duplicate_active_coverage() { +fn test_add_insurance_allows_multiple_active_coverages_within_cumulative_cap() { let (env, client, contract_id) = setup(); env.mock_all_auths(); @@ -467,16 +470,61 @@ fn test_add_insurance_rejects_duplicate_active_coverage() { let provider_b = Address::generate(&env); let id = store_investment(&env, &contract_id, &investor, 10_000, InvestmentStatus::Active, 30); - // First add succeeds + client.add_investment_insurance(&id, &provider_a, &60u32); + client.add_investment_insurance(&id, &provider_b, &40u32); + + let records = client.query_investment_insurance(&id); + assert_eq!(records.len(), 2); + assert!(records.get(0).unwrap().active); + assert!(records.get(1).unwrap().active); + assert_eq!(records.get(0).unwrap().coverage_percentage, 60); + assert_eq!(records.get(1).unwrap().coverage_percentage, 40); +} + +#[test] +fn test_add_insurance_rejects_when_cumulative_coverage_exceeds_cap() { + let (env, client, contract_id) = setup(); + env.mock_all_auths(); + + let investor = Address::generate(&env); + let provider_a = Address::generate(&env); + let provider_b = Address::generate(&env); + let id = store_investment(&env, &contract_id, &investor, 10_000, InvestmentStatus::Active, 31); + client.add_investment_insurance(&id, &provider_a, &60u32); - // Second add while active coverage exists must fail let err = client - .try_add_investment_insurance(&id, &provider_b, &40u32) + .try_add_investment_insurance(&id, &provider_b, &41u32) .err() .unwrap() .unwrap(); assert_eq!(err, QuickLendXError::OperationNotAllowed); + + let records = client.query_investment_insurance(&id); + assert_eq!(records.len(), 1); + assert_eq!(records.get(0).unwrap().coverage_percentage, 60); +} + +#[test] +fn test_add_insurance_accepts_exact_cumulative_cap_across_multiple_policies() { + let (env, client, contract_id) = setup(); + env.mock_all_auths(); + + let investor = Address::generate(&env); + let provider_a = Address::generate(&env); + let provider_b = Address::generate(&env); + let provider_c = Address::generate(&env); + let id = store_investment(&env, &contract_id, &investor, 10_000, InvestmentStatus::Active, 32); + + client.add_investment_insurance(&id, &provider_a, &30u32); + client.add_investment_insurance(&id, &provider_b, &30u32); + client.add_investment_insurance(&id, &provider_c, &40u32); + + let records = client.query_investment_insurance(&id); + assert_eq!(records.len(), 3); + assert_eq!(records.get(0).unwrap().coverage_percentage, 30); + assert_eq!(records.get(1).unwrap().coverage_percentage, 30); + assert_eq!(records.get(2).unwrap().coverage_percentage, 40); } #[test] @@ -487,9 +535,9 @@ fn test_add_insurance_allowed_after_previous_is_deactivated() { let investor = Address::generate(&env); let provider_a = Address::generate(&env); let provider_b = Address::generate(&env); - let id = store_investment(&env, &contract_id, &investor, 10_000, InvestmentStatus::Active, 31); + let id = store_investment(&env, &contract_id, &investor, 10_000, InvestmentStatus::Active, 33); - client.add_investment_insurance(&id, &provider_a, &40u32); + client.add_investment_insurance(&id, &provider_a, &60u32); set_insurance_inactive(&env, &contract_id, &id, 0); // After deactivation a new policy may be added @@ -714,6 +762,39 @@ fn test_second_claim_returns_none_after_first() { assert!(investment.process_insurance_claim().is_none()); } +#[test] +fn test_process_all_insurance_claims_deactivates_all_active_coverages() { + let env = Env::default(); + let investor = Address::generate(&env); + let provider_a = Address::generate(&env); + let provider_b = Address::generate(&env); + + let mut investment = Investment { + investment_id: BytesN::from_array(&env, &[17u8; 32]), + invoice_id: BytesN::from_array(&env, &[18u8; 32]), + investor: investor.clone(), + amount: 10_000, + funded_at: env.ledger().timestamp(), + status: InvestmentStatus::Active, + insurance: Vec::new(&env), + }; + + let premium_a = Investment::calculate_premium(10_000, 40); + let premium_b = Investment::calculate_premium(10_000, 60); + investment.add_insurance(provider_a.clone(), 40, premium_a).unwrap(); + investment.add_insurance(provider_b.clone(), 60, premium_b).unwrap(); + + let claims = investment.process_all_insurance_claims(&env); + assert_eq!(claims.len(), 2); + assert_eq!(claims.get(0).unwrap(), (provider_a, 4_000)); + assert_eq!(claims.get(1).unwrap(), (provider_b, 6_000)); + assert!(!investment.has_active_insurance()); + + let records = investment.insurance; + assert!(!records.get(0).unwrap().active); + assert!(!records.get(1).unwrap().active); +} + // ============================================================================ // 12. Cross-investment isolation // ============================================================================ @@ -857,10 +938,12 @@ fn test_add_insurance_unit_rejects_premium_exceeding_coverage_amount() { } #[test] -fn test_add_insurance_unit_active_guard() { +fn test_add_insurance_unit_enforces_cumulative_cap() { let env = Env::default(); let investor = Address::generate(&env); - let provider = Address::generate(&env); + let provider_a = Address::generate(&env); + let provider_b = Address::generate(&env); + let provider_c = Address::generate(&env); let mut inv = Investment { investment_id: BytesN::from_array(&env, &[15u8; 32]), @@ -872,12 +955,16 @@ fn test_add_insurance_unit_active_guard() { insurance: Vec::new(&env), }; - let premium = Investment::calculate_premium(10_000, 50); - inv.add_insurance(provider.clone(), 50, premium).unwrap(); + let premium_a = Investment::calculate_premium(10_000, 50); + let premium_b = Investment::calculate_premium(10_000, 30); + let premium_c = Investment::calculate_premium(10_000, 21); + inv.add_insurance(provider_a.clone(), 50, premium_a).unwrap(); + inv.add_insurance(provider_b.clone(), 30, premium_b).unwrap(); + + assert_eq!(inv.total_active_coverage_percentage(), 80); - // Second add while active → OperationNotAllowed assert_eq!( - inv.add_insurance(provider.clone(), 30, premium), + inv.add_insurance(provider_c.clone(), 21, premium_c), Err(QuickLendXError::OperationNotAllowed) ); }