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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 44 additions & 12 deletions docs/contracts/investment-insurance.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:**

Expand All @@ -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` |

---

Expand All @@ -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)
Expand All @@ -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 |

---

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -248,14 +255,39 @@ 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 |
| Integer overflow | `saturating_mul` + `checked_div` throughout |

---

## 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)
Expand Down
28 changes: 11 additions & 17 deletions quicklendx-contracts/src/defaults.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
);
}
}
}

Expand Down
73 changes: 62 additions & 11 deletions quicklendx-contracts/src/investment.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)> {
Expand All @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion quicklendx-contracts/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,4 +33,4 @@ mod test_fuzz;
mod test_business_kyc;

#[cfg(test)]
mod test_investor_kyc;
mod test_investor_kyc;
46 changes: 34 additions & 12 deletions quicklendx-contracts/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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]
Expand Down
Loading
Loading