From e036efa50acbe6888d8bf4c307751489e694ed40 Mon Sep 17 00:00:00 2001 From: eric Date: Sun, 30 Aug 2026 22:40:45 +0200 Subject: [PATCH] fix: add emergency controls and migration guards --- contracts/claims-processor/src/lib.rs | 502 ++++++---- contracts/claims-processor/src/test.rs | 330 +++--- .../claims-processor/src/test_advanced.rs | 315 +++--- .../claims-processor/src/test_integration.rs | 207 ++-- contracts/claims-processor/src/types.rs | 1 - contracts/governance-dao/src/lib.rs | 49 +- contracts/oracle-verifier/src/lib.rs | 60 +- contracts/oracle-verifier/src/test.rs | 150 ++- contracts/oracle-verifier/src/types.rs | 3 +- contracts/policy-engine/src/lib.rs | 555 +++++++---- contracts/policy-engine/src/test.rs | 794 +++++++++------ contracts/policy-engine/src/test_advanced.rs | 106 +- contracts/policy-engine/src/types.rs | 1 - contracts/risk-pool/src/lib.rs | 936 +++++++++++++----- contracts/risk-pool/src/test.rs | 110 +- contracts/risk-pool/src/test_advanced.rs | 47 +- contracts/risk-pool/src/test_edge.rs | 85 +- contracts/risk-pool/src/types.rs | 50 +- 18 files changed, 2917 insertions(+), 1384 deletions(-) diff --git a/contracts/claims-processor/src/lib.rs b/contracts/claims-processor/src/lib.rs index ff8ab8e..013fc0a 100644 --- a/contracts/claims-processor/src/lib.rs +++ b/contracts/claims-processor/src/lib.rs @@ -20,8 +20,8 @@ extern crate alloc; use alloc::string::ToString; use soroban_sdk::{ - contract, contractimpl, contracttype, contracterror, panic_with_error, - Address, BytesN, Env, Vec, Symbol, + contract, contracterror, contractimpl, contracttype, panic_with_error, Address, BytesN, Env, + Symbol, Vec, }; pub mod types; @@ -60,6 +60,7 @@ const TTL_THRESHOLD: u32 = 518_400; /// Extend persistent entries out to ~1 year (at ~5s/ledger) so pending claims /// survive long enough to be processed. const TTL_EXTEND_TO: u32 = 6_312_000; +const CURRENT_STORAGE_VERSION: u32 = 3; // ─── Storage keys ───────────────────────────────────────────────────────────── @@ -70,13 +71,13 @@ enum StorageKey { PolicyEngine, RiskPool, OracleVerifier, - StalenessThreshold, // u64 — max acceptable oracle data age in seconds + StalenessThreshold, // u64 — max acceptable oracle data age in seconds Claim(u128), - PolicyClaim(u128), // policy_id → claim_id (one claim per policy) + PolicyClaim(u128), // policy_id → claim_id (one claim per policy) NextClaimId, - PendingClaims, // Vec - Keeper(Address), // keeper whitelist: address → bool - Paused, // bool — emergency pause state + PendingClaims, // Vec + Keeper(Address), // keeper whitelist: address → bool + Paused, // bool — emergency pause state /// Contract version (u32) for storage migration tracking Version, } @@ -88,14 +89,14 @@ enum StorageKey { #[repr(u32)] pub enum Error { AlreadyInitialized = 1, - NotInitialized = 2, - Unauthorized = 3, - ClaimNotFound = 4, - PolicyNotActive = 5, - AlreadyClaimed = 6, - AlreadyProcessed = 7, - InvalidAddress = 8, - Paused = 9, + NotInitialized = 2, + Unauthorized = 3, + ClaimNotFound = 4, + PolicyNotActive = 5, + AlreadyClaimed = 6, + AlreadyProcessed = 7, + InvalidAddress = 8, + Paused = 9, } // ─── Contract ───────────────────────────────────────────────────────────────── @@ -105,7 +106,6 @@ pub struct ClaimsProcessor; #[contractimpl] impl ClaimsProcessor { - // ── Lifecycle ──────────────────────────────────────────────────────────── /// One-time initialisation. Links the contract to `policy_engine`, `risk_pool`, and @@ -123,7 +123,7 @@ impl ClaimsProcessor { panic_with_error!(&env, Error::AlreadyInitialized); } let admin_str = admin.to_string(); - + if false { panic!("invalid address: admin must be an account address"); } @@ -138,8 +138,7 @@ impl ClaimsProcessor { let policy_engine_str = policy_engine.to_string(); let oracle_verifier_str = oracle_verifier.to_string(); - - + if policy_engine_str.len() != 56 { panic!("invalid address: policy_engine must be a contract address"); } @@ -164,15 +163,29 @@ impl ClaimsProcessor { Self::validate_stellar_address(&env, &policy_engine); Self::validate_stellar_address(&env, &risk_pool); Self::validate_stellar_address(&env, &oracle_verifier); - - env.storage().instance().set(&StorageKey::Initialized, &true); + + env.storage() + .instance() + .set(&StorageKey::Initialized, &true); env.storage().instance().set(&StorageKey::Admin, &admin); - env.storage().instance().set(&StorageKey::PolicyEngine, &policy_engine); - env.storage().instance().set(&StorageKey::RiskPool, &risk_pool); - env.storage().instance().set(&StorageKey::OracleVerifier, &oracle_verifier); - env.storage().instance().set(&StorageKey::StalenessThreshold, &staleness_threshold); - env.storage().instance().set(&StorageKey::NextClaimId, &1u128); - env.storage().instance().set(&StorageKey::PendingClaims, &Vec::::new(&env)); + env.storage() + .instance() + .set(&StorageKey::PolicyEngine, &policy_engine); + env.storage() + .instance() + .set(&StorageKey::RiskPool, &risk_pool); + env.storage() + .instance() + .set(&StorageKey::OracleVerifier, &oracle_verifier); + env.storage() + .instance() + .set(&StorageKey::StalenessThreshold, &staleness_threshold); + env.storage() + .instance() + .set(&StorageKey::NextClaimId, &1u128); + env.storage() + .instance() + .set(&StorageKey::PendingClaims, &Vec::::new(&env)); env.storage().instance().set(&StorageKey::Paused, &false); env.events().publish( @@ -193,26 +206,27 @@ impl ClaimsProcessor { /// batch_auto_process. Without this, no address can settle claims. pub fn add_keeper(env: Env, admin: Address, keeper: Address) { Self::require_admin(&env, &admin); - env.storage().persistent().set(&StorageKey::Keeper(keeper.clone()), &true); - env.events().publish( - (Symbol::new(&env, "keeper_added"),), - keeper, - ); + env.storage() + .persistent() + .set(&StorageKey::Keeper(keeper.clone()), &true); + env.events() + .publish((Symbol::new(&env, "keeper_added"),), keeper); } /// Admin-only: revoke a keeper's settlement authority. pub fn remove_keeper(env: Env, admin: Address, keeper: Address) { Self::require_admin(&env, &admin); - env.storage().persistent().remove(&StorageKey::Keeper(keeper.clone())); - env.events().publish( - (Symbol::new(&env, "keeper_removed"),), - keeper, - ); + env.storage() + .persistent() + .remove(&StorageKey::Keeper(keeper.clone())); + env.events() + .publish((Symbol::new(&env, "keeper_removed"),), keeper); } /// Whether `keeper` is currently authorized to settle claims. pub fn is_keeper(env: Env, keeper: Address) -> bool { - env.storage().persistent() + env.storage() + .persistent() .get(&StorageKey::Keeper(keeper)) .unwrap_or(false) } @@ -226,16 +240,21 @@ impl ClaimsProcessor { Self::require_not_paused(&env); // Guard: one claim per policy - if env.storage().persistent().has(&StorageKey::PolicyClaim(policy_id)) { + if env + .storage() + .persistent() + .has(&StorageKey::PolicyClaim(policy_id)) + { panic_with_error!(&env, Error::AlreadyClaimed); } // Verify policy is Active via Policy Engine - let policy_engine: Address = env.storage().instance() + let policy_engine: Address = env + .storage() + .instance() .get(&StorageKey::PolicyEngine) .unwrap_or_else(|| panic_with_error!(&env, Error::NotInitialized)); - let policy = PolicyEngineClient::new(&env, &policy_engine) - .get_policy(&policy_id); + let policy = PolicyEngineClient::new(&env, &policy_engine).get_policy(&policy_id); if policy.policyholder != claimant { panic_with_error!(&env, Error::Unauthorized); @@ -244,8 +263,8 @@ impl ClaimsProcessor { panic_with_error!(&env, Error::PolicyNotActive); } - let claim_id = Self::next_claim_id(&env); - let now = env.ledger().timestamp(); + let claim_id = Self::next_claim_id(&env); + let now = env.ledger().timestamp(); let claim = Claim { id: claim_id, policy_id, @@ -258,15 +277,32 @@ impl ClaimsProcessor { processed_at: None, dispute_reason: None, }; - env.storage().persistent().set(&StorageKey::Claim(claim_id), &claim); - env.storage().persistent().extend_ttl(&StorageKey::Claim(claim_id), TTL_THRESHOLD, TTL_EXTEND_TO); - env.storage().persistent().set(&StorageKey::PolicyClaim(policy_id), &claim_id); - env.storage().persistent().extend_ttl(&StorageKey::PolicyClaim(policy_id), TTL_THRESHOLD, TTL_EXTEND_TO); + env.storage() + .persistent() + .set(&StorageKey::Claim(claim_id), &claim); + env.storage().persistent().extend_ttl( + &StorageKey::Claim(claim_id), + TTL_THRESHOLD, + TTL_EXTEND_TO, + ); + env.storage() + .persistent() + .set(&StorageKey::PolicyClaim(policy_id), &claim_id); + env.storage().persistent().extend_ttl( + &StorageKey::PolicyClaim(policy_id), + TTL_THRESHOLD, + TTL_EXTEND_TO, + ); - let mut pending: Vec = env.storage().instance() - .get(&StorageKey::PendingClaims).unwrap_or_else(|| Vec::new(&env)); + let mut pending: Vec = env + .storage() + .instance() + .get(&StorageKey::PendingClaims) + .unwrap_or_else(|| Vec::new(&env)); pending.push_back(claim_id); - env.storage().instance().set(&StorageKey::PendingClaims, &pending); + env.storage() + .instance() + .set(&StorageKey::PendingClaims, &pending); env.events().publish( (Symbol::new(&env, "claim_submitted"),), @@ -285,7 +321,9 @@ impl ClaimsProcessor { pub fn process_claim(env: Env, keeper: Address, claim_id: u128) -> ClaimResult { Self::require_keeper(&env, &keeper); Self::require_not_paused(&env); - let mut claim: Claim = env.storage().persistent() + let mut claim: Claim = env + .storage() + .persistent() .get(&StorageKey::Claim(claim_id)) .unwrap_or_else(|| panic_with_error!(&env, Error::ClaimNotFound)); @@ -293,11 +331,12 @@ impl ClaimsProcessor { return ClaimResult::AlreadyProcessed; } - let policy_engine: Address = env.storage().instance() + let policy_engine: Address = env + .storage() + .instance() .get(&StorageKey::PolicyEngine) .unwrap_or_else(|| panic_with_error!(&env, Error::NotInitialized)); - let policy = PolicyEngineClient::new(&env, &policy_engine) - .get_policy(&claim.policy_id); + let policy = PolicyEngineClient::new(&env, &policy_engine).get_policy(&claim.policy_id); Self::evaluate_and_settle(&env, &mut claim, &policy) } @@ -311,29 +350,43 @@ impl ClaimsProcessor { // ─── IDEMPOTENCY GUARD ─── // Check if an evaluation record already exists for this policy in our storage - if env.storage().persistent().has(&StorageKey::PolicyClaim(policy_id)) { - let existing_claim_id: u128 = env.storage().persistent() - .get(&StorageKey::PolicyClaim(policy_id)).unwrap(); - - if let Some(existing_claim) = env.storage().persistent().get::(&StorageKey::Claim(existing_claim_id)) { + if env + .storage() + .persistent() + .has(&StorageKey::PolicyClaim(policy_id)) + { + let existing_claim_id: u128 = env + .storage() + .persistent() + .get(&StorageKey::PolicyClaim(policy_id)) + .unwrap(); + + if let Some(existing_claim) = env + .storage() + .persistent() + .get::(&StorageKey::Claim(existing_claim_id)) + { if existing_claim.status != ClaimStatus::Pending { return ClaimResult::AlreadyProcessed; } } } - let policy_engine: Address = env.storage().instance() + let policy_engine: Address = env + .storage() + .instance() .get(&StorageKey::PolicyEngine) .unwrap_or_else(|| panic_with_error!(&env, Error::NotInitialized)); - let policy = PolicyEngineClient::new(&env, &policy_engine) - .get_policy(&policy_id); + let policy = PolicyEngineClient::new(&env, &policy_engine).get_policy(&policy_id); // Idempotency: check current policy status from down-stream contract match policy.status { - parashield_policy_engine::PolicyStatus::Claimed => return ClaimResult::AlreadyClaimed, - parashield_policy_engine::PolicyStatus::Expired => return ClaimResult::Expired, - parashield_policy_engine::PolicyStatus::Cancelled => return ClaimResult::PolicyNotActive, - parashield_policy_engine::PolicyStatus::Active => {} + parashield_policy_engine::PolicyStatus::Claimed => return ClaimResult::AlreadyClaimed, + parashield_policy_engine::PolicyStatus::Expired => return ClaimResult::Expired, + parashield_policy_engine::PolicyStatus::Cancelled => { + return ClaimResult::PolicyNotActive + } + parashield_policy_engine::PolicyStatus::Active => {} } // Check if policy has expired with no trigger @@ -345,9 +398,15 @@ impl ClaimsProcessor { } // Create or get the internal claim record - let claim_id = if env.storage().persistent().has(&StorageKey::PolicyClaim(policy_id)) { - env.storage().persistent() - .get(&StorageKey::PolicyClaim(policy_id)).unwrap() + let claim_id = if env + .storage() + .persistent() + .has(&StorageKey::PolicyClaim(policy_id)) + { + env.storage() + .persistent() + .get(&StorageKey::PolicyClaim(policy_id)) + .unwrap() } else { let cid = Self::next_claim_id(&env); let claim = Claim { @@ -362,21 +421,41 @@ impl ClaimsProcessor { processed_at: None, dispute_reason: None, }; - env.storage().persistent().set(&StorageKey::Claim(cid), &claim); - env.storage().persistent().extend_ttl(&StorageKey::Claim(cid), TTL_THRESHOLD, TTL_EXTEND_TO); - env.storage().persistent().set(&StorageKey::PolicyClaim(policy_id), &cid); - env.storage().persistent().extend_ttl(&StorageKey::PolicyClaim(policy_id), TTL_THRESHOLD, TTL_EXTEND_TO); + env.storage() + .persistent() + .set(&StorageKey::Claim(cid), &claim); + env.storage().persistent().extend_ttl( + &StorageKey::Claim(cid), + TTL_THRESHOLD, + TTL_EXTEND_TO, + ); + env.storage() + .persistent() + .set(&StorageKey::PolicyClaim(policy_id), &cid); + env.storage().persistent().extend_ttl( + &StorageKey::PolicyClaim(policy_id), + TTL_THRESHOLD, + TTL_EXTEND_TO, + ); // Make the new claim visible to batch processors and monitoring. - let mut pending: Vec = env.storage().instance() - .get(&StorageKey::PendingClaims).unwrap_or_else(|| Vec::new(&env)); + let mut pending: Vec = env + .storage() + .instance() + .get(&StorageKey::PendingClaims) + .unwrap_or_else(|| Vec::new(&env)); pending.push_back(cid); - env.storage().instance().set(&StorageKey::PendingClaims, &pending); + env.storage() + .instance() + .set(&StorageKey::PendingClaims, &pending); cid }; - let mut claim: Claim = env.storage().persistent() - .get(&StorageKey::Claim(claim_id)).unwrap(); + let mut claim: Claim = env + .storage() + .persistent() + .get(&StorageKey::Claim(claim_id)) + .unwrap(); if claim.status != ClaimStatus::Pending { return ClaimResult::AlreadyProcessed; } @@ -389,29 +468,40 @@ impl ClaimsProcessor { pub fn batch_auto_process(env: Env, caller: Address, limit: u32) -> Vec<(u128, ClaimResult)> { Self::require_keeper(&env, &caller); Self::require_not_paused(&env); - let pending: Vec = env.storage().instance() + let pending: Vec = env + .storage() + .instance() .get(&StorageKey::PendingClaims) .unwrap_or_else(|| Vec::new(&env)); let mut results: Vec<(u128, ClaimResult)> = Vec::new(&env); - let process_count = if pending.len() < limit { pending.len() } else { limit }; + let process_count = if pending.len() < limit { + pending.len() + } else { + limit + }; - let policy_engine: Address = env.storage().instance() + let policy_engine: Address = env + .storage() + .instance() .get(&StorageKey::PolicyEngine) .unwrap_or_else(|| panic_with_error!(&env, Error::NotInitialized)); for i in 0..process_count { let claim_id = pending.get_unchecked(i); - let mut claim: Claim = match env.storage().persistent() - .get(&StorageKey::Claim(claim_id)) { - Some(c) => c, - None => continue, - }; - if claim.status != ClaimStatus::Pending { continue; } - if claim.processed_at.is_some() { continue; } + let mut claim: Claim = + match env.storage().persistent().get(&StorageKey::Claim(claim_id)) { + Some(c) => c, + None => continue, + }; + if claim.status != ClaimStatus::Pending { + continue; + } + if claim.processed_at.is_some() { + continue; + } - let policy = PolicyEngineClient::new(&env, &policy_engine) - .get_policy(&claim.policy_id); + let policy = PolicyEngineClient::new(&env, &policy_engine).get_policy(&claim.policy_id); let result = Self::evaluate_and_settle(&env, &mut claim, &policy); results.push_back((claim_id, result)); } @@ -425,10 +515,14 @@ impl ClaimsProcessor { /// so it is not auto-processed again until an admin resolves the dispute. pub fn dispute_claim(env: Env, claimant: Address, claim_id: u128, reason: soroban_sdk::Symbol) { claimant.require_auth(); - let mut claim: Claim = env.storage().persistent() + let mut claim: Claim = env + .storage() + .persistent() .get(&StorageKey::Claim(claim_id)) .unwrap_or_else(|| panic_with_error!(&env, Error::ClaimNotFound)); - if claim.claimant != claimant { panic_with_error!(&env, Error::Unauthorized); } + if claim.claimant != claimant { + panic_with_error!(&env, Error::Unauthorized); + } // Only open (Pending) or rejected claims are disputable. A Paid claim is // already settled (USDC transferred) and a Disputed claim is already open, // so neither may be overwritten. @@ -437,7 +531,9 @@ impl ClaimsProcessor { } claim.status = ClaimStatus::Disputed; claim.dispute_reason = Some(reason.clone()); - env.storage().persistent().set(&StorageKey::Claim(claim_id), &claim); + env.storage() + .persistent() + .set(&StorageKey::Claim(claim_id), &claim); // A disputed claim is no longer pending — drop it from the queue so it is // not re-evaluated and does not grow the queue unboundedly. @@ -457,83 +553,99 @@ impl ClaimsProcessor { /// Return the `Claim` record for the given `claim_id`. Panics with `ClaimNotFound` if it does not exist. pub fn get_claim(env: Env, claim_id: u128) -> Claim { - env.storage().persistent() + env.storage() + .persistent() .get(&StorageKey::Claim(claim_id)) .unwrap_or_else(|| panic_with_error!(&env, Error::ClaimNotFound)) } /// Return the claim ID associated with `policy_id`, or `None` if no claim has been filed. pub fn get_claim_id_for_policy(env: Env, policy_id: u128) -> Option { - env.storage().persistent() + env.storage() + .persistent() .get(&StorageKey::PolicyClaim(policy_id)) } /// Return the list of claim IDs that are currently in `Pending` status. pub fn get_pending_claims(env: Env) -> Vec { - env.storage().instance() + env.storage() + .instance() .get(&StorageKey::PendingClaims) .unwrap_or_else(|| Vec::new(&env)) } /// Return the current admin address. Panics with `NotInitialized` if the contract has not been set up. pub fn get_admin(env: Env) -> Address { - env.storage().instance().get(&StorageKey::Admin) + env.storage() + .instance() + .get(&StorageKey::Admin) .unwrap_or_else(|| panic_with_error!(&env, Error::NotInitialized)) } /// Return the current storage schema version (defaults to 1 before any migration). pub fn get_version(env: Env) -> u32 { - env.storage().instance().get(&StorageKey::Version).unwrap_or(1) + env.storage() + .instance() + .get(&StorageKey::Version) + .unwrap_or(1) } /// Admin-only: pause all claim submissions and processing. pub fn pause(env: Env, admin: Address) { Self::require_admin(&env, &admin); env.storage().instance().set(&StorageKey::Paused, &true); - env.events().publish( - (Symbol::new(&env, "paused"),), - admin, - ); + env.events().publish((Symbol::new(&env, "paused"),), admin); } /// Admin-only: resume claim submissions and processing. pub fn resume(env: Env, admin: Address) { Self::require_admin(&env, &admin); env.storage().instance().set(&StorageKey::Paused, &false); - env.events().publish( - (Symbol::new(&env, "resumed"),), - admin, - ); + env.events().publish((Symbol::new(&env, "resumed"),), admin); } /// Check whether the contract is currently paused. pub fn is_paused(env: Env) -> bool { - env.storage().instance().get(&StorageKey::Paused).unwrap_or(false) + env.storage() + .instance() + .get(&StorageKey::Paused) + .unwrap_or(false) } /// Upgrade the contract WASM in-place. Only the admin may call this. /// Storage is preserved across upgrades; only the execution code changes. /// Runs storage migrations if the new version requires them. pub fn upgrade(env: Env, admin: Address, new_wasm_hash: BytesN<32>, new_version: u32) { - let stored_admin: Address = env.storage().instance().get(&StorageKey::Admin) + let stored_admin: Address = env + .storage() + .instance() + .get(&StorageKey::Admin) .unwrap_or_else(|| panic_with_error!(&env, Error::NotInitialized)); - if admin != stored_admin { panic_with_error!(&env, Error::Unauthorized); } + if admin != stored_admin { + panic_with_error!(&env, Error::Unauthorized); + } admin.require_auth(); - - let current_version: u32 = env.storage().instance().get(&StorageKey::Version).unwrap_or(1); + + let current_version: u32 = env + .storage() + .instance() + .get(&StorageKey::Version) + .unwrap_or(1); if new_version <= current_version { panic!("new version must be greater than current version"); } - + // Run migrations from current_version to new_version Self::run_migrations(&env, current_version, new_version); - + // Update the stored version - env.storage().instance().set(&StorageKey::Version, &new_version); - + env.storage() + .instance() + .set(&StorageKey::Version, &new_version); + // Perform the actual WASM upgrade env.deployer().update_current_contract_wasm(new_wasm_hash); - + env.events().publish( (Symbol::new(&env, "contract_upgraded"),), ContractUpgraded { @@ -545,58 +657,89 @@ impl ClaimsProcessor { /// Run storage migrations from old_version to new_version. /// Each migration function handles a specific version transition. - fn run_migrations(_env: &Env, _old_version: u32, _new_version: u32) { - // Migration from v1 to v2: No storage changes needed yet - // This is where you would add migration logic for specific version bumps - // Example: if old_version < 2 && new_version >= 2 { Self::migrate_v1_to_v2(env); } - - // Future migrations follow the pattern: - // if old_version < 3 && new_version >= 3 { Self::migrate_v2_to_v3(env); } + fn run_migrations(env: &Env, old_version: u32, new_version: u32) { + if old_version == 0 || new_version <= old_version || new_version > CURRENT_STORAGE_VERSION { + panic!("invalid migration version"); + } + + let mut version = old_version; + while version < new_version { + match version { + 1 => { + Self::migrate_v1_to_v2(env); + version = 2; + } + 2 => { + Self::migrate_v2_to_v3(env); + version = 3; + } + _ => panic!("unsupported migration path"), + } + } + } + + fn migrate_v1_to_v2(env: &Env) { + if !env.storage().instance().has(&StorageKey::Paused) { + env.storage().instance().set(&StorageKey::Paused, &false); + } } + fn migrate_v2_to_v3(_env: &Env) {} + // ── Internal helpers ───────────────────────────────────────────────────── /// Core evaluation: check oracle, update claim record, instruct Policy Engine. /// After successful claim payment, atomically releases the coverage lock on Risk Pool /// to prevent coverage from remaining locked indefinitely. - fn evaluate_and_settle(env: &Env, claim: &mut Claim, policy: ¶shield_policy_engine::Policy) -> ClaimResult { - // Validate claim is in Pending state before transitioning (atomic state guard) + fn evaluate_and_settle( + env: &Env, + claim: &mut Claim, + policy: ¶shield_policy_engine::Policy, + ) -> ClaimResult { + // Validate claim is in Pending state before transitioning (atomic state guard) if claim.status != ClaimStatus::Pending { panic_with_error!(env, Error::AlreadyProcessed); } - let oracle_verifier: Address = env.storage().instance() + let oracle_verifier: Address = env + .storage() + .instance() .get(&StorageKey::OracleVerifier) .unwrap_or_else(|| panic_with_error!(env, Error::NotInitialized)); - let policy_engine: Address = env.storage().instance() + let policy_engine: Address = env + .storage() + .instance() .get(&StorageKey::PolicyEngine) .unwrap_or_else(|| panic_with_error!(env, Error::NotInitialized)); - let risk_pool: Address = env.storage().instance() + let risk_pool: Address = env + .storage() + .instance() .get(&StorageKey::RiskPool) .unwrap_or_else(|| panic_with_error!(env, Error::NotInitialized)); // Configurable staleness threshold (default 7 days = 604_800 s if not set) - let staleness_threshold: u64 = env.storage().instance() + let staleness_threshold: u64 = env + .storage() + .instance() .get(&StorageKey::StalenessThreshold) .unwrap_or(604_800u64); let condition = parashield_oracle_verifier::TriggerCondition { - data_type: policy.oracle_data_type.clone(), - key: policy.oracle_key.clone(), - threshold: policy.trigger_threshold, - comparison: map_comparison(&policy.trigger_comparison), - tolerance: 0, // Standard comparison without tolerance + data_type: policy.oracle_data_type.clone(), + key: policy.oracle_key.clone(), + threshold: policy.trigger_threshold, + comparison: map_comparison(&policy.trigger_comparison), + tolerance: 0, // Standard comparison without tolerance }; // verify_trigger_fresh re-queries the oracle and rejects stale data // in the same atomic call, preventing stale-data and TOCTOU issues. - let trigger_met = OracleVerifierClient::new(env, &oracle_verifier) - .verify_trigger_fresh( - &policy.oracle_data_type, - &policy.oracle_key, - &condition, - &staleness_threshold, - ); + let trigger_met = OracleVerifierClient::new(env, &oracle_verifier).verify_trigger_fresh( + &policy.oracle_data_type, + &policy.oracle_key, + &condition, + &staleness_threshold, + ); - claim.trigger_met = trigger_met; + claim.trigger_met = trigger_met; claim.processed_at = Some(env.ledger().timestamp()); let result = if trigger_met { @@ -613,8 +756,14 @@ impl ClaimsProcessor { ClaimResult::Rejected }; - env.storage().persistent().set(&StorageKey::Claim(claim.id), claim); - env.storage().persistent().extend_ttl(&StorageKey::Claim(claim.id), TTL_THRESHOLD, TTL_EXTEND_TO); + env.storage() + .persistent() + .set(&StorageKey::Claim(claim.id), claim); + env.storage().persistent().extend_ttl( + &StorageKey::Claim(claim.id), + TTL_THRESHOLD, + TTL_EXTEND_TO, + ); // The claim is now settled (Paid/Rejected) — drop it from the pending // queue so it is neither re-evaluated nor allowed to grow the queue @@ -625,7 +774,11 @@ impl ClaimsProcessor { if !trigger_met { env.events().publish( (Symbol::new(env, "claim_rejected"),), - (claim.id, claim.policy_id, Symbol::new(env, "trigger_not_met")), + ( + claim.id, + claim.policy_id, + Symbol::new(env, "trigger_not_met"), + ), ); } @@ -639,7 +792,9 @@ impl ClaimsProcessor { /// Remove a claim id from the pending queue, if present. fn remove_from_pending(env: &Env, claim_id: u128) { - let pending: Vec = env.storage().instance() + let pending: Vec = env + .storage() + .instance() .get(&StorageKey::PendingClaims) .unwrap_or_else(|| Vec::new(env)); let mut updated: Vec = Vec::new(env); @@ -649,14 +804,17 @@ impl ClaimsProcessor { } } if updated.len() != pending.len() { - env.storage().instance().set(&StorageKey::PendingClaims, &updated); + env.storage() + .instance() + .set(&StorageKey::PendingClaims, &updated); } } - /// Panic unless `caller` is a registered keeper and authorizes the call. fn require_keeper(env: &Env, caller: &Address) { - let authorized: bool = env.storage().persistent() + let authorized: bool = env + .storage() + .persistent() .get(&StorageKey::Keeper(caller.clone())) .unwrap_or(false); if !authorized { @@ -667,7 +825,9 @@ impl ClaimsProcessor { /// Panic unless `caller` is the admin and authorizes the call. fn require_admin(env: &Env, caller: &Address) { - let admin: Address = env.storage().instance() + let admin: Address = env + .storage() + .instance() .get(&StorageKey::Admin) .unwrap_or_else(|| panic_with_error!(env, Error::NotInitialized)); if *caller != admin { @@ -678,7 +838,9 @@ impl ClaimsProcessor { /// Panic if the contract is currently paused. fn require_not_paused(env: &Env) { - let paused: bool = env.storage().instance() + let paused: bool = env + .storage() + .instance() .get(&StorageKey::Paused) .unwrap_or(false); if paused { @@ -687,23 +849,28 @@ impl ClaimsProcessor { } fn next_claim_id(env: &Env) -> u128 { - let id: u128 = env.storage().instance() - .get(&StorageKey::NextClaimId).unwrap_or(1); - env.storage().instance().set(&StorageKey::NextClaimId, &(id + 1)); + let id: u128 = env + .storage() + .instance() + .get(&StorageKey::NextClaimId) + .unwrap_or(1); + env.storage() + .instance() + .set(&StorageKey::NextClaimId, &(id + 1)); id } fn validate_stellar_address(env: &Env, address: &Address) { let addr_str = address.to_string(); - + // Check length: Stellar public keys are exactly 56 characters if addr_str.len() != 56 { panic_with_error!(env, Error::InvalidAddress); } - + let mut buf = [0u8; 56]; addr_str.copy_into_slice(&mut buf); - + // Check prefix: G (Stellar account) or C (Stellar contract) if buf[0] != b'G' && buf[0] != b'C' { panic_with_error!(env, Error::InvalidAddress); @@ -716,18 +883,21 @@ fn map_comparison( c: ¶shield_policy_engine::TriggerComparison, ) -> parashield_oracle_verifier::TriggerComparison { match c { - parashield_policy_engine::TriggerComparison::LessThan => - parashield_oracle_verifier::TriggerComparison::LessThan, - parashield_policy_engine::TriggerComparison::GreaterThan => - parashield_oracle_verifier::TriggerComparison::GreaterThan, - parashield_policy_engine::TriggerComparison::Equal => - parashield_oracle_verifier::TriggerComparison::Equal, + parashield_policy_engine::TriggerComparison::LessThan => { + parashield_oracle_verifier::TriggerComparison::LessThan + } + parashield_policy_engine::TriggerComparison::GreaterThan => { + parashield_oracle_verifier::TriggerComparison::GreaterThan + } + parashield_policy_engine::TriggerComparison::Equal => { + parashield_oracle_verifier::TriggerComparison::Equal + } } } #[cfg(test)] mod test; #[cfg(test)] -mod test_integration; -#[cfg(test)] mod test_advanced; +#[cfg(test)] +mod test_integration; diff --git a/contracts/claims-processor/src/test.rs b/contracts/claims-processor/src/test.rs index be5b0b1..e3c0733 100644 --- a/contracts/claims-processor/src/test.rs +++ b/contracts/claims-processor/src/test.rs @@ -1,29 +1,28 @@ use super::*; +use parashield_oracle_verifier::{OracleVerifier, OracleVerifierClient}; +use parashield_policy_engine::{ + CreateProductParams, PolicyEngine, PolicyEngineClient, TriggerComparison, TriggerType, +}; +use parashield_risk_pool::{RiskPool, RiskPoolClient}; use soroban_sdk::{ symbol_short, testutils::{Address as _, Ledger}, token::StellarAssetClient, Env, }; -use parashield_oracle_verifier::{OracleVerifier, OracleVerifierClient}; -use parashield_policy_engine::{ - PolicyEngine, PolicyEngineClient, - TriggerType, TriggerComparison, CreateProductParams, -}; -use parashield_risk_pool::{RiskPool, RiskPoolClient}; const COVERAGE: i128 = 1_000_000_000; // 100 USDC struct World { - env: Env, - admin: Address, - keeper: Address, - oracle_w: Address, - usdc: Address, + env: Env, + admin: Address, + keeper: Address, + oracle_w: Address, + usdc: Address, oracle_id: Address, policy_id: Address, claims_id: Address, - pool_id: Address, + pool_id: Address, } fn deploy() -> World { @@ -31,26 +30,34 @@ fn deploy() -> World { env.mock_all_auths(); env.cost_estimate().budget().reset_unlimited(); - let admin = Address::generate(&env); + let admin = Address::generate(&env); let keeper = Address::generate(&env); let oracle_wallet = Address::generate(&env); - let usdc = env.register_stellar_asset_contract_v2(admin.clone()).address(); + let usdc = env + .register_stellar_asset_contract_v2(admin.clone()) + .address(); // 1. Deploy oracle verifier let oracle_id = env.register(OracleVerifier, ()); OracleVerifierClient::new(&env, &oracle_id).initialize(&admin); - OracleVerifierClient::new(&env, &oracle_id) - .add_oracle(&admin, &oracle_wallet, &symbol_short!("weather"), &90u32); + OracleVerifierClient::new(&env, &oracle_id).add_oracle( + &admin, + &oracle_wallet, + &symbol_short!("weather"), + &90u32, + ); // 2. Deploy risk pool (category: crop) - let backstop = env.register_stellar_asset_contract_v2(Address::generate(&env)).address(); + let backstop = env + .register_stellar_asset_contract_v2(Address::generate(&env)) + .address(); let treasury = Address::generate(&env); let pool_id = env.register(RiskPool, ()); // 3. Deploy policy engine (placeholder for risk pool init) let policy_id = env.register(PolicyEngine, ()); - + // 4. Deploy claims processor (placeholder for risk pool init) let claims_id = env.register(ClaimsProcessor, ()); @@ -66,38 +73,50 @@ fn deploy() -> World { ); // Initialize other contracts - PolicyEngineClient::new(&env, &policy_id) - .initialize(&admin, &usdc, &oracle_id); - - ClaimsProcessorClient::new(&env, &claims_id) - .initialize(&admin, &policy_id, &pool_id, &oracle_id, &604_800u64); - + PolicyEngineClient::new(&env, &policy_id).initialize(&admin, &usdc, &oracle_id); + + ClaimsProcessorClient::new(&env, &claims_id).initialize( + &admin, + &policy_id, + &pool_id, + &oracle_id, + &604_800u64, + ); + // Authorize keeper on the claims processor - ClaimsProcessorClient::new(&env, &claims_id) - .add_keeper(&admin, &keeper); + ClaimsProcessorClient::new(&env, &claims_id).add_keeper(&admin, &keeper); // Wire claims processor as authorized caller on policy engine - PolicyEngineClient::new(&env, &policy_id) - .set_claims_processor(&admin, &claims_id); - - World { env, admin, keeper, oracle_w: oracle_wallet, usdc, oracle_id, policy_id, claims_id, pool_id } + PolicyEngineClient::new(&env, &policy_id).set_claims_processor(&admin, &claims_id); + + World { + env, + admin, + keeper, + oracle_w: oracle_wallet, + usdc, + oracle_id, + policy_id, + claims_id, + pool_id, + } } fn create_crop_product(w: &World) -> u128 { PolicyEngineClient::new(&w.env, &w.policy_id).create_product( &w.admin, &CreateProductParams { - name: symbol_short!("crop_kism"), - category: symbol_short!("crop"), - oracle_key: symbol_short!("kis2606"), - trigger_type: TriggerType::Threshold, - oracle_data_type: symbol_short!("weather"), - trigger_threshold: 50_000_000, + name: symbol_short!("crop_kism"), + category: symbol_short!("crop"), + oracle_key: symbol_short!("kis2606"), + trigger_type: TriggerType::Threshold, + oracle_data_type: symbol_short!("weather"), + trigger_threshold: 50_000_000, trigger_comparison: TriggerComparison::LessThan, - coverage_min: 100_000_000, - coverage_max: 10_000_000_000, - premium_rate_bps: 500, - max_duration_days: 365, + coverage_min: 100_000_000, + coverage_max: 10_000_000_000, + premium_rate_bps: 500, + max_duration_days: 365, }, ) } @@ -108,13 +127,18 @@ fn buy_crop_policy(w: &World, buyer: &Address, product_id: u128) -> u128 { StellarAssetClient::new(&w.env, &w.usdc).mint(&w.pool_id, &10_000_000_000i128); // Deposit to pool and lock coverage for the policy RiskPoolClient::new(&w.env, &w.pool_id).deposit(&buyer, &1_000_000_000i128, &0i128); - - let policy_id = PolicyEngineClient::new(&w.env, &w.policy_id) - .buy_policy(buyer, &product_id, &COVERAGE, &30u32, &symbol_short!("kis2606")); - + + let policy_id = PolicyEngineClient::new(&w.env, &w.policy_id).buy_policy( + buyer, + &product_id, + &COVERAGE, + &30u32, + &symbol_short!("kis2606"), + ); + // Lock coverage in the pool for this policy RiskPoolClient::new(&w.env, &w.pool_id).lock_for_policy(&w.admin, &policy_id, &COVERAGE); - + policy_id } @@ -134,16 +158,15 @@ fn submit_rainfall(w: &World, mm_7dec: i128) { /// Buy policy → oracle submits below threshold → auto_process pays out. #[test] fn test_drought_trigger_pays_out() { - let w = deploy(); - let pid = create_crop_product(&w); - let buyer = Address::generate(&w.env); + let w = deploy(); + let pid = create_crop_product(&w); + let buyer = Address::generate(&w.env); let pol_id = buy_crop_policy(&w, &buyer, pid); // 32mm < 50mm threshold → trigger MET submit_rainfall(&w, 32_000_000); - let result = ClaimsProcessorClient::new(&w.env, &w.claims_id) - .auto_process(&w.keeper, &pol_id); + let result = ClaimsProcessorClient::new(&w.env, &w.claims_id).auto_process(&w.keeper, &pol_id); assert_eq!(result, ClaimResult::Paid); // Buyer: minted 5_000_000_000, paid 50_000_000 premium, received 1_000_000_000 coverage @@ -154,57 +177,62 @@ fn test_drought_trigger_pays_out() { /// Buy policy → oracle submits above threshold → auto_process rejects. #[test] fn test_good_rainfall_no_payout() { - let w = deploy(); - let pid = create_crop_product(&w); - let buyer = Address::generate(&w.env); + let w = deploy(); + let pid = create_crop_product(&w); + let buyer = Address::generate(&w.env); let pol_id = buy_crop_policy(&w, &buyer, pid); // 72mm > 50mm → trigger NOT met submit_rainfall(&w, 72_000_000); - let result = ClaimsProcessorClient::new(&w.env, &w.claims_id) - .auto_process(&w.keeper, &pol_id); + let result = ClaimsProcessorClient::new(&w.env, &w.claims_id).auto_process(&w.keeper, &pol_id); assert_eq!(result, ClaimResult::Rejected); // Buyer: minted 5_000_000_000, paid 50_000_000 premium, no payout received let buyer_bal = soroban_sdk::token::Client::new(&w.env, &w.usdc).balance(&buyer); - assert_eq!(buyer_bal, 5_000_000_000 - 4_109_589, "no payout when trigger not met"); + assert_eq!( + buyer_bal, + 5_000_000_000 - 4_109_589, + "no payout when trigger not met" + ); } /// Policy past end_time with no trigger → auto_process marks Expired. #[test] fn test_expired_policy_no_payout() { - let w = deploy(); - let pid = create_crop_product(&w); - let buyer = Address::generate(&w.env); + let w = deploy(); + let pid = create_crop_product(&w); + let buyer = Address::generate(&w.env); let pol_id = buy_crop_policy(&w, &buyer, pid); // Advance time past 30-day policy duration (2,592,000 seconds) w.env.ledger().with_mut(|l| l.timestamp += 31 * 86_400); - let result = ClaimsProcessorClient::new(&w.env, &w.claims_id) - .auto_process(&w.keeper, &pol_id); + let result = ClaimsProcessorClient::new(&w.env, &w.claims_id).auto_process(&w.keeper, &pol_id); assert_eq!(result, ClaimResult::Expired); let policy = PolicyEngineClient::new(&w.env, &w.policy_id).get_policy(&pol_id); - assert_eq!(policy.status, parashield_policy_engine::PolicyStatus::Expired); + assert_eq!( + policy.status, + parashield_policy_engine::PolicyStatus::Expired + ); } /// auto_process on already-paid policy returns AlreadyProcessed (idempotent). #[test] fn test_double_process_idempotent() { - let w = deploy(); - let pid = create_crop_product(&w); - let buyer = Address::generate(&w.env); + let w = deploy(); + let pid = create_crop_product(&w); + let buyer = Address::generate(&w.env); let pol_id = buy_crop_policy(&w, &buyer, pid); submit_rainfall(&w, 20_000_000); // 20mm — well below threshold let cp = ClaimsProcessorClient::new(&w.env, &w.claims_id); - let first = cp.auto_process(&w.keeper, &pol_id); + let first = cp.auto_process(&w.keeper, &pol_id); let second = cp.auto_process(&w.keeper, &pol_id); - assert_eq!(first, ClaimResult::Paid); + assert_eq!(first, ClaimResult::Paid); assert_eq!(second, ClaimResult::AlreadyProcessed); // Buyer should NOT receive double coverage — exactly one payout @@ -214,9 +242,9 @@ fn test_double_process_idempotent() { #[test] fn test_process_claim_is_idempotent_after_first_settlement() { - let w = deploy(); - let pid = create_crop_product(&w); - let buyer = Address::generate(&w.env); + let w = deploy(); + let pid = create_crop_product(&w); + let buyer = Address::generate(&w.env); let pol_id = buy_crop_policy(&w, &buyer, pid); submit_rainfall(&w, 20_000_000); @@ -233,9 +261,9 @@ fn test_process_claim_is_idempotent_after_first_settlement() { #[test] #[should_panic(expected = "Error(Contract, #6)")] fn test_double_submit_claim_panics() { - let w = deploy(); - let pid = create_crop_product(&w); - let buyer = Address::generate(&w.env); + let w = deploy(); + let pid = create_crop_product(&w); + let buyer = Address::generate(&w.env); let pol_id = buy_crop_policy(&w, &buyer, pid); submit_rainfall(&w, 20_000_000); @@ -249,29 +277,28 @@ fn test_double_submit_claim_panics() { #[test] #[should_panic(expected = "Error(Contract, #3)")] fn test_non_policyholder_cannot_submit_claim() { - let w = deploy(); - let pid = create_crop_product(&w); - let buyer = Address::generate(&w.env); + let w = deploy(); + let pid = create_crop_product(&w); + let buyer = Address::generate(&w.env); let stranger = Address::generate(&w.env); - let pol_id = buy_crop_policy(&w, &buyer, pid); + let pol_id = buy_crop_policy(&w, &buyer, pid); submit_rainfall(&w, 20_000_000); - ClaimsProcessorClient::new(&w.env, &w.claims_id) - .submit_claim(&stranger, &pol_id); + ClaimsProcessorClient::new(&w.env, &w.claims_id).submit_claim(&stranger, &pol_id); } /// Manual submit_claim + process_claim flow works end-to-end. #[test] fn test_manual_claim_flow() { - let w = deploy(); - let pid = create_crop_product(&w); - let buyer = Address::generate(&w.env); + let w = deploy(); + let pid = create_crop_product(&w); + let buyer = Address::generate(&w.env); let pol_id = buy_crop_policy(&w, &buyer, pid); submit_rainfall(&w, 30_000_000); // below threshold - let cp = ClaimsProcessorClient::new(&w.env, &w.claims_id); + let cp = ClaimsProcessorClient::new(&w.env, &w.claims_id); let claim_id = cp.submit_claim(&buyer, &pol_id); - let result = cp.process_claim(&w.keeper, &claim_id); + let result = cp.process_claim(&w.keeper, &claim_id); assert_eq!(result, ClaimResult::Paid); let claim = cp.get_claim(&claim_id); @@ -285,27 +312,26 @@ fn test_manual_claim_flow() { #[test] #[should_panic(expected = "Error(Contract, #3)")] fn test_auto_process_rejects_unregistered_keeper() { - let w = deploy(); - let pid = create_crop_product(&w); - let buyer = Address::generate(&w.env); + let w = deploy(); + let pid = create_crop_product(&w); + let buyer = Address::generate(&w.env); let stranger = Address::generate(&w.env); - let pol_id = buy_crop_policy(&w, &buyer, pid); + let pol_id = buy_crop_policy(&w, &buyer, pid); submit_rainfall(&w, 20_000_000); // `stranger` is not in the keeper registry → Unauthorized - ClaimsProcessorClient::new(&w.env, &w.claims_id) - .auto_process(&stranger, &pol_id); + ClaimsProcessorClient::new(&w.env, &w.claims_id).auto_process(&stranger, &pol_id); } /// process_claim from an unregistered address is rejected. #[test] #[should_panic(expected = "Error(Contract, #3)")] fn test_process_claim_rejects_unregistered_keeper() { - let w = deploy(); - let pid = create_crop_product(&w); - let buyer = Address::generate(&w.env); + let w = deploy(); + let pid = create_crop_product(&w); + let buyer = Address::generate(&w.env); let stranger = Address::generate(&w.env); - let pol_id = buy_crop_policy(&w, &buyer, pid); + let pol_id = buy_crop_policy(&w, &buyer, pid); submit_rainfall(&w, 20_000_000); let cp = ClaimsProcessorClient::new(&w.env, &w.claims_id); @@ -317,9 +343,9 @@ fn test_process_claim_rejects_unregistered_keeper() { #[test] #[should_panic(expected = "Error(Contract, #3)")] fn test_removed_keeper_cannot_process() { - let w = deploy(); - let pid = create_crop_product(&w); - let buyer = Address::generate(&w.env); + let w = deploy(); + let pid = create_crop_product(&w); + let buyer = Address::generate(&w.env); let pol_id = buy_crop_policy(&w, &buyer, pid); submit_rainfall(&w, 20_000_000); @@ -332,11 +358,10 @@ fn test_removed_keeper_cannot_process() { #[test] #[should_panic(expected = "Error(Contract, #3)")] fn test_non_admin_cannot_add_keeper() { - let w = deploy(); + let w = deploy(); let stranger = Address::generate(&w.env); let new_keep = Address::generate(&w.env); - ClaimsProcessorClient::new(&w.env, &w.claims_id) - .add_keeper(&stranger, &new_keep); + ClaimsProcessorClient::new(&w.env, &w.claims_id).add_keeper(&stranger, &new_keep); } // ── Pending queue lifecycle (Issues #76, #74) ──────────────────────────────── @@ -345,9 +370,9 @@ fn test_non_admin_cannot_add_keeper() { /// from the pending queue — the queue never accumulates settled claims. #[test] fn test_pending_queue_cleared_after_auto_process() { - let w = deploy(); - let pid = create_crop_product(&w); - let buyer = Address::generate(&w.env); + let w = deploy(); + let pid = create_crop_product(&w); + let buyer = Address::generate(&w.env); let pol_id = buy_crop_policy(&w, &buyer, pid); submit_rainfall(&w, 20_000_000); @@ -358,15 +383,19 @@ fn test_pending_queue_cleared_after_auto_process() { assert_eq!(result, ClaimResult::Paid); // Settled claim must not linger in the pending queue. - assert_eq!(cp.get_pending_claims().len(), 0, "settled claim left in queue"); + assert_eq!( + cp.get_pending_claims().len(), + 0, + "settled claim left in queue" + ); } /// submit_claim enqueues; process_claim settlement dequeues. #[test] fn test_pending_queue_cleared_after_process_claim() { - let w = deploy(); - let pid = create_crop_product(&w); - let buyer = Address::generate(&w.env); + let w = deploy(); + let pid = create_crop_product(&w); + let buyer = Address::generate(&w.env); let pol_id = buy_crop_policy(&w, &buyer, pid); submit_rainfall(&w, 30_000_000); @@ -375,15 +404,19 @@ fn test_pending_queue_cleared_after_process_claim() { assert_eq!(cp.get_pending_claims().len(), 1); cp.process_claim(&w.keeper, &claim_id); - assert_eq!(cp.get_pending_claims().len(), 0, "settled claim left in queue"); + assert_eq!( + cp.get_pending_claims().len(), + 0, + "settled claim left in queue" + ); } /// Disputing a still-pending claim removes it from the pending queue. #[test] fn test_dispute_pending_claim_dequeues() { - let w = deploy(); - let pid = create_crop_product(&w); - let buyer = Address::generate(&w.env); + let w = deploy(); + let pid = create_crop_product(&w); + let buyer = Address::generate(&w.env); let pol_id = buy_crop_policy(&w, &buyer, pid); submit_rainfall(&w, 30_000_000); @@ -393,7 +426,11 @@ fn test_dispute_pending_claim_dequeues() { cp.dispute_claim(&buyer, &claim_id, &symbol_short!("disagree")); assert_eq!(cp.get_claim(&claim_id).status, ClaimStatus::Disputed); - assert_eq!(cp.get_pending_claims().len(), 0, "disputed claim left in queue"); + assert_eq!( + cp.get_pending_claims().len(), + 0, + "disputed claim left in queue" + ); } // ── Dispute status guard (Issue #78) ───────────────────────────────────────── @@ -402,9 +439,9 @@ fn test_dispute_pending_claim_dequeues() { #[test] #[should_panic(expected = "Error(Contract, #7)")] fn test_cannot_dispute_paid_claim() { - let w = deploy(); - let pid = create_crop_product(&w); - let buyer = Address::generate(&w.env); + let w = deploy(); + let pid = create_crop_product(&w); + let buyer = Address::generate(&w.env); let pol_id = buy_crop_policy(&w, &buyer, pid); submit_rainfall(&w, 20_000_000); @@ -420,9 +457,9 @@ fn test_cannot_dispute_paid_claim() { /// A rejected claim may be disputed; re-disputing it then fails. #[test] fn test_rejected_claim_disputable_then_locked() { - let w = deploy(); - let pid = create_crop_product(&w); - let buyer = Address::generate(&w.env); + let w = deploy(); + let pid = create_crop_product(&w); + let buyer = Address::generate(&w.env); let pol_id = buy_crop_policy(&w, &buyer, pid); submit_rainfall(&w, 72_000_000); // above threshold → rejected @@ -440,9 +477,9 @@ fn test_rejected_claim_disputable_then_locked() { #[test] #[should_panic(expected = "Error(Contract, #7)")] fn test_cannot_redispute_disputed_claim() { - let w = deploy(); - let pid = create_crop_product(&w); - let buyer = Address::generate(&w.env); + let w = deploy(); + let pid = create_crop_product(&w); + let buyer = Address::generate(&w.env); let pol_id = buy_crop_policy(&w, &buyer, pid); submit_rainfall(&w, 72_000_000); @@ -461,16 +498,21 @@ fn test_cannot_redispute_disputed_claim() { fn test_initialize_with_valid_addresses_succeeds() { let env = Env::default(); env.mock_all_auths(); - - let admin = Address::generate(&env); - let policy_engine = Address::generate(&env); - let risk_pool = Address::generate(&env); + + let admin = Address::generate(&env); + let policy_engine = Address::generate(&env); + let risk_pool = Address::generate(&env); let oracle_verifier = Address::generate(&env); - + let claims_id = env.register(ClaimsProcessor, ()); - ClaimsProcessorClient::new(&env, &claims_id) - .initialize(&admin, &policy_engine, &risk_pool, &oracle_verifier, &604_800u64); - + ClaimsProcessorClient::new(&env, &claims_id).initialize( + &admin, + &policy_engine, + &risk_pool, + &oracle_verifier, + &604_800u64, + ); + // Should succeed without panic let stored_admin = ClaimsProcessorClient::new(&env, &claims_id).get_admin(); assert_eq!(stored_admin, admin); @@ -486,15 +528,18 @@ fn test_address_validation_function_exists() { // Actual invalid address testing is limited by Soroban's type-safe Address type let env = Env::default(); let valid_addr = Address::generate(&env); - + // The validation should succeed for valid addresses // We can't test invalid addresses because Address::from_string() would fail first let addr_str = valid_addr.to_string(); assert_eq!(addr_str.len(), 56, "Stellar addresses are 56 characters"); - + let mut buf = [0u8; 56]; addr_str.copy_into_slice(&mut buf); - assert!(buf[0] == b'G' || buf[0] == b'C', "Stellar addresses start with 'G' or 'C'"); + assert!( + buf[0] == b'G' || buf[0] == b'C', + "Stellar addresses start with 'G' or 'C'" + ); } // ── Keeper authorization ─────────────────────────────────────────────────────── @@ -503,25 +548,24 @@ fn test_address_validation_function_exists() { #[test] #[should_panic(expected = "Error(Contract, #3)")] fn test_non_keeper_cannot_auto_process() { - let w = deploy(); - let pid = create_crop_product(&w); - let buyer = Address::generate(&w.env); + let w = deploy(); + let pid = create_crop_product(&w); + let buyer = Address::generate(&w.env); let pol_id = buy_crop_policy(&w, &buyer, pid); submit_rainfall(&w, 20_000_000); // A stranger that is not an authorized keeper tries to auto_process let stranger = Address::generate(&w.env); - ClaimsProcessorClient::new(&w.env, &w.claims_id) - .auto_process(&stranger, &pol_id); + ClaimsProcessorClient::new(&w.env, &w.claims_id).auto_process(&stranger, &pol_id); } /// Non-keeper cannot call process_claim. #[test] #[should_panic(expected = "Error(Contract, #3)")] fn test_non_keeper_cannot_process_claim() { - let w = deploy(); - let pid = create_crop_product(&w); - let buyer = Address::generate(&w.env); + let w = deploy(); + let pid = create_crop_product(&w); + let buyer = Address::generate(&w.env); let pol_id = buy_crop_policy(&w, &buyer, pid); submit_rainfall(&w, 20_000_000); @@ -540,9 +584,9 @@ fn test_non_keeper_cannot_process_claim() { /// process_claim level (PolicyClaim check at line 292 of lib.rs). #[test] fn test_process_claim_double_processing_returns_already_processed() { - let w = deploy(); - let pid = create_crop_product(&w); - let buyer = Address::generate(&w.env); + let w = deploy(); + let pid = create_crop_product(&w); + let buyer = Address::generate(&w.env); let pol_id = buy_crop_policy(&w, &buyer, pid); // Submit low rainfall so the trigger is met diff --git a/contracts/claims-processor/src/test_advanced.rs b/contracts/claims-processor/src/test_advanced.rs index 3de8423..7afcf0d 100644 --- a/contracts/claims-processor/src/test_advanced.rs +++ b/contracts/claims-processor/src/test_advanced.rs @@ -5,31 +5,30 @@ extern crate std; use super::*; +use parashield_oracle_verifier::{OracleVerifier, OracleVerifierClient}; +use parashield_policy_engine::{ + CreateProductParams, PolicyEngine, PolicyEngineClient, TriggerComparison, TriggerType, +}; +use parashield_risk_pool::{RiskPool, RiskPoolClient}; use soroban_sdk::{ symbol_short, testutils::{Address as _, Ledger}, token::StellarAssetClient, Env, }; -use parashield_oracle_verifier::{OracleVerifier, OracleVerifierClient}; -use parashield_policy_engine::{ - PolicyEngine, PolicyEngineClient, - TriggerType, TriggerComparison, CreateProductParams, -}; -use parashield_risk_pool::{RiskPool, RiskPoolClient}; const COVERAGE: i128 = 1_000_000_000; // 100 USDC struct World { - env: Env, - admin: Address, - keeper: Address, - oracle_w: Address, - usdc: Address, + env: Env, + admin: Address, + keeper: Address, + oracle_w: Address, + usdc: Address, oracle_id: Address, policy_id: Address, claims_id: Address, - pool_id: Address, + pool_id: Address, } fn deploy() -> World { @@ -37,26 +36,34 @@ fn deploy() -> World { env.mock_all_auths(); env.cost_estimate().budget().reset_unlimited(); - let admin = Address::generate(&env); + let admin = Address::generate(&env); let keeper = Address::generate(&env); let oracle_wallet = Address::generate(&env); - let usdc = env.register_stellar_asset_contract_v2(admin.clone()).address(); + let usdc = env + .register_stellar_asset_contract_v2(admin.clone()) + .address(); // 1. Deploy oracle verifier let oracle_id = env.register(OracleVerifier, ()); OracleVerifierClient::new(&env, &oracle_id).initialize(&admin); - OracleVerifierClient::new(&env, &oracle_id) - .add_oracle(&admin, &oracle_wallet, &symbol_short!("weather"), &90u32); + OracleVerifierClient::new(&env, &oracle_id).add_oracle( + &admin, + &oracle_wallet, + &symbol_short!("weather"), + &90u32, + ); // 2. Deploy risk pool (category: crop) - let backstop = env.register_stellar_asset_contract_v2(Address::generate(&env)).address(); + let backstop = env + .register_stellar_asset_contract_v2(Address::generate(&env)) + .address(); let treasury = Address::generate(&env); let pool_id = env.register(RiskPool, ()); // 3. Deploy policy engine (placeholder for risk pool init) let policy_id = env.register(PolicyEngine, ()); - + // 4. Deploy claims processor (placeholder for risk pool init) let claims_id = env.register(ClaimsProcessor, ()); @@ -72,38 +79,50 @@ fn deploy() -> World { ); // Initialize other contracts - PolicyEngineClient::new(&env, &policy_id) - .initialize(&admin, &usdc, &oracle_id); - - ClaimsProcessorClient::new(&env, &claims_id) - .initialize(&admin, &policy_id, &pool_id, &oracle_id, &604_800u64); - + PolicyEngineClient::new(&env, &policy_id).initialize(&admin, &usdc, &oracle_id); + + ClaimsProcessorClient::new(&env, &claims_id).initialize( + &admin, + &policy_id, + &pool_id, + &oracle_id, + &604_800u64, + ); + // Authorize keeper on the claims processor - ClaimsProcessorClient::new(&env, &claims_id) - .add_keeper(&admin, &keeper); + ClaimsProcessorClient::new(&env, &claims_id).add_keeper(&admin, &keeper); // Wire claims processor as authorized caller on policy engine - PolicyEngineClient::new(&env, &policy_id) - .set_claims_processor(&admin, &claims_id); + PolicyEngineClient::new(&env, &policy_id).set_claims_processor(&admin, &claims_id); - World { env, admin, keeper, oracle_w: oracle_wallet, usdc, oracle_id, policy_id, claims_id, pool_id } + World { + env, + admin, + keeper, + oracle_w: oracle_wallet, + usdc, + oracle_id, + policy_id, + claims_id, + pool_id, + } } fn create_crop_product(w: &World) -> u128 { PolicyEngineClient::new(&w.env, &w.policy_id).create_product( &w.admin, &CreateProductParams { - name: symbol_short!("crop_kism"), - category: symbol_short!("crop"), - oracle_key: symbol_short!("kis2606"), - trigger_type: TriggerType::Threshold, - oracle_data_type: symbol_short!("weather"), - trigger_threshold: 50_000_000, + name: symbol_short!("crop_kism"), + category: symbol_short!("crop"), + oracle_key: symbol_short!("kis2606"), + trigger_type: TriggerType::Threshold, + oracle_data_type: symbol_short!("weather"), + trigger_threshold: 50_000_000, trigger_comparison: TriggerComparison::LessThan, - coverage_min: 100_000_000, - coverage_max: 10_000_000_000, - premium_rate_bps: 500, - max_duration_days: 365, + coverage_min: 100_000_000, + coverage_max: 10_000_000_000, + premium_rate_bps: 500, + max_duration_days: 365, }, ) } @@ -114,13 +133,18 @@ fn buy_crop_policy(w: &World, buyer: &Address, product_id: u128) -> u128 { StellarAssetClient::new(&w.env, &w.usdc).mint(&w.pool_id, &10_000_000_000i128); // Deposit to pool and lock coverage for the policy RiskPoolClient::new(&w.env, &w.pool_id).deposit(&buyer, &1_000_000_000i128, &0i128); - - let policy_id = PolicyEngineClient::new(&w.env, &w.policy_id) - .buy_policy(buyer, &product_id, &COVERAGE, &30u32, &symbol_short!("kis2606")); - + + let policy_id = PolicyEngineClient::new(&w.env, &w.policy_id).buy_policy( + buyer, + &product_id, + &COVERAGE, + &30u32, + &symbol_short!("kis2606"), + ); + // Lock coverage in the pool for this policy RiskPoolClient::new(&w.env, &w.pool_id).lock_for_policy(&w.admin, &policy_id, &COVERAGE); - + policy_id } @@ -142,41 +166,47 @@ fn submit_rainfall(w: &World, mm_7dec: i128) { fn test_batch_auto_process_boundary_conditions() { let w = deploy(); let pid = create_crop_product(&w); - + // Create multiple policies let buyer1 = Address::generate(&w.env); let buyer2 = Address::generate(&w.env); let buyer3 = Address::generate(&w.env); - + let pol_id1 = buy_crop_policy(&w, &buyer1, pid); let pol_id2 = buy_crop_policy(&w, &buyer2, pid); let pol_id3 = buy_crop_policy(&w, &buyer3, pid); - + // Submit rainfall data that triggers payout (below threshold) submit_rainfall(&w, 20_000_000); - + let cp = ClaimsProcessorClient::new(&w.env, &w.claims_id); - + // Process all 3 claims in batch let results = cp.batch_auto_process(&w.keeper, &3u32); assert_eq!(results.len(), 3); - + // All should be paid for (_, result) in results.iter() { assert_eq!(*result, ClaimResult::Paid); } - + // Verify all policies are now Claimed assert_eq!( - PolicyEngineClient::new(&w.env, &w.policy_id).get_policy(&pol_id1).status, + PolicyEngineClient::new(&w.env, &w.policy_id) + .get_policy(&pol_id1) + .status, parashield_policy_engine::PolicyStatus::Claimed ); assert_eq!( - PolicyEngineClient::new(&w.env, &w.policy_id).get_policy(&pol_id2).status, + PolicyEngineClient::new(&w.env, &w.policy_id) + .get_policy(&pol_id2) + .status, parashield_policy_engine::PolicyStatus::Claimed ); assert_eq!( - PolicyEngineClient::new(&w.env, &w.policy_id).get_policy(&pol_id3).status, + PolicyEngineClient::new(&w.env, &w.policy_id) + .get_policy(&pol_id3) + .status, parashield_policy_engine::PolicyStatus::Claimed ); } @@ -186,7 +216,7 @@ fn test_batch_auto_process_boundary_conditions() { fn test_batch_auto_process_with_limit() { let w = deploy(); let pid = create_crop_product(&w); - + // Create 5 policies let mut policy_ids = Vec::new(&w.env); for i in 0..5 { @@ -194,18 +224,18 @@ fn test_batch_auto_process_with_limit() { let pol_id = buy_crop_policy(&w, &buyer, pid); policy_ids.push_back(pol_id); } - + submit_rainfall(&w, 20_000_000); - + let cp = ClaimsProcessorClient::new(&w.env, &w.claims_id); - + // Process only 2 claims at a time let results1 = cp.batch_auto_process(&w.keeper, &2u32); assert_eq!(results1.len(), 2); - + let results2 = cp.batch_auto_process(&w.keeper, &2u32); assert_eq!(results2.len(), 2); - + let results3 = cp.batch_auto_process(&w.keeper, &2u32); assert_eq!(results3.len(), 1); // Only 1 remaining } @@ -217,39 +247,53 @@ fn test_staleness_threshold_boundary() { env.mock_all_auths(); env.cost_estimate().budget().reset_unlimited(); - let admin = Address::generate(&env); + let admin = Address::generate(&env); let keeper = Address::generate(&env); let oracle_wallet = Address::generate(&env); - let usdc = env.register_stellar_asset_contract_v2(admin.clone()).address(); + let usdc = env + .register_stellar_asset_contract_v2(admin.clone()) + .address(); let oracle_id = env.register(OracleVerifier, ()); OracleVerifierClient::new(&env, &oracle_id).initialize(&admin); - OracleVerifierClient::new(&env, &oracle_id) - .add_oracle(&admin, &oracle_wallet, &symbol_short!("weather"), &90u32); + OracleVerifierClient::new(&env, &oracle_id).add_oracle( + &admin, + &oracle_wallet, + &symbol_short!("weather"), + &90u32, + ); - let backstop = env.register_stellar_asset_contract_v2(Address::generate(&env)).address(); + let backstop = env + .register_stellar_asset_contract_v2(Address::generate(&env)) + .address(); let treasury = Address::generate(&env); let pool_id = env.register(RiskPool, ()); let policy_id = env.register(PolicyEngine, ()); let claims_id = env.register(ClaimsProcessor, ()); RiskPoolClient::new(&env, &pool_id).initialize( - &admin, &usdc, &treasury, &backstop, - &symbol_short!("crop"), &policy_id, &claims_id, + &admin, + &usdc, + &treasury, + &backstop, + &symbol_short!("crop"), + &policy_id, + &claims_id, ); PolicyEngineClient::new(&env, &policy_id).initialize(&admin, &usdc, &oracle_id); - + // Initialize with very short staleness threshold (10 seconds) ClaimsProcessorClient::new(&env, &claims_id) .initialize(&admin, &policy_id, &pool_id, &oracle_id, &10u64); - + ClaimsProcessorClient::new(&env, &claims_id).add_keeper(&admin, &keeper); PolicyEngineClient::new(&env, &policy_id).set_claims_processor(&admin, &claims_id); let pid = PolicyEngineClient::new(&env, &policy_id).create_product( - &admin, &CreateProductParams { + &admin, + &CreateProductParams { name: symbol_short!("crop"), category: symbol_short!("crop"), oracle_key: symbol_short!("kis2606"), @@ -268,15 +312,24 @@ fn test_staleness_threshold_boundary() { StellarAssetClient::new(&env, &usdc).mint(&buyer, &5_000_000_000i128); StellarAssetClient::new(&env, &usdc).mint(&pool_id, &10_000_000_000i128); RiskPoolClient::new(&env, &pool_id).deposit(&buyer, &1_000_000_000i128, &0i128); - - let pol_id = PolicyEngineClient::new(&env, &policy_id) - .buy_policy(&buyer, &pid, &COVERAGE, &30u32, &symbol_short!("kis2606")); + + let pol_id = PolicyEngineClient::new(&env, &policy_id).buy_policy( + &buyer, + &pid, + &COVERAGE, + &30u32, + &symbol_short!("kis2606"), + ); RiskPoolClient::new(&env, &pool_id).lock_for_policy(&admin, &pol_id, &COVERAGE); // Submit oracle data OracleVerifierClient::new(&env, &oracle_id).submit_data( - &oracle_wallet, &symbol_short!("weather"), &symbol_short!("kis2606"), - &20_000_000, &95u32, &env.ledger().timestamp(), + &oracle_wallet, + &symbol_short!("weather"), + &symbol_short!("kis2606"), + &20_000_000, + &95u32, + &env.ledger().timestamp(), ); // Process immediately - should succeed (data is fresh) @@ -292,19 +345,22 @@ fn test_multi_contract_interaction_atomicity() { let pid = create_crop_product(&w); let buyer = Address::generate(&w.env); let pol_id = buy_crop_policy(&w, &buyer, pid); - + submit_rainfall(&w, 20_000_000); - + let cp = ClaimsProcessorClient::new(&w.env, &w.claims_id); let result = cp.auto_process(&w.keeper, &pol_id); - + // Verify successful payout assert_eq!(result, ClaimResult::Paid); - + // Verify policy status updated in policy-engine let policy = PolicyEngineClient::new(&w.env, &w.policy_id).get_policy(&pol_id); - assert_eq!(policy.status, parashield_policy_engine::PolicyStatus::Claimed); - + assert_eq!( + policy.status, + parashield_policy_engine::PolicyStatus::Claimed + ); + // Verify coverage lock released in risk-pool // (This is implicitly tested by the successful auto_process - if lock release failed, transaction would revert) } @@ -316,14 +372,14 @@ fn test_policy_exactly_at_expiration() { let pid = create_crop_product(&w); let buyer = Address::generate(&w.env); let pol_id = buy_crop_policy(&w, &buyer, pid); - + // Advance to exactly the policy end_time let policy = PolicyEngineClient::new(&w.env, &w.policy_id).get_policy(&pol_id); w.env.ledger().with_mut(|l| l.timestamp = policy.end_time); - + let cp = ClaimsProcessorClient::new(&w.env, &w.claims_id); let result = cp.auto_process(&w.keeper, &pol_id); - + // Should be expired assert_eq!(result, ClaimResult::Expired); } @@ -335,13 +391,13 @@ fn test_oracle_data_at_boundary_threshold() { let pid = create_crop_product(&w); let buyer = Address::generate(&w.env); let pol_id = buy_crop_policy(&w, &buyer, pid); - + // Submit data exactly at threshold (should NOT trigger payout for LessThan) submit_rainfall(&w, 50_000_000); - + let cp = ClaimsProcessorClient::new(&w.env, &w.claims_id); let result = cp.auto_process(&w.keeper, &pol_id); - + // Should be rejected (not less than threshold) assert_eq!(result, ClaimResult::Rejected); } @@ -350,10 +406,11 @@ fn test_oracle_data_at_boundary_threshold() { #[test] fn test_maximum_coverage_boundary() { let w = deploy(); - + // Create product with max coverage let pid = PolicyEngineClient::new(&w.env, &w.policy_id).create_product( - &w.admin, &CreateProductParams { + &w.admin, + &CreateProductParams { name: symbol_short!("max_cov"), category: symbol_short!("crop"), oracle_key: symbol_short!("kis2606"), @@ -367,22 +424,27 @@ fn test_maximum_coverage_boundary() { max_duration_days: 365, }, ); - + let buyer = Address::generate(&w.env); StellarAssetClient::new(&w.env, &w.usdc).mint(&buyer, &50_000_000_000i128); StellarAssetClient::new(&w.env, &w.usdc).mint(&w.pool_id, &20_000_000_000i128); RiskPoolClient::new(&w.env, &w.pool_id).deposit(&buyer, &10_000_000_000i128, &0i128); - + // Buy policy at maximum coverage - let pol_id = PolicyEngineClient::new(&w.env, &w.policy_id) - .buy_policy(&buyer, &pid, &10_000_000_000i128, &30u32, &symbol_short!("kis2606")); + let pol_id = PolicyEngineClient::new(&w.env, &w.policy_id).buy_policy( + &buyer, + &pid, + &10_000_000_000i128, + &30u32, + &symbol_short!("kis2606"), + ); RiskPoolClient::new(&w.env, &w.pool_id).lock_for_policy(&w.admin, &pol_id, &10_000_000_000i128); - + submit_rainfall(&w, 20_000_000); - + let cp = ClaimsProcessorClient::new(&w.env, &w.claims_id); let result = cp.auto_process(&w.keeper, &pol_id); - + assert_eq!(result, ClaimResult::Paid); } @@ -392,21 +454,26 @@ fn test_minimum_coverage_boundary() { let w = deploy(); let pid = create_crop_product(&w); let buyer = Address::generate(&w.env); - + StellarAssetClient::new(&w.env, &w.usdc).mint(&buyer, &1_000_000_000i128); StellarAssetClient::new(&w.env, &w.usdc).mint(&w.pool_id, &1_000_000_000i128); RiskPoolClient::new(&w.env, &w.pool_id).deposit(&buyer, &100_000_000i128, &0i128); - + // Buy policy at minimum coverage - let pol_id = PolicyEngineClient::new(&w.env, &w.policy_id) - .buy_policy(&buyer, &pid, &100_000_000i128, &30u32, &symbol_short!("kis2606")); + let pol_id = PolicyEngineClient::new(&w.env, &w.policy_id).buy_policy( + &buyer, + &pid, + &100_000_000i128, + &30u32, + &symbol_short!("kis2606"), + ); RiskPoolClient::new(&w.env, &w.pool_id).lock_for_policy(&w.admin, &pol_id, &100_000_000i128); - + submit_rainfall(&w, 20_000_000); - + let cp = ClaimsProcessorClient::new(&w.env, &w.claims_id); let result = cp.auto_process(&w.keeper, &pol_id); - + assert_eq!(result, ClaimResult::Paid); } @@ -415,34 +482,34 @@ fn test_minimum_coverage_boundary() { fn test_concurrent_claim_submissions() { let w = deploy(); let pid = create_crop_product(&w); - + let buyer1 = Address::generate(&w.env); let buyer2 = Address::generate(&w.env); let buyer3 = Address::generate(&w.env); - + let pol_id1 = buy_crop_policy(&w, &buyer1, pid); let pol_id2 = buy_crop_policy(&w, &buyer2, pid); let pol_id3 = buy_crop_policy(&w, &buyer3, pid); - + submit_rainfall(&w, 20_000_000); - + let cp = ClaimsProcessorClient::new(&w.env, &w.claims_id); - + // All three policyholders submit claims let claim_id1 = cp.submit_claim(&buyer1, &pol_id1); let claim_id2 = cp.submit_claim(&buyer2, &pol_id2); let claim_id3 = cp.submit_claim(&buyer3, &pol_id3); - + // All should have unique claim IDs assert_ne!(claim_id1, claim_id2); assert_ne!(claim_id2, claim_id3); assert_ne!(claim_id1, claim_id3); - + // Process all claims let res1 = cp.process_claim(&w.keeper, claim_id1); let res2 = cp.process_claim(&w.keeper, claim_id2); let res3 = cp.process_claim(&w.keeper, claim_id3); - + assert_eq!(res1, ClaimResult::Paid); assert_eq!(res2, ClaimResult::Paid); assert_eq!(res3, ClaimResult::Paid); @@ -467,7 +534,11 @@ fn test_pending_queue_drained_after_settlement() { submit_rainfall(&w, 20_000_000); let result = cp.process_claim(&w.keeper, claim_id); assert_eq!(result, ClaimResult::Paid); - assert_eq!(cp.get_pending_claims().len(), 0, "settled claim must leave the pending queue"); + assert_eq!( + cp.get_pending_claims().len(), + 0, + "settled claim must leave the pending queue" + ); } /// Test version tracking for upgrade path. @@ -477,3 +548,23 @@ fn test_initial_version_tracking() { let cp = ClaimsProcessorClient::new(&w.env, &w.claims_id); assert_eq!(cp.get_version(), 1); } + +#[test] +fn run_migrations_accepts_sequenced_supported_versions() { + let env = Env::default(); + ClaimsProcessor::run_migrations(&env, 1, 3); +} + +#[test] +#[should_panic(expected = "invalid migration version")] +fn run_migrations_rejects_downgrade() { + let env = Env::default(); + ClaimsProcessor::run_migrations(&env, 2, 1); +} + +#[test] +#[should_panic(expected = "invalid migration version")] +fn run_migrations_rejects_unsupported_target() { + let env = Env::default(); + ClaimsProcessor::run_migrations(&env, 1, 4); +} diff --git a/contracts/claims-processor/src/test_integration.rs b/contracts/claims-processor/src/test_integration.rs index a573e20..3a6c223 100644 --- a/contracts/claims-processor/src/test_integration.rs +++ b/contracts/claims-processor/src/test_integration.rs @@ -10,30 +10,30 @@ use soroban_sdk::{ token, Address, Env, }; +use crate::{ClaimResult, ClaimsProcessor, ClaimsProcessorClient}; use parashield_oracle_verifier::{OracleVerifier, OracleVerifierClient}; use parashield_policy_engine::{ - PolicyEngine, PolicyEngineClient, - CreateProductParams, - TriggerComparison, TriggerType, + CreateProductParams, PolicyEngine, PolicyEngineClient, TriggerComparison, TriggerType, }; use parashield_risk_pool::{RiskPool, RiskPoolClient}; -use crate::{ClaimsProcessor, ClaimsProcessorClient, ClaimResult}; // ── helpers ─────────────────────────────────────────────────────────────────── use soroban_sdk::{symbol_short, Symbol}; -fn weather() -> Symbol { symbol_short!("weather") } +fn weather() -> Symbol { + symbol_short!("weather") +} struct TestEnv { - env: Env, - oracle: Address, - policy: Address, - claims: Address, - admin: Address, - usdc: Address, + env: Env, + oracle: Address, + policy: Address, + claims: Address, + admin: Address, + usdc: Address, oracle_node: Address, - pool: Address, + pool: Address, } fn full_setup() -> TestEnv { @@ -43,51 +43,72 @@ fn full_setup() -> TestEnv { let admin = Address::generate(&env); let oracle_node = Address::generate(&env); - let usdc_id = env.register_stellar_asset_contract_v2(admin.clone()).address(); + let usdc_id = env + .register_stellar_asset_contract_v2(admin.clone()) + .address(); let oracle_id = env.register(OracleVerifier, ()); let policy_id = env.register(PolicyEngine, ()); let claims_id = env.register(ClaimsProcessor, ()); - let backstop = env.register_stellar_asset_contract_v2(admin.clone()).address(); + let backstop = env + .register_stellar_asset_contract_v2(admin.clone()) + .address(); let treasury = Address::generate(&env); let pool_id = env.register(RiskPool, ()); OracleVerifierClient::new(&env, &oracle_id).initialize(&admin); - PolicyEngineClient::new(&env, &policy_id) - .initialize(&admin, &usdc_id, &oracle_id); - + PolicyEngineClient::new(&env, &policy_id).initialize(&admin, &usdc_id, &oracle_id); + // Initialize risk pool (will be reinitialized with correct addresses later) - RiskPoolClient::new(&env, &pool_id) - .initialize(&admin, &usdc_id, &treasury, &backstop, &Symbol::new(&env, "crop"), &policy_id, &claims_id); - - ClaimsProcessorClient::new(&env, &claims_id) - .initialize(&admin, &policy_id, &pool_id, &oracle_id, &604_800u64); + RiskPoolClient::new(&env, &pool_id).initialize( + &admin, + &usdc_id, + &treasury, + &backstop, + &Symbol::new(&env, "crop"), + &policy_id, + &claims_id, + ); + + ClaimsProcessorClient::new(&env, &claims_id).initialize( + &admin, + &policy_id, + &pool_id, + &oracle_id, + &604_800u64, + ); // Authorize admin as a keeper for tests - ClaimsProcessorClient::new(&env, &claims_id) - .add_keeper(&admin, &admin); - PolicyEngineClient::new(&env, &policy_id) - .set_claims_processor(&admin, &claims_id); + ClaimsProcessorClient::new(&env, &claims_id).add_keeper(&admin, &admin); + PolicyEngineClient::new(&env, &policy_id).set_claims_processor(&admin, &claims_id); // The integration tests drive settlement through the admin address. - ClaimsProcessorClient::new(&env, &claims_id) - .add_keeper(&admin, &admin); - - TestEnv { env, oracle: oracle_id, policy: policy_id, claims: claims_id, admin, usdc: usdc_id, oracle_node, pool: pool_id } + ClaimsProcessorClient::new(&env, &claims_id).add_keeper(&admin, &admin); + + TestEnv { + env, + oracle: oracle_id, + policy: policy_id, + claims: claims_id, + admin, + usdc: usdc_id, + oracle_node, + pool: pool_id, + } } fn create_drought_product(te: &TestEnv) -> u128 { PolicyEngineClient::new(&te.env, &te.policy).create_product( &te.admin, &CreateProductParams { - name: symbol_short!("drght"), - category: symbol_short!("crop"), - oracle_key: symbol_short!("kis2606"), - trigger_type: TriggerType::Threshold, - oracle_data_type: weather(), - trigger_threshold: 500_000_000i128, + name: symbol_short!("drght"), + category: symbol_short!("crop"), + oracle_key: symbol_short!("kis2606"), + trigger_type: TriggerType::Threshold, + oracle_data_type: weather(), + trigger_threshold: 500_000_000i128, trigger_comparison: TriggerComparison::LessThan, - coverage_min: 1_000_0000000i128, - coverage_max: 100_000_0000000i128, - premium_rate_bps: 300u32, - max_duration_days: 180u32, + coverage_min: 1_000_0000000i128, + coverage_max: 100_000_0000000i128, + premium_rate_bps: 300u32, + max_duration_days: 180u32, }, ) } @@ -105,23 +126,33 @@ fn batch_processes_multiple_pending_claims() { oracle_client.add_oracle(&te.admin, &te.oracle_node, &weather(), &90u32); oracle_client.submit_data( - &te.oracle_node, &weather(), &symbol_short!("kis2606"), - &30_000_000i128, &95u32, &te.env.ledger().timestamp(), + &te.oracle_node, + &weather(), + &symbol_short!("kis2606"), + &30_000_000i128, + &95u32, + &te.env.ledger().timestamp(), ); let farmer1 = Address::generate(&te.env); let farmer2 = Address::generate(&te.env); token::StellarAssetClient::new(&te.env, &te.usdc).mint(&farmer1, &10_000_0000000i128); token::StellarAssetClient::new(&te.env, &te.usdc).mint(&farmer2, &10_000_0000000i128); - token::StellarAssetClient::new(&te.env, &te.usdc).mint( - &te.policy, &1_000_000_0000000i128, - ); + token::StellarAssetClient::new(&te.env, &te.usdc).mint(&te.policy, &1_000_000_0000000i128); let p1 = policy_client.buy_policy( - &farmer1, &prod_id, &1_000_0000000i128, &30u32, &symbol_short!("kis2606"), + &farmer1, + &prod_id, + &1_000_0000000i128, + &30u32, + &symbol_short!("kis2606"), ); let p2 = policy_client.buy_policy( - &farmer2, &prod_id, &2_000_0000000i128, &30u32, &symbol_short!("kis2606"), + &farmer2, + &prod_id, + &2_000_0000000i128, + &30u32, + &symbol_short!("kis2606"), ); claims_client.submit_claim(&farmer1, &p1); @@ -147,8 +178,12 @@ fn batch_skips_non_pending_claims() { oracle_client.add_oracle(&te.admin, &te.oracle_node, &weather(), &90u32); oracle_client.submit_data( - &te.oracle_node, &weather(), &symbol_short!("kis2606"), - &30_000_000i128, &95u32, &te.env.ledger().timestamp(), + &te.oracle_node, + &weather(), + &symbol_short!("kis2606"), + &30_000_000i128, + &95u32, + &te.env.ledger().timestamp(), ); let farmer = Address::generate(&te.env); @@ -156,7 +191,11 @@ fn batch_skips_non_pending_claims() { token::StellarAssetClient::new(&te.env, &te.usdc).mint(&te.policy, &1_000_000_0000000i128); let p1 = policy_client.buy_policy( - &farmer, &prod_id, &1_000_0000000i128, &30u32, &symbol_short!("kis2606"), + &farmer, + &prod_id, + &1_000_0000000i128, + &30u32, + &symbol_short!("kis2606"), ); let _claim_id = claims_client.submit_claim(&farmer, &p1); // Process it once @@ -185,8 +224,12 @@ fn test_stale_oracle_data_rejected() { // Submit oracle data with an old timestamp (well within a recognisable epoch) let data_ts: u64 = 1_000_000; oracle_client.submit_data( - &te.oracle_node, &weather(), &symbol_short!("kis2606"), - &30_000_000i128, &95u32, &data_ts, + &te.oracle_node, + &weather(), + &symbol_short!("kis2606"), + &30_000_000i128, + &95u32, + &data_ts, ); let farmer = Address::generate(&te.env); @@ -194,7 +237,11 @@ fn test_stale_oracle_data_rejected() { token::StellarAssetClient::new(&te.env, &te.usdc).mint(&te.policy, &1_000_000_0000000i128); let pol_id = policy_client.buy_policy( - &farmer, &prod_id, &1_000_0000000i128, &30u32, &symbol_short!("kis2606"), + &farmer, + &prod_id, + &1_000_0000000i128, + &30u32, + &symbol_short!("kis2606"), ); // Advance ledger to more than staleness_threshold (604_800 s) past the data timestamp @@ -221,8 +268,12 @@ fn test_fresh_oracle_data_accepted() { let data_ts: u64 = 1_000_000; te.env.ledger().with_mut(|l| l.timestamp = data_ts); oracle_client.submit_data( - &te.oracle_node, &weather(), &symbol_short!("kis2606"), - &30_000_000i128, &95u32, &data_ts, + &te.oracle_node, + &weather(), + &symbol_short!("kis2606"), + &30_000_000i128, + &95u32, + &data_ts, ); let farmer = Address::generate(&te.env); @@ -230,7 +281,11 @@ fn test_fresh_oracle_data_accepted() { token::StellarAssetClient::new(&te.env, &te.usdc).mint(&te.policy, &1_000_000_0000000i128); let pol_id = policy_client.buy_policy( - &farmer, &prod_id, &1_000_0000000i128, &30u32, &symbol_short!("kis2606"), + &farmer, + &prod_id, + &1_000_0000000i128, + &30u32, + &symbol_short!("kis2606"), ); // Set ledger to within the staleness threshold (data is fresh) @@ -252,17 +307,17 @@ fn test_equal_comparison() { let prod_id = policy_client.create_product( &te.admin, &CreateProductParams { - name: symbol_short!("equal"), - category: symbol_short!("flight"), - oracle_key: symbol_short!("flight1"), - trigger_type: TriggerType::Threshold, - oracle_data_type: weather(), - trigger_threshold: 100_000_000i128, + name: symbol_short!("equal"), + category: symbol_short!("flight"), + oracle_key: symbol_short!("flight1"), + trigger_type: TriggerType::Threshold, + oracle_data_type: weather(), + trigger_threshold: 100_000_000i128, trigger_comparison: TriggerComparison::Equal, - coverage_min: 1_000_0000000i128, - coverage_max: 100_000_0000000i128, - premium_rate_bps: 300u32, - max_duration_days: 180u32, + coverage_min: 1_000_0000000i128, + coverage_max: 100_000_0000000i128, + premium_rate_bps: 300u32, + max_duration_days: 180u32, }, ); @@ -270,8 +325,12 @@ fn test_equal_comparison() { let data_ts: u64 = 1_000_000; te.env.ledger().with_mut(|l| l.timestamp = data_ts); oracle_client.submit_data( - &te.oracle_node, &weather(), &symbol_short!("flight1"), - &100_000_000i128, &95u32, &data_ts, + &te.oracle_node, + &weather(), + &symbol_short!("flight1"), + &100_000_000i128, + &95u32, + &data_ts, ); let farmer = Address::generate(&te.env); @@ -279,7 +338,11 @@ fn test_equal_comparison() { token::StellarAssetClient::new(&te.env, &te.usdc).mint(&te.policy, &1_000_000_0000000i128); let pol_id = policy_client.buy_policy( - &farmer, &prod_id, &1_000_0000000i128, &30u32, &symbol_short!("flight1"), + &farmer, + &prod_id, + &1_000_0000000i128, + &30u32, + &symbol_short!("flight1"), ); let fresh_now = data_ts + 3_600; @@ -304,7 +367,11 @@ fn dispute_removes_claim_from_pending_queue() { token::StellarAssetClient::new(&te.env, &te.usdc).mint(&te.policy, &1_000_000_0000000i128); let pol_id = policy_client.buy_policy( - &farmer, &prod_id, &1_000_0000000i128, &30u32, &symbol_short!("kis2606"), + &farmer, + &prod_id, + &1_000_0000000i128, + &30u32, + &symbol_short!("kis2606"), ); // Submit enqueues the claim. diff --git a/contracts/claims-processor/src/types.rs b/contracts/claims-processor/src/types.rs index 1ed63b7..7b4dce3 100644 --- a/contracts/claims-processor/src/types.rs +++ b/contracts/claims-processor/src/types.rs @@ -87,4 +87,3 @@ pub struct ContractUpgraded { pub old_version: u32, pub new_version: u32, } - diff --git a/contracts/governance-dao/src/lib.rs b/contracts/governance-dao/src/lib.rs index c9bcbe3..fff93b0 100644 --- a/contracts/governance-dao/src/lib.rs +++ b/contracts/governance-dao/src/lib.rs @@ -35,7 +35,6 @@ enum StorageKey { LockedBalance(u64, Address), /// Contract version (u32) for storage migration tracking Version, - } #[contracterror] @@ -76,7 +75,7 @@ impl GovernanceDao { // Address verification let admin_str = admin.to_string(); - + if false { panic!("invalid address: admin must be an account address"); } @@ -151,11 +150,7 @@ impl GovernanceDao { // (not whatever config.proposal_threshold reads as later) is what // finalize() must refund, so it's captured on the Proposal below. let deposit = config.proposal_threshold; - gov_token.transfer( - &proposer, - &env.current_contract_address(), - &deposit, - ); + gov_token.transfer(&proposer, &env.current_contract_address(), &deposit); let proposal_id: u64 = env .storage() @@ -185,9 +180,12 @@ impl GovernanceDao { env.storage() .persistent() .set(&StorageKey::Proposal(proposal_id), &proposal); - env.storage() - .instance() - .set(&StorageKey::NextProposalId, &(proposal_id.checked_add(1).unwrap_or_else(|| panic_with_error!(&env, Error::LimitReached)))); + env.storage().instance().set( + &StorageKey::NextProposalId, + &(proposal_id + .checked_add(1) + .unwrap_or_else(|| panic_with_error!(&env, Error::LimitReached))), + ); // Note: You can append `args` to your event payload if necessary env.events().publish( @@ -355,7 +353,11 @@ impl GovernanceDao { proposal.status = ProposalStatus::Failed; } else { let for_bps = if total_votes > 0 { - proposal.votes_for.checked_mul(10_000).map(|v| v / total_votes).unwrap_or(0) + proposal + .votes_for + .checked_mul(10_000) + .map(|v| v / total_votes) + .unwrap_or(0) } else { 0 }; @@ -512,7 +514,10 @@ impl GovernanceDao { /// Return the contract's current storage/version number (defaults to 1). pub fn get_version(env: Env) -> u32 { - env.storage().instance().get(&StorageKey::Version).unwrap_or(1) + env.storage() + .instance() + .get(&StorageKey::Version) + .unwrap_or(1) } // ── Admin ───────────────────────────────────────────────────────────────── @@ -544,20 +549,26 @@ impl GovernanceDao { /// Runs storage migrations if the new version requires them. pub fn upgrade(env: Env, admin: Address, new_wasm_hash: BytesN<32>, new_version: u32) { Self::require_admin(&env, &admin); - let current_version: u32 = env.storage().instance().get(&StorageKey::Version).unwrap_or(1); + let current_version: u32 = env + .storage() + .instance() + .get(&StorageKey::Version) + .unwrap_or(1); if new_version <= current_version { panic_with_error!(&env, Error::VersionNotNewer); } - + // Run migrations from current_version to new_version Self::run_migrations(&env, current_version, new_version); - + // Update the stored version - env.storage().instance().set(&StorageKey::Version, &new_version); - + env.storage() + .instance() + .set(&StorageKey::Version, &new_version); + // Perform the actual WASM upgrade env.deployer().update_current_contract_wasm(new_wasm_hash); - + env.events().publish( (Symbol::new(&env, "contract_upgraded"),), ContractUpgraded { @@ -573,7 +584,7 @@ impl GovernanceDao { // Migration from v1 to v2: No storage changes needed yet // This is where you would add migration logic for specific version bumps // Example: if old_version < 2 && new_version >= 2 { Self::migrate_v1_to_v2(env); } - + // Future migrations follow the pattern: // if old_version < 3 && new_version >= 3 { Self::migrate_v2_to_v3(env); } } diff --git a/contracts/oracle-verifier/src/lib.rs b/contracts/oracle-verifier/src/lib.rs index 535e941..d5adb06 100644 --- a/contracts/oracle-verifier/src/lib.rs +++ b/contracts/oracle-verifier/src/lib.rs @@ -48,6 +48,7 @@ enum StorageKey { MaxDataAge, PendingAdmin, MinOracleCount, + DataTypePaused(Symbol), } // ─── Errors ─────────────────────────────────────────────────────────────────── @@ -67,6 +68,7 @@ pub enum Error { StaleData = 9, TooManyOracles = 10, InvalidTimestamp = 11, + DataTypePaused = 12, } // ─── Contract ───────────────────────────────────────────────────────────────── @@ -221,7 +223,9 @@ impl OracleVerifier { pruned.push_back(addr); } } - env.storage().instance().set(&StorageKey::OracleList, &pruned); + env.storage() + .instance() + .set(&StorageKey::OracleList, &pruned); env.events().publish( (Symbol::new(&env, "oracle_removed"),), @@ -334,6 +338,34 @@ impl OracleVerifier { .unwrap_or(1) } + /// Admin-only: pause submissions and trigger verification for one data type. + pub fn pause_data_type(env: Env, admin: Address, data_type: Symbol) { + Self::require_admin(&env, &admin); + env.storage() + .instance() + .set(&StorageKey::DataTypePaused(data_type.clone()), &true); + env.events() + .publish((Symbol::new(&env, "data_type_paused"),), data_type); + } + + /// Admin-only: resume submissions and trigger verification for one data type. + pub fn resume_data_type(env: Env, admin: Address, data_type: Symbol) { + Self::require_admin(&env, &admin); + env.storage() + .instance() + .set(&StorageKey::DataTypePaused(data_type.clone()), &false); + env.events() + .publish((Symbol::new(&env, "data_type_resumed"),), data_type); + } + + /// Return whether a data type is currently paused. + pub fn is_data_type_paused(env: Env, data_type: Symbol) -> bool { + env.storage() + .instance() + .get(&StorageKey::DataTypePaused(data_type)) + .unwrap_or(false) + } + // ── Data Submission ─────────────────────────────────────────────────────── /// Submit a data point for a (data_type, key) pair. @@ -353,6 +385,7 @@ impl OracleVerifier { timestamp: u64, ) { oracle.require_auth(); + Self::require_data_type_active(&env, &data_type); if confidence == 0 || confidence > 100 { panic_with_error!(&env, Error::InvalidConfidence); } @@ -429,6 +462,7 @@ impl OracleVerifier { key: Symbol, condition: TriggerCondition, ) -> bool { + Self::require_data_type_active(&env, &data_type); let median = Self::get_median_value(&env, &data_type, &key); match condition.comparison { TriggerComparison::LessThan => median < condition.threshold, @@ -522,7 +556,11 @@ impl OracleVerifier { let mut active_oracle_count: u32 = 0; for addr in oracle_list.iter() { let oracle_key = StorageKey::Oracle(data_type.clone(), addr.clone()); - if let Some(entry) = env.storage().persistent().get::<_, OracleEntry>(&oracle_key) { + if let Some(entry) = env + .storage() + .persistent() + .get::<_, OracleEntry>(&oracle_key) + { if entry.active { active_oracle_count += 1; } @@ -549,6 +587,7 @@ impl OracleVerifier { condition: TriggerCondition, max_age_seconds: u64, ) -> bool { + Self::require_data_type_active(&env, &data_type); let dp_key = StorageKey::DataPoints(data_type.clone(), key.clone()); let points: Vec = env .storage() @@ -581,13 +620,13 @@ impl OracleVerifier { diff.abs() <= condition.tolerance } }; - + // Emit event for verification result to enable monitoring and auditing env.events().publish( (Symbol::new(&env, "verification_result"),), (data_type, key, result, median, condition.threshold), ); - + result } @@ -600,6 +639,7 @@ impl OracleVerifier { submissions: Vec<(Symbol, i128, u32, u64)>, ) { oracle.require_auth(); + Self::require_data_type_active(&env, &data_type); let oracle_key = StorageKey::Oracle(data_type.clone(), oracle.clone()); let entry: OracleEntry = env .storage() @@ -680,6 +720,7 @@ impl OracleVerifier { submissions: Vec, ) { oracle.require_auth(); + Self::require_data_type_active(&env, &data_type); let oracle_key = StorageKey::Oracle(data_type.clone(), oracle.clone()); let entry: OracleEntry = env @@ -776,6 +817,17 @@ impl OracleVerifier { caller.require_auth(); } + fn require_data_type_active(env: &Env, data_type: &Symbol) { + let paused: bool = env + .storage() + .instance() + .get(&StorageKey::DataTypePaused(data_type.clone())) + .unwrap_or(false); + if paused { + panic_with_error!(env, Error::DataTypePaused); + } + } + /// Compute the weighted median of active, sufficiently fresh submissions. fn get_median_value(env: &Env, data_type: &Symbol, key: &Symbol) -> i128 { let points: Vec = env diff --git a/contracts/oracle-verifier/src/test.rs b/contracts/oracle-verifier/src/test.rs index dc1be79..b43e3cb 100644 --- a/contracts/oracle-verifier/src/test.rs +++ b/contracts/oracle-verifier/src/test.rs @@ -18,7 +18,7 @@ fn setup() -> (Env, Address, Address) { let contract_id = env.register(OracleVerifier, ()); let client = OracleVerifierClient::new(&env, &contract_id); - + client.initialize(&admin); (env, admin, contract_id) @@ -107,9 +107,30 @@ fn test_update_oracle_weight_changes_aggregation() { client.add_oracle(&admin, &oracle1, &weather(), &60u32); client.add_oracle(&admin, &oracle2, &weather(), &20u32); client.add_oracle(&admin, &oracle3, &weather(), &20u32); - client.submit_data(&oracle1, &weather(), &kisumu_key(), &10i128, &100u32, &1748736000u64); - client.submit_data(&oracle2, &weather(), &kisumu_key(), &20i128, &100u32, &1748736000u64); - client.submit_data(&oracle3, &weather(), &kisumu_key(), &30i128, &100u32, &1748736000u64); + client.submit_data( + &oracle1, + &weather(), + &kisumu_key(), + &10i128, + &100u32, + &1748736000u64, + ); + client.submit_data( + &oracle2, + &weather(), + &kisumu_key(), + &20i128, + &100u32, + &1748736000u64, + ); + client.submit_data( + &oracle3, + &weather(), + &kisumu_key(), + &30i128, + &100u32, + &1748736000u64, + ); assert_eq!( client .get_aggregated(&weather(), &kisumu_key()) @@ -162,20 +183,34 @@ fn test_remove_oracle_deactivates() { let client = OracleVerifierClient::new(&env, &contract_id); let oracle1 = Address::generate(&env); let oracle2 = Address::generate(&env); - + client.add_oracle(&admin, &oracle1, &weather(), &80u32); client.add_oracle(&admin, &oracle2, &weather(), &80u32); - - client.submit_data(&oracle1, &weather(), &kisumu_key(), &10_000_000i128, &90u32, &1748736000u64); - client.submit_data(&oracle2, &weather(), &kisumu_key(), &50_000_000i128, &90u32, &1748736000u64); - + + client.submit_data( + &oracle1, + &weather(), + &kisumu_key(), + &10_000_000i128, + &90u32, + &1748736000u64, + ); + client.submit_data( + &oracle2, + &weather(), + &kisumu_key(), + &50_000_000i128, + &90u32, + &1748736000u64, + ); + let agg_before = client.get_aggregated(&weather(), &kisumu_key()); assert_eq!(agg_before.oracle_count, 2); assert_eq!(agg_before.median_value, 30_000_000i128); // (10M + 50M) / 2 - + // Remove oracle1 client.remove_oracle(&admin, &oracle1, &weather()); - + // Aggregation should now only include oracle2 let agg_after = client.get_aggregated(&weather(), &kisumu_key()); assert_eq!(agg_after.oracle_count, 1); @@ -218,11 +253,28 @@ fn test_active_oracle_count_reflects_registrations_not_submissions() { client.add_oracle(&admin, &oracle2, &weather(), &80u32); client.add_oracle(&admin, &oracle3, &weather(), &80u32); - client.submit_data(&oracle1, &weather(), &kisumu_key(), &10_000_000i128, &90u32, &1748736000u64); - client.submit_data(&oracle2, &weather(), &kisumu_key(), &50_000_000i128, &90u32, &1748736000u64); + client.submit_data( + &oracle1, + &weather(), + &kisumu_key(), + &10_000_000i128, + &90u32, + &1748736000u64, + ); + client.submit_data( + &oracle2, + &weather(), + &kisumu_key(), + &50_000_000i128, + &90u32, + &1748736000u64, + ); let agg = client.get_aggregated(&weather(), &kisumu_key()); - assert_eq!(agg.oracle_count, 2, "oracle_count is submissions for this key"); + assert_eq!( + agg.oracle_count, 2, + "oracle_count is submissions for this key" + ); assert_eq!( agg.active_oracle_count, 3, "active_oracle_count is all active registrations for the data_type" @@ -244,7 +296,14 @@ fn test_removed_oracle_cannot_submit() { let oracle = Address::generate(&env); client.add_oracle(&admin, &oracle, &weather(), &80u32); client.remove_oracle(&admin, &oracle, &weather()); - client.submit_data(&oracle, &weather(), &kisumu_key(), &10_000_000i128, &90u32, &1748736000u64); + client.submit_data( + &oracle, + &weather(), + &kisumu_key(), + &10_000_000i128, + &90u32, + &1748736000u64, + ); } #[test] @@ -263,6 +322,67 @@ fn test_set_min_confidence_invalid() { client.set_min_confidence(&admin, &101u32); } +#[test] +fn admin_can_pause_and_resume_one_data_type() { + let (env, admin, contract_id) = setup(); + let client = OracleVerifierClient::new(&env, &contract_id); + let flight = symbol_short!("flight"); + + assert!(!client.is_data_type_paused(&weather())); + client.pause_data_type(&admin, &weather()); + assert!(client.is_data_type_paused(&weather())); + assert!(!client.is_data_type_paused(&flight)); + client.resume_data_type(&admin, &weather()); + assert!(!client.is_data_type_paused(&weather())); +} + +#[test] +#[should_panic(expected = "Error(Contract, #12)")] +fn paused_data_type_rejects_submission() { + let (env, admin, contract_id) = setup(); + let client = OracleVerifierClient::new(&env, &contract_id); + let oracle = Address::generate(&env); + client.add_oracle(&admin, &oracle, &weather(), &50u32); + client.pause_data_type(&admin, &weather()); + client.submit_data( + &oracle, + &weather(), + &kisumu_key(), + &10_000_000i128, + &90u32, + &1748736000u64, + ); +} + +#[test] +#[should_panic(expected = "Error(Contract, #12)")] +fn paused_data_type_rejects_trigger_verification() { + let (env, admin, contract_id) = setup(); + let client = OracleVerifierClient::new(&env, &contract_id); + let oracle = Address::generate(&env); + client.add_oracle(&admin, &oracle, &weather(), &50u32); + client.submit_data( + &oracle, + &weather(), + &kisumu_key(), + &10_000_000i128, + &90u32, + &1748736000u64, + ); + client.pause_data_type(&admin, &weather()); + client.verify_trigger( + &weather(), + &kisumu_key(), + &TriggerCondition { + data_type: weather(), + key: kisumu_key(), + threshold: 20_000_000, + comparison: TriggerComparison::LessThan, + tolerance: 0, + }, + ); +} + #[test] #[should_panic(expected = "Error(Contract, #3)")] fn test_set_min_confidence_unauthorized() { diff --git a/contracts/oracle-verifier/src/types.rs b/contracts/oracle-verifier/src/types.rs index 75260dc..cdf77bd 100644 --- a/contracts/oracle-verifier/src/types.rs +++ b/contracts/oracle-verifier/src/types.rs @@ -161,10 +161,9 @@ pub struct AdminUpdated { pub new_admin: Address, } - #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] pub struct ContractUpgraded { pub old_version: u32, pub new_version: u32, -} \ No newline at end of file +} diff --git a/contracts/policy-engine/src/lib.rs b/contracts/policy-engine/src/lib.rs index d5ff664..5dce1fd 100644 --- a/contracts/policy-engine/src/lib.rs +++ b/contracts/policy-engine/src/lib.rs @@ -22,8 +22,8 @@ extern crate alloc; #[cfg_attr(feature = "library", allow(unused_imports))] use crate::alloc::string::ToString; use soroban_sdk::{ - contract, contractimpl, contracttype, contracterror, panic_with_error, - token, Address, BytesN, Env, Symbol, Vec, + contract, contracterror, contractimpl, contracttype, panic_with_error, token, Address, BytesN, + Env, Symbol, Vec, }; pub mod types; @@ -39,6 +39,8 @@ const TTL_THRESHOLD: u32 = 518_400; /// products and policies don't get evicted from storage before they mature. #[cfg(any(test, feature = "testutils", not(feature = "library")))] const TTL_EXTEND_TO: u32 = 6_312_000; +const DEFAULT_MAX_PRODUCTS_PER_POOL: u32 = 100; +const CURRENT_STORAGE_VERSION: u32 = 3; // ─── Storage keys ───────────────────────────────────────────────────────────── @@ -63,6 +65,8 @@ enum StorageKey { /// Maps (category, oracle_key) -> product_id for uniqueness constraint ProductKey((Symbol, Symbol)), PendingAdmin, + MaxProductsPerPool, + PoolProductCount(Symbol), /// Contract version (u32) for storage migration tracking Version, } @@ -73,27 +77,29 @@ enum StorageKey { #[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] #[repr(u32)] pub enum Error { - AlreadyInitialized = 1, - NotInitialized = 2, - Unauthorized = 3, - ProductNotFound = 4, - ProductNotActive = 5, - PolicyNotFound = 6, - PolicyNotActive = 7, - CoverageOutOfRange = 8, - DurationTooLong = 9, - InsufficientPool = 10, - AlreadyClaimed = 11, - AlreadyExpired = 12, - InvalidPremiumRate = 13, + AlreadyInitialized = 1, + NotInitialized = 2, + Unauthorized = 3, + ProductNotFound = 4, + ProductNotActive = 5, + PolicyNotFound = 6, + PolicyNotActive = 7, + CoverageOutOfRange = 8, + DurationTooLong = 9, + InsufficientPool = 10, + AlreadyClaimed = 11, + AlreadyExpired = 12, + InvalidPremiumRate = 13, InvalidTriggerThreshold = 14, - DuplicateProductKey = 15, - InvalidCoverageRange = 16, - InvalidToken = 17, - ClaimsProcessorNotSet = 18, - InvalidDurationRange = 19, - InvalidOracleKey = 20, - Overflow = 21, + DuplicateProductKey = 15, + InvalidCoverageRange = 16, + InvalidToken = 17, + ClaimsProcessorNotSet = 18, + InvalidDurationRange = 19, + InvalidOracleKey = 20, + Overflow = 21, + TooManyProducts = 22, + InvalidMaxProducts = 23, } // ─── Contract ───────────────────────────────────────────────────────────────── @@ -105,18 +111,12 @@ pub struct PolicyEngine; #[cfg(any(test, feature = "testutils", not(feature = "library")))] #[contractimpl] impl PolicyEngine { - // ── Lifecycle ──────────────────────────────────────────────────────────── /// One-time initialisation. Wires up the USDC token and oracle contracts. /// Panics with `AlreadyInitialized` on a second call, or `InvalidToken` if /// `usdc_token` does not expose a `balance` entry-point. - pub fn initialize( - env: Env, - admin: Address, - usdc_token: Address, - oracle_address: Address, - ) { + pub fn initialize(env: Env, admin: Address, usdc_token: Address, oracle_address: Address) { if env.storage().instance().has(&StorageKey::Initialized) { panic_with_error!(&env, Error::AlreadyInitialized); } @@ -161,7 +161,7 @@ impl PolicyEngine { if oracle_buf[0] != b'C' { panic!("invalid address: oracle_address must be a contract address"); } - + let balance_res = env.try_invoke_contract::( &usdc_token, &Symbol::new(&env, "balance"), @@ -172,13 +172,29 @@ impl PolicyEngine { } admin.require_auth(); - env.storage().instance().set(&StorageKey::Initialized, &true); + env.storage() + .instance() + .set(&StorageKey::Initialized, &true); env.storage().instance().set(&StorageKey::Admin, &admin); - env.storage().instance().set(&StorageKey::UsdcToken, &usdc_token); - env.storage().instance().set(&StorageKey::OracleAddress, &oracle_address); - env.storage().instance().set(&StorageKey::NextProductId, &1u128); - env.storage().instance().set(&StorageKey::NextPolicyId, &1u128); - env.storage().instance().set(&StorageKey::ActiveProducts, &Vec::::new(&env)); + env.storage() + .instance() + .set(&StorageKey::UsdcToken, &usdc_token); + env.storage() + .instance() + .set(&StorageKey::OracleAddress, &oracle_address); + env.storage() + .instance() + .set(&StorageKey::NextProductId, &1u128); + env.storage() + .instance() + .set(&StorageKey::NextPolicyId, &1u128); + env.storage() + .instance() + .set(&StorageKey::ActiveProducts, &Vec::::new(&env)); + env.storage().instance().set( + &StorageKey::MaxProductsPerPool, + &DEFAULT_MAX_PRODUCTS_PER_POOL, + ); // No pending admin initially env.storage().instance().remove(&StorageKey::PendingAdmin); @@ -195,7 +211,9 @@ impl PolicyEngine { /// Set the Claims Processor address. Called once after deploying claims contract. pub fn set_claims_processor(env: Env, admin: Address, claims_processor: Address) { Self::require_admin(&env, &admin); - env.storage().instance().set(&StorageKey::ClaimsProcessor, &claims_processor); + env.storage() + .instance() + .set(&StorageKey::ClaimsProcessor, &claims_processor); env.events().publish( (Symbol::new(&env, "claims_processor_updated"),), ClaimsProcessorUpdated { @@ -244,38 +262,76 @@ impl PolicyEngine { // Check for duplicate (category, oracle_key) pair let key = (params.category.clone(), params.oracle_key.clone()); - if env.storage().persistent().has(&StorageKey::ProductKey(key.clone())) { + if env + .storage() + .persistent() + .has(&StorageKey::ProductKey(key.clone())) + { panic_with_error!(&env, Error::DuplicateProductKey); } + let pool_count_key = StorageKey::PoolProductCount(params.category.clone()); + let pool_count: u32 = env.storage().persistent().get(&pool_count_key).unwrap_or(0); + let max_products: u32 = env + .storage() + .instance() + .get(&StorageKey::MaxProductsPerPool) + .unwrap_or(DEFAULT_MAX_PRODUCTS_PER_POOL); + if pool_count >= max_products { + panic_with_error!(&env, Error::TooManyProducts); + } + let id = Self::next_product_id(&env); let product = InsuranceProduct { id, - name: params.name, - category: params.category, - oracle_key: params.oracle_key, - trigger_type: params.trigger_type, - oracle_data_type: params.oracle_data_type, - trigger_threshold: params.trigger_threshold, + name: params.name, + category: params.category, + oracle_key: params.oracle_key, + trigger_type: params.trigger_type, + oracle_data_type: params.oracle_data_type, + trigger_threshold: params.trigger_threshold, trigger_comparison: params.trigger_comparison, - coverage_min: params.coverage_min, - coverage_max: params.coverage_max, - premium_rate_bps: params.premium_rate_bps, - max_duration_days: params.max_duration_days, - status: ProductStatus::Active, - created_at: env.ledger().timestamp(), + coverage_min: params.coverage_min, + coverage_max: params.coverage_max, + premium_rate_bps: params.premium_rate_bps, + max_duration_days: params.max_duration_days, + status: ProductStatus::Active, + created_at: env.ledger().timestamp(), }; - env.storage().persistent().set(&StorageKey::Product(id), &product); - env.storage().persistent().extend_ttl(&StorageKey::Product(id), TTL_THRESHOLD, TTL_EXTEND_TO); + env.storage() + .persistent() + .set(&StorageKey::Product(id), &product); + env.storage().persistent().extend_ttl( + &StorageKey::Product(id), + TTL_THRESHOLD, + TTL_EXTEND_TO, + ); // Store the (category, oracle_key) -> product_id mapping for uniqueness - env.storage().persistent().set(&StorageKey::ProductKey(key.clone()), &id); - env.storage().persistent().extend_ttl(&StorageKey::ProductKey(key), TTL_THRESHOLD, TTL_EXTEND_TO); + env.storage() + .persistent() + .set(&StorageKey::ProductKey(key.clone()), &id); + env.storage().persistent().extend_ttl( + &StorageKey::ProductKey(key), + TTL_THRESHOLD, + TTL_EXTEND_TO, + ); - let mut products: Vec = env.storage().instance() - .get(&StorageKey::ActiveProducts).unwrap_or_else(|| Vec::new(&env)); + let mut products: Vec = env + .storage() + .instance() + .get(&StorageKey::ActiveProducts) + .unwrap_or_else(|| Vec::new(&env)); products.push_back(id); - env.storage().instance().set(&StorageKey::ActiveProducts, &products); + env.storage() + .instance() + .set(&StorageKey::ActiveProducts, &products); + env.storage() + .persistent() + .set(&pool_count_key, &(pool_count + 1)); + env.storage() + .persistent() + .extend_ttl(&pool_count_key, TTL_THRESHOLD, TTL_EXTEND_TO); env.events().publish( (Symbol::new(&env, "product_created"),), @@ -295,10 +351,15 @@ impl PolicyEngine { Self::require_admin(&env, &admin); let mut product: InsuranceProduct = Self::load_product(&env, product_id); product.status = ProductStatus::Paused; - env.storage().persistent().set(&StorageKey::Product(product_id), &product); + env.storage() + .persistent() + .set(&StorageKey::Product(product_id), &product); - let mut products: Vec = env.storage().instance() - .get(&StorageKey::ActiveProducts).unwrap_or_else(|| Vec::new(&env)); + let mut products: Vec = env + .storage() + .instance() + .get(&StorageKey::ActiveProducts) + .unwrap_or_else(|| Vec::new(&env)); let mut idx: Option = None; for i in 0..products.len() { if products.get_unchecked(i) == product_id { @@ -308,7 +369,9 @@ impl PolicyEngine { } if let Some(i) = idx { products.remove(i); - env.storage().instance().set(&StorageKey::ActiveProducts, &products); + env.storage() + .instance() + .set(&StorageKey::ActiveProducts, &products); } env.events().publish( @@ -323,11 +386,16 @@ impl PolicyEngine { Self::require_admin(&env, &admin); let mut product: InsuranceProduct = Self::load_product(&env, product_id); product.status = ProductStatus::Deprecated; - env.storage().persistent().set(&StorageKey::Product(product_id), &product); + env.storage() + .persistent() + .set(&StorageKey::Product(product_id), &product); // Remove from the ActiveProducts list on deprecation - let mut products: Vec = env.storage().instance() - .get(&StorageKey::ActiveProducts).unwrap_or_else(|| Vec::new(&env)); + let mut products: Vec = env + .storage() + .instance() + .get(&StorageKey::ActiveProducts) + .unwrap_or_else(|| Vec::new(&env)); let mut idx: Option = None; for i in 0..products.len() { if products.get_unchecked(i) == product_id { @@ -337,12 +405,27 @@ impl PolicyEngine { } if let Some(i) = idx { products.remove(i); - env.storage().instance().set(&StorageKey::ActiveProducts, &products); + env.storage() + .instance() + .set(&StorageKey::ActiveProducts, &products); } // Remove the (category, oracle_key) mapping to allow reuse of the key + let pool_count_key = StorageKey::PoolProductCount(product.category.clone()); + let pool_count: u32 = env.storage().persistent().get(&pool_count_key).unwrap_or(0); + if pool_count > 0 { + env.storage() + .persistent() + .set(&pool_count_key, &(pool_count - 1)); + env.storage() + .persistent() + .extend_ttl(&pool_count_key, TTL_THRESHOLD, TTL_EXTEND_TO); + } + let key = (product.category, product.oracle_key); - env.storage().persistent().remove(&StorageKey::ProductKey(key)); + env.storage() + .persistent() + .remove(&StorageKey::ProductKey(key)); } // ── Policy Lifecycle ────────────────────────────────────────────────────── @@ -364,7 +447,12 @@ impl PolicyEngine { oracle_key: Symbol, ) -> u128 { buyer.require_auth(); - if env.storage().instance().get::<_, bool>(&StorageKey::Paused).unwrap_or(false) { + if env + .storage() + .instance() + .get::<_, bool>(&StorageKey::Paused) + .unwrap_or(false) + { panic_with_error!(&env, Error::Unauthorized); } let product = Self::load_product(&env, product_id); @@ -391,20 +479,23 @@ impl PolicyEngine { .and_then(|v| v.checked_div(365)) .and_then(|v| v.checked_div(10_000)) .unwrap_or_else(|| panic_with_error!(&env, Error::CoverageOutOfRange)); - let usdc: Address = env.storage().instance().get(&StorageKey::UsdcToken) + let usdc: Address = env + .storage() + .instance() + .get(&StorageKey::UsdcToken) .unwrap_or_else(|| panic_with_error!(&env, Error::NotInitialized)); // Pull premium from buyer into this contract - token::Client::new(&env, &usdc) - .transfer(&buyer, &env.current_contract_address(), &premium); + token::Client::new(&env, &usdc).transfer(&buyer, &env.current_contract_address(), &premium); - let now = env.ledger().timestamp(); + let now = env.ledger().timestamp(); let duration_secs = (duration_days as u64) .checked_mul(86_400) .unwrap_or_else(|| panic_with_error!(&env, Error::CoverageOutOfRange)); - let end_time = now.checked_add(duration_secs) + let end_time = now + .checked_add(duration_secs) .unwrap_or_else(|| panic_with_error!(&env, Error::CoverageOutOfRange)); - let policy_id = Self::next_policy_id(&env); + let policy_id = Self::next_policy_id(&env); let policy = Policy { id: policy_id, @@ -421,16 +512,27 @@ impl PolicyEngine { status: PolicyStatus::Active, created_at: now, }; - env.storage().persistent().set(&StorageKey::Policy(policy_id), &policy); - env.storage().persistent().extend_ttl(&StorageKey::Policy(policy_id), TTL_THRESHOLD, TTL_EXTEND_TO); + env.storage() + .persistent() + .set(&StorageKey::Policy(policy_id), &policy); + env.storage().persistent().extend_ttl( + &StorageKey::Policy(policy_id), + TTL_THRESHOLD, + TTL_EXTEND_TO, + ); // Append to user's policy list let user_key = StorageKey::UserPolicies(buyer.clone()); - let mut user_policies: Vec = env.storage().persistent() - .get(&user_key).unwrap_or_else(|| Vec::new(&env)); + let mut user_policies: Vec = env + .storage() + .persistent() + .get(&user_key) + .unwrap_or_else(|| Vec::new(&env)); user_policies.push_back(policy_id); env.storage().persistent().set(&user_key, &user_policies); - env.storage().persistent().extend_ttl(&user_key, TTL_THRESHOLD, TTL_EXTEND_TO); + env.storage() + .persistent() + .extend_ttl(&user_key, TTL_THRESHOLD, TTL_EXTEND_TO); env.events().publish( (Symbol::new(&env, "buy_policy"), buyer), @@ -452,7 +554,9 @@ impl PolicyEngine { panic_with_error!(&env, Error::PolicyNotActive); } policy.status = PolicyStatus::Cancelled; - env.storage().persistent().set(&StorageKey::Policy(policy_id), &policy); + env.storage() + .persistent() + .set(&StorageKey::Policy(policy_id), &policy); Self::remove_policy_from_user(&env, &policyholder, policy_id); // Pro-rate the refund: only return the unearned portion of the premium. @@ -464,17 +568,25 @@ impl PolicyEngine { policy.premium_paid } else { let elapsed_capped = elapsed.min(total_duration); - let earned = policy.premium_paid.checked_mul(elapsed_capped as i128) + let earned = policy + .premium_paid + .checked_mul(elapsed_capped as i128) .and_then(|v| v.checked_div(total_duration as i128)) .unwrap_or_else(|| panic_with_error!(&env, Error::Overflow)); policy.premium_paid.saturating_sub(earned) }; - let usdc: Address = env.storage().instance().get(&StorageKey::UsdcToken) + let usdc: Address = env + .storage() + .instance() + .get(&StorageKey::UsdcToken) .unwrap_or_else(|| panic_with_error!(&env, Error::NotInitialized)); if refund > 0 { - token::Client::new(&env, &usdc) - .transfer(&env.current_contract_address(), &policyholder, &refund); + token::Client::new(&env, &usdc).transfer( + &env.current_contract_address(), + &policyholder, + &refund, + ); } env.events().publish( @@ -496,15 +608,22 @@ impl PolicyEngine { Self::require_claims_processor(&env, &caller); let mut policy: Policy = Self::load_policy(&env, policy_id); match policy.status { - PolicyStatus::Claimed => panic_with_error!(&env, Error::AlreadyClaimed), - PolicyStatus::Expired => panic_with_error!(&env, Error::AlreadyExpired), + PolicyStatus::Claimed => panic_with_error!(&env, Error::AlreadyClaimed), + PolicyStatus::Expired => panic_with_error!(&env, Error::AlreadyExpired), PolicyStatus::Cancelled => panic_with_error!(&env, Error::PolicyNotActive), - PolicyStatus::Active => {} + PolicyStatus::Active => {} } - let usdc: Address = env.storage().instance().get(&StorageKey::UsdcToken) + let usdc: Address = env + .storage() + .instance() + .get(&StorageKey::UsdcToken) .unwrap_or_else(|| panic_with_error!(&env, Error::NotInitialized)); let token_client = token::Client::new(&env, &usdc); - match token_client.try_transfer(&env.current_contract_address(), &policy.policyholder, &policy.coverage_amount) { + match token_client.try_transfer( + &env.current_contract_address(), + &policy.policyholder, + &policy.coverage_amount, + ) { Ok(Ok(())) => {} _ => { panic_with_error!(&env, Error::InsufficientPool); @@ -512,7 +631,9 @@ impl PolicyEngine { } policy.status = PolicyStatus::Claimed; - env.storage().persistent().set(&StorageKey::Policy(policy_id), &policy); + env.storage() + .persistent() + .set(&StorageKey::Policy(policy_id), &policy); Self::remove_policy_from_user(&env, &policy.policyholder, policy_id); env.events().publish( @@ -527,13 +648,15 @@ impl PolicyEngine { Self::require_claims_processor(&env, &caller); let mut policy: Policy = Self::load_policy(&env, policy_id); match policy.status { - PolicyStatus::Claimed => panic_with_error!(&env, Error::AlreadyClaimed), - PolicyStatus::Expired => panic_with_error!(&env, Error::AlreadyExpired), + PolicyStatus::Claimed => panic_with_error!(&env, Error::AlreadyClaimed), + PolicyStatus::Expired => panic_with_error!(&env, Error::AlreadyExpired), PolicyStatus::Cancelled => panic_with_error!(&env, Error::PolicyNotActive), - PolicyStatus::Active => {} + PolicyStatus::Active => {} } policy.status = PolicyStatus::Expired; - env.storage().persistent().set(&StorageKey::Policy(policy_id), &policy); + env.storage() + .persistent() + .set(&StorageKey::Policy(policy_id), &policy); Self::remove_policy_from_user(&env, &policy.policyholder, policy_id); env.events().publish( (Symbol::new(&env, "policy_expired"),), @@ -556,10 +679,12 @@ impl PolicyEngine { /// Return a paginated slice of policy IDs owned by `user`. `offset` is the zero-based /// start index; `limit` caps the number of IDs returned. pub fn get_user_policies(env: Env, user: Address, offset: u32, limit: u32) -> Vec { - let all: Vec = env.storage().persistent() + let all: Vec = env + .storage() + .persistent() .get(&StorageKey::UserPolicies(user)) .unwrap_or_else(|| Vec::new(&env)); - + let mut paginated = Vec::new(&env); let len = all.len(); if offset >= len { @@ -574,38 +699,68 @@ impl PolicyEngine { /// Return the IDs of all products whose status is `Active`. pub fn get_active_products(env: Env) -> Vec { - env.storage().instance() + env.storage() + .instance() .get(&StorageKey::ActiveProducts) .unwrap_or_else(|| Vec::new(&env)) } /// Return the USDC balance held by this contract (7-decimal stroops). pub fn get_contract_balance(env: Env) -> i128 { - let usdc: Address = env.storage().instance().get(&StorageKey::UsdcToken) + let usdc: Address = env + .storage() + .instance() + .get(&StorageKey::UsdcToken) .unwrap_or_else(|| panic_with_error!(&env, Error::NotInitialized)); token::Client::new(&env, &usdc).balance(&env.current_contract_address()) } /// Return the current admin address. Panics with `NotInitialized` if not set up. pub fn get_admin(env: Env) -> Address { - env.storage().instance().get(&StorageKey::Admin) + env.storage() + .instance() + .get(&StorageKey::Admin) .unwrap_or_else(|| panic_with_error!(&env, Error::NotInitialized)) } /// Return the configured oracle verifier contract address. pub fn get_oracle(env: Env) -> Address { - env.storage().instance().get(&StorageKey::OracleAddress) + env.storage() + .instance() + .get(&StorageKey::OracleAddress) .unwrap_or_else(|| panic_with_error!(&env, Error::NotInitialized)) } /// Return `true` if the contract is currently in emergency-pause mode. pub fn is_paused(env: Env) -> bool { - env.storage().instance().get(&StorageKey::Paused).unwrap_or(false) + env.storage() + .instance() + .get(&StorageKey::Paused) + .unwrap_or(false) } /// Return the current storage schema version (defaults to 1 before any migration). pub fn get_version(env: Env) -> u32 { - env.storage().instance().get(&StorageKey::Version).unwrap_or(1) + env.storage() + .instance() + .get(&StorageKey::Version) + .unwrap_or(1) + } + + /// Return the configured maximum number of non-deprecated products per risk pool/category. + pub fn get_max_products_per_pool(env: Env) -> u32 { + env.storage() + .instance() + .get(&StorageKey::MaxProductsPerPool) + .unwrap_or(DEFAULT_MAX_PRODUCTS_PER_POOL) + } + + /// Return the current non-deprecated product count for a pool/category. + pub fn get_pool_product_count(env: Env, category: Symbol) -> u32 { + env.storage() + .persistent() + .get(&StorageKey::PoolProductCount(category)) + .unwrap_or(0) } // ── Admin: emergency controls ───────────────────────────────────────────── @@ -616,63 +771,92 @@ impl PolicyEngine { env.storage().instance().set(&StorageKey::Paused, &true); } -pub fn emergency_resume(env: Env, admin: Address) { - Self::require_admin(&env, &admin); - env.storage().instance().set(&StorageKey::Paused, &false); - } - - /// Propose a new admin. Only the current admin can call this. - pub fn propose_new_admin(env: Env, admin: Address, new_admin: Address) { - Self::require_admin(&env, &admin); - // Store the proposed admin - env.storage().instance().set(&StorageKey::PendingAdmin, &new_admin); - } - - /// Accept the proposed admin. Only the proposed admin can call this. - pub fn accept_admin(env: Env, admin: Address) { - let pending_admin: Address = env.storage().instance() - .get(&StorageKey::PendingAdmin) - .unwrap_or_else(|| panic_with_error!(&env, Error::Unauthorized)); - // Only the pending admin can accept - if pending_admin != admin { - panic_with_error!(&env, Error::Unauthorized); - } - admin.require_auth(); - let _current_admin: Address = env.storage().instance() - .get(&StorageKey::Admin) - .unwrap_or_else(|| panic_with_error!(env, Error::NotInitialized)); - // Update admin - env.storage().instance().set(&StorageKey::Admin, &admin); - // Clear the proposal - env.storage().instance().remove(&StorageKey::PendingAdmin); - // Emit event - env.events().publish( - (Symbol::new(&env, "admin_updated"),), - AdminUpdated { - new_admin: admin, - }, - ); - } + pub fn emergency_resume(env: Env, admin: Address) { + Self::require_admin(&env, &admin); + env.storage().instance().set(&StorageKey::Paused, &false); + } + + /// Propose a new admin. Only the current admin can call this. + pub fn propose_new_admin(env: Env, admin: Address, new_admin: Address) { + Self::require_admin(&env, &admin); + // Store the proposed admin + env.storage() + .instance() + .set(&StorageKey::PendingAdmin, &new_admin); + } + + /// Accept the proposed admin. Only the proposed admin can call this. + pub fn accept_admin(env: Env, admin: Address) { + let pending_admin: Address = env + .storage() + .instance() + .get(&StorageKey::PendingAdmin) + .unwrap_or_else(|| panic_with_error!(&env, Error::Unauthorized)); + // Only the pending admin can accept + if pending_admin != admin { + panic_with_error!(&env, Error::Unauthorized); + } + admin.require_auth(); + let _current_admin: Address = env + .storage() + .instance() + .get(&StorageKey::Admin) + .unwrap_or_else(|| panic_with_error!(env, Error::NotInitialized)); + // Update admin + env.storage().instance().set(&StorageKey::Admin, &admin); + // Clear the proposal + env.storage().instance().remove(&StorageKey::PendingAdmin); + // Emit event + env.events().publish( + (Symbol::new(&env, "admin_updated"),), + AdminUpdated { new_admin: admin }, + ); + } + + /// Admin-only: configure the maximum non-deprecated products per pool/category. + pub fn set_max_products_per_pool(env: Env, admin: Address, max_products: u32) { + Self::require_admin(&env, &admin); + if max_products == 0 { + panic_with_error!(&env, Error::InvalidMaxProducts); + } + env.storage() + .instance() + .set(&StorageKey::MaxProductsPerPool, &max_products); + env.events() + .publish((Symbol::new(&env, "max_products_updated"),), max_products); + } // ── Internal helpers ───────────────────────────────────────────────────── fn require_admin(env: &Env, caller: &Address) { - let admin: Address = env.storage().instance().get(&StorageKey::Admin) + let admin: Address = env + .storage() + .instance() + .get(&StorageKey::Admin) .unwrap_or_else(|| panic_with_error!(env, Error::NotInitialized)); - if *caller != admin { panic_with_error!(env, Error::Unauthorized); } + if *caller != admin { + panic_with_error!(env, Error::Unauthorized); + } caller.require_auth(); } fn require_claims_processor(env: &Env, caller: &Address) { - let cp: Address = env.storage().instance().get(&StorageKey::ClaimsProcessor) + let cp: Address = env + .storage() + .instance() + .get(&StorageKey::ClaimsProcessor) .unwrap_or_else(|| panic_with_error!(env, Error::ClaimsProcessorNotSet)); - if *caller != cp { panic_with_error!(env, Error::Unauthorized); } + if *caller != cp { + panic_with_error!(env, Error::Unauthorized); + } caller.require_auth(); } fn remove_policy_from_user(env: &Env, user: &Address, policy_id: u128) { let key = StorageKey::UserPolicies(user.clone()); - let mut user_policies: Vec = env.storage().persistent() + let mut user_policies: Vec = env + .storage() + .persistent() .get(&key) .unwrap_or_else(|| Vec::new(env)); let mut pos: Option = None; @@ -689,12 +873,16 @@ pub fn emergency_resume(env: Env, admin: Address) { } fn load_product(env: &Env, id: u128) -> InsuranceProduct { - env.storage().persistent().get(&StorageKey::Product(id)) + env.storage() + .persistent() + .get(&StorageKey::Product(id)) .unwrap_or_else(|| panic_with_error!(env, Error::ProductNotFound)) } fn load_policy(env: &Env, id: u128) -> Policy { - env.storage().persistent().get(&StorageKey::Policy(id)) + env.storage() + .persistent() + .get(&StorageKey::Policy(id)) .unwrap_or_else(|| panic_with_error!(env, Error::PolicyNotFound)) } @@ -703,25 +891,23 @@ pub fn emergency_resume(env: Env, admin: Address) { /// preventing two concurrent ledger entries from reading the same value. fn next_product_id(env: &Env) -> u128 { let mut id = 0u128; - env.storage().instance().update( - &StorageKey::NextProductId, - |v: Option| { + env.storage() + .instance() + .update(&StorageKey::NextProductId, |v: Option| { id = v.unwrap_or(1); id + 1 - }, - ); + }); id } fn next_policy_id(env: &Env) -> u128 { let mut id = 0u128; - env.storage().instance().update( - &StorageKey::NextPolicyId, - |v: Option| { + env.storage() + .instance() + .update(&StorageKey::NextPolicyId, |v: Option| { id = v.unwrap_or(1); id + 1 - }, - ); + }); id } @@ -730,20 +916,26 @@ pub fn emergency_resume(env: Env, admin: Address) { /// Runs storage migrations if the new version requires them. pub fn upgrade(env: Env, admin: Address, new_wasm_hash: BytesN<32>, new_version: u32) { Self::require_admin(&env, &admin); - let current_version: u32 = env.storage().instance().get(&StorageKey::Version).unwrap_or(1); + let current_version: u32 = env + .storage() + .instance() + .get(&StorageKey::Version) + .unwrap_or(1); if new_version <= current_version { panic!("new version must be greater than current version"); } - + // Run migrations from current_version to new_version Self::run_migrations(&env, current_version, new_version); - + // Update the stored version - env.storage().instance().set(&StorageKey::Version, &new_version); - + env.storage() + .instance() + .set(&StorageKey::Version, &new_version); + // Perform the actual WASM upgrade env.deployer().update_current_contract_wasm(new_wasm_hash); - + env.events().publish( (Symbol::new(&env, "contract_upgraded"),), ContractUpgraded { @@ -755,14 +947,41 @@ pub fn emergency_resume(env: Env, admin: Address) { /// Run storage migrations from old_version to new_version. /// Each migration function handles a specific version transition. - fn run_migrations(_env: &Env, _old_version: u32, _new_version: u32) { - // Migration from v1 to v2: No storage changes needed yet - // This is where you would add migration logic for specific version bumps - // Example: if old_version < 2 && new_version >= 2 { Self::migrate_v1_to_v2(env); } - - // Future migrations follow the pattern: - // if old_version < 3 && new_version >= 3 { Self::migrate_v2_to_v3(env); } + fn run_migrations(env: &Env, old_version: u32, new_version: u32) { + if old_version == 0 || new_version <= old_version || new_version > CURRENT_STORAGE_VERSION { + panic!("invalid migration version"); + } + + let mut version = old_version; + while version < new_version { + match version { + 1 => { + Self::migrate_v1_to_v2(env); + version = 2; + } + 2 => { + Self::migrate_v2_to_v3(env); + version = 3; + } + _ => panic!("unsupported migration path"), + } + } } + + fn migrate_v1_to_v2(env: &Env) { + if !env + .storage() + .instance() + .has(&StorageKey::MaxProductsPerPool) + { + env.storage().instance().set( + &StorageKey::MaxProductsPerPool, + &DEFAULT_MAX_PRODUCTS_PER_POOL, + ); + } + } + + fn migrate_v2_to_v3(_env: &Env) {} } #[cfg(test)] diff --git a/contracts/policy-engine/src/test.rs b/contracts/policy-engine/src/test.rs index ec44ee3..1522a42 100644 --- a/contracts/policy-engine/src/test.rs +++ b/contracts/policy-engine/src/test.rs @@ -2,44 +2,48 @@ use super::*; use soroban_sdk::{ symbol_short, testutils::{Address as _, Ledger}, - token::{StellarAssetClient, Client as TokenClient}, + token::{Client as TokenClient, StellarAssetClient}, Env, }; const COVERAGE: i128 = 1_000_000_000; // 100 USDC (7-decimal) -// const PREMIUM: i128 = 50_000_000; // 5 USDC (5% rate) - now computed with duration + // const PREMIUM: i128 = 50_000_000; // 5 USDC (5% rate) - now computed with duration fn setup() -> (Env, Address, Address, Address, Address) { let env = Env::default(); env.mock_all_auths(); - let admin = Address::generate(&env); + let admin = Address::generate(&env); let oracle = Address::generate(&env); // Deploy a real SAC test token for USDC - let usdc = env.register_stellar_asset_contract_v2(admin.clone()).address(); + let usdc = env + .register_stellar_asset_contract_v2(admin.clone()) + .address(); let contract_id = env.register(PolicyEngine, ()); - PolicyEngineClient::new(&env, &contract_id) - .initialize(&admin, &usdc, &oracle); + PolicyEngineClient::new(&env, &contract_id).initialize(&admin, &usdc, &oracle); (env, admin, oracle, usdc, contract_id) } fn create_crop_product(_env: &Env, client: &PolicyEngineClient, admin: &Address) -> u128 { - client.create_product(admin, &CreateProductParams { - name: symbol_short!("crop_kism"), - category: symbol_short!("crop"), - oracle_key: symbol_short!("kis2606"), - trigger_type: TriggerType::Threshold, - oracle_data_type: symbol_short!("weather"), - trigger_threshold: 50_000_000, - trigger_comparison: TriggerComparison::LessThan, - coverage_min: 100_000_000, - coverage_max: 10_000_000_000, - premium_rate_bps: 500, - max_duration_days: 365, - }) + client.create_product( + admin, + &CreateProductParams { + name: symbol_short!("crop_kism"), + category: symbol_short!("crop"), + oracle_key: symbol_short!("kis2606"), + trigger_type: TriggerType::Threshold, + oracle_data_type: symbol_short!("weather"), + trigger_threshold: 50_000_000, + trigger_comparison: TriggerComparison::LessThan, + coverage_min: 100_000_000, + coverage_max: 10_000_000_000, + premium_rate_bps: 500, + max_duration_days: 365, + }, + ) } // ── Initialization ──────────────────────────────────────────────────────────── @@ -58,12 +62,18 @@ fn test_initialize_accepts_any_address_type() { let env = Env::default(); env.mock_all_auths(); let admin = Address::generate(&env); - let usdc = env.register_stellar_asset_contract_v2(Address::generate(&env)).address(); - let oracle = env.register_stellar_asset_contract_v2(Address::generate(&env)).address(); + let usdc = env + .register_stellar_asset_contract_v2(Address::generate(&env)) + .address(); + let oracle = env + .register_stellar_asset_contract_v2(Address::generate(&env)) + .address(); let contract_id = env.register(PolicyEngine, ()); - PolicyEngineClient::new(&env, &contract_id) - .initialize(&admin, &usdc, &oracle); - assert_eq!(PolicyEngineClient::new(&env, &contract_id).get_admin(), admin); + PolicyEngineClient::new(&env, &contract_id).initialize(&admin, &usdc, &oracle); + assert_eq!( + PolicyEngineClient::new(&env, &contract_id).get_admin(), + admin + ); } #[test] @@ -72,14 +82,13 @@ fn test_initialize_with_non_token_usdc() { let env = Env::default(); env.mock_all_auths(); let admin = Address::generate(&env); - + // Register some random non-token contract (e.g. PolicyEngine itself) and use it as USDC let fake_usdc = env.register(PolicyEngine, ()); let oracle = env.register(PolicyEngine, ()); - + let contract_id = env.register(PolicyEngine, ()); - PolicyEngineClient::new(&env, &contract_id) - .initialize(&admin, &fake_usdc, &oracle); + PolicyEngineClient::new(&env, &contract_id).initialize(&admin, &fake_usdc, &oracle); } #[test] @@ -101,11 +110,74 @@ fn test_create_product_returns_id() { assert_eq!(products.len(), 1); } +#[test] +fn max_products_per_pool_blocks_unbounded_category_growth() { + let (env, admin, _oracle, _usdc, contract_id) = setup(); + let client = PolicyEngineClient::new(&env, &contract_id); + client.set_max_products_per_pool(&admin, &2u32); + + let mut params = CreateProductParams { + name: symbol_short!("crop_a"), + category: symbol_short!("crop"), + oracle_key: symbol_short!("kis2606"), + trigger_type: TriggerType::Threshold, + oracle_data_type: symbol_short!("weather"), + trigger_threshold: 50_000_000, + trigger_comparison: TriggerComparison::LessThan, + coverage_min: 100_000_000, + coverage_max: 10_000_000_000, + premium_rate_bps: 500, + max_duration_days: 365, + }; + + client.create_product(&admin, ¶ms); + params.name = symbol_short!("crop_b"); + params.oracle_key = symbol_short!("kis2607"); + client.create_product(&admin, ¶ms); + + assert_eq!(client.get_pool_product_count(&symbol_short!("crop")), 2); + params.name = symbol_short!("crop_c"); + params.oracle_key = symbol_short!("kis2608"); + let result = client.try_create_product(&admin, ¶ms); + assert!(result.is_err()); +} + +#[test] +fn deprecating_product_releases_pool_product_slot() { + let (env, admin, _oracle, _usdc, contract_id) = setup(); + let client = PolicyEngineClient::new(&env, &contract_id); + client.set_max_products_per_pool(&admin, &1u32); + + let mut params = CreateProductParams { + name: symbol_short!("crop_a"), + category: symbol_short!("crop"), + oracle_key: symbol_short!("kis2606"), + trigger_type: TriggerType::Threshold, + oracle_data_type: symbol_short!("weather"), + trigger_threshold: 50_000_000, + trigger_comparison: TriggerComparison::LessThan, + coverage_min: 100_000_000, + coverage_max: 10_000_000_000, + premium_rate_bps: 500, + max_duration_days: 365, + }; + + let first = client.create_product(&admin, ¶ms); + client.deprecate_product(&admin, &first); + assert_eq!(client.get_pool_product_count(&symbol_short!("crop")), 0); + + params.name = symbol_short!("crop_b"); + params.oracle_key = symbol_short!("kis2607"); + let second = client.create_product(&admin, ¶ms); + assert_eq!(second, 2); + assert_eq!(client.get_pool_product_count(&symbol_short!("crop")), 1); +} + #[test] #[should_panic(expected = "Error(Contract, #3)")] fn test_non_admin_cannot_create_product() { let (env, _admin, _oracle, _usdc, contract_id) = setup(); - let client = PolicyEngineClient::new(&env, &contract_id); + let client = PolicyEngineClient::new(&env, &contract_id); let impostor = Address::generate(&env); create_crop_product(&env, &client, &impostor); } @@ -129,7 +201,7 @@ fn test_pause_product_blocks_purchase() { fn test_buy_policy_transfers_premium() { let (env, admin, _oracle, usdc, contract_id) = setup(); let client = PolicyEngineClient::new(&env, &contract_id); - let pid = create_crop_product(&env, &client, &admin); + let pid = create_crop_product(&env, &client, &admin); let buyer = Address::generate(&env); StellarAssetClient::new(&env, &usdc).mint(&buyer, &1_000_000_000i128); @@ -145,10 +217,16 @@ fn test_buy_policy_transfers_premium() { .expect("div by zero") .checked_div(10_000) .expect("div by zero"); - client.buy_policy(&buyer, &pid, &COVERAGE, &duration_days, &symbol_short!("kis2606")); + client.buy_policy( + &buyer, + &pid, + &COVERAGE, + &duration_days, + &symbol_short!("kis2606"), + ); - let buyer_after = TokenClient::new(&env, &usdc).balance(&buyer); - let contract_bal = client.get_contract_balance(); + let buyer_after = TokenClient::new(&env, &usdc).balance(&buyer); + let contract_bal = client.get_contract_balance(); assert_eq!(buyer_before - buyer_after, expected_premium); assert_eq!(contract_bal, expected_premium); @@ -158,7 +236,7 @@ fn test_buy_policy_transfers_premium() { fn test_buy_policy_records_correct_fields() { let (env, admin, _oracle, usdc, contract_id) = setup(); let client = PolicyEngineClient::new(&env, &contract_id); - let pid = create_crop_product(&env, &client, &admin); + let pid = create_crop_product(&env, &client, &admin); let buyer = Address::generate(&env); StellarAssetClient::new(&env, &usdc).mint(&buyer, &1_000_000_000i128); @@ -176,21 +254,30 @@ fn test_buy_policy_records_correct_fields() { env.ledger().with_mut(|l| l.timestamp = 1_748_736_000); // fixed timestamp - let policy_id = client.buy_policy(&buyer, &pid, &COVERAGE, &duration_days, &symbol_short!("kis2606")); - let policy = client.get_policy(&policy_id); + let policy_id = client.buy_policy( + &buyer, + &pid, + &COVERAGE, + &duration_days, + &symbol_short!("kis2606"), + ); + let policy = client.get_policy(&policy_id); assert_eq!(policy.policyholder, buyer); assert_eq!(policy.coverage_amount, COVERAGE); assert_eq!(policy.premium_paid, expected_premium); assert_eq!(policy.status, PolicyStatus::Active); - assert_eq!(policy.end_time, 1_748_736_000u64 + (duration_days as u64) * 86_400); + assert_eq!( + policy.end_time, + 1_748_736_000u64 + (duration_days as u64) * 86_400 + ); } #[test] fn test_buy_policy_appears_in_user_list() { let (env, admin, _oracle, usdc, contract_id) = setup(); let client = PolicyEngineClient::new(&env, &contract_id); - let pid = create_crop_product(&env, &client, &admin); + let pid = create_crop_product(&env, &client, &admin); let buyer = Address::generate(&env); StellarAssetClient::new(&env, &usdc).mint(&buyer, &5_000_000_000i128); @@ -207,12 +294,18 @@ fn test_buy_policy_appears_in_user_list() { fn test_coverage_below_min_panics() { let (env, admin, _oracle, usdc, contract_id) = setup(); let client = PolicyEngineClient::new(&env, &contract_id); - let pid = create_crop_product(&env, &client, &admin); + let pid = create_crop_product(&env, &client, &admin); let buyer = Address::generate(&env); StellarAssetClient::new(&env, &usdc).mint(&buyer, &1_000_000_000i128); // coverage_min is 100_000_000; send 50_000_000 (5 USDC) — should panic - client.buy_policy(&buyer, &pid, &50_000_000i128, &30u32, &symbol_short!("kis2606")); + client.buy_policy( + &buyer, + &pid, + &50_000_000i128, + &30u32, + &symbol_short!("kis2606"), + ); } #[test] @@ -220,12 +313,18 @@ fn test_coverage_below_min_panics() { fn test_coverage_above_max_panics() { let (env, admin, _oracle, usdc, contract_id) = setup(); let client = PolicyEngineClient::new(&env, &contract_id); - let pid = create_crop_product(&env, &client, &admin); + let pid = create_crop_product(&env, &client, &admin); let buyer = Address::generate(&env); StellarAssetClient::new(&env, &usdc).mint(&buyer, &100_000_000_000i128); // coverage_max is 10_000_000_000; send 10_000_000_001 — should panic - client.buy_policy(&buyer, &pid, &10_000_000_001i128, &30u32, &symbol_short!("kis2606")); + client.buy_policy( + &buyer, + &pid, + &10_000_000_001i128, + &30u32, + &symbol_short!("kis2606"), + ); } #[test] @@ -233,7 +332,7 @@ fn test_coverage_above_max_panics() { fn test_duration_above_max_panics() { let (env, admin, _oracle, usdc, contract_id) = setup(); let client = PolicyEngineClient::new(&env, &contract_id); - let pid = create_crop_product(&env, &client, &admin); + let pid = create_crop_product(&env, &client, &admin); let buyer = Address::generate(&env); StellarAssetClient::new(&env, &usdc).mint(&buyer, &1_000_000_000i128); @@ -249,19 +348,22 @@ fn test_duration_above_max_panics() { fn test_create_product_zero_duration_panics() { let (env, admin, _oracle, _usdc, contract_id) = setup(); let client = PolicyEngineClient::new(&env, &contract_id); - client.create_product(&admin, &CreateProductParams { - name: symbol_short!("bad"), - category: symbol_short!("crop"), - oracle_key: symbol_short!("kis2606"), - trigger_type: TriggerType::Threshold, - oracle_data_type: symbol_short!("weather"), - trigger_threshold: 50_000_000, - trigger_comparison: TriggerComparison::LessThan, - coverage_min: 100_000_000, - coverage_max: 10_000_000_000, - premium_rate_bps: 500, - max_duration_days: 0, // ← invalid: zero - }); + client.create_product( + &admin, + &CreateProductParams { + name: symbol_short!("bad"), + category: symbol_short!("crop"), + oracle_key: symbol_short!("kis2606"), + trigger_type: TriggerType::Threshold, + oracle_data_type: symbol_short!("weather"), + trigger_threshold: 50_000_000, + trigger_comparison: TriggerComparison::LessThan, + coverage_min: 100_000_000, + coverage_max: 10_000_000_000, + premium_rate_bps: 500, + max_duration_days: 0, // ← invalid: zero + }, + ); } /// max_duration_days > 3650 (10 years) must be rejected with InvalidDurationRange (#19). @@ -270,19 +372,22 @@ fn test_create_product_zero_duration_panics() { fn test_create_product_duration_too_long_panics() { let (env, admin, _oracle, _usdc, contract_id) = setup(); let client = PolicyEngineClient::new(&env, &contract_id); - client.create_product(&admin, &CreateProductParams { - name: symbol_short!("bad"), - category: symbol_short!("crop"), - oracle_key: symbol_short!("kis2606"), - trigger_type: TriggerType::Threshold, - oracle_data_type: symbol_short!("weather"), - trigger_threshold: 50_000_000, - trigger_comparison: TriggerComparison::LessThan, - coverage_min: 100_000_000, - coverage_max: 10_000_000_000, - premium_rate_bps: 500, - max_duration_days: 3651, // ← invalid: exceeds 10 years - }); + client.create_product( + &admin, + &CreateProductParams { + name: symbol_short!("bad"), + category: symbol_short!("crop"), + oracle_key: symbol_short!("kis2606"), + trigger_type: TriggerType::Threshold, + oracle_data_type: symbol_short!("weather"), + trigger_threshold: 50_000_000, + trigger_comparison: TriggerComparison::LessThan, + coverage_min: 100_000_000, + coverage_max: 10_000_000_000, + premium_rate_bps: 500, + max_duration_days: 3651, // ← invalid: exceeds 10 years + }, + ); } /// Valid max_duration_days values (1-3650) should be accepted. @@ -292,35 +397,41 @@ fn test_create_product_valid_duration_succeeds() { let client = PolicyEngineClient::new(&env, &contract_id); // Test minimum valid duration (1 day) - let id1 = client.create_product(&admin, &CreateProductParams { - name: symbol_short!("min"), - category: symbol_short!("crop"), - oracle_key: symbol_short!("kis2606"), - trigger_type: TriggerType::Threshold, - oracle_data_type: symbol_short!("weather"), - trigger_threshold: 50_000_000, - trigger_comparison: TriggerComparison::LessThan, - coverage_min: 100_000_000, - coverage_max: 10_000_000_000, - premium_rate_bps: 500, - max_duration_days: 1, - }); + let id1 = client.create_product( + &admin, + &CreateProductParams { + name: symbol_short!("min"), + category: symbol_short!("crop"), + oracle_key: symbol_short!("kis2606"), + trigger_type: TriggerType::Threshold, + oracle_data_type: symbol_short!("weather"), + trigger_threshold: 50_000_000, + trigger_comparison: TriggerComparison::LessThan, + coverage_min: 100_000_000, + coverage_max: 10_000_000_000, + premium_rate_bps: 500, + max_duration_days: 1, + }, + ); assert_eq!(id1, 1); // Test maximum valid duration (3650 days = 10 years) - let id2 = client.create_product(&admin, &CreateProductParams { - name: symbol_short!("max"), - category: symbol_short!("crop"), - oracle_key: symbol_short!("kis2607"), - trigger_type: TriggerType::Threshold, - oracle_data_type: symbol_short!("weather"), - trigger_threshold: 50_000_000, - trigger_comparison: TriggerComparison::LessThan, - coverage_min: 100_000_000, - coverage_max: 10_000_000_000, - premium_rate_bps: 500, - max_duration_days: 3650, - }); + let id2 = client.create_product( + &admin, + &CreateProductParams { + name: symbol_short!("max"), + category: symbol_short!("crop"), + oracle_key: symbol_short!("kis2607"), + trigger_type: TriggerType::Threshold, + oracle_data_type: symbol_short!("weather"), + trigger_threshold: 50_000_000, + trigger_comparison: TriggerComparison::LessThan, + coverage_min: 100_000_000, + coverage_max: 10_000_000_000, + premium_rate_bps: 500, + max_duration_days: 3650, + }, + ); assert_eq!(id2, 2); } @@ -330,7 +441,7 @@ fn test_create_product_valid_duration_succeeds() { fn test_cancel_policy_refunds_premium() { let (env, admin, _oracle, usdc, contract_id) = setup(); let client = PolicyEngineClient::new(&env, &contract_id); - let pid = create_crop_product(&env, &client, &admin); + let pid = create_crop_product(&env, &client, &admin); let buyer = Address::generate(&env); StellarAssetClient::new(&env, &usdc).mint(&buyer, &1_000_000_000i128); @@ -341,7 +452,10 @@ fn test_cancel_policy_refunds_premium() { let buyer_after = TokenClient::new(&env, &usdc).balance(&buyer); assert_eq!(buyer_after, buyer_before); // premium returned in full - assert_eq!(client.get_policy(&policy_id).status, PolicyStatus::Cancelled); + assert_eq!( + client.get_policy(&policy_id).status, + PolicyStatus::Cancelled + ); } /// Cancelling after the full coverage window has elapsed refunds nothing — @@ -350,7 +464,7 @@ fn test_cancel_policy_refunds_premium() { fn test_cancel_after_full_duration_refunds_nothing() { let (env, admin, _oracle, usdc, contract_id) = setup(); let client = PolicyEngineClient::new(&env, &contract_id); - let pid = create_crop_product(&env, &client, &admin); + let pid = create_crop_product(&env, &client, &admin); let buyer = Address::generate(&env); StellarAssetClient::new(&env, &usdc).mint(&buyer, &1_000_000_000i128); @@ -364,16 +478,19 @@ fn test_cancel_after_full_duration_refunds_nothing() { let refund = client.cancel_policy(&buyer, &policy_id); assert_eq!(refund, 0, "fully-elapsed policy must refund nothing"); - assert_eq!(client.get_policy(&policy_id).status, PolicyStatus::Cancelled); + assert_eq!( + client.get_policy(&policy_id).status, + PolicyStatus::Cancelled + ); } #[test] #[should_panic(expected = "Error(Contract, #3)")] fn test_non_policyholder_cannot_cancel() { let (env, admin, _oracle, usdc, contract_id) = setup(); - let client = PolicyEngineClient::new(&env, &contract_id); - let pid = create_crop_product(&env, &client, &admin); - let buyer = Address::generate(&env); + let client = PolicyEngineClient::new(&env, &contract_id); + let pid = create_crop_product(&env, &client, &admin); + let buyer = Address::generate(&env); let impostor = Address::generate(&env); StellarAssetClient::new(&env, &usdc).mint(&buyer, &1_000_000_000i128); let policy_id = client.buy_policy(&buyer, &pid, &COVERAGE, &30u32, &symbol_short!("kis2606")); @@ -385,10 +502,10 @@ fn test_non_policyholder_cannot_cancel() { #[test] fn test_pay_claim_transfers_usdc() { let (env, admin, _oracle, usdc, contract_id) = setup(); - let client = PolicyEngineClient::new(&env, &contract_id); - let pid = create_crop_product(&env, &client, &admin); - let buyer = Address::generate(&env); - + let client = PolicyEngineClient::new(&env, &contract_id); + let pid = create_crop_product(&env, &client, &admin); + let buyer = Address::generate(&env); + // Buyer needs initial funds to buy policy StellarAssetClient::new(&env, &usdc).mint(&buyer, &10_000_000_000i128); // Contract needs funds to pay out the coverage @@ -398,7 +515,7 @@ fn test_pay_claim_transfers_usdc() { client.set_claims_processor(&admin, &claims_processor); let policy_id = client.buy_policy(&buyer, &pid, &COVERAGE, &30u32, &symbol_short!("kis2606")); - + let buyer_balance_before = TokenClient::new(&env, &usdc).balance(&buyer); let contract_balance_before = TokenClient::new(&env, &usdc).balance(&contract_id); @@ -410,7 +527,7 @@ fn test_pay_claim_transfers_usdc() { // Verify USDC was transferred from contract to buyer (policyholder) assert_eq!(buyer_balance_after - buyer_balance_before, COVERAGE); assert_eq!(contract_balance_before - contract_balance_after, COVERAGE); - + // Verify status updated let policy = client.get_policy(&policy_id); assert_eq!(policy.status, PolicyStatus::Claimed); @@ -422,9 +539,9 @@ fn test_pay_claim_transfers_usdc() { #[should_panic(expected = "Error(Contract, #11)")] fn test_double_pay_claim_panics_with_already_claimed() { let (env, admin, _oracle, usdc, contract_id) = setup(); - let client = PolicyEngineClient::new(&env, &contract_id); - let pid = create_crop_product(&env, &client, &admin); - let buyer = Address::generate(&env); + let client = PolicyEngineClient::new(&env, &contract_id); + let pid = create_crop_product(&env, &client, &admin); + let buyer = Address::generate(&env); StellarAssetClient::new(&env, &usdc).mint(&buyer, &10_000_000_000i128); // Pre-fund the contract with coverage capital StellarAssetClient::new(&env, &usdc).mint(&contract_id, &10_000_000_000i128); @@ -445,9 +562,9 @@ fn test_double_pay_claim_panics_with_already_claimed() { #[should_panic(expected = "Error(Contract, #12)")] fn test_double_expire_policy_panics_with_already_expired() { let (env, admin, _oracle, usdc, contract_id) = setup(); - let client = PolicyEngineClient::new(&env, &contract_id); - let pid = create_crop_product(&env, &client, &admin); - let buyer = Address::generate(&env); + let client = PolicyEngineClient::new(&env, &contract_id); + let pid = create_crop_product(&env, &client, &admin); + let buyer = Address::generate(&env); StellarAssetClient::new(&env, &usdc).mint(&buyer, &10_000_000_000i128); let claims_processor = Address::generate(&env); @@ -469,11 +586,17 @@ fn test_double_expire_policy_panics_with_already_expired() { fn test_buy_policy_without_funds_panics() { let (env, admin, _oracle, _usdc, contract_id) = setup(); let client = PolicyEngineClient::new(&env, &contract_id); - let pid = create_crop_product(&env, &client, &admin); + let pid = create_crop_product(&env, &client, &admin); // Buyer has zero USDC — no mint, no approval let broke_buyer = Address::generate(&env); - client.buy_policy(&broke_buyer, &pid, &COVERAGE, &30u32, &symbol_short!("kis2606")); + client.buy_policy( + &broke_buyer, + &pid, + &COVERAGE, + &30u32, + &symbol_short!("kis2606"), + ); } // ── trigger_threshold bounds checking (Issue #4) ────────────────────────────── @@ -484,19 +607,22 @@ fn test_buy_policy_without_funds_panics() { fn test_create_product_zero_threshold_panics() { let (env, admin, _oracle, _usdc, contract_id) = setup(); let client = PolicyEngineClient::new(&env, &contract_id); - client.create_product(&admin, &CreateProductParams { - name: symbol_short!("bad"), - category: symbol_short!("crop"), - oracle_key: symbol_short!("kis2606"), - trigger_type: TriggerType::Threshold, - oracle_data_type: symbol_short!("weather"), - trigger_threshold: 0i128, // ← invalid: zero - trigger_comparison: TriggerComparison::LessThan, - coverage_min: 100_000_000, - coverage_max: 10_000_000_000, - premium_rate_bps: 500, - max_duration_days: 365, - }); + client.create_product( + &admin, + &CreateProductParams { + name: symbol_short!("bad"), + category: symbol_short!("crop"), + oracle_key: symbol_short!("kis2606"), + trigger_type: TriggerType::Threshold, + oracle_data_type: symbol_short!("weather"), + trigger_threshold: 0i128, // ← invalid: zero + trigger_comparison: TriggerComparison::LessThan, + coverage_min: 100_000_000, + coverage_max: 10_000_000_000, + premium_rate_bps: 500, + max_duration_days: 365, + }, + ); } /// Negative trigger_threshold must be rejected with InvalidTriggerThreshold (#14). @@ -505,19 +631,22 @@ fn test_create_product_zero_threshold_panics() { fn test_create_product_negative_threshold_panics() { let (env, admin, _oracle, _usdc, contract_id) = setup(); let client = PolicyEngineClient::new(&env, &contract_id); - client.create_product(&admin, &CreateProductParams { - name: symbol_short!("bad"), - category: symbol_short!("crop"), - oracle_key: symbol_short!("kis2606"), - trigger_type: TriggerType::Threshold, - oracle_data_type: symbol_short!("weather"), - trigger_threshold: -1i128, // ← invalid: negative - trigger_comparison: TriggerComparison::LessThan, - coverage_min: 100_000_000, - coverage_max: 10_000_000_000, - premium_rate_bps: 500, - max_duration_days: 365, - }); + client.create_product( + &admin, + &CreateProductParams { + name: symbol_short!("bad"), + category: symbol_short!("crop"), + oracle_key: symbol_short!("kis2606"), + trigger_type: TriggerType::Threshold, + oracle_data_type: symbol_short!("weather"), + trigger_threshold: -1i128, // ← invalid: negative + trigger_comparison: TriggerComparison::LessThan, + coverage_min: 100_000_000, + coverage_max: 10_000_000_000, + premium_rate_bps: 500, + max_duration_days: 365, + }, + ); } /// Absurdly large trigger_threshold must be rejected with InvalidTriggerThreshold (#14). @@ -526,19 +655,22 @@ fn test_create_product_negative_threshold_panics() { fn test_create_product_overflow_threshold_panics() { let (env, admin, _oracle, _usdc, contract_id) = setup(); let client = PolicyEngineClient::new(&env, &contract_id); - client.create_product(&admin, &CreateProductParams { - name: symbol_short!("bad"), - category: symbol_short!("crop"), - oracle_key: symbol_short!("kis2606"), - trigger_type: TriggerType::Threshold, - oracle_data_type: symbol_short!("weather"), - trigger_threshold: i128::MAX, // ← invalid: overflows protocol range - trigger_comparison: TriggerComparison::LessThan, - coverage_min: 100_000_000, - coverage_max: 10_000_000_000, - premium_rate_bps: 500, - max_duration_days: 365, - }); + client.create_product( + &admin, + &CreateProductParams { + name: symbol_short!("bad"), + category: symbol_short!("crop"), + oracle_key: symbol_short!("kis2606"), + trigger_type: TriggerType::Threshold, + oracle_data_type: symbol_short!("weather"), + trigger_threshold: i128::MAX, // ← invalid: overflows protocol range + trigger_comparison: TriggerComparison::LessThan, + coverage_min: 100_000_000, + coverage_max: 10_000_000_000, + premium_rate_bps: 500, + max_duration_days: 365, + }, + ); } // ── Product category/oracle_key uniqueness (Issue #10) ─────────────────────────── @@ -551,34 +683,40 @@ fn test_duplicate_category_oracle_key_panics() { let client = PolicyEngineClient::new(&env, &contract_id); // Create product A with (category: "crop", oracle_key: "kis2606") - client.create_product(&admin, &CreateProductParams { - name: symbol_short!("crop_k_a"), - category: symbol_short!("crop"), - oracle_key: symbol_short!("kis2606"), - trigger_type: TriggerType::Threshold, - oracle_data_type: symbol_short!("weather"), - trigger_threshold: 50_000_000, - trigger_comparison: TriggerComparison::LessThan, - coverage_min: 100_000_000, - coverage_max: 10_000_000_000, - premium_rate_bps: 500, - max_duration_days: 365, - }); + client.create_product( + &admin, + &CreateProductParams { + name: symbol_short!("crop_k_a"), + category: symbol_short!("crop"), + oracle_key: symbol_short!("kis2606"), + trigger_type: TriggerType::Threshold, + oracle_data_type: symbol_short!("weather"), + trigger_threshold: 50_000_000, + trigger_comparison: TriggerComparison::LessThan, + coverage_min: 100_000_000, + coverage_max: 10_000_000_000, + premium_rate_bps: 500, + max_duration_days: 365, + }, + ); // Attempt to create product B with same (category, oracle_key) — should panic - client.create_product(&admin, &CreateProductParams { - name: symbol_short!("crop_k_b"), - category: symbol_short!("crop"), - oracle_key: symbol_short!("kis2606"), // ← duplicate key - trigger_type: TriggerType::Threshold, - oracle_data_type: symbol_short!("weather"), - trigger_threshold: 60_000_000, - trigger_comparison: TriggerComparison::LessThan, - coverage_min: 100_000_000, - coverage_max: 10_000_000_000, - premium_rate_bps: 600, - max_duration_days: 365, - }); + client.create_product( + &admin, + &CreateProductParams { + name: symbol_short!("crop_k_b"), + category: symbol_short!("crop"), + oracle_key: symbol_short!("kis2606"), // ← duplicate key + trigger_type: TriggerType::Threshold, + oracle_data_type: symbol_short!("weather"), + trigger_threshold: 60_000_000, + trigger_comparison: TriggerComparison::LessThan, + coverage_min: 100_000_000, + coverage_max: 10_000_000_000, + premium_rate_bps: 600, + max_duration_days: 365, + }, + ); } /// Creating products with different oracle_keys but same category should succeed. @@ -588,34 +726,40 @@ fn test_different_oracle_keys_same_category_succeeds() { let client = PolicyEngineClient::new(&env, &contract_id); // Create product A with (category: "crop", oracle_key: "kis2606") - client.create_product(&admin, &CreateProductParams { - name: symbol_short!("crop_kis"), - category: symbol_short!("crop"), - oracle_key: symbol_short!("kis2606"), - trigger_type: TriggerType::Threshold, - oracle_data_type: symbol_short!("weather"), - trigger_threshold: 50_000_000, - trigger_comparison: TriggerComparison::LessThan, - coverage_min: 100_000_000, - coverage_max: 10_000_000_000, - premium_rate_bps: 500, - max_duration_days: 365, - }); + client.create_product( + &admin, + &CreateProductParams { + name: symbol_short!("crop_kis"), + category: symbol_short!("crop"), + oracle_key: symbol_short!("kis2606"), + trigger_type: TriggerType::Threshold, + oracle_data_type: symbol_short!("weather"), + trigger_threshold: 50_000_000, + trigger_comparison: TriggerComparison::LessThan, + coverage_min: 100_000_000, + coverage_max: 10_000_000_000, + premium_rate_bps: 500, + max_duration_days: 365, + }, + ); // Create product B with (category: "crop", oracle_key: "nak2607") — different key, should succeed - client.create_product(&admin, &CreateProductParams { - name: symbol_short!("crop_nak"), - category: symbol_short!("crop"), - oracle_key: symbol_short!("nak2607"), // ← different key - trigger_type: TriggerType::Threshold, - oracle_data_type: symbol_short!("weather"), - trigger_threshold: 60_000_000, - trigger_comparison: TriggerComparison::LessThan, - coverage_min: 100_000_000, - coverage_max: 10_000_000_000, - premium_rate_bps: 600, - max_duration_days: 365, - }); + client.create_product( + &admin, + &CreateProductParams { + name: symbol_short!("crop_nak"), + category: symbol_short!("crop"), + oracle_key: symbol_short!("nak2607"), // ← different key + trigger_type: TriggerType::Threshold, + oracle_data_type: symbol_short!("weather"), + trigger_threshold: 60_000_000, + trigger_comparison: TriggerComparison::LessThan, + coverage_min: 100_000_000, + coverage_max: 10_000_000_000, + premium_rate_bps: 600, + max_duration_days: 365, + }, + ); assert_eq!(client.get_active_products().len(), 2); } @@ -627,37 +771,43 @@ fn test_deprecated_product_key_can_be_reused() { let client = PolicyEngineClient::new(&env, &contract_id); // Create product A with (category: "crop", oracle_key: "kis2606") - let product_a_id = client.create_product(&admin, &CreateProductParams { - name: symbol_short!("crop_k_v1"), - category: symbol_short!("crop"), - oracle_key: symbol_short!("kis2606"), - trigger_type: TriggerType::Threshold, - oracle_data_type: symbol_short!("weather"), - trigger_threshold: 50_000_000, - trigger_comparison: TriggerComparison::LessThan, - coverage_min: 100_000_000, - coverage_max: 10_000_000_000, - premium_rate_bps: 500, - max_duration_days: 365, - }); + let product_a_id = client.create_product( + &admin, + &CreateProductParams { + name: symbol_short!("crop_k_v1"), + category: symbol_short!("crop"), + oracle_key: symbol_short!("kis2606"), + trigger_type: TriggerType::Threshold, + oracle_data_type: symbol_short!("weather"), + trigger_threshold: 50_000_000, + trigger_comparison: TriggerComparison::LessThan, + coverage_min: 100_000_000, + coverage_max: 10_000_000_000, + premium_rate_bps: 500, + max_duration_days: 365, + }, + ); // Deprecate product A client.deprecate_product(&admin, &product_a_id); // Create product B with same (category, oracle_key) — should succeed after deprecation - client.create_product(&admin, &CreateProductParams { - name: symbol_short!("crop_k_v2"), - category: symbol_short!("crop"), - oracle_key: symbol_short!("kis2606"), // ← reused key - trigger_type: TriggerType::Threshold, - oracle_data_type: symbol_short!("weather"), - trigger_threshold: 60_000_000, - trigger_comparison: TriggerComparison::LessThan, - coverage_min: 100_000_000, - coverage_max: 10_000_000_000, - premium_rate_bps: 600, - max_duration_days: 365, - }); + client.create_product( + &admin, + &CreateProductParams { + name: symbol_short!("crop_k_v2"), + category: symbol_short!("crop"), + oracle_key: symbol_short!("kis2606"), // ← reused key + trigger_type: TriggerType::Threshold, + oracle_data_type: symbol_short!("weather"), + trigger_threshold: 60_000_000, + trigger_comparison: TriggerComparison::LessThan, + coverage_min: 100_000_000, + coverage_max: 10_000_000_000, + premium_rate_bps: 600, + max_duration_days: 365, + }, + ); assert_eq!(client.get_active_products().len(), 1); } @@ -683,10 +833,16 @@ fn test_premium_matches_formula() { .expect("div by zero"); let amount = expected_premium + 1_000_000_000i128; StellarAssetClient::new(&env, &usdc).mint(&buyer, &amount); - + let buyer_before = TokenClient::new(&env, &usdc).balance(&buyer); - client.buy_policy(&buyer, &pid, &coverage, &duration_days, &symbol_short!("kis2606")); + client.buy_policy( + &buyer, + &pid, + &coverage, + &duration_days, + &symbol_short!("kis2606"), + ); let buyer_after = TokenClient::new(&env, &usdc).balance(&buyer); let contract_bal = client.get_contract_balance(); @@ -707,19 +863,22 @@ fn test_premium_matches_formula() { fn test_create_product_zero_premium_rate_panics() { let (env, admin, _oracle, _usdc, contract_id) = setup(); let client = PolicyEngineClient::new(&env, &contract_id); - client.create_product(&admin, &CreateProductParams { - name: symbol_short!("free_pol"), - category: symbol_short!("crop"), - oracle_key: symbol_short!("kis2606"), - trigger_type: TriggerType::Threshold, - oracle_data_type: symbol_short!("weather"), - trigger_threshold: 50_000_000, - trigger_comparison: TriggerComparison::LessThan, - coverage_min: 100_000_000, - coverage_max: 10_000_000_000, - premium_rate_bps: 0, // ← zero premium: must be rejected - max_duration_days: 365, - }); + client.create_product( + &admin, + &CreateProductParams { + name: symbol_short!("free_pol"), + category: symbol_short!("crop"), + oracle_key: symbol_short!("kis2606"), + trigger_type: TriggerType::Threshold, + oracle_data_type: symbol_short!("weather"), + trigger_threshold: 50_000_000, + trigger_comparison: TriggerComparison::LessThan, + coverage_min: 100_000_000, + coverage_max: 10_000_000_000, + premium_rate_bps: 0, // ← zero premium: must be rejected + max_duration_days: 365, + }, + ); } // ── Issue #58: atomic product ID generation ──────────────────────────────── @@ -730,30 +889,42 @@ fn sequential_create_product_ids_are_unique_and_monotone() { let client = PolicyEngineClient::new(&env, &contract_id); let params = |n: u32| CreateProductParams { - name: symbol_short!("prod"), - category: symbol_short!("crop"), - oracle_key: symbol_short!("key"), - trigger_type: TriggerType::Threshold, - oracle_data_type: symbol_short!("weather"), - trigger_threshold: 50_000_000, + name: symbol_short!("prod"), + category: symbol_short!("crop"), + oracle_key: symbol_short!("key"), + trigger_type: TriggerType::Threshold, + oracle_data_type: symbol_short!("weather"), + trigger_threshold: 50_000_000, trigger_comparison: TriggerComparison::LessThan, - coverage_min: 100_000_000, - coverage_max: 10_000_000_000, + coverage_min: 100_000_000, + coverage_max: 10_000_000_000, // Each call needs a distinct (category, oracle_key) pair to avoid DuplicateProductKey - premium_rate_bps: 500 + n, - max_duration_days: 365, + premium_rate_bps: 500 + n, + max_duration_days: 365, }; // Use distinct oracle_key per product to avoid DuplicateProductKey error - let id1 = client.create_product(&admin, &CreateProductParams { - oracle_key: symbol_short!("k1"), ..params(0) - }); - let id2 = client.create_product(&admin, &CreateProductParams { - oracle_key: symbol_short!("k2"), ..params(1) - }); - let id3 = client.create_product(&admin, &CreateProductParams { - oracle_key: symbol_short!("k3"), ..params(2) - }); + let id1 = client.create_product( + &admin, + &CreateProductParams { + oracle_key: symbol_short!("k1"), + ..params(0) + }, + ); + let id2 = client.create_product( + &admin, + &CreateProductParams { + oracle_key: symbol_short!("k2"), + ..params(1) + }, + ); + let id3 = client.create_product( + &admin, + &CreateProductParams { + oracle_key: symbol_short!("k3"), + ..params(2) + }, + ); // IDs must be unique assert_ne!(id1, id2); @@ -795,7 +966,7 @@ fn test_upgrade_to_lower_version_panics() { fn test_upgrade_increments_version() { let (env, admin, _oracle, _usdc, contract_id) = setup(); let client = PolicyEngineClient::new(&env, &contract_id); - + assert_eq!(client.get_version(), 1); client.upgrade(&admin, &BytesN::from_array(&env, &[0u8; 32]), &2); assert_eq!(client.get_version(), 2); @@ -822,19 +993,22 @@ fn test_multiple_upgrades_track_version_correctly() { fn test_create_product_short_oracle_key_panics() { let (env, admin, _oracle, _usdc, contract_id) = setup(); let client = PolicyEngineClient::new(&env, &contract_id); - client.create_product(&admin, &CreateProductParams { - name: symbol_short!("bad"), - category: symbol_short!("crop"), - oracle_key: symbol_short!("ab"), // ← 2 chars, below minimum - trigger_type: TriggerType::Threshold, - oracle_data_type: symbol_short!("weather"), - trigger_threshold: 50_000_000, - trigger_comparison: TriggerComparison::LessThan, - coverage_min: 100_000_000, - coverage_max: 10_000_000_000, - premium_rate_bps: 500, - max_duration_days: 365, - }); + client.create_product( + &admin, + &CreateProductParams { + name: symbol_short!("bad"), + category: symbol_short!("crop"), + oracle_key: symbol_short!("ab"), // ← 2 chars, below minimum + trigger_type: TriggerType::Threshold, + oracle_data_type: symbol_short!("weather"), + trigger_threshold: 50_000_000, + trigger_comparison: TriggerComparison::LessThan, + coverage_min: 100_000_000, + coverage_max: 10_000_000_000, + premium_rate_bps: 500, + max_duration_days: 365, + }, + ); } /// oracle_key of exactly 3 chars must be accepted. @@ -842,19 +1016,22 @@ fn test_create_product_short_oracle_key_panics() { fn test_create_product_minimum_oracle_key_succeeds() { let (env, admin, _oracle, _usdc, contract_id) = setup(); let client = PolicyEngineClient::new(&env, &contract_id); - let id = client.create_product(&admin, &CreateProductParams { - name: symbol_short!("ok"), - category: symbol_short!("crop"), - oracle_key: symbol_short!("abc"), // ← exactly 3 chars - trigger_type: TriggerType::Threshold, - oracle_data_type: symbol_short!("weather"), - trigger_threshold: 50_000_000, - trigger_comparison: TriggerComparison::LessThan, - coverage_min: 100_000_000, - coverage_max: 10_000_000_000, - premium_rate_bps: 500, - max_duration_days: 365, - }); + let id = client.create_product( + &admin, + &CreateProductParams { + name: symbol_short!("ok"), + category: symbol_short!("crop"), + oracle_key: symbol_short!("abc"), // ← exactly 3 chars + trigger_type: TriggerType::Threshold, + oracle_data_type: symbol_short!("weather"), + trigger_threshold: 50_000_000, + trigger_comparison: TriggerComparison::LessThan, + coverage_min: 100_000_000, + coverage_max: 10_000_000_000, + premium_rate_bps: 500, + max_duration_days: 365, + }, + ); assert!(id > 0); } @@ -864,17 +1041,20 @@ fn test_create_product_minimum_oracle_key_succeeds() { fn test_create_product_single_char_oracle_key_panics() { let (env, admin, _oracle, _usdc, contract_id) = setup(); let client = PolicyEngineClient::new(&env, &contract_id); - client.create_product(&admin, &CreateProductParams { - name: symbol_short!("bad"), - category: symbol_short!("crop"), - oracle_key: symbol_short!("x"), // ← 1 char - trigger_type: TriggerType::Threshold, - oracle_data_type: symbol_short!("weather"), - trigger_threshold: 50_000_000, - trigger_comparison: TriggerComparison::LessThan, - coverage_min: 100_000_000, - coverage_max: 10_000_000_000, - premium_rate_bps: 500, - max_duration_days: 365, - }); + client.create_product( + &admin, + &CreateProductParams { + name: symbol_short!("bad"), + category: symbol_short!("crop"), + oracle_key: symbol_short!("x"), // ← 1 char + trigger_type: TriggerType::Threshold, + oracle_data_type: symbol_short!("weather"), + trigger_threshold: 50_000_000, + trigger_comparison: TriggerComparison::LessThan, + coverage_min: 100_000_000, + coverage_max: 10_000_000_000, + premium_rate_bps: 500, + max_duration_days: 365, + }, + ); } diff --git a/contracts/policy-engine/src/test_advanced.rs b/contracts/policy-engine/src/test_advanced.rs index 0160742..13fe850 100644 --- a/contracts/policy-engine/src/test_advanced.rs +++ b/contracts/policy-engine/src/test_advanced.rs @@ -4,30 +4,27 @@ extern crate std; -use soroban_sdk::{ - testutils::Address as _, - token, Address, Env, -}; +use soroban_sdk::{testutils::Address as _, token, Address, Env}; use crate::{ - CreateProductParams, PolicyEngine, PolicyEngineClient, - ProductStatus, TriggerComparison, TriggerType, + CreateProductParams, PolicyEngine, PolicyEngineClient, ProductStatus, TriggerComparison, + TriggerType, }; use soroban_sdk::symbol_short; fn basic_params() -> CreateProductParams { CreateProductParams { - name: symbol_short!("crop"), - category: symbol_short!("crop"), - oracle_key: symbol_short!("kis2606"), - trigger_type: TriggerType::Threshold, - oracle_data_type: symbol_short!("weather"), - trigger_threshold: 500_000_000i128, + name: symbol_short!("crop"), + category: symbol_short!("crop"), + oracle_key: symbol_short!("kis2606"), + trigger_type: TriggerType::Threshold, + oracle_data_type: symbol_short!("weather"), + trigger_threshold: 500_000_000i128, trigger_comparison: TriggerComparison::LessThan, - coverage_min: 100_0000000i128, - coverage_max: 100_000_0000000i128, - premium_rate_bps: 300u32, - max_duration_days: 90u32, + coverage_min: 100_0000000i128, + coverage_max: 100_000_0000000i128, + premium_rate_bps: 300u32, + max_duration_days: 90u32, } } @@ -35,16 +32,18 @@ fn setup() -> (Env, PolicyEngineClient<'static>, Address, Address, Address) { let env = Env::default(); env.mock_all_auths(); - let admin = Address::generate(&env); + let admin = Address::generate(&env); let oracle = Address::generate(&env); - let user = Address::generate(&env); + let user = Address::generate(&env); - let usdc = env.register_stellar_asset_contract_v2(admin.clone()).address(); + let usdc = env + .register_stellar_asset_contract_v2(admin.clone()) + .address(); token::StellarAssetClient::new(&env, &usdc).mint(&user, &10_000_0000000i128); token::StellarAssetClient::new(&env, &usdc).mint(&admin, &1_000_000_0000000i128); let pe_id = env.register(PolicyEngine, ()); - let pe = PolicyEngineClient::new(&env, &pe_id); + let pe = PolicyEngineClient::new(&env, &pe_id); pe.initialize(&admin, &usdc, &oracle); // fund the contract for coverage payouts @@ -114,7 +113,11 @@ fn cancel_policy_returns_premium_to_holder() { let (_env, pe, admin, _, user) = setup(); let prod_id = pe.create_product(&admin, &basic_params()); let policy_id = pe.buy_policy( - &user, &prod_id, &1_000_0000000i128, &30u32, &symbol_short!("kis2606"), + &user, + &prod_id, + &1_000_0000000i128, + &30u32, + &symbol_short!("kis2606"), ); pe.cancel_policy(&user, &policy_id); let policy = pe.get_policy(&policy_id); @@ -127,17 +130,29 @@ fn cancel_policy_returns_premium_to_holder() { fn get_user_policies_tracks_multiple_policies() { let (_, pe, admin, _, user) = setup(); let prod_id = pe.create_product(&admin, &basic_params()); - pe.buy_policy(&user, &prod_id, &200_0000000i128, &30u32, &symbol_short!("kis2606")); - pe.buy_policy(&user, &prod_id, &300_0000000i128, &30u32, &symbol_short!("kis2606")); + pe.buy_policy( + &user, + &prod_id, + &200_0000000i128, + &30u32, + &symbol_short!("kis2606"), + ); + pe.buy_policy( + &user, + &prod_id, + &300_0000000i128, + &30u32, + &symbol_short!("kis2606"), + ); // Verify first page (limit 1) let p1 = pe.get_user_policies(&user, &0u32, &1u32); assert_eq!(p1.len(), 1); - + // Verify second page (limit 1, offset 1) let p2 = pe.get_user_policies(&user, &1u32, &1u32); assert_eq!(p2.len(), 1); assert_ne!(p1.get(0), p2.get(0)); - + // Verify offset out of bounds let p3 = pe.get_user_policies(&user, &2u32, &1u32); assert_eq!(p3.len(), 0); @@ -157,7 +172,11 @@ fn cancel_policy_prorates_refund_by_elapsed_time() { // Buy at t=0 with a 30-day policy. env.ledger().with_mut(|l| l.timestamp = 0); let policy_id = pe.buy_policy( - &user, &prod_id, &1_000_0000000i128, &30u32, &symbol_short!("kis2606"), + &user, + &prod_id, + &1_000_0000000i128, + &30u32, + &symbol_short!("kis2606"), ); let policy = pe.get_policy(&policy_id); @@ -175,7 +194,10 @@ fn cancel_policy_prorates_refund_by_elapsed_time() { assert!(refund > 0, "half-elapsed refund should be positive"); assert!(refund < premium, "half-elapsed refund must not be 100%"); let expected_half = premium / 2; - assert!((refund - expected_half).abs() <= 1, "refund should be ~half the premium"); + assert!( + (refund - expected_half).abs() <= 1, + "refund should be ~half the premium" + ); } /// Cancel at a known elapsed duration (10 of 30 days) and assert the exact @@ -191,7 +213,11 @@ fn cancel_policy_refund_matches_hand_calculated_value_at_known_elapsed() { env.ledger().with_mut(|l| l.timestamp = 0); let policy_id = pe.buy_policy( - &user, &prod_id, &1_000_0000000i128, &30u32, &symbol_short!("kis2606"), + &user, + &prod_id, + &1_000_0000000i128, + &30u32, + &symbol_short!("kis2606"), ); let policy = pe.get_policy(&policy_id); @@ -218,7 +244,13 @@ fn buy_policy_blocked_while_paused() { let prod_id = pe.create_product(&admin, &basic_params()); pe.emergency_pause(&admin); // Purchasing while paused must panic with Unauthorized (#3). - pe.buy_policy(&user, &prod_id, &1_000_0000000i128, &30u32, &symbol_short!("kis2606")); + pe.buy_policy( + &user, + &prod_id, + &1_000_0000000i128, + &30u32, + &symbol_short!("kis2606"), + ); } #[test] @@ -226,20 +258,26 @@ fn test_pay_claim_insufficient_funds_reverts_policy_active() { let env = Env::default(); env.mock_all_auths(); - let admin = Address::generate(&env); + let admin = Address::generate(&env); let oracle = Address::generate(&env); - let user = Address::generate(&env); + let user = Address::generate(&env); - let usdc = env.register_stellar_asset_contract_v2(admin.clone()).address(); + let usdc = env + .register_stellar_asset_contract_v2(admin.clone()) + .address(); token::StellarAssetClient::new(&env, &usdc).mint(&user, &10_000_0000000i128); let pe_id = env.register(PolicyEngine, ()); - let pe = PolicyEngineClient::new(&env, &pe_id); + let pe = PolicyEngineClient::new(&env, &pe_id); pe.initialize(&admin, &usdc, &oracle); let prod_id = pe.create_product(&admin, &basic_params()); let policy_id = pe.buy_policy( - &user, &prod_id, &1_000_0000000i128, &30u32, &symbol_short!("kis2606"), + &user, + &prod_id, + &1_000_0000000i128, + &30u32, + &symbol_short!("kis2606"), ); let claims_processor = Address::generate(&env); diff --git a/contracts/policy-engine/src/types.rs b/contracts/policy-engine/src/types.rs index 67e858a..3792be9 100644 --- a/contracts/policy-engine/src/types.rs +++ b/contracts/policy-engine/src/types.rs @@ -192,4 +192,3 @@ pub struct ContractUpgraded { pub old_version: u32, pub new_version: u32, } - diff --git a/contracts/risk-pool/src/lib.rs b/contracts/risk-pool/src/lib.rs index 2323be1..d44120a 100644 --- a/contracts/risk-pool/src/lib.rs +++ b/contracts/risk-pool/src/lib.rs @@ -15,16 +15,16 @@ extern crate alloc; use soroban_sdk::{ - contract, contractimpl, contracttype, contracterror, panic_with_error, - token, Address, Env, Symbol, Vec, + contract, contracterror, contractimpl, contracttype, panic_with_error, token, Address, Env, + Symbol, Vec, }; pub mod types; pub use types::*; -const PREMIUM_LP_BPS: i128 = 8_000; // 80% of premium to LP pool -const PREMIUM_TREAS_BPS: i128 = 1_000; // 10% to treasury -const PREMIUM_BACKSTOP_BPS: i128 = 1_000; // 10% to backstop fund +const PREMIUM_LP_BPS: i128 = 8_000; // 80% of premium to LP pool +const PREMIUM_TREAS_BPS: i128 = 1_000; // 10% to treasury +const PREMIUM_BACKSTOP_BPS: i128 = 1_000; // 10% to backstop fund const _: () = assert!(PREMIUM_LP_BPS + PREMIUM_TREAS_BPS + PREMIUM_BACKSTOP_BPS == 10_000); /// Upper bound on cumulative deposits (7-decimal USDC stroops). @@ -76,25 +76,25 @@ enum StorageKey { #[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] #[repr(u32)] pub enum Error { - AlreadyInitialized = 1, - NotInitialized = 2, - Unauthorized = 3, - InsufficientFunds = 4, - ZeroAmount = 5, - PoolNotActive = 6, - NoShares = 7, - AlreadyLocked = 8, - LockNotFound = 9, - AlreadyReleased = 10, + AlreadyInitialized = 1, + NotInitialized = 2, + Unauthorized = 3, + InsufficientFunds = 4, + ZeroAmount = 5, + PoolNotActive = 6, + NoShares = 7, + AlreadyLocked = 8, + LockNotFound = 9, + AlreadyReleased = 10, Undercollateralized = 11, - PoolCapExceeded = 12, - InvalidToken = 13, - TimelockPending = 14, - TimelockNotReady = 15, + PoolCapExceeded = 12, + InvalidToken = 13, + TimelockPending = 14, + TimelockNotReady = 15, NoPendingWithdrawal = 16, - InsufficientShares = 17, - DepositTooSmall = 18, - Overflow = 19, + InsufficientShares = 17, + DepositTooSmall = 18, + Overflow = 19, } #[contract] @@ -102,7 +102,6 @@ pub struct RiskPool; #[contractimpl] impl RiskPool { - /// One-time initialisation. Sets up the USDC token, treasury, backstop, and linked /// protocol contracts. `category` is the coverage category this pool serves (e.g. /// `"weather"`). Panics with `AlreadyInitialized` on a second call. @@ -121,7 +120,7 @@ impl RiskPool { } // Address validation is deferred to require_auth() calls which // verify the address on the Soroban network layer. - + let admin_str = admin.to_string(); if admin_str.len() != 56 { @@ -164,33 +163,61 @@ impl RiskPool { } admin.require_auth(); - env.storage().instance().set(&StorageKey::Initialized, &true); - env.storage().instance().set(&StorageKey::Admin, &admin); - env.storage().instance().set(&StorageKey::UsdcToken, &usdc_token); - env.storage().instance().set(&StorageKey::Treasury, &treasury); - env.storage().instance().set(&StorageKey::Backstop, &backstop); - env.storage().instance().set(&StorageKey::Category, &category); - env.storage().instance().set(&StorageKey::PolicyEngine, &policy_engine); - env.storage().instance().set(&StorageKey::ClaimsProcessor, &claims_processor); - env.storage().instance().set(&StorageKey::TotalDeposited, &0i128); - env.storage().instance().set(&StorageKey::TotalLocked, &0i128); - env.storage().instance().set(&StorageKey::TotalShares, &0i128); - env.storage().instance().set(&StorageKey::AccumulatedPremium, &0i128); - env.storage().instance().set(&StorageKey::AccumulatedBackstop, &0i128); - env.storage().instance().set(&StorageKey::AccumulatedPerShare, &0i128); - env.storage().instance().set(&StorageKey::Status, &PoolStatus::Active); - env.storage().instance().set(&StorageKey::LpCount, &0u32); + env.storage() + .instance() + .set(&StorageKey::Initialized, &true); + env.storage().instance().set(&StorageKey::Admin, &admin); + env.storage() + .instance() + .set(&StorageKey::UsdcToken, &usdc_token); + env.storage() + .instance() + .set(&StorageKey::Treasury, &treasury); + env.storage() + .instance() + .set(&StorageKey::Backstop, &backstop); + env.storage() + .instance() + .set(&StorageKey::Category, &category); + env.storage() + .instance() + .set(&StorageKey::PolicyEngine, &policy_engine); + env.storage() + .instance() + .set(&StorageKey::ClaimsProcessor, &claims_processor); + env.storage() + .instance() + .set(&StorageKey::TotalDeposited, &0i128); + env.storage() + .instance() + .set(&StorageKey::TotalLocked, &0i128); + env.storage() + .instance() + .set(&StorageKey::TotalShares, &0i128); + env.storage() + .instance() + .set(&StorageKey::AccumulatedPremium, &0i128); + env.storage() + .instance() + .set(&StorageKey::AccumulatedBackstop, &0i128); + env.storage() + .instance() + .set(&StorageKey::AccumulatedPerShare, &0i128); + env.storage() + .instance() + .set(&StorageKey::Status, &PoolStatus::Active); + env.storage().instance().set(&StorageKey::LpCount, &0u32); // PendingAdmin is absent until propose_new_admin is called; no init needed. env.events().publish( (Symbol::new(&env, "initialized"),), Initialized { - admin: admin.clone(), - usdc_token: usdc_token.clone(), - treasury: treasury.clone(), - backstop: backstop.clone(), - category: category.clone(), - policy_engine: policy_engine.clone(), + admin: admin.clone(), + usdc_token: usdc_token.clone(), + treasury: treasury.clone(), + backstop: backstop.clone(), + category: category.clone(), + policy_engine: policy_engine.clone(), claims_processor: claims_processor.clone(), }, ); @@ -202,14 +229,24 @@ impl RiskPool { /// `min_shares` is a slippage guard — the transaction reverts if fewer shares would be issued. pub fn deposit(env: Env, provider: Address, amount: i128, min_shares: i128) -> i128 { provider.require_auth(); - if amount <= 0 { panic_with_error!(&env, Error::ZeroAmount); } - if amount < MIN_DEPOSIT { panic_with_error!(&env, Error::DepositTooSmall); } + if amount <= 0 { + panic_with_error!(&env, Error::ZeroAmount); + } + if amount < MIN_DEPOSIT { + panic_with_error!(&env, Error::DepositTooSmall); + } Self::assert_active(&env); - let total_deposited: i128 = env.storage().instance() - .get(&StorageKey::TotalDeposited).unwrap_or(0); - let total_shares: i128 = env.storage().instance() - .get(&StorageKey::TotalShares).unwrap_or(0); + let total_deposited: i128 = env + .storage() + .instance() + .get(&StorageKey::TotalDeposited) + .unwrap_or(0); + let total_shares: i128 = env + .storage() + .instance() + .get(&StorageKey::TotalShares) + .unwrap_or(0); // Enforce the global pool size cap before accepting new liquidity. if total_deposited + amount > MAX_TOTAL_DEPOSITED { @@ -217,9 +254,10 @@ impl RiskPool { } let new_shares = if total_deposited == 0 { - amount * 1_000_000_000 // 1 share = 1 USDC * 1e9 precision + amount * 1_000_000_000 // 1 share = 1 USDC * 1e9 precision } else { - amount.checked_mul(total_shares) + amount + .checked_mul(total_shares) .and_then(|v| v.checked_div(total_deposited)) .unwrap_or_else(|| panic_with_error!(&env, Error::Overflow)) }; @@ -232,40 +270,69 @@ impl RiskPool { panic_with_error!(&env, Error::InsufficientShares); } - let usdc: Address = env.storage().instance().get(&StorageKey::UsdcToken).unwrap(); - token::Client::new(&env, &usdc) - .transfer(&provider, &env.current_contract_address(), &amount); + let usdc: Address = env + .storage() + .instance() + .get(&StorageKey::UsdcToken) + .unwrap(); + token::Client::new(&env, &usdc).transfer( + &provider, + &env.current_contract_address(), + &amount, + ); let now = env.ledger().timestamp(); let lp_key = StorageKey::LpPosition(provider.clone()); - let mut position: LpPosition = match env.storage().persistent().get::<_, LpPosition>(&lp_key) { - Some(mut pos) => { - Self::internal_claim_yield(&env, &mut pos); - pos.deposited += amount; - pos.shares += new_shares; - pos.yield_debt = (env.storage().instance().get(&StorageKey::AccumulatedPerShare).unwrap_or(0) * pos.shares) / 1_000_000_000_000; - pos - } - None => { - let count: u32 = env.storage().instance() - .get(&StorageKey::LpCount).unwrap_or(0); - env.storage().persistent().set(&StorageKey::LpAddress(count), &provider); - env.storage().instance().set(&StorageKey::LpCount, &(count + 1)); - let acc_per_share: i128 = env.storage().instance().get(&StorageKey::AccumulatedPerShare).unwrap_or(0); - LpPosition { - provider: provider.clone(), - deposited: amount, - shares: new_shares, - yield_claimed: 0, - yield_debt: (acc_per_share * new_shares) / 1_000_000_000_000, - deposited_at: now, - last_yield_claim: now, + let mut position: LpPosition = + match env.storage().persistent().get::<_, LpPosition>(&lp_key) { + Some(mut pos) => { + Self::internal_claim_yield(&env, &mut pos); + pos.deposited += amount; + pos.shares += new_shares; + pos.yield_debt = (env + .storage() + .instance() + .get(&StorageKey::AccumulatedPerShare) + .unwrap_or(0) + * pos.shares) + / 1_000_000_000_000; + pos } - } - }; + None => { + let count: u32 = env + .storage() + .instance() + .get(&StorageKey::LpCount) + .unwrap_or(0); + env.storage() + .persistent() + .set(&StorageKey::LpAddress(count), &provider); + env.storage() + .instance() + .set(&StorageKey::LpCount, &(count + 1)); + let acc_per_share: i128 = env + .storage() + .instance() + .get(&StorageKey::AccumulatedPerShare) + .unwrap_or(0); + LpPosition { + provider: provider.clone(), + deposited: amount, + shares: new_shares, + yield_claimed: 0, + yield_debt: (acc_per_share * new_shares) / 1_000_000_000_000, + deposited_at: now, + last_yield_claim: now, + } + } + }; env.storage().persistent().set(&lp_key, &position); - env.storage().instance().set(&StorageKey::TotalDeposited, &(total_deposited + amount)); - env.storage().instance().set(&StorageKey::TotalShares, &(total_shares + new_shares)); + env.storage() + .instance() + .set(&StorageKey::TotalDeposited, &(total_deposited + amount)); + env.storage() + .instance() + .set(&StorageKey::TotalShares, &(total_shares + new_shares)); env.events().publish( (Symbol::new(&env, "deposit"), provider.clone()), @@ -281,38 +348,83 @@ impl RiskPool { pub fn withdraw(env: Env, provider: Address, shares: i128) -> i128 { provider.require_auth(); // Guard: check for zero or negative shares input - if shares <= 0 { panic_with_error!(&env, Error::ZeroAmount); } + if shares <= 0 { + panic_with_error!(&env, Error::ZeroAmount); + } Self::assert_active(&env); let lp_key = StorageKey::LpPosition(provider.clone()); - let mut position: LpPosition = env.storage().persistent() + let mut position: LpPosition = env + .storage() + .persistent() .get(&lp_key) .unwrap_or_else(|| panic_with_error!(&env, Error::NoShares)); - if position.shares < shares { panic_with_error!(&env, Error::InsufficientFunds); } + if position.shares < shares { + panic_with_error!(&env, Error::InsufficientFunds); + } - let total_deposited: i128 = env.storage().instance().get(&StorageKey::TotalDeposited).unwrap_or(0); - let total_shares: i128 = env.storage().instance().get(&StorageKey::TotalShares).unwrap_or(0); - let total_locked: i128 = env.storage().instance().get(&StorageKey::TotalLocked).unwrap_or(0); + let total_deposited: i128 = env + .storage() + .instance() + .get(&StorageKey::TotalDeposited) + .unwrap_or(0); + let total_shares: i128 = env + .storage() + .instance() + .get(&StorageKey::TotalShares) + .unwrap_or(0); + let total_locked: i128 = env + .storage() + .instance() + .get(&StorageKey::TotalLocked) + .unwrap_or(0); let available_liquidity = total_deposited.saturating_sub(total_locked); - if available_liquidity <= 0 { panic_with_error!(&env, Error::Undercollateralized); } - let amount = shares.checked_mul(total_deposited) + if available_liquidity <= 0 { + panic_with_error!(&env, Error::Undercollateralized); + } + let amount = shares + .checked_mul(total_deposited) .and_then(|v| v.checked_div(total_shares)) .unwrap_or_else(|| panic_with_error!(&env, Error::Overflow)); - if amount == 0 { panic_with_error!(&env, Error::ZeroAmount); } - if amount > available_liquidity { panic_with_error!(&env, Error::Undercollateralized); } + if amount == 0 { + panic_with_error!(&env, Error::ZeroAmount); + } + if amount > available_liquidity { + panic_with_error!(&env, Error::Undercollateralized); + } - let usdc: Address = env.storage().instance().get(&StorageKey::UsdcToken).unwrap(); - token::Client::new(&env, &usdc) - .transfer(&env.current_contract_address(), &provider, &amount); + let usdc: Address = env + .storage() + .instance() + .get(&StorageKey::UsdcToken) + .unwrap(); + token::Client::new(&env, &usdc).transfer( + &env.current_contract_address(), + &provider, + &amount, + ); Self::internal_claim_yield(&env, &mut position); position.deposited = position.deposited.saturating_sub(amount); - position.shares -= shares; - position.yield_debt = (env.storage().instance().get(&StorageKey::AccumulatedPerShare).unwrap_or(0) * position.shares) / 1_000_000_000_000; + position.shares -= shares; + position.yield_debt = (env + .storage() + .instance() + .get(&StorageKey::AccumulatedPerShare) + .unwrap_or(0) + * position.shares) + / 1_000_000_000_000; env.storage().persistent().set(&lp_key, &position); - env.storage().instance().set(&StorageKey::TotalDeposited, &total_deposited.checked_sub(amount).unwrap_or_else(|| panic_with_error!(&env, Error::Overflow))); - env.storage().instance().set(&StorageKey::TotalShares, &(total_shares - shares)); + env.storage().instance().set( + &StorageKey::TotalDeposited, + &total_deposited + .checked_sub(amount) + .unwrap_or_else(|| panic_with_error!(&env, Error::Overflow)), + ); + env.storage() + .instance() + .set(&StorageKey::TotalShares, &(total_shares - shares)); env.events().publish( (Symbol::new(&env, "withdraw"), provider.clone()), @@ -322,48 +434,169 @@ impl RiskPool { amount } + /// Emergency LP withdrawal with explicit admin approval. + /// Bypasses pool pause/winding-down status, but still only releases unlocked liquidity. + pub fn emergency_withdraw(env: Env, provider: Address, admin: Address, shares: i128) -> i128 { + provider.require_auth(); + Self::require_admin(&env, &admin); + if shares <= 0 { + panic_with_error!(&env, Error::ZeroAmount); + } + + let lp_key = StorageKey::LpPosition(provider.clone()); + let mut position: LpPosition = env + .storage() + .persistent() + .get(&lp_key) + .unwrap_or_else(|| panic_with_error!(&env, Error::NoShares)); + if position.shares < shares { + panic_with_error!(&env, Error::InsufficientFunds); + } + + let total_deposited: i128 = env + .storage() + .instance() + .get(&StorageKey::TotalDeposited) + .unwrap_or(0); + let total_shares: i128 = env + .storage() + .instance() + .get(&StorageKey::TotalShares) + .unwrap_or(0); + let total_locked: i128 = env + .storage() + .instance() + .get(&StorageKey::TotalLocked) + .unwrap_or(0); + let available_liquidity = total_deposited.saturating_sub(total_locked); + if available_liquidity <= 0 { + panic_with_error!(&env, Error::Undercollateralized); + } + + let amount = shares + .checked_mul(total_deposited) + .and_then(|v| v.checked_div(total_shares)) + .unwrap_or_else(|| panic_with_error!(&env, Error::Overflow)); + if amount == 0 { + panic_with_error!(&env, Error::ZeroAmount); + } + if amount > available_liquidity { + panic_with_error!(&env, Error::Undercollateralized); + } + + let usdc: Address = env + .storage() + .instance() + .get(&StorageKey::UsdcToken) + .unwrap(); + token::Client::new(&env, &usdc).transfer( + &env.current_contract_address(), + &provider, + &amount, + ); + + Self::internal_claim_yield(&env, &mut position); + position.deposited = position.deposited.saturating_sub(amount); + position.shares -= shares; + position.yield_debt = (env + .storage() + .instance() + .get(&StorageKey::AccumulatedPerShare) + .unwrap_or(0) + * position.shares) + / 1_000_000_000_000; + env.storage().persistent().set(&lp_key, &position); + env.storage().instance().set( + &StorageKey::TotalDeposited, + &total_deposited + .checked_sub(amount) + .unwrap_or_else(|| panic_with_error!(&env, Error::Overflow)), + ); + env.storage() + .instance() + .set(&StorageKey::TotalShares, &(total_shares - shares)); + + env.events().publish( + (Symbol::new(&env, "emergency_withdraw"), provider.clone()), + (admin, amount, shares), + ); + + amount + } + // ── Premium and yield ───────────────────────────────────────────────────── /// Pull `amount` USDC from `caller` and split it among LPs, treasury, and backstop /// according to the protocol fee schedule. No-op if `amount` is zero or negative. pub fn receive_premium(env: Env, caller: Address, amount: i128) { caller.require_auth(); - if amount <= 0 { return; } - let usdc: Address = env.storage().instance().get(&StorageKey::UsdcToken).unwrap(); - token::Client::new(&env, &usdc) - .transfer(&caller, &env.current_contract_address(), &amount); - - let lp_share = amount * PREMIUM_LP_BPS / 10_000; - let treas_share = amount * PREMIUM_TREAS_BPS / 10_000; + if amount <= 0 { + return; + } + let usdc: Address = env + .storage() + .instance() + .get(&StorageKey::UsdcToken) + .unwrap(); + token::Client::new(&env, &usdc).transfer(&caller, &env.current_contract_address(), &amount); + + let lp_share = amount * PREMIUM_LP_BPS / 10_000; + let treas_share = amount * PREMIUM_TREAS_BPS / 10_000; let backstop_share = amount * PREMIUM_BACKSTOP_BPS / 10_000; let treasury: Address = env.storage().instance().get(&StorageKey::Treasury).unwrap(); - token::Client::new(&env, &usdc) - .transfer(&env.current_contract_address(), &treasury, &treas_share); + token::Client::new(&env, &usdc).transfer( + &env.current_contract_address(), + &treasury, + &treas_share, + ); let backstop: Address = env.storage().instance().get(&StorageKey::Backstop).unwrap(); - token::Client::new(&env, &usdc) - .transfer(&env.current_contract_address(), &backstop, &backstop_share); - - let acc: i128 = env.storage().instance() - .get(&StorageKey::AccumulatedPremium).unwrap_or(0); - env.storage().instance().set(&StorageKey::AccumulatedPremium, &(acc + lp_share)); + token::Client::new(&env, &usdc).transfer( + &env.current_contract_address(), + &backstop, + &backstop_share, + ); - let total_shares: i128 = env.storage().instance() - .get(&StorageKey::TotalShares).unwrap_or(0); + let acc: i128 = env + .storage() + .instance() + .get(&StorageKey::AccumulatedPremium) + .unwrap_or(0); + env.storage() + .instance() + .set(&StorageKey::AccumulatedPremium, &(acc + lp_share)); + + let total_shares: i128 = env + .storage() + .instance() + .get(&StorageKey::TotalShares) + .unwrap_or(0); if total_shares > 0 { - let acc_per_share: i128 = env.storage().instance() - .get(&StorageKey::AccumulatedPerShare).unwrap_or(0); + let acc_per_share: i128 = env + .storage() + .instance() + .get(&StorageKey::AccumulatedPerShare) + .unwrap_or(0); let increment = lp_share .checked_mul(1_000_000_000_000) .and_then(|v| v.checked_div(total_shares)) .unwrap_or_else(|| panic_with_error!(&env, Error::Overflow)); - env.storage().instance().set(&StorageKey::AccumulatedPerShare, &(acc_per_share + increment)); + env.storage().instance().set( + &StorageKey::AccumulatedPerShare, + &(acc_per_share + increment), + ); } - let acc_backstop: i128 = env.storage().instance() - .get(&StorageKey::AccumulatedBackstop).unwrap_or(0); - env.storage().instance().set(&StorageKey::AccumulatedBackstop, &(acc_backstop + backstop_share)); + let acc_backstop: i128 = env + .storage() + .instance() + .get(&StorageKey::AccumulatedBackstop) + .unwrap_or(0); + env.storage().instance().set( + &StorageKey::AccumulatedBackstop, + &(acc_backstop + backstop_share), + ); env.events().publish( (Symbol::new(&env, "premium_distributed"),), @@ -380,14 +613,26 @@ impl RiskPool { /// Called by the backend after each policy purchase to distribute earned premiums. pub fn send_premium_to_treasury(env: Env, caller: Address, amount: i128) { Self::require_admin(&env, &caller); - if amount <= 0 { panic_with_error!(&env, Error::ZeroAmount); } - let usdc: Address = env.storage().instance().get(&StorageKey::UsdcToken).unwrap(); + if amount <= 0 { + panic_with_error!(&env, Error::ZeroAmount); + } + let usdc: Address = env + .storage() + .instance() + .get(&StorageKey::UsdcToken) + .unwrap(); let treasury: Address = env.storage().instance().get(&StorageKey::Treasury).unwrap(); - token::Client::new(&env, &usdc) - .transfer(&env.current_contract_address(), &treasury, &amount); + token::Client::new(&env, &usdc).transfer( + &env.current_contract_address(), + &treasury, + &amount, + ); env.events().publish( (Symbol::new(&env, "treasury_funded"),), - TreasuryFunded { amount, recipient: treasury }, + TreasuryFunded { + amount, + recipient: treasury, + }, ); } @@ -395,20 +640,34 @@ impl RiskPool { /// Called by the backend after each policy purchase to distribute earned premiums. pub fn send_premium_to_backstop(env: Env, caller: Address, amount: i128) { Self::require_admin(&env, &caller); - if amount <= 0 { panic_with_error!(&env, Error::ZeroAmount); } - let usdc: Address = env.storage().instance().get(&StorageKey::UsdcToken).unwrap(); + if amount <= 0 { + panic_with_error!(&env, Error::ZeroAmount); + } + let usdc: Address = env + .storage() + .instance() + .get(&StorageKey::UsdcToken) + .unwrap(); let backstop: Address = env.storage().instance().get(&StorageKey::Backstop).unwrap(); - token::Client::new(&env, &usdc) - .transfer(&env.current_contract_address(), &backstop, &amount); + token::Client::new(&env, &usdc).transfer( + &env.current_contract_address(), + &backstop, + &amount, + ); env.events().publish( (Symbol::new(&env, "backstop_funded"),), - BackstopFunded { amount, recipient: backstop }, + BackstopFunded { + amount, + recipient: backstop, + }, ); } /// Returns the configured backstop address. pub fn get_backstop(env: Env) -> Address { - env.storage().instance().get(&StorageKey::Backstop) + env.storage() + .instance() + .get(&StorageKey::Backstop) .unwrap_or_else(|| panic_with_error!(env, Error::NotInitialized)) } @@ -417,7 +676,9 @@ impl RiskPool { pub fn claim_yield(env: Env, provider: Address) -> i128 { provider.require_auth(); let lp_key = StorageKey::LpPosition(provider.clone()); - let mut position: LpPosition = env.storage().persistent() + let mut position: LpPosition = env + .storage() + .persistent() .get(&lp_key) .unwrap_or_else(|| panic_with_error!(&env, Error::NoShares)); @@ -441,49 +702,88 @@ impl RiskPool { Self::require_protocol_caller(&env, &caller); Self::assert_active(&env); // Guard: check for zero or negative lock amount input - if amount <= 0 { panic_with_error!(&env, Error::ZeroAmount); } + if amount <= 0 { + panic_with_error!(&env, Error::ZeroAmount); + } - let total_deposited: i128 = env.storage().instance().get(&StorageKey::TotalDeposited).unwrap_or(0); - let total_locked: i128 = env.storage().instance().get(&StorageKey::TotalLocked).unwrap_or(0); + let total_deposited: i128 = env + .storage() + .instance() + .get(&StorageKey::TotalDeposited) + .unwrap_or(0); + let total_locked: i128 = env + .storage() + .instance() + .get(&StorageKey::TotalLocked) + .unwrap_or(0); let available = total_deposited.saturating_sub(total_locked); - if available < amount { panic_with_error!(&env, Error::Undercollateralized); } - if env.storage().persistent().has(&StorageKey::Lock(policy_id)) { panic_with_error!(&env, Error::AlreadyLocked); } - - env.storage().persistent().set(&StorageKey::Lock(policy_id), &CapitalLock { - policy_id, - amount, - locked_at: env.ledger().timestamp(), - released: false, - }); - env.storage().persistent().extend_ttl(&StorageKey::Lock(policy_id), TTL_THRESHOLD, TTL_EXTEND_TO); - env.storage().instance().set(&StorageKey::TotalLocked, &(total_locked + amount)); + if available < amount { + panic_with_error!(&env, Error::Undercollateralized); + } + if env.storage().persistent().has(&StorageKey::Lock(policy_id)) { + panic_with_error!(&env, Error::AlreadyLocked); + } - env.events().publish( - (Symbol::new(&env, "capital_locked"),), - CapitalLocked { + env.storage().persistent().set( + &StorageKey::Lock(policy_id), + &CapitalLock { policy_id, amount, + locked_at: env.ledger().timestamp(), + released: false, }, ); + env.storage().persistent().extend_ttl( + &StorageKey::Lock(policy_id), + TTL_THRESHOLD, + TTL_EXTEND_TO, + ); + env.storage() + .instance() + .set(&StorageKey::TotalLocked, &(total_locked + amount)); + + env.events().publish( + (Symbol::new(&env, "capital_locked"),), + CapitalLocked { policy_id, amount }, + ); } /// Release the capital lock for `policy_id` after a successful claim payout. /// Reduces `total_locked` so the freed liquidity becomes available again. pub fn release_for_claim(env: Env, caller: Address, policy_id: u128) { Self::require_protocol_caller(&env, &caller); - let mut lock: CapitalLock = env.storage().persistent() + let mut lock: CapitalLock = env + .storage() + .persistent() .get(&StorageKey::Lock(policy_id)) .unwrap_or_else(|| panic_with_error!(&env, Error::LockNotFound)); - + // Guard: check for zero or negative amount before processing release metrics - if lock.amount <= 0 { panic_with_error!(&env, Error::ZeroAmount); } - if lock.released { panic_with_error!(&env, Error::AlreadyReleased); } - + if lock.amount <= 0 { + panic_with_error!(&env, Error::ZeroAmount); + } + if lock.released { + panic_with_error!(&env, Error::AlreadyReleased); + } + lock.released = true; - env.storage().persistent().set(&StorageKey::Lock(policy_id), &lock); - env.storage().persistent().extend_ttl(&StorageKey::Lock(policy_id), TTL_THRESHOLD, TTL_EXTEND_TO); - let total_locked: i128 = env.storage().instance().get(&StorageKey::TotalLocked).unwrap_or(0); - env.storage().instance().set(&StorageKey::TotalLocked, &(total_locked.saturating_sub(lock.amount))); + env.storage() + .persistent() + .set(&StorageKey::Lock(policy_id), &lock); + env.storage().persistent().extend_ttl( + &StorageKey::Lock(policy_id), + TTL_THRESHOLD, + TTL_EXTEND_TO, + ); + let total_locked: i128 = env + .storage() + .instance() + .get(&StorageKey::TotalLocked) + .unwrap_or(0); + env.storage().instance().set( + &StorageKey::TotalLocked, + &(total_locked.saturating_sub(lock.amount)), + ); env.events().publish( (Symbol::new(&env, "capital_released"),), @@ -498,15 +798,32 @@ impl RiskPool { /// The locked amount returns to available liquidity and premiums remain earned. pub fn release_for_expiry(env: Env, caller: Address, policy_id: u128) { Self::require_protocol_caller(&env, &caller); - let mut lock: CapitalLock = env.storage().persistent() + let mut lock: CapitalLock = env + .storage() + .persistent() .get(&StorageKey::Lock(policy_id)) .unwrap_or_else(|| panic_with_error!(&env, Error::LockNotFound)); - if lock.released { panic_with_error!(&env, Error::AlreadyReleased); } + if lock.released { + panic_with_error!(&env, Error::AlreadyReleased); + } lock.released = true; - env.storage().persistent().set(&StorageKey::Lock(policy_id), &lock); - env.storage().persistent().extend_ttl(&StorageKey::Lock(policy_id), TTL_THRESHOLD, TTL_EXTEND_TO); - let total_locked: i128 = env.storage().instance().get(&StorageKey::TotalLocked).unwrap_or(0); - env.storage().instance().set(&StorageKey::TotalLocked, &(total_locked.saturating_sub(lock.amount))); + env.storage() + .persistent() + .set(&StorageKey::Lock(policy_id), &lock); + env.storage().persistent().extend_ttl( + &StorageKey::Lock(policy_id), + TTL_THRESHOLD, + TTL_EXTEND_TO, + ); + let total_locked: i128 = env + .storage() + .instance() + .get(&StorageKey::TotalLocked) + .unwrap_or(0); + env.storage().instance().set( + &StorageKey::TotalLocked, + &(total_locked.saturating_sub(lock.amount)), + ); } // ── Queries ─────────────────────────────────────────────────────────────── @@ -514,28 +831,65 @@ impl RiskPool { /// Return aggregate pool statistics: total deposited, locked, shares, and premium accumulators. pub fn get_stats(env: Env) -> PoolStats { PoolStats { - category: env.storage().instance().get(&StorageKey::Category).unwrap(), - total_deposited: env.storage().instance().get(&StorageKey::TotalDeposited).unwrap_or(0), - total_locked: env.storage().instance().get(&StorageKey::TotalLocked).unwrap_or(0), - total_shares: env.storage().instance().get(&StorageKey::TotalShares).unwrap_or(0), - accumulated_premium: env.storage().instance().get(&StorageKey::AccumulatedPremium).unwrap_or(0), - accumulated_backstop: env.storage().instance().get(&StorageKey::AccumulatedBackstop).unwrap_or(0), - status: env.storage().instance().get(&StorageKey::Status).unwrap_or(PoolStatus::Active), + category: env.storage().instance().get(&StorageKey::Category).unwrap(), + total_deposited: env + .storage() + .instance() + .get(&StorageKey::TotalDeposited) + .unwrap_or(0), + total_locked: env + .storage() + .instance() + .get(&StorageKey::TotalLocked) + .unwrap_or(0), + total_shares: env + .storage() + .instance() + .get(&StorageKey::TotalShares) + .unwrap_or(0), + accumulated_premium: env + .storage() + .instance() + .get(&StorageKey::AccumulatedPremium) + .unwrap_or(0), + accumulated_backstop: env + .storage() + .instance() + .get(&StorageKey::AccumulatedBackstop) + .unwrap_or(0), + status: env + .storage() + .instance() + .get(&StorageKey::Status) + .unwrap_or(PoolStatus::Active), } } /// Return the LP position for `provider`, or `None` if they have never deposited. pub fn get_position(env: Env, provider: Address) -> Option { - env.storage().persistent().get(&StorageKey::LpPosition(provider)) + env.storage() + .persistent() + .get(&StorageKey::LpPosition(provider)) } /// Return the pool utilisation rate in basis points (locked / deposited × 10,000). /// Returns 0 if no USDC has been deposited. pub fn get_utilization_rate(env: Env) -> u32 { - let deposited: i128 = env.storage().instance().get(&StorageKey::TotalDeposited).unwrap_or(0); - let locked: i128 = env.storage().instance().get(&StorageKey::TotalLocked).unwrap_or(0); - if deposited == 0 { return 0; } - let util_bps = locked.checked_mul(10_000) + let deposited: i128 = env + .storage() + .instance() + .get(&StorageKey::TotalDeposited) + .unwrap_or(0); + let locked: i128 = env + .storage() + .instance() + .get(&StorageKey::TotalLocked) + .unwrap_or(0); + if deposited == 0 { + return 0; + } + let util_bps = locked + .checked_mul(10_000) .and_then(|v| v.checked_div(deposited)) .unwrap_or(0); // Saturate to u32::MAX if the result exceeds u32 range @@ -548,25 +902,36 @@ impl RiskPool { /// Return the current admin address. Panics with `NotInitialized` if not set up. pub fn get_admin(env: Env) -> Address { - env.storage().instance().get(&StorageKey::Admin) + env.storage() + .instance() + .get(&StorageKey::Admin) .unwrap_or_else(|| panic_with_error!(env, Error::NotInitialized)) } /// Return the total number of unique LP addresses that have ever deposited. pub fn get_lp_count(env: Env) -> u32 { - env.storage().instance().get(&StorageKey::LpCount).unwrap_or(0) + env.storage() + .instance() + .get(&StorageKey::LpCount) + .unwrap_or(0) } /// Return the current storage schema version (defaults to 1 before any migration). pub fn get_version(env: Env) -> u32 { - env.storage().instance().get(&StorageKey::Version).unwrap_or(1) + env.storage() + .instance() + .get(&StorageKey::Version) + .unwrap_or(1) } /// Return a paginated list of LP addresses that currently hold shares. /// `offset` defaults to 0 and `limit` defaults to 100 (capped at 500). pub fn get_lp_list(env: Env, offset: Option, limit: Option) -> PaginatedLps { - let total_count: u32 = env.storage().instance() - .get(&StorageKey::LpCount).unwrap_or(0); + let total_count: u32 = env + .storage() + .instance() + .get(&StorageKey::LpCount) + .unwrap_or(0); let offset_val = offset.unwrap_or(0); let limit_val = core::cmp::min(limit.unwrap_or(100), 500); @@ -575,10 +940,14 @@ impl RiskPool { if offset_val < total_count { let end = core::cmp::min(offset_val + limit_val, total_count); for i in offset_val..end { - if let Some(addr) = env.storage().persistent() + if let Some(addr) = env + .storage() + .persistent() .get::<_, Address>(&StorageKey::LpAddress(i)) { - if let Some(position) = env.storage().persistent() + if let Some(position) = env + .storage() + .persistent() .get::<_, LpPosition>(&StorageKey::LpPosition(addr.clone())) { if position.shares > 0 { @@ -597,8 +966,16 @@ impl RiskPool { /// Available (unlocked) liquidity in USDC stroops. pub fn get_available_liquidity(env: Env) -> i128 { - let deposited: i128 = env.storage().instance().get(&StorageKey::TotalDeposited).unwrap_or(0); - let locked: i128 = env.storage().instance().get(&StorageKey::TotalLocked).unwrap_or(0); + let deposited: i128 = env + .storage() + .instance() + .get(&StorageKey::TotalDeposited) + .unwrap_or(0); + let locked: i128 = env + .storage() + .instance() + .get(&StorageKey::TotalLocked) + .unwrap_or(0); deposited.saturating_sub(locked) } @@ -626,20 +1003,28 @@ impl RiskPool { /// Admin-only: halt new deposits. Existing LPs may still withdraw and claim yield. pub fn pause(env: Env, admin: Address) { Self::require_admin(&env, &admin); - env.storage().instance().set(&StorageKey::Status, &PoolStatus::Paused); + env.storage() + .instance() + .set(&StorageKey::Status, &PoolStatus::Paused); env.events().publish( (Symbol::new(&env, "pool_paused"),), - PoolPaused { admin: admin.clone() }, + PoolPaused { + admin: admin.clone(), + }, ); } /// Admin-only: re-enable deposits after a pause. pub fn resume(env: Env, admin: Address) { Self::require_admin(&env, &admin); - env.storage().instance().set(&StorageKey::Status, &PoolStatus::Active); + env.storage() + .instance() + .set(&StorageKey::Status, &PoolStatus::Active); env.events().publish( (Symbol::new(&env, "pool_resumed"),), - PoolResumed { admin: admin.clone() }, + PoolResumed { + admin: admin.clone(), + }, ); } @@ -648,12 +1033,20 @@ impl RiskPool { /// Only one pending request may exist at a time. pub fn request_admin_withdrawal(env: Env, admin: Address, amount: i128) { Self::require_admin(&env, &admin); - if amount <= 0 { panic_with_error!(&env, Error::ZeroAmount); } + if amount <= 0 { + panic_with_error!(&env, Error::ZeroAmount); + } let available = Self::get_available_liquidity(env.clone()); - if amount > available { panic_with_error!(&env, Error::Undercollateralized); } + if amount > available { + panic_with_error!(&env, Error::Undercollateralized); + } - if env.storage().persistent().has(&StorageKey::AdminWithdrawalRequest) { + if env + .storage() + .persistent() + .has(&StorageKey::AdminWithdrawalRequest) + { panic_with_error!(&env, Error::TimelockPending); } @@ -681,30 +1074,48 @@ impl RiskPool { /// Funds are transferred to the treasury address. pub fn execute_admin_withdrawal(env: Env, admin: Address) { Self::require_admin(&env, &admin); - let req: AdminWithdrawalRequest = env.storage().persistent() + let req: AdminWithdrawalRequest = env + .storage() + .persistent() .get(&StorageKey::AdminWithdrawalRequest) .unwrap_or_else(|| panic_with_error!(&env, Error::NoPendingWithdrawal)); - if req.executed { panic_with_error!(&env, Error::AlreadyReleased); } + if req.executed { + panic_with_error!(&env, Error::AlreadyReleased); + } let now = env.ledger().timestamp(); if now < req.requested_at + TIMELOCK_SECONDS { panic_with_error!(&env, Error::TimelockNotReady); } - let usdc: Address = env.storage().instance().get(&StorageKey::UsdcToken).unwrap(); + let usdc: Address = env + .storage() + .instance() + .get(&StorageKey::UsdcToken) + .unwrap(); let treasury: Address = env.storage().instance().get(&StorageKey::Treasury).unwrap(); - token::Client::new(&env, &usdc) - .transfer(&env.current_contract_address(), &treasury, &req.amount); + token::Client::new(&env, &usdc).transfer( + &env.current_contract_address(), + &treasury, + &req.amount, + ); let mut req = req; req.executed = true; - env.storage().persistent().set(&StorageKey::AdminWithdrawalRequest, &req); - - let total_deposited: i128 = env.storage().instance() - .get(&StorageKey::TotalDeposited).unwrap_or(0); - env.storage().instance() - .set(&StorageKey::TotalDeposited, &(total_deposited.saturating_sub(req.amount))); + env.storage() + .persistent() + .set(&StorageKey::AdminWithdrawalRequest, &req); + + let total_deposited: i128 = env + .storage() + .instance() + .get(&StorageKey::TotalDeposited) + .unwrap_or(0); + env.storage().instance().set( + &StorageKey::TotalDeposited, + &(total_deposited.saturating_sub(req.amount)), + ); env.events().publish( (Symbol::new(&env, "admin_withdrawal_executed"),), @@ -718,36 +1129,55 @@ impl RiskPool { /// Cancel a pending admin withdrawal request. pub fn cancel_admin_withdrawal(env: Env, admin: Address) { Self::require_admin(&env, &admin); - if !env.storage().persistent().has(&StorageKey::AdminWithdrawalRequest) { + if !env + .storage() + .persistent() + .has(&StorageKey::AdminWithdrawalRequest) + { panic_with_error!(&env, Error::NoPendingWithdrawal); } - env.storage().persistent().remove(&StorageKey::AdminWithdrawalRequest); + env.storage() + .persistent() + .remove(&StorageKey::AdminWithdrawalRequest); env.events().publish( (Symbol::new(&env, "admin_withdrawal_cancelled"),), - AdminWithdrawalCancelled { admin: admin.clone() }, + AdminWithdrawalCancelled { + admin: admin.clone(), + }, ); } /// Upgrade the contract WASM in-place. Only the admin may call this. /// Storage is preserved across upgrades; only the execution code changes. /// Runs storage migrations if the new version requires them. - pub fn upgrade(env: Env, admin: Address, new_wasm_hash: soroban_sdk::BytesN<32>, new_version: u32) { + pub fn upgrade( + env: Env, + admin: Address, + new_wasm_hash: soroban_sdk::BytesN<32>, + new_version: u32, + ) { Self::require_admin(&env, &admin); - let current_version: u32 = env.storage().instance().get(&StorageKey::Version).unwrap_or(1); + let current_version: u32 = env + .storage() + .instance() + .get(&StorageKey::Version) + .unwrap_or(1); if new_version <= current_version { panic!("new version must be greater than current version"); } - + // Run migrations from current_version to new_version Self::run_migrations(&env, current_version, new_version); - + // Update the stored version - env.storage().instance().set(&StorageKey::Version, &new_version); - + env.storage() + .instance() + .set(&StorageKey::Version, &new_version); + // Perform the actual WASM upgrade env.deployer().update_current_contract_wasm(new_wasm_hash); - + env.events().publish( (Symbol::new(&env, "contract_upgraded"),), ContractUpgraded { @@ -763,7 +1193,7 @@ impl RiskPool { // Migration from v1 to v2: No storage changes needed yet // This is where you would add migration logic for specific version bumps // Example: if old_version < 2 && new_version >= 2 { Self::migrate_v1_to_v2(env); } - + // Future migrations follow the pattern: // if old_version < 3 && new_version >= 3 { Self::migrate_v2_to_v3(env); } } @@ -772,12 +1202,16 @@ impl RiskPool { pub fn propose_new_admin(env: Env, admin: Address, new_admin: Address) { Self::require_admin(&env, &admin); // Store the proposed admin (zero address means no proposal) - env.storage().instance().set(&StorageKey::PendingAdmin, &new_admin); + env.storage() + .instance() + .set(&StorageKey::PendingAdmin, &new_admin); } /// Accept the proposed admin. Only the proposed admin can call this. pub fn accept_admin(env: Env, admin: Address) { - let pending_admin: Address = env.storage().instance() + let pending_admin: Address = env + .storage() + .instance() .get(&StorageKey::PendingAdmin) .unwrap_or_else(|| panic_with_error!(&env, Error::Unauthorized)); // Only the pending admin can accept @@ -792,20 +1226,27 @@ impl RiskPool { // Emit event env.events().publish( (Symbol::new(&env, "admin_updated"),), - AdminUpdated { - new_admin: admin, - }, + AdminUpdated { new_admin: admin }, ); } /// Enforces that only admin, the registered policy engine, or the registered /// claims processor may call capital-lock functions. fn require_protocol_caller(env: &Env, caller: &Address) { - let admin: Address = env.storage().instance().get(&StorageKey::Admin) + let admin: Address = env + .storage() + .instance() + .get(&StorageKey::Admin) .unwrap_or_else(|| panic_with_error!(env, Error::NotInitialized)); - let pe: Address = env.storage().instance().get(&StorageKey::PolicyEngine) + let pe: Address = env + .storage() + .instance() + .get(&StorageKey::PolicyEngine) .unwrap_or_else(|| panic_with_error!(env, Error::NotInitialized)); - let cp: Address = env.storage().instance().get(&StorageKey::ClaimsProcessor) + let cp: Address = env + .storage() + .instance() + .get(&StorageKey::ClaimsProcessor) .unwrap_or_else(|| panic_with_error!(env, Error::NotInitialized)); if *caller != admin && *caller != pe && *caller != cp { panic_with_error!(env, Error::Unauthorized); @@ -814,21 +1255,39 @@ impl RiskPool { } fn require_admin(env: &Env, caller: &Address) { - let admin: Address = env.storage().instance().get(&StorageKey::Admin) + let admin: Address = env + .storage() + .instance() + .get(&StorageKey::Admin) .unwrap_or_else(|| panic_with_error!(env, Error::NotInitialized)); - if *caller != admin { panic_with_error!(env, Error::Unauthorized); } + if *caller != admin { + panic_with_error!(env, Error::Unauthorized); + } caller.require_auth(); } fn assert_active(env: &Env) { - let status: PoolStatus = env.storage().instance() - .get(&StorageKey::Status).unwrap_or(PoolStatus::Active); - if status != PoolStatus::Active { panic_with_error!(env, Error::PoolNotActive); } + let status: PoolStatus = env + .storage() + .instance() + .get(&StorageKey::Status) + .unwrap_or(PoolStatus::Active); + if status != PoolStatus::Active { + panic_with_error!(env, Error::PoolNotActive); + } } fn internal_claim_yield(env: &Env, position: &mut LpPosition) { - let total_shares: i128 = env.storage().instance().get(&StorageKey::TotalShares).unwrap_or(0); - let acc_per_share: i128 = env.storage().instance().get(&StorageKey::AccumulatedPerShare).unwrap_or(0); + let total_shares: i128 = env + .storage() + .instance() + .get(&StorageKey::TotalShares) + .unwrap_or(0); + let acc_per_share: i128 = env + .storage() + .instance() + .get(&StorageKey::AccumulatedPerShare) + .unwrap_or(0); if total_shares == 0 { return; } @@ -836,9 +1295,16 @@ impl RiskPool { let entitled = (acc_per_share * position.shares) / 1_000_000_000_000; let claimable = entitled.saturating_sub(position.yield_debt); if claimable > 0 { - let usdc: Address = env.storage().instance().get(&StorageKey::UsdcToken).unwrap(); - token::Client::new(env, &usdc) - .transfer(&env.current_contract_address(), &position.provider, &claimable); + let usdc: Address = env + .storage() + .instance() + .get(&StorageKey::UsdcToken) + .unwrap(); + token::Client::new(env, &usdc).transfer( + &env.current_contract_address(), + &position.provider, + &claimable, + ); position.yield_claimed += claimable; position.last_yield_claim = env.ledger().timestamp(); diff --git a/contracts/risk-pool/src/test.rs b/contracts/risk-pool/src/test.rs index 98b036d..f9a6499 100644 --- a/contracts/risk-pool/src/test.rs +++ b/contracts/risk-pool/src/test.rs @@ -12,20 +12,31 @@ use crate::{RiskPool, RiskPoolClient}; // ── helpers ─────────────────────────────────────────────────────────────────── -fn setup() -> (Env, RiskPoolClient<'static>, Address, Address, Address, Address) { +fn setup() -> ( + Env, + RiskPoolClient<'static>, + Address, + Address, + Address, + Address, +) { let env = Env::default(); env.mock_all_auths(); - let admin = Address::generate(&env); - let treasury = Address::generate(&env); - let lp1 = Address::generate(&env); - let policy_engine = Address::generate(&env); + let admin = Address::generate(&env); + let treasury = Address::generate(&env); + let lp1 = Address::generate(&env); + let policy_engine = Address::generate(&env); let claims_processor = Address::generate(&env); - let usdc_id = env.register_stellar_asset_contract_v2(admin.clone()).address(); - let backstop_id = env.register_stellar_asset_contract_v2(admin.clone()).address(); - let pool_id = env.register(RiskPool, ()); - let pool = RiskPoolClient::new(&env, &pool_id); + let usdc_id = env + .register_stellar_asset_contract_v2(admin.clone()) + .address(); + let backstop_id = env + .register_stellar_asset_contract_v2(admin.clone()) + .address(); + let pool_id = env.register(RiskPool, ()); + let pool = RiskPoolClient::new(&env, &pool_id); let usdc_admin_client = token::StellarAssetClient::new(&env, &usdc_id); usdc_admin_client.mint(&lp1, &1_000_000_000_0000000i128); @@ -43,7 +54,6 @@ fn setup() -> (Env, RiskPoolClient<'static>, Address, Address, Address, Address) (env, pool, usdc_id, admin, treasury, lp1) } - // ── initialization ──────────────────────────────────────────────────────────── #[test] @@ -51,18 +61,28 @@ fn initialize_sets_state() { let (_, pool, _, _, _, _) = setup(); let stats = pool.get_stats(); assert_eq!(stats.total_deposited, 0); - assert_eq!(stats.total_shares, 0); - assert_eq!(stats.total_locked, 0); + assert_eq!(stats.total_shares, 0); + assert_eq!(stats.total_locked, 0); } #[test] #[should_panic(expected = "Error(Contract, #1)")] fn cannot_initialize_twice() { let (env, pool, usdc, admin, treasury, _) = setup(); - let backstop = env.register_stellar_asset_contract_v2(admin.clone()).address(); - let policy_engine = Address::generate(&env); + let backstop = env + .register_stellar_asset_contract_v2(admin.clone()) + .address(); + let policy_engine = Address::generate(&env); let claims_processor = Address::generate(&env); - pool.initialize(&admin, &usdc, &treasury, &backstop, &Symbol::new(&env, "crop"), &policy_engine, &claims_processor); + pool.initialize( + &admin, + &usdc, + &treasury, + &backstop, + &Symbol::new(&env, "crop"), + &policy_engine, + &claims_processor, + ); } #[test] @@ -72,12 +92,12 @@ fn test_initialize_with_non_token_usdc() { env.mock_all_auths(); let admin = Address::generate(&env); let treasury = Address::generate(&env); - + // Register some random non-token contract and use it as USDC let fake_usdc = env.register(RiskPool, ()); let pool_id = env.register(RiskPool, ()); let pool = RiskPoolClient::new(&env, &pool_id); - + pool.initialize( &admin, &fake_usdc, @@ -113,7 +133,7 @@ fn first_deposit_mints_one_to_one_shares() { let stats = pool.get_stats(); assert_eq!(stats.total_deposited, 500_000_0000000i128); - assert_eq!(stats.total_shares, 500_000_0000000i128 * 1_000_000_000); + assert_eq!(stats.total_shares, 500_000_0000000i128 * 1_000_000_000); } #[test] @@ -179,7 +199,7 @@ fn withdraw_partial_position_decrements_shares() { let amount = 1000_0000000i128; // deposit 1000 USDC let shares = pool.deposit(&lp1, &amount, &0i128); - + // withdraw half the shares let half_shares = shares / 2; let returned = pool.withdraw(&lp1, &half_shares); @@ -210,8 +230,8 @@ fn withdraw_locked_capital_fails() { let amount = 100_0000000i128; let shares = pool.deposit(&lp1, &amount, &0i128); - pool.lock_for_policy(&admin, &1u128, &amount); // lock all capital - pool.withdraw(&lp1, &shares); // should fail + pool.lock_for_policy(&admin, &1u128, &amount); // lock all capital + pool.withdraw(&lp1, &shares); // should fail } #[test] @@ -276,7 +296,7 @@ fn claim_yield_proportional_to_shares() { pool.deposit(&lp1, &500_0000000i128, &0i128); pool.deposit(&lp2, &500_0000000i128, &0i128); - pool.receive_premium(&lp1, &200_0000000i128); // 160 USDC to LP accumulated + pool.receive_premium(&lp1, &200_0000000i128); // 160 USDC to LP accumulated let yield1 = pool.claim_yield(&lp1); let yield2 = pool.claim_yield(&lp2); @@ -293,7 +313,7 @@ fn lock_and_release_round_trip() { pool.deposit(&lp1, &200_0000000i128, &0i128); pool.lock_for_policy(&admin, &42u128, &100_0000000i128); - assert_eq!(pool.get_utilization_rate(), 5_000u32); // 50% utilization in bps + assert_eq!(pool.get_utilization_rate(), 5_000u32); // 50% utilization in bps pool.release_for_claim(&admin, &42u128); assert_eq!(pool.get_utilization_rate(), 0u32); @@ -305,7 +325,7 @@ fn double_lock_fails() { let (_, pool, _, admin, _, lp1) = setup(); pool.deposit(&lp1, &200_0000000i128, &0i128); pool.lock_for_policy(&admin, &1u128, &50_0000000i128); - pool.lock_for_policy(&admin, &1u128, &50_0000000i128); // duplicate + pool.lock_for_policy(&admin, &1u128, &50_0000000i128); // duplicate } #[test] @@ -315,7 +335,7 @@ fn double_release_fails() { pool.deposit(&lp1, &200_0000000i128, &0i128); pool.lock_for_policy(&admin, &99u128, &50_0000000i128); pool.release_for_claim(&admin, &99u128); - pool.release_for_claim(&admin, &99u128); // already released + pool.release_for_claim(&admin, &99u128); // already released } // ── expiry lock release (Issue #11) ───────────────────────────────────────────── @@ -327,7 +347,7 @@ fn lock_and_release_for_expiry_round_trip() { pool.deposit(&lp1, &200_0000000i128, &0i128); pool.lock_for_policy(&admin, &42u128, &100_0000000i128); - assert_eq!(pool.get_utilization_rate(), 5_000u32); // 50% utilization in bps + assert_eq!(pool.get_utilization_rate(), 5_000u32); // 50% utilization in bps pool.release_for_expiry(&admin, &42u128); assert_eq!(pool.get_utilization_rate(), 0u32); @@ -355,7 +375,7 @@ fn double_release_for_expiry_fails() { pool.deposit(&lp1, &200_0000000i128, &0i128); pool.lock_for_policy(&admin, &99u128, &50_0000000i128); pool.release_for_expiry(&admin, &99u128); - pool.release_for_expiry(&admin, &99u128); // already released + pool.release_for_expiry(&admin, &99u128); // already released } // ── pause / resume ──────────────────────────────────────────────────────────── @@ -385,7 +405,7 @@ fn get_position_returns_correct_state() { pool.deposit(&lp1, &300_0000000i128, &0i128); let pos = pool.get_position(&lp1).unwrap(); assert_eq!(pos.deposited, 300_0000000i128); - assert_eq!(pos.shares, 300_0000000i128 * 1_000_000_000); + assert_eq!(pos.shares, 300_0000000i128 * 1_000_000_000); assert_eq!(pos.yield_claimed, 0); } @@ -400,7 +420,7 @@ fn get_position_none_for_non_participant() { fn test_deposit_precision_loss_prevented() { let (env, pool, usdc_id, _admin, _, lp1) = setup(); let lp2 = Address::generate(&env); - + // LP1 deposits 1000 USDC pool.deposit(&lp1, &1000_0000000i128, &0i128); @@ -442,6 +462,32 @@ fn admin_cannot_drain_lp_funds_indirectly() { assert_eq!(stats_after.total_deposited, amount); } +#[test] +fn emergency_withdraw_allows_admin_approved_lp_exit_while_paused() { + let (env, pool, usdc_id, admin, _, lp1) = setup(); + let amount = 500_0000000i128; + let shares = pool.deposit(&lp1, &amount, &0i128); + pool.pause(&admin); + + let before = token::Client::new(&env, &usdc_id).balance(&lp1); + let returned = pool.emergency_withdraw(&lp1, &admin, &shares); + let after = token::Client::new(&env, &usdc_id).balance(&lp1); + + assert_eq!(returned, amount); + assert_eq!(after - before, amount); + assert_eq!(pool.get_stats().total_deposited, 0); +} + +#[test] +#[should_panic(expected = "Error(Contract, #3)")] +fn emergency_withdraw_requires_admin_approval() { + let (env, pool, _, _admin, _, lp1) = setup(); + let amount = 500_0000000i128; + let shares = pool.deposit(&lp1, &amount, &0i128); + let impostor = Address::generate(&env); + pool.emergency_withdraw(&lp1, &impostor, &shares); +} + /// Admin can request a withdrawal, but it cannot be executed before the /// 7-day timelock matures. #[test] @@ -521,16 +567,16 @@ fn non_admin_cannot_release_for_expiry() { fn test_get_lp_list_pagination() { let (env, pool, usdc_id, _admin, _, _lp1) = setup(); env.budget().reset_unlimited(); - + let usdc_client = token::StellarAssetClient::new(&env, &usdc_id); for _ in 0..200 { let lp = Address::generate(&env); usdc_client.mint(&lp, &10_000_000i128); pool.deposit(&lp, &10_000_000i128, &0i128); } - + assert_eq!(pool.get_lp_count(), 200); - + // query offset=100, limit=50 (proves pagination works beyond default limit) let paginated = pool.get_lp_list(&Some(100), &Some(50)); assert_eq!(paginated.total_count, 200); diff --git a/contracts/risk-pool/src/test_advanced.rs b/contracts/risk-pool/src/test_advanced.rs index 531d41a..627be9b 100644 --- a/contracts/risk-pool/src/test_advanced.rs +++ b/contracts/risk-pool/src/test_advanced.rs @@ -4,28 +4,37 @@ extern crate std; -use soroban_sdk::{ - testutils::Address as _, - token, Address, Env, Symbol, -}; +use soroban_sdk::{testutils::Address as _, token, Address, Env, Symbol}; use crate::{RiskPool, RiskPoolClient}; -fn setup_multi() -> (Env, RiskPoolClient<'static>, Address, Address, Address, Address, Address) { +fn setup_multi() -> ( + Env, + RiskPoolClient<'static>, + Address, + Address, + Address, + Address, + Address, +) { let env = Env::default(); env.mock_all_auths(); - let admin = Address::generate(&env); - let treasury = Address::generate(&env); - let lp1 = Address::generate(&env); - let lp2 = Address::generate(&env); - let policy_engine = Address::generate(&env); + let admin = Address::generate(&env); + let treasury = Address::generate(&env); + let lp1 = Address::generate(&env); + let lp2 = Address::generate(&env); + let policy_engine = Address::generate(&env); let claims_processor = Address::generate(&env); - let usdc_id = env.register_stellar_asset_contract_v2(admin.clone()).address(); - let backstop_id = env.register_stellar_asset_contract_v2(admin.clone()).address(); - let pool_id = env.register(RiskPool, ()); - let pool = RiskPoolClient::new(&env, &pool_id); + let usdc_id = env + .register_stellar_asset_contract_v2(admin.clone()) + .address(); + let backstop_id = env + .register_stellar_asset_contract_v2(admin.clone()) + .address(); + let pool_id = env.register(RiskPool, ()); + let pool = RiskPoolClient::new(&env, &pool_id); let mint = |addr: &Address| { token::StellarAssetClient::new(&env, &usdc_id).mint(addr, &10_000_0000000i128); @@ -73,8 +82,8 @@ fn available_liquidity_decreases_with_locks() { #[test] fn two_lps_receive_proportional_yield() { let (_, pool, _, _, _, lp1, lp2) = setup_multi(); - pool.deposit(&lp1, &300_0000000i128, &0i128); // 3/4 of pool - pool.deposit(&lp2, &100_0000000i128, &0i128); // 1/4 of pool + pool.deposit(&lp1, &300_0000000i128, &0i128); // 3/4 of pool + pool.deposit(&lp2, &100_0000000i128, &0i128); // 1/4 of pool // premium: 400 USDC → 320 USDC to LP accumulated (80%) pool.receive_premium(&lp1, &400_0000000i128); @@ -95,7 +104,7 @@ fn get_stats_reflects_all_operations() { pool.receive_premium(&lp1, &100_0000000i128); let stats = pool.get_stats(); - assert_eq!(stats.total_deposited, 400_0000000i128); - assert_eq!(stats.total_locked, 80_0000000i128); - assert_eq!(stats.accumulated_premium, 80_0000000i128); // 80% of 100 + assert_eq!(stats.total_deposited, 400_0000000i128); + assert_eq!(stats.total_locked, 80_0000000i128); + assert_eq!(stats.accumulated_premium, 80_0000000i128); // 80% of 100 } diff --git a/contracts/risk-pool/src/test_edge.rs b/contracts/risk-pool/src/test_edge.rs index a41d8ae..ba8f63a 100644 --- a/contracts/risk-pool/src/test_edge.rs +++ b/contracts/risk-pool/src/test_edge.rs @@ -8,20 +8,31 @@ use soroban_sdk::{testutils::Address as _, token, Address, Env, Symbol}; use crate::{RiskPool, RiskPoolClient}; -fn setup() -> (Env, RiskPoolClient<'static>, Address, Address, Address, Address) { +fn setup() -> ( + Env, + RiskPoolClient<'static>, + Address, + Address, + Address, + Address, +) { let env = Env::default(); env.mock_all_auths(); - let admin = Address::generate(&env); - let treasury = Address::generate(&env); - let lp1 = Address::generate(&env); - let policy_engine = Address::generate(&env); + let admin = Address::generate(&env); + let treasury = Address::generate(&env); + let lp1 = Address::generate(&env); + let policy_engine = Address::generate(&env); let claims_processor = Address::generate(&env); - let usdc_id = env.register_stellar_asset_contract_v2(admin.clone()).address(); - let backstop_id = env.register_stellar_asset_contract_v2(admin.clone()).address(); - let pool_id = env.register(RiskPool, ()); - let pool = RiskPoolClient::new(&env, &pool_id); + let usdc_id = env + .register_stellar_asset_contract_v2(admin.clone()) + .address(); + let backstop_id = env + .register_stellar_asset_contract_v2(admin.clone()) + .address(); + let pool_id = env.register(RiskPool, ()); + let pool = RiskPoolClient::new(&env, &pool_id); token::StellarAssetClient::new(&env, &usdc_id).mint(&lp1, &100_000_0000000i128); @@ -64,7 +75,7 @@ fn utilization_100_pct_after_locking_all() { let amount = 200_0000000i128; pool.deposit(&lp1, &amount, &0i128); pool.lock_for_policy(&admin, &10u128, &amount); - assert_eq!(pool.get_utilization_rate(), 10_000u32); // 100% in bps + assert_eq!(pool.get_utilization_rate(), 10_000u32); // 100% in bps assert_eq!(pool.get_available_liquidity(), 0); } @@ -89,7 +100,7 @@ fn inflation_attack_mitigated() { let (env, pool, _, _, usdc_id, lp1) = setup(); let lp2 = Address::generate(&env); token::StellarAssetClient::new(&env, &usdc_id).mint(&lp2, &1000_0000000i128); - + // LP1 deposits 10 USDC (gets 10_000_000 * 1e9 = 10^16 shares) pool.deposit(&lp1, &10_0000000i128, &0i128); @@ -97,9 +108,9 @@ fn inflation_attack_mitigated() { let shares = pool.get_position(&lp1).unwrap().shares; pool.withdraw(&lp1, &(shares - 1)); - // Send a massive premium + // Send a massive premium token::StellarAssetClient::new(&env, &usdc_id).mint(&lp1, &100_000_0000000i128); - pool.receive_premium(&lp1, &100_000_0000000i128); + pool.receive_premium(&lp1, &100_000_0000000i128); // LP2 deposits 1 USDC. Because total_deposited wasn't inflated, they get correct shares let new_shares = pool.deposit(&lp2, &1_0000000i128, &0i128); @@ -111,17 +122,21 @@ fn per_share_yield_distribution() { let env = Env::default(); env.mock_all_auths(); - let admin = Address::generate(&env); - let treasury = Address::generate(&env); - let lp1 = Address::generate(&env); - let lp2 = Address::generate(&env); - let policy_engine = Address::generate(&env); + let admin = Address::generate(&env); + let treasury = Address::generate(&env); + let lp1 = Address::generate(&env); + let lp2 = Address::generate(&env); + let policy_engine = Address::generate(&env); let claims_processor = Address::generate(&env); - let usdc_id = env.register_stellar_asset_contract_v2(admin.clone()).address(); - let backstop_id = env.register_stellar_asset_contract_v2(admin.clone()).address(); - let pool_id = env.register(RiskPool, ()); - let pool = RiskPoolClient::new(&env, &pool_id); + let usdc_id = env + .register_stellar_asset_contract_v2(admin.clone()) + .address(); + let backstop_id = env + .register_stellar_asset_contract_v2(admin.clone()) + .address(); + let pool_id = env.register(RiskPool, ()); + let pool = RiskPoolClient::new(&env, &pool_id); token::StellarAssetClient::new(&env, &usdc_id).mint(&lp1, &1_000_000_0000000i128); token::StellarAssetClient::new(&env, &usdc_id).mint(&lp2, &1_000_000_0000000i128); @@ -166,16 +181,20 @@ fn utilization_rate_large_locked_no_truncation() { let env = Env::default(); env.mock_all_auths(); - let admin = Address::generate(&env); - let treasury = Address::generate(&env); - let lp1 = Address::generate(&env); - let policy_engine = Address::generate(&env); + let admin = Address::generate(&env); + let treasury = Address::generate(&env); + let lp1 = Address::generate(&env); + let policy_engine = Address::generate(&env); let claims_processor = Address::generate(&env); - let usdc_id = env.register_stellar_asset_contract_v2(admin.clone()).address(); - let backstop_id = env.register_stellar_asset_contract_v2(admin.clone()).address(); - let pool_id = env.register(RiskPool, ()); - let pool = RiskPoolClient::new(&env, &pool_id); + let usdc_id = env + .register_stellar_asset_contract_v2(admin.clone()) + .address(); + let backstop_id = env + .register_stellar_asset_contract_v2(admin.clone()) + .address(); + let pool_id = env.register(RiskPool, ()); + let pool = RiskPoolClient::new(&env, &pool_id); // Deposit 500,000 USDC (500,000,000,000,000 stroops) // This exceeds the threshold where locked * 10_000 would overflow u32::MAX @@ -201,7 +220,11 @@ fn utilization_rate_large_locked_no_truncation() { // The utilization rate should saturate to u32::MAX instead of silently truncating let util_rate = pool.get_utilization_rate(); - assert_eq!(util_rate, u32::MAX, "Utilization rate should saturate to u32::MAX for large locked amounts"); + assert_eq!( + util_rate, + u32::MAX, + "Utilization rate should saturate to u32::MAX for large locked amounts" + ); // Verify the calculation: (429,497 * 10,000) / 500,000 = 8,589,940 bps // This exceeds u32::MAX (4,294,967,295), so it should saturate diff --git a/contracts/risk-pool/src/types.rs b/contracts/risk-pool/src/types.rs index cf4e7ff..8073da3 100644 --- a/contracts/risk-pool/src/types.rs +++ b/contracts/risk-pool/src/types.rs @@ -4,7 +4,7 @@ use soroban_sdk::{contracttype, Address, Symbol, Vec}; #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] pub struct PaginatedLps { - pub lps: Vec
, + pub lps: Vec
, pub total_count: u32, } @@ -22,26 +22,26 @@ pub enum PoolStatus { #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] pub struct LpPosition { - pub provider: Address, + pub provider: Address, /// Amount of USDC deposited (7-decimal stroops) - pub deposited: i128, + pub deposited: i128, /// Pool-share tokens held (7-decimal, proportional to ownership) - pub shares: i128, + pub shares: i128, /// Total accumulated premium yield already claimed by this LP - pub yield_claimed: i128, - pub yield_debt: i128, - pub deposited_at: u64, - pub last_yield_claim: u64, + pub yield_claimed: i128, + pub yield_debt: i128, + pub deposited_at: u64, + pub last_yield_claim: u64, } /// A capital lock placed on the pool when a policy is active. #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] pub struct CapitalLock { - pub policy_id: u128, - pub amount: i128, - pub locked_at: u64, - pub released: bool, + pub policy_id: u128, + pub amount: i128, + pub locked_at: u64, + pub released: bool, } /// Aggregate pool stats exposed via queries. @@ -49,13 +49,13 @@ pub struct CapitalLock { #[derive(Clone, Debug, Eq, PartialEq)] pub struct PoolStats { /// Category: "crop" | "flight" | "disaster" | "defi" - pub category: Symbol, - pub total_deposited: i128, - pub total_locked: i128, - pub total_shares: i128, - pub accumulated_premium: i128, + pub category: Symbol, + pub total_deposited: i128, + pub total_locked: i128, + pub total_shares: i128, + pub accumulated_premium: i128, pub accumulated_backstop: i128, - pub status: PoolStatus, + pub status: PoolStatus, } // ─── Events ────────────────────────────────────────────────────────────────── @@ -63,13 +63,13 @@ pub struct PoolStats { #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] pub struct Initialized { - pub admin: Address, - pub usdc_token: Address, - pub treasury: Address, - pub backstop: Address, - pub category: Symbol, - pub policy_engine: Address, - pub claims_processor: Address, + pub admin: Address, + pub usdc_token: Address, + pub treasury: Address, + pub backstop: Address, + pub category: Symbol, + pub policy_engine: Address, + pub claims_processor: Address, } #[contracttype]