From 10c1f6c6fd922d815d59fc8267089707b81417bd Mon Sep 17 00:00:00 2001 From: just-bamford Date: Thu, 27 Aug 2026 20:59:26 +0100 Subject: [PATCH] feat: Add resolve_dispute, get_product_stats, invalidate_data, and pause/resume events --- contracts/claims-processor/src/lib.rs | 46 ++++++++ contracts/claims-processor/src/test.rs | 114 ++++++++++++++++++ contracts/claims-processor/src/types.rs | 7 ++ contracts/oracle-verifier/src/lib.rs | 15 +++ contracts/oracle-verifier/src/test.rs | 98 ++++++++++++++++ contracts/policy-engine/src/lib.rs | 68 ++++++++++- contracts/policy-engine/src/test.rs | 146 ++++++++++++++++++++++++ contracts/policy-engine/src/types.rs | 12 ++ 8 files changed, 502 insertions(+), 4 deletions(-) diff --git a/contracts/claims-processor/src/lib.rs b/contracts/claims-processor/src/lib.rs index cefb923..1463076 100644 --- a/contracts/claims-processor/src/lib.rs +++ b/contracts/claims-processor/src/lib.rs @@ -580,6 +580,52 @@ impl ClaimsProcessor { ); } + /// Admin-only: resolve a disputed claim and re-queue it for processing. + /// + /// When a claim is disputed, it is removed from the pending queue and sits in + /// Disputed status indefinitely. This function allows the admin to review the + /// dispute and either: + /// - Clear the dispute and return the claim to Pending for re-evaluation, or + /// - Perform an off-chain investigation and then call this to re-queue the claim. + /// + /// The claim transitions from Disputed → Pending and is added back to the pending + /// claims queue for the next keeper to process. + pub fn resolve_dispute(env: Env, admin: Address, claim_id: u128) { + Self::require_admin(&env, &admin); + + let mut claim: Claim = env.storage().persistent() + .get(&StorageKey::Claim(claim_id)) + .unwrap_or_else(|| panic_with_error!(&env, Error::ClaimNotFound)); + + // Only Disputed claims can be resolved + if claim.status != ClaimStatus::Disputed { + panic_with_error!(&env, Error::AlreadyProcessed); + } + + // Clear dispute and return to Pending status + claim.status = ClaimStatus::Pending; + claim.dispute_reason = None; + + let claim_key = StorageKey::Claim(claim_id); + env.storage().persistent().set(&claim_key, &claim); + Self::extend_claim_ttl(&env, &claim_key); + + // Re-add the claim to the pending queue for re-processing + 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.events().publish( + (Symbol::new(&env, "claim_resolved"),), + ClaimResolved { + claim_id, + resolver: admin, + }, + ); + } + // ── Queries ─────────────────────────────────────────────────────────────── /// Return the `Claim` record for the given `claim_id`. Panics with `ClaimNotFound` if it does not exist. diff --git a/contracts/claims-processor/src/test.rs b/contracts/claims-processor/src/test.rs index e389e86..27556d0 100644 --- a/contracts/claims-processor/src/test.rs +++ b/contracts/claims-processor/src/test.rs @@ -1164,3 +1164,117 @@ fn test_removed_attestor_cannot_submit_attestation() { &w.env.ledger().timestamp(), ); } + + +// ── Dispute resolution (resolve_dispute feature) ───────────────────────────── + +/// Admin can resolve a disputed claim, moving it back to Pending status and +/// re-adding it to the pending queue for re-processing by a keeper. +#[test] +fn test_resolve_dispute_succeeds() { + 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); + + let cp = ClaimsProcessorClient::new(&w.env, &w.claims_id); + let claim_id = cp.submit_claim(&buyer, &pol_id); + + 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); + + cp.resolve_dispute(&w.admin, &claim_id); + + let claim = cp.get_claim(&claim_id); + assert_eq!(claim.status, ClaimStatus::Pending); + assert_eq!(claim.dispute_reason, None); + assert_eq!(cp.get_pending_claims().len(), 1); + assert_eq!(cp.get_pending_claims().get_unchecked(0), claim_id); +} + +/// Resolving a non-existent claim must fail with ClaimNotFound. +#[test] +#[should_panic(expected = "Error(Contract, #4)")] +fn test_resolve_dispute_nonexistent_claim_fails() { + let w = deploy(); + ClaimsProcessorClient::new(&w.env, &w.claims_id) + .resolve_dispute(&w.admin, &999_999u128); +} + +/// Only admin can call resolve_dispute; non-admin is rejected. +#[test] +#[should_panic(expected = "Error(Contract, #3)")] +fn test_resolve_dispute_non_admin_fails() { + 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); + + let cp = ClaimsProcessorClient::new(&w.env, &w.claims_id); + let claim_id = cp.submit_claim(&buyer, &pol_id); + cp.dispute_claim(&buyer, &claim_id, &symbol_short!("disagree")); + + let stranger = Address::generate(&w.env); + cp.resolve_dispute(&stranger, &claim_id); +} + +/// Resolving a claim that is not in Disputed status must fail. +#[test] +#[should_panic(expected = "Error(Contract, #7)")] +fn test_resolve_dispute_non_disputed_claim_fails() { + 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); + + let cp = ClaimsProcessorClient::new(&w.env, &w.claims_id); + let claim_id = cp.submit_claim(&buyer, &pol_id); + + cp.resolve_dispute(&w.admin, &claim_id); +} + +/// After resolving a dispute, the claim can be successfully processed by keeper. +#[test] +fn test_resolved_dispute_can_be_processed() { + 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); + + let cp = ClaimsProcessorClient::new(&w.env, &w.claims_id); + let claim_id = cp.submit_claim(&buyer, &pol_id); + + cp.dispute_claim(&buyer, &claim_id, &symbol_short!("disagree")); + cp.resolve_dispute(&w.admin, &claim_id); + + let result = cp.process_claim(&w.keeper, &claim_id, &None); + assert_eq!(result, ClaimResult::Paid); + + let claim = cp.get_claim(&claim_id); + assert_eq!(claim.status, ClaimStatus::Paid); + + let balance = soroban_sdk::token::Client::new(&w.env, &w.usdc).balance(&buyer); + assert_eq!(balance, 5_000_000_000 - 4_109_589 + 1_000_000_000); +} + +/// Resolving a Paid claim must fail with AlreadyProcessed. +#[test] +#[should_panic(expected = "Error(Contract, #7)")] +fn test_resolve_dispute_paid_claim_fails() { + 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); + + let cp = ClaimsProcessorClient::new(&w.env, &w.claims_id); + let claim_id = cp.submit_claim(&buyer, &pol_id); + cp.process_claim(&w.keeper, &claim_id, &None); + + cp.resolve_dispute(&w.admin, &claim_id); +} diff --git a/contracts/claims-processor/src/types.rs b/contracts/claims-processor/src/types.rs index 5de6079..3ae4e40 100644 --- a/contracts/claims-processor/src/types.rs +++ b/contracts/claims-processor/src/types.rs @@ -113,6 +113,13 @@ pub struct ClaimDisputed { pub reason: Symbol, } +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ClaimResolved { + pub claim_id: u128, + pub resolver: Address, +} + /// Emitted when an overdue claim is escalated for manual review. #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] diff --git a/contracts/oracle-verifier/src/lib.rs b/contracts/oracle-verifier/src/lib.rs index adc7370..97142b0 100644 --- a/contracts/oracle-verifier/src/lib.rs +++ b/contracts/oracle-verifier/src/lib.rs @@ -1249,6 +1249,21 @@ impl OracleVerifier { Self::encryption_required(&env, &data_type) } + /// Admin-only: Invalidate all oracle data points for a given (data_type, key) pair. + /// This removes bad or stale data from storage, preventing it from being used + /// in future trigger verifications or aggregations. + pub fn invalidate_data(env: Env, admin: Address, data_type: Symbol, key: Symbol) { + Self::require_admin(&env, &admin); + + let storage_key = StorageKey::DataPoints(data_type.clone(), key.clone()); + env.storage().persistent().remove(&storage_key); + + env.events().publish( + (Symbol::new(&env, "oracle_data_invalidated"),), + (data_type, key), + ); + } + /// Submit an encrypted data point for a (data_type, key) pair. /// /// The contract never sees the plaintext value: `ciphertext` and `nonce` diff --git a/contracts/oracle-verifier/src/test.rs b/contracts/oracle-verifier/src/test.rs index 0799d06..0056803 100644 --- a/contracts/oracle-verifier/src/test.rs +++ b/contracts/oracle-verifier/src/test.rs @@ -1090,3 +1090,101 @@ fn test_disabling_encryption_requirement_restores_plaintext_path() { ); assert_eq!(client.get_data(&weather(), &kisumu_key()).value, 32_000_000); } + + +// ── Data invalidation (invalidate_data) ─────────────────────────────────────── + +/// Admin can invalidate all data for a (data_type, key) pair. +#[test] +fn test_invalidate_data_removes_points() { + let (env, admin, contract_id) = setup(); + let client = OracleVerifierClient::new(&env, &contract_id); + + let oracle = Address::generate(&env); + client.add_oracle(&admin, &oracle, &weather(), &90u32); + + // Submit data + client.submit_data(&oracle, &weather(), &kisumu_key(), &32_000_000i128, &95u32, &env.ledger().timestamp()); + + // Verify data exists + let data = client.get_data(&weather(), &kisumu_key()); + assert_eq!(data.value, 32_000_000i128); + + // Invalidate the data + client.invalidate_data(&admin, &weather(), &kisumu_key()); + + // Verify it was removed by checking aggregated data returns error + // (get_aggregated will panic if min oracle count not met and no data available) + // Since we only have one oracle and removed the data, the function should panic +} + +/// Attempting to get data after invalidation panics. +#[test] +#[should_panic(expected = "Error(Contract, #11)")] +fn test_get_data_after_invalidate_panics() { + let (env, admin, contract_id) = setup(); + let client = OracleVerifierClient::new(&env, &contract_id); + + let oracle = Address::generate(&env); + client.add_oracle(&admin, &oracle, &weather(), &90u32); + + // Submit data + client.submit_data(&oracle, &weather(), &kisumu_key(), &32_000_000i128, &95u32, &env.ledger().timestamp()); + + // Invalidate the data + client.invalidate_data(&admin, &weather(), &kisumu_key()); + + // Try to get the invalidated data - should panic with NoDataAvailable + client.get_data(&weather(), &kisumu_key()); +} + +/// Non-admin cannot invalidate data. +#[test] +#[should_panic(expected = "Error(Contract, #3)")] +fn test_invalidate_data_requires_admin() { + let (env, admin, contract_id) = setup(); + let client = OracleVerifierClient::new(&env, &contract_id); + + let oracle = Address::generate(&env); + let stranger = Address::generate(&env); + + client.add_oracle(&admin, &oracle, &weather(), &90u32); + client.submit_data(&oracle, &weather(), &kisumu_key(), &32_000_000i128, &95u32, &env.ledger().timestamp()); + + // Non-admin tries to invalidate + client.invalidate_data(&stranger, &weather(), &kisumu_key()); +} + +/// Invalidating non-existent data succeeds (idempotent). +#[test] +fn test_invalidate_nonexistent_data_succeeds() { + let (env, admin, contract_id) = setup(); + let client = OracleVerifierClient::new(&env, &contract_id); + + // Should not panic even though no data exists + client.invalidate_data(&admin, &weather(), &kisumu_key()); +} + +/// Invalidating data only affects that specific (data_type, key) pair. +#[test] +fn test_invalidate_data_scoped_to_key() { + let (env, admin, contract_id) = setup(); + let client = OracleVerifierClient::new(&env, &contract_id); + + let oracle = Address::generate(&env); + client.add_oracle(&admin, &oracle, &weather(), &90u32); + + let key1 = symbol_short!("kis2606"); + let key2 = symbol_short!("mom2606"); + + // Submit data for two different keys + client.submit_data(&oracle, &weather(), &key1, &32_000_000i128, &95u32, &env.ledger().timestamp()); + client.submit_data(&oracle, &weather(), &key2, &45_000_000i128, &90u32, &env.ledger().timestamp()); + + // Invalidate only key1 + client.invalidate_data(&admin, &weather(), &key1); + + // key2 data should still exist + let data = client.get_data(&weather(), &key2); + assert_eq!(data.value, 45_000_000i128); +} diff --git a/contracts/policy-engine/src/lib.rs b/contracts/policy-engine/src/lib.rs index 7ef3220..52399a7 100644 --- a/contracts/policy-engine/src/lib.rs +++ b/contracts/policy-engine/src/lib.rs @@ -957,6 +957,57 @@ impl PolicyEngine { Self::load_policy(&env, policy_id) } + /// Return aggregated statistics for a product: total policies, active count, + /// total coverage, and total premiums collected. Returns zeros if the product + /// does not exist or has no policies. + pub fn get_product_stats(env: Env, product_id: u128) -> ProductStats { + // Verify product exists + let _product = match env.storage().persistent() + .get::<_, InsuranceProduct>(&StorageKey::Product(product_id)) { + Some(p) => p, + None => return ProductStats { + product_id, + total_policies: 0, + active_policies: 0, + total_coverage: 0, + total_premium_collected: 0, + }, + }; + + // Aggregate stats across all policies for this product + let mut total_policies: u32 = 0; + let mut active_policies: u32 = 0; + let mut total_coverage: i128 = 0; + let mut total_premium_collected: i128 = 0; + + // Iterate through next_policy_id to find all policies + let next_id: u128 = env.storage().instance() + .get(&StorageKey::NextPolicyId) + .unwrap_or(1); + + for pid in 1..next_id { + if let Some(policy) = env.storage().persistent() + .get::<_, Policy>(&StorageKey::Policy(pid)) { + if policy.product_id == product_id { + total_policies += 1; + if policy.status == PolicyStatus::Active { + active_policies += 1; + } + total_coverage = total_coverage.saturating_add(policy.coverage_amount); + total_premium_collected = total_premium_collected.saturating_add(policy.premium_paid); + } + } + } + + ProductStats { + product_id, + total_policies, + active_policies, + total_coverage, + total_premium_collected, + } + } + /// Return a paginated slice of policy IDs owned by `user`. `offset` is the zero-based /// start index; `limit` caps the number of IDs returned and is itself clamped to /// `MAX_PAGE_SIZE`. @@ -1027,12 +1078,21 @@ impl PolicyEngine { pub fn emergency_pause(env: Env, admin: Address) { Self::require_admin(&env, &admin); env.storage().instance().set(&StorageKey::Paused, &true); + env.events().publish( + (Symbol::new(&env, "contract_paused"),), + ContractPaused { admin: admin.clone() }, + ); } -pub fn emergency_resume(env: Env, admin: Address) { - Self::require_admin(&env, &admin); - env.storage().instance().set(&StorageKey::Paused, &false); - } + /// Emergency resume — re-enables buy_policy for all products. + pub fn emergency_resume(env: Env, admin: Address) { + Self::require_admin(&env, &admin); + env.storage().instance().set(&StorageKey::Paused, &false); + env.events().publish( + (Symbol::new(&env, "contract_resumed"),), + ContractResumed { admin: admin.clone() }, + ); + } /// Propose a new admin. Only the current admin can call this. /// diff --git a/contracts/policy-engine/src/test.rs b/contracts/policy-engine/src/test.rs index 4b7b9d2..d02d7be 100644 --- a/contracts/policy-engine/src/test.rs +++ b/contracts/policy-engine/src/test.rs @@ -1233,3 +1233,149 @@ fn test_expired_policy_reports_not_active() { ExpiryState::NotActive ); } + + +// ── Product statistics (get_product_stats) ──────────────────────────────────── + +/// get_product_stats returns aggregated statistics for a product. +#[test] +fn test_get_product_stats_returns_zeros_for_nonexistent_product() { + let (env, _admin, _oracle, _usdc, contract_id) = setup(); + let client = PolicyEngineClient::new(&env, &contract_id); + + let stats = client.get_product_stats(&999_u128); + assert_eq!(stats.product_id, 999); + assert_eq!(stats.total_policies, 0); + assert_eq!(stats.active_policies, 0); + assert_eq!(stats.total_coverage, 0); + assert_eq!(stats.total_premium_collected, 0); +} + +/// get_product_stats aggregates across multiple policies for a product. +#[test] +fn test_get_product_stats_aggregates_policies() { + let (env, admin, _oracle, usdc, contract_id) = setup(); + let client = PolicyEngineClient::new(&env, &contract_id); + + let pid = create_crop_product(&env, &client, &admin); + + // Mint and fund USDC for multiple buyers + let buyer1 = Address::generate(&env); + let buyer2 = Address::generate(&env); + + StellarAssetClient::new(&env, &usdc).mint(&buyer1, &2_000_000_000i128); + StellarAssetClient::new(&env, &usdc).mint(&buyer2, &2_000_000_000i128); + + // Fund policy engine with coverage capital + StellarAssetClient::new(&env, &usdc).mint(&contract_id, &5_000_000_000i128); + + // Buy two policies + let pol1 = client.buy_policy(&buyer1, &pid, &COVERAGE, &30u32, &symbol_short!("kis2606")); + let pol2 = client.buy_policy(&buyer2, &pid, &(2_000_000_000i128), &60u32, &symbol_short!("kis2606")); + + // Get stats + let stats = client.get_product_stats(&pid); + + assert_eq!(stats.product_id, pid); + assert_eq!(stats.total_policies, 2, "should have 2 policies"); + assert_eq!(stats.active_policies, 2, "both policies should be active"); + assert_eq!(stats.total_coverage, COVERAGE + 2_000_000_000i128); + assert!(stats.total_premium_collected > 0, "premiums should be collected"); +} + +/// get_product_stats counts only Active policies as active. +#[test] +fn test_get_product_stats_counts_by_status() { + 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); + StellarAssetClient::new(&env, &usdc).mint(&buyer, &2_000_000_000i128); + StellarAssetClient::new(&env, &usdc).mint(&contract_id, &5_000_000_000i128); + + let _pol_id = client.buy_policy(&buyer, &pid, &COVERAGE, &30u32, &symbol_short!("kis2606")); + + // Check stats immediately after purchase + let stats = client.get_product_stats(&pid); + + assert_eq!(stats.total_policies, 1, "total count should be 1"); + assert_eq!(stats.active_policies, 1, "newly purchased policy is active"); + assert_eq!(stats.total_coverage, COVERAGE); + assert!(stats.total_premium_collected > 0); +} + + +// ── Emergency pause/resume (event emission) ────────────────────────────────── + +/// Admin can call emergency_pause and it blocks buy_policy. +#[test] +#[should_panic(expected = "Error(Contract, #3)")] +fn test_emergency_pause_blocks_buy_policy() { + 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); + StellarAssetClient::new(&env, &usdc).mint(&buyer, &2_000_000_000i128); + StellarAssetClient::new(&env, &usdc).mint(&contract_id, &5_000_000_000i128); + + // Buy policy succeeds before pause + let _pol1 = client.buy_policy(&buyer, &pid, &COVERAGE, &30u32, &symbol_short!("kis2606")); + + // Pause the contract + client.emergency_pause(&admin); + assert!(client.is_paused()); + + // Buy policy fails after pause (panics with Unauthorized = error 3) + client.buy_policy(&buyer, &pid, &COVERAGE, &30u32, &symbol_short!("kis2606")); +} + +/// Admin can call emergency_resume to re-enable buy_policy. +#[test] +fn test_emergency_resume_enables_buy_policy() { + 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); + StellarAssetClient::new(&env, &usdc).mint(&buyer, &2_000_000_000i128); + StellarAssetClient::new(&env, &usdc).mint(&contract_id, &5_000_000_000i128); + + // Pause then resume + client.emergency_pause(&admin); + assert!(client.is_paused()); + + client.emergency_resume(&admin); + assert!(!client.is_paused()); + + // Buy policy succeeds after resume + let _pol = client.buy_policy(&buyer, &pid, &COVERAGE, &30u32, &symbol_short!("kis2606")); +} + +/// Non-admin cannot pause contract. +#[test] +#[should_panic(expected = "Error(Contract, #3)")] +fn test_non_admin_cannot_pause() { + let (env, admin, _oracle, _usdc, contract_id) = setup(); + let client = PolicyEngineClient::new(&env, &contract_id); + + let stranger = Address::generate(&env); + client.emergency_pause(&stranger); +} + +/// Non-admin cannot resume contract. +#[test] +#[should_panic(expected = "Error(Contract, #3)")] +fn test_non_admin_cannot_resume() { + let (env, admin, _oracle, _usdc, contract_id) = setup(); + let client = PolicyEngineClient::new(&env, &contract_id); + + client.emergency_pause(&admin); + + let stranger = Address::generate(&env); + client.emergency_resume(&stranger); +} diff --git a/contracts/policy-engine/src/types.rs b/contracts/policy-engine/src/types.rs index 67625f1..985d96d 100644 --- a/contracts/policy-engine/src/types.rs +++ b/contracts/policy-engine/src/types.rs @@ -199,6 +199,18 @@ pub struct PolicyClaimed { pub coverage_amount: i128, } +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ContractPaused { + pub admin: Address, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ContractResumed { + pub admin: Address, +} + #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] pub struct PolicyExpired {