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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions contracts/claims-processor/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<u128> = 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.
Expand Down
114 changes: 114 additions & 0 deletions contracts/claims-processor/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
7 changes: 7 additions & 0 deletions contracts/claims-processor/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down
15 changes: 15 additions & 0 deletions contracts/oracle-verifier/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
98 changes: 98 additions & 0 deletions contracts/oracle-verifier/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
68 changes: 64 additions & 4 deletions contracts/policy-engine/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down Expand Up @@ -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.
///
Expand Down
Loading