From 2a13420c605b75d36961d8325eb71ea045dfcbd0 Mon Sep 17 00:00:00 2001 From: Nuel-ship-it Date: Wed, 29 Apr 2026 00:25:50 +0000 Subject: [PATCH] feat(insurance): add claims dispute resolution (#255) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement a three-step dispute resolution flow for rejected insurance claims: 1. raise_dispute – claimant disputes a rejected claim (one per claim) 2. vote_on_dispute – authorized assessors/admin cast votes 3. resolve_dispute – admin closes the dispute and applies the outcome; ClaimantWins re-approves the claim and executes payout, InsurerWins keeps the claim rejected. Changes: - types.rs: DisputeStatus (Open/Resolved/Dismissed), DisputeOutcome (ClaimantWins/InsurerWins), ClaimDispute struct - errors.rs: DisputeNotFound, DisputeAlreadyExists, DisputeNotOpen, AlreadyVoted, ClaimNotRejected - lib.rs: dispute storage fields, DisputeRaised/DisputeVoteCast/ DisputeResolved events, raise_dispute/vote_on_dispute/resolve_dispute messages, get_dispute/get_claim_dispute_id/get_dispute_count queries - tests.rs: 11 unit tests covering the full lifecycle Closes #255 --- contracts/insurance/src/errors.rs | 6 + contracts/insurance/src/lib.rs | 258 ++++++++++++++++++++++++++++++ contracts/insurance/src/tests.rs | 251 +++++++++++++++++++++++++++++ contracts/insurance/src/types.rs | 55 +++++++ 4 files changed, 570 insertions(+) diff --git a/contracts/insurance/src/errors.rs b/contracts/insurance/src/errors.rs index 48bd271f..b77607a9 100644 --- a/contracts/insurance/src/errors.rs +++ b/contracts/insurance/src/errors.rs @@ -23,4 +23,10 @@ pub enum InsuranceError { PropertyNotInsurable, DuplicateClaim, ReentrantCall, + // Dispute resolution errors (Issue #255) + DisputeNotFound, + DisputeAlreadyExists, + DisputeNotOpen, + AlreadyVoted, + ClaimNotRejected, } diff --git a/contracts/insurance/src/lib.rs b/contracts/insurance/src/lib.rs index e2bc2022..ac2c2b48 100644 --- a/contracts/insurance/src/lib.rs +++ b/contracts/insurance/src/lib.rs @@ -86,6 +86,14 @@ mod propchain_insurance { // Reentrancy protection reentrancy_guard: ReentrancyGuard, + + // ── Dispute resolution (Issue #255) ────────────────────────────────── + disputes: Mapping, + dispute_count: u64, + /// claim_id → dispute_id (one dispute per claim) + claim_dispute: Mapping, + /// dispute_id → set of voters (to prevent double-voting) + dispute_voters: Mapping<(u64, AccountId), bool>, } // ========================================================================= @@ -210,6 +218,38 @@ mod propchain_insurance { timestamp: u64, } + // ── Dispute resolution events (Issue #255) ──────────────────────────────── + + #[ink(event)] + pub struct DisputeRaised { + #[ink(topic)] + dispute_id: u64, + #[ink(topic)] + claim_id: u64, + #[ink(topic)] + claimant: AccountId, + timestamp: u64, + } + + #[ink(event)] + pub struct DisputeVoteCast { + #[ink(topic)] + dispute_id: u64, + voter: AccountId, + in_favour_of_claimant: bool, + } + + #[ink(event)] + pub struct DisputeResolved { + #[ink(topic)] + dispute_id: u64, + #[ink(topic)] + claim_id: u64, + outcome: DisputeOutcome, + resolved_by: AccountId, + timestamp: u64, + } + // ========================================================================= // IMPLEMENTATION // ========================================================================= @@ -246,6 +286,11 @@ mod propchain_insurance { claim_cooldown_period: 2_592_000, // 30 days in seconds min_pool_capital: 100_000_000_000, // Minimum pool capital reentrancy_guard: ReentrancyGuard::new(), + // Dispute resolution (Issue #255) + disputes: Mapping::default(), + dispute_count: 0, + claim_dispute: Mapping::default(), + dispute_voters: Mapping::default(), } } @@ -956,6 +1001,217 @@ mod propchain_insurance { Ok(()) } + // ===================================================================== + // DISPUTE RESOLUTION (Issue #255) + // ===================================================================== + + /// Raise a dispute against a rejected claim. + /// + /// Only the original claimant may dispute, and only when the claim + /// status is `Rejected`. Each claim may have at most one open dispute. + #[ink(message)] + pub fn raise_dispute( + &mut self, + claim_id: u64, + reason: String, + ) -> Result { + let caller = self.env().caller(); + let mut claim = self + .claims + .get(&claim_id) + .ok_or(InsuranceError::ClaimNotFound)?; + + if claim.claimant != caller { + return Err(InsuranceError::Unauthorized); + } + if claim.status != ClaimStatus::Rejected { + return Err(InsuranceError::ClaimNotRejected); + } + if self.claim_dispute.get(&claim_id).is_some() { + return Err(InsuranceError::DisputeAlreadyExists); + } + + let dispute_id = self.dispute_count + 1; + self.dispute_count = dispute_id; + + let now = self.env().block_timestamp(); + let dispute = ClaimDispute { + dispute_id, + claim_id, + claimant: caller, + reason, + status: DisputeStatus::Open, + outcome: None, + votes_for_claimant: 0, + votes_for_insurer: 0, + raised_at: now, + resolved_at: None, + resolved_by: None, + }; + + self.disputes.insert(&dispute_id, &dispute); + self.claim_dispute.insert(&claim_id, &dispute_id); + + // Mark claim as disputed + claim.status = ClaimStatus::Disputed; + self.claims.insert(&claim_id, &claim); + + self.env().emit_event(DisputeRaised { + dispute_id, + claim_id, + claimant: caller, + timestamp: now, + }); + + Ok(dispute_id) + } + + /// Cast a vote on an open dispute. + /// + /// Only authorized assessors or the admin may vote. Each address may + /// vote only once per dispute. + #[ink(message)] + pub fn vote_on_dispute( + &mut self, + dispute_id: u64, + in_favour_of_claimant: bool, + ) -> Result<(), InsuranceError> { + let caller = self.env().caller(); + if caller != self.admin + && !self.authorized_assessors.get(&caller).unwrap_or(false) + { + return Err(InsuranceError::Unauthorized); + } + + let mut dispute = self + .disputes + .get(&dispute_id) + .ok_or(InsuranceError::DisputeNotFound)?; + + if dispute.status != DisputeStatus::Open { + return Err(InsuranceError::DisputeNotOpen); + } + + let voter_key = (dispute_id, caller); + if self.dispute_voters.get(&voter_key).unwrap_or(false) { + return Err(InsuranceError::AlreadyVoted); + } + self.dispute_voters.insert(&voter_key, &true); + + if in_favour_of_claimant { + dispute.votes_for_claimant += 1; + } else { + dispute.votes_for_insurer += 1; + } + self.disputes.insert(&dispute_id, &dispute); + + self.env().emit_event(DisputeVoteCast { + dispute_id, + voter: caller, + in_favour_of_claimant, + }); + + Ok(()) + } + + /// Resolve an open dispute (admin only). + /// + /// The admin calls this to close voting and apply the outcome. + /// If `ClaimantWins`, the claim is re-approved and the payout executed. + /// If `InsurerWins`, the claim remains rejected. + #[ink(message)] + pub fn resolve_dispute( + &mut self, + dispute_id: u64, + outcome: DisputeOutcome, + ) -> Result<(), InsuranceError> { + non_reentrant!(self, { + self.ensure_admin()?; + + let mut dispute = self + .disputes + .get(&dispute_id) + .ok_or(InsuranceError::DisputeNotFound)?; + + if dispute.status != DisputeStatus::Open { + return Err(InsuranceError::DisputeNotOpen); + } + + let now = self.env().block_timestamp(); + let caller = self.env().caller(); + + dispute.status = DisputeStatus::Resolved; + dispute.outcome = Some(outcome.clone()); + dispute.resolved_at = Some(now); + dispute.resolved_by = Some(caller); + self.disputes.insert(&dispute_id, &dispute); + + if outcome == DisputeOutcome::ClaimantWins { + let mut claim = self + .claims + .get(&dispute.claim_id) + .ok_or(InsuranceError::ClaimNotFound)?; + + let policy = self + .policies + .get(&claim.policy_id) + .ok_or(InsuranceError::PolicyNotFound)?; + + let payout = if claim.claim_amount > policy.deductible { + claim.claim_amount.saturating_sub(policy.deductible) + } else { + 0 + }; + + claim.payout_amount = payout; + claim.status = ClaimStatus::Approved; + claim.processed_at = Some(now); + claim.assessor = Some(caller); + self.claims.insert(&dispute.claim_id, &claim); + + self.execute_payout(dispute.claim_id, claim.policy_id, claim.claimant, payout)?; + } else { + // Insurer wins — keep claim rejected + let mut claim = self + .claims + .get(&dispute.claim_id) + .ok_or(InsuranceError::ClaimNotFound)?; + claim.status = ClaimStatus::Rejected; + self.claims.insert(&dispute.claim_id, &claim); + } + + self.env().emit_event(DisputeResolved { + dispute_id, + claim_id: dispute.claim_id, + outcome, + resolved_by: caller, + timestamp: now, + }); + + Ok(()) + }) + } + + // ── Dispute queries ─────────────────────────────────────────────────── + + /// Get a dispute by ID. + #[ink(message)] + pub fn get_dispute(&self, dispute_id: u64) -> Option { + self.disputes.get(&dispute_id) + } + + /// Get the dispute ID for a claim (if any). + #[ink(message)] + pub fn get_claim_dispute_id(&self, claim_id: u64) -> Option { + self.claim_dispute.get(&claim_id) + } + + /// Get total dispute count. + #[ink(message)] + pub fn get_dispute_count(&self) -> u64 { + self.dispute_count + } + // ===================================================================== // ADMIN / AUTHORITY MANAGEMENT // ===================================================================== @@ -1287,3 +1543,5 @@ mod propchain_insurance { pub use crate::propchain_insurance::{InsuranceError, PropertyInsurance}; // Unit tests extracted to tests.rs (Issue #101) +#[path = "tests.rs"] +mod insurance_tests_module; diff --git a/contracts/insurance/src/tests.rs b/contracts/insurance/src/tests.rs index 5cb78755..034ddd3c 100644 --- a/contracts/insurance/src/tests.rs +++ b/contracts/insurance/src/tests.rs @@ -853,4 +853,255 @@ mod insurance_tests { let holder_policies = contract.get_policyholder_policies(accounts.bob); assert_eq!(holder_policies.len(), 2); } + + // ========================================================================= + // DISPUTE RESOLUTION TESTS (Issue #255) + // ========================================================================= + + use crate::propchain_insurance::{DisputeOutcome, DisputeStatus}; + + /// Helper: set up a contract with a pool, policy, and a rejected claim. + fn setup_rejected_claim() -> (PropertyInsurance, u64, u64) { + let accounts = test::default_accounts::(); + test::set_caller::(accounts.alice); + test::set_block_timestamp::(3_000_000); + let mut contract = PropertyInsurance::new(accounts.alice); + + // Pool + let pool_id = contract + .create_risk_pool("Test Pool".into(), CoverageType::Fire, 8000, 500_000_000_000u128) + .unwrap(); + test::set_value_transferred::(10_000_000_000_000u128); + contract.provide_pool_liquidity(pool_id).unwrap(); + + // Risk assessment + contract + .update_risk_assessment(1, 75, 80, 85, 90, 86_400 * 365) + .unwrap(); + + // Policy (bob) + let calc = contract + .calculate_premium(1, 500_000_000_000u128, CoverageType::Fire) + .unwrap(); + test::set_caller::(accounts.bob); + test::set_value_transferred::(calc.annual_premium * 2); + let policy_id = contract + .create_policy( + 1, + CoverageType::Fire, + 500_000_000_000u128, + pool_id, + 86_400 * 365, + "ipfs://test".into(), + ) + .unwrap(); + + // Claim (bob) + test::set_value_transferred::(0); + let claim_id = contract + .submit_claim(policy_id, 1_000_000u128, "Damage".into(), "ipfs://e".into()) + .unwrap(); + + // Reject the claim (alice as admin) + test::set_caller::(accounts.alice); + contract + .process_claim(claim_id, false, "ipfs://r".into(), "Insufficient evidence".into()) + .unwrap(); + + (contract, claim_id, policy_id) + } + + #[ink::test] + fn test_raise_dispute_works() { + let (mut contract, claim_id, _) = setup_rejected_claim(); + let accounts = test::default_accounts::(); + + test::set_caller::(accounts.bob); + let result = contract.raise_dispute(claim_id, "Evidence was valid".into()); + assert!(result.is_ok()); + let dispute_id = result.unwrap(); + assert_eq!(dispute_id, 1); + assert_eq!(contract.get_dispute_count(), 1); + + let dispute = contract.get_dispute(dispute_id).unwrap(); + assert_eq!(dispute.claim_id, claim_id); + assert_eq!(dispute.claimant, accounts.bob); + assert_eq!(dispute.status, DisputeStatus::Open); + assert!(dispute.outcome.is_none()); + + // Claim should now be Disputed + let claim = contract.get_claim(claim_id).unwrap(); + assert_eq!(claim.status, ClaimStatus::Disputed); + + // claim_dispute mapping populated + assert_eq!(contract.get_claim_dispute_id(claim_id), Some(dispute_id)); + } + + #[ink::test] + fn test_raise_dispute_non_claimant_fails() { + let (mut contract, claim_id, _) = setup_rejected_claim(); + let accounts = test::default_accounts::(); + + test::set_caller::(accounts.charlie); + let result = contract.raise_dispute(claim_id, "Not my claim".into()); + assert_eq!(result, Err(InsuranceError::Unauthorized)); + } + + #[ink::test] + fn test_raise_dispute_on_non_rejected_claim_fails() { + let mut contract = setup(); + let accounts = test::default_accounts::(); + let pool_id = contract + .create_risk_pool("Pool".into(), CoverageType::Fire, 8000, 500_000_000_000u128) + .unwrap(); + test::set_value_transferred::(10_000_000_000_000u128); + contract.provide_pool_liquidity(pool_id).unwrap(); + add_risk_assessment(&mut contract, 1); + let calc = contract + .calculate_premium(1, 500_000_000_000u128, CoverageType::Fire) + .unwrap(); + test::set_caller::(accounts.bob); + test::set_value_transferred::(calc.annual_premium * 2); + let policy_id = contract + .create_policy(1, CoverageType::Fire, 500_000_000_000u128, pool_id, 86_400 * 365, "ipfs://t".into()) + .unwrap(); + test::set_value_transferred::(0); + let claim_id = contract + .submit_claim(policy_id, 1_000_000u128, "Damage".into(), "ipfs://e".into()) + .unwrap(); + // Claim is Pending, not Rejected + let result = contract.raise_dispute(claim_id, "reason".into()); + assert_eq!(result, Err(InsuranceError::ClaimNotRejected)); + } + + #[ink::test] + fn test_raise_duplicate_dispute_fails() { + let (mut contract, claim_id, _) = setup_rejected_claim(); + let accounts = test::default_accounts::(); + + test::set_caller::(accounts.bob); + contract.raise_dispute(claim_id, "First".into()).unwrap(); + let result = contract.raise_dispute(claim_id, "Second".into()); + assert_eq!(result, Err(InsuranceError::DisputeAlreadyExists)); + } + + #[ink::test] + fn test_vote_on_dispute_works() { + let (mut contract, claim_id, _) = setup_rejected_claim(); + let accounts = test::default_accounts::(); + + test::set_caller::(accounts.bob); + let dispute_id = contract.raise_dispute(claim_id, "Evidence valid".into()).unwrap(); + + // Alice (admin) votes for claimant + test::set_caller::(accounts.alice); + assert!(contract.vote_on_dispute(dispute_id, true).is_ok()); + let dispute = contract.get_dispute(dispute_id).unwrap(); + assert_eq!(dispute.votes_for_claimant, 1); + assert_eq!(dispute.votes_for_insurer, 0); + + // Charlie (assessor) votes for insurer + contract.authorize_assessor(accounts.charlie).unwrap(); + test::set_caller::(accounts.charlie); + assert!(contract.vote_on_dispute(dispute_id, false).is_ok()); + let dispute = contract.get_dispute(dispute_id).unwrap(); + assert_eq!(dispute.votes_for_insurer, 1); + } + + #[ink::test] + fn test_vote_unauthorized_fails() { + let (mut contract, claim_id, _) = setup_rejected_claim(); + let accounts = test::default_accounts::(); + + test::set_caller::(accounts.bob); + let dispute_id = contract.raise_dispute(claim_id, "reason".into()).unwrap(); + + // Bob is not an assessor + let result = contract.vote_on_dispute(dispute_id, true); + assert_eq!(result, Err(InsuranceError::Unauthorized)); + } + + #[ink::test] + fn test_double_vote_fails() { + let (mut contract, claim_id, _) = setup_rejected_claim(); + let accounts = test::default_accounts::(); + + test::set_caller::(accounts.bob); + let dispute_id = contract.raise_dispute(claim_id, "reason".into()).unwrap(); + + test::set_caller::(accounts.alice); + contract.vote_on_dispute(dispute_id, true).unwrap(); + let result = contract.vote_on_dispute(dispute_id, false); + assert_eq!(result, Err(InsuranceError::AlreadyVoted)); + } + + #[ink::test] + fn test_resolve_dispute_claimant_wins() { + let (mut contract, claim_id, _) = setup_rejected_claim(); + let accounts = test::default_accounts::(); + + test::set_caller::(accounts.bob); + let dispute_id = contract.raise_dispute(claim_id, "Evidence valid".into()).unwrap(); + + test::set_caller::(accounts.alice); + let result = contract.resolve_dispute(dispute_id, DisputeOutcome::ClaimantWins); + assert!(result.is_ok()); + + let dispute = contract.get_dispute(dispute_id).unwrap(); + assert_eq!(dispute.status, DisputeStatus::Resolved); + assert_eq!(dispute.outcome, Some(DisputeOutcome::ClaimantWins)); + + // Claim should be Approved (or Paid after payout) + let claim = contract.get_claim(claim_id).unwrap(); + assert!( + claim.status == ClaimStatus::Approved || claim.status == ClaimStatus::Paid, + "expected Approved or Paid, got {:?}", claim.status + ); + } + + #[ink::test] + fn test_resolve_dispute_insurer_wins() { + let (mut contract, claim_id, _) = setup_rejected_claim(); + let accounts = test::default_accounts::(); + + test::set_caller::(accounts.bob); + let dispute_id = contract.raise_dispute(claim_id, "reason".into()).unwrap(); + + test::set_caller::(accounts.alice); + let result = contract.resolve_dispute(dispute_id, DisputeOutcome::InsurerWins); + assert!(result.is_ok()); + + let dispute = contract.get_dispute(dispute_id).unwrap(); + assert_eq!(dispute.outcome, Some(DisputeOutcome::InsurerWins)); + + let claim = contract.get_claim(claim_id).unwrap(); + assert_eq!(claim.status, ClaimStatus::Rejected); + } + + #[ink::test] + fn test_resolve_dispute_unauthorized_fails() { + let (mut contract, claim_id, _) = setup_rejected_claim(); + let accounts = test::default_accounts::(); + + test::set_caller::(accounts.bob); + let dispute_id = contract.raise_dispute(claim_id, "reason".into()).unwrap(); + + // Bob is not admin + let result = contract.resolve_dispute(dispute_id, DisputeOutcome::ClaimantWins); + assert_eq!(result, Err(InsuranceError::Unauthorized)); + } + + #[ink::test] + fn test_resolve_already_resolved_dispute_fails() { + let (mut contract, claim_id, _) = setup_rejected_claim(); + let accounts = test::default_accounts::(); + + test::set_caller::(accounts.bob); + let dispute_id = contract.raise_dispute(claim_id, "reason".into()).unwrap(); + + test::set_caller::(accounts.alice); + contract.resolve_dispute(dispute_id, DisputeOutcome::InsurerWins).unwrap(); + let result = contract.resolve_dispute(dispute_id, DisputeOutcome::ClaimantWins); + assert_eq!(result, Err(InsuranceError::DisputeNotOpen)); + } } diff --git a/contracts/insurance/src/types.rs b/contracts/insurance/src/types.rs index 701e5ae8..09d9c4b7 100644 --- a/contracts/insurance/src/types.rs +++ b/contracts/insurance/src/types.rs @@ -1,4 +1,59 @@ // Data types for the insurance contract (Issue #101 - extracted from lib.rs) +// Dispute resolution types added for Issue #255 + +/// Lifecycle status of a claim dispute. +#[derive( + Debug, + Clone, + PartialEq, + Eq, + scale::Encode, + scale::Decode, + ink::storage::traits::StorageLayout, +)] +#[cfg_attr(feature = "std", derive(scale_info::TypeInfo))] +pub enum DisputeStatus { + Open, + Resolved, + Dismissed, +} + +/// Outcome of a resolved dispute. +#[derive( + Debug, + Clone, + PartialEq, + Eq, + scale::Encode, + scale::Decode, + ink::storage::traits::StorageLayout, +)] +#[cfg_attr(feature = "std", derive(scale_info::TypeInfo))] +pub enum DisputeOutcome { + /// Original rejection overturned; claim approved and paid. + ClaimantWins, + /// Original decision upheld; claim stays rejected. + InsurerWins, +} + +/// A dispute raised against a rejected insurance claim. +#[derive( + Debug, Clone, PartialEq, scale::Encode, scale::Decode, ink::storage::traits::StorageLayout, +)] +#[cfg_attr(feature = "std", derive(scale_info::TypeInfo))] +pub struct ClaimDispute { + pub dispute_id: u64, + pub claim_id: u64, + pub claimant: AccountId, + pub reason: String, + pub status: DisputeStatus, + pub outcome: Option, + pub votes_for_claimant: u32, + pub votes_for_insurer: u32, + pub raised_at: u64, + pub resolved_at: Option, + pub resolved_by: Option, +} #[derive( Debug,