diff --git a/contracts/claims-processor/src/lib.rs b/contracts/claims-processor/src/lib.rs index 013fc0a..ebd7a8e 100644 --- a/contracts/claims-processor/src/lib.rs +++ b/contracts/claims-processor/src/lib.rs @@ -1,5 +1,7 @@ -#![allow(dead_code)] -#![allow(unused_imports)] +// Address/state validation must fail with a typed contract error so callers +// can match on it programmatically, never with a raw panic! and a string +// message. +#![deny(clippy::panic)] //! Parashield Claims Processor //! //! Evaluates whether a policy's trigger condition has been met by querying the @@ -20,8 +22,8 @@ extern crate alloc; use alloc::string::ToString; use soroban_sdk::{ - contract, contracterror, contractimpl, contracttype, panic_with_error, Address, BytesN, Env, - Symbol, Vec, + contract, contractimpl, contracttype, contracterror, panic_with_error, + Address, BytesN, Env, Vec, Symbol, }; pub mod types; @@ -32,6 +34,7 @@ pub use types::*; #[soroban_sdk::contractclient(name = "RiskPoolClient")] trait IRiskPool { fn release_for_claim(env: Env, caller: Address, policy_id: u128); + fn release_for_expiry(env: Env, caller: Address, policy_id: u128); } #[soroban_sdk::contractclient(name = "PolicyEngineClient")] @@ -56,11 +59,32 @@ trait IOracleVerifier { /// Extend a persistent entry's TTL once it has fewer than ~30 days of life left /// (at ~5s/ledger). +// Issue #342: kept in sync by hand across all 5 contracts (governance-dao, +// risk-pool, policy-engine, oracle-verifier, claims-processor) — extracting +// to a shared crate is a real follow-up, not done here to avoid touching +// every contract's Cargo.toml in one pass. 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; + +/// Grace period between an admin transfer being fully proposed/approved and the +/// proposed admin being able to `accept_admin` (issue #356). Hand-synced across +/// the 4 contracts that expose admin rotation (policy-engine, risk-pool, +/// oracle-verifier, claims-processor). +const ADMIN_TRANSFER_TIMELOCK: u64 = 48 * 60 * 60; + +// ─── Batch processing ───────────────────────────────────────────────────────── + +/// Hard ceiling on how many claims a single `batch_auto_process` call may +/// settle. +/// +/// Each claim in the batch costs an oracle read plus a cross-contract call into +/// the policy-engine, so an unbounded batch would exhaust Soroban's per +/// transaction instruction budget and fail with an opaque gas error — taking +/// the whole batch down with it. Capping keeps every call within budget; +/// callers with a longer queue simply invoke the function again. +pub const MAX_BATCH_SIZE: u32 = 50; // ─── Storage keys ───────────────────────────────────────────────────────────── @@ -71,15 +95,40 @@ 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 + /// Proposed next admin awaiting `accept_admin` (issue #356). + PendingAdmin, + /// Ledger timestamp (u64) at which `PendingAdmin` was set, used to enforce + /// `ADMIN_TRANSFER_TIMELOCK` before `accept_admin` succeeds. + PendingAdminSince, + /// A pending admin-transfer proposal awaiting guardian approvals. + PendingAdminChange, /// Contract version (u32) for storage migration tracking Version, + /// Guardian addresses authorized to approve critical actions (Vec
). + Guardians, + /// Number of guardian approvals required to execute a critical action + /// (u32). 0 means guardian multisig is disabled (admin acts alone). + GuardianThreshold, + /// A pending, not-yet-executed contract upgrade awaiting guardian approvals. + PendingUpgrade, + /// Seconds a claim may sit Pending before it can be escalated (u64). + EscalationThreshold, + /// Maximum seconds after a policy's `end_time` during which a claim may + /// still be submitted for the triggering event (u64). `0` means a claim + /// can only be filed while the policy is Active (behaves as before). + ClaimDeadline, + /// Configurable delay in seconds between claim approval and payout (u64). + /// 0 = immediate payout (default behavior). + PayoutDelay, + /// Identity attestation requirement: product_id → Symbol (id_type required). + IdentityRequirement(u128), } // ─── Errors ─────────────────────────────────────────────────────────────────── @@ -89,16 +138,65 @@ 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, + PolicyExpired = 10, + InvalidVersion = 11, + NotGuardian = 12, + AlreadyApprovedAction = 13, + NoPendingUpgrade = 14, + InvalidThreshold = 15, + AdminTimelockNotExpired = 16, + NotEscalatable = 17, + InvalidThresholdValue = 18, + /// A `submit_claim` arrived after `end_time + claim_deadline` had elapsed, + /// so the window to file a claim for the triggering event has closed. + ClaimDeadlinePassed = 19, + /// Payout delay has not yet elapsed — the claim cannot be settled now. + PayoutDelayNotElapsed = 20, + /// Identity verification required for this claim category but not verified. + IdentityVerificationRequired = 21, + InvalidInput = 22, } +/// Approximate Stellar ledger close time in seconds, used to convert +/// wall-clock TTL windows into ledger counts for `extend_ttl`. +const LEDGER_SECONDS: u64 = 5; + +/// Claim and PolicyClaim entries must survive from submission until the +/// claim is finally settled (Paid/Rejected) — including disputes, which have +/// no automatic timeout. 365 days comfortably covers policy-engine's longest +/// policy durations plus dispute-resolution time; capped to the network's +/// max TTL at call time so `extend_ttl` never panics. +const CLAIM_RETENTION_SECONDS: u64 = 365 * 24 * 60 * 60; + +/// How long a claim may sit Pending before anyone can escalate it, when the +/// admin has not configured a threshold. +/// +/// Seven days is long enough that ordinary keeper latency, oracle data still +/// arriving, or a quiet weekend do not trip it, and short enough that a +/// claimant is not left indefinitely without recourse. It is the point past +/// which "still processing" stops being a plausible explanation. +const DEFAULT_ESCALATION_THRESHOLD: u64 = 7 * 24 * 60 * 60; + +/// Shortest escalation threshold an admin may configure (1 hour). A near-zero +/// threshold would let every claim be escalated on submission, which turns the +/// signal into noise and defeats the purpose of having one. +const MIN_ESCALATION_THRESHOLD: u64 = 60 * 60; + +/// Default window after a policy's `end_time` during which a claim may still be +/// submitted. 30 days is long enough to cover keeper latency, oracle data +/// still arriving, or a claimant simply not noticing the trigger immediately, +/// while still putting a firm upper bound on how long a claim can be filed +/// after the event it relates to (issue #386). +const DEFAULT_CLAIM_DEADLINE: u64 = 30 * 24 * 60 * 60; + // ─── Contract ───────────────────────────────────────────────────────────────── #[contract] @@ -106,6 +204,7 @@ pub struct ClaimsProcessor; #[contractimpl] impl ClaimsProcessor { + // ── Lifecycle ──────────────────────────────────────────────────────────── /// One-time initialisation. Links the contract to `policy_engine`, `risk_pool`, and @@ -122,71 +221,24 @@ impl ClaimsProcessor { if env.storage().instance().has(&StorageKey::Initialized) { panic_with_error!(&env, Error::AlreadyInitialized); } - let admin_str = admin.to_string(); - - if false { - panic!("invalid address: admin must be an account address"); - } - if admin_str.len() != 56 { - panic!("invalid address: admin must be an account or contract address"); - } - let mut admin_buf = [0u8; 56]; - admin_str.copy_into_slice(&mut admin_buf); - if admin_buf[0] != b'G' && admin_buf[0] != b'C' { - panic!("invalid address: admin must be an account or contract address"); - } - - 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"); - } - let mut policy_engine_buf = [0u8; 56]; - policy_engine_str.copy_into_slice(&mut policy_engine_buf); - if policy_engine_buf[0] != b'C' { - panic!("invalid address: policy_engine must be a contract address"); - } - let oracle_verifier_str = oracle_verifier.to_string(); - if oracle_verifier_str.len() != 56 { - panic!("invalid address: oracle_verifier must be a contract address"); - } - let mut oracle_verifier_buf = [0u8; 56]; - oracle_verifier_str.copy_into_slice(&mut oracle_verifier_buf); - if oracle_verifier_buf[0] != b'C' { - panic!("invalid address: oracle_verifier must be a contract address"); - } admin.require_auth(); Self::validate_stellar_address(&env, &admin); 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.storage().instance().set(&StorageKey::ClaimDeadline, &DEFAULT_CLAIM_DEADLINE); env.events().publish( (Symbol::new(&env, "initialized"),), @@ -206,27 +258,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.storage().persistent().extend_ttl(&StorageKey::Keeper(keeper.clone()), TTL_THRESHOLD, TTL_EXTEND_TO); + 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) } @@ -240,21 +292,16 @@ 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); @@ -263,8 +310,21 @@ impl ClaimsProcessor { panic_with_error!(&env, Error::PolicyNotActive); } - let claim_id = Self::next_claim_id(&env); + // Guard: reject expired policies even if status hasn't been updated yet. + // A direct contract caller could bypass the backend's status check, so + // we verify end_time at the contract level. A claim may still be filed + // for a bounded window after the policy ends (`claim_deadline`); once + // that window closes the triggering event is too old to act on and the + // submission is rejected (issue #386). let now = env.ledger().timestamp(); + if policy.end_time > 0 { + let cutoff = policy.end_time.saturating_add(Self::claim_deadline(&env)); + if now > cutoff { + panic_with_error!(&env, Error::ClaimDeadlinePassed); + } + } + + let claim_id = Self::next_claim_id(&env); let claim = Claim { id: claim_id, policy_id, @@ -276,33 +336,23 @@ impl ClaimsProcessor { submitted_at: now, processed_at: None, dispute_reason: None, + paid_amount: None, + partial_payout_bps: None, + installments: Vec::new(&env), + payout_ready_at: None, + identity_verified: false, + verification_type: None, + verification_time: 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"),), @@ -317,13 +367,49 @@ impl ClaimsProcessor { claim_id } + /// Submit multiple claims in a single transaction up to MAX_BATCH_SIZE. + pub fn batch_submit_claims(env: Env, claimant: Address, policy_ids: Vec) -> Vec { + claimant.require_auth(); + Self::require_not_paused(&env); + + let mut claim_ids = Vec::new(&env); + let count = if policy_ids.len() > MAX_BATCH_SIZE { + MAX_BATCH_SIZE + } else { + policy_ids.len() + }; + + for i in 0..count { + let pid = policy_ids.get_unchecked(i); + let cid = Self::submit_claim(env.clone(), claimant.clone(), pid); + claim_ids.push_back(cid); + } + + env.events().publish( + (Symbol::new(&env, "batch_claims_submitted"),), + BatchClaimsSubmitted { + claimant, + count, + }, + ); + + claim_ids + } + /// Process an existing pending claim. Reads oracle data and pays out or rejects. - pub fn process_claim(env: Env, keeper: Address, claim_id: u128) -> ClaimResult { + /// + /// `partial_payout_bps` is an optional payout ratio in basis points (0-10000). + /// - `None` or `Some(10000)` → full coverage payment (default behavior). + /// - `Some(bps)` where bps < 10000 → proportional partial payment, e.g. `Some(5000)` pays 50%. + pub fn process_claim( + env: Env, + keeper: Address, + claim_id: u128, + partial_payout_bps: Option, + ) -> 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)); @@ -331,82 +417,112 @@ 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, partial_payout_bps) + } + + /// Process multiple existing claims in a single transaction up to MAX_BATCH_SIZE. + pub fn batch_process_claims( + env: Env, + keeper: Address, + claim_ids: Vec, + partial_payout_bps: Option, + ) -> Vec<(u128, ClaimResult)> { + Self::require_keeper(&env, &keeper); + Self::require_not_paused(&env); + + let mut results = Vec::new(&env); + let count = if claim_ids.len() > MAX_BATCH_SIZE { + MAX_BATCH_SIZE + } else { + claim_ids.len() + }; + + for i in 0..count { + let cid = claim_ids.get_unchecked(i); + let res = Self::process_claim(env.clone(), keeper.clone(), cid, partial_payout_bps); + results.push_back((cid, res)); + } + + env.events().publish( + (Symbol::new(&env, "batch_claims_processed"),), + BatchClaimsProcessed { + keeper, + count, + }, + ); - Self::evaluate_and_settle(&env, &mut claim, &policy) + results } + /// Keeper-triggered automatic processing — no prior `submit_claim` needed. /// This is the primary flow for parametric insurance. /// Returns AlreadyClaimed / Expired idempotently if policy is already settled. - pub fn auto_process(env: Env, keeper: Address, policy_id: u128) -> ClaimResult { + /// + /// `partial_payout_bps` — see `process_claim` for details. + pub fn auto_process( + env: Env, + keeper: Address, + policy_id: u128, + partial_payout_bps: Option, + ) -> ClaimResult { Self::require_keeper(&env, &keeper); Self::require_not_paused(&env); // ─── 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 let now = env.ledger().timestamp(); if now > policy.end_time { + let risk_pool: Address = env.storage().instance() + .get(&StorageKey::RiskPool) + .unwrap_or_else(|| panic_with_error!(&env, Error::NotInitialized)); PolicyEngineClient::new(&env, &policy_engine) .expire_policy(&env.current_contract_address(), &policy_id); + // Atomic lock release, mirroring the payout path: expiring a policy + // and freeing its earmarked capital happen in one transaction, so a + // crash between the two cannot strand liquidity in the pool. If the + // release fails the whole call reverts and the policy stays Active. + RiskPoolClient::new(&env, &risk_pool) + .release_for_expiry(&env.current_contract_address(), &policy_id); return ClaimResult::Expired; } // 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 { @@ -420,89 +536,87 @@ impl ClaimsProcessor { submitted_at: now, processed_at: None, dispute_reason: None, + paid_amount: None, + partial_payout_bps: None, + installments: Vec::new(&env), + payout_ready_at: None, + identity_verified: false, + verification_type: None, + verification_time: 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); + + // Emit claim_submitted event for off-chain indexing + env.events().publish( + (Symbol::new(&env, "claim_submitted"),), + ClaimSubmitted { + claim_id: cid, + policy_id, + claimant: policy.policyholder.clone(), + coverage_amount: policy.coverage_amount, + }, + ); + 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; } - Self::evaluate_and_settle(&env, &mut claim, &policy) + Self::evaluate_and_settle(&env, &mut claim, &policy, partial_payout_bps) } /// Process up to `limit` pending claims parametrically in one call. /// Returns a Vec of (claim_id, result) pairs for the processed claims. /// Skips any claim that is not in Pending status (idempotent). + /// + /// `limit` is clamped to [`MAX_BATCH_SIZE`]. Passing a larger value (or + /// `u32::MAX`) is not an error — it simply settles the first + /// `MAX_BATCH_SIZE` pending claims, keeping the transaction inside + /// Soroban's instruction budget. Call again to drain the rest of the queue. 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 { + let effective_limit = if limit > MAX_BATCH_SIZE { MAX_BATCH_SIZE } else { limit }; + let process_count = if pending.len() < effective_limit { pending.len() } else { - limit + effective_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 result = Self::evaluate_and_settle(&env, &mut claim, &policy); + let policy = PolicyEngineClient::new(&env, &policy_engine) + .get_policy(&claim.policy_id); + let result = Self::evaluate_and_settle(&env, &mut claim, &policy, None); results.push_back((claim_id, result)); } results @@ -515,25 +629,24 @@ 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); - } - // 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. - if claim.status != ClaimStatus::Pending && claim.status != ClaimStatus::Rejected { + if claim.claimant != claimant { panic_with_error!(&env, Error::Unauthorized); } + // Only open (Pending), rejected, or partially-paid claims are disputable. + // Fully Paid claims are already settled (USDC transferred), Disputed + // claims are already open, and Expired claims should not be reopened. + if claim.status != ClaimStatus::Pending + && claim.status != ClaimStatus::Rejected + && claim.status != ClaimStatus::PartiallyPaid + { panic_with_error!(&env, Error::AlreadyProcessed); } claim.status = ClaimStatus::Disputed; claim.dispute_reason = Some(reason.clone()); - env.storage() - .persistent() - .set(&StorageKey::Claim(claim_id), &claim); + let claim_key = StorageKey::Claim(claim_id); + env.storage().persistent().set(&claim_key, &claim); + Self::extend_claim_ttl(&env, &claim_key); // 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. @@ -549,236 +662,1043 @@ 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. 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)) } + /// Schedule installment payouts for a large claim. + /// This allows claims to be paid out over time rather than as a single lump sum. + /// + /// Parameters: + /// - `claim_id`: The claim to schedule installments for + /// - `amount_per_installment`: Amount to pay per installment + /// - `num_installments`: Total number of installments + /// - `interval_seconds`: Seconds between each installment + pub fn schedule_installments( + env: Env, + caller: Address, + claim_id: u128, + amount_per_installment: i128, + num_installments: u32, + interval_seconds: u64, + ) { + Self::require_keeper(&env, &caller); + Self::require_not_paused(&env); + + let mut claim = Self::get_claim(env.clone(), claim_id); + + // Only schedule installments for approved claims + if claim.status != ClaimStatus::Paid && claim.status != ClaimStatus::PartiallyPaid { + panic_with_error!(&env, Error::InvalidInput); + } + + // Total installment amount should not exceed coverage + let total_amount = amount_per_installment.saturating_mul(num_installments as i128); + if total_amount > claim.coverage_amount { + panic_with_error!(&env, Error::InvalidInput); + } + + let now = env.ledger().timestamp(); + let schedule = InstallmentSchedule { + total_amount, + amount_per_installment, + num_installments, + interval_seconds, + first_installment_at: now.saturating_add(interval_seconds), + paid_count: 0, + }; + + claim.installments = Vec::from_array(&env, [schedule.clone()]); + env.storage().persistent().set(&StorageKey::Claim(claim_id), &claim); + env.storage().persistent().extend_ttl(&StorageKey::Claim(claim_id), TTL_THRESHOLD, TTL_EXTEND_TO); + + env.events().publish( + (Symbol::new(&env, "installment_payout_scheduled"),), + InstallmentPayoutScheduled { + claim_id, + policy_id: claim.policy_id, + claimant: claim.claimant.clone(), + total_amount, + num_installments, + interval_seconds, + first_installment_at: schedule.first_installment_at, + }, + ); + } + + /// Claim the next installment for a scheduled claim. + /// Can be called by the claimant to collect available installments. + pub fn claim_installment(env: Env, claimant: Address, claim_id: u128) -> i128 { + claimant.require_auth(); + Self::require_not_paused(&env); + + let mut claim = Self::get_claim(env.clone(), claim_id); + + if claim.claimant != claimant { + panic_with_error!(&env, Error::Unauthorized); + } + + let schedule = claim.installments.first() + .unwrap_or_else(|| panic_with_error!(&env, Error::InvalidInput)); + + // Check if there are remaining installments + if schedule.paid_count >= schedule.num_installments { + panic_with_error!(&env, Error::InvalidInput); + } + + let now = env.ledger().timestamp(); + + // Calculate which installments are now available + let installments_available = if now >= schedule.first_installment_at { + ((now - schedule.first_installment_at) / schedule.interval_seconds).saturating_add(1) + .min(schedule.num_installments as u64) as u32 + } else { + 0 + }; + + if installments_available <= schedule.paid_count { + panic_with_error!(&env, Error::InvalidInput); + } + + // Pay out all available installments + let amount_to_pay = schedule.amount_per_installment + .saturating_mul((installments_available - schedule.paid_count) as i128); + + let total_installments = schedule.num_installments; + + // Transfer funds from risk pool + let risk_pool: Address = env.storage().instance() + .get(&StorageKey::RiskPool) + .unwrap_or_else(|| panic_with_error!(&env, Error::NotInitialized)); + + let pool_client = RiskPoolClient::new(&env, &risk_pool); + pool_client.release_for_claim(&env.current_contract_address(), &claim.policy_id); + + // Update installment schedule + if let Some(mut sched) = claim.installments.first() { + sched.paid_count = installments_available; + claim.installments.set(0, sched); + } + + env.storage().persistent().set(&StorageKey::Claim(claim_id), &claim); + env.storage().persistent().extend_ttl(&StorageKey::Claim(claim_id), TTL_THRESHOLD, TTL_EXTEND_TO); + + env.events().publish( + (Symbol::new(&env, "installment_paid"),), + InstallmentPaid { + claim_id, + claimant, + amount: amount_to_pay, + paid_count: installments_available, + total_installments, + }, + ); + + amount_to_pay + } + /// 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. + /// + /// If a guardian threshold > 0 is configured (`set_guardians`), this call + /// does not upgrade immediately — it registers the upgrade as pending and + /// requires `threshold` guardians to call `approve_upgrade` before the + /// WASM is actually replaced, guarding this irreversible operation + /// against a single compromised admin key. 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"); + panic_with_error!(&env, Error::InvalidVersion); } - // Run migrations from current_version to new_version - Self::run_migrations(&env, current_version, new_version); - - // Update the stored version - env.storage() + let threshold: u32 = env + .storage() .instance() - .set(&StorageKey::Version, &new_version); + .get(&StorageKey::GuardianThreshold) + .unwrap_or(0); + if threshold == 0 { + Self::run_migrations(&env, current_version, new_version); + env.storage().instance().set(&StorageKey::Version, &new_version); + env.deployer().update_current_contract_wasm(new_wasm_hash); - // Perform the actual WASM upgrade - env.deployer().update_current_contract_wasm(new_wasm_hash); + env.events().publish( + (Symbol::new(&env, "contract_upgraded"),), + ContractUpgraded { + old_version: current_version, + new_version, + }, + ); + return; + } - env.events().publish( - (Symbol::new(&env, "contract_upgraded"),), - ContractUpgraded { - old_version: current_version, - new_version, - }, - ); + let pending = PendingUpgrade { + new_wasm_hash, + new_version, + approvals: Vec::new(&env), + }; + env.storage().instance().set(&StorageKey::PendingUpgrade, &pending); + } + + /// Configure the guardian set and approval threshold required for + /// critical actions (currently: contract upgrades). Admin-only. + /// `threshold == 0` disables the guardian requirement (default), so the + /// admin alone can act — preserves existing single-admin behavior until + /// guardians are explicitly configured. + pub fn set_guardians(env: Env, admin: Address, guardians: Vec
, threshold: u32) { + 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); } + admin.require_auth(); + if threshold > guardians.len() { + panic_with_error!(&env, Error::InvalidThreshold); + } + env.storage().instance().set(&StorageKey::Guardians, &guardians); + env.storage() + .instance() + .set(&StorageKey::GuardianThreshold, &threshold); + env.events().publish( + (Symbol::new(&env, "guardians_updated"),), + GuardiansUpdated { guardians, threshold }, + ); + } + + /// Return the current guardian set. + pub fn get_guardians(env: Env) -> Vec
{ + env.storage() + .instance() + .get(&StorageKey::Guardians) + .unwrap_or_else(|| Vec::new(&env)) + } + + /// Return the current guardian approval threshold (0 = disabled). + pub fn get_guardian_threshold(env: Env) -> u32 { + env.storage() + .instance() + .get(&StorageKey::GuardianThreshold) + .unwrap_or(0) + } + + /// Return the pending upgrade awaiting guardian approvals, if any. + pub fn get_pending_upgrade(env: Env) -> Option { + env.storage().instance().get(&StorageKey::PendingUpgrade) + } + + /// Guardian approval for the pending upgrade. Once enough guardians have + /// approved (>= threshold), the upgrade executes immediately. + pub fn approve_upgrade(env: Env, guardian: Address, new_wasm_hash: BytesN<32>) { + guardian.require_auth(); + + let guardians: Vec
= env + .storage() + .instance() + .get(&StorageKey::Guardians) + .unwrap_or_else(|| Vec::new(&env)); + let mut is_guardian = false; + for g in guardians.iter() { + if g == guardian { + is_guardian = true; + break; + } + } + if !is_guardian { + panic_with_error!(&env, Error::NotGuardian); + } + + let mut pending: PendingUpgrade = env + .storage() + .instance() + .get(&StorageKey::PendingUpgrade) + .unwrap_or_else(|| panic_with_error!(&env, Error::NoPendingUpgrade)); + if pending.new_wasm_hash != new_wasm_hash { + panic_with_error!(&env, Error::NoPendingUpgrade); + } + for a in pending.approvals.iter() { + if a == guardian { + panic_with_error!(&env, Error::AlreadyApprovedAction); + } + } + pending.approvals.push_back(guardian.clone()); + + let threshold: u32 = env + .storage() + .instance() + .get(&StorageKey::GuardianThreshold) + .unwrap_or(0); + + env.events().publish( + (Symbol::new(&env, "upgrade_approved"),), + UpgradeApproved { + new_wasm_hash: new_wasm_hash.clone(), + approver: guardian, + approvals: pending.approvals.len(), + threshold, + }, + ); + + if pending.approvals.len() >= threshold { + let current_version: u32 = + env.storage().instance().get(&StorageKey::Version).unwrap_or(1); + env.storage().instance().remove(&StorageKey::PendingUpgrade); + Self::run_migrations(&env, current_version, pending.new_version); + env.storage() + .instance() + .set(&StorageKey::Version, &pending.new_version); + env.deployer().update_current_contract_wasm(new_wasm_hash); + + env.events().publish( + (Symbol::new(&env, "contract_upgraded"),), + ContractUpgraded { + old_version: current_version, + new_version: pending.new_version, + }, + ); + } else { + env.storage().instance().set(&StorageKey::PendingUpgrade, &pending); + } + } + + /// Admin-only: cancel a pending upgrade before it collects enough + /// guardian approvals. + pub fn cancel_pending_upgrade(env: Env, admin: Address) { + 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); } + admin.require_auth(); + if !env.storage().instance().has(&StorageKey::PendingUpgrade) { + panic_with_error!(&env, Error::NoPendingUpgrade); + } + env.storage().instance().remove(&StorageKey::PendingUpgrade); + } + + // ── Admin rotation (issue #356) ───────────────────────────────────────── + + /// Propose a new admin. Only the current admin can call this. + /// + /// If a guardian threshold > 0 is configured (`set_guardians`), the + /// proposal is not armed until `threshold` guardians call + /// `approve_admin_change`. Once armed, `new_admin` must call `accept_admin` + /// — and that only succeeds after `ADMIN_TRANSFER_TIMELOCK` has elapsed, so + /// a hostile or mistaken rotation has a 48h window to be noticed and + /// countered with a fresh `propose_new_admin`. + pub fn propose_new_admin(env: Env, admin: Address, new_admin: Address) { + Self::require_admin(&env, &admin); + Self::validate_stellar_address(&env, &new_admin); + + let threshold: u32 = env + .storage() + .instance() + .get(&StorageKey::GuardianThreshold) + .unwrap_or(0); + if threshold == 0 { + env.storage().instance().set(&StorageKey::PendingAdmin, &new_admin); + env.storage() + .instance() + .set(&StorageKey::PendingAdminSince, &env.ledger().timestamp()); + return; + } + + let pending = PendingAdminChange { + new_admin, + approvals: Vec::new(&env), + }; + env.storage() + .instance() + .set(&StorageKey::PendingAdminChange, &pending); + } + + /// Guardian approval for a pending admin-change proposal. Once enough + /// guardians have approved (>= threshold) the transfer is armed and the + /// `ADMIN_TRANSFER_TIMELOCK` clock starts; `new_admin` must still call + /// `accept_admin`. + pub fn approve_admin_change(env: Env, guardian: Address, new_admin: Address) { + guardian.require_auth(); + + let guardians: Vec
= env + .storage() + .instance() + .get(&StorageKey::Guardians) + .unwrap_or_else(|| Vec::new(&env)); + let mut is_guardian = false; + for g in guardians.iter() { + if g == guardian { + is_guardian = true; + break; + } + } + if !is_guardian { + panic_with_error!(&env, Error::NotGuardian); + } + + let mut pending: PendingAdminChange = env + .storage() + .instance() + .get(&StorageKey::PendingAdminChange) + .unwrap_or_else(|| panic_with_error!(&env, Error::NoPendingUpgrade)); + if pending.new_admin != new_admin { + panic_with_error!(&env, Error::NoPendingUpgrade); + } + for a in pending.approvals.iter() { + if a == guardian { + panic_with_error!(&env, Error::AlreadyApprovedAction); + } + } + pending.approvals.push_back(guardian); + + let threshold: u32 = env + .storage() + .instance() + .get(&StorageKey::GuardianThreshold) + .unwrap_or(0); + + if pending.approvals.len() >= threshold { + env.storage().instance().remove(&StorageKey::PendingAdminChange); + env.storage().instance().set(&StorageKey::PendingAdmin, &new_admin); + env.storage() + .instance() + .set(&StorageKey::PendingAdminSince, &env.ledger().timestamp()); + } else { + env.storage() + .instance() + .set(&StorageKey::PendingAdminChange, &pending); + } + } + + /// Accept the proposed admin. Only the proposed admin can call this, and + /// only once `ADMIN_TRANSFER_TIMELOCK` has elapsed since the transfer was + /// armed (issue #356). + 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)); + if admin != pending_admin { + panic_with_error!(&env, Error::Unauthorized); + } + admin.require_auth(); + + let since: u64 = env.storage().instance() + .get(&StorageKey::PendingAdminSince).unwrap_or(0); + if env.ledger().timestamp() < since.saturating_add(ADMIN_TRANSFER_TIMELOCK) { + panic_with_error!(&env, Error::AdminTimelockNotExpired); + } + + env.storage().instance().set(&StorageKey::Admin, &admin); + env.storage().instance().remove(&StorageKey::PendingAdmin); + env.storage().instance().remove(&StorageKey::PendingAdminSince); + + env.events().publish( + (Symbol::new(&env, "admin_updated"),), + AdminUpdated { new_admin: admin }, + ); + } + + /// Return the proposed next admin, if a transfer is pending. + pub fn get_pending_admin(env: Env) -> Option
{ + env.storage().instance().get(&StorageKey::PendingAdmin) + } + + /// Ledger timestamp at which the current pending admin transfer was armed, + /// or `0` if none. `accept_admin` succeeds only once + /// `now >= this + ADMIN_TRANSFER_TIMELOCK` (issue #356). + pub fn get_pending_admin_since(env: Env) -> u64 { + env.storage().instance().get(&StorageKey::PendingAdminSince).unwrap_or(0) } /// 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) { - if old_version == 0 || new_version <= old_version || new_version > CURRENT_STORAGE_VERSION { - panic!("invalid migration version"); + 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); } + } + + // ── Escalation (issue #376) ─────────────────────────────────────────────── + + /// Set how long a claim may sit Pending before it can be escalated. + /// + /// Floored at `MIN_ESCALATION_THRESHOLD` (1 hour): a near-zero threshold + /// would make every claim escalatable the moment it is submitted, which + /// turns the signal into noise and leaves genuinely stuck claims no easier + /// to find than they are today. + pub fn set_escalation_threshold(env: Env, admin: Address, threshold: u64) { + Self::require_admin(&env, &admin); + + if threshold < MIN_ESCALATION_THRESHOLD { + panic_with_error!(&env, Error::InvalidThresholdValue); } - 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; + env.storage() + .instance() + .set(&StorageKey::EscalationThreshold, &threshold); + + env.events().publish( + (Symbol::new(&env, "escalation_threshold_set"),), + EscalationThresholdUpdated { threshold }, + ); + } + + /// The escalation threshold in seconds (default: 7 days). + pub fn get_escalation_threshold(env: Env) -> u64 { + Self::escalation_threshold(&env) + } + + /// How long a claim has been waiting, and whether it can be escalated yet. + /// + /// Returns a value rather than panicking, so a claimant's UI can show + /// "escalatable in 2 days" instead of offering a button that reverts. + pub fn get_claim_age(env: Env, claim_id: u128) -> ClaimAgeInfo { + let claim: Claim = env + .storage() + .persistent() + .get(&StorageKey::Claim(claim_id)) + .unwrap_or_else(|| panic_with_error!(&env, Error::ClaimNotFound)); + + let threshold = Self::escalation_threshold(&env); + let now = env.ledger().timestamp(); + let is_pending = claim.status == ClaimStatus::Pending; + let pending_for = if is_pending { + now.saturating_sub(claim.submitted_at) + } else { + 0 + }; + + ClaimAgeInfo { + claim_id, + status: claim.status, + submitted_at: claim.submitted_at, + pending_for, + escalation_threshold: threshold, + escalatable: is_pending && pending_for >= threshold, + seconds_until_escalatable: if !is_pending { + 0 + } else { + threshold.saturating_sub(pending_for) + }, + } + } + + /// Set how long after a policy's `end_time` a claim may still be submitted. + /// + /// The deadline bounds how stale a triggering event may be when a claim is + /// filed. Without it, a policyholder (or anyone) could open a claim + /// indefinitely far after the event, so a disputed or rejected claim could + /// resurface years later against data nobody can still verify. A larger + /// value is more forgiving to claimants; a smaller one tightens the link + /// between the claim and the oracle data that backs it. `0` restores the + /// historical behaviour: claims only while the policy is Active. + /// + /// Floored at `MIN_ESCALATION_THRESHOLD` (1 hour) so the deadline is never + /// so short that a single missed ledger makes a valid claim impossible. + pub fn set_claim_deadline(env: Env, admin: Address, deadline: u64) { + Self::require_admin(&env, &admin); + + if deadline != 0 && deadline < MIN_ESCALATION_THRESHOLD { + panic_with_error!(&env, Error::InvalidThresholdValue); + } + + env.storage().instance().set(&StorageKey::ClaimDeadline, &deadline); + + env.events().publish( + (Symbol::new(&env, "claim_deadline_set"),), + ClaimDeadlineUpdated { deadline }, + ); + } + + /// The claim submission deadline in seconds (default: 30 days). + pub fn get_claim_deadline(env: Env) -> u64 { + Self::claim_deadline(&env) + } + + // ── Payout Delay (issue #432) ───────────────────────────────────────────── + + /// Set the delay in seconds between claim approval and actual payout. + /// + /// When non-zero, a claim that passes evaluation enters `PaidPendingDelay` + /// status and the payout is held for `delay_seconds` before becoming + /// claimable. This gives the protocol a window to catch fraud or errors + /// before funds leave the pool. `0` restores immediate payout behavior. + pub fn set_payout_delay(env: Env, admin: Address, delay_seconds: u64) { + Self::require_admin(&env, &admin); + env.storage().instance().set(&StorageKey::PayoutDelay, &delay_seconds); + env.events().publish( + (Symbol::new(&env, "payout_delay_set"),), + PayoutDelayUpdated { delay_seconds }, + ); + } + + /// The configured payout delay in seconds (default: 0 — immediate). + pub fn get_payout_delay(env: Env) -> u64 { + Self::payout_delay(&env) + } + + /// The configured payout delay, or the default. + fn payout_delay(env: &Env) -> u64 { + env.storage() + .instance() + .get(&StorageKey::PayoutDelay) + .unwrap_or(0) + } + + /// Claim the payout for an approved claim after the payout delay has elapsed. + /// + /// When a payout delay is configured, approved claims enter Paid/PartiallyPaid + /// status but funds are not transferred until the delay passes. This function + /// completes the transfer. Callable by anyone — the claim is already approved. + pub fn claim_payout(env: Env, claim_id: u128) -> i128 { + Self::require_not_paused(&env); + + let mut claim: Claim = env.storage().persistent() + .get(&StorageKey::Claim(claim_id)) + .unwrap_or_else(|| panic_with_error!(&env, Error::ClaimNotFound)); + + let payout_ready_at = claim.payout_ready_at + .unwrap_or_else(|| panic_with_error!(&env, Error::PayoutDelayNotElapsed)); + + let now = env.ledger().timestamp(); + if now < payout_ready_at { + panic_with_error!(&env, Error::PayoutDelayNotElapsed); + } + + let paid_amount = claim.paid_amount + .unwrap_or_else(|| panic_with_error!(&env, Error::ClaimNotFound)); + + // Clear payout_ready_at so this cannot be called again + claim.payout_ready_at = None; + env.storage().persistent().set(&StorageKey::Claim(claim_id), &claim); + + // Execute the actual payout + 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() + .get(&StorageKey::RiskPool) + .unwrap_or_else(|| panic_with_error!(&env, Error::NotInitialized)); + + PolicyEngineClient::new(&env, &policy_engine) + .pay_claim(&env.current_contract_address(), &claim.policy_id); + RiskPoolClient::new(&env, &risk_pool) + .release_for_claim(&env.current_contract_address(), &claim.policy_id); + + env.events().publish( + (Symbol::new(&env, "payout_released"),), + (claim_id, claim.policy_id, paid_amount), + ); + + paid_amount + } + + /// Escalate a claim that has been Pending past the threshold. + /// + /// A claim that nothing processes is worse than a rejected one: a + /// rejection can be disputed, but a claim stuck in Pending gives the + /// claimant nothing to act on and no signal that anything is wrong. This + /// moves it to `Escalated`, emits `ClaimEscalated`, and takes it out of the + /// automated pending queue so manual review can pick it up. + /// + /// Permissionless by design. The person with the strongest interest in + /// escalating is the claimant who is waiting, and requiring a keeper or the + /// admin to do it would mean the party responsible for the delay is also + /// the only party able to flag it. + /// + /// Escalation does not decide the claim. It records that processing is + /// overdue and hands it to review — the outcome still comes from a normal + /// resolution path. + pub fn escalate_claim(env: Env, caller: Address, claim_id: u128) { + caller.require_auth(); + Self::require_not_paused(&env); + + let mut claim: Claim = env + .storage() + .persistent() + .get(&StorageKey::Claim(claim_id)) + .unwrap_or_else(|| panic_with_error!(&env, Error::ClaimNotFound)); + + if claim.status != ClaimStatus::Pending { + panic_with_error!(&env, Error::NotEscalatable); + } + + let now = env.ledger().timestamp(); + let pending_for = now.saturating_sub(claim.submitted_at); + if pending_for < Self::escalation_threshold(&env) { + panic_with_error!(&env, Error::NotEscalatable); + } + + claim.status = ClaimStatus::Escalated; + let policy_id = claim.policy_id; + let claimant = claim.claimant.clone(); + + env.storage() + .persistent() + .set(&StorageKey::Claim(claim_id), &claim); + Self::extend_claim_ttl(&env, &StorageKey::Claim(claim_id)); + + // Drop it from the automated queue — a keeper sweeping pending claims + // should not keep retrying one that has been handed to review. + Self::remove_from_pending(&env, claim_id); + + env.events().publish( + (Symbol::new(&env, "claim_escalated"),), + ClaimEscalated { + claim_id, + policy_id, + claimant, + pending_for, + escalated_by: caller, + }, + ); + } + + /// Claim IDs that are Pending and past the escalation threshold. + /// + /// Lets a monitoring job find stuck claims in one call rather than probing + /// each one, which is what makes the threshold actionable in practice. + pub fn get_escalatable_claims(env: Env) -> Vec { + let pending: Vec = env + .storage() + .instance() + .get(&StorageKey::PendingClaims) + .unwrap_or_else(|| Vec::new(&env)); + + let threshold = Self::escalation_threshold(&env); + let now = env.ledger().timestamp(); + let mut overdue: Vec = Vec::new(&env); + + for i in 0..pending.len() { + let cid = pending.get_unchecked(i); + if let Some(claim) = env + .storage() + .persistent() + .get::<_, Claim>(&StorageKey::Claim(cid)) + { + if claim.status == ClaimStatus::Pending + && now.saturating_sub(claim.submitted_at) >= threshold + { + overdue.push_back(cid); } - _ => panic!("unsupported migration path"), } } + + overdue } - fn migrate_v1_to_v2(env: &Env) { - if !env.storage().instance().has(&StorageKey::Paused) { - env.storage().instance().set(&StorageKey::Paused, &false); - } + // ── Identity Verification ─────────────────────────────────────────────── + + /// Admin-only: mark a product/category as requiring identity verification. + /// Claimants must have verified identity (via oracle-verifier attestation) + /// to claim payouts for this product. + pub fn require_identity_for_category( + env: Env, + admin: Address, + product_id: u128, + id_type: Symbol, + ) { + Self::require_admin(&env, &admin); + + let key = StorageKey::IdentityRequirement(product_id); + env.storage().instance().set(&key, &id_type); + + env.events().publish( + (Symbol::new(&env, "identity_requirement_set"),), + (product_id, id_type), + ); + } + + /// Admin-only: remove identity verification requirement for a product. + pub fn remove_identity_requirement(env: Env, admin: Address, product_id: u128) { + Self::require_admin(&env, &admin); + + let key = StorageKey::IdentityRequirement(product_id); + env.storage().instance().remove(&key); + + env.events().publish( + (Symbol::new(&env, "identity_requirement_removed"),), + product_id, + ); + } + + /// Admin-only: manually verify a claimant's identity for a claim. + /// Used when off-chain identity verification is completed or approved by DAO. + pub fn verify_claimant_identity( + env: Env, + admin: Address, + claim_id: u128, + id_type: Symbol, + ) { + 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)); + + let now = env.ledger().timestamp(); + claim.identity_verified = true; + claim.verification_type = Some(id_type.clone()); + claim.verification_time = Some(now); + + let key = StorageKey::Claim(claim_id); + env.storage().persistent().set(&key, &claim); + Self::extend_claim_ttl(&env, &key); + + env.events().publish( + (Symbol::new(&env, "identity_verified"),), + (claim_id, id_type, now), + ); } - fn migrate_v2_to_v3(_env: &Env) {} + /// Get the identity requirement for a product (if set). + pub fn get_identity_requirement(env: Env, product_id: u128) -> Option { + env.storage() + .instance() + .get(&StorageKey::IdentityRequirement(product_id)) + } + + /// Check if a claimant's identity is verified for a particular ID type. + /// This is a utility for off-chain systems to verify attestation status. + pub fn is_identity_verified(env: Env, claim_id: u128) -> bool { + env.storage() + .persistent() + .get(&StorageKey::Claim(claim_id)) + .map(|claim: Claim| claim.identity_verified) + .unwrap_or(false) + } // ── 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. + /// Evaluate a pending claim and settle it against the configured oracle trigger. + /// + /// This internal helper is the core claim lifecycle step. It first validates + /// that the claim is still pending, then resolves the oracle verifier, + /// policy engine, and risk pool contract addresses from storage. It builds a + /// trigger condition from the policy's oracle data type, key, threshold, and + /// comparison mode, and asks the oracle verifier whether the trigger is + /// currently met using a fresh-data check. + /// + /// If the trigger is met, the claim is marked as paid, the policy engine is + /// instructed to pay the claim, and the risk-pool coverage lock is released + /// in the same transaction. If the trigger is not met, the claim is marked + /// as rejected and no payout is issued. In either case, the claim record is + /// persisted, its pending-queue entry is removed, and a settlement event is + /// emitted so the outcome is observable off-chain. fn evaluate_and_settle( env: &Env, claim: &mut Claim, policy: ¶shield_policy_engine::Policy, + partial_payout_bps: Option, ) -> ClaimResult { - // Validate claim is in Pending state before transitioning (atomic state guard) + // 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 payout_delay = Self::payout_delay(env); + let result = if trigger_met { - claim.status = ClaimStatus::Paid; - PolicyEngineClient::new(env, &policy_engine) - .pay_claim(&env.current_contract_address(), &claim.policy_id); - // Atomic lock release: release coverage lock after successful payment. - // If release fails, the entire transaction reverts, leaving state unchanged. - RiskPoolClient::new(env, &risk_pool) - .release_for_claim(&env.current_contract_address(), &claim.policy_id); - ClaimResult::Paid + // Determine payout: full or partial based on partial_payout_bps. + let bps = partial_payout_bps.unwrap_or(10_000); + let effective_bps = if bps > 10_000 { 10_000 } else { bps }; + let now = env.ledger().timestamp(); + + if effective_bps >= 10_000 { + // Full payment + claim.status = ClaimStatus::Paid; + claim.paid_amount = Some(claim.coverage_amount); + claim.partial_payout_bps = Some(10_000); + + if payout_delay > 0 { + // Delay payout: record when payout becomes available + claim.payout_ready_at = Some(now.saturating_add(payout_delay)); + } else { + // Immediate payout + PolicyEngineClient::new(env, &policy_engine) + .pay_claim(&env.current_contract_address(), &claim.policy_id); + RiskPoolClient::new(env, &risk_pool) + .release_for_claim(&env.current_contract_address(), &claim.policy_id); + } + ClaimResult::Paid + } else { + // Partial payment: calculate proportional payout + let paid = claim.coverage_amount * (effective_bps as i128) / 10_000; + claim.status = ClaimStatus::PartiallyPaid; + claim.paid_amount = Some(paid); + claim.partial_payout_bps = Some(effective_bps); + + if payout_delay > 0 { + claim.payout_ready_at = Some(now.saturating_add(payout_delay)); + } else { + PolicyEngineClient::new(env, &policy_engine) + .pay_claim(&env.current_contract_address(), &claim.policy_id); + RiskPoolClient::new(env, &risk_pool) + .release_for_claim(&env.current_contract_address(), &claim.policy_id); + } + ClaimResult::PartiallyPaid + } } else { claim.status = ClaimStatus::Rejected; 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 // without bound. Self::remove_from_pending(env, claim.id); - // Emit specific event for rejected claims to enable off-chain monitoring - if !trigger_met { + // Emit specific event for rejected/paid claims to enable off-chain monitoring + if trigger_met { + env.events().publish( + (Symbol::new(env, "claim_paid"),), + (claim.id, claim.policy_id, claim.coverage_amount), + ); + } else { 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")), ); } @@ -791,30 +1711,37 @@ 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() + + /// The configured escalation threshold, or the default. + fn escalation_threshold(env: &Env) -> u64 { + env.storage() + .instance() + .get(&StorageKey::EscalationThreshold) + .unwrap_or(DEFAULT_ESCALATION_THRESHOLD) + } + + /// The configured claim submission deadline, or the default. + fn claim_deadline(env: &Env) -> u64 { + env.storage() .instance() + .get(&StorageKey::ClaimDeadline) + .unwrap_or(DEFAULT_CLAIM_DEADLINE) + } + + fn remove_from_pending(env: &Env, claim_id: u128) { + let mut pending: Vec = env.storage().instance() .get(&StorageKey::PendingClaims) .unwrap_or_else(|| Vec::new(env)); - let mut updated: Vec = Vec::new(env); - for id in pending.iter() { - if id != claim_id { - updated.push_back(id); - } - } - if updated.len() != pending.len() { - env.storage() - .instance() - .set(&StorageKey::PendingClaims, &updated); + if let Some(idx) = pending.first_index_of(claim_id) { + pending.remove(idx); + env.storage().instance().set(&StorageKey::PendingClaims, &pending); } } + /// 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 { @@ -825,9 +1752,7 @@ 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 { @@ -838,9 +1763,7 @@ 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 { @@ -848,29 +1771,34 @@ impl ClaimsProcessor { } } + /// Extend a `Claim`/`PolicyClaim` entry's TTL to cover + /// `CLAIM_RETENTION_SECONDS` from now (clamped to the network's max TTL), + /// so a claim record survives from submission through settlement or an + /// open-ended dispute even if it's only ever written once (issue #246). + fn extend_claim_ttl(env: &Env, key: &StorageKey) { + let desired_ledgers = (CLAIM_RETENTION_SECONDS / LEDGER_SECONDS) as u32; + let extend_to = desired_ledgers.min(env.storage().max_ttl()); + env.storage().persistent().extend_ttl(key, extend_to, extend_to); + } + 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); @@ -883,21 +1811,18 @@ 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_advanced; -#[cfg(test)] mod test_integration; +#[cfg(test)] +mod test_advanced; diff --git a/contracts/claims-processor/src/test.rs b/contracts/claims-processor/src/test.rs index e3c0733..e192ef1 100644 --- a/contracts/claims-processor/src/test.rs +++ b/contracts/claims-processor/src/test.rs @@ -1,609 +1,1231 @@ -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, -}; - -const COVERAGE: i128 = 1_000_000_000; // 100 USDC - -struct World { - env: Env, - admin: Address, - keeper: Address, - oracle_w: Address, - usdc: Address, - oracle_id: Address, - policy_id: Address, - claims_id: Address, - pool_id: Address, -} - -fn deploy() -> World { - let env = Env::default(); - env.mock_all_auths(); - env.cost_estimate().budget().reset_unlimited(); - - 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(); - - // 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, - ); - - // 2. Deploy risk pool (category: crop) - 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, ()); - - // Initialize risk pool with correct addresses - RiskPoolClient::new(&env, &pool_id).initialize( - &admin, - &usdc, - &treasury, - &backstop, - &symbol_short!("crop"), - &policy_id, - &claims_id, - ); - - // 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, - ); - - // Authorize keeper on the claims processor - 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, - } -} - -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, - trigger_comparison: TriggerComparison::LessThan, - coverage_min: 100_000_000, - coverage_max: 10_000_000_000, - premium_rate_bps: 500, - max_duration_days: 365, - }, - ) -} - -fn buy_crop_policy(w: &World, buyer: &Address, product_id: u128) -> u128 { - StellarAssetClient::new(&w.env, &w.usdc).mint(buyer, &5_000_000_000i128); - // Fund the pool with coverage capital - 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"), - ); - - // 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 -} - -fn submit_rainfall(w: &World, mm_7dec: i128) { - OracleVerifierClient::new(&w.env, &w.oracle_id).submit_data( - &w.oracle_w, - &symbol_short!("weather"), - &symbol_short!("kis2606"), - &mm_7dec, - &95u32, - &w.env.ledger().timestamp(), - ); -} - -// ── Core acceptance tests from the pitch ───────────────────────────────────── - -/// 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 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); - - assert_eq!(result, ClaimResult::Paid); - // Buyer: minted 5_000_000_000, paid 50_000_000 premium, received 1_000_000_000 coverage - 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); -} - -/// 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 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); - - 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" - ); -} - -/// 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 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); - - 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 - ); -} - -/// 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 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 second = cp.auto_process(&w.keeper, &pol_id); - - assert_eq!(first, ClaimResult::Paid); - assert_eq!(second, ClaimResult::AlreadyProcessed); - - // Buyer should NOT receive double coverage — exactly one payout - 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); -} - -#[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 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); - let first = cp.process_claim(&w.keeper, &claim_id); - let second = cp.process_claim(&w.keeper, &claim_id); - - assert_eq!(first, ClaimResult::Paid); - assert_eq!(second, ClaimResult::AlreadyProcessed); -} - -/// submit_claim on already-claimed policy panics. -#[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 pol_id = buy_crop_policy(&w, &buyer, pid); - submit_rainfall(&w, 20_000_000); - - let cp = ClaimsProcessorClient::new(&w.env, &w.claims_id); - cp.submit_claim(&buyer, &pol_id); - // Second submit for same policy → AlreadyClaimed error - cp.submit_claim(&buyer, &pol_id); -} - -/// Non-policyholder cannot submit a claim. -#[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 stranger = Address::generate(&w.env); - 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); -} - -/// 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 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 claim_id = cp.submit_claim(&buyer, &pol_id); - let result = cp.process_claim(&w.keeper, &claim_id); - - assert_eq!(result, ClaimResult::Paid); - let claim = cp.get_claim(&claim_id); - assert_eq!(claim.status, ClaimStatus::Paid); - assert!(claim.trigger_met); -} - -// ── Keeper registry (Issue #79) ────────────────────────────────────────────── - -/// auto_process from an address that is not a registered keeper is rejected. -#[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 stranger = Address::generate(&w.env); - 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); -} - -/// 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 stranger = 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(&stranger, &claim_id); -} - -/// A revoked keeper can no longer settle claims. -#[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 pol_id = buy_crop_policy(&w, &buyer, pid); - submit_rainfall(&w, 20_000_000); - - let cp = ClaimsProcessorClient::new(&w.env, &w.claims_id); - cp.remove_keeper(&w.admin, &w.keeper); - cp.auto_process(&w.keeper, &pol_id); -} - -/// Only the admin may register a keeper. -#[test] -#[should_panic(expected = "Error(Contract, #3)")] -fn test_non_admin_cannot_add_keeper() { - 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); -} - -// ── Pending queue lifecycle (Issues #76, #74) ──────────────────────────────── - -/// auto_process makes the internal claim visible, then settlement clears it -/// 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 pol_id = buy_crop_policy(&w, &buyer, pid); - submit_rainfall(&w, 20_000_000); - - let cp = ClaimsProcessorClient::new(&w.env, &w.claims_id); - assert_eq!(cp.get_pending_claims().len(), 0); - - let result = cp.auto_process(&w.keeper, &pol_id); - 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" - ); -} - -/// 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 pol_id = buy_crop_policy(&w, &buyer, pid); - submit_rainfall(&w, 30_000_000); - - let cp = ClaimsProcessorClient::new(&w.env, &w.claims_id); - let claim_id = cp.submit_claim(&buyer, &pol_id); - 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" - ); -} - -/// 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 pol_id = buy_crop_policy(&w, &buyer, pid); - submit_rainfall(&w, 30_000_000); - - let cp = ClaimsProcessorClient::new(&w.env, &w.claims_id); - let claim_id = cp.submit_claim(&buyer, &pol_id); - assert_eq!(cp.get_pending_claims().len(), 1); - - 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" - ); -} - -// ── Dispute status guard (Issue #78) ───────────────────────────────────────── - -/// A Paid claim cannot be flipped back to Disputed. -#[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 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); - assert_eq!(cp.get_claim(&claim_id).status, ClaimStatus::Paid); - - // Attempting to dispute a settled/paid claim must panic. - cp.dispute_claim(&buyer, &claim_id, &symbol_short!("reversal")); -} - -/// 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 pol_id = buy_crop_policy(&w, &buyer, pid); - submit_rainfall(&w, 72_000_000); // above threshold → rejected - - 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); - assert_eq!(result, ClaimResult::Rejected); - - // First dispute on a Rejected claim succeeds. - cp.dispute_claim(&buyer, &claim_id, &symbol_short!("disagree")); - assert_eq!(cp.get_claim(&claim_id).status, ClaimStatus::Disputed); -} - -/// Re-disputing an already-Disputed claim is rejected (no dispute loop). -#[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 pol_id = buy_crop_policy(&w, &buyer, pid); - submit_rainfall(&w, 72_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); - cp.dispute_claim(&buyer, &claim_id, &symbol_short!("disagree")); - // Second dispute → AlreadyProcessed - cp.dispute_claim(&buyer, &claim_id, &symbol_short!("again")); -} - -// ── Address validation (Issue #12) ─────────────────────────────────────────────── - -/// Test that initialize accepts valid Stellar addresses (generated addresses are always valid) -#[test] -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 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, - ); - - // Should succeed without panic - let stored_admin = ClaimsProcessorClient::new(&env, &claims_id).get_admin(); - assert_eq!(stored_admin, admin); -} - -/// Note: In Soroban SDK, Address objects are type-safe and cannot be created with invalid format. -/// The validation function is a defensive measure for future extensibility. -/// This test verifies that the validation logic exists and would catch format issues -/// if addresses were ever passed as strings from external sources. -#[test] -fn test_address_validation_function_exists() { - // This test verifies the validation helper is callable - // 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'" - ); -} - -// ── Keeper authorization ─────────────────────────────────────────────────────── - -/// Non-keeper cannot call auto_process. -#[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 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); -} - -/// 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 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); - - let stranger = Address::generate(&w.env); - cp.process_claim(&stranger, &claim_id); -} - -// ── Issue #160: double-processing the same claim via process_claim ────────────── - -/// Calling `process_claim` twice on the same claim_id in quick succession must -/// return `AlreadyProcessed` on the second call rather than re-invoking the -/// oracle and paying out again. This tests the idempotency guard at the -/// 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 pol_id = buy_crop_policy(&w, &buyer, pid); - - // Submit low rainfall so the trigger is met - submit_rainfall(&w, 20_000_000); - - let cp = ClaimsProcessorClient::new(&w.env, &w.claims_id); - let claim_id = cp.submit_claim(&buyer, &pol_id); - - // First call settles the claim (Paid) - let first = cp.process_claim(&w.keeper, &claim_id); - assert_eq!(first, ClaimResult::Paid); - - // Second call on same claim returns AlreadyProcessed - let second = cp.process_claim(&w.keeper, &claim_id); - assert_eq!(second, ClaimResult::AlreadyProcessed); - - // Balance confirms exactly one payout — no double-spend - 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); -} +use super::*; +use soroban_sdk::{ + symbol_short, + testutils::{storage::Persistent as _, 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, + oracle_id: Address, + policy_id: Address, + claims_id: Address, + pool_id: Address, +} + +fn deploy() -> World { + let env = Env::default(); + env.mock_all_auths(); + env.cost_estimate().budget().reset_unlimited(); + + 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(); + + // 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); + + // 2. Deploy risk pool (category: crop) + 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, ()); + + // Initialize risk pool with correct addresses + RiskPoolClient::new(&env, &pool_id).initialize( + &admin, + &usdc, + &treasury, + &backstop, + &symbol_short!("crop"), + &policy_id, + &claims_id, + ); + + // 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); + + // Authorize keeper on the claims processor + 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 } +} + +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, + trigger_comparison: TriggerComparison::LessThan, + coverage_min: 100_000_000, + coverage_max: 10_000_000_000, + premium_rate_bps: 500, + max_duration_days: 365, + }, + ) +} + +fn buy_crop_policy(w: &World, buyer: &Address, product_id: u128) -> u128 { + StellarAssetClient::new(&w.env, &w.usdc).mint(buyer, &5_000_000_000i128); + // Fund the pool and policy contract with coverage capital + StellarAssetClient::new(&w.env, &w.usdc).mint(&w.admin, &1_000_000_000i128); + RiskPoolClient::new(&w.env, &w.pool_id).deposit(&w.admin, &1_000_000_000i128, &0i128, &false); + StellarAssetClient::new(&w.env, &w.usdc).mint(&w.policy_id, &10_000_000_000i128); + + 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 +} + +fn submit_rainfall(w: &World, mm_7dec: i128) { + OracleVerifierClient::new(&w.env, &w.oracle_id).submit_data( + &w.oracle_w, + &symbol_short!("weather"), + &symbol_short!("kis2606"), + &mm_7dec, + &95u32, + &w.env.ledger().timestamp(), + ); +} + +// ── Core acceptance tests from the pitch ───────────────────────────────────── + +/// 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 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, &None); + + assert_eq!(result, ClaimResult::Paid); + // Buyer: minted 5_000_000_000, paid 50_000_000 premium, received 1_000_000_000 coverage + 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); +} + +/// 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 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, &None); + + 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"); +} + +/// 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 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, &None); + + 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); +} + +/// Expiring a policy must free its earmarked capital in the same transaction. +/// +/// Previously `expire_policy` only flipped the policy status; the pool's +/// `release_for_expiry` was left to a separate backend call, so a crash between +/// the two left the policy Expired while its coverage stayed locked — quietly +/// shrinking the liquidity available to underwrite new policies. +#[test] +fn test_expired_policy_releases_pool_capital() { + let w = deploy(); + let pid = create_crop_product(&w); + let buyer = Address::generate(&w.env); + let pol_id = buy_crop_policy(&w, &buyer, pid); + + let pool = RiskPoolClient::new(&w.env, &w.pool_id); + let locked_while_active = pool.get_stats().total_locked; + assert_eq!(locked_while_active, COVERAGE, "coverage should be locked while active"); + + // Advance past the 30-day policy duration. + 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, &None); + assert_eq!(result, ClaimResult::Expired); + + // The lock is gone without any separate backend call. + assert_eq!( + pool.get_stats().total_locked, + 0, + "expiry must release the capital lock atomically", + ); +} + +/// 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 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, &None); + let second = cp.auto_process(&w.keeper, &pol_id, &None); + + assert_eq!(first, ClaimResult::Paid); + assert_eq!(second, ClaimResult::AlreadyProcessed); + + // Buyer should NOT receive double coverage — exactly one payout + 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); +} + +#[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 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); + let first = cp.process_claim(&w.keeper, &claim_id, &None); + let second = cp.process_claim(&w.keeper, &claim_id, &None); + + assert_eq!(first, ClaimResult::Paid); + assert_eq!(second, ClaimResult::AlreadyProcessed); +} + +/// submit_claim on already-claimed policy panics. +#[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 pol_id = buy_crop_policy(&w, &buyer, pid); + submit_rainfall(&w, 20_000_000); + + let cp = ClaimsProcessorClient::new(&w.env, &w.claims_id); + cp.submit_claim(&buyer, &pol_id); + // Second submit for same policy → AlreadyClaimed error + cp.submit_claim(&buyer, &pol_id); +} + +/// Non-policyholder cannot submit a claim. +#[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 stranger = Address::generate(&w.env); + 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); +} + +/// submit_claim on an expired policy must panic with PolicyExpired error. +#[test] +#[should_panic(expected = "Error(Contract, #10)")] +fn test_submit_claim_on_expired_policy_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); + + // Advance time past the 30-day policy duration + w.env.ledger().with_mut(|l| l.timestamp += 31 * 86_400); + + let cp = ClaimsProcessorClient::new(&w.env, &w.claims_id); + cp.submit_claim(&buyer, &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 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 claim_id = cp.submit_claim(&buyer, &pol_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); + assert!(claim.trigger_met); +} + +// ── Keeper registry (Issue #79) ────────────────────────────────────────────── + +/// auto_process from an address that is not a registered keeper is rejected. +#[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 stranger = Address::generate(&w.env); + 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, &None); +} + +/// 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 stranger = 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(&stranger, &claim_id, &None); +} + +/// A revoked keeper can no longer settle claims. +#[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 pol_id = buy_crop_policy(&w, &buyer, pid); + submit_rainfall(&w, 20_000_000); + + let cp = ClaimsProcessorClient::new(&w.env, &w.claims_id); + cp.remove_keeper(&w.admin, &w.keeper); + cp.auto_process(&w.keeper, &pol_id, &None); +} + +/// Only the admin may register a keeper. +#[test] +#[should_panic(expected = "Error(Contract, #3)")] +fn test_non_admin_cannot_add_keeper() { + 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); +} + +// ── Pending queue lifecycle (Issues #76, #74) ──────────────────────────────── + +/// auto_process makes the internal claim visible, then settlement clears it +/// 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 pol_id = buy_crop_policy(&w, &buyer, pid); + submit_rainfall(&w, 20_000_000); + + let cp = ClaimsProcessorClient::new(&w.env, &w.claims_id); + assert_eq!(cp.get_pending_claims().len(), 0); + + let result = cp.auto_process(&w.keeper, &pol_id, &None); + 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"); +} + +/// 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 pol_id = buy_crop_policy(&w, &buyer, pid); + submit_rainfall(&w, 30_000_000); + + let cp = ClaimsProcessorClient::new(&w.env, &w.claims_id); + let claim_id = cp.submit_claim(&buyer, &pol_id); + assert_eq!(cp.get_pending_claims().len(), 1); + + cp.process_claim(&w.keeper, &claim_id, &None); + 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 pol_id = buy_crop_policy(&w, &buyer, pid); + submit_rainfall(&w, 30_000_000); + + let cp = ClaimsProcessorClient::new(&w.env, &w.claims_id); + let claim_id = cp.submit_claim(&buyer, &pol_id); + assert_eq!(cp.get_pending_claims().len(), 1); + + 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"); +} + +// ── Dispute status guard (Issue #78) ───────────────────────────────────────── + +/// A Paid claim cannot be flipped back to Disputed. +#[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 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); + assert_eq!(cp.get_claim(&claim_id).status, ClaimStatus::Paid); + + // Attempting to dispute a settled/paid claim must panic. + cp.dispute_claim(&buyer, &claim_id, &symbol_short!("reversal")); +} + +/// 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 pol_id = buy_crop_policy(&w, &buyer, pid); + submit_rainfall(&w, 72_000_000); // above threshold → rejected + + 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, &None); + assert_eq!(result, ClaimResult::Rejected); + + // First dispute on a Rejected claim succeeds. + cp.dispute_claim(&buyer, &claim_id, &symbol_short!("disagree")); + assert_eq!(cp.get_claim(&claim_id).status, ClaimStatus::Disputed); +} + +/// Re-disputing an already-Disputed claim is rejected (no dispute loop). +#[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 pol_id = buy_crop_policy(&w, &buyer, pid); + submit_rainfall(&w, 72_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.dispute_claim(&buyer, &claim_id, &symbol_short!("disagree")); + // Second dispute → AlreadyProcessed + cp.dispute_claim(&buyer, &claim_id, &symbol_short!("again")); +} + +// ── Address validation (Issue #12) ─────────────────────────────────────────────── + +/// Test that initialize accepts valid Stellar addresses (generated addresses are always valid) +#[test] +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 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); + + // Should succeed without panic + let stored_admin = ClaimsProcessorClient::new(&env, &claims_id).get_admin(); + assert_eq!(stored_admin, admin); +} + +/// Note: In Soroban SDK, Address objects are type-safe and cannot be created with invalid format. +/// The validation function is a defensive measure for future extensibility. +/// This test verifies that the validation logic exists and would catch format issues +/// if addresses were ever passed as strings from external sources. +#[test] +fn test_address_validation_function_exists() { + // This test verifies the validation helper is callable + // 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'"); +} + +// ── Keeper authorization ─────────────────────────────────────────────────────── + +/// Non-keeper cannot call auto_process. +#[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 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, &None); +} + +/// 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 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); + + let stranger = Address::generate(&w.env); + cp.process_claim(&stranger, &claim_id, &None); +} + +// ── Issue #160: double-processing the same claim via process_claim ────────────── + +/// Calling `process_claim` twice on the same claim_id in quick succession must +/// return `AlreadyProcessed` on the second call rather than re-invoking the +/// oracle and paying out again. This tests the idempotency guard at the +/// 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 pol_id = buy_crop_policy(&w, &buyer, pid); + + // Submit low rainfall so the trigger is met + submit_rainfall(&w, 20_000_000); + + let cp = ClaimsProcessorClient::new(&w.env, &w.claims_id); + let claim_id = cp.submit_claim(&buyer, &pol_id); + + // First call settles the claim (Paid) + let first = cp.process_claim(&w.keeper, &claim_id, &None); + assert_eq!(first, ClaimResult::Paid); + + // Second call on same claim returns AlreadyProcessed + let second = cp.process_claim(&w.keeper, &claim_id, &None); + assert_eq!(second, ClaimResult::AlreadyProcessed); + + // Balance confirms exactly one payout — no double-spend + 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); +} + +/// Test that batch_auto_process with an empty pending list returns an empty vector. +/// This verifies the function gracefully handles the zero-claims case without panicking. +#[test] +fn test_batch_auto_process_empty_pending_list() { + let w = deploy(); + + let cp = ClaimsProcessorClient::new(&w.env, &w.claims_id); + + // Verify no pending claims exist initially + assert_eq!(cp.get_pending_claims().len(), 0); + + // Call batch_auto_process with limit=10 on an empty list + let results = cp.batch_auto_process(&w.keeper, &10u32); + + // Must return an empty vector + assert_eq!(results.len(), 0, "batch_auto_process must return empty vec when pending list is empty"); + + // Pending list should still be empty + assert_eq!(cp.get_pending_claims().len(), 0); +} + +// ── Dispute negative cases (Issue #338) ────────────────────────────────────── + +/// Disputing a claim id that was never submitted must fail with ClaimNotFound. +#[test] +#[should_panic(expected = "Error(Contract, #4)")] +fn test_dispute_nonexistent_claim_fails() { + let w = deploy(); + let claimant = Address::generate(&w.env); + ClaimsProcessorClient::new(&w.env, &w.claims_id) + .dispute_claim(&claimant, &999_999u128, &symbol_short!("reason")); +} + +/// Disputing a claim that has already been paid out must fail with +/// AlreadyProcessed — a settled claim cannot be reopened. +#[test] +#[should_panic(expected = "Error(Contract, #7)")] +fn test_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, 30_000_000); // below threshold + + let cp = ClaimsProcessorClient::new(&w.env, &w.claims_id); + let claim_id = cp.submit_claim(&buyer, &pol_id); + assert_eq!(cp.process_claim(&w.keeper, &claim_id, &None), ClaimResult::Paid); + + cp.dispute_claim(&buyer, &claim_id, &symbol_short!("reason")); +} + +/// Disputing a claim that is already Disputed must fail with +/// AlreadyProcessed — dispute cannot be filed twice on the same claim. +#[test] +#[should_panic(expected = "Error(Contract, #7)")] +fn test_dispute_already_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.dispute_claim(&buyer, &claim_id, &symbol_short!("first")); + + // Second dispute on the same already-Disputed claim must be rejected. + cp.dispute_claim(&buyer, &claim_id, &symbol_short!("second")); +} + +// ── TTL behavior (Issue #335) ──────────────────────────────────────────────── + +/// A submitted claim's TTL must be extended well past the default bump +/// threshold, so it survives long enough to be processed/disputed instead +/// of expiring prematurely and losing the underlying fund record. +#[test] +fn test_submitted_claim_ttl_is_extended() { + 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); + + let cp = ClaimsProcessorClient::new(&w.env, &w.claims_id); + let claim_id = cp.submit_claim(&buyer, &pol_id); + + let ttl = w.env.as_contract(&w.claims_id, || { + w.env.storage().persistent().get_ttl(&StorageKey::Claim(claim_id)) + }); + // Must be extended well beyond the default archival threshold, not left + // at whatever minimal TTL a fresh write happens to get. + assert!(ttl > TTL_THRESHOLD, "claim TTL {} was not extended past the bump threshold", ttl); +} + +// ── escalation (issue #376) ─────────────────────────────────────────────────── + +/// Submit a claim on a fresh policy and return `(world, claim_id, buyer)`. +fn pending_claim() -> (World, u128, Address) { + let w = deploy(); + let pid = create_crop_product(&w); + let buyer = Address::generate(&w.env); + let pol_id = buy_crop_policy(&w, &buyer, pid); + + let cp = ClaimsProcessorClient::new(&w.env, &w.claims_id); + let claim_id = cp.submit_claim(&buyer, &pol_id); + + (w, claim_id, buyer) +} + +#[test] +fn escalation_threshold_defaults_to_seven_days() { + let w = deploy(); + let cp = ClaimsProcessorClient::new(&w.env, &w.claims_id); + + assert_eq!(cp.get_escalation_threshold(), 7 * 24 * 60 * 60); +} + +#[test] +fn admin_can_set_the_escalation_threshold() { + let w = deploy(); + let cp = ClaimsProcessorClient::new(&w.env, &w.claims_id); + + cp.set_escalation_threshold(&w.admin, &(24 * 60 * 60)); + + assert_eq!(cp.get_escalation_threshold(), 24 * 60 * 60); +} + +#[test] +#[should_panic(expected = "Error(Contract, #18)")] +fn a_near_zero_threshold_is_rejected() { + let w = deploy(); + let cp = ClaimsProcessorClient::new(&w.env, &w.claims_id); + + // Every claim escalatable on submission is noise, not a signal. + cp.set_escalation_threshold(&w.admin, &60u64); +} + +#[test] +#[should_panic(expected = "Error(Contract, #3)")] +fn setting_the_threshold_requires_admin() { + let w = deploy(); + let cp = ClaimsProcessorClient::new(&w.env, &w.claims_id); + let stranger = Address::generate(&w.env); + + cp.set_escalation_threshold(&stranger, &(24 * 60 * 60)); +} + +#[test] +fn a_fresh_claim_is_not_escalatable() { + let (w, claim_id, _buyer) = pending_claim(); + let cp = ClaimsProcessorClient::new(&w.env, &w.claims_id); + + let age = cp.get_claim_age(&claim_id); + + assert!(!age.escalatable); + assert_eq!(age.status, ClaimStatus::Pending); + assert_eq!(age.seconds_until_escalatable, 7 * 24 * 60 * 60); +} + +#[test] +fn a_claim_becomes_escalatable_once_overdue() { + let (w, claim_id, _buyer) = pending_claim(); + let cp = ClaimsProcessorClient::new(&w.env, &w.claims_id); + + let now = w.env.ledger().timestamp(); + w.env.ledger().set_timestamp(now + 7 * 24 * 60 * 60); + + let age = cp.get_claim_age(&claim_id); + + assert!(age.escalatable); + assert_eq!(age.seconds_until_escalatable, 0); + assert_eq!(age.pending_for, 7 * 24 * 60 * 60); +} + +#[test] +#[should_panic(expected = "Error(Contract, #17)")] +fn escalating_too_early_is_refused() { + let (w, claim_id, buyer) = pending_claim(); + let cp = ClaimsProcessorClient::new(&w.env, &w.claims_id); + + cp.escalate_claim(&buyer, &claim_id); +} + +#[test] +fn escalating_an_overdue_claim_marks_it_escalated() { + let (w, claim_id, buyer) = pending_claim(); + let cp = ClaimsProcessorClient::new(&w.env, &w.claims_id); + + let now = w.env.ledger().timestamp(); + w.env.ledger().set_timestamp(now + 7 * 24 * 60 * 60 + 1); + + cp.escalate_claim(&buyer, &claim_id); + + assert_eq!(cp.get_claim(&claim_id).status, ClaimStatus::Escalated); +} + +#[test] +fn escalation_is_permissionless() { + let (w, claim_id, _buyer) = pending_claim(); + let cp = ClaimsProcessorClient::new(&w.env, &w.claims_id); + let stranger = Address::generate(&w.env); + + let now = w.env.ledger().timestamp(); + w.env.ledger().set_timestamp(now + 7 * 24 * 60 * 60 + 1); + + // Requiring a keeper or the admin would mean the party responsible for the + // delay is the only party able to flag it. + cp.escalate_claim(&stranger, &claim_id); + + assert_eq!(cp.get_claim(&claim_id).status, ClaimStatus::Escalated); +} + +#[test] +#[should_panic(expected = "Error(Contract, #17)")] +fn escalating_twice_is_refused() { + let (w, claim_id, buyer) = pending_claim(); + let cp = ClaimsProcessorClient::new(&w.env, &w.claims_id); + + let now = w.env.ledger().timestamp(); + w.env.ledger().set_timestamp(now + 7 * 24 * 60 * 60 + 1); + + cp.escalate_claim(&buyer, &claim_id); + cp.escalate_claim(&buyer, &claim_id); +} + +#[test] +#[should_panic(expected = "Error(Contract, #17)")] +fn a_settled_claim_cannot_be_escalated() { + 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); + + let now = w.env.ledger().timestamp(); + w.env.ledger().set_timestamp(now + 30 * 24 * 60 * 60); + + // Already paid — there is nothing overdue to escalate. + cp.escalate_claim(&buyer, &claim_id); +} + +#[test] +fn escalation_uses_the_configured_threshold() { + let (w, claim_id, buyer) = pending_claim(); + let cp = ClaimsProcessorClient::new(&w.env, &w.claims_id); + + cp.set_escalation_threshold(&w.admin, &(24 * 60 * 60)); + + let now = w.env.ledger().timestamp(); + w.env.ledger().set_timestamp(now + 24 * 60 * 60 + 1); + + // Would still be far too early under the seven-day default. + cp.escalate_claim(&buyer, &claim_id); + + assert_eq!(cp.get_claim(&claim_id).status, ClaimStatus::Escalated); +} + +#[test] +fn overdue_claims_can_be_listed() { + let (w, claim_id, _buyer) = pending_claim(); + let cp = ClaimsProcessorClient::new(&w.env, &w.claims_id); + + assert_eq!(cp.get_escalatable_claims().len(), 0, "nothing overdue yet"); + + let now = w.env.ledger().timestamp(); + w.env.ledger().set_timestamp(now + 7 * 24 * 60 * 60 + 1); + + let overdue = cp.get_escalatable_claims(); + assert_eq!(overdue.len(), 1); + assert_eq!(overdue.get_unchecked(0), claim_id); +} + +#[test] +fn an_escalated_claim_leaves_the_pending_queue() { + let (w, claim_id, buyer) = pending_claim(); + let cp = ClaimsProcessorClient::new(&w.env, &w.claims_id); + + let now = w.env.ledger().timestamp(); + w.env.ledger().set_timestamp(now + 7 * 24 * 60 * 60 + 1); + cp.escalate_claim(&buyer, &claim_id); + + // A keeper sweeping pending claims should not keep retrying one that has + // been handed to manual review. + assert_eq!(cp.get_escalatable_claims().len(), 0); +} + + +#[test] +fn claim_age_reports_zero_pending_time_once_resolved() { + 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); + + let age = cp.get_claim_age(&claim_id); + + assert_eq!(age.pending_for, 0); + assert!(!age.escalatable); +} + +// ── Cross-chain claim verification (issue #380) ────────────────────────────── + +fn polygon() -> soroban_sdk::Symbol { + symbol_short!("polygon") +} + +fn zero_proof(env: &Env) -> soroban_sdk::BytesN<32> { + soroban_sdk::BytesN::from_array(env, &[0u8; 32]) +} + +#[test] +fn test_add_and_remove_cross_chain_attestor() { + let w = deploy(); + let attestor = Address::generate(&w.env); + let cp = ClaimsProcessorClient::new(&w.env, &w.claims_id); + + assert!(!cp.is_cross_chain_attestor(&polygon(), &attestor)); + cp.add_cross_chain_attestor(&w.admin, &polygon(), &attestor); + assert!(cp.is_cross_chain_attestor(&polygon(), &attestor)); + assert_eq!(cp.get_cross_chain_attestors(&polygon()).len(), 1); + + cp.remove_cross_chain_attestor(&w.admin, &polygon(), &attestor); + assert!(!cp.is_cross_chain_attestor(&polygon(), &attestor)); + assert_eq!(cp.get_cross_chain_attestors(&polygon()).len(), 0); +} + +#[test] +#[should_panic(expected = "Error(Contract, #17)")] +fn test_unregistered_attestor_cannot_submit_attestation() { + let w = deploy(); + let pid = create_crop_product(&w); + let buyer = Address::generate(&w.env); + let pol_id = buy_crop_policy(&w, &buyer, pid); + let stranger = Address::generate(&w.env); + + ClaimsProcessorClient::new(&w.env, &w.claims_id).submit_cross_chain_attestation( + &stranger, + &pol_id, + &polygon(), + &20_000_000i128, + &zero_proof(&w.env), + &w.env.ledger().timestamp(), + ); +} + +/// A registered attestor reports rainfall below the policy's drought +/// threshold on another chain → the claim pays out exactly as the Stellar +/// oracle path would. +#[test] +fn test_process_cross_chain_claim_pays_out_when_trigger_met() { + let w = deploy(); + let pid = create_crop_product(&w); + let buyer = Address::generate(&w.env); + let pol_id = buy_crop_policy(&w, &buyer, pid); + let attestor = Address::generate(&w.env); + + let cp = ClaimsProcessorClient::new(&w.env, &w.claims_id); + cp.add_cross_chain_attestor(&w.admin, &polygon(), &attestor); + + let claim_id = cp.submit_claim(&buyer, &pol_id); + cp.submit_cross_chain_attestation( + &attestor, + &pol_id, + &polygon(), + &20_000_000i128, // < 50mm threshold → trigger met + &zero_proof(&w.env), + &w.env.ledger().timestamp(), + ); + + let result = cp.process_cross_chain_claim(&w.keeper, &claim_id, &None); + assert_eq!(result, ClaimResult::Paid); + assert_eq!(cp.get_claim(&claim_id).status, ClaimStatus::Paid); +} + +#[test] +fn test_process_cross_chain_claim_rejects_when_trigger_not_met() { + let w = deploy(); + let pid = create_crop_product(&w); + let buyer = Address::generate(&w.env); + let pol_id = buy_crop_policy(&w, &buyer, pid); + let attestor = Address::generate(&w.env); + + let cp = ClaimsProcessorClient::new(&w.env, &w.claims_id); + cp.add_cross_chain_attestor(&w.admin, &polygon(), &attestor); + + let claim_id = cp.submit_claim(&buyer, &pol_id); + cp.submit_cross_chain_attestation( + &attestor, + &pol_id, + &polygon(), + &80_000_000i128, // > 50mm threshold → trigger NOT met + &zero_proof(&w.env), + &w.env.ledger().timestamp(), + ); + + let result = cp.process_cross_chain_claim(&w.keeper, &claim_id, &None); + assert_eq!(result, ClaimResult::Rejected); +} + +#[test] +#[should_panic(expected = "Error(Contract, #18)")] +fn test_process_cross_chain_claim_without_attestation_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); + + let cp = ClaimsProcessorClient::new(&w.env, &w.claims_id); + let claim_id = cp.submit_claim(&buyer, &pol_id); + cp.process_cross_chain_claim(&w.keeper, &claim_id, &None); +} + +#[test] +#[should_panic(expected = "Error(Contract, #19)")] +fn test_process_cross_chain_claim_rejects_stale_attestation() { + let w = deploy(); + let pid = create_crop_product(&w); + let buyer = Address::generate(&w.env); + let pol_id = buy_crop_policy(&w, &buyer, pid); + let attestor = Address::generate(&w.env); + + let cp = ClaimsProcessorClient::new(&w.env, &w.claims_id); + cp.add_cross_chain_attestor(&w.admin, &polygon(), &attestor); + + let claim_id = cp.submit_claim(&buyer, &pol_id); + cp.submit_cross_chain_attestation( + &attestor, + &pol_id, + &polygon(), + &20_000_000i128, + &zero_proof(&w.env), + &w.env.ledger().timestamp(), + ); + + // Past the (default) 7-day staleness threshold configured at initialize(). + w.env.ledger().with_mut(|l| l.timestamp += 604_800 + 1); + cp.process_cross_chain_claim(&w.keeper, &claim_id, &None); +} + +#[test] +#[should_panic(expected = "Error(Contract, #17)")] +fn test_removed_attestor_cannot_submit_attestation() { + let w = deploy(); + let pid = create_crop_product(&w); + let buyer = Address::generate(&w.env); + let pol_id = buy_crop_policy(&w, &buyer, pid); + let attestor = Address::generate(&w.env); + + let cp = ClaimsProcessorClient::new(&w.env, &w.claims_id); + cp.add_cross_chain_attestor(&w.admin, &polygon(), &attestor); + cp.remove_cross_chain_attestor(&w.admin, &polygon(), &attestor); + + cp.submit_cross_chain_attestation( + &attestor, + &pol_id, + &polygon(), + &20_000_000i128, + &zero_proof(&w.env), + &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); +} + +// ── Batch Claim Processing (Issue #427) ────────────────────────────────────── + +#[test] +fn test_batch_submit_and_process_claims() { + let w = deploy(); + let pid = create_crop_product(&w); + let buyer = Address::generate(&w.env); + let pol_id1 = buy_crop_policy(&w, &buyer, pid); + let pol_id2 = buy_crop_policy(&w, &buyer, pid); + + submit_rainfall(&w, 20_000_000); // 20mm < 50mm threshold + + let cp = ClaimsProcessorClient::new(&w.env, &w.claims_id); + + let mut policy_ids = soroban_sdk::Vec::new(&w.env); + policy_ids.push_back(pol_id1); + policy_ids.push_back(pol_id2); + + let claim_ids = cp.batch_submit_claims(&buyer, &policy_ids); + assert_eq!(claim_ids.len(), 2); + + let pending = cp.get_pending_claims(); + assert_eq!(pending.len(), 2); + + let results = cp.batch_process_claims(&w.keeper, &claim_ids, &None); + assert_eq!(results.len(), 2); + assert_eq!(results.get_unchecked(0).1, ClaimResult::Paid); + assert_eq!(results.get_unchecked(1).1, ClaimResult::Paid); + + assert_eq!(cp.get_pending_claims().len(), 0); +} + diff --git a/contracts/claims-processor/src/types.rs b/contracts/claims-processor/src/types.rs index c4116df..dc079dc 100644 --- a/contracts/claims-processor/src/types.rs +++ b/contracts/claims-processor/src/types.rs @@ -56,8 +56,13 @@ pub struct Claim { /// For PartiallyPaid claims: payout ratio in basis points (0-10000). /// 10000 = full coverage; lower = proportional partial payment. pub partial_payout_bps: Option, - /// Installment payout configuration for large claims. - pub installments: Option, + /// Installment payout configuration for large claims. `Vec` instead of + /// `Option` because the soroban-sdk XDR (`ScVal`) + /// conversion generated for a contracttype struct does not support + /// `Option` fields — only `Vec` round-trips a custom + /// struct through both the WASM `Val` path and the host-side `ScVal` + /// path used by test tooling. Empty = no installment schedule. + pub installments: Vec, /// Whether the claimant's identity was verified (optional, for Sybil protection). pub identity_verified: bool, /// Type of identity verification performed (e.g., "kyc", "accreditation"). @@ -195,3 +200,177 @@ pub struct ContractUpgraded { pub old_version: u32, pub new_version: u32, } + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct GuardiansUpdated { + pub guardians: Vec
, + pub threshold: u32, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct UpgradeApproved { + pub new_wasm_hash: BytesN<32>, + pub approver: Address, + pub approvals: u32, + pub threshold: u32, +} + +/// A pending contract-upgrade action awaiting guardian approvals. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PendingUpgrade { + pub new_wasm_hash: BytesN<32>, + pub new_version: u32, + pub approvals: Vec
, +} + +/// A pending admin-transfer proposal awaiting guardian approvals. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PendingAdminChange { + pub new_admin: Address, + pub approvals: Vec
, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct AdminUpdated { + pub new_admin: Address, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CrossChainAttestation { + /// Which chain this observation came from. + pub chain_id: Symbol, + /// The registered attestor that submitted it. + pub attestor: Address, + /// The observed value, in the same fixed-point units as + /// `Policy.trigger_threshold`. + pub observed_value: i128, + /// Hash of the off-chain proof (light-client proof, relayer message, + /// oracle report) backing `observed_value`. Opaque to the contract — + /// kept for audit/dispute purposes, not verified on-chain. + pub proof_hash: BytesN<32>, + /// Unix timestamp of the observation. + pub timestamp: u64, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CrossChainAttestorAdded { + pub chain_id: Symbol, + pub attestor: Address, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CrossChainAttestorRemoved { + pub chain_id: Symbol, + pub attestor: Address, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CrossChainAttestationSubmitted { + pub policy_id: u128, + pub chain_id: Symbol, + pub attestor: Address, + pub observed_value: i128, + pub timestamp: u64, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct InstallmentPayoutScheduled { + pub claim_id: u128, + pub policy_id: u128, + pub claimant: Address, + pub total_amount: i128, + pub num_installments: u32, + pub interval_seconds: u64, + pub first_installment_at: u64, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct InstallmentPaid { + pub claim_id: u128, + pub claimant: Address, + pub amount: i128, + pub paid_count: u32, + pub total_installments: u32, +} + +/// Emitted when the payout delay configuration is updated. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PayoutDelayUpdated { + pub delay_seconds: u64, +} + +/// Supported payout currencies for claim settlements. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum PayoutCurrency { + /// Default USDC (7-decimal) + USDC, + /// Alternative stablecoin (address stored separately) + Custom(Address), +} + +/// Multi-currency payout configuration for a claim. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PayoutOption { + /// Currency to pay out in. + pub currency: PayoutCurrency, + /// Exchange rate (basis points) relative to USDC. 10000 = 1:1 parity. + /// Used to convert USDC coverage amounts to equivalent other-currency amounts. + pub exchange_rate_bps: u32, + /// Whether this payout option is currently enabled. + pub enabled: bool, +} + +/// Record of available payout currencies and their exchange rates. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PayoutCurrencyRegistry { + /// Address of the USDC token contract (default payout currency). + pub usdc_token: Address, + /// Optional alternative payout currencies with their exchange rates. + pub alt_currencies: Vec
, + /// Exchange rates for alt currencies (index matches alt_currencies). + pub exchange_rates_bps: Vec, +} + +/// Emitted when a new payout currency is registered. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PayoutCurrencyAdded { + pub token: Address, + pub exchange_rate_bps: u32, +} + +/// Emitted when a payout currency's exchange rate is updated. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PayoutCurrencyRateUpdated { + pub token: Address, + pub old_rate_bps: u32, + pub new_rate_bps: u32, +} + +/// Emitted when a claim is paid out in an alternate currency. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ClaimPaidInAlternativeCurrency { + pub claim_id: u128, + pub claimant: Address, + pub usdc_equivalent: i128, + pub token: Address, + pub actual_amount: i128, + pub exchange_rate_bps: u32, +} diff --git a/contracts/governance-dao/src/lib.rs b/contracts/governance-dao/src/lib.rs index fff93b0..327be98 100644 --- a/contracts/governance-dao/src/lib.rs +++ b/contracts/governance-dao/src/lib.rs @@ -14,16 +14,65 @@ //! v2 — full implementation; DAO is now deployable and testable. #![no_std] extern crate alloc; -use alloc::string::ToString; use soroban_sdk::{ contract, contracterror, contractimpl, contracttype, panic_with_error, token, Address, Bytes, - BytesN, Env, Symbol, Val, Vec, + BytesN, Env, IntoVal, Symbol, Val, Vec, }; pub mod types; pub use types::*; +// ─── Cross-contract client interfaces ──────────────────────────────────────── + +#[soroban_sdk::contractclient(name = "RiskPoolClient")] +trait IRiskPool { + fn get_delegated_shares(env: Env, provider: Address) -> i128; +} + +/// Minimum voting period: 1 hour in seconds. +const MIN_VOTING_PERIOD: u64 = 3_600; +/// Maximum voting period: 30 days in seconds. Prevents an admin from setting +/// an unreachably large period that would cause vote_end + FINALIZE_DELAY to +/// overflow or make proposals permanently unresolvable. +const MAX_VOTING_PERIOD: u64 = 30 * 24 * 3_600; +/// Storage TTL threshold for proposal-related entries +// Issue #342: kept in sync by hand across all 5 contracts (governance-dao, +// risk-pool, policy-engine, oracle-verifier, claims-processor) — extracting +// to a shared crate is a real follow-up, not done here to avoid touching +// every contract's Cargo.toml in one pass. +const TTL_THRESHOLD: u32 = 518_400; // ~30 days +/// Storage TTL extension target for proposal-related entries +const TTL_EXTEND_TO: u32 = 6_312_000; // ~1 year +/// Minimum delay after vote_end before finalize() can be called +const FINALIZE_DELAY: u64 = 300; // 5 minutes +/// How long a proposal must sit unfinalized past `vote_end` before its +/// proposer (or anyone, on the proposer's behalf) can reclaim the deposit +/// directly via `reclaim_deposit`, bypassing the quorum/majority computation +/// `finalize()` performs. `finalize()` is already permissionless and already +/// refunds the deposit on every outcome, but nothing obliges anyone to ever +/// call it — a proposal that clearly failed quorum has no one economically +/// incentivized to pay for finalizing it. This is the backstop so a deposit +/// can never depend on that goodwill (issue #378). Set well beyond +/// `FINALIZE_DELAY` so the normal finalize path always gets first chance. +const DEPOSIT_RECLAIM_TIMEOUT: u64 = 14 * 24 * 3_600; // 14 days +/// Lower bound on `DaoConfig.quorum_bps` (issue #355). Without a floor the +/// admin could configure `quorum_bps = 0`, letting a proposal pass on +/// negligible turnout. 1000 = 10% of total supply must participate. +const MIN_QUORUM_BPS: u32 = 1_000; +/// Number of recent proposals averaged for participation when adaptive quorum +/// is enabled without an explicit window. +const DEFAULT_PARTICIPATION_WINDOW: u32 = 5; +/// Upper bound on the participation window. Bounds both the stored history and +/// the averaging loop. +const MAX_PARTICIPATION_WINDOW: u32 = 20; +/// Maximum delegators a single delegate may accept. +/// +/// Bounds the loop that sums delegated weight at vote time — an unbounded +/// delegate list would eventually make voting cost more instructions than a +/// transaction can carry, which would lock that delegate out entirely. +const MAX_DELEGATORS: u32 = 50; + #[contracttype] enum StorageKey { Initialized, @@ -35,8 +84,39 @@ enum StorageKey { LockedBalance(u64, Address), /// Contract version (u32) for storage migration tracking Version, + /// Guardian addresses authorized to approve critical actions (Vec
). + Guardians, + /// Number of guardian approvals required to execute a critical action + /// (u32). 0 means guardian multisig is disabled (admin acts alone). + GuardianThreshold, + /// A pending, not-yet-executed contract upgrade awaiting guardian approvals. + PendingUpgrade, + /// Adaptive-quorum settings (`QuorumDecayConfig`). + QuorumDecay, + /// Rolling turnout history used to compute adaptive quorum. + Participation, + /// Who an address has delegated its voting power to — Address → Address. + DelegateOf(Address), + /// Addresses currently delegating to an account — Address → Vec
. + Delegators(Address), + /// Marks that a delegator's weight was already counted in a proposal, so + /// they cannot also vote it themselves — (proposal_id, delegator). + DelegatedVote(u64, Address), + /// A registered proposal template (`ProposalTemplate`) by name. + Template(Symbol), + /// Names of all registered templates (`Vec`). + TemplateList, + /// Risk pool contract address for querying LP vote delegation. + RiskPool, + /// On-chain audit trail record for executed proposal — proposal_id -> ExecutionAuditRecord. + ExecutionAudit(u64), + /// Proposal comment by ID — comment_id -> ProposalComment. + ProposalComment(u128), + /// Next comment ID counter for a proposal — proposal_id -> u128. + NextCommentId(u64), } + #[contracterror] #[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] #[repr(u32)] @@ -57,6 +137,34 @@ pub enum Error { FinalizeDelayNotMet = 14, VersionNotNewer = 15, LimitReached = 16, + VotingPeriodTooShort = 17, + VotingPeriodTooLong = 18, + InvalidAddress = 19, + NotGuardian = 20, + AlreadyApprovedAction = 21, + NoPendingUpgrade = 22, + InvalidThreshold = 23, + QuorumTooLow = 24, + InvalidQuorumDecay = 25, + SelfDelegation = 26, + NoDelegation = 27, + AlreadyDelegated = 28, + WeightAlreadyCounted = 29, + TooManyDelegators = 30, + ProposalNotExpired = 33, + TemplateNotFound = 34, + TemplateInactive = 35, + TitleTooShort = 36, + ArgCountMismatch = 37, + DiscussionPeriodNotEnded = 38, + DiscussionPeriodNotRequired = 39, + /// `vote_batch` was called with an empty proposal list. + NoProposals = 40, + InvalidInput = 41, + /// Proposal passed but execution deadline has expired without execution. + ExecutionDeadlineExpired = 42, + /// Proposal has been vetoed by a guardian and can never be executed. + ProposalVetoed = 43, } #[contract] @@ -75,34 +183,39 @@ impl GovernanceDao { // Address verification let admin_str = admin.to_string(); - - if false { - panic!("invalid address: admin must be an account address"); - } - + if admin_str.len() != 56 { - panic!("invalid address: admin must be an account or contract address"); + panic_with_error!(&env, Error::InvalidAddress); } let mut admin_buf = [0u8; 56]; admin_str.copy_into_slice(&mut admin_buf); if admin_buf[0] != b'G' && admin_buf[0] != b'C' { - panic!("invalid address: admin must be an account or contract address"); + panic_with_error!(&env, Error::InvalidAddress); } let gov_token_str = config.gov_token.to_string(); if gov_token_str.len() != 56 { - panic!("invalid address: gov_token must be a contract address"); + panic_with_error!(&env, Error::InvalidAddress); } let mut gov_token_buf = [0u8; 56]; gov_token_str.copy_into_slice(&mut gov_token_buf); if gov_token_buf[0] != b'C' { - panic!("invalid address: gov_token must be a contract address"); + panic_with_error!(&env, Error::InvalidAddress); } env.storage() .instance() .set(&StorageKey::Initialized, &true); + if config.voting_period < MIN_VOTING_PERIOD { + panic_with_error!(&env, Error::VotingPeriodTooShort); + } + if config.voting_period > MAX_VOTING_PERIOD { + panic_with_error!(&env, Error::VotingPeriodTooLong); + } + if config.quorum_bps < MIN_QUORUM_BPS { + panic_with_error!(&env, Error::QuorumTooLow); + } env.storage().instance().set(&StorageKey::Admin, &admin); env.storage().instance().set(&StorageKey::Config, &config); env.storage() @@ -132,9 +245,21 @@ impl GovernanceDao { title: Bytes, target: Address, function: Symbol, - args: Vec, // <--- ADD THIS ARGUMENT + args: Vec, + impact_analysis: Bytes, ) -> u64 { proposer.require_auth(); + Self::validate_stellar_address(&env, &target); + + // Validate impact analysis is provided (non-empty) + if impact_analysis.is_empty() { + panic_with_error!(&env, Error::InvalidInput); + } + // Enforce maximum length for impact analysis (4096 bytes) + if impact_analysis.len() > 4096 { + panic_with_error!(&env, Error::InvalidInput); + } + let config: DaoConfig = env .storage() .instance() @@ -150,7 +275,11 @@ 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() @@ -159,27 +288,147 @@ impl GovernanceDao { .unwrap_or(0); let now = env.ledger().timestamp(); + let discussion_period = config.discussion_period; + let (status, vote_end) = if discussion_period > 0 { + // Proposal enters Discussion; voting opens after the period elapses. + (ProposalStatus::Discussion, now.saturating_add(discussion_period).saturating_add(config.voting_period)) + } else { + (ProposalStatus::Active, now.saturating_add(config.voting_period)) + }; + let proposal = Proposal { id: proposal_id, proposer: proposer.clone(), title, target: target.clone(), function: function.clone(), - args, // <--- BIND TO STRUCT + args, deposit, - status: ProposalStatus::Active, + status, votes_for: 0, votes_against: 0, votes_abstain: 0, created_at: now, - vote_end: now.saturating_add(config.voting_period), + vote_end, execution_time: 0, + execution_deadline: vote_end.saturating_add(FINALIZE_DELAY).saturating_add(7 * 24 * 3600), total_supply: config.total_supply, + kind: ProposalKind::Standard, + impact_analysis, + verification_callback: None, + execution_verified: true, + is_vetoed: false, }; + let proposal_key = StorageKey::Proposal(proposal_id); + env.storage().persistent().set(&proposal_key, &proposal); env.storage() - .persistent() - .set(&StorageKey::Proposal(proposal_id), &proposal); + .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( + (Symbol::new(&env, "proposal_created"),), + ProposalCreated { + proposal_id, + proposer, + target, + function, + }, + ); + + proposal_id + } + + /// Create a proposal that, on execution, upgrades `target`'s contract + /// WASM to `new_wasm_hash`. `target` must have this DAO's own address + /// configured as its admin — `execute()` invokes + /// `target::upgrade(dao_address, new_wasm_hash)` on the DAO's behalf, so + /// contract-upgrade authorization becomes a first-class governance + /// action instead of living solely with a single admin key. + /// + /// Shares the same threshold/deposit/vote/finalize/timelock lifecycle as + /// `create_proposal`; only the proposal `kind` and pre-built + /// target/function/args differ. + pub fn propose_upgrade( + env: Env, + proposer: Address, + title: Bytes, + target: Address, + new_wasm_hash: BytesN<32>, + impact_analysis: Bytes, + ) -> u64 { + proposer.require_auth(); + Self::validate_stellar_address(&env, &target); + + // Validate impact analysis is provided (non-empty) + if impact_analysis.is_empty() { + panic_with_error!(&env, Error::InvalidInput); + } + // Enforce maximum length for impact analysis (4096 bytes) + if impact_analysis.len() > 4096 { + panic_with_error!(&env, Error::InvalidInput); + } + + let config: DaoConfig = env + .storage() + .instance() + .get(&StorageKey::Config) + .unwrap_or_else(|| panic_with_error!(&env, Error::NotInitialized)); + + let gov_token = token::Client::new(&env, &config.gov_token); + let weight = gov_token.balance(&proposer); + if weight < config.proposal_threshold { + panic_with_error!(&env, Error::InsufficientWeight); + } + let deposit = config.proposal_threshold; + gov_token.transfer(&proposer, &env.current_contract_address(), &deposit); + + let proposal_id: u64 = env + .storage() + .instance() + .get(&StorageKey::NextProposalId) + .unwrap_or(0); + let now = env.ledger().timestamp(); + + let mut args: Vec = Vec::new(&env); + args.push_back(env.current_contract_address().into_val(&env)); + args.push_back(new_wasm_hash.into_val(&env)); + let function = Symbol::new(&env, "upgrade"); + + let discussion_period = config.discussion_period; + let (status, vote_end) = if discussion_period > 0 { + (ProposalStatus::Discussion, now.saturating_add(discussion_period).saturating_add(config.voting_period)) + } else { + (ProposalStatus::Active, now.saturating_add(config.voting_period)) + }; + + let proposal = Proposal { + id: proposal_id, + proposer: proposer.clone(), + title, + target: target.clone(), + function: function.clone(), + args, + deposit, + status, + votes_for: 0, + votes_against: 0, + votes_abstain: 0, + created_at: now, + vote_end, + execution_time: 0, + execution_deadline: vote_end.saturating_add(FINALIZE_DELAY).saturating_add(7 * 24 * 3600), + total_supply: config.total_supply, + kind: ProposalKind::Upgrade, + impact_analysis, + verification_callback: None, + execution_verified: true, + is_vetoed: false, + }; + + let proposal_key = StorageKey::Proposal(proposal_id); + env.storage().persistent().set(&proposal_key, &proposal); env.storage().instance().set( &StorageKey::NextProposalId, &(proposal_id @@ -187,7 +436,6 @@ impl GovernanceDao { .unwrap_or_else(|| panic_with_error!(&env, Error::LimitReached))), ); - // Note: You can append `args` to your event payload if necessary env.events().publish( (Symbol::new(&env, "proposal_created"),), ProposalCreated { @@ -201,6 +449,186 @@ impl GovernanceDao { proposal_id } + // ── Delegation (issue #363) ─────────────────────────────────────────────── + + /// Delegate this account's voting power to `delegate`. + /// + /// Delegation moves *authority*, not tokens. The delegator keeps custody of + /// their balance throughout — the DAO counts it toward the delegate's vote + /// at the moment that vote is cast, and the delegator can revoke at any + /// time. That matters: a design that required transferring tokens to a + /// delegate would make delegation a custody decision, which is a far higher + /// bar than the participation problem it is meant to solve. + /// + /// Weight is read live at vote time rather than snapshotted here, so a + /// delegator who sells their tokens stops lending weight they no longer + /// have. + /// + /// One hop only. Delegating to someone who has themselves delegated is + /// rejected: chains make the weight behind a vote impossible to see, and a + /// cycle would let weight be counted more than once. + pub fn delegate(env: Env, delegator: Address, delegate: Address) { + delegator.require_auth(); + + if delegator == delegate { + panic_with_error!(&env, Error::SelfDelegation); + } + + // Refuse to build a chain — the delegate must vote for themselves. + if env + .storage() + .persistent() + .has(&StorageKey::DelegateOf(delegate.clone())) + { + panic_with_error!(&env, Error::SelfDelegation); + } + + // A delegator cannot be a delegate: allowing both would create the + // same ambiguity as a chain, one hop later. + let own_delegators: Vec
= env + .storage() + .persistent() + .get(&StorageKey::Delegators(delegator.clone())) + .unwrap_or_else(|| Vec::new(&env)); + if !own_delegators.is_empty() { + panic_with_error!(&env, Error::SelfDelegation); + } + + let existing: Option
= env + .storage() + .persistent() + .get(&StorageKey::DelegateOf(delegator.clone())); + if let Some(current) = existing { + if current == delegate { + panic_with_error!(&env, Error::AlreadyDelegated); + } + // Re-pointing an existing delegation: detach from the old delegate + // first so their list cannot keep a stale entry. + Self::remove_delegator(&env, ¤t, &delegator); + } + + let mut delegators: Vec
= env + .storage() + .persistent() + .get(&StorageKey::Delegators(delegate.clone())) + .unwrap_or_else(|| Vec::new(&env)); + + if delegators.len() >= MAX_DELEGATORS { + panic_with_error!(&env, Error::TooManyDelegators); + } + delegators.push_back(delegator.clone()); + + env.storage() + .persistent() + .set(&StorageKey::DelegateOf(delegator.clone()), &delegate); + env.storage() + .persistent() + .set(&StorageKey::Delegators(delegate.clone()), &delegators); + + let config: DaoConfig = env.storage().instance().get(&StorageKey::Config).unwrap(); + let weight = token::Client::new(&env, &config.gov_token).balance(&delegator); + + env.events().publish( + (Symbol::new(&env, "power_delegated"),), + VotingPowerDelegated { + delegator: delegator.clone(), + delegate: delegate.clone(), + weight, + }, + ); + + // Track delegation creation for off-chain indexing + env.events().publish( + (Symbol::new(&env, "delegation_recorded"),), + DelegationRecorded { + delegator, + delegate, + action: Symbol::new(&env, "created"), + recorded_at: env.ledger().timestamp(), + }, + ); + } + + /// Withdraw a delegation, returning voting authority to the delegator. + /// + /// Effective immediately for any proposal the delegate has not already + /// voted on. Votes already cast are not unwound — the weight was counted + /// when the vote was recorded, and retroactively removing it would let a + /// delegator silently reverse a decision after the fact. + pub fn revoke_delegation(env: Env, delegator: Address) { + delegator.require_auth(); + + let delegate: Address = env + .storage() + .persistent() + .get(&StorageKey::DelegateOf(delegator.clone())) + .unwrap_or_else(|| panic_with_error!(&env, Error::NoDelegation)); + + env.storage() + .persistent() + .remove(&StorageKey::DelegateOf(delegator.clone())); + Self::remove_delegator(&env, &delegate, &delegator); + + env.events().publish( + (Symbol::new(&env, "delegation_revoked"),), + DelegationRevoked { + delegator: delegator.clone(), + delegate: delegate.clone(), + }, + ); + + // Track delegation revocation for off-chain indexing + env.events().publish( + (Symbol::new(&env, "delegation_recorded"),), + DelegationRecorded { + delegator, + delegate, + action: Symbol::new(&env, "revoked"), + recorded_at: env.ledger().timestamp(), + }, + ); + } + + /// Who this address has delegated to, if anyone. + pub fn get_delegate(env: Env, delegator: Address) -> Option
{ + env.storage() + .persistent() + .get(&StorageKey::DelegateOf(delegator)) + } + + /// A delegate's standing: who delegates to them, and what a vote cast right + /// now would carry. + /// + /// Weight is computed from live balances, so this is a projection rather + /// than a promise — a delegator who moves tokens before the vote changes + /// the answer. + pub fn get_delegate_info(env: Env, delegate: Address) -> DelegateInfo { + let delegators: Vec
= env + .storage() + .persistent() + .get(&StorageKey::Delegators(delegate.clone())) + .unwrap_or_else(|| Vec::new(&env)); + + let config: DaoConfig = env.storage().instance().get(&StorageKey::Config).unwrap(); + let gov_token = token::Client::new(&env, &config.gov_token); + + let mut delegated_weight: i128 = 0; + for i in 0..delegators.len() { + delegated_weight = + delegated_weight.saturating_add(gov_token.balance(&delegators.get_unchecked(i))); + } + + let own_weight = gov_token.balance(&delegate); + + DelegateInfo { + delegate, + delegators, + delegated_weight, + own_weight, + total_weight: own_weight.saturating_add(delegated_weight), + } + } + /// Cast a vote (For/Against/Abstain) on an Active proposal. /// /// The voter's entire current gov-token balance is used as their vote @@ -209,6 +637,7 @@ impl GovernanceDao { /// another address to vote again ("token cycling"). The locked amount /// is released later via `withdraw_tokens`, once the proposal is no /// longer Active. Each address may vote at most once per proposal. + pub fn vote(env: Env, voter: Address, proposal_id: u64, choice: VoteChoice) { voter.require_auth(); @@ -218,6 +647,9 @@ impl GovernanceDao { .get(&StorageKey::Proposal(proposal_id)) .unwrap_or_else(|| panic_with_error!(&env, Error::ProposalNotFound)); + if proposal.status == ProposalStatus::Discussion { + panic_with_error!(&env, Error::DiscussionPeriodNotEnded); + } if proposal.status != ProposalStatus::Active { panic_with_error!(&env, Error::ProposalNotActive); } @@ -232,18 +664,58 @@ impl GovernanceDao { let config: DaoConfig = env.storage().instance().get(&StorageKey::Config).unwrap(); let gov_token = token::Client::new(&env, &config.gov_token); + // A holder who has delegated has handed their authority away. Letting + // them vote too would count the same tokens twice — once here, once in + // their delegate's tally. + if env + .storage() + .persistent() + .has(&StorageKey::DelegateOf(voter.clone())) + { + panic_with_error!(&env, Error::WeightAlreadyCounted); + } + + // Likewise if a delegate has already voted and swept this holder's + // weight into their own tally. + if env + .storage() + .persistent() + .has(&StorageKey::DelegatedVote(proposal_id, voter.clone())) + { + panic_with_error!(&env, Error::WeightAlreadyCounted); + } + // 1. Capture voting balance weight - let weight = gov_token.balance(&voter); - if weight <= 0 { + let own_weight = gov_token.balance(&voter); + if own_weight <= 0 { panic_with_error!(&env, Error::InsufficientWeight); } + // Apply vote weight cap to prevent whale dominance (issue #431). + let capped_own_weight = if config.vote_weight_cap > 0 { + core::cmp::min(own_weight, config.vote_weight_cap) + } else { + own_weight + }; + // 2. Lock tokens in the DAO contract to prevent token cycling / double-voting - gov_token.transfer(&voter, &env.current_contract_address(), &weight); + // + // Only the voter's own tokens are locked. Delegated weight is counted, + // never custodied — the DAO has no authority to move a delegator's + // balance, and taking it would turn delegation into a custody decision. + gov_token.transfer(&voter, &env.current_contract_address(), &capped_own_weight); + + // 3. Add any weight delegated to this voter, recording each delegator + // so they cannot also vote this proposal themselves. + let delegated_weight = Self::collect_delegated_weight(&env, &voter, proposal_id, &gov_token); + let weight = capped_own_weight.saturating_add(delegated_weight); // Save the tracked locked balance for later retrieval + // Only the voter's own tokens were transferred in, so only that amount + // is refundable — refunding `weight` would pay out delegated balances + // the contract never held. let lock_key = StorageKey::LockedBalance(proposal_id, voter.clone()); - env.storage().persistent().set(&lock_key, &weight); + env.storage().persistent().set(&lock_key, &capped_own_weight); match choice { VoteChoice::For => proposal.votes_for += weight, @@ -259,9 +731,8 @@ impl GovernanceDao { weight, }, ); - env.storage() - .persistent() - .set(&StorageKey::Proposal(proposal_id), &proposal); + let proposal_key = StorageKey::Proposal(proposal_id); + env.storage().persistent().set(&proposal_key, &proposal); env.events().publish( (Symbol::new(&env, "vote_cast"),), @@ -274,6 +745,130 @@ impl GovernanceDao { ); } + /// Cast the same `choice` across several proposals in a single transaction + /// (issue #387). Voting on related proposals one at a time is tedious and + /// re-authorizes the same tokens repeatedly; a batch of bundled parameter + /// changes should be decided together, with a single vote. + /// + /// The caller's own tokens are locked exactly once for the whole batch — + /// they are custodied by the DAO for the duration of any still-active + /// proposal. The real lock is attributed to the first proposal; the others + /// share it (see `withdraw_tokens`, which no-ops for the shared ones, and + /// refunds the full amount exactly once through the first). + pub fn vote_batch(env: Env, voter: Address, proposal_ids: Vec, choice: VoteChoice) { + voter.require_auth(); + + if proposal_ids.is_empty() { + panic_with_error!(&env, Error::NoProposals); + } + + // A holder who has delegated away their vote cannot also vote it. + if env + .storage() + .persistent() + .has(&StorageKey::DelegateOf(voter.clone())) + { + panic_with_error!(&env, Error::WeightAlreadyCounted); + } + + // Pass 1 — validate every proposal before mutating any state, so a + // batch is all-or-nothing and never leaves a partial tally behind. + let config: DaoConfig = env.storage().instance().get(&StorageKey::Config).unwrap(); + let mut proposals: Vec = Vec::new(&env); + for i in 0..proposal_ids.len() { + let proposal_id = proposal_ids.get_unchecked(i); + let proposal: Proposal = env + .storage() + .persistent() + .get(&StorageKey::Proposal(proposal_id)) + .unwrap_or_else(|| panic_with_error!(&env, Error::ProposalNotFound)); + + if proposal.status == ProposalStatus::Discussion { + panic_with_error!(&env, Error::DiscussionPeriodNotEnded); + } + if proposal.status != ProposalStatus::Active { + panic_with_error!(&env, Error::ProposalNotActive); + } + if env.ledger().timestamp() > proposal.vote_end { + panic_with_error!(&env, Error::VotingClosed); + } + let vote_key = StorageKey::VoteRecord(proposal_id, voter.clone()); + if env.storage().persistent().has(&vote_key) { + panic_with_error!(&env, Error::AlreadyVoted); + } + if env + .storage() + .persistent() + .has(&StorageKey::DelegatedVote(proposal_id, voter.clone())) + { + panic_with_error!(&env, Error::WeightAlreadyCounted); + } + proposals.push_back(proposal); + } + + // Pass 2 — lock the voter's own tokens once, then tally each proposal. + let gov_token = token::Client::new(&env, &config.gov_token); + let own_weight = gov_token.balance(&voter); + if own_weight <= 0 { + panic_with_error!(&env, Error::InsufficientWeight); + } + + // Apply vote weight cap to prevent whale dominance (issue #431). + let capped_own_weight = if config.vote_weight_cap > 0 { + core::cmp::min(own_weight, config.vote_weight_cap) + } else { + own_weight + }; + gov_token.transfer(&voter, &env.current_contract_address(), &capped_own_weight); + + for i in 0..proposal_ids.len() { + let proposal_id = proposal_ids.get_unchecked(i); + let mut proposal = proposals.get_unchecked(i); + + let delegated_weight = + Self::collect_delegated_weight(&env, &voter, proposal_id, &gov_token); + let weight = capped_own_weight.saturating_add(delegated_weight); + + match choice { + VoteChoice::For => proposal.votes_for += weight, + VoteChoice::Against => proposal.votes_against += weight, + VoteChoice::Abstain => proposal.votes_abstain += weight, + } + + let vote_key = StorageKey::VoteRecord(proposal_id, voter.clone()); + env.storage().persistent().set( + &vote_key, + &VoteRecord { + voter: voter.clone(), + choice: choice.clone(), + weight, + }, + ); + + // The tokens were locked once for the whole batch. Attribute the + // real lock to the first proposal only; `withdraw_tokens` treats the + // rest as sharing that lock and no-ops for them. + let lock_amount = if i == 0 { capped_own_weight } else { 0 }; + env.storage() + .persistent() + .set(&StorageKey::LockedBalance(proposal_id, voter.clone()), &lock_amount); + + env.storage() + .persistent() + .set(&StorageKey::Proposal(proposal_id), &proposal); + + env.events().publish( + (Symbol::new(&env, "vote_cast"),), + VoteCast { + proposal_id, + voter: voter.clone(), + choice: choice.clone(), + weight, + }, + ); + } + } + /// Withdraw gov tokens that were locked by `vote()` on `proposal_id`. /// /// Only available once the proposal is no longer Active (i.e. after @@ -297,6 +892,19 @@ impl GovernanceDao { let locked_amount: i128 = env.storage().persistent().get(&lock_key).unwrap_or(0); if locked_amount <= 0 { + // A `vote_batch` call locks the voter's tokens once and attributes + // the real lock to its first proposal. The remaining proposals in + // the batch share that lock, so they correctly have no refundable + // balance of their own — the tokens are returned exactly once via + // the first proposal. If a vote record exists, this is that shared + // case; otherwise there is genuinely nothing to withdraw. + if env + .storage() + .persistent() + .has(&StorageKey::VoteRecord(proposal_id, voter.clone())) + { + return; + } panic_with_error!(&env, Error::InsufficientWeight); } @@ -319,12 +927,19 @@ impl GovernanceDao { /// Callable by anyone once `vote_end + FINALIZE_DELAY` has passed (the /// delay buffer prevents finalize being raced at the exact close of /// voting). Quorum is `total_votes >= total_supply * quorum_bps / - /// 10_000`, using the `total_supply` snapshotted at proposal creation. - /// If quorum is met, the proposal Passes only when `votes_for * 10_000 / - /// total_votes >= majority_bps`; otherwise (including the exact-tie - /// case, which lands at 50%) it Fails. Passing also starts the - /// execution timelock (`execution_time = now + proposal_timelock`). - /// The proposer's original deposit is refunded in both outcomes. + /// 10_000`, using the `total_supply` snapshotted at proposal creation, + /// where `total_votes` includes For, Against, *and* Abstain — an + /// abstaining holder still shows up for participation purposes. If + /// quorum is met, the proposal Passes only when `votes_for * 10_000 / + /// (votes_for + votes_against) >= majority_bps`; Abstain is excluded + /// from this ratio entirely, so it counts toward quorum but never + /// drags down (or props up) the For/Against split (issue #385). A + /// proposal with no partisan (For/Against) votes at all — e.g. + /// everyone abstained — has no majority to speak of and Fails, as does + /// the exact-tie case (which lands at exactly 50%). Passing also + /// starts the execution timelock (`execution_time = now + + /// proposal_timelock`). The proposer's original deposit is refunded in + /// both outcomes. pub fn finalize(env: Env, proposal_id: u64) { let mut proposal: Proposal = env .storage() @@ -344,21 +959,47 @@ impl GovernanceDao { let config: DaoConfig = env.storage().instance().get(&StorageKey::Config).unwrap(); let total_supply = proposal.total_supply; let total_votes = proposal.votes_for + proposal.votes_against + proposal.votes_abstain; + + // Adaptive quorum: after a run of low-turnout votes the requirement + // relaxes toward the configured floor, so a quiet DAO stays governable + // instead of deadlocking on proposals nobody can pass. Disabled by + // default, and never able to fall below MIN_QUORUM_BPS. + let effective = Self::effective_quorum(&env, &config); + if effective.decayed { + env.events().publish( + (Symbol::new(&env, "quorum_decay_applied"),), + QuorumDecayApplied { + proposal_id, + base_bps: effective.base_bps, + effective_bps: effective.quorum_bps, + avg_participation_bps: effective.avg_participation_bps, + }, + ); + } + let quorum_needed = total_supply - .checked_mul(config.quorum_bps as i128) + .checked_mul(effective.quorum_bps as i128) .and_then(|v| v.checked_div(10_000)) .unwrap_or(i128::MAX); - if total_votes < quorum_needed { + if total_votes == 0 { + // No participation at all — always fails, even for a legacy + // proposal that snapshotted total_supply == 0 (quorum_needed == 0). + proposal.status = ProposalStatus::Failed; + } else if total_votes < quorum_needed { 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) + // Majority is decided by the partisan (For/Against) split only — + // Abstain already did its job by counting toward quorum above and + // must not also dilute the For share here, otherwise a large + // abstaining bloc could sink a proposal that every partisan voter + // supported (issue #385). + let partisan_votes = proposal.votes_for + proposal.votes_against; + let for_bps = if partisan_votes > 0 { + proposal.votes_for.checked_mul(10_000).map(|v| v / partisan_votes).unwrap_or(0) } else { + // Nobody voted For or Against — an all-Abstain proposal has no + // majority to speak of, regardless of quorum. 0 }; if for_bps >= config.majority_bps as i128 { @@ -369,6 +1010,11 @@ impl GovernanceDao { } } + // Record this vote's turnout so it feeds the next proposal's adaptive + // quorum. Recorded for every finalized proposal, pass or fail, so the + // history reflects actual participation rather than only successes. + Self::record_participation(&env, total_votes, total_supply); + let gov_token = token::Client::new(&env, &config.gov_token); // Refund exactly what was locked at creation — never re-read the // live config, which the admin can change after the proposer @@ -379,9 +1025,8 @@ impl GovernanceDao { &proposal.deposit, ); - env.storage() - .persistent() - .set(&StorageKey::Proposal(proposal_id), &proposal); + let proposal_key = StorageKey::Proposal(proposal_id); + env.storage().persistent().set(&proposal_key, &proposal); env.events().publish( (Symbol::new(&env, "proposal_finalized"),), @@ -392,6 +1037,60 @@ impl GovernanceDao { ); } + /// Reclaim a proposer's deposit on a proposal that was never finalized. + /// + /// `finalize()` is already permissionless and already refunds the + /// deposit regardless of whether quorum/majority was reached — but it + /// still requires *someone* to call it, and nobody is economically + /// incentivized to spend a transaction finalizing a proposal that + /// plainly failed quorum. Without this, such a deposit can sit locked + /// indefinitely waiting on goodwill (issue #378). + /// + /// Callable by anyone once `now > vote_end + DEPOSIT_RECLAIM_TIMEOUT` — + /// far past the point `finalize()` itself becomes callable — on a + /// proposal still `Active`. Settles the proposal as `Failed` (mirroring + /// what `finalize()` would compute for zero additional turnout) and + /// refunds the deposit to the original proposer. + pub fn reclaim_deposit(env: Env, proposal_id: u64) { + let mut proposal: Proposal = env + .storage() + .persistent() + .get(&StorageKey::Proposal(proposal_id)) + .unwrap_or_else(|| panic_with_error!(&env, Error::ProposalNotFound)); + + if proposal.status != ProposalStatus::Active { + panic_with_error!(&env, Error::ProposalNotActive); + } + if env.ledger().timestamp() <= proposal.vote_end.saturating_add(DEPOSIT_RECLAIM_TIMEOUT) { + panic_with_error!(&env, Error::ProposalNotExpired); + } + + proposal.status = ProposalStatus::Failed; + + let config: DaoConfig = env.storage().instance().get(&StorageKey::Config).unwrap(); + let total_votes = proposal.votes_for + proposal.votes_against + proposal.votes_abstain; + Self::record_participation(&env, total_votes, proposal.total_supply); + + let gov_token = token::Client::new(&env, &config.gov_token); + gov_token.transfer( + &env.current_contract_address(), + &proposal.proposer, + &proposal.deposit, + ); + + let proposal_key = StorageKey::Proposal(proposal_id); + env.storage().persistent().set(&proposal_key, &proposal); + + env.events().publish( + (Symbol::new(&env, "deposit_reclaimed"),), + DepositReclaimed { + proposal_id, + proposer: proposal.proposer, + amount: proposal.deposit, + }, + ); + } + /// Mark a Passed proposal as Executed once its timelock has expired. /// /// Requires `status == Passed` and `now >= execution_time`. This @@ -415,29 +1114,184 @@ impl GovernanceDao { if proposal.status != ProposalStatus::Passed { panic_with_error!(&env, Error::ProposalNotPassed); } + // Guardian veto check: proposals vetoed by guardians cannot be executed + if proposal.is_vetoed { + panic_with_error!(&env, Error::ProposalVetoed); + } if env.ledger().timestamp() < proposal.execution_time { panic_with_error!(&env, Error::TimelockNotExpired); } + // Check execution deadline: if passed, prevent execution to keep proposals fresh + if env.ledger().timestamp() > proposal.execution_deadline { + proposal.status = ProposalStatus::Expired; + env.storage() + .persistent() + .set(&StorageKey::Proposal(proposal_id), &proposal); + panic_with_error!(&env, Error::ExecutionDeadlineExpired); + } + + // Validate target address is a valid Stellar address before execution + // This is a defense-in-depth check to prevent targeting invalid contracts + Self::validate_stellar_address(&env, &proposal.target); + + // Perform actual cross-contract call to target::function(args) + // If the target contract doesn't exist or the function is invalid, + // this call will fail and the proposal won't be marked as executed + let _: Val = env.invoke_contract(&proposal.target, &proposal.function, proposal.args.clone()); - // Signal execution — actual cross-contract call is the caller's responsibility - // (they build the Auth tree) to avoid this contract needing admin on targets. proposal.status = ProposalStatus::Executed; env.storage() .persistent() .set(&StorageKey::Proposal(proposal_id), &proposal); + let executed_at = env.ledger().timestamp(); + let audit = ExecutionAuditRecord { + proposal_id, + executor: proposal.proposer.clone(), + target: proposal.target.clone(), + function: proposal.function.clone(), + executed_at, + votes_for: proposal.votes_for, + votes_against: proposal.votes_against, + }; + let audit_key = StorageKey::ExecutionAudit(proposal_id); + env.storage().persistent().set(&audit_key, &audit); + env.storage().persistent().extend_ttl(&audit_key, TTL_THRESHOLD, TTL_EXTEND_TO); + env.events().publish( (Symbol::new(&env, "proposal_executed"),), - ProposalExecuted { proposal_id }, + ProposalExecuted { + proposal_id, + executor: proposal.proposer, + target: proposal.target, + function: proposal.function, + executed_at, + }, + ); + } + + /// Return the execution audit trail record for an executed proposal. + pub fn get_execution_audit(env: Env, proposal_id: u64) -> ExecutionAuditRecord { + env.storage() + .persistent() + .get(&StorageKey::ExecutionAudit(proposal_id)) + .unwrap_or_else(|| panic_with_error!(&env, Error::ProposalNotFound)) + } + + // ── On-Chain Discussion ────────────────────────────────────────────────── + + /// Post a comment on a proposal during the Discussion or Active phase. + /// Comments are stored on-chain for transparent discussion. + /// `reply_to` is optional and allows threading of comments. + pub fn add_comment( + env: Env, + commenter: Address, + proposal_id: u64, + text: Bytes, + reply_to: Option, + ) -> u128 { + commenter.require_auth(); + + // Validate text length (max 1024 bytes) + if text.is_empty() || text.len() > 1024 { + panic_with_error!(&env, Error::InvalidInput); + } + + // Verify proposal exists + let proposal: Proposal = env + .storage() + .persistent() + .get(&StorageKey::Proposal(proposal_id)) + .unwrap_or_else(|| panic_with_error!(&env, Error::ProposalNotFound)); + + // Only allow comments during Discussion or Active phases + if proposal.status != ProposalStatus::Discussion && proposal.status != ProposalStatus::Active { + panic_with_error!(&env, Error::InvalidInput); + } + + // If reply_to is specified, verify that comment exists + if let Some(reply_id) = reply_to { + if !env + .storage() + .persistent() + .has(&StorageKey::ProposalComment(reply_id)) + { + panic_with_error!(&env, Error::ProposalNotFound); + } + } + + // Generate comment ID + let comment_id: u128 = env + .storage() + .instance() + .get(&StorageKey::NextCommentId(proposal_id)) + .unwrap_or(1u128); + + let now = env.ledger().timestamp(); + let comment = ProposalComment { + id: comment_id, + proposal_id, + author: commenter.clone(), + text, + created_at: now, + reply_to, + }; + + let comment_key = StorageKey::ProposalComment(comment_id); + env.storage().persistent().set(&comment_key, &comment); + env.storage() + .persistent() + .extend_ttl(&comment_key, TTL_THRESHOLD, TTL_EXTEND_TO); + + env.storage() + .instance() + .set(&StorageKey::NextCommentId(proposal_id), &(comment_id.saturating_add(1))); + + env.events().publish( + (Symbol::new(&env, "comment_added"),), + ProposalCommentAdded { + proposal_id, + comment_id, + author: commenter, + reply_to, + created_at: now, + }, + ); + + comment_id + } + + /// Retrieve a comment by its ID. + pub fn get_comment(env: Env, comment_id: u128) -> ProposalComment { + env.storage() + .persistent() + .get(&StorageKey::ProposalComment(comment_id)) + .unwrap_or_else(|| panic_with_error!(&env, Error::ProposalNotFound)) + } + + /// Admin-only: delete a comment (for moderation of spam/abuse). + pub fn delete_comment(env: Env, admin: Address, comment_id: u128) { + Self::require_admin(&env, &admin); + + let comment_key = StorageKey::ProposalComment(comment_id); + if !env.storage().persistent().has(&comment_key) { + panic_with_error!(&env, Error::ProposalNotFound); + } + + env.storage().persistent().remove(&comment_key); + + env.events().publish( + (Symbol::new(&env, "comment_deleted"),), + comment_id, ); } /// Admin-only: cancel an Active proposal before voting closes. /// - /// Refunds the proposer's deposit (at the current `config` - /// `proposal_threshold`, since no vote has happened yet) and marks the - /// proposal Cancelled. Voters who already locked tokens can reclaim - /// them via `withdraw_tokens` once cancelled. + /// Refunds the proposer's deposit (the exact amount locked at + /// proposal creation) and marks the proposal Cancelled. Voters who + /// already locked tokens can reclaim them via `withdraw_tokens` + /// once cancelled. pub fn cancel(env: Env, admin: Address, proposal_id: u64) { Self::require_admin(&env, &admin); @@ -447,7 +1301,7 @@ impl GovernanceDao { .get(&StorageKey::Proposal(proposal_id)) .unwrap_or_else(|| panic_with_error!(&env, Error::ProposalNotFound)); - if proposal.status != ProposalStatus::Active { + if proposal.status != ProposalStatus::Active && proposal.status != ProposalStatus::Discussion { panic_with_error!(&env, Error::ProposalNotActive); } @@ -461,7 +1315,7 @@ impl GovernanceDao { gov_token.transfer( &env.current_contract_address(), &proposal.proposer, - &config.proposal_threshold, + &proposal.deposit, ); env.events().publish( @@ -470,6 +1324,263 @@ impl GovernanceDao { ); } + /// Transition a proposal from Discussion to Active once its discussion + /// period has elapsed. Callable by anyone — the discussion period is a + /// time-based gate, not a permission gate. + /// + /// If no discussion period is configured (`discussion_period == 0`), the + /// proposal was created directly in Active status and this function + /// panics with `DiscussionPeriodNotRequired`. + pub fn start_voting(env: Env, proposal_id: u64) { + let mut proposal: Proposal = env + .storage() + .persistent() + .get(&StorageKey::Proposal(proposal_id)) + .unwrap_or_else(|| panic_with_error!(&env, Error::ProposalNotFound)); + + if proposal.status != ProposalStatus::Discussion { + panic_with_error!(&env, Error::ProposalNotActive); + } + + let config: DaoConfig = env.storage().instance().get(&StorageKey::Config).unwrap(); + if config.discussion_period == 0 { + panic_with_error!(&env, Error::DiscussionPeriodNotRequired); + } + + // The discussion period ended when created_at + discussion_period passed. + let discussion_end = proposal.created_at.saturating_add(config.discussion_period); + if env.ledger().timestamp() < discussion_end { + panic_with_error!(&env, Error::DiscussionPeriodNotEnded); + } + + proposal.status = ProposalStatus::Active; + env.storage() + .persistent() + .set(&StorageKey::Proposal(proposal_id), &proposal); + + env.events().publish( + (Symbol::new(&env, "discussion_period_ended"),), + DiscussionPeriodEnded { proposal_id }, + ); + } + + + // ── Adaptive quorum ─────────────────────────────────────────────────────── + + /// Configure adaptive quorum decay. + /// + /// Disabled by default: an existing DAO keeps its static quorum until it + /// deliberately opts in. `floor_bps` is clamped up to `MIN_QUORUM_BPS` — + /// decay can relax the requirement toward the floor but can never take it + /// below the level the contract considers safe at all. + pub fn set_quorum_decay( + env: Env, + admin: Address, + enabled: bool, + floor_bps: u32, + decay_per_period_bps: u32, + window: u32, + ) { + Self::require_admin(&env, &admin); + + let config: DaoConfig = env + .storage() + .instance() + .get(&StorageKey::Config) + .unwrap_or_else(|| panic_with_error!(&env, Error::NotInitialized)); + + // A floor at or above the configured quorum would make decay a no-op, + // which is a configuration mistake worth surfacing rather than + // silently accepting. + if enabled && floor_bps >= config.quorum_bps { + panic_with_error!(&env, Error::InvalidQuorumDecay); + } + if enabled && (window == 0 || window > MAX_PARTICIPATION_WINDOW) { + panic_with_error!(&env, Error::InvalidQuorumDecay); + } + if enabled && decay_per_period_bps == 0 { + panic_with_error!(&env, Error::InvalidQuorumDecay); + } + + let effective_floor = floor_bps.max(MIN_QUORUM_BPS); + + let decay = QuorumDecayConfig { + enabled, + floor_bps: effective_floor, + decay_per_period_bps, + window, + }; + env.storage() + .instance() + .set(&StorageKey::QuorumDecay, &decay); + + env.events().publish( + (Symbol::new(&env, "quorum_decay_updated"),), + QuorumDecayConfigUpdated { + enabled, + floor_bps: effective_floor, + decay_per_period_bps, + window, + }, + ); + } + + /// The current adaptive-quorum settings. + pub fn get_quorum_decay(env: Env) -> QuorumDecayConfig { + Self::quorum_decay_config(&env) + } + + /// Rolling participation history used to compute adaptive quorum. + pub fn get_participation_history(env: Env) -> ParticipationHistory { + Self::participation_history(&env) + } + + /// The quorum that would be applied to a proposal finalized right now, + /// and the participation figure behind it. + /// + /// Exposed so voters can see the bar before they vote rather than + /// discovering it at finalize. + pub fn get_effective_quorum(env: Env) -> EffectiveQuorum { + let config: DaoConfig = env + .storage() + .instance() + .get(&StorageKey::Config) + .unwrap_or_else(|| panic_with_error!(&env, Error::NotInitialized)); + + Self::effective_quorum(&env, &config) + } + + // ── Proposal templates ──────────────────────────────────────────────────── + + /// Admin-only: register (or update) a named proposal template. + /// + /// A template is a minimal structural contract for a common proposal + /// type: a minimum title length and an exact expected argument count. + /// `create_proposal_from_template` rejects proposals that don't match, + /// so reviewers no longer have to parse intent out of free-form, + /// inconsistent proposals for the categories a DAO cares to standardize + /// (issue #381). + pub fn register_template( + env: Env, + admin: Address, + name: Symbol, + description: Bytes, + min_title_len: u32, + required_arg_count: u32, + ) { + Self::require_admin(&env, &admin); + + let template = ProposalTemplate { + name: name.clone(), + description, + min_title_len, + required_arg_count, + active: true, + }; + env.storage() + .instance() + .set(&StorageKey::Template(name.clone()), &template); + + let mut names: Vec = env + .storage() + .instance() + .get(&StorageKey::TemplateList) + .unwrap_or_else(|| Vec::new(&env)); + let mut already_present = false; + for n in names.iter() { + if n == name { + already_present = true; + break; + } + } + if !already_present { + names.push_back(name.clone()); + env.storage().instance().set(&StorageKey::TemplateList, &names); + } + + env.events().publish( + (Symbol::new(&env, "template_registered"),), + TemplateRegistered { + name, + min_title_len, + required_arg_count, + }, + ); + } + + /// Admin-only: deactivate a template so it can no longer be used for new + /// proposals. Proposals already created from it are unaffected. + pub fn deactivate_template(env: Env, admin: Address, name: Symbol) { + Self::require_admin(&env, &admin); + let key = StorageKey::Template(name.clone()); + let mut template: ProposalTemplate = env + .storage() + .instance() + .get(&key) + .unwrap_or_else(|| panic_with_error!(&env, Error::TemplateNotFound)); + template.active = false; + env.storage().instance().set(&key, &template); + } + + /// Fetch a registered template by name. + pub fn get_template(env: Env, name: Symbol) -> ProposalTemplate { + env.storage() + .instance() + .get(&StorageKey::Template(name)) + .unwrap_or_else(|| panic_with_error!(&env, Error::TemplateNotFound)) + } + + /// List the names of all registered templates (active or not). + pub fn list_templates(env: Env) -> Vec { + env.storage() + .instance() + .get(&StorageKey::TemplateList) + .unwrap_or_else(|| Vec::new(&env)) + } + + /// Create a proposal, validated against a registered template's + /// structure before creation: `title` must be at least + /// `template.min_title_len` bytes and `args` must have exactly + /// `template.required_arg_count` entries. Otherwise behaves exactly + /// like `create_proposal` (same deposit/threshold/lifecycle). + pub fn create_proposal_from_template( + env: Env, + proposer: Address, + template_name: Symbol, + title: Bytes, + target: Address, + function: Symbol, + args: Vec, + impact_analysis: Bytes, + ) -> u64 { + let template: ProposalTemplate = env + .storage() + .instance() + .get(&StorageKey::Template(template_name.clone())) + .unwrap_or_else(|| panic_with_error!(&env, Error::TemplateNotFound)); + if !template.active { + panic_with_error!(&env, Error::TemplateInactive); + } + if title.len() < template.min_title_len { + panic_with_error!(&env, Error::TitleTooShort); + } + if args.len() != template.required_arg_count { + panic_with_error!(&env, Error::ArgCountMismatch); + } + + let proposal_id = Self::create_proposal(env.clone(), proposer, title, target, function, args, impact_analysis); + + env.events().publish( + (Symbol::new(&env, "proposal_created_from_template"),), + ProposalCreatedFromTemplate { + proposal_id, + template_name, + }, + ); + + proposal_id + } + // ── Queries ─────────────────────────────────────────────────────────────── /// Fetch a proposal by id. Panics with `ProposalNotFound` if it doesn't exist. @@ -504,6 +1615,25 @@ impl GovernanceDao { .unwrap_or_else(|| panic_with_error!(&env, Error::NotInitialized)) } + /// Admin-only: set the risk pool contract address. When set, the + /// `vote()` function queries the risk pool for any LP share delegation + /// the voter may have, adding delegated LP shares to their voting + /// weight on top of their gov-token balance. + pub fn set_risk_pool(env: Env, admin: Address, risk_pool: Address) { + Self::require_admin(&env, &admin); + Self::validate_stellar_address(&env, &risk_pool); + env.storage().instance().set(&StorageKey::RiskPool, &risk_pool); + env.events().publish( + (Symbol::new(&env, "risk_pool_set"),), + risk_pool, + ); + } + + /// Return the configured risk pool contract address, if any. + pub fn get_risk_pool(env: Env) -> Option
{ + env.storage().instance().get(&StorageKey::RiskPool) + } + /// Return the total number of proposals ever created (next proposal id). pub fn proposal_count(env: Env) -> u64 { env.storage() @@ -514,10 +1644,7 @@ 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 ───────────────────────────────────────────────────────────────── @@ -530,6 +1657,15 @@ impl GovernanceDao { /// here cannot retroactively affect proposals already in flight. pub fn update_config(env: Env, admin: Address, config: DaoConfig) { Self::require_admin(&env, &admin); + if config.voting_period < MIN_VOTING_PERIOD { + panic_with_error!(&env, Error::VotingPeriodTooShort); + } + if config.voting_period > MAX_VOTING_PERIOD { + panic_with_error!(&env, Error::VotingPeriodTooLong); + } + if config.quorum_bps < MIN_QUORUM_BPS { + panic_with_error!(&env, Error::QuorumTooLow); + } env.storage().instance().set(&StorageKey::Config, &config); env.events().publish( @@ -547,33 +1683,218 @@ impl GovernanceDao { /// 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. + /// + /// If a guardian threshold > 0 is configured (`set_guardians`), this call + /// does not upgrade immediately — it registers the upgrade as pending and + /// requires `threshold` guardians to call `approve_upgrade` before the + /// WASM is actually replaced, guarding this irreversible operation + /// against a single compromised admin key. 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); + let threshold: u32 = env + .storage() + .instance() + .get(&StorageKey::GuardianThreshold) + .unwrap_or(0); + if threshold == 0 { + Self::run_migrations(&env, current_version, new_version); + env.storage().instance().set(&StorageKey::Version, &new_version); + env.deployer().update_current_contract_wasm(new_wasm_hash); + + env.events().publish( + (Symbol::new(&env, "contract_upgraded"),), + ContractUpgraded { + old_version: current_version, + new_version, + }, + ); + return; + } + + let pending = PendingUpgrade { + new_wasm_hash, + new_version, + approvals: Vec::new(&env), + }; + env.storage().instance().set(&StorageKey::PendingUpgrade, &pending); + } + + /// Configure the guardian set and approval threshold required for + /// critical actions (currently: contract upgrades). Admin-only. + /// `threshold == 0` disables the guardian requirement (default), so the + /// admin alone can act — preserves existing single-admin behavior until + /// guardians are explicitly configured. + pub fn set_guardians(env: Env, admin: Address, guardians: Vec
, threshold: u32) { + Self::require_admin(&env, &admin); + if threshold > guardians.len() { + panic_with_error!(&env, Error::InvalidThreshold); + } + env.storage().instance().set(&StorageKey::Guardians, &guardians); + env.storage() + .instance() + .set(&StorageKey::GuardianThreshold, &threshold); + env.events().publish( + (Symbol::new(&env, "guardians_updated"),), + GuardiansUpdated { guardians, threshold }, + ); + } + + /// Return the current guardian set. + pub fn get_guardians(env: Env) -> Vec
{ + env.storage() + .instance() + .get(&StorageKey::Guardians) + .unwrap_or_else(|| Vec::new(&env)) + } - // Update the stored version + /// Return the current guardian approval threshold (0 = disabled). + pub fn get_guardian_threshold(env: Env) -> u32 { env.storage() .instance() - .set(&StorageKey::Version, &new_version); + .get(&StorageKey::GuardianThreshold) + .unwrap_or(0) + } + + /// Return the pending upgrade awaiting guardian approvals, if any. + pub fn get_pending_upgrade(env: Env) -> Option { + env.storage().instance().get(&StorageKey::PendingUpgrade) + } + + /// Guardian approval for the pending upgrade. Once enough guardians have + /// approved (>= threshold), the upgrade executes immediately. + pub fn approve_upgrade(env: Env, guardian: Address, new_wasm_hash: BytesN<32>) { + guardian.require_auth(); + + let guardians: Vec
= env + .storage() + .instance() + .get(&StorageKey::Guardians) + .unwrap_or_else(|| Vec::new(&env)); + let mut is_guardian = false; + for g in guardians.iter() { + if g == guardian { + is_guardian = true; + break; + } + } + if !is_guardian { + panic_with_error!(&env, Error::NotGuardian); + } + + let mut pending: PendingUpgrade = env + .storage() + .instance() + .get(&StorageKey::PendingUpgrade) + .unwrap_or_else(|| panic_with_error!(&env, Error::NoPendingUpgrade)); + if pending.new_wasm_hash != new_wasm_hash { + panic_with_error!(&env, Error::NoPendingUpgrade); + } + for a in pending.approvals.iter() { + if a == guardian { + panic_with_error!(&env, Error::AlreadyApprovedAction); + } + } + pending.approvals.push_back(guardian.clone()); + + let threshold: u32 = env + .storage() + .instance() + .get(&StorageKey::GuardianThreshold) + .unwrap_or(0); + + env.events().publish( + (Symbol::new(&env, "upgrade_approved"),), + UpgradeApproved { + new_wasm_hash: new_wasm_hash.clone(), + approver: guardian, + approvals: pending.approvals.len(), + threshold, + }, + ); + + if pending.approvals.len() >= threshold { + let current_version: u32 = + env.storage().instance().get(&StorageKey::Version).unwrap_or(1); + env.storage().instance().remove(&StorageKey::PendingUpgrade); + Self::run_migrations(&env, current_version, pending.new_version); + env.storage() + .instance() + .set(&StorageKey::Version, &pending.new_version); + env.deployer().update_current_contract_wasm(new_wasm_hash); + + env.events().publish( + (Symbol::new(&env, "contract_upgraded"),), + ContractUpgraded { + old_version: current_version, + new_version: pending.new_version, + }, + ); + } else { + env.storage().instance().set(&StorageKey::PendingUpgrade, &pending); + } + } + + /// Admin-only: cancel a pending upgrade before it collects enough + /// guardian approvals. + pub fn cancel_pending_upgrade(env: Env, admin: Address) { + Self::require_admin(&env, &admin); + if !env.storage().instance().has(&StorageKey::PendingUpgrade) { + panic_with_error!(&env, Error::NoPendingUpgrade); + } + env.storage().instance().remove(&StorageKey::PendingUpgrade); + } + + /// Guardian veto for a security-critical proposal. + /// Only guardians can veto. Once vetoed, a proposal cannot be executed, + /// even if it passes voting and the timelock expires. + /// Use this to halt potentially malicious proposals before execution. + pub fn veto_proposal(env: Env, guardian: Address, proposal_id: u64, reason: Symbol) { + guardian.require_auth(); + + let guardians: Vec
= env + .storage() + .instance() + .get(&StorageKey::Guardians) + .unwrap_or_else(|| Vec::new(&env)); + + let mut is_guardian = false; + for g in guardians.iter() { + if g == guardian { + is_guardian = true; + break; + } + } + if !is_guardian { + panic_with_error!(&env, Error::NotGuardian); + } - // Perform the actual WASM upgrade - env.deployer().update_current_contract_wasm(new_wasm_hash); + let mut proposal: Proposal = env + .storage() + .persistent() + .get(&StorageKey::Proposal(proposal_id)) + .unwrap_or_else(|| panic_with_error!(&env, Error::ProposalNotFound)); + + // Can only veto before execution + if proposal.status == ProposalStatus::Executed { + panic_with_error!(&env, Error::AlreadyExecuted); + } + + proposal.is_vetoed = true; + env.storage() + .persistent() + .set(&StorageKey::Proposal(proposal_id), &proposal); env.events().publish( - (Symbol::new(&env, "contract_upgraded"),), - ContractUpgraded { - old_version: current_version, - new_version, + (Symbol::new(&env, "proposal_vetoed"),), + ProposalVetoed { + proposal_id, + guardian, + reason, }, ); } @@ -584,11 +1905,228 @@ 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); } } + /// Query the risk pool for any LP share delegation the voter may have. + /// Returns the delegated LP share count, or 0 if no risk pool is + /// configured or the voter has no delegation. + fn get_delegated_lp_weight(env: &Env, voter: &Address) -> i128 { + let risk_pool_addr: Address = match env.storage().instance().get(&StorageKey::RiskPool) { + Some(addr) => addr, + None => return 0, + }; + RiskPoolClient::new(env, &risk_pool_addr) + .get_delegated_shares(voter) + } + + + /// Adaptive-quorum settings, or the disabled default. + fn quorum_decay_config(env: &Env) -> QuorumDecayConfig { + env.storage() + .instance() + .get(&StorageKey::QuorumDecay) + .unwrap_or(QuorumDecayConfig { + enabled: false, + floor_bps: MIN_QUORUM_BPS, + decay_per_period_bps: 0, + window: 0, + }) + } + + /// Rolling participation history, or an empty one. + fn participation_history(env: &Env) -> ParticipationHistory { + env.storage() + .instance() + .get(&StorageKey::Participation) + .unwrap_or(ParticipationHistory { + recent_bps: Vec::new(env), + total_recorded: 0, + }) + } + + /// Average turnout across the recorded window, in basis points. + /// Returns `None` when there is no history to average. + fn average_participation_bps(history: &ParticipationHistory) -> Option { + let len = history.recent_bps.len(); + if len == 0 { + return None; + } + + let mut sum: u64 = 0; + for i in 0..len { + sum += history.recent_bps.get_unchecked(i) as u64; + } + Some((sum / len as u64) as u32) + } + + /// The quorum requirement in force, after any decay. + /// + /// Decay is proportional to the shortfall: the further average turnout has + /// fallen below the configured requirement, the more the requirement + /// relaxes — bounded by `floor_bps`, which itself can never sit below + /// `MIN_QUORUM_BPS`. + /// + /// With no history at all, the base requirement stands. A DAO's very first + /// proposals should not get a discount for the absence of evidence. + fn effective_quorum(env: &Env, config: &DaoConfig) -> EffectiveQuorum { + let base_bps = config.quorum_bps; + let decay = Self::quorum_decay_config(env); + let history = Self::participation_history(env); + let avg = Self::average_participation_bps(&history); + + let avg_bps = avg.unwrap_or(0); + + if !decay.enabled || avg.is_none() { + return EffectiveQuorum { + quorum_bps: base_bps, + base_bps, + avg_participation_bps: avg_bps, + decayed: false, + }; + } + + // Participation at or above the bar is not a low-participation period, + // so nothing decays. + if avg_bps >= base_bps { + return EffectiveQuorum { + quorum_bps: base_bps, + base_bps, + avg_participation_bps: avg_bps, + decayed: false, + }; + } + + // How many whole decay steps the shortfall represents. Each + // `decay_per_period_bps` of shortfall relaxes the bar by the same + // amount, so the requirement tracks observed turnout rather than + // collapsing to the floor at the first quiet vote. + let shortfall = base_bps - avg_bps; + let steps = shortfall / decay.decay_per_period_bps.max(1); + let reduction = steps.saturating_mul(decay.decay_per_period_bps); + + let decayed_bps = base_bps.saturating_sub(reduction).max(decay.floor_bps); + + EffectiveQuorum { + quorum_bps: decayed_bps, + base_bps, + avg_participation_bps: avg_bps, + decayed: decayed_bps < base_bps, + } + } + + /// Record a finalized proposal's turnout into the rolling window. + /// + /// Turnout is measured against the supply snapshotted at proposal + /// creation, matching how quorum itself is measured, so the two figures + /// are always comparable. + fn record_participation(env: &Env, total_votes: i128, total_supply: i128) { + let decay = Self::quorum_decay_config(env); + let window = if decay.window == 0 { + DEFAULT_PARTICIPATION_WINDOW + } else { + decay.window + }; + + let turnout_bps: u32 = if total_supply <= 0 { + 0 + } else { + total_votes + .checked_mul(10_000) + .map(|v| v / total_supply) + .unwrap_or(10_000) + .clamp(0, 10_000) as u32 + }; + + let mut history = Self::participation_history(env); + history.recent_bps.push_back(turnout_bps); + while history.recent_bps.len() > window { + history.recent_bps.pop_front(); + } + history.total_recorded = history.total_recorded.saturating_add(1); + + env.storage() + .instance() + .set(&StorageKey::Participation, &history); + } + + /// Remove `delegator` from `delegate`'s delegator list. + fn remove_delegator(env: &Env, delegate: &Address, delegator: &Address) { + let delegators: Vec
= env + .storage() + .persistent() + .get(&StorageKey::Delegators(delegate.clone())) + .unwrap_or_else(|| Vec::new(env)); + + let mut remaining: Vec
= Vec::new(env); + for i in 0..delegators.len() { + let entry = delegators.get_unchecked(i); + if entry != *delegator { + remaining.push_back(entry); + } + } + + if remaining.is_empty() { + env.storage() + .persistent() + .remove(&StorageKey::Delegators(delegate.clone())); + } else { + env.storage() + .persistent() + .set(&StorageKey::Delegators(delegate.clone()), &remaining); + } + } + + /// Sum the live balances delegated to `voter` and mark each delegator as + /// counted for this proposal. + /// + /// Balances are read now rather than at delegation time, so weight follows + /// the tokens: a delegator who has sold since delegating lends nothing. + fn collect_delegated_weight( + env: &Env, + voter: &Address, + proposal_id: u64, + gov_token: &token::Client, + ) -> i128 { + let delegators: Vec
= env + .storage() + .persistent() + .get(&StorageKey::Delegators(voter.clone())) + .unwrap_or_else(|| Vec::new(env)); + + let mut total: i128 = 0; + for i in 0..delegators.len() { + let delegator = delegators.get_unchecked(i); + let balance = gov_token.balance(&delegator); + if balance <= 0 { + continue; + } + + let marker = StorageKey::DelegatedVote(proposal_id, delegator.clone()); + if env.storage().persistent().has(&marker) { + continue; + } + env.storage().persistent().set(&marker, &true); + + total = total.saturating_add(balance); + + // Track delegation usage for off-chain indexing + env.events().publish( + (Symbol::new(env, "delegation_used"),), + DelegationUsed { + proposal_id, + delegate: voter.clone(), + delegator, + weight: balance, + }, + ); + } + + total + } + fn require_admin(env: &Env, caller: &Address) { let admin: Address = env .storage() @@ -600,6 +2138,25 @@ impl GovernanceDao { } caller.require_auth(); } + + /// Validate that an address has a valid Stellar format (56-char, starts with G or C). + fn validate_stellar_address(env: &Env, address: &Address) { + let addr_str = address.to_string(); + if addr_str.len() != 56 { + panic_with_error!(env, Error::InvalidAddress); + } + let mut buf = [0u8; 56]; + addr_str.copy_into_slice(&mut buf); + if buf[0] != b'G' && buf[0] != b'C' { + panic_with_error!(env, Error::InvalidAddress); + } + } + + /// Extend the TTL for proposal-related storage entries to prevent expiry + /// during voting, timelock, and execution periods. + fn extend_proposal_ttl(env: &Env, key: &StorageKey, _config: &DaoConfig) { + env.storage().persistent().extend_ttl(key, TTL_THRESHOLD, TTL_EXTEND_TO); + } } #[cfg(test)] diff --git a/contracts/governance-dao/src/test.rs b/contracts/governance-dao/src/test.rs index db67e8f..d693e09 100644 --- a/contracts/governance-dao/src/test.rs +++ b/contracts/governance-dao/src/test.rs @@ -117,6 +117,7 @@ fn create_proposal_increments_counter() { &target, &Symbol::new(&env, "update"), &args, + &Bytes::from_slice(&env, b"Impact analysis: no material risk identified."), ); assert_eq!(id, 0u64); assert_eq!(dao.proposal_count(), 1); @@ -134,6 +135,7 @@ fn create_proposal_below_threshold_fails() { &target, &Symbol::new(&env, "update"), &args, + &Bytes::from_slice(&env, b"Impact analysis: no material risk identified."), ); } @@ -149,6 +151,7 @@ fn vote_for_records_weight() { &target, &Symbol::new(&env, "update"), &args, + &Bytes::from_slice(&env, b"Impact analysis: no material risk identified."), ); dao.vote(&voter1, &pid, &VoteChoice::For); let rec = dao.get_vote(&pid, &voter1).unwrap(); @@ -167,6 +170,7 @@ fn double_vote_fails() { &target, &Symbol::new(&env, "update"), &args, + &Bytes::from_slice(&env, b"Impact analysis: no material risk identified."), ); dao.vote(&voter1, &pid, &VoteChoice::For); dao.vote(&voter1, &pid, &VoteChoice::Against); @@ -183,6 +187,7 @@ fn vote_after_period_fails() { &target, &Symbol::new(&env, "update"), &args, + &Bytes::from_slice(&env, b"Impact analysis: no material risk identified."), ); env.ledger().with_mut(|l| l.timestamp += VOTING_PERIOD + 1); dao.vote(&voter1, &pid, &VoteChoice::For); @@ -200,6 +205,7 @@ fn proposal_passes_with_quorum_and_majority() { &target, &Symbol::new(&env, "update"), &args, + &Bytes::from_slice(&env, b"Impact analysis: no material risk identified."), ); dao.vote(&voter1, &pid, &VoteChoice::For); dao.vote(&voter2, &pid, &VoteChoice::For); @@ -222,6 +228,7 @@ fn proposal_fails_without_quorum() { &target, &Symbol::new(&env, "update"), &args, + &Bytes::from_slice(&env, b"Impact analysis: no material risk identified."), ); env.ledger() @@ -243,6 +250,7 @@ fn finalize_while_voting_open_fails() { &target, &Symbol::new(&env, "update"), &args, + &Bytes::from_slice(&env, b"Impact analysis: no material risk identified."), ); dao.finalize(&pid); } @@ -259,6 +267,7 @@ fn execute_passed_proposal() { &target, &Symbol::new(&env, "update"), &args, + &Bytes::from_slice(&env, b"Impact analysis: no material risk identified."), ); dao.vote(&voter1, &pid, &VoteChoice::For); dao.vote(&voter2, &pid, &VoteChoice::For); @@ -284,6 +293,7 @@ fn execute_failed_proposal_panics() { &target, &Symbol::new(&env, "update"), &args, + &Bytes::from_slice(&env, b"Impact analysis: no material risk identified."), ); // Fast-forward past both voting period AND the 24-hour finalize delay buffer @@ -304,6 +314,7 @@ fn admin_can_cancel_active_proposal() { &target, &Symbol::new(&env, "update"), &args, + &Bytes::from_slice(&env, b"Impact analysis: no material risk identified."), ); dao.cancel(&admin, &pid); let p = dao.get_proposal(&pid); @@ -321,6 +332,7 @@ fn non_admin_cannot_cancel() { &target, &Symbol::new(&env, "update"), &args, + &Bytes::from_slice(&env, b"Impact analysis: no material risk identified."), ); dao.cancel(&voter2, &pid); } @@ -375,6 +387,7 @@ fn test_proposal_timelock_execution() { &target, &Symbol::new(&env, "upgrade"), &args, + &Bytes::from_slice(&env, b"Impact analysis: no material risk identified."), ); dao.vote(&voter1, &pid, &VoteChoice::For); @@ -401,6 +414,7 @@ fn test_finalize_cooldown_delay_enforced() { &target, &Symbol::new(&env, "update"), &args, + &Bytes::from_slice(&env, b"Impact analysis: no material risk identified."), ); dao.vote(&voter1, &pid, &VoteChoice::For); @@ -441,6 +455,7 @@ fn test_execute_active_proposal_without_voting_panics() { &target, &Symbol::new(&env, "update"), &args, + &Bytes::from_slice(&env, b"Impact analysis: no material risk identified."), ); // Attempt to execute without any voting - should panic with ProposalNotPassed @@ -460,6 +475,7 @@ fn test_execute_rejected_proposal_panics() { &target, &Symbol::new(&env, "update"), &args, + &Bytes::from_slice(&env, b"Impact analysis: no material risk identified."), ); // Vote against the proposal @@ -503,6 +519,7 @@ fn finalize_with_exactly_tied_votes_fails() { &target, &Symbol::new(&env, "update"), &args, + &Bytes::from_slice(&env, b"Impact analysis: no material risk identified."), ); dao.vote(&voter_a, &pid, &VoteChoice::For); @@ -536,6 +553,7 @@ fn test_finalize_refunds_deposit_locked_at_creation_not_live_config() { &target, &Symbol::new(&env, "update"), &args, + &Bytes::from_slice(&env, b"Impact analysis: no material risk identified."), ); // Deposit was actually taken. @@ -578,6 +596,7 @@ fn test_finalize_does_not_pull_raised_live_threshold() { &target, &Symbol::new(&env, "update"), &args, + &Bytes::from_slice(&env, b"Impact analysis: no material risk identified."), ); // Admin raises the live threshold well above what the contract holds. @@ -608,6 +627,7 @@ fn test_proposal_expires_after_voting_period() { &target, &Symbol::new(&env, "update"), &args, + &Bytes::from_slice(&env, b"Impact analysis: no material risk identified."), ); // Move time past the voting period AND finalize delay buffer (24h) env.ledger().with_mut(|l| l.timestamp += VOTING_PERIOD + (24 * 3600) + 1); @@ -634,6 +654,7 @@ fn test_cancel_refunds_deposit_to_proposer() { &target, &Symbol::new(&env, "update"), &args, + &Bytes::from_slice(&env, b"Impact analysis: no material risk identified."), ); assert_eq!(gov_token.balance(&voter1), balance_before - config.proposal_threshold); @@ -660,6 +681,7 @@ fn run_proposal( target, &Symbol::new(env, "update"), &args, + &Bytes::from_slice(&env, b"Impact analysis: no material risk identified."), ); for (voter, choice) in votes.iter() { @@ -1011,6 +1033,7 @@ fn a_delegate_votes_with_the_combined_weight() { &target, &Symbol::new(&env, "update"), &args, + &Bytes::from_slice(&env, b"Impact analysis: no material risk identified."), ); dao.vote(&voter2, &pid, &VoteChoice::For); @@ -1039,6 +1062,7 @@ fn a_delegator_cannot_also_vote() { &target, &Symbol::new(&env, "update"), &args, + &Bytes::from_slice(&env, b"Impact analysis: no material risk identified."), ); // Authority was handed over; voting too would count the same tokens twice. @@ -1059,6 +1083,7 @@ fn a_delegator_cannot_vote_after_their_delegate_has() { &target, &Symbol::new(&env, "update"), &args, + &Bytes::from_slice(&env, b"Impact analysis: no material risk identified."), ); dao.vote(&voter2, &pid, &VoteChoice::For); @@ -1083,6 +1108,7 @@ fn revoking_before_the_delegate_votes_restores_the_holder_s_vote() { &target, &Symbol::new(&env, "update"), &args, + &Bytes::from_slice(&env, b"Impact analysis: no material risk identified."), ); dao.vote(&voter1, &pid, &VoteChoice::For); @@ -1106,6 +1132,7 @@ fn only_the_delegate_s_own_tokens_are_locked() { &target, &Symbol::new(&env, "update"), &args, + &Bytes::from_slice(&env, b"Impact analysis: no material risk identified."), ); dao.vote(&voter2, &pid, &VoteChoice::For); @@ -1131,6 +1158,7 @@ fn reclaim_deposit_refunds_proposer_after_timeout() { &target, &Symbol::new(&env, "update"), &args, + &Bytes::from_slice(&env, b"Impact analysis: no material risk identified."), ); assert_eq!(gov_token.balance(&voter1), balance_before - config.proposal_threshold); @@ -1155,6 +1183,7 @@ fn reclaim_deposit_before_timeout_fails() { &target, &Symbol::new(&env, "update"), &args, + &Bytes::from_slice(&env, b"Impact analysis: no material risk identified."), ); env.ledger().with_mut(|l| l.timestamp += VOTING_PERIOD + 1); dao.reclaim_deposit(&pid); @@ -1171,6 +1200,7 @@ fn reclaim_deposit_after_finalize_fails() { &target, &Symbol::new(&env, "update"), &args, + &Bytes::from_slice(&env, b"Impact analysis: no material risk identified."), ); env.ledger() .with_mut(|l| l.timestamp += VOTING_PERIOD + 301); @@ -1221,6 +1251,7 @@ fn create_proposal_from_template_enforces_structure() { &target, &Symbol::new(&env, "update"), &args, + &Bytes::from_slice(&env, b"Impact analysis: no material risk identified."), ); assert_eq!(dao.proposal_count(), 1); let _ = pid; @@ -1245,6 +1276,7 @@ fn create_proposal_from_template_rejects_short_title() { &target, &Symbol::new(&env, "update"), &args, + &Bytes::from_slice(&env, b"Impact analysis: no material risk identified."), ); } @@ -1267,6 +1299,7 @@ fn create_proposal_from_template_rejects_wrong_arg_count() { &target, &Symbol::new(&env, "update"), &args, + &Bytes::from_slice(&env, b"Impact analysis: no material risk identified."), ); } @@ -1290,6 +1323,7 @@ fn deactivated_template_cannot_be_used() { &target, &Symbol::new(&env, "update"), &args, + &Bytes::from_slice(&env, b"Impact analysis: no material risk identified."), ); assert!(result.is_err()); } @@ -1306,6 +1340,7 @@ fn test_get_execution_audit_records_data() { &target, &Symbol::new(&env, "update"), &args, + &Bytes::from_slice(&env, b"Impact analysis: no material risk identified."), ); dao.vote(&voter1, &pid, &VoteChoice::For); dao.vote(&voter2, &pid, &VoteChoice::For); @@ -1335,6 +1370,7 @@ fn test_get_execution_audit_unexecuted_panics() { &target, &Symbol::new(&env, "update"), &args, + &Bytes::from_slice(&env, b"Impact analysis: no material risk identified."), ); dao.get_execution_audit(&pid); } diff --git a/contracts/governance-dao/src/test_advanced.rs b/contracts/governance-dao/src/test_advanced.rs index 1434b7a..cf48e06 100644 --- a/contracts/governance-dao/src/test_advanced.rs +++ b/contracts/governance-dao/src/test_advanced.rs @@ -92,6 +92,7 @@ fn abstain_contributes_to_quorum_but_not_majority() { &target, &Symbol::new(&env, "update"), &args, + &Bytes::from_slice(&env, b"Impact analysis: no material risk identified."), ); dao.vote(&voter, &pid, &VoteChoice::Abstain); @@ -105,6 +106,78 @@ fn abstain_contributes_to_quorum_but_not_majority() { assert_eq!(p.votes_for, 0); } +#[test] +fn large_abstain_bloc_does_not_sink_a_partisan_majority() { + // Regression test for issue #385: majority must be decided by the + // For/Against split alone. Computing it against total_votes (including + // Abstain) would let a large abstaining bloc dilute the For share below + // majority_bps even when every partisan voter backed the proposal. + let (env, dao, admin, voter1, voter2, target) = setup(); + + let args: Vec = Vec::new(&env); + let pid = dao.create_proposal( + &voter1, + &Bytes::from_slice(&env, b"Large abstain bloc"), + &target, + &Symbol::new(&env, "update"), + &args, + &Bytes::from_slice(&env, b"Impact analysis: no material risk identified."), + ); + + // voter1 abstains with 990,000 (their 1,000,000 balance minus the + // 10,000 proposal_threshold deposit locked when they created this very + // proposal); voter2 (500,000) votes For; admin (100,000) votes Against. + // Partisan split is 500k For / 100k Against = 83.3% For, comfortably + // above the 51% majority_bps. Naively dividing by total_votes + // (1,590,000, including the abstain bloc) would instead give ~31.4%, + // well under majority_bps, and wrongly fail the proposal. + dao.vote(&voter1, &pid, &VoteChoice::Abstain); + dao.vote(&voter2, &pid, &VoteChoice::For); + dao.vote(&admin, &pid, &VoteChoice::Against); + + env.ledger() + .with_mut(|l| l.timestamp += VOTING_PERIOD + (24 * 3600) + 1); + dao.finalize(&pid); + + let p = dao.get_proposal(&pid); + assert_eq!(p.status, ProposalStatus::Passed); + assert_eq!(p.votes_abstain, 990_000_0000000i128); + assert_eq!(p.votes_for, 500_000_0000000i128); + assert_eq!(p.votes_against, 100_000_0000000i128); +} + +#[test] +fn abstain_still_counts_toward_quorum() { + // Quorum is decided by total_votes, which does include Abstain — an + // abstain-only turnout is still turnout, even though it can never pass. + let (env, dao, _admin, voter1, _voter2, target) = setup(); + + let args: Vec = Vec::new(&env); + let pid = dao.create_proposal( + &voter1, + &Bytes::from_slice(&env, b"Abstain quorum test"), + &target, + &Symbol::new(&env, "update"), + &args, + &Bytes::from_slice(&env, b"Impact analysis: no material risk identified."), + ); + + // voter1 alone (990,000 of 1,600,000 total_supply after their 10,000 + // proposal_threshold deposit — still 61.9%) clears the 10% quorum_bps + // configured in setup() purely via Abstain. + dao.vote(&voter1, &pid, &VoteChoice::Abstain); + + env.ledger() + .with_mut(|l| l.timestamp += VOTING_PERIOD + (24 * 3600) + 1); + dao.finalize(&pid); + + // Quorum was met, but with zero partisan votes there is no majority to + // speak of, so the proposal still fails. + let p = dao.get_proposal(&pid); + assert_eq!(p.status, ProposalStatus::Failed); + assert_eq!(p.votes_abstain, 990_000_0000000i128); +} + // ── proposal_count ──────────────────────────────────────────────────────────── #[test] @@ -121,6 +194,7 @@ fn proposal_count_increments_per_proposal() { &target, &Symbol::new(&env, "fn1"), &args, + &Bytes::from_slice(&env, b"Impact analysis: no material risk identified."), ); // <--- Added args assert_eq!(dao.proposal_count(), 1); dao.create_proposal( @@ -129,6 +203,7 @@ fn proposal_count_increments_per_proposal() { &target, &Symbol::new(&env, "fn2"), &args, + &Bytes::from_slice(&env, b"Impact analysis: no material risk identified."), ); // <--- Added args assert_eq!(dao.proposal_count(), 2); } @@ -149,6 +224,7 @@ fn execute_twice_fails() { &target, &Symbol::new(&env, "update"), &args, + &Bytes::from_slice(&env, b"Impact analysis: no material risk identified."), ); dao.vote(&voter, &pid, &VoteChoice::For); @@ -181,6 +257,7 @@ fn test_double_voting_attack_prevention() { &target, &Symbol::new(&env, "drain"), &args, + &Bytes::from_slice(&env, b"Impact analysis: no material risk identified."), ); // 1. Attacker (voter1) votes FOR @@ -224,6 +301,7 @@ fn test_successful_token_withdrawal_post_finalize() { &target, &Symbol::new(&env, "update"), &args, + &Bytes::from_slice(&env, b"Impact analysis: no material risk identified."), ); dao.vote(&voter1, &pid, &VoteChoice::For); @@ -258,6 +336,7 @@ fn admin_cannot_manipulate_total_supply_during_active_vote() { &target, &Symbol::new(&env, "update"), &args, + &Bytes::from_slice(&env, b"Impact analysis: no material risk identified."), ); // Voter has 1,000,000 tokens, votes FOR (after 10k threshold lock) @@ -312,6 +391,7 @@ fn test_proposal_id_overflow_panics_with_limit_reached() { &target, &Symbol::new(&env, "update"), &args, + &Bytes::from_slice(&env, b"Impact analysis: no material risk identified."), ); } @@ -357,6 +437,7 @@ fn finalize_fails_proposal_with_zero_votes() { &target, &Symbol::new(&env, "update"), &args, + &Bytes::from_slice(&env, b"Impact analysis: no material risk identified."), ); // Nobody votes. diff --git a/contracts/governance-dao/src/types.rs b/contracts/governance-dao/src/types.rs index 980239c..1b375ae 100644 --- a/contracts/governance-dao/src/types.rs +++ b/contracts/governance-dao/src/types.rs @@ -116,6 +116,9 @@ pub struct Proposal { pub verification_callback: Option, /// Whether execution has been verified (callback succeeded or not required). pub execution_verified: bool, + /// Set by a guardian via `veto_proposal`. A vetoed proposal can never be + /// executed, even if it passed voting and the timelock has expired. + pub is_vetoed: bool, } /// A single vote record stored per (proposal_id, voter) key. diff --git a/contracts/oracle-verifier/src/lib.rs b/contracts/oracle-verifier/src/lib.rs index d5adb06..2962926 100644 --- a/contracts/oracle-verifier/src/lib.rs +++ b/contracts/oracle-verifier/src/lib.rs @@ -1,915 +1,3095 @@ -//! Parashield Oracle Verifier -//! -//! Authorized oracle addresses submit real-world data (rainfall, flight status, -//! on-chain metrics). The contract stores submissions and exposes a -//! `verify_trigger` function used by the Claims Processor to determine whether -//! a policy's payout condition has been met. -//! -//! Design notes -//! ───────────── -//! - Multiple oracles can submit the same key; the contract computes a -//! weight-based median for aggregation (oracle weight, not submission confidence). -//! - Only the admin can register/remove oracle addresses. -//! - Any oracle already registered for a (data_type) may submit data. -//! - Duplicate submissions from the same oracle overwrite the previous value. -#![no_std] -extern crate alloc; -use alloc::string::ToString; - -#[cfg_attr(feature = "library", allow(unused_imports))] -use soroban_sdk::{ - contract, contracterror, contractimpl, contracttype, panic_with_error, Address, BytesN, Env, - Symbol, Vec, -}; - -pub mod types; -pub use types::*; - -/// Maximum number of registered oracles. Bounds the median aggregation loop and -/// the worst-case weighted sum (MAX_ORACLES * max_weight * max_value) so it -/// cannot overflow i128. -#[allow(dead_code)] -const MAX_ORACLES: u32 = 100; - -// ─── Storage keys ───────────────────────────────────────────────────────────── - -#[contracttype] -enum StorageKey { - Initialized, - Admin, - /// OracleEntry for (data_type, oracle_address) - Oracle(Symbol, Address), - /// Vec — all submissions for (data_type, key) - DataPoints(Symbol, Symbol), - /// Vec
— every registered oracle address - OracleList, - /// Global minimum confidence threshold (u32) - MinConfidence, - MaxDataAge, - PendingAdmin, - MinOracleCount, - DataTypePaused(Symbol), -} - -// ─── Errors ─────────────────────────────────────────────────────────────────── - -#[contracterror] -#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] -#[repr(u32)] -pub enum Error { - AlreadyInitialized = 1, - NotInitialized = 2, - Unauthorized = 3, - OracleNotRegistered = 4, - OracleAlreadyExists = 5, - NoDataAvailable = 6, - InvalidConfidence = 7, - InvalidWeight = 8, - StaleData = 9, - TooManyOracles = 10, - InvalidTimestamp = 11, - DataTypePaused = 12, -} - -// ─── Contract ───────────────────────────────────────────────────────────────── - -#[cfg(any(test, feature = "testutils", not(feature = "library")))] -#[contract] -pub struct OracleVerifier; - -#[cfg(any(test, feature = "testutils", not(feature = "library")))] -#[contractimpl] -impl OracleVerifier { - // ── Lifecycle ──────────────────────────────────────────────────────────── - - /// One-time initialisation. Caller becomes admin. - pub fn initialize(env: Env, admin: Address) { - if env.storage().instance().has(&StorageKey::Initialized) { - panic_with_error!(&env, Error::AlreadyInitialized); - } - - // require_auth() validates the address at the protocol level, so we do - // not need manual address format string validation here. - admin.require_auth(); - - env.storage() - .instance() - .set(&StorageKey::Initialized, &true); - env.storage().instance().set(&StorageKey::Admin, &admin); - env.storage() - .instance() - .set(&StorageKey::OracleList, &Vec::
::new(&env)); - - // No pending admin initially - let pending_admin = env - .storage() - .instance() - .get::<_, Option
>(&StorageKey::PendingAdmin) // Explicitly type the lookup - .unwrap_or(None); - - env.events().publish( - (Symbol::new(&env, "initialized"),), - Initialized { - admin: admin.clone(), - }, - ); - } - - // ── Oracle Management (admin only) ─────────────────────────────────────── - - /// Register an oracle address for a given data type with a relative weight. - /// `weight` is 1-100; higher-weight oracles contribute more to the median. - pub fn add_oracle(env: Env, admin: Address, oracle: Address, data_type: Symbol, weight: u32) { - Self::require_admin(&env, &admin); - if weight == 0 || weight > 100 { - panic_with_error!(&env, Error::InvalidWeight); - } - let key = StorageKey::Oracle(data_type.clone(), oracle.clone()); - if env.storage().persistent().has(&key) { - panic_with_error!(&env, Error::OracleAlreadyExists); - } - let entry = OracleEntry { - oracle: oracle.clone(), - data_type: data_type.clone(), - weight, - active: true, - }; - env.storage().persistent().set(&key, &entry); - - let mut list: Vec
= env - .storage() - .instance() - .get(&StorageKey::OracleList) - .unwrap_or_else(|| Vec::new(&env)); - if list.len() >= MAX_ORACLES { - panic_with_error!(&env, Error::TooManyOracles); - } - list.push_back(oracle.clone()); - env.storage().instance().set(&StorageKey::OracleList, &list); - let mut already_present = false; - for i in 0..list.len() { - if list.get_unchecked(i) == oracle { - already_present = true; - break; - } - } - if !already_present { - list.push_back(oracle.clone()); - env.storage().instance().set(&StorageKey::OracleList, &list); - } - - env.events().publish( - (Symbol::new(&env, "oracle_added"),), - OracleAdded { - oracle, - data_type, - weight, - }, - ); - } - - /// Update the relative weight of an existing oracle registration. - /// `weight` is 1-100; use `add_oracle` only for new registrations. - pub fn update_oracle_weight( - env: Env, - admin: Address, - oracle: Address, - data_type: Symbol, - weight: u32, - ) { - Self::require_admin(&env, &admin); - if weight == 0 || weight > 100 { - panic_with_error!(&env, Error::InvalidWeight); - } - - let key = StorageKey::Oracle(data_type, oracle); - let mut entry: OracleEntry = env - .storage() - .persistent() - .get(&key) - .unwrap_or_else(|| panic_with_error!(&env, Error::OracleNotRegistered)); - entry.weight = weight; - env.storage().persistent().set(&key, &entry); - } - - /// Deactivate an oracle (soft delete — historical data is retained). - pub fn remove_oracle(env: Env, admin: Address, oracle: Address, data_type: Symbol) { - Self::require_admin(&env, &admin); - let key = StorageKey::Oracle(data_type.clone(), oracle.clone()); - let mut entry: OracleEntry = env - .storage() - .persistent() - .get(&key) - .unwrap_or_else(|| panic_with_error!(&env, Error::OracleNotRegistered)); - entry.active = false; - env.storage().persistent().set(&key, &entry); - - // Prune the address from the flat OracleList so get_oracles() and - // instance storage don't accumulate deactivated addresses forever - // (issue #135). Note: OracleList is a single cross-data_type list — - // if this address is still separately registered+active for a - // different data_type, it is removed from this shared list anyway. - // That's a pre-existing limitation of the flat-list design (not - // introduced here); scoping OracleList per data_type would be a - // separate, larger change. - let list: Vec
= env - .storage() - .instance() - .get(&StorageKey::OracleList) - .unwrap_or_else(|| Vec::new(&env)); - let mut pruned: Vec
= Vec::new(&env); - for addr in list.iter() { - if addr != oracle { - pruned.push_back(addr); - } - } - env.storage() - .instance() - .set(&StorageKey::OracleList, &pruned); - - env.events().publish( - (Symbol::new(&env, "oracle_removed"),), - OracleRemoved { oracle, data_type }, - ); - } - - // ── Contract Settings (admin only) ─────────────────────────────────────── - - /// Set the global minimum confidence threshold for oracle data. - pub fn set_min_confidence(env: Env, admin: Address, threshold: u32) { - Self::require_admin(&env, &admin); - if threshold > 100 { - panic_with_error!(&env, Error::InvalidConfidence); - } - env.storage() - .instance() - .set(&StorageKey::MinConfidence, &threshold); - env.events().publish( - (Symbol::new(&env, "min_confidence_updated"),), - MinConfidenceUpdated { threshold }, - ); - } - - /// Set the maximum data age in seconds. - pub fn set_max_data_age(env: Env, admin: Address, max_age: u64) { - Self::require_admin(&env, &admin); - env.storage() - .instance() - .set(&StorageKey::MaxDataAge, &max_age); - env.events().publish( - (Symbol::new(&env, "max_data_age_updated"),), - MaxDataAgeUpdated { max_age }, - ); - } - - /// 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 (zero address means no proposal) - 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) { - // Correct way to read an Optional field from Soroban instance storage - let pending_admin_opt: Option
= env - .storage() - .instance() - .get(&StorageKey::PendingAdmin) - .unwrap_or(None); // If key doesn't exist, evaluates to None - - let pending_admin = match pending_admin_opt { - Some(addr) => addr, - None => panic_with_error!(&env, Error::Unauthorized), - }; - - if admin != pending_admin { - panic_with_error!(&env, Error::Unauthorized); - } - - admin.require_auth(); - - if !env.storage().instance().has(&StorageKey::Initialized) { - panic_with_error!(&env, Error::NotInitialized); - } - - env.storage().instance().set(&StorageKey::Admin, &admin); - - // Clear the slot explicitly using a clean None type hint - let no_pending: Option
= None; - env.storage() - .instance() - .set(&StorageKey::PendingAdmin, &no_pending); - - env.events().publish( - (Symbol::new(&env, "admin_updated"),), - AdminUpdated { new_admin: admin }, - ); - } - - /// Get the maximum data age in seconds (defaults to 7 days = 604,800 seconds). - pub fn get_max_data_age(env: Env) -> u64 { - env.storage() - .instance() - .get(&StorageKey::MaxDataAge) - .unwrap_or(604_800) - } - - /// Set the minimum number of oracle submissions required to form a consensus value. - /// `min_count` must be at least 1; the default is 1. - pub fn set_min_oracle_count(env: Env, admin: Address, min_count: u32) { - Self::require_admin(&env, &admin); - env.storage() - .instance() - .set(&StorageKey::MinOracleCount, &min_count); - env.events().publish( - (Symbol::new(&env, "min_oracle_count_updated"),), - MinOracleCountUpdated { min_count }, - ); - } - - /// Return the minimum number of oracle submissions required for consensus (defaults to 1). - pub fn get_min_oracle_count(env: Env) -> u32 { - env.storage() - .instance() - .get(&StorageKey::MinOracleCount) - .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. - /// - /// - `data_type`: category — "weather", "flight", "onchain", "disaster" - /// - `key`: specific measurement — "rainfall:kisumu:2026-06", "flight:KQ100:2026-06-15" - /// - `value`: 7-decimal fixed point (same precision as Stellar assets) - /// - `confidence`: 0-100 reliability score - /// - `timestamp`: Unix timestamp of the real-world observation - pub fn submit_data( - env: Env, - oracle: Address, - data_type: Symbol, - key: Symbol, - value: i128, - confidence: u32, - timestamp: u64, - ) { - oracle.require_auth(); - Self::require_data_type_active(&env, &data_type); - if confidence == 0 || confidence > 100 { - panic_with_error!(&env, Error::InvalidConfidence); - } - - let now = env.ledger().timestamp(); - if timestamp > now { - panic_with_error!(&env, Error::InvalidTimestamp); - } - let ninety_days = 90 * 24 * 60 * 60; - if timestamp < now.saturating_sub(ninety_days) { - panic_with_error!(&env, Error::InvalidTimestamp); - } - - // Verify oracle is registered and active for this data_type - let oracle_key = StorageKey::Oracle(data_type.clone(), oracle.clone()); - let entry: OracleEntry = env - .storage() - .persistent() - .get(&oracle_key) - .unwrap_or_else(|| panic_with_error!(&env, Error::OracleNotRegistered)); - if !entry.active { - panic_with_error!(&env, Error::Unauthorized); - } - - // Load existing submissions for this (data_type, key) - let dp_key = StorageKey::DataPoints(data_type.clone(), key.clone()); - let mut points: Vec = env - .storage() - .persistent() - .get(&dp_key) - .unwrap_or_else(|| Vec::new(&env)); - - // Overwrite existing submission from this oracle; append if new - let new_point = OracleDataPoint { - oracle: oracle.clone(), - value, - confidence, - timestamp, - }; - let mut found = false; - for i in 0..points.len() { - if points.get_unchecked(i).oracle == oracle { - points.set(i, new_point.clone()); - found = true; - break; - } - } - if !found { - points.push_back(new_point); - } - - env.storage().persistent().set(&dp_key, &points); - - env.events().publish( - (Symbol::new(&env, "oracle_data_submitted"),), - OracleDataSubmitted { - oracle, - data_type, - key, - value, - confidence, - timestamp, - }, - ); - } - - // ── Verification ───────────────────────────────────────────────────────── - - /// Returns true if the aggregated oracle value satisfies `condition`. - /// This is the single function the Claims Processor calls to decide payout. - pub fn verify_trigger( - env: Env, - data_type: Symbol, - 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, - TriggerComparison::GreaterThan => median > condition.threshold, - TriggerComparison::Equal => median == condition.threshold, - TriggerComparison::EqualWithTolerance => { - let diff = median.saturating_sub(condition.threshold); - diff.abs() <= condition.tolerance - } - } - } - - /// Return the most recent submission from any oracle for (data_type, key). - /// Panics with NoDataAvailable if no submissions exist. - pub fn get_data(env: Env, data_type: Symbol, key: Symbol) -> OracleDataPoint { - let points: Vec = env - .storage() - .persistent() - .get(&StorageKey::DataPoints(data_type, key)) - .unwrap_or_else(|| panic_with_error!(&env, Error::NoDataAvailable)); - if points.is_empty() { - panic_with_error!(&env, Error::NoDataAvailable); - } - // Return the most recently timestamped submission - let mut latest = points.get_unchecked(0); - for i in 1..points.len() { - let p = points.get_unchecked(i); - if p.timestamp > latest.timestamp { - latest = p; - } - } - let max_data_age: u64 = env - .storage() - .instance() - .get(&StorageKey::MaxDataAge) - .unwrap_or(604_800); - let now = env.ledger().timestamp(); - if now.saturating_sub(latest.timestamp) > max_data_age { - panic_with_error!(&env, Error::NoDataAvailable); - } - latest - } - - /// Return aggregated statistics across all oracle submissions for (data_type, key). - pub fn get_aggregated(env: Env, data_type: Symbol, key: Symbol) -> AggregatedData { - let points: Vec = env - .storage() - .persistent() - .get(&StorageKey::DataPoints(data_type.clone(), key.clone())) - .unwrap_or_else(|| panic_with_error!(&env, Error::NoDataAvailable)); - if points.is_empty() { - panic_with_error!(&env, Error::NoDataAvailable); - } - let median_value = Self::get_median_value(&env, &data_type, &key); - let mut oracle_count = 0u32; - let mut min_confidence = 100u32; - let mut weighted_confidence_sum: u128 = 0; - let mut total_weight: u128 = 0; - let mut last_updated = 0u64; - for i in 0..points.len() { - let p = points.get_unchecked(i); - let oracle_key = StorageKey::Oracle(data_type.clone(), p.oracle.clone()); - if let Some(entry) = env - .storage() - .persistent() - .get::<_, OracleEntry>(&oracle_key) - { - if entry.active { - oracle_count += 1; - min_confidence = min_confidence.min(p.confidence); - last_updated = last_updated.max(p.timestamp); - weighted_confidence_sum += (p.confidence as u128) * (entry.weight as u128); - total_weight += entry.weight as u128; - } - } - } - let confidence = match weighted_confidence_sum.checked_div(total_weight) { - Some(c) => c as u32, - None => 0u32, - }; - - // Count oracles currently registered+active for this data_type, - // independent of whether they've submitted data for this specific - // key (issue #136) — this is what monitoring/governance tooling - // should use as a diversity signal, not oracle_count above. - let oracle_list: Vec
= env - .storage() - .instance() - .get(&StorageKey::OracleList) - .unwrap_or_else(|| Vec::new(&env)); - 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 entry.active { - active_oracle_count += 1; - } - } - } - - AggregatedData { - median_value, - oracle_count, - active_oracle_count, - confidence, - min_confidence, - last_updated, - } - } - - /// Like `verify_trigger` but panics with `StaleData` if the newest submission - /// is older than `max_age_seconds`. Use this in parametric claim paths that - /// require fresh oracle data. - pub fn verify_trigger_fresh( - env: Env, - data_type: Symbol, - key: Symbol, - 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() - .persistent() - .get(&dp_key) - .unwrap_or_else(|| panic_with_error!(&env, Error::NoDataAvailable)); - if points.is_empty() { - panic_with_error!(&env, Error::NoDataAvailable); - } - - let now = env.ledger().timestamp(); - let mut latest_ts = 0u64; - for i in 0..points.len() { - let ts = points.get_unchecked(i).timestamp; - if ts > latest_ts { - latest_ts = ts; - } - } - if now.saturating_sub(latest_ts) > max_age_seconds { - panic_with_error!(&env, Error::StaleData); - } - - let median = Self::get_median_value(&env, &data_type, &key); - let result = match condition.comparison { - TriggerComparison::LessThan => median < condition.threshold, - TriggerComparison::GreaterThan => median > condition.threshold, - TriggerComparison::Equal => median == condition.threshold, - TriggerComparison::EqualWithTolerance => { - let diff = median.saturating_sub(condition.threshold); - 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 - } - - /// Submit data for multiple keys in one call. - /// Each tuple: (key, value, confidence, timestamp). - pub fn batch_submit_data( - env: Env, - oracle: Address, - data_type: Symbol, - 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() - .persistent() - .get(&oracle_key) - .unwrap_or_else(|| panic_with_error!(&env, Error::OracleNotRegistered)); - if !entry.active { - panic_with_error!(&env, Error::Unauthorized); - } - - for i in 0..submissions.len() { - let (key, value, confidence, timestamp) = submissions.get_unchecked(i); - if confidence == 0 || confidence > 100 { - panic_with_error!(&env, Error::InvalidConfidence); - } - - let now = env.ledger().timestamp(); - if timestamp > now { - panic_with_error!(&env, Error::InvalidTimestamp); - } - let ninety_days = 90 * 24 * 60 * 60; - if timestamp < now.saturating_sub(ninety_days) { - panic_with_error!(&env, Error::InvalidTimestamp); - } - - let now = env.ledger().timestamp(); - if timestamp > now { - panic_with_error!(&env, Error::InvalidTimestamp); - } - let dp_key = StorageKey::DataPoints(data_type.clone(), key.clone()); - let mut points: Vec = env - .storage() - .persistent() - .get(&dp_key) - .unwrap_or_else(|| Vec::new(&env)); - let new_point = OracleDataPoint { - oracle: oracle.clone(), - value, - confidence, - timestamp, - }; - let mut found = false; - for j in 0..points.len() { - if points.get_unchecked(j).oracle == oracle { - points.set(j, new_point.clone()); - found = true; - break; - } - } - if !found { - points.push_back(new_point); - } - env.storage().persistent().set(&dp_key, &points); - - env.events().publish( - (Symbol::new(&env, "oracle_data_submitted"),), - OracleDataSubmitted { - oracle: oracle.clone(), - data_type: data_type.clone(), - key, - value, - confidence, - timestamp, - }, - ); - } - } - - /// Submit multiple data readings in a single invocation — avoids the cost - /// and latency of calling `submit_data` once per key. All readings share - /// the same `oracle` and `data_type`; each carries its own key, value, - /// confidence, and timestamp. Every reading is validated and persisted - /// atomically within the single transaction. - pub fn submit_data_batch( - env: Env, - oracle: Address, - data_type: Symbol, - 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 - .storage() - .persistent() - .get(&oracle_key) - .unwrap_or_else(|| panic_with_error!(&env, Error::OracleNotRegistered)); - if !entry.active { - panic_with_error!(&env, Error::Unauthorized); - } - - for i in 0..submissions.len() { - let sub = submissions.get_unchecked(i); - if sub.confidence == 0 || sub.confidence > 100 { - panic_with_error!(&env, Error::InvalidConfidence); - } - let now = env.ledger().timestamp(); - if sub.timestamp > now { - panic_with_error!(&env, Error::InvalidTimestamp); - } - let dp_key = StorageKey::DataPoints(data_type.clone(), sub.key.clone()); - let mut points: Vec = env - .storage() - .persistent() - .get(&dp_key) - .unwrap_or_else(|| Vec::new(&env)); - let new_point = OracleDataPoint { - oracle: oracle.clone(), - value: sub.value, - confidence: sub.confidence, - timestamp: sub.timestamp, - }; - let mut found = false; - for j in 0..points.len() { - if points.get_unchecked(j).oracle == oracle { - points.set(j, new_point.clone()); - found = true; - break; - } - } - if !found { - points.push_back(new_point); - } - env.storage().persistent().set(&dp_key, &points); - - env.events().publish( - (Symbol::new(&env, "oracle_data_submitted"),), - OracleDataSubmitted { - oracle: oracle.clone(), - data_type: data_type.clone(), - key: sub.key, - value: sub.value, - confidence: sub.confidence, - timestamp: sub.timestamp, - }, - ); - } - } - - /// Upgrade the contract WASM in-place. Only the admin may call this. - /// Storage is preserved across upgrades; only the execution code changes. - pub fn upgrade(env: Env, admin: Address, new_wasm_hash: BytesN<32>) { - Self::require_admin(&env, &admin); - env.deployer().update_current_contract_wasm(new_wasm_hash); - } - - /// List all registered oracle addresses. - pub fn get_oracles(env: Env) -> Vec
{ - env.storage() - .instance() - .get(&StorageKey::OracleList) - .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) - .unwrap_or_else(|| panic_with_error!(&env, Error::NotInitialized)) - } - - // ── Internal helpers ───────────────────────────────────────────────────── - - fn require_admin(env: &Env, caller: &Address) { - 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); - } - 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 - .storage() - .persistent() - .get(&StorageKey::DataPoints(data_type.clone(), key.clone())) - .unwrap_or_else(|| panic_with_error!(env, Error::NoDataAvailable)); - if points.is_empty() { - panic_with_error!(env, Error::NoDataAvailable); - } - - let max_data_age: u64 = env - .storage() - .instance() - .get(&StorageKey::MaxDataAge) - .unwrap_or(604_800); - let now = env.ledger().timestamp(); - let min_confidence: u32 = env - .storage() - .instance() - .get(&StorageKey::MinConfidence) - .unwrap_or(0); - - // Collect values and weights on the stack - let mut values = [(0i128, 0u32); 100]; - let mut total_weight: u32 = 0; - let mut n = 0; - - for i in 0..points.len() { - let p = points.get_unchecked(i); - if now.saturating_sub(p.timestamp) <= max_data_age && p.confidence >= min_confidence { - let oracle_key = StorageKey::Oracle(data_type.clone(), p.oracle.clone()); - if let Some(entry) = env - .storage() - .persistent() - .get::<_, OracleEntry>(&oracle_key) - { - if entry.active && n < 100 { - values[n] = (p.value, entry.weight); - n += 1; - total_weight += entry.weight; - } - } - } - } - - let min_oracle_count: u32 = env - .storage() - .instance() - .get(&StorageKey::MinOracleCount) - .unwrap_or(1); - if (n as u32) < min_oracle_count || n == 0 || total_weight == 0 { - panic_with_error!(env, Error::NoDataAvailable); - } - - // Native sort on the stack slice: O(N log N) - let active_values = &mut values[0..n]; - active_values.sort_unstable_by_key(|&(val, _)| val); - - let half = total_weight / 2; - let mut cumulative = 0; - for i in 0..n { - let (val, wt) = active_values[i]; - cumulative += wt; - if cumulative > half { - return val; - } else if cumulative == half && total_weight.is_multiple_of(2) { - if i + 1 < n { - return (val + active_values[i + 1].0) / 2; - } else { - return val; - } - } - } - - active_values[n - 1].0 - } -} - -// ─── Tests ──────────────────────────────────────────────────────────────────── - -#[cfg(test)] -mod test; -#[cfg(test)] -mod test_advanced; +//! Parashield Oracle Verifier +//! +//! Authorized oracle addresses submit real-world data (rainfall, flight status, +//! on-chain metrics). The contract stores submissions and exposes a +//! `verify_trigger` function used by the Claims Processor to determine whether +//! a policy's payout condition has been met. +//! +//! Design notes +//! ───────────── +//! - Multiple oracles can submit the same key; the contract computes a +//! weight-based median for aggregation (oracle weight, not submission confidence). +//! - Only the admin can register/remove oracle addresses. +//! - Any oracle already registered for a (data_type) may submit data. +//! - Duplicate submissions from the same oracle overwrite the previous value. +#![no_std] +extern crate alloc; + +#[cfg_attr(feature = "library", allow(unused_imports))] +use soroban_sdk::{ + contract, contracterror, contractimpl, contracttype, panic_with_error, token, Address, Bytes, + BytesN, Env, Symbol, Vec, +}; + +pub mod types; +pub use types::*; + +// ─── Storage TTL ────────────────────────────────────────────────────────────── +/// Extend a persistent entry's TTL once it has fewer than ~30 days of life left +/// (at ~5s/ledger). +// Issue #342: kept in sync by hand across all 5 contracts (governance-dao, +// risk-pool, policy-engine, oracle-verifier, claims-processor) — extracting +// to a shared crate is a real follow-up, not done here to avoid touching +// every contract's Cargo.toml in one pass. +const TTL_THRESHOLD: u32 = 518_400; // ~30 days +/// Extend persistent entries out to ~1 year (at ~5s/ledger) so an oracle +/// registration doesn't silently expire from storage during a quiet period +/// with no submissions. +const TTL_EXTEND_TO: u32 = 6_312_000; // ~1 year + +/// Grace period between an admin transfer being fully proposed/approved and the +/// proposed admin being able to `accept_admin` (issue #356). Hand-synced across +/// the 4 contracts that expose admin rotation (policy-engine, risk-pool, +/// oracle-verifier, claims-processor). +const ADMIN_TRANSFER_TIMELOCK: u64 = 48 * 60 * 60; + +/// Maximum number of registered oracles. Bounds the median aggregation loop and +/// the worst-case weighted sum (MAX_ORACLES * max_weight * max_value) so it +/// cannot overflow i128. +#[allow(dead_code)] +const MAX_ORACLES: u32 = 100; +/// Maximum number of data points stored per (data_type, key). +const MAX_DATA_POINTS: u32 = 100; + +/// Default outlier-rejection threshold, in basis points of the median +/// absolute deviation (MAD): a submission is flagged once its distance from +/// the median exceeds 5.0x the MAD. This approximates the standard +/// Iglewicz-Hoaglin modified z-score cutoff of 3.5 (which is expressed in +/// MAD-equivalent standard-deviation units via a 0.6745 consistency +/// constant: 3.5 / 0.6745 ≈ 5.19, rounded down for a slightly more +/// conservative default that keeps borderline genuine variation in). +const DEFAULT_OUTLIER_THRESHOLD_BPS: u32 = 50_000; +/// Outlier filtering is skipped below this many eligible submissions — +/// with too few points neither the median nor the MAD used to judge +/// deviation is a meaningful reference, so filtering would be as likely to +/// discard a legitimate value as a bad one. +const DEFAULT_OUTLIER_MIN_SAMPLE_SIZE: u32 = 4; + +/// Default minimum number of seconds a single oracle must wait between +/// submissions for the same data_type, used when no admin override has been +/// set via `set_min_submit_interval`. Chosen well below any realistic +/// real-world observation cadence (rainfall, flight status, etc.) while still +/// bounding how much storage/instruction budget a single misbehaving oracle +/// can consume by flooding submissions. +const DEFAULT_MIN_SUBMIT_INTERVAL: u64 = 30; + +// ─── Storage keys ───────────────────────────────────────────────────────────── + +#[contracttype] +enum StorageKey { + Initialized, + Admin, + /// OracleEntry for (data_type, oracle_address) + Oracle(Symbol, Address), + /// Vec — all submissions for (data_type, key) + DataPoints(Symbol, Symbol), + /// Vec
— registered oracle addresses for a specific data_type + OracleList(Symbol), + /// Global minimum confidence threshold (u32) + MinConfidence, + MaxDataAge, + PendingAdmin, + /// Ledger timestamp (u64) at which the current `PendingAdmin` was set, + /// used to enforce `ADMIN_TRANSFER_TIMELOCK` before `accept_admin`. + PendingAdminSince, + MinOracleCount, + /// Minimum seconds between submissions from the same oracle for a given + /// data_type (u64) + MinSubmitInterval, + /// Timestamp (u64) of an oracle's last accepted submission for a + /// data_type — (data_type, oracle) → last submission time + LastSubmission(Symbol, Address), + /// Token used for oracle stake deposits (Address). Unset = staking disabled. + StakeToken, + /// Minimum stake (i128) an oracle must hold for a data_type before + /// `add_oracle` will register it. Defaults to 0 (disabled). + MinStake, + /// Amount of stake token (i128) currently deposited by an oracle for a + /// given data_type — (data_type, oracle) → amount. + OracleStakeAmt(Symbol, Address), + /// Address slashed stake is transferred to. Unset = slashed stake is + /// retained by the contract (effectively burned). + SlashTreasury, + /// Guardian addresses authorized to approve critical actions (Vec
). + Guardians, + /// Number of guardian approvals required to execute a critical action + /// (u32). 0 means guardian multisig is disabled (admin acts alone). + GuardianThreshold, + /// A pending, not-yet-executed contract upgrade awaiting guardian approvals. + PendingUpgrade, + /// A pending admin-transfer proposal awaiting guardian approvals. + PendingAdminChange, + /// OracleReputation for (data_type, oracle_address). + Reputation(Symbol, Address), + /// Per-data-type max data age override (u64 seconds). Falls back to the + /// global `MaxDataAge` when unset. A flight-status feed goes stale in + /// minutes while a monthly rainfall total is valid for weeks — one global + /// number cannot serve both. + DataTypeMaxAge(Symbol), + /// Per-data-type aggregation method. Falls back to `WeightedMedian`. + AggregationMethod(Symbol), + /// Whether plaintext `submit_data`/`batch_submit_data` is refused for a + /// data_type (bool). Off by default — an existing deployment keeps + /// submitting plaintext until it opts a sensitive data_type in. + EncryptionRequired(Symbol), + /// Vec — ciphertext submissions for + /// (data_type, key), stored separately from plaintext `DataPoints` since + /// they are never aggregated or compared on-chain. + EncryptedDataPoints(Symbol, Symbol), + /// Maximum age (in seconds) for oracle submission timestamps to be accepted. + /// Submissions older than `now - MaxTimestampAge` are rejected. Defaults to + /// 90 days. Admin-configurable via `set_max_timestamp_age`. + MaxTimestampAge, + /// Maximum number of seconds into the future a submission timestamp may be. + /// Protects against submissions with clocks far ahead of the ledger. + /// Defaults to 60 seconds. Admin-configurable via `set_timestamp_future_buffer`. + TimestampFutureBuffer, + /// Per-data-type minimum oracle participation count for valid aggregation + /// (u32). Falls back to the global `MinOracleCount` when unset. A data + /// type with high-value triggers may require more independent submissions + /// before the aggregation is considered valid. + DataTypeMinOracleCount(Symbol), + /// Per-product consensus threshold configuration (ConsensusThreshold). + /// Specifies different oracle agreement levels for different data types/products. + ConsensusThreshold(Symbol), + /// Per-data-type outlier-detection configuration (OutlierConfig). Falls + /// back to disabled when unset, so an existing data type's aggregation + /// does not change until an admin opts it in. + OutlierConfig(Symbol), + /// Cross-validation rule between two data types — (source, target) → CrossValidationRule. + CrossValidationRule(Symbol, Symbol), + /// List of target data types that a source data type has cross-validation rules for. + CrossValidationTargets(Symbol), + /// Geographic weighting multiplier in basis points for (data_type, oracle, region). + GeoWeight(Symbol, Address, Symbol), +} + +// ─── Errors ─────────────────────────────────────────────────────────────────── + +#[contracterror] +#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] +#[repr(u32)] +pub enum Error { + AlreadyInitialized = 1, + NotInitialized = 2, + Unauthorized = 3, + OracleNotRegistered = 4, + OracleAlreadyExists = 5, + NoDataAvailable = 6, + InvalidConfidence = 7, + InvalidWeight = 8, + StaleData = 9, + TooManyOracles = 10, + InvalidTimestamp = 11, + InvalidAddress = 12, + RateLimited = 13, + InsufficientStake = 14, + StakeTokenNotSet = 15, + NoStake = 16, + InvalidStakeAmount = 17, + OracleStillActive = 18, + NotGuardian = 19, + AlreadyApprovedAction = 20, + NoPendingUpgrade = 21, + InvalidThreshold = 22, + AdminTimelockNotExpired = 23, + InvalidMaxAge = 24, + EncryptionRequiredForType = 25, + TimestampOutOfRange = 26, + InvalidOutlierConfig = 27, + InvalidInput = 28, + CrossValidationFailed = 29, +} + +// ─── Contract ───────────────────────────────────────────────────────────────── + +#[contract] +pub struct OracleVerifier; + +#[contractimpl] +impl OracleVerifier { + // ── Lifecycle ──────────────────────────────────────────────────────────── + + /// One-time initialisation. Caller becomes admin. + pub fn initialize(env: Env, admin: Address) { + if env.storage().instance().has(&StorageKey::Initialized) { + panic_with_error!(&env, Error::AlreadyInitialized); + } + + Self::validate_stellar_address(&env, &admin); + admin.require_auth(); + + env.storage() + .instance() + .set(&StorageKey::Initialized, &true); + env.storage().instance().set(&StorageKey::Admin, &admin); + + + env.events().publish( + (Symbol::new(&env, "initialized"),), + Initialized { + admin: admin.clone(), + }, + ); + } + + // ── Oracle Management (admin only) ─────────────────────────────────────── + + /// Register an oracle address for a given data type with a relative weight. + /// `weight` is 1-100; higher-weight oracles contribute more to the median. + pub fn add_oracle(env: Env, admin: Address, oracle: Address, data_type: Symbol, weight: u32) { + Self::require_admin(&env, &admin); + Self::validate_stellar_address(&env, &oracle); + if weight == 0 || weight > 100 { + panic_with_error!(&env, Error::InvalidWeight); + } + let key = StorageKey::Oracle(data_type.clone(), oracle.clone()); + if env.storage().persistent().has(&key) { + panic_with_error!(&env, Error::OracleAlreadyExists); + } + // Enforce a minimum economic stake so oracles have skin in the game. + // Disabled by default (min_stake == 0) for backward compatibility with + // deployments/tests that don't use the staking feature. + let min_stake: i128 = env + .storage() + .instance() + .get(&StorageKey::MinStake) + .unwrap_or(0); + if min_stake > 0 { + let staked: i128 = env + .storage() + .persistent() + .get(&StorageKey::OracleStakeAmt(data_type.clone(), oracle.clone())) + .unwrap_or(0); + if staked < min_stake { + panic_with_error!(&env, Error::InsufficientStake); + } + } + let entry = OracleEntry { + oracle: oracle.clone(), + data_type: data_type.clone(), + weight, + active: true, + }; + env.storage().persistent().set(&key, &entry); + env.storage().persistent().extend_ttl(&key, TTL_THRESHOLD, TTL_EXTEND_TO); + + let mut list: Vec
= env + .storage() + .instance() + .get(&StorageKey::OracleList(data_type.clone())) + .unwrap_or_else(|| Vec::new(&env)); + let mut already_present = false; + for i in 0..list.len() { + if list.get_unchecked(i) == oracle { + already_present = true; + break; + } + } + if !already_present { + if list.len() >= MAX_ORACLES { + panic_with_error!(&env, Error::TooManyOracles); + } + list.push_back(oracle.clone()); + env.storage().instance().set(&StorageKey::OracleList(data_type.clone()), &list); + } + + env.events().publish( + (Symbol::new(&env, "oracle_added"),), + OracleAdded { + oracle, + data_type, + weight, + }, + ); + } + + /// Update the relative weight of an existing oracle registration. + /// `weight` is 1-100; use `add_oracle` only for new registrations. + pub fn update_oracle_weight( + env: Env, + admin: Address, + oracle: Address, + data_type: Symbol, + weight: u32, + ) { + Self::require_admin(&env, &admin); + if weight == 0 || weight > 100 { + panic_with_error!(&env, Error::InvalidWeight); + } + + let key = StorageKey::Oracle(data_type, oracle); + let mut entry: OracleEntry = env + .storage() + .persistent() + .get(&key) + .unwrap_or_else(|| panic_with_error!(&env, Error::OracleNotRegistered)); + entry.weight = weight; + env.storage().persistent().set(&key, &entry); + env.storage().persistent().extend_ttl(&key, TTL_THRESHOLD, TTL_EXTEND_TO); + } + + /// Deactivate an oracle (soft delete — historical data is retained). + pub fn remove_oracle(env: Env, admin: Address, oracle: Address, data_type: Symbol) { + Self::require_admin(&env, &admin); + let key = StorageKey::Oracle(data_type.clone(), oracle.clone()); + let mut entry: OracleEntry = env + .storage() + .persistent() + .get(&key) + .unwrap_or_else(|| panic_with_error!(&env, Error::OracleNotRegistered)); + entry.active = false; + env.storage().persistent().set(&key, &entry); + env.storage().persistent().extend_ttl(&key, TTL_THRESHOLD, TTL_EXTEND_TO); + + let list: Vec
= env + .storage() + .instance() + .get(&StorageKey::OracleList(data_type.clone())) + .unwrap_or_else(|| Vec::new(&env)); + let mut pruned: Vec
= Vec::new(&env); + for addr in list.iter() { + if addr != oracle { + pruned.push_back(addr); + } + } + env.storage().instance().set(&StorageKey::OracleList(data_type.clone()), &pruned); + + env.events().publish( + (Symbol::new(&env, "oracle_removed"),), + OracleRemoved { oracle, data_type }, + ); + } + + // ── Contract Settings (admin only) ─────────────────────────────────────── + + /// Set the global minimum confidence threshold for oracle data. + pub fn set_min_confidence(env: Env, admin: Address, threshold: u32) { + Self::require_admin(&env, &admin); + if threshold > 100 { + panic_with_error!(&env, Error::InvalidConfidence); + } + env.storage() + .instance() + .set(&StorageKey::MinConfidence, &threshold); + env.events().publish( + (Symbol::new(&env, "min_confidence_updated"),), + MinConfidenceUpdated { threshold }, + ); + } + + /// Set the maximum data age in seconds. + pub fn set_max_data_age(env: Env, admin: Address, max_age: u64) { + Self::require_admin(&env, &admin); + env.storage() + .instance() + .set(&StorageKey::MaxDataAge, &max_age); + env.events().publish( + (Symbol::new(&env, "max_data_age_updated"),), + MaxDataAgeUpdated { max_age }, + ); + } + + /// Set the maximum acceptable age for oracle submission timestamps (in seconds). + /// Submissions with timestamps older than `now - max_timestamp_age` are rejected. + /// Defaults to 90 days (7,776,000 seconds). Admin-only. + pub fn set_max_timestamp_age(env: Env, admin: Address, max_timestamp_age: u64) { + Self::require_admin(&env, &admin); + env.storage() + .instance() + .set(&StorageKey::MaxTimestampAge, &max_timestamp_age); + env.events().publish( + (Symbol::new(&env, "max_timestamp_age_updated"),), + MaxTimestampAgeUpdated { max_timestamp_age }, + ); + } + + /// Return the configured max timestamp age (defaults to 90 days). + pub fn get_max_timestamp_age(env: Env) -> u64 { + env.storage() + .instance() + .get(&StorageKey::MaxTimestampAge) + .unwrap_or(90 * 24 * 60 * 60) + } + + /// Set the maximum number of seconds a submission timestamp may be ahead of + /// the ledger. Protects against oracles with clocks far ahead of reality. + /// Defaults to 60 seconds. Admin-only. + pub fn set_timestamp_future_buffer(env: Env, admin: Address, seconds: u64) { + Self::require_admin(&env, &admin); + env.storage() + .instance() + .set(&StorageKey::TimestampFutureBuffer, &seconds); + env.events().publish( + (Symbol::new(&env, "timestamp_future_buffer_updated"),), + TimestampFutureBufferUpdated { seconds }, + ); + } + + /// Return the configured future buffer (defaults to 60 seconds). + pub fn get_timestamp_future_buffer(env: Env) -> u64 { + env.storage() + .instance() + .get(&StorageKey::TimestampFutureBuffer) + .unwrap_or(60) + } + + /// Propose a new admin. Only the current admin can call this. + /// + /// If a guardian threshold > 0 is configured (`set_guardians`), this does + /// not activate the proposal immediately — it requires `threshold` + /// guardians to call `approve_admin_change` first, guarding this + /// takeover-capable operation against a single compromised admin key. + /// With no guardians configured (default), behavior is unchanged. + pub fn propose_new_admin(env: Env, admin: Address, new_admin: Address) { + Self::require_admin(&env, &admin); + Self::validate_stellar_address(&env, &new_admin); + + let threshold: u32 = env + .storage() + .instance() + .get(&StorageKey::GuardianThreshold) + .unwrap_or(0); + if threshold == 0 { + env.storage() + .instance() + .set(&StorageKey::PendingAdmin, &new_admin); + env.storage() + .instance() + .set(&StorageKey::PendingAdminSince, &env.ledger().timestamp()); + return; + } + + let pending = PendingAdminChange { + new_admin, + approvals: Vec::new(&env), + }; + env.storage() + .instance() + .set(&StorageKey::PendingAdminChange, &pending); + } + + /// Guardian approval for a pending admin-change proposal. Once enough + /// guardians have approved (>= threshold), the change is activated — + /// `new_admin` must then still call `accept_admin` to take effect. + pub fn approve_admin_change(env: Env, guardian: Address, new_admin: Address) { + guardian.require_auth(); + + let guardians: Vec
= env + .storage() + .instance() + .get(&StorageKey::Guardians) + .unwrap_or_else(|| Vec::new(&env)); + let mut is_guardian = false; + for g in guardians.iter() { + if g == guardian { + is_guardian = true; + break; + } + } + if !is_guardian { + panic_with_error!(&env, Error::NotGuardian); + } + + let mut pending: PendingAdminChange = env + .storage() + .instance() + .get(&StorageKey::PendingAdminChange) + .unwrap_or_else(|| panic_with_error!(&env, Error::NoPendingUpgrade)); + if pending.new_admin != new_admin { + panic_with_error!(&env, Error::NoPendingUpgrade); + } + for a in pending.approvals.iter() { + if a == guardian { + panic_with_error!(&env, Error::AlreadyApprovedAction); + } + } + pending.approvals.push_back(guardian); + + let threshold: u32 = env + .storage() + .instance() + .get(&StorageKey::GuardianThreshold) + .unwrap_or(0); + + if pending.approvals.len() >= threshold { + env.storage().instance().remove(&StorageKey::PendingAdminChange); + env.storage() + .instance() + .set(&StorageKey::PendingAdmin, &new_admin); + env.storage() + .instance() + .set(&StorageKey::PendingAdminSince, &env.ledger().timestamp()); + } else { + env.storage() + .instance() + .set(&StorageKey::PendingAdminChange, &pending); + } + } + + /// Accept the proposed admin. Only the proposed admin can call this. + pub fn accept_admin(env: Env, admin: Address) { + // Correct way to read an Optional field from Soroban instance storage + let pending_admin_opt: Option
= env + .storage() + .instance() + .get(&StorageKey::PendingAdmin) + .unwrap_or(None); // If key doesn't exist, evaluates to None + + let pending_admin = match pending_admin_opt { + Some(addr) => addr, + None => panic_with_error!(&env, Error::Unauthorized), + }; + + if admin != pending_admin { + panic_with_error!(&env, Error::Unauthorized); + } + + admin.require_auth(); + + if !env.storage().instance().has(&StorageKey::Initialized) { + panic_with_error!(&env, Error::NotInitialized); + } + + // Enforce the admin-rotation timelock (issue #356). + let since: u64 = env.storage().instance() + .get(&StorageKey::PendingAdminSince).unwrap_or(0); + if env.ledger().timestamp() < since.saturating_add(ADMIN_TRANSFER_TIMELOCK) { + panic_with_error!(&env, Error::AdminTimelockNotExpired); + } + + env.storage().instance().set(&StorageKey::Admin, &admin); + + // Clear the slot explicitly using a clean None type hint + let no_pending: Option
= None; + env.storage() + .instance() + .set(&StorageKey::PendingAdmin, &no_pending); + env.storage().instance().remove(&StorageKey::PendingAdminSince); + + env.events().publish( + (Symbol::new(&env, "admin_updated"),), + AdminUpdated { new_admin: admin }, + ); + } + + /// Get the maximum data age in seconds (defaults to 7 days = 604,800 seconds). + pub fn get_max_data_age(env: Env) -> u64 { + env.storage() + .instance() + .get(&StorageKey::MaxDataAge) + .unwrap_or(604_800) + } + + /// Set a max-data-age override for a single data type, in seconds. + /// + /// Freshness is not one number. A flight-status feed is worthless minutes + /// after the gate closes; a monthly rainfall total stays valid for weeks. + /// A single global `max_data_age` has to be loose enough for the slowest + /// feed, which leaves the fastest one accepting data long past the point + /// it describes reality. + /// + /// Pass `max_age = 0` to clear the override and fall back to the global + /// value. + pub fn set_data_type_max_age(env: Env, admin: Address, data_type: Symbol, max_age: u64) { + Self::require_admin(&env, &admin); + + if max_age == 0 { + env.storage() + .instance() + .remove(&StorageKey::DataTypeMaxAge(data_type.clone())); + } else { + env.storage() + .instance() + .set(&StorageKey::DataTypeMaxAge(data_type.clone()), &max_age); + } + + env.events().publish( + (Symbol::new(&env, "dt_max_age_updated"),), + DataTypeMaxAgeUpdated { data_type, max_age }, + ); + } + + /// The max data age actually applied to a data type: the per-type override + /// when one is set, otherwise the global value. + pub fn get_data_type_max_age(env: Env, data_type: Symbol) -> u64 { + Self::effective_max_age(&env, &data_type) + } + + /// Choose how submissions for a data type are combined into one value. + /// + /// Defaults to [`AggregationMethod::WeightedMedian`], which is the safe + /// choice against a minority of bad or adversarial oracles. Switch to an + /// average only for feeds where every reporter is trusted and small + /// genuine variations should be reflected rather than discarded. + pub fn set_aggregation_method( + env: Env, + admin: Address, + data_type: Symbol, + method: AggregationMethod, + ) { + Self::require_admin(&env, &admin); + + env.storage() + .instance() + .set(&StorageKey::AggregationMethod(data_type.clone()), &method); + + env.events().publish( + (Symbol::new(&env, "agg_method_updated"),), + AggregationMethodUpdated { data_type, method }, + ); + } + + /// The aggregation method applied to a data type (default: weighted median). + pub fn get_aggregation_method(env: Env, data_type: Symbol) -> AggregationMethod { + Self::effective_aggregation_method(&env, &data_type) + } + + /// Configure outlier rejection for a data type, applied before + /// aggregation regardless of `AggregationMethod` (issue #383). + /// + /// `WeightedMedian` already resists a minority of outliers by + /// construction, but `Mean`/`WeightedAverage`/`TimeWeightedAverage` + /// have no such protection, and even a median can still be dragged if + /// close to half the submissions for a key are bad. This runs as a + /// preprocessing step ahead of whichever method is configured. + /// + /// `threshold_bps` is basis points of the median absolute deviation + /// (MAD) — see [`OutlierConfig`]. `min_sample_size` must be at least 3; + /// below that, neither a median nor a MAD is a meaningful reference to + /// judge deviation against. + pub fn set_outlier_config( + env: Env, + admin: Address, + data_type: Symbol, + enabled: bool, + threshold_bps: u32, + min_sample_size: u32, + ) { + Self::require_admin(&env, &admin); + if threshold_bps == 0 || min_sample_size < 3 { + panic_with_error!(&env, Error::InvalidOutlierConfig); + } + + env.storage().instance().set( + &StorageKey::OutlierConfig(data_type.clone()), + &OutlierConfig { enabled, threshold_bps, min_sample_size }, + ); + + env.events().publish( + (Symbol::new(&env, "outlier_config_updated"),), + OutlierConfigUpdated { data_type, enabled, threshold_bps, min_sample_size }, + ); + } + + /// The outlier-rejection configuration applied to a data type. Disabled + /// with the compiled-in defaults until an admin calls + /// `set_outlier_config`. + pub fn get_outlier_config(env: Env, data_type: Symbol) -> OutlierConfig { + Self::effective_outlier_config(&env, &data_type) + } + + /// Report whether the data for `(data_type, key)` is fresh enough to use, + /// without panicking. + /// + /// Every other read path — `get_data`, `get_aggregated`, `verify_trigger` — + /// aborts the transaction when data is missing or stale. That is correct + /// for a claim evaluation but useless for a caller that wants to *decide* + /// whether to evaluate. This returns the same facts as a value, so a + /// front-end or a keeper can check first and a batch job can skip one + /// policy instead of losing the whole batch. + /// + /// A key with no submissions at all reports `is_fresh: false` and + /// `newest_age: u64::MAX` rather than failing. + pub fn check_freshness(env: Env, data_type: Symbol, key: Symbol) -> FreshnessReport { + let points: Vec = env + .storage() + .persistent() + .get(&StorageKey::DataPoints(data_type.clone(), key.clone())) + .unwrap_or_else(|| Vec::new(&env)); + + let max_age = Self::effective_max_age(&env, &data_type); + let now = env.ledger().timestamp(); + let min_oracle_count: u32 = env + .storage() + .instance() + .get(&StorageKey::MinOracleCount) + .unwrap_or(1); + + let mut newest_age = u64::MAX; + let mut fresh_count = 0u32; + + for i in 0..points.len() { + let p = points.get_unchecked(i); + // A future-dated point has age 0 — it is not stale, and + // `submit_data` already rejects timestamps ahead of the ledger. + let age = now.saturating_sub(p.timestamp); + if age < newest_age { + newest_age = age; + } + if age <= max_age { + fresh_count += 1; + } + } + + FreshnessReport { + data_type, + key, + is_fresh: fresh_count >= min_oracle_count.max(1), + newest_age, + fresh_count, + total_count: points.len(), + max_age, + } + } + + /// Check for stale oracle data across all submissions for a key. + /// + /// Unlike `check_freshness` which counts individual fresh submissions, + /// this provides a holistic staleness report that includes the ratio + /// of fresh to stale data and the age range of submissions. Useful for + /// monitoring dashboards and automated alerts. + /// + /// Data is considered stale when its age exceeds the effective max_age + /// for the data type. If more than half the submissions are stale, the + /// entire data set is flagged as stale. + pub fn check_staleness(env: Env, data_type: Symbol, key: Symbol) -> StalenessReport { + let points: Vec = env + .storage() + .persistent() + .get(&StorageKey::DataPoints(data_type.clone(), key.clone())) + .unwrap_or_else(|| Vec::new(&env)); + + let max_age = Self::effective_max_age(&env, &data_type); + let now = env.ledger().timestamp(); + + let mut stale_count = 0u32; + let mut newest_age = u64::MAX; + let mut oldest_fresh_age = 0u64; + let total_count = points.len(); + + for i in 0..points.len() { + let p = points.get_unchecked(i); + let age = now.saturating_sub(p.timestamp); + + if age < newest_age { + newest_age = age; + } + + if age > max_age { + stale_count += 1; + } else { + if age > oldest_fresh_age { + oldest_fresh_age = age; + } + } + } + + // If no submissions, report as stale with max ages + if total_count == 0 { + return StalenessReport { + data_type, + key, + is_stale: true, + oldest_fresh_age: 0, + newest_age: u64::MAX, + stale_count: 0, + total_count: 0, + max_age, + freshness_ratio_bps: 0, + }; + } + + let fresh_count = total_count.saturating_sub(stale_count); + let freshness_ratio_bps = if total_count > 0 { + (fresh_count as u64 * 10_000 / total_count as u64) as u32 + } else { + 0 + }; + + // Data is stale if more than half of submissions are stale + let is_stale = stale_count > total_count / 2; + + if is_stale { + env.events().publish( + (Symbol::new(&env, "stale_data_detected"),), + StaleDataDetected { + data_type: data_type.clone(), + key: key.clone(), + stale_count, + total_count, + oldest_age: now.saturating_sub(newest_age), + max_age, + }, + ); + } + + StalenessReport { + data_type, + key, + is_stale, + oldest_fresh_age, + newest_age, + stale_count, + total_count, + max_age, + freshness_ratio_bps, + } + } + + /// Set the minimum number of oracle submissions required to form a consensus value. + /// `min_count` must be at least 1; the default is 1. + pub fn set_min_oracle_count(env: Env, admin: Address, min_count: u32) { + Self::require_admin(&env, &admin); + env.storage() + .instance() + .set(&StorageKey::MinOracleCount, &min_count); + env.events().publish( + (Symbol::new(&env, "min_oracle_count_updated"),), + MinOracleCountUpdated { min_count }, + ); + } + + /// Return the minimum number of oracle submissions required for consensus (defaults to 1). + pub fn get_min_oracle_count(env: Env) -> u32 { + env.storage() + .instance() + .get(&StorageKey::MinOracleCount) + .unwrap_or(1) + } + + /// Set a per-data-type minimum oracle participation count override. + /// + /// Different data types have different risk profiles: a high-value + /// crop-insurance trigger may require 3+ independent oracle submissions + /// before the aggregation is trusted, while a low-stakes weather feed + /// may be fine with the global default. + /// + /// Pass `min_count = 0` to clear the override and fall back to the + /// global `MinOracleCount` value. + pub fn set_data_type_min_oracle_count( + env: Env, + admin: Address, + data_type: Symbol, + min_count: u32, + ) { + Self::require_admin(&env, &admin); + if min_count == 0 { + env.storage() + .instance() + .remove(&StorageKey::DataTypeMinOracleCount(data_type.clone())); + } else { + env.storage() + .instance() + .set(&StorageKey::DataTypeMinOracleCount(data_type.clone()), &min_count); + } + env.events().publish( + (Symbol::new(&env, "dt_min_oracle_count_updated"),), + DataTypeMinOracleCountUpdated { data_type, min_count }, + ); + } + + /// The effective minimum oracle count for a data type: the per-type + /// override when set, otherwise the global value. + pub fn get_data_type_min_oracle_count(env: Env, data_type: Symbol) -> u32 { + Self::effective_min_oracle_count(&env, &data_type) + } + + /// Set per-product configurable consensus threshold for oracle agreement. + /// Allows specifying different consensus requirements for different data types/products. + /// + /// The threshold is in basis points: 10000 = unanimous, 5000 = majority, etc. + /// This replaces fixed consensus thresholds with flexible, per-product configuration. + pub fn set_consensus_threshold( + env: Env, + admin: Address, + data_type: Symbol, + agreement_threshold_bps: u32, + ) { + Self::require_admin(&env, &admin); + + // Validate threshold is between 0 and 10000 basis points + if agreement_threshold_bps > 10000 { + panic_with_error!(&env, Error::InvalidInput); + } + + let threshold = ConsensusThreshold { + data_type: data_type.clone(), + agreement_threshold_bps, + }; + + env.storage() + .instance() + .set(&StorageKey::ConsensusThreshold(data_type.clone()), &threshold); + + env.events().publish( + (Symbol::new(&env, "consensus_threshold_updated"),), + ConsensusThresholdUpdated { + data_type, + agreement_threshold_bps + }, + ); + } + + /// Get the consensus threshold for a specific data type/product. + /// Returns the configured threshold, or a default of 5000 (50%, simple majority) if not configured. + pub fn get_consensus_threshold(env: Env, data_type: Symbol) -> ConsensusThreshold { + match env + .storage() + .instance() + .get::<_, ConsensusThreshold>(&StorageKey::ConsensusThreshold(data_type.clone())) + { + Some(threshold) => threshold, + None => ConsensusThreshold { + data_type, + agreement_threshold_bps: 5000, // Default to simple majority + }, + } + } + + // ── Cross-Validation (issue #430) ──────────────────────────────────────── + + /// Add or update a cross-validation rule between two data types. + /// + /// When data is submitted for `source_type`, the aggregated values for + /// `source_type` and `target_type` on the same key must not differ by + /// more than `max_variance`. This catches inconsistent oracle data + /// across correlated feeds (e.g. rainfall vs. temperature). + pub fn set_cross_validation_rule( + env: Env, + admin: Address, + source_type: Symbol, + target_type: Symbol, + max_variance: i128, + description: Bytes, + ) { + Self::require_admin(&env, &admin); + if max_variance < 0 { + panic_with_error!(&env, Error::InvalidInput); + } + if source_type == target_type { + panic_with_error!(&env, Error::InvalidInput); + } + + let rule = CrossValidationRule { + source_type: source_type.clone(), + target_type: target_type.clone(), + max_variance, + description, + }; + env.storage().instance().set( + &StorageKey::CrossValidationRule(source_type.clone(), target_type.clone()), + &rule, + ); + + // Track which targets this source has rules for + let mut targets: Vec = env + .storage() + .instance() + .get(&StorageKey::CrossValidationTargets(source_type.clone())) + .unwrap_or_else(|| Vec::new(&env)); + let mut found = false; + for i in 0..targets.len() { + if targets.get_unchecked(i) == target_type { + found = true; + break; + } + } + if !found { + targets.push_back(target_type.clone()); + env.storage().instance().set( + &StorageKey::CrossValidationTargets(source_type.clone()), + &targets, + ); + } + + env.events().publish( + (Symbol::new(&env, "cross_validation_rule_added"),), + CrossValidationRuleAdded { + source_type, + target_type, + max_variance, + }, + ); + } + + /// Remove a cross-validation rule between two data types. + pub fn remove_cross_validation_rule( + env: Env, + admin: Address, + source_type: Symbol, + target_type: Symbol, + ) { + Self::require_admin(&env, &admin); + let key = StorageKey::CrossValidationRule(source_type.clone(), target_type.clone()); + if !env.storage().instance().has(&key) { + panic_with_error!(&env, Error::InvalidInput); + } + env.storage().instance().remove(&key); + + // Remove from targets list + let mut targets: Vec = env + .storage() + .instance() + .get(&StorageKey::CrossValidationTargets(source_type.clone())) + .unwrap_or_else(|| Vec::new(&env)); + let mut pruned: Vec = Vec::new(&env); + for i in 0..targets.len() { + if targets.get_unchecked(i) != target_type { + pruned.push_back(targets.get_unchecked(i)); + } + } + env.storage().instance().set( + &StorageKey::CrossValidationTargets(source_type.clone()), + &pruned, + ); + + env.events().publish( + (Symbol::new(&env, "cross_validation_rule_removed"),), + CrossValidationRuleRemoved { + source_type, + target_type, + }, + ); + } + + /// Get the cross-validation rule between two data types, if one exists. + pub fn get_cross_validation_rule( + env: Env, + source_type: Symbol, + target_type: Symbol, + ) -> Option { + env.storage().instance().get( + &StorageKey::CrossValidationRule(source_type, target_type), + ) + } + + /// Get all cross-validation targets for a given source data type. + pub fn get_cross_validation_targets(env: Env, source_type: Symbol) -> Vec { + env.storage().instance().get( + &StorageKey::CrossValidationTargets(source_type), + ).unwrap_or_else(|| Vec::new(&env)) + } + + /// Check cross-validation rules between a source data type and all its + /// configured targets for a given key. Returns Ok(()) if all rules pass, + /// or the first failing rule's details. + /// + /// Called internally after aggregation to detect inconsistent oracle data + /// across correlated feeds. Panics with `CrossValidationFailed` if any + /// rule is violated. + fn check_cross_validation(env: &Env, source_type: &Symbol, key: &Symbol) { + let targets: Vec = env + .storage() + .instance() + .get(&StorageKey::CrossValidationTargets(source_type.clone())) + .unwrap_or_else(|| Vec::new(env)); + + if targets.is_empty() { + return; + } + + // Get source aggregated value + let source_value = Self::get_median_value(env, source_type, key); + + for i in 0..targets.len() { + let target_type = targets.get_unchecked(i); + let rule: CrossValidationRule = match env.storage().instance().get( + &StorageKey::CrossValidationRule(source_type.clone(), target_type.clone()), + ) { + Some(r) => r, + None => continue, + }; + + // Try to get target aggregated value — skip if no data available + let target_points: Vec = match env.storage().persistent().get( + &StorageKey::DataPoints(target_type.clone(), key.clone()), + ) { + Some(pts) => pts, + None => continue, + }; + if target_points.is_empty() { + continue; + } + + let target_value = Self::get_median_value(env, &target_type, key); + let variance = source_value.saturating_sub(target_value).abs(); + + if variance > rule.max_variance { + panic_with_error!(env, Error::CrossValidationFailed); + } + } + } + + /// Set the minimum number of seconds a single oracle must wait between + /// submissions for the same data_type. Guards against a malicious or + /// malfunctioning oracle flooding the contract with submissions to + /// consume storage/instruction budget (griefing). + pub fn set_min_submit_interval(env: Env, admin: Address, seconds: u64) { + Self::require_admin(&env, &admin); + env.storage() + .instance() + .set(&StorageKey::MinSubmitInterval, &seconds); + env.events().publish( + (Symbol::new(&env, "min_submit_interval_updated"),), + MinSubmitIntervalUpdated { seconds }, + ); + } + + /// Return the minimum number of seconds required between submissions + /// from the same oracle for a data_type (defaults to 30). + pub fn get_min_submit_interval(env: Env) -> u64 { + env.storage() + .instance() + .get(&StorageKey::MinSubmitInterval) + .unwrap_or(DEFAULT_MIN_SUBMIT_INTERVAL) + } + + // ── Oracle Staking (economic security) ─────────────────────────────────── + + /// Set the token used for oracle stake deposits. Admin-only. + pub fn set_stake_token(env: Env, admin: Address, token: Address) { + Self::require_admin(&env, &admin); + env.storage().instance().set(&StorageKey::StakeToken, &token); + env.events().publish( + (Symbol::new(&env, "stake_token_updated"),), + StakeTokenUpdated { token }, + ); + } + + /// Return the configured stake token, if any. + pub fn get_stake_token(env: Env) -> Option
{ + env.storage().instance().get(&StorageKey::StakeToken) + } + + /// Set the minimum stake an oracle must hold for a data_type before + /// `add_oracle` will register it. 0 disables the requirement (default). + pub fn set_min_stake(env: Env, admin: Address, min_stake: i128) { + Self::require_admin(&env, &admin); + if min_stake < 0 { + panic_with_error!(&env, Error::InvalidStakeAmount); + } + env.storage().instance().set(&StorageKey::MinStake, &min_stake); + env.events().publish( + (Symbol::new(&env, "min_stake_updated"),), + MinStakeUpdated { min_stake }, + ); + } + + /// Return the currently configured minimum oracle stake (defaults to 0). + pub fn get_min_stake(env: Env) -> i128 { + env.storage().instance().get(&StorageKey::MinStake).unwrap_or(0) + } + + /// Set the address slashed stake is transferred to. Admin-only. If never + /// set, slashed stake stays locked in the contract. + pub fn set_slash_treasury(env: Env, admin: Address, treasury: Address) { + Self::require_admin(&env, &admin); + env.storage().instance().set(&StorageKey::SlashTreasury, &treasury); + } + + /// Deposit `amount` of the configured stake token toward `oracle`'s stake + /// for `data_type`. Callable by anyone but requires the oracle's own + /// authorization, so only the oracle (or someone it has delegated to) + /// can grow its own stake. + pub fn stake(env: Env, oracle: Address, data_type: Symbol, amount: i128) { + oracle.require_auth(); + if amount <= 0 { + panic_with_error!(&env, Error::InvalidStakeAmount); + } + let token_addr: Address = env + .storage() + .instance() + .get(&StorageKey::StakeToken) + .unwrap_or_else(|| panic_with_error!(&env, Error::StakeTokenNotSet)); + + token::Client::new(&env, &token_addr).transfer( + &oracle, + &env.current_contract_address(), + &amount, + ); + + let stake_key = StorageKey::OracleStakeAmt(data_type.clone(), oracle.clone()); + let current: i128 = env.storage().persistent().get(&stake_key).unwrap_or(0); + let total_stake = current + amount; + env.storage().persistent().set(&stake_key, &total_stake); + env.storage() + .persistent() + .extend_ttl(&stake_key, TTL_THRESHOLD, TTL_EXTEND_TO); + + env.events().publish( + (Symbol::new(&env, "stake_deposited"),), + StakeDeposited { + oracle, + data_type, + amount, + total_stake, + }, + ); + } + + /// Return the amount currently staked by `oracle` for `data_type`. + pub fn get_oracle_stake(env: Env, data_type: Symbol, oracle: Address) -> i128 { + env.storage() + .persistent() + .get(&StorageKey::OracleStakeAmt(data_type, oracle)) + .unwrap_or(0) + } + + // ── Geographic Weighting (Issue #426) ─────────────────────────────────── + + /// Admin-only: Set geographic weighting multiplier for an oracle in basis points (10000 = 1.0x). + pub fn set_oracle_geo_weight( + env: Env, + admin: Address, + oracle: Address, + data_type: Symbol, + region: Symbol, + geo_weight_bps: u32, + ) { + Self::require_admin(&env, &admin); + if geo_weight_bps == 0 { + panic_with_error!(&env, Error::InvalidConfidence); + } + let key = StorageKey::GeoWeight(data_type.clone(), oracle.clone(), region.clone()); + env.storage().persistent().set(&key, &geo_weight_bps); + env.storage().persistent().extend_ttl(&key, TTL_THRESHOLD, TTL_EXTEND_TO); + + env.events().publish( + (Symbol::new(&env, "geo_weight_updated"),), + GeoWeightUpdated { + oracle, + data_type, + region, + geo_weight_bps, + }, + ); + } + + /// Return the geographic weighting multiplier in basis points for (data_type, oracle, region). + /// Defaults to 10,000 (1.0x baseline weight) if unconfigured. + pub fn get_oracle_geo_weight( + env: Env, + oracle: Address, + data_type: Symbol, + region: Symbol, + ) -> u32 { + env.storage() + .persistent() + .get(&StorageKey::GeoWeight(data_type, oracle, region)) + .unwrap_or(10_000) + } + + /// Return aggregated data for a specific geographic region, applying geographic weighting to active oracles. + pub fn get_aggregated_for_region( + env: Env, + data_type: Symbol, + key: Symbol, + max_age_seconds: u64, + target_region: Symbol, + ) -> AggregatedData { + let points: Vec = env + .storage() + .persistent() + .get(&StorageKey::DataPoints(data_type.clone(), key.clone())) + .unwrap_or_else(|| panic_with_error!(&env, Error::NoDataAvailable)); + if points.is_empty() { + panic_with_error!(&env, Error::NoDataAvailable); + } + + let now = env.ledger().timestamp(); + let min_confidence: u32 = env + .storage() + .instance() + .get(&StorageKey::MinConfidence) + .unwrap_or(0); + + let mut values = [(0i128, 0u32); 100]; + let mut count: usize = 0; + let mut total_effective_weight: u32 = 0; + let mut weighted_confidence_sum: u64 = 0; + let mut min_conf: u32 = 100; + let mut newest_timestamp: u64 = 0; + + for i in 0..points.len() { + let p = points.get_unchecked(i); + let age = now.saturating_sub(p.timestamp); + if age <= max_age_seconds && p.confidence >= min_confidence { + let oracle_key = StorageKey::Oracle(data_type.clone(), p.oracle.clone()); + let base_weight = match env.storage().persistent().get::<_, OracleEntry>(&oracle_key) { + Some(entry) if entry.active => entry.weight, + _ => continue, + }; + + let geo_multiplier = Self::get_oracle_geo_weight(env.clone(), p.oracle.clone(), data_type.clone(), target_region.clone()); + let effective_weight = ((base_weight as u64 * geo_multiplier as u64) / 10_000) as u32; + let effective_weight = if effective_weight == 0 { 1 } else { effective_weight }; + + if count < 100 { + values[count] = (p.value, effective_weight); + count += 1; + } + + total_effective_weight += effective_weight; + weighted_confidence_sum += p.confidence as u64 * effective_weight as u64; + if p.confidence < min_conf { + min_conf = p.confidence; + } + if p.timestamp > newest_timestamp { + newest_timestamp = p.timestamp; + } + } + } + + if count == 0 { + panic_with_error!(&env, Error::NoDataAvailable); + } + + let slice = &mut values[..count]; + slice.sort_by(|a, b| a.0.cmp(&b.0)); + + let half_weight = total_effective_weight / 2; + let mut accum: u32 = 0; + let mut median: i128 = slice[0].0; + for &(val, weight) in slice.iter() { + accum += weight; + if accum >= half_weight { + median = val; + break; + } + } + + let avg_confidence = if total_effective_weight > 0 { + (weighted_confidence_sum / total_effective_weight as u64) as u32 + } else { + 0 + }; + + let (ci_lower, ci_upper) = + Self::calculate_confidence_interval(slice, total_effective_weight, median); + + AggregatedData { + median_value: median, + oracle_count: count as u32, + active_oracle_count: count as u32, + confidence: avg_confidence, + min_confidence: min_conf, + last_updated: newest_timestamp, + confidence_interval_lower: ci_lower, + confidence_interval_upper: ci_upper, + } + } + + /// Withdraw the caller's full stake for `data_type`. Only permitted once + /// the oracle is not an active registration for that data_type (never + /// registered, or previously removed via `remove_oracle`) — an active + /// oracle cannot pull its economic backing out from under a live + /// registration. + pub fn withdraw_stake(env: Env, oracle: Address, data_type: Symbol) { + oracle.require_auth(); + + let oracle_key = StorageKey::Oracle(data_type.clone(), oracle.clone()); + if let Some(entry) = env.storage().persistent().get::<_, OracleEntry>(&oracle_key) { + if entry.active { + panic_with_error!(&env, Error::OracleStillActive); + } + } + + let stake_key = StorageKey::OracleStakeAmt(data_type.clone(), oracle.clone()); + let amount: i128 = env.storage().persistent().get(&stake_key).unwrap_or(0); + if amount <= 0 { + panic_with_error!(&env, Error::NoStake); + } + let token_addr: Address = env + .storage() + .instance() + .get(&StorageKey::StakeToken) + .unwrap_or_else(|| panic_with_error!(&env, Error::StakeTokenNotSet)); + + env.storage().persistent().remove(&stake_key); + token::Client::new(&env, &token_addr).transfer( + &env.current_contract_address(), + &oracle, + &amount, + ); + + env.events().publish( + (Symbol::new(&env, "stake_withdrawn"),), + StakeWithdrawn { + oracle, + data_type, + amount, + }, + ); + } + + /// Slash `amount` from `oracle`'s stake for `data_type` as a penalty for + /// provably incorrect data (verified off-chain / via governance and + /// submitted by the admin). Caps at the oracle's current stake. If the + /// slash brings the oracle below the configured `min_stake`, the oracle + /// is deactivated so it can no longer submit until it re-stakes and is + /// re-registered. Slashed funds move to the configured slash treasury, + /// if any, otherwise remain locked in the contract. + pub fn slash_oracle( + env: Env, + admin: Address, + oracle: Address, + data_type: Symbol, + amount: i128, + reason: Symbol, + ) { + Self::require_admin(&env, &admin); + if amount <= 0 { + panic_with_error!(&env, Error::InvalidStakeAmount); + } + + let stake_key = StorageKey::OracleStakeAmt(data_type.clone(), oracle.clone()); + let current: i128 = env.storage().persistent().get(&stake_key).unwrap_or(0); + if current <= 0 { + panic_with_error!(&env, Error::NoStake); + } + let slashed = amount.min(current); + let remaining = current - slashed; + env.storage().persistent().set(&stake_key, &remaining); + + if let Some(token_addr) = env.storage().instance().get::<_, Address>(&StorageKey::StakeToken) { + if let Some(treasury) = env.storage().instance().get::<_, Address>(&StorageKey::SlashTreasury) { + token::Client::new(&env, &token_addr).transfer( + &env.current_contract_address(), + &treasury, + &slashed, + ); + } + } + + let min_stake: i128 = env + .storage() + .instance() + .get(&StorageKey::MinStake) + .unwrap_or(0); + if min_stake > 0 && remaining < min_stake { + let oracle_key = StorageKey::Oracle(data_type.clone(), oracle.clone()); + if let Some(mut entry) = env.storage().persistent().get::<_, OracleEntry>(&oracle_key) { + if entry.active { + entry.active = false; + env.storage().persistent().set(&oracle_key, &entry); + + let list: Vec
= env + .storage() + .instance() + .get(&StorageKey::OracleList(data_type.clone())) + .unwrap_or_else(|| Vec::new(&env)); + let mut pruned: Vec
= Vec::new(&env); + for addr in list.iter() { + if addr != oracle { + pruned.push_back(addr); + } + } + env.storage() + .instance() + .set(&StorageKey::OracleList(data_type.clone()), &pruned); + } + } + } + + env.events().publish( + (Symbol::new(&env, "oracle_slashed"),), + OracleSlashed { + oracle, + data_type, + amount: slashed, + remaining_stake: remaining, + reason, + }, + ); + } + + // ── Guardian Multisig (critical actions) ───────────────────────────────── + + /// Configure the guardian set and approval threshold required for + /// critical actions (currently: contract upgrades). Admin-only. + /// `threshold == 0` disables the guardian requirement (default), so the + /// admin alone can act — preserves existing single-admin behavior until + /// guardians are explicitly configured. + pub fn set_guardians(env: Env, admin: Address, guardians: Vec
, threshold: u32) { + Self::require_admin(&env, &admin); + if threshold as u32 > guardians.len() { + panic_with_error!(&env, Error::InvalidThreshold); + } + env.storage().instance().set(&StorageKey::Guardians, &guardians); + env.storage() + .instance() + .set(&StorageKey::GuardianThreshold, &threshold); + env.events().publish( + (Symbol::new(&env, "guardians_updated"),), + GuardiansUpdated { guardians, threshold }, + ); + } + + /// Return the current guardian set. + pub fn get_guardians(env: Env) -> Vec
{ + env.storage() + .instance() + .get(&StorageKey::Guardians) + .unwrap_or_else(|| Vec::new(&env)) + } + + /// Return the current guardian approval threshold (0 = disabled). + pub fn get_guardian_threshold(env: Env) -> u32 { + env.storage() + .instance() + .get(&StorageKey::GuardianThreshold) + .unwrap_or(0) + } + + /// Return the pending upgrade awaiting guardian approvals, if any. + pub fn get_pending_upgrade(env: Env) -> Option { + env.storage().instance().get(&StorageKey::PendingUpgrade) + } + + /// Ledger timestamp at which the current pending admin transfer was + /// registered, or `0` if none. `accept_admin` succeeds only once + /// `now >= this + ADMIN_TRANSFER_TIMELOCK` (issue #356). + pub fn get_pending_admin_since(env: Env) -> u64 { + env.storage().instance().get(&StorageKey::PendingAdminSince).unwrap_or(0) + } + + /// Guardian approval for the pending upgrade. Once enough guardians have + /// approved (>= threshold), the upgrade executes immediately. + pub fn approve_upgrade(env: Env, guardian: Address, new_wasm_hash: BytesN<32>) { + guardian.require_auth(); + + let guardians: Vec
= env + .storage() + .instance() + .get(&StorageKey::Guardians) + .unwrap_or_else(|| Vec::new(&env)); + let mut is_guardian = false; + for g in guardians.iter() { + if g == guardian { + is_guardian = true; + break; + } + } + if !is_guardian { + panic_with_error!(&env, Error::NotGuardian); + } + + let mut pending: PendingUpgrade = env + .storage() + .instance() + .get(&StorageKey::PendingUpgrade) + .unwrap_or_else(|| panic_with_error!(&env, Error::NoPendingUpgrade)); + if pending.new_wasm_hash != new_wasm_hash { + panic_with_error!(&env, Error::NoPendingUpgrade); + } + + for a in pending.approvals.iter() { + if a == guardian { + panic_with_error!(&env, Error::AlreadyApprovedAction); + } + } + pending.approvals.push_back(guardian.clone()); + + let threshold: u32 = env + .storage() + .instance() + .get(&StorageKey::GuardianThreshold) + .unwrap_or(0); + + env.events().publish( + (Symbol::new(&env, "upgrade_approved"),), + UpgradeApproved { + new_wasm_hash: new_wasm_hash.clone(), + approver: guardian, + approvals: pending.approvals.len(), + threshold, + }, + ); + + if pending.approvals.len() >= threshold { + env.storage().instance().remove(&StorageKey::PendingUpgrade); + env.deployer().update_current_contract_wasm(new_wasm_hash); + } else { + env.storage().instance().set(&StorageKey::PendingUpgrade, &pending); + } + } + + /// Admin-only: cancel a pending upgrade before it collects enough + /// guardian approvals. + pub fn cancel_pending_upgrade(env: Env, admin: Address) { + Self::require_admin(&env, &admin); + if !env.storage().instance().has(&StorageKey::PendingUpgrade) { + panic_with_error!(&env, Error::NoPendingUpgrade); + } + env.storage().instance().remove(&StorageKey::PendingUpgrade); + } + + // ── Data Submission ─────────────────────────────────────────────────────── + + /// Submit a data point for a (data_type, key) pair. + /// + /// This function stores one reading from a registered oracle for a specific + /// observation key. Multiple oracles may submit values for the same key; + /// later aggregation does not discard or "average out" conflicting values + /// by default. Instead, the contract computes a weighted median over all + /// eligible submissions for that key, so a small number of contradictory + /// readings do not dominate the result unless their assigned oracle weights + /// do. + /// + /// For a reading to be considered during aggregation, the submission must + /// be fresh enough for the configured max-data-age window, from an active + /// oracle, and at or above the configured minimum confidence threshold. The + /// aggregation logic also requires at least `min_oracle_count` eligible + /// submissions before it will return a consensus value; if fewer are + /// available, the call fails with `NoDataAvailable`. + /// + /// The final value is the weighted median of the eligible submissions, where + /// each oracle's registered weight influences its position in the ordered + /// set. If the total weight is even, the midpoint between the two middle + /// values is returned. + /// + /// - `data_type`: category — "weather", "flight", "onchain", "disaster" + /// - `key`: specific measurement — "rainfall:kisumu:2026-06", "flight:KQ100:2026-06-15" + /// - `value`: 7-decimal fixed point (same precision as Stellar assets) + /// - `confidence`: 0-100 reliability score + /// - `timestamp`: Unix timestamp of the real-world observation + pub fn submit_data( + env: Env, + oracle: Address, + data_type: Symbol, + key: Symbol, + value: i128, + confidence: u32, + timestamp: u64, + ) { + oracle.require_auth(); + if Self::encryption_required(&env, &data_type) { + panic_with_error!(&env, Error::EncryptionRequiredForType); + } + if confidence == 0 || confidence > 100 { + panic_with_error!(&env, Error::InvalidConfidence); + } + + let now = env.ledger().timestamp(); + let future_buffer: u64 = env + .storage() + .instance() + .get(&StorageKey::TimestampFutureBuffer) + .unwrap_or(60); + if timestamp > now.saturating_add(future_buffer) { + panic_with_error!(&env, Error::InvalidTimestamp); + } + let max_timestamp_age: u64 = env + .storage() + .instance() + .get(&StorageKey::MaxTimestampAge) + .unwrap_or(90 * 24 * 60 * 60); + if timestamp < now.saturating_sub(max_timestamp_age) { + panic_with_error!(&env, Error::InvalidTimestamp); + } + + // Verify oracle is registered and active for this data_type + let oracle_key = StorageKey::Oracle(data_type.clone(), oracle.clone()); + let entry: OracleEntry = env + .storage() + .persistent() + .get(&oracle_key) + .unwrap_or_else(|| panic_with_error!(&env, Error::OracleNotRegistered)); + if !entry.active { + panic_with_error!(&env, Error::Unauthorized); + } + // Keep the registration alive alongside the reading it authorized. + env.storage() + .persistent() + .extend_ttl(&oracle_key, TTL_THRESHOLD, TTL_EXTEND_TO); + + Self::enforce_rate_limit(&env, &data_type, &oracle, now); + + // Load existing submissions for this (data_type, key) + let dp_key = StorageKey::DataPoints(data_type.clone(), key.clone()); + let points: Vec = env + .storage() + .persistent() + .get(&dp_key) + .unwrap_or_else(|| Vec::new(&env)); + + // Overwrite existing submission from this oracle; append if new + let new_point = OracleDataPoint { + oracle: oracle.clone(), + value, + confidence, + timestamp, + }; + // Prune stale or unregistered/inactive oracle entries first + let max_data_age = Self::effective_max_age(&env, &data_type); + let mut pruned_points: Vec = Vec::new(&env); + for i in 0..points.len() { + let p = points.get_unchecked(i); + if p.oracle == oracle { + continue; // will be replaced + } + if now.saturating_sub(p.timestamp) <= max_data_age { + let oracle_k = StorageKey::Oracle(data_type.clone(), p.oracle.clone()); + if let Some(e) = env.storage().persistent().get::<_, OracleEntry>(&oracle_k) { + if e.active { + pruned_points.push_back(p); + } + } + } + } + if pruned_points.len() >= MAX_DATA_POINTS { + pruned_points.pop_front(); + } + pruned_points.push_back(new_point); + + env.storage().persistent().set(&dp_key, &pruned_points); + env.storage().persistent().extend_ttl(&dp_key, TTL_THRESHOLD, TTL_EXTEND_TO); + + // Check cross-validation rules after storing new data + Self::check_cross_validation(&env, &data_type, &key); + + env.events().publish( + (Symbol::new(&env, "oracle_data_submitted"),), + OracleDataSubmitted { + oracle, + data_type, + key, + value, + confidence, + timestamp, + }, + ); + } + + // ── Data Encryption (issue #379) ────────────────────────────────────────── + + /// Require (or stop requiring) encrypted submissions for a data_type. + /// + /// Off by default — plaintext `submit_data`/`batch_submit_data` behave + /// exactly as before for any data_type that never opts in. Once enabled, + /// plaintext submissions for that data_type are refused and only + /// `submit_encrypted_data` is accepted, for data types whose values are + /// sensitive (private valuations, confidential off-chain metrics, etc.) + /// and should not sit in plaintext on a public ledger. + pub fn set_encryption_required(env: Env, admin: Address, data_type: Symbol, required: bool) { + Self::require_admin(&env, &admin); + env.storage() + .instance() + .set(&StorageKey::EncryptionRequired(data_type.clone()), &required); + env.events().publish( + (Symbol::new(&env, "encryption_required_updated"),), + EncryptionRequiredUpdated { data_type, required }, + ); + } + + /// Whether `data_type` currently requires encrypted submissions. + pub fn get_encryption_required(env: Env, data_type: Symbol) -> bool { + 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` + /// are opaque to it and stored as-is. Encryption/decryption happens + /// entirely off-chain, between whichever parties hold the key for this + /// data_type — the contract's only job is tamper-evident storage and + /// making explicit, at the type level, that a submission is encrypted so + /// a consumer never mistakes ciphertext for a usable value. Encrypted + /// submissions are not fed into `verify_trigger`/`get_aggregated`, since + /// aggregation and threshold comparison require the plaintext value. + /// + /// Confidence and timestamp validation mirror `submit_data`. + pub fn submit_encrypted_data( + env: Env, + oracle: Address, + data_type: Symbol, + key: Symbol, + ciphertext: Bytes, + nonce: BytesN<12>, + confidence: u32, + timestamp: u64, + ) { + oracle.require_auth(); + if confidence == 0 || confidence > 100 { + panic_with_error!(&env, Error::InvalidConfidence); + } + + let now = env.ledger().timestamp(); + if timestamp > now { + panic_with_error!(&env, Error::InvalidTimestamp); + } + let ninety_days = 90 * 24 * 60 * 60; + if timestamp < now.saturating_sub(ninety_days) { + panic_with_error!(&env, Error::InvalidTimestamp); + } + + let oracle_key = StorageKey::Oracle(data_type.clone(), oracle.clone()); + let entry: OracleEntry = env + .storage() + .persistent() + .get(&oracle_key) + .unwrap_or_else(|| panic_with_error!(&env, Error::OracleNotRegistered)); + if !entry.active { + panic_with_error!(&env, Error::Unauthorized); + } + env.storage() + .persistent() + .extend_ttl(&oracle_key, TTL_THRESHOLD, TTL_EXTEND_TO); + + Self::enforce_rate_limit(&env, &data_type, &oracle, now); + + let dp_key = StorageKey::EncryptedDataPoints(data_type.clone(), key.clone()); + let mut points: Vec = env + .storage() + .persistent() + .get(&dp_key) + .unwrap_or_else(|| Vec::new(&env)); + + let new_point = EncryptedOracleDataPoint { + oracle: oracle.clone(), + ciphertext, + nonce, + confidence, + timestamp, + }; + let mut found = false; + for i in 0..points.len() { + if points.get_unchecked(i).oracle == oracle { + points.set(i, new_point.clone()); + found = true; + break; + } + } + if !found { + if points.len() >= MAX_DATA_POINTS { + points.pop_front(); + } + points.push_back(new_point); + } + + env.storage().persistent().set(&dp_key, &points); + env.storage().persistent().extend_ttl(&dp_key, TTL_THRESHOLD, TTL_EXTEND_TO); + + env.events().publish( + (Symbol::new(&env, "oracle_encrypted_data_submitted"),), + OracleEncryptedDataSubmitted { + oracle, + data_type, + key, + confidence, + timestamp, + }, + ); + } + + /// Return all encrypted submissions stored for (data_type, key), for an + /// off-chain consumer holding the decryption key to decrypt and verify. + pub fn get_encrypted_data(env: Env, data_type: Symbol, key: Symbol) -> Vec { + env.storage() + .persistent() + .get(&StorageKey::EncryptedDataPoints(data_type, key)) + .unwrap_or_else(|| Vec::new(&env)) + } + + // ── Verification ───────────────────────────────────────────────────────── + + /// Evaluate whether the aggregated oracle value satisfies a trigger condition. + /// + /// This is the single entry point the Claims Processor uses to decide + /// whether a policy should payout. The function reads the aggregated value + /// for the requested `(data_type, key)` pair and compares it to the + /// configured threshold using the requested comparison operator. + /// + /// The comparison is performed on the same fixed-point numeric units used + /// for oracle submissions, so the threshold and observed value should be + /// expressed in the same precision. `LessThan`, `GreaterThan`, and `Equal` + /// apply the standard relational comparison directly, while + /// `EqualWithTolerance` treats the condition as satisfied when the + /// absolute difference between the aggregated value and the threshold is + /// less than or equal to the supplied tolerance. + /// + /// The return value is `true` when the condition is met and `false` + /// otherwise. + pub fn verify_trigger( + env: Env, + data_type: Symbol, + key: Symbol, + condition: TriggerCondition, + ) -> bool { + // Enforce minimum oracle participation before aggregation so a single + // oracle cannot unilaterally determine the outcome. + let min_count = Self::effective_min_oracle_count(&env, &data_type); + let points: Vec = env + .storage() + .persistent() + .get(&StorageKey::DataPoints(data_type.clone(), key.clone())) + .unwrap_or_else(|| panic_with_error!(&env, Error::NoDataAvailable)); + let max_data_age = Self::effective_max_age(&env, &data_type); + let now = env.ledger().timestamp(); + let min_confidence: u32 = env + .storage() + .instance() + .get(&StorageKey::MinConfidence) + .unwrap_or(0); + let mut eligible_count = 0u32; + for i in 0..points.len() { + let p = points.get_unchecked(i); + if now.saturating_sub(p.timestamp) <= max_data_age && p.confidence >= min_confidence { + let oracle_key = StorageKey::Oracle(data_type.clone(), p.oracle.clone()); + if let Some(entry) = env.storage().persistent().get::<_, OracleEntry>(&oracle_key) { + if entry.active { + eligible_count += 1; + } + } + } + } + if eligible_count < min_count { + panic_with_error!(&env, Error::NoDataAvailable); + } + + let median = Self::get_median_value(&env, &data_type, &key); + let result = match condition.comparison { + TriggerComparison::LessThan => median < condition.threshold, + TriggerComparison::GreaterThan => median > condition.threshold, + TriggerComparison::Equal => median == condition.threshold, + TriggerComparison::EqualWithTolerance => { + let diff = median.saturating_sub(condition.threshold); + diff.abs() <= condition.tolerance + } + }; + + // Update reputation for all contributing oracles + let points: Vec = env + .storage() + .persistent() + .get(&StorageKey::DataPoints(data_type.clone(), key.clone())) + .unwrap_or_else(|| Vec::new(&env)); + + let max_data_age = Self::effective_max_age(&env, &data_type); + let now = env.ledger().timestamp(); + let min_confidence: u32 = env + .storage() + .instance() + .get(&StorageKey::MinConfidence) + .unwrap_or(0); + + let tolerance = condition.tolerance; + for i in 0..points.len() { + let p = points.get_unchecked(i); + if now.saturating_sub(p.timestamp) <= max_data_age && p.confidence >= min_confidence { + let oracle_key = StorageKey::Oracle(data_type.clone(), p.oracle.clone()); + if let Some(entry) = env.storage().persistent().get::<_, OracleEntry>(&oracle_key) { + if entry.active { + // Oracle is accurate if its value is within tolerance of the median + let diff = (p.value - median).abs(); + let accurate = diff <= tolerance; + Self::update_reputation(&env, &p.oracle, &data_type, accurate); + } + } + } + } + + result + } + + /// Like `verify_trigger` but also returns the aggregated oracle data — + /// including the aggregated `confidence` score — alongside the boolean + /// verdict. A caller can thus act on the trigger result and reason about how + /// trustworthy that result is in a single call, instead of having to call + /// `get_aggregated` separately to obtain the confidence (issue #388). + pub fn verify_trigger_with_confidence( + env: Env, + data_type: Symbol, + key: Symbol, + condition: TriggerCondition, + ) -> (bool, AggregatedData) { + let agg = Self::get_aggregated(env.clone(), data_type.clone(), key.clone()); + let median = agg.median_value; + let result = match condition.comparison { + TriggerComparison::LessThan => median < condition.threshold, + TriggerComparison::GreaterThan => median > condition.threshold, + TriggerComparison::Equal => median == condition.threshold, + TriggerComparison::EqualWithTolerance => { + let diff = median.saturating_sub(condition.threshold); + diff.abs() <= condition.tolerance + } + }; + (result, agg) + } + + /// Get weighted confidence score taking oracle reputation into account. + /// Confidence scores from higher-reputation oracles contribute more weight + /// to the final confidence value. + pub fn reputation_weighted_confidence( + env: Env, + data_type: Symbol, + key: Symbol, + ) -> u32 { + let points: Vec = env + .storage() + .persistent() + .get(&StorageKey::DataPoints(data_type.clone(), key.clone())) + .unwrap_or_else(|| panic_with_error!(&env, Error::NoDataAvailable)); + if points.is_empty() { + panic_with_error!(&env, Error::NoDataAvailable); + } + + let max_data_age = Self::effective_max_age(&env, &data_type); + let now = env.ledger().timestamp(); + let min_confidence: u32 = env + .storage() + .instance() + .get(&StorageKey::MinConfidence) + .unwrap_or(0); + + let mut weighted_sum: u64 = 0; + let mut total_weight: u64 = 0; + + for i in 0..points.len().min(100) { + let p = points.get_unchecked(i); + if now.saturating_sub(p.timestamp) <= max_data_age && p.confidence >= min_confidence { + let oracle_key = StorageKey::Oracle(data_type.clone(), p.oracle.clone()); + if let Some(entry) = env + .storage() + .persistent() + .get::<_, OracleEntry>(&oracle_key) + { + if entry.active { + // Use effective weight which factors in oracle reputation + let effective_weight = Self::get_effective_weight(env.clone(), p.oracle.clone(), data_type.clone()); + weighted_sum = weighted_sum.saturating_add((p.confidence as u64) * (effective_weight as u64)); + total_weight = total_weight.saturating_add(effective_weight as u64); + } + } + } + } + + if total_weight == 0 { + return 0; + } + + ((weighted_sum / total_weight) as u32).min(1000) + } + + /// Return the most recent submission from any oracle for (data_type, key). + /// Panics with NoDataAvailable if no submissions exist. + pub fn get_data(env: Env, data_type: Symbol, key: Symbol) -> OracleDataPoint { + let points: Vec = env + .storage() + .persistent() + .get(&StorageKey::DataPoints(data_type.clone(), key)) + .unwrap_or_else(|| panic_with_error!(&env, Error::NoDataAvailable)); + if points.is_empty() { + panic_with_error!(&env, Error::NoDataAvailable); + } + // Return the most recently timestamped submission + let mut latest = points.get_unchecked(0); + for i in 1..points.len() { + let p = points.get_unchecked(i); + if p.timestamp > latest.timestamp { + latest = p; + } + } + let max_data_age = Self::effective_max_age(&env, &data_type); + let now = env.ledger().timestamp(); + if now.saturating_sub(latest.timestamp) > max_data_age { + panic_with_error!(&env, Error::NoDataAvailable); + } + latest + } + + /// Return aggregated statistics across all oracle submissions for (data_type, key). + pub fn get_aggregated(env: Env, data_type: Symbol, key: Symbol) -> AggregatedData { + let points: Vec = env + .storage() + .persistent() + .get(&StorageKey::DataPoints(data_type.clone(), key.clone())) + .unwrap_or_else(|| panic_with_error!(&env, Error::NoDataAvailable)); + if points.is_empty() { + panic_with_error!(&env, Error::NoDataAvailable); + } + + let max_data_age = Self::effective_max_age(&env, &data_type); + let now = env.ledger().timestamp(); + let min_confidence: u32 = env + .storage() + .instance() + .get(&StorageKey::MinConfidence) + .unwrap_or(0); + + let mut values = [(0i128, 0u32); 100]; + let mut timestamps = [0u64; 100]; + let mut total_weight: u32 = 0; + let mut n = 0; + + let mut oracle_count = 0u32; + let mut min_confidence_val = 100u32; + let mut weighted_confidence_sum: u128 = 0; + let mut total_weight_sum: u128 = 0; + let mut last_updated = 0u64; + + let points_cap = points.len().min(MAX_DATA_POINTS); + for i in 0..points_cap { + let p = points.get_unchecked(i); + let oracle_key = StorageKey::Oracle(data_type.clone(), p.oracle.clone()); + if let Some(entry) = env + .storage() + .persistent() + .get::<_, OracleEntry>(&oracle_key) + { + if entry.active { + oracle_count += 1; + min_confidence_val = min_confidence_val.min(p.confidence); + last_updated = last_updated.max(p.timestamp); + weighted_confidence_sum += (p.confidence as u128) * (entry.weight as u128); + total_weight_sum += entry.weight as u128; + + if now.saturating_sub(p.timestamp) <= max_data_age && p.confidence >= min_confidence { + if n >= 100 { + panic_with_error!(&env, Error::TooManyOracles); + } + values[n] = (p.value, entry.weight); + timestamps[n] = p.timestamp; + n += 1; + total_weight += entry.weight; + } + } + } + } + + n = Self::filter_outliers(&env, &data_type, &mut values, &mut timestamps, n); + total_weight = values[0..n].iter().map(|&(_, wt)| wt).sum(); + + let min_oracle_count: u32 = env + .storage() + .instance() + .get(&StorageKey::MinOracleCount) + .unwrap_or(1); + if (n as u32) < min_oracle_count || n == 0 || total_weight == 0 { + panic_with_error!(&env, Error::NoDataAvailable); + } + + // Aggregate through the same dispatcher `verify_trigger` uses, so the + // value reported here can never disagree with the value a claim is + // actually evaluated against. This previously computed its own median + // inline, which silently ignored the configured aggregation method. + let active_values = &mut values[0..n]; + let active_timestamps = ×tamps[0..n]; + let median_value = match Self::effective_aggregation_method(&env, &data_type) { + AggregationMethod::WeightedMedian => Self::weighted_median(active_values, total_weight), + AggregationMethod::WeightedAverage => { + Self::weighted_average(active_values, total_weight) + } + AggregationMethod::Mean => Self::mean(active_values), + AggregationMethod::TimeWeightedAverage => { + Self::time_weighted_average(&env, active_values, active_timestamps) + } + }; + + let confidence = match weighted_confidence_sum.checked_div(total_weight_sum) { + Some(c) => u32::try_from(c).unwrap_or(u32::MAX), + None => 0u32, + }; + + let oracle_list: Vec
= env + .storage() + .instance() + .get(&StorageKey::OracleList(data_type)) + .unwrap_or_else(|| Vec::new(&env)); + let active_oracle_count: u32 = oracle_list.len(); + + // Calculate 95% confidence interval based on value spread and sample size + let (ci_lower, ci_upper) = Self::calculate_confidence_interval(&mut values[0..n], total_weight, median_value); + + AggregatedData { + median_value, + oracle_count, + active_oracle_count, + confidence, + min_confidence: min_confidence_val, + last_updated, + confidence_interval_lower: ci_lower, + confidence_interval_upper: ci_upper, + } + } + + /// Like `verify_trigger` but panics with `StaleData` if the newest submission + /// is older than `max_age_seconds`. Use this in parametric claim paths that + /// require fresh oracle data. + pub fn verify_trigger_fresh( + env: Env, + data_type: Symbol, + key: Symbol, + condition: TriggerCondition, + max_age_seconds: u64, + ) -> bool { + let dp_key = StorageKey::DataPoints(data_type.clone(), key.clone()); + let points: Vec = env + .storage() + .persistent() + .get(&dp_key) + .unwrap_or_else(|| panic_with_error!(&env, Error::NoDataAvailable)); + if points.is_empty() { + panic_with_error!(&env, Error::NoDataAvailable); + } + + // Enforce minimum oracle participation before aggregation so a single + // oracle cannot unilaterally determine the outcome. + let min_count = Self::effective_min_oracle_count(&env, &data_type); + let now = env.ledger().timestamp(); + let max_data_age = Self::effective_max_age(&env, &data_type); + let min_confidence_val: u32 = env + .storage() + .instance() + .get(&StorageKey::MinConfidence) + .unwrap_or(0); + let mut eligible_count = 0u32; + for i in 0..points.len() { + let p = points.get_unchecked(i); + if now.saturating_sub(p.timestamp) <= max_data_age && p.confidence >= min_confidence_val { + let oracle_key = StorageKey::Oracle(data_type.clone(), p.oracle.clone()); + if let Some(entry) = env.storage().persistent().get::<_, OracleEntry>(&oracle_key) { + if entry.active { + eligible_count += 1; + } + } + } + } + if eligible_count < min_count { + panic_with_error!(&env, Error::NoDataAvailable); + } + + let mut latest_ts = 0u64; + for i in 0..points.len() { + let ts = points.get_unchecked(i).timestamp; + if ts > latest_ts { + latest_ts = ts; + } + } + if now.saturating_sub(latest_ts) > max_age_seconds { + panic_with_error!(&env, Error::StaleData); + } + + let median = Self::get_median_value(&env, &data_type, &key); + let result = match condition.comparison { + TriggerComparison::LessThan => median < condition.threshold, + TriggerComparison::GreaterThan => median > condition.threshold, + TriggerComparison::Equal => median == condition.threshold, + TriggerComparison::EqualWithTolerance => { + let diff = median.saturating_sub(condition.threshold); + diff.abs() <= condition.tolerance + } + }; + + // Emit event for verification result to enable monitoring and auditing + env.events().publish( + (Symbol::new(&env, "verification_result"),), + (data_type.clone(), key, result, median, condition.threshold), + ); + + // Update reputation for all contributing oracles + let max_data_age = Self::effective_max_age(&env, &data_type); + let min_confidence: u32 = env + .storage() + .instance() + .get(&StorageKey::MinConfidence) + .unwrap_or(0); + + let tolerance = condition.tolerance; + for i in 0..points.len() { + let p = points.get_unchecked(i); + if now.saturating_sub(p.timestamp) <= max_data_age && p.confidence >= min_confidence { + let oracle_key = StorageKey::Oracle(data_type.clone(), p.oracle.clone()); + if let Some(entry) = env.storage().persistent().get::<_, OracleEntry>(&oracle_key) { + if entry.active { + let diff = (p.value - median).abs(); + let accurate = diff <= tolerance; + Self::update_reputation(&env, &p.oracle, &data_type, accurate); + } + } + } + } + + result + } + + /// Submit data for multiple keys in one call. + /// Each tuple: (key, value, confidence, timestamp). + pub fn batch_submit_data( + env: Env, + oracle: Address, + data_type: Symbol, + submissions: Vec<(Symbol, i128, u32, u64)>, + ) { + oracle.require_auth(); + if Self::encryption_required(&env, &data_type) { + panic_with_error!(&env, Error::EncryptionRequiredForType); + } + let oracle_key = StorageKey::Oracle(data_type.clone(), oracle.clone()); + let entry: OracleEntry = env + .storage() + .persistent() + .get(&oracle_key) + .unwrap_or_else(|| panic_with_error!(&env, Error::OracleNotRegistered)); + if !entry.active { + panic_with_error!(&env, Error::Unauthorized); + } + // Keep the registration alive alongside the readings it authorized. + env.storage() + .persistent() + .extend_ttl(&oracle_key, TTL_THRESHOLD, TTL_EXTEND_TO); + + Self::enforce_rate_limit(&env, &data_type, &oracle, env.ledger().timestamp()); + + for i in 0..submissions.len() { + let (key, value, confidence, timestamp) = submissions.get_unchecked(i); + if confidence == 0 || confidence > 100 { + panic_with_error!(&env, Error::InvalidConfidence); + } + + let now = env.ledger().timestamp(); + let future_buffer: u64 = env + .storage() + .instance() + .get(&StorageKey::TimestampFutureBuffer) + .unwrap_or(60); + if timestamp > now.saturating_add(future_buffer) { + panic_with_error!(&env, Error::InvalidTimestamp); + } + let max_timestamp_age: u64 = env + .storage() + .instance() + .get(&StorageKey::MaxTimestampAge) + .unwrap_or(90 * 24 * 60 * 60); + if timestamp < now.saturating_sub(max_timestamp_age) { + panic_with_error!(&env, Error::InvalidTimestamp); + } + + let dp_key = StorageKey::DataPoints(data_type.clone(), key.clone()); + let mut points: Vec = env + .storage() + .persistent() + .get(&dp_key) + .unwrap_or_else(|| Vec::new(&env)); + let new_point = OracleDataPoint { + oracle: oracle.clone(), + value, + confidence, + timestamp, + }; + let mut found = false; + for j in 0..points.len() { + if points.get_unchecked(j).oracle == oracle { + points.set(j, new_point.clone()); + found = true; + break; + } + } + if !found { + points.push_back(new_point); + } + env.storage().persistent().set(&dp_key, &points); + env.storage().persistent().extend_ttl(&dp_key, TTL_THRESHOLD, TTL_EXTEND_TO); + + env.events().publish( + (Symbol::new(&env, "oracle_data_submitted"),), + OracleDataSubmitted { + oracle: oracle.clone(), + data_type: data_type.clone(), + key, + value, + confidence, + timestamp, + }, + ); + } + } + + /// Submit multiple data readings in a single invocation — avoids the cost + /// and latency of calling `submit_data` once per key. All readings share + /// the same `oracle` and `data_type`; each carries its own key, value, + /// confidence, and timestamp. Every reading is validated and persisted + /// atomically within the single transaction. + pub fn submit_data_batch( + env: Env, + oracle: Address, + data_type: Symbol, + submissions: Vec, + ) { + oracle.require_auth(); + if Self::encryption_required(&env, &data_type) { + panic_with_error!(&env, Error::EncryptionRequiredForType); + } + + let oracle_key = StorageKey::Oracle(data_type.clone(), oracle.clone()); + let entry: OracleEntry = env + .storage() + .persistent() + .get(&oracle_key) + .unwrap_or_else(|| panic_with_error!(&env, Error::OracleNotRegistered)); + if !entry.active { + panic_with_error!(&env, Error::Unauthorized); + } + // Keep the registration alive alongside the readings it authorized. + env.storage() + .persistent() + .extend_ttl(&oracle_key, TTL_THRESHOLD, TTL_EXTEND_TO); + + Self::enforce_rate_limit(&env, &data_type, &oracle, env.ledger().timestamp()); + + for i in 0..submissions.len() { + let sub = submissions.get_unchecked(i); + if sub.confidence == 0 || sub.confidence > 100 { + panic_with_error!(&env, Error::InvalidConfidence); + } + let now = env.ledger().timestamp(); + let future_buffer: u64 = env + .storage() + .instance() + .get(&StorageKey::TimestampFutureBuffer) + .unwrap_or(60); + if sub.timestamp > now.saturating_add(future_buffer) { + panic_with_error!(&env, Error::InvalidTimestamp); + } + let max_timestamp_age: u64 = env + .storage() + .instance() + .get(&StorageKey::MaxTimestampAge) + .unwrap_or(90 * 24 * 60 * 60); + if sub.timestamp < now.saturating_sub(max_timestamp_age) { + panic_with_error!(&env, Error::InvalidTimestamp); + } + let dp_key = StorageKey::DataPoints(data_type.clone(), sub.key.clone()); + let mut points: Vec = env + .storage() + .persistent() + .get(&dp_key) + .unwrap_or_else(|| Vec::new(&env)); + let new_point = OracleDataPoint { + oracle: oracle.clone(), + value: sub.value, + confidence: sub.confidence, + timestamp: sub.timestamp, + }; + let mut found = false; + for j in 0..points.len() { + if points.get_unchecked(j).oracle == oracle { + points.set(j, new_point.clone()); + found = true; + break; + } + } + if !found { + points.push_back(new_point); + } + env.storage().persistent().set(&dp_key, &points); + env.storage().persistent().extend_ttl(&dp_key, TTL_THRESHOLD, TTL_EXTEND_TO); + + env.events().publish( + (Symbol::new(&env, "oracle_data_submitted"),), + OracleDataSubmitted { + oracle: oracle.clone(), + data_type: data_type.clone(), + key: sub.key, + value: sub.value, + confidence: sub.confidence, + timestamp: sub.timestamp, + }, + ); + } + } + + /// Upgrade the contract WASM in-place. Only the admin may call this. + /// Storage is preserved across upgrades; only the execution code changes. + /// + /// If a guardian threshold > 0 is configured (`set_guardians`), this call + /// does not upgrade immediately — it registers the upgrade as pending and + /// requires `threshold` guardians to call `approve_upgrade` before the + /// WASM is actually replaced, guarding this irreversible operation + /// against a single compromised admin key. + pub fn upgrade(env: Env, admin: Address, new_wasm_hash: BytesN<32>) { + Self::require_admin(&env, &admin); + + let threshold: u32 = env + .storage() + .instance() + .get(&StorageKey::GuardianThreshold) + .unwrap_or(0); + if threshold == 0 { + env.deployer().update_current_contract_wasm(new_wasm_hash); + return; + } + + let pending = PendingUpgrade { + new_wasm_hash, + approvals: Vec::new(&env), + }; + env.storage().instance().set(&StorageKey::PendingUpgrade, &pending); + } + + /// List all registered oracle addresses for a specific data_type. + pub fn get_oracles(env: Env, data_type: Symbol) -> Vec
{ + env.storage() + .instance() + .get(&StorageKey::OracleList(data_type)) + .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) + .unwrap_or_else(|| panic_with_error!(&env, Error::NotInitialized)) + } + + // ── Oracle Reputation ──────────────────────────────────────────────────── + + /// Get the reputation score for an oracle. + pub fn get_reputation(env: Env, oracle: Address, data_type: Symbol) -> OracleReputation { + let key = StorageKey::Reputation(data_type.clone(), oracle.clone()); + env.storage().persistent().get(&key).unwrap_or(OracleReputation { + oracle, + data_type, + total_submissions: 0, + accurate_submissions: 0, + score: 500, // Default 50% score for new oracles + last_updated: 0, + }) + } + + /// Update oracle reputation based on whether submission was accurate. + /// Called internally after aggregation to track oracle performance. + fn update_reputation(env: &Env, oracle: &Address, data_type: &Symbol, accurate: bool) { + let key = StorageKey::Reputation(data_type.clone(), oracle.clone()); + let mut rep: OracleReputation = env.storage().persistent().get(&key).unwrap_or(OracleReputation { + oracle: oracle.clone(), + data_type: data_type.clone(), + total_submissions: 0, + accurate_submissions: 0, + score: 500, + last_updated: 0, + }); + + rep.total_submissions += 1; + if accurate { + rep.accurate_submissions += 1; + } + // Calculate score as basis points (0-1000) + if rep.total_submissions > 0 { + rep.score = ((rep.accurate_submissions * 1000) / rep.total_submissions) as u32; + } + rep.last_updated = env.ledger().timestamp(); + + env.storage().persistent().set(&key, &rep); + env.storage().persistent().extend_ttl(&key, TTL_THRESHOLD, TTL_EXTEND_TO); + + env.events().publish( + (Symbol::new(env, "reputation_updated"),), + ReputationUpdated { + oracle: oracle.clone(), + data_type: data_type.clone(), + score: rep.score, + accurate: rep.accurate_submissions, + total: rep.total_submissions, + }, + ); + } + + /// Get the effective weight of an oracle, adjusted by reputation. + /// Oracles with higher reputation get higher effective weights. + pub fn get_effective_weight(env: Env, oracle: Address, data_type: Symbol) -> u32 { + let oracle_key = StorageKey::Oracle(data_type.clone(), oracle.clone()); + let entry: OracleEntry = env + .storage() + .persistent() + .get(&oracle_key) + .unwrap_or_else(|| panic_with_error!(&env, Error::OracleNotRegistered)); + + let rep = Self::get_reputation(env.clone(), oracle.clone(), data_type); + // Effective weight = base weight * (score / 1000), minimum 1 + let effective = (entry.weight as u64 * rep.score as u64) / 1000; + core::cmp::max(effective as u32, 1) + } + + // ── Internal helpers ───────────────────────────────────────────────────── + + /// Validate that an address has a valid Stellar format (56-char, starts with G or C). + fn validate_stellar_address(env: &Env, address: &Address) { + let addr_str = address.to_string(); + + if addr_str.len() != 56 { + panic_with_error!(env, Error::InvalidAddress); + } + + let mut buf = [0u8; 56]; + addr_str.copy_into_slice(&mut buf); + + if buf[0] != b'G' && buf[0] != b'C' { + panic_with_error!(env, Error::InvalidAddress); + } + } + + /// Enforce the per-oracle submission cooldown for `data_type`: panics with + /// `RateLimited` if `oracle` submitted for this data_type more recently + /// than `get_min_submit_interval()` seconds ago, otherwise records `now` + /// as the new last-submission time. Call once per submission entry point + /// (submit_data / submit_data_batch / batch_submit_data), not per reading, + /// so a single call with many keys pays the check once. + fn enforce_rate_limit(env: &Env, data_type: &Symbol, oracle: &Address, now: u64) { + let min_interval = Self::get_min_submit_interval(env.clone()); + let key = StorageKey::LastSubmission(data_type.clone(), oracle.clone()); + if let Some(last) = env.storage().persistent().get::<_, u64>(&key) { + if now.saturating_sub(last) < min_interval { + panic_with_error!(env, Error::RateLimited); + } + } + env.storage().persistent().set(&key, &now); + env.storage() + .persistent() + .extend_ttl(&key, TTL_THRESHOLD, TTL_EXTEND_TO); + } + + fn require_admin(env: &Env, caller: &Address) { + 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); + } + caller.require_auth(); + } + + /// Whether `data_type` currently requires `submit_encrypted_data` instead + /// of plaintext submission. Off by default. + fn encryption_required(env: &Env, data_type: &Symbol) -> bool { + env.storage() + .instance() + .get(&StorageKey::EncryptionRequired(data_type.clone())) + .unwrap_or(false) + } + + /// The max data age that applies to a data type: the per-type override + /// when set, otherwise the global value (default 7 days). + fn effective_max_age(env: &Env, data_type: &Symbol) -> u64 { + env.storage() + .instance() + .get(&StorageKey::DataTypeMaxAge(data_type.clone())) + .unwrap_or_else(|| { + env.storage() + .instance() + .get(&StorageKey::MaxDataAge) + .unwrap_or(604_800) + }) + } + + /// The aggregation method configured for a data type. Weighted median is + /// the default because it is the only one a minority of bad oracles + /// cannot move. + fn effective_aggregation_method(env: &Env, data_type: &Symbol) -> AggregationMethod { + env.storage() + .instance() + .get(&StorageKey::AggregationMethod(data_type.clone())) + .unwrap_or(AggregationMethod::WeightedMedian) + } + + /// The outlier-rejection config for a data type, or disabled with the + /// compiled-in defaults if never configured. + fn effective_outlier_config(env: &Env, data_type: &Symbol) -> OutlierConfig { + env.storage() + .instance() + .get(&StorageKey::OutlierConfig(data_type.clone())) + .unwrap_or(OutlierConfig { + enabled: false, + threshold_bps: DEFAULT_OUTLIER_THRESHOLD_BPS, + min_sample_size: DEFAULT_OUTLIER_MIN_SAMPLE_SIZE, + }) + } + + /// Drop statistical outliers from a batch of collected oracle values + /// ahead of aggregation, using a MAD-based (median absolute deviation) + /// modified z-score (issue #383). `values`/`timestamps` are the same + /// parallel, stack-allocated buffers the aggregation methods consume; + /// only the first `n` entries of each are live. + /// + /// A no-op when outlier detection is disabled for `data_type` or below + /// `min_sample_size` live entries. Never drops enough points to leave + /// fewer than `min_sample_size` behind — the worst offenders are + /// removed first, so a data type in genuine turmoil still yields some + /// aggregate rather than an empty set. + /// + /// Returns the number of live entries remaining at the front of each + /// buffer; relative order among the survivors is otherwise preserved, + /// which does not matter since every aggregation method sorts (or sums + /// order-independently) afterward. + fn filter_outliers( + env: &Env, + data_type: &Symbol, + values: &mut [(i128, u32)], + timestamps: &mut [u64], + n: usize, + ) -> usize { + let config = Self::effective_outlier_config(env, data_type); + if !config.enabled || n < 3 || n < config.min_sample_size as usize { + return n; + } + + let mut sorted_vals = [0i128; 100]; + for i in 0..n { + sorted_vals[i] = values[i].0; + } + let sorted_vals = &mut sorted_vals[0..n]; + sorted_vals.sort_unstable(); + let median = if n % 2 == 1 { + sorted_vals[n / 2] + } else { + (sorted_vals[n / 2 - 1] + sorted_vals[n / 2]) / 2 + }; + + let mut deviations = [0i128; 100]; + for i in 0..n { + deviations[i] = (values[i].0 - median).abs(); + } + let dev_slice = &mut deviations[0..n]; + dev_slice.sort_unstable(); + let mad = if n % 2 == 1 { + dev_slice[n / 2] + } else { + (dev_slice[n / 2 - 1] + dev_slice[n / 2]) / 2 + }; + // Deliberately no `mad == 0` short-circuit: a MAD of 0 means at + // least half the live submissions agree exactly, which is the + // clearest possible signal, not a reason to abstain. With + // threshold * mad == 0, any submission that differs from the + // median at all is flagged below — exactly right when the + // majority is unanimous and one value is clearly not. + + let threshold = config.threshold_bps as i128; + let min_keep = (config.min_sample_size as usize).max(1); + + // Rank live entries by deviation, worst first, so trimming toward + // `min_keep` always drops the most extreme values. + let mut order = [0usize; 100]; + for i in 0..n { + order[i] = i; + } + let order_slice = &mut order[0..n]; + order_slice.sort_unstable_by_key(|&i| core::cmp::Reverse((values[i].0 - median).abs())); + + let mut is_outlier = [false; 100]; + let mut flagged = 0usize; + for &i in order_slice.iter() { + if n - flagged <= min_keep { + break; + } + let deviation = (values[i].0 - median).abs(); + if deviation.saturating_mul(10_000) > threshold.saturating_mul(mad) { + is_outlier[i] = true; + flagged += 1; + } else { + // Sorted by deviation descending — once one entry is inside + // the threshold, every entry after it is too. + break; + } + } + if flagged == 0 { + return n; + } + + let mut write = 0usize; + for read in 0..n { + if !is_outlier[read] { + values[write] = values[read]; + timestamps[write] = timestamps[read]; + write += 1; + } + } + write + } + + /// The effective minimum oracle count for a data type: the per-type + /// override when set, otherwise the global value (default 1). + fn effective_min_oracle_count(env: &Env, data_type: &Symbol) -> u32 { + env.storage() + .instance() + .get(&StorageKey::DataTypeMinOracleCount(data_type.clone())) + .unwrap_or_else(|| { + env.storage() + .instance() + .get(&StorageKey::MinOracleCount) + .unwrap_or(1) + }) + } + + /// 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 + .storage() + .persistent() + .get(&StorageKey::DataPoints(data_type.clone(), key.clone())) + .unwrap_or_else(|| panic_with_error!(env, Error::NoDataAvailable)); + if points.is_empty() { + panic_with_error!(env, Error::NoDataAvailable); + } + + let max_data_age = Self::effective_max_age(env, data_type); + let now = env.ledger().timestamp(); + let min_confidence: u32 = env + .storage() + .instance() + .get(&StorageKey::MinConfidence) + .unwrap_or(0); + + // Collect values and weights on the stack (capped by MAX_DATA_POINTS) + let mut values = [(0i128, 0u32); 100]; + let mut timestamps = [0u64; 100]; + let mut total_weight: u32 = 0; + let mut n = 0; + + let points_cap = points.len().min(MAX_DATA_POINTS); + for i in 0..points_cap { + let p = points.get_unchecked(i); + if now.saturating_sub(p.timestamp) <= max_data_age && p.confidence >= min_confidence { + let oracle_key = StorageKey::Oracle(data_type.clone(), p.oracle.clone()); + if let Some(entry) = env + .storage() + .persistent() + .get::<_, OracleEntry>(&oracle_key) + { + if entry.active { + if n >= 100 { + panic_with_error!(env, Error::TooManyOracles); + } + // Use effective weight which factors in oracle reputation + let effective_weight = Self::get_effective_weight(env.clone(), p.oracle.clone(), data_type.clone()); + values[n] = (p.value, effective_weight); + timestamps[n] = p.timestamp; + n += 1; + total_weight += entry.weight; + } + } + } + } + + n = Self::filter_outliers(env, data_type, &mut values, &mut timestamps, n); + total_weight = values[0..n].iter().map(|&(_, wt)| wt).sum(); + + let min_oracle_count: u32 = env + .storage() + .instance() + .get(&StorageKey::MinOracleCount) + .unwrap_or(1); + if (n as u32) < min_oracle_count || n == 0 || total_weight == 0 { + panic_with_error!(env, Error::NoDataAvailable); + } + + let active_values = &mut values[0..n]; + let active_timestamps = ×tamps[0..n]; + + match Self::effective_aggregation_method(env, data_type) { + AggregationMethod::WeightedMedian => Self::weighted_median(active_values, total_weight), + AggregationMethod::WeightedAverage => Self::weighted_average(active_values, total_weight), + AggregationMethod::Mean => Self::mean(active_values), + AggregationMethod::TimeWeightedAverage => { + Self::time_weighted_average(env, active_values, active_timestamps) + } + } + } + + /// The value at the middle of cumulative weight. + /// + /// Because it picks an actual reported value rather than blending them, a + /// minority of oracles cannot move the result no matter how extreme their + /// submissions are. That property is why this is the default. + fn weighted_median(values: &mut [(i128, u32)], total_weight: u32) -> i128 { + // Native sort on the stack slice: O(N log N) + values.sort_unstable_by_key(|&(val, _)| val); + let n = values.len(); + + let half = total_weight / 2; + let mut cumulative = 0; + for i in 0..n { + let (val, wt) = values[i]; + cumulative += wt; + if cumulative > half { + return val; + } else if cumulative == half && total_weight.is_multiple_of(2) { + if i + 1 < n { + return (val + values[i + 1].0) / 2; + } else { + return val; + } + } + } + + values[n - 1].0 + } + + /// `sum(value * weight) / sum(weight)`, rounded toward zero. + /// + /// Every submission contributes in proportion to its registered weight, so + /// small genuine variations show up instead of being discarded. The + /// trade-off is the one the median avoids: a single extreme value drags + /// the result, in proportion to that oracle's weight. + /// + /// The accumulator is i128 and the caller has already capped the input at + /// `MAX_DATA_POINTS` (100) with weights bounded to 100, so the running sum + /// cannot overflow for any value an oracle can submit. `saturating_*` is + /// used regardless rather than trusting that reasoning to hold if those + /// bounds ever change. + fn weighted_average(values: &[(i128, u32)], total_weight: u32) -> i128 { + let mut weighted_sum: i128 = 0; + for &(val, wt) in values.iter() { + weighted_sum = weighted_sum.saturating_add(val.saturating_mul(wt as i128)); + } + weighted_sum / (total_weight as i128) + } + + /// Unweighted arithmetic mean — every valid submission counts equally. + /// + /// Use when registered weights carry no meaning for a data type and + /// treating them as significance would be misleading. + fn mean(values: &[(i128, u32)]) -> i128 { + let mut sum: i128 = 0; + for &(val, _) in values.iter() { + sum = sum.saturating_add(val); + } + sum / (values.len() as i128) + } + + /// Oracle-weight- and time-weighted average. + /// + /// Oracles only ever submit point-in-time observations — there is no + /// interval or duration attached to a single reading. TWA reconstructs + /// a time-weighted average from those snapshots: sorted by timestamp, + /// each value is treated as holding from its own timestamp until the + /// next (later) submission's timestamp, or until "now" for the newest + /// one, and contributes to the average in proportion to that duration + /// times its oracle's registered weight. A plain average (weighted or + /// not) would let a burst of submissions clustered in a short window + /// dominate the result as heavily as one that held for hours; TWA + /// corrects for that by weighting on how long each value actually + /// prevailed. + /// + /// `values` and `timestamps` are parallel slices (same length, same + /// index refers to the same submission) — callers build them from the + /// same loop that already collects `(value, weight)` pairs for the + /// other aggregation methods. + fn time_weighted_average(env: &Env, values: &[(i128, u32)], timestamps: &[u64]) -> i128 { + let n = values.len(); + if n == 1 { + return values[0].0; + } + + // Pair each (value, weight) with its timestamp and sort by time — + // `values`/`timestamps` are capped at MAX_DATA_POINTS (100), so + // this stays on the stack like the other aggregation methods. + let mut by_time = [(0i128, 0u32, 0u64); 100]; + for i in 0..n { + let (val, wt) = values[i]; + by_time[i] = (val, wt, timestamps[i]); + } + let active = &mut by_time[0..n]; + active.sort_unstable_by_key(|&(_, _, ts)| ts); + + let now = env.ledger().timestamp(); + let mut weighted_sum: i128 = 0; + let mut total_weighted_duration: i128 = 0; + for i in 0..n { + let (val, wt, ts) = active[i]; + let end = if i + 1 < n { active[i + 1].2 } else { now.max(ts) }; + // A submission sharing its timestamp with the next (or with + // "now") held for zero observed seconds; floor at 1 second so + // it still contributes rather than vanishing from the average. + let duration = end.saturating_sub(ts).max(1) as i128; + let weighted_duration = duration.saturating_mul(wt as i128); + weighted_sum = weighted_sum.saturating_add(val.saturating_mul(weighted_duration)); + total_weighted_duration = total_weighted_duration.saturating_add(weighted_duration); + } + + if total_weighted_duration == 0 { + return active[n - 1].0; + } + weighted_sum / total_weighted_duration + } + + /// Calculate a 95% confidence interval for the aggregated value. + /// Uses the spread of values and the sample size to estimate reliability. + /// Returns (lower_bound, upper_bound) in 7-decimal fixed point. + fn calculate_confidence_interval( + values: &mut [(i128, u32)], + total_weight: u32, + median_value: i128, + ) -> (i128, i128) { + if values.is_empty() || total_weight == 0 { + return (median_value, median_value); + } + + // Calculate standard deviation from the median (robust measure of spread) + let n = values.len(); + let mut sum_sq_deviations: i128 = 0; + + for (val, _wt) in values.iter() { + let deviation = val.saturating_sub(median_value); + let sq_deviation = deviation.saturating_mul(deviation); + sum_sq_deviations = sum_sq_deviations.saturating_add(sq_deviation); + } + + // Avoid division by zero + if sum_sq_deviations == 0 || n <= 1 { + // No spread: return tight confidence interval around median + return (median_value, median_value); + } + + // Calculate variance (mean squared deviation) + let variance = sum_sq_deviations / (n as i128); + + // Approximate standard deviation using integer square root + // For 95% CI with normal distribution: use ~2.0 standard errors + // Standard error = stddev / sqrt(n) + // So margin = 2.0 * sqrt(variance) / sqrt(n) ≈ 2.0 * sqrt(variance/n) + + // Simplified: margin = 2 * sqrt(variance / n) + // For fixed-point arithmetic, we compute this conservatively + let margin = if n < 10 { + // Wider margin for small sample sizes + variance / 2 + } else if n < 50 { + // Medium confidence for moderate samples + variance / 4 + } else { + // Narrower margin for large samples + variance / 8 + }; + + let lower = median_value.saturating_sub(margin); + let upper = median_value.saturating_add(margin); + + (lower, upper) + } +} + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod test; +#[cfg(test)] +mod test_advanced; diff --git a/contracts/oracle-verifier/src/test_advanced.rs b/contracts/oracle-verifier/src/test_advanced.rs index 361b1af..acb1707 100644 --- a/contracts/oracle-verifier/src/test_advanced.rs +++ b/contracts/oracle-verifier/src/test_advanced.rs @@ -752,3 +752,116 @@ fn test_set_data_type_max_age_requires_admin() { let impostor = Address::generate(&env); client.set_data_type_max_age(&impostor, &wt(), &600u64); } + +// ── outlier detection (issue #383) ────────────────────────────────────────── + +#[test] +fn outlier_config_disabled_by_default() { + let (env, _admin, cid) = setup(); + let c = OracleVerifierClient::new(&env, &cid); + let cfg = c.get_outlier_config(&wt()); + assert!(!cfg.enabled); + assert_eq!(cfg.threshold_bps, DEFAULT_OUTLIER_THRESHOLD_BPS); + assert_eq!(cfg.min_sample_size, DEFAULT_OUTLIER_MIN_SAMPLE_SIZE); +} + +#[test] +#[should_panic(expected = "Error(Contract, #27)")] +fn set_outlier_config_rejects_zero_threshold() { + let (env, admin, cid) = setup(); + let c = OracleVerifierClient::new(&env, &cid); + c.set_outlier_config(&admin, &wt(), &true, &0u32, &4u32); +} + +#[test] +#[should_panic(expected = "Error(Contract, #27)")] +fn set_outlier_config_rejects_tiny_sample_size() { + let (env, admin, cid) = setup(); + let c = OracleVerifierClient::new(&env, &cid); + c.set_outlier_config(&admin, &wt(), &true, &50_000u32, &2u32); +} + +/// Five oracles, four in tight agreement and one reporting a value 1000x +/// larger. With outlier detection off, `Mean` (which has no built-in +/// resistance to outliers the way `WeightedMedian` does) is dragged far +/// above what every well-behaved oracle actually reported. +#[test] +fn mean_without_outlier_filtering_is_skewed_by_one_bad_oracle() { + let (env, admin, cid) = setup(); + let c = OracleVerifierClient::new(&env, &cid); + c.set_aggregation_method(&admin, &wt(), &AggregationMethod::Mean); + + let oracles: std::vec::Vec
= (0..5).map(|_| Address::generate(&env)).collect(); + for o in oracles.iter() { + c.add_oracle(&admin, o, &wt(), &50u32); + } + + env.ledger().set_timestamp(1); + let good = 100_0000000i128; + let bad = 100_000_0000000i128; // 1000x the honest value + for o in oracles.iter().take(4) { + c.submit_data(o, &wt(), &kk(), &good, &90u32, &1u64); + } + c.submit_data(&oracles[4], &wt(), &kk(), &bad, &90u32, &1u64); + + let agg = c.get_aggregated(&wt(), &kk()); + // (4*100 + 100_000) / 5 = 20_080 — nowhere near the honest value of 100. + assert!(agg.median_value > good * 100); +} + +/// Same five submissions as above, but with outlier detection enabled for +/// the data type. The lone extreme submission is dropped before +/// aggregation, so `Mean` reflects only the four honest oracles. +#[test] +fn outlier_filtering_protects_mean_from_one_bad_oracle() { + let (env, admin, cid) = setup(); + let c = OracleVerifierClient::new(&env, &cid); + c.set_aggregation_method(&admin, &wt(), &AggregationMethod::Mean); + c.set_outlier_config(&admin, &wt(), &true, &DEFAULT_OUTLIER_THRESHOLD_BPS, &DEFAULT_OUTLIER_MIN_SAMPLE_SIZE); + + let oracles: std::vec::Vec
= (0..5).map(|_| Address::generate(&env)).collect(); + for o in oracles.iter() { + c.add_oracle(&admin, o, &wt(), &50u32); + } + + env.ledger().set_timestamp(1); + let good = 100_0000000i128; + let bad = 100_000_0000000i128; + for o in oracles.iter().take(4) { + c.submit_data(o, &wt(), &kk(), &good, &90u32, &1u64); + } + c.submit_data(&oracles[4], &wt(), &kk(), &bad, &90u32, &1u64); + + let agg = c.get_aggregated(&wt(), &kk()); + assert_eq!(agg.median_value, good); +} + +/// Outlier detection never removes enough entries to drop below +/// `min_sample_size` — with exactly four submissions and a `min_sample_size` +/// of 4, the arrangement has no slack to trim, so even a wild outlier +/// survives into the aggregate untouched. +#[test] +fn outlier_filtering_never_drops_below_min_sample_size() { + let (env, admin, cid) = setup(); + let c = OracleVerifierClient::new(&env, &cid); + c.set_aggregation_method(&admin, &wt(), &AggregationMethod::Mean); + c.set_outlier_config(&admin, &wt(), &true, &DEFAULT_OUTLIER_THRESHOLD_BPS, &4u32); + + let oracles: std::vec::Vec
= (0..4).map(|_| Address::generate(&env)).collect(); + for o in oracles.iter() { + c.add_oracle(&admin, o, &wt(), &50u32); + } + + env.ledger().set_timestamp(1); + let good = 100_0000000i128; + let bad = 100_000_0000000i128; + for o in oracles.iter().take(3) { + c.submit_data(o, &wt(), &kk(), &good, &90u32, &1u64); + } + c.submit_data(&oracles[3], &wt(), &kk(), &bad, &90u32, &1u64); + + let agg = c.get_aggregated(&wt(), &kk()); + // All 4 submissions survive (below the slack needed to trim), so the + // outlier still drags the mean up. + assert!(agg.median_value > good * 100); +} diff --git a/contracts/oracle-verifier/src/types.rs b/contracts/oracle-verifier/src/types.rs index cdf77bd..6ec7c58 100644 --- a/contracts/oracle-verifier/src/types.rs +++ b/contracts/oracle-verifier/src/types.rs @@ -1,169 +1,593 @@ -use soroban_sdk::{contracttype, Address, Symbol}; - -/// How the trigger threshold is compared against the observed value. -#[contracttype] -#[derive(Clone, Debug, Eq, PartialEq)] -pub enum TriggerComparison { - /// Trigger fires when observed < threshold (drought: rainfall < 50mm) - LessThan, - /// Trigger fires when observed > threshold (storm: wind_speed > 120 km/h) - GreaterThan, - /// Trigger fires when observed == threshold (binary events) - Equal, - /// Matches if |median - threshold| <= tolerance - EqualWithTolerance, -} - -/// A trigger condition evaluated by the Claims Processor. -#[contracttype] -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct TriggerCondition { - /// Oracle data category: "weather", "flight", "disaster", "onchain" - pub data_type: Symbol, - /// Specific key: "rainfall:kisumu:2026-06", "flight:KQ100:2026-06-15" - pub key: Symbol, - /// Threshold in 7-decimal fixed point - pub threshold: i128, - pub comparison: TriggerComparison, - /// The maximum acceptable absolute variance. - /// Set to 0 if utilizing standard LessThan/GreaterThan/Equal. - pub tolerance: i128, -} - -/// Input struct for a single reading inside a bulk `submit_data_batch` call. -#[contracttype] -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct OracleDataSubmission { - /// Specific measurement key — e.g., `symbol_short!("kis2606")` - pub key: Symbol, - /// Observed value in 7-decimal fixed point - pub value: i128, - /// Reliability score 0-100 - pub confidence: u32, - /// Unix timestamp of the real-world observation - pub timestamp: u64, -} - -/// A single data submission from one oracle. -#[contracttype] -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct OracleDataPoint { - pub oracle: Address, - /// Observed value in 7-decimal fixed point (e.g., 32_000_000 = 32.0000000 mm) - pub value: i128, - /// Reliability score 0-100 - pub confidence: u32, - /// Unix timestamp of the real-world observation - pub timestamp: u64, -} - -/// Aggregated statistics across all oracles for a key. -#[contracttype] -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct AggregatedData { - pub median_value: i128, - /// Number of data submissions currently stored for this (data_type, key) — - /// NOT the number of currently-registered active oracles for the - /// data_type (see `active_oracle_count`). Kept for backward - /// compatibility with existing callers of this field. - pub oracle_count: u32, - /// Number of oracles currently registered and active for this - /// data_type, independent of whether they have submitted data for - /// this specific key. Use this (not `oracle_count`) as a proxy for - /// oracle diversity/registration health — `oracle_count` conflates - /// submission count with registration count (issue #136). - pub active_oracle_count: u32, - /// Aggregated confidence is the weighted average of valid oracle confidences, - /// weighted by each oracle's configured registration weight and rounded down. - pub confidence: u32, - pub min_confidence: u32, - pub last_updated: u64, -} - -/// Registered oracle record. -#[contracttype] -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct OracleEntry { - pub oracle: Address, - pub data_type: Symbol, - /// Relative weight for future weighted-median (1-100) - pub weight: u32, - pub active: bool, -} - -/// Summary returned by get_oracle_health for a specific oracle. -#[contracttype] -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct OracleHealth { - pub oracle: Address, - pub data_type: Symbol, - pub weight: u32, - pub active: bool, - /// Timestamp of their most recent submission, or 0 if never. - pub last_submitted: u64, -} - -// ─── Events ────────────────────────────────────────────────────────────────── - -#[contracttype] -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct Initialized { - pub admin: Address, -} - -#[contracttype] -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct OracleAdded { - pub oracle: Address, - pub data_type: Symbol, - pub weight: u32, -} - -#[contracttype] -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct OracleRemoved { - pub oracle: Address, - pub data_type: Symbol, -} - -#[contracttype] -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct MinConfidenceUpdated { - pub threshold: u32, -} - -#[contracttype] -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct MaxDataAgeUpdated { - pub max_age: u64, -} - -#[contracttype] -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct MinOracleCountUpdated { - pub min_count: u32, -} - -#[contracttype] -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct OracleDataSubmitted { - pub oracle: Address, - pub data_type: Symbol, - pub key: Symbol, - pub value: i128, - pub confidence: u32, - pub timestamp: u64, -} - -#[contracttype] -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct AdminUpdated { - pub new_admin: Address, -} - -#[contracttype] -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct ContractUpgraded { - pub old_version: u32, - pub new_version: u32, -} +use soroban_sdk::{contracttype, Address, Bytes, BytesN, Symbol, Vec}; + +/// Reputation score for an oracle, tracking accuracy over time. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct OracleReputation { + pub oracle: Address, + pub data_type: Symbol, + /// Total number of submissions made by this oracle + pub total_submissions: u64, + /// Number of submissions that were accurate (within tolerance of median) + pub accurate_submissions: u64, + /// Reputation score 0-1000 (basis points of accuracy) + pub score: u32, + /// Timestamp of last reputation update + pub last_updated: u64, +} + +// ─── Events ────────────────────────────────────────────────────────────────── + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ReputationUpdated { + pub oracle: Address, + pub data_type: Symbol, + pub score: u32, + pub accurate: u64, + pub total: u64, +} + +/// How multiple oracle submissions are combined into a single consensus value. +/// +/// The right choice depends on the threat model for a data type. Median +/// resists a minority of outliers completely — one oracle reporting an absurd +/// value cannot move it — which is what you want for adversarial or +/// error-prone feeds. A weighted average uses every submission in proportion +/// to its registered weight, which tracks small genuine variations more +/// smoothly but lets a single extreme value drag the result. A time-weighted +/// average additionally accounts for how long each submission held before +/// being superseded, so a feed with irregular submission cadence isn't +/// skewed by a burst of readings clustered in a short window. +#[contracttype] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum AggregationMethod { + /// Weight-aware median: the value at the middle of cumulative weight. + /// A minority of outliers cannot move it. This is the default. + WeightedMedian, + /// Weighted arithmetic mean: `sum(value * weight) / sum(weight)`. + /// Smooth, but one extreme submission moves the result. + WeightedAverage, + /// Unweighted arithmetic mean: every valid submission counts equally. + /// Use when registered weights are not meaningful for this data type. + Mean, + /// Oracle-weight- and time-weighted average: each eligible submission + /// is weighted by its registered oracle weight *and* by how many + /// seconds it was the most recent reading for its oracle — from its + /// own timestamp until the next later submission's timestamp (or + /// "now" for the newest one). Only point-in-time values are ever + /// submitted; this reconstructs a time-weighted average (TWAP-style) + /// over the observation window from those snapshots, rather than + /// treating every snapshot as equally significant regardless of how + /// long it held. + TimeWeightedAverage, +} + +/// How the trigger threshold is compared against the observed value. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum TriggerComparison { + /// Trigger fires when observed < threshold (drought: rainfall < 50mm) + LessThan, + /// Trigger fires when observed > threshold (storm: wind_speed > 120 km/h) + GreaterThan, + /// Trigger fires when observed == threshold (binary events) + Equal, + /// Matches if |median - threshold| <= tolerance + EqualWithTolerance, +} + +/// A trigger condition evaluated by the Claims Processor. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TriggerCondition { + /// Oracle data category: "weather", "flight", "disaster", "onchain" + pub data_type: Symbol, + /// Specific key: "rainfall:kisumu:2026-06", "flight:KQ100:2026-06-15" + pub key: Symbol, + /// Threshold in 7-decimal fixed point + pub threshold: i128, + pub comparison: TriggerComparison, + /// The maximum acceptable absolute variance. + /// Set to 0 if utilizing standard LessThan/GreaterThan/Equal. + pub tolerance: i128, +} + +/// Input struct for a single reading inside a bulk `submit_data_batch` call. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct OracleDataSubmission { + /// Specific measurement key — e.g., `symbol_short!("kis2606")` + pub key: Symbol, + /// Observed value in 7-decimal fixed point + pub value: i128, + /// Reliability score 0-100 + pub confidence: u32, + /// Unix timestamp of the real-world observation + pub timestamp: u64, +} + +/// A single data submission from one oracle. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct OracleDataPoint { + pub oracle: Address, + /// Observed value in 7-decimal fixed point (e.g., 32_000_000 = 32.0000000 mm) + pub value: i128, + /// Reliability score 0-100 + pub confidence: u32, + /// Unix timestamp of the real-world observation + pub timestamp: u64, +} + +/// Aggregated statistics across all oracles for a key. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct AggregatedData { + pub median_value: i128, + /// Number of data submissions currently stored for this (data_type, key) — + /// NOT the number of currently-registered active oracles for the + /// data_type (see `active_oracle_count`). Kept for backward + /// compatibility with existing callers of this field. + pub oracle_count: u32, + /// Number of oracles currently registered and active for this + /// data_type, independent of whether they have submitted data for + /// this specific key. Use this (not `oracle_count`) as a proxy for + /// oracle diversity/registration health — `oracle_count` conflates + /// submission count with registration count (issue #136). + pub active_oracle_count: u32, + /// Aggregated confidence is the weighted average of valid oracle confidences, + /// weighted by each oracle's configured registration weight and rounded down. + pub confidence: u32, + pub min_confidence: u32, + pub last_updated: u64, + /// Lower bound of the 95% confidence interval (in 7-decimal fixed point). + /// Helps users assess result reliability. Calculated from value spread and oracle count. + pub confidence_interval_lower: i128, + /// Upper bound of the 95% confidence interval (in 7-decimal fixed point). + /// Helps users assess result reliability. Calculated from value spread and oracle count. + pub confidence_interval_upper: i128, +} + +/// Registered oracle record. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct OracleEntry { + pub oracle: Address, + pub data_type: Symbol, + /// Relative weight for future weighted-median (1-100) + pub weight: u32, + pub active: bool, +} + +/// Summary returned by get_oracle_health for a specific oracle. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct OracleHealth { + pub oracle: Address, + pub data_type: Symbol, + pub weight: u32, + pub active: bool, + /// Timestamp of their most recent submission, or 0 if never. + pub last_submitted: u64, +} + +// ─── Events ────────────────────────────────────────────────────────────────── + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Initialized { + pub admin: Address, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct OracleAdded { + pub oracle: Address, + pub data_type: Symbol, + pub weight: u32, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct OracleRemoved { + pub oracle: Address, + pub data_type: Symbol, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MinConfidenceUpdated { + pub threshold: u32, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MaxDataAgeUpdated { + pub max_age: u64, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct DataTypeMaxAgeUpdated { + pub data_type: Symbol, + /// `None` is encoded as 0, meaning the override was cleared. + pub max_age: u64, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct AggregationMethodUpdated { + pub data_type: Symbol, + pub method: AggregationMethod, +} + +/// Freshness report for a `(data_type, key)` pair. +/// +/// Answers "can this data be used right now?" without panicking, so a caller +/// can check before committing to a claim evaluation that would otherwise +/// abort the whole transaction. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct FreshnessReport { + pub data_type: Symbol, + pub key: Symbol, + /// True when at least `min_oracle_count` submissions are within the + /// effective max age for this data type. + pub is_fresh: bool, + /// Age in seconds of the newest submission. `u64::MAX` when there are no + /// submissions at all. + pub newest_age: u64, + /// Number of submissions still inside the freshness window. + pub fresh_count: u32, + /// Number of submissions held for this key, fresh or not. + pub total_count: u32, + /// The max age applied — the per-data-type override when set, else global. + pub max_age: u64, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MinOracleCountUpdated { + pub min_count: u32, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct GeoWeightUpdated { + pub oracle: Address, + pub data_type: Symbol, + pub region: Symbol, + pub geo_weight_bps: u32, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct DataTypeMinOracleCountUpdated { + pub data_type: Symbol, + pub min_count: u32, +} + +/// Per-product consensus threshold configuration. +/// Allows specifying minimum oracle agreement levels per data type/product. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ConsensusThreshold { + pub data_type: Symbol, + /// Minimum number of agreeing oracles required for consensus (basis points). + /// 10000 = unanimous, 5000 = majority, etc. + pub agreement_threshold_bps: u32, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MinSubmitIntervalUpdated { + pub seconds: u64, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ConsensusThresholdUpdated { + pub data_type: Symbol, + pub agreement_threshold_bps: u32, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct OracleDataSubmitted { + pub oracle: Address, + pub data_type: Symbol, + pub key: Symbol, + pub value: i128, + pub confidence: u32, + pub timestamp: u64, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct AdminUpdated { + pub new_admin: Address, +} + + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ContractUpgraded { + pub old_version: u32, + pub new_version: u32, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct StakeDeposited { + pub oracle: Address, + pub data_type: Symbol, + pub amount: i128, + pub total_stake: i128, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct StakeWithdrawn { + pub oracle: Address, + pub data_type: Symbol, + pub amount: i128, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct OracleSlashed { + pub oracle: Address, + pub data_type: Symbol, + pub amount: i128, + pub remaining_stake: i128, + pub reason: Symbol, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MinStakeUpdated { + pub min_stake: i128, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct StakeTokenUpdated { + pub token: Address, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct GuardiansUpdated { + pub guardians: Vec
, + pub threshold: u32, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct UpgradeApproved { + pub new_wasm_hash: BytesN<32>, + pub approver: Address, + pub approvals: u32, + pub threshold: u32, +} + +/// A pending contract-upgrade action awaiting guardian approvals. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PendingUpgrade { + pub new_wasm_hash: BytesN<32>, + pub approvals: Vec
, +} + +/// A pending admin-transfer proposal awaiting guardian approvals. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PendingAdminChange { + pub new_admin: Address, + pub approvals: Vec
, +} + +/// An encrypted data submission from one oracle. +/// +/// `ciphertext`/`nonce` are opaque to the contract — decryption happens +/// entirely off-chain by whichever parties hold the key for this data_type. +/// Kept in a separate storage path from `OracleDataPoint` so a consumer can +/// never accidentally treat ciphertext as a usable plaintext value. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct EncryptedOracleDataPoint { + pub oracle: Address, + pub ciphertext: Bytes, + /// Nonce/IV used for this submission's encryption. 12 bytes fits the + /// common AEAD schemes (e.g. AES-GCM, ChaCha20-Poly1305) off-chain + /// consumers are expected to use. + pub nonce: BytesN<12>, + /// Reliability score 0-100, same meaning as on a plaintext submission. + pub confidence: u32, + pub timestamp: u64, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct EncryptionRequiredUpdated { + pub data_type: Symbol, + pub required: bool, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MaxTimestampAgeUpdated { + pub max_timestamp_age: u64, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct OracleEncryptedDataSubmitted { + pub oracle: Address, + pub data_type: Symbol, + pub key: Symbol, + pub confidence: u32, + pub timestamp: u64, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TimestampFutureBufferUpdated { + pub seconds: u64, +} + +/// Outlier-rejection settings applied before aggregation for a data type. +/// +/// Filtering uses a MAD-based (median absolute deviation) modified +/// z-score: robust to the same kind of extreme-minority manipulation the +/// submissions themselves represent, unlike a mean/standard-deviation +/// approach whose own spread is skewed by the very outlier it is trying to +/// catch. +#[contracttype] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct OutlierConfig { + /// Off by default — an existing data type's aggregation does not + /// change until an admin opts it in. + pub enabled: bool, + /// Rejection threshold in basis points of the MAD: a submission is + /// flagged once `|value - median| * 10_000 > threshold_bps * MAD`. + pub threshold_bps: u32, + /// Filtering is skipped below this many eligible submissions, and never + /// removes enough points to leave fewer than this many behind. + pub min_sample_size: u32, +} + +/// A cross-validation rule between two oracle data types. +/// +/// Ensures that submitted data for `source_type` is consistent with +/// `target_type` within `max_variance`. For example, rainfall and +/// temperature data from the same region should not diverge wildly. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CrossValidationRule { + pub source_type: Symbol, + pub target_type: Symbol, + /// Maximum allowed absolute variance between aggregated values (fixed-point). + pub max_variance: i128, + /// Description of the rule for auditability. + pub description: Bytes, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct OutlierConfigUpdated { + pub data_type: Symbol, + pub enabled: bool, + pub threshold_bps: u32, + pub min_sample_size: u32, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CrossValidationRuleAdded { + pub source_type: Symbol, + pub target_type: Symbol, + pub max_variance: i128, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CrossValidationRuleRemoved { + pub source_type: Symbol, + pub target_type: Symbol, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CrossValidationFailed { + pub source_type: Symbol, + pub source_value: i128, + pub target_type: Symbol, + pub target_value: i128, + pub variance: i128, + pub max_variance: i128, +} + +/// Staleness report for oracle data across all submissions for a key. +/// +/// Unlike `FreshnessReport` which checks individual submissions, this +/// aggregates staleness across the entire data set to give a holistic +/// view of whether the data is safe to use for decision-making. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct StalenessReport { + pub data_type: Symbol, + pub key: Symbol, + /// Whether the data is considered stale overall. + pub is_stale: bool, + /// Age in seconds of the oldest fresh submission. + pub oldest_fresh_age: u64, + /// Age in seconds of the newest submission. + pub newest_age: u64, + /// Number of submissions that are stale (older than max_age). + pub stale_count: u32, + /// Total submissions for this key. + pub total_count: u32, + /// The max age threshold used (per-type or global). + pub max_age: u64, + /// Percentage of submissions that are fresh (0-10000 basis points). + pub freshness_ratio_bps: u32, +} + +/// Temporal decay configuration for oracle submissions. +/// Applies exponential decay to older submissions, giving more weight to recent data. +/// Prevents stale oracles from permanently distorting aggregation as data ages. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TemporalDecayConfig { + pub data_type: Symbol, + /// Whether temporal decay weighting is enabled for this data type. + pub enabled: bool, + /// Half-life in seconds: age at which submission weight drops to 50%. + /// For example: 86400 = 1 day, 3600 = 1 hour. + pub half_life_seconds: u64, + /// Minimum weight floor (basis points). Submissions never drop below this. + /// 0 = no floor, 1000 = 10% minimum weight. + pub min_weight_bps: u32, + /// Maximum weight ceiling (basis points) for the newest submissions. + /// 10000 = no ceiling, newest always get full weight. + pub max_weight_bps: u32, +} + +/// Emitted when stale oracle data is detected during submission or verification. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct StaleDataDetected { + pub data_type: Symbol, + pub key: Symbol, + pub stale_count: u32, + pub total_count: u32, + pub oldest_age: u64, + pub max_age: u64, +} + + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TemporalDecayConfigUpdated { + pub data_type: Symbol, + pub enabled: bool, + pub half_life_seconds: u64, + pub min_weight_bps: u32, + pub max_weight_bps: u32, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct AggregatedDataWeighted { + pub data_type: Symbol, + pub key: Symbol, + pub median_value: i128, + /// Whether temporal decay weighting was applied. + pub temporal_decay_applied: bool, + /// Average age in seconds of submissions included in aggregation. + pub average_submission_age: u64, + /// Total decay factor applied (0-10000 basis points). + pub decay_factor_bps: u32, +} diff --git a/contracts/risk-pool/src/lib.rs b/contracts/risk-pool/src/lib.rs index d44120a..24d1504 100644 --- a/contracts/risk-pool/src/lib.rs +++ b/contracts/risk-pool/src/lib.rs @@ -1,1329 +1,2703 @@ -//! Parashield Risk Pool -//! -//! Liquidity providers deposit USDC into category-specific risk pools. -//! Pool-share tokens represent proportional ownership. -//! -//! Economics -//! ────────── -//! - Premium flow: 80% pool yield, 10% protocol treasury, 10% backstop fund -//! - Claims flow: coverage settled from pool balance; LP share value decreases -//! - Utilization rate = total_locked / total_deposited -//! - Target APY: 8-40% depending on risk category -//! -//! v2 — full implementation; Risk Pool is now deployable and testable. -#![no_std] -extern crate alloc; - -use soroban_sdk::{ - 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 _: () = assert!(PREMIUM_LP_BPS + PREMIUM_TREAS_BPS + PREMIUM_BACKSTOP_BPS == 10_000); - -/// Upper bound on cumulative deposits (7-decimal USDC stroops). -/// 10^15 stroops == 100,000,000 USDC. Caps total pool size so share value -/// cannot become infinitesimal and total_shares cannot overflow. -const MAX_TOTAL_DEPOSITED: i128 = 1_000_000_000_000_000; - -/// Minimum deposit amount (1_000_000 stroops). -const MIN_DEPOSIT: i128 = 1_000_000; - -/// Timelock duration for admin withdrawals: 7 days in seconds. -const TIMELOCK_SECONDS: u64 = 7 * 24 * 60 * 60; - -/// Extend a persistent entry's TTL once it has fewer than ~30 days of life left -/// (at ~5s/ledger). -const TTL_THRESHOLD: u32 = 518_400; -/// Extend persistent entries out to ~1 year (at ~5s/ledger) so capital locks -/// backing long-dated policies don't expire from storage before maturity. -const TTL_EXTEND_TO: u32 = 6_312_000; - -#[contracttype] -enum StorageKey { - Initialized, - Admin, - Treasury, - Backstop, - UsdcToken, - Category, - TotalDeposited, - TotalLocked, - TotalShares, - AccumulatedPremium, - AccumulatedBackstop, - AccumulatedPerShare, - Status, - LpPosition(Address), - LpCount, - LpAddress(u32), - Lock(u128), - AdminWithdrawalRequest, - PendingAdmin, - PolicyEngine, - ClaimsProcessor, - /// Contract version (u32) for storage migration tracking - Version, -} - -#[contracterror] -#[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, - Undercollateralized = 11, - PoolCapExceeded = 12, - InvalidToken = 13, - TimelockPending = 14, - TimelockNotReady = 15, - NoPendingWithdrawal = 16, - InsufficientShares = 17, - DepositTooSmall = 18, - Overflow = 19, -} - -#[contract] -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. - pub fn initialize( - env: Env, - admin: Address, - usdc_token: Address, - treasury: Address, - backstop: Address, - category: Symbol, - policy_engine: Address, - claims_processor: Address, - ) { - if env.storage().instance().has(&StorageKey::Initialized) { - panic_with_error!(&env, Error::AlreadyInitialized); - } - // 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 { - panic!("invalid address: admin must be an account or contract address"); - } - let mut admin_buf = [0u8; 56]; - admin_str.copy_into_slice(&mut admin_buf); - if admin_buf[0] != b'G' && admin_buf[0] != b'C' { - panic!("invalid address: admin must be an account or contract address"); - } - - let usdc_str = usdc_token.to_string(); - - let balance_res = env.try_invoke_contract::( - &usdc_token, - &Symbol::new(&env, "balance"), - soroban_sdk::vec![&env, env.current_contract_address().to_val()], - ); - if balance_res.is_err() { - panic_with_error!(&env, Error::InvalidToken); - } - - if usdc_str.len() != 56 { - panic!("invalid address: usdc_token must be a contract address"); - } - let mut usdc_buf = [0u8; 56]; - usdc_str.copy_into_slice(&mut usdc_buf); - if usdc_buf[0] != b'C' { - panic!("invalid address: usdc_token must be a contract address"); - } - - let treasury_str = treasury.to_string(); - if treasury_str.len() != 56 { - panic!("invalid address: treasury must be a contract address"); - } - let mut treasury_buf = [0u8; 56]; - treasury_str.copy_into_slice(&mut treasury_buf); - if treasury_buf[0] != b'C' { - panic!("invalid address: treasury must be a contract address"); - } - - 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); - // 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(), - claims_processor: claims_processor.clone(), - }, - ); - } - - // ── Deposits ────────────────────────────────────────────────────────────── - - /// Deposit USDC into the pool and receive LP shares. Returns the number of shares minted. - /// `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); - } - 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); - - // Enforce the global pool size cap before accepting new liquidity. - if total_deposited + amount > MAX_TOTAL_DEPOSITED { - panic_with_error!(&env, Error::PoolCapExceeded); - } - - let new_shares = if total_deposited == 0 { - amount * 1_000_000_000 // 1 share = 1 USDC * 1e9 precision - } else { - amount - .checked_mul(total_shares) - .and_then(|v| v.checked_div(total_deposited)) - .unwrap_or_else(|| panic_with_error!(&env, Error::Overflow)) - }; - - if new_shares == 0 { - panic_with_error!(&env, Error::ZeroAmount); - } - - if new_shares < min_shares { - 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 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, - } - } - }; - 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.events().publish( - (Symbol::new(&env, "deposit"), provider.clone()), - (amount, new_shares), - ); - - new_shares - } - - /// Burn `shares` and return the proportional USDC to `provider`. Returns the USDC amount - /// transferred. Panics with `Undercollateralized` if the available (unlocked) liquidity - /// is insufficient to cover the redemption. - 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); - } - Self::assert_active(&env); - - 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, "withdraw"), provider.clone()), - (amount, shares), - ); - - 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; - 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, - ); - - 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)); - - 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 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), - ); - } - - 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"),), - PremiumDistributed { - amount, - lp_share, - treasury_share: treas_share, - backstop_share, - }, - ); - } - - /// Send a specified amount of USDC from the pool to the treasury address. - /// 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(); - let treasury: Address = env.storage().instance().get(&StorageKey::Treasury).unwrap(); - token::Client::new(&env, &usdc).transfer( - &env.current_contract_address(), - &treasury, - &amount, - ); - env.events().publish( - (Symbol::new(&env, "treasury_funded"),), - TreasuryFunded { - amount, - recipient: treasury, - }, - ); - } - - /// Send a specified amount of USDC from the pool to the backstop address. - /// 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(); - let backstop: Address = env.storage().instance().get(&StorageKey::Backstop).unwrap(); - token::Client::new(&env, &usdc).transfer( - &env.current_contract_address(), - &backstop, - &amount, - ); - env.events().publish( - (Symbol::new(&env, "backstop_funded"),), - BackstopFunded { - amount, - recipient: backstop, - }, - ); - } - - /// Returns the configured backstop address. - pub fn get_backstop(env: Env) -> Address { - env.storage() - .instance() - .get(&StorageKey::Backstop) - .unwrap_or_else(|| panic_with_error!(env, Error::NotInitialized)) - } - - /// Collect accumulated premium yield for `provider` and transfer it in USDC. - /// Returns the amount claimed. No-op (returns 0) if no yield has accrued. - 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() - .get(&lp_key) - .unwrap_or_else(|| panic_with_error!(&env, Error::NoShares)); - - let old_claimed = position.yield_claimed; - Self::internal_claim_yield(&env, &mut position); - let claimed = position.yield_claimed - old_claimed; - - if claimed > 0 { - env.storage().persistent().set(&lp_key, &position); - } - - claimed - } - - // ── Capital locks ───────────────────────────────────────────────────────── - - /// Earmark `amount` USDC as collateral for `policy_id`. Only the policy engine or - /// claims processor may call this. Panics if the pool is under-collateralised or if - /// a lock for this policy already exists. - pub fn lock_for_policy(env: Env, caller: Address, policy_id: u128, amount: i128) { - 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); - } - - 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)); - - 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() - .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); - } - - 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.events().publish( - (Symbol::new(&env, "capital_released"),), - CapitalReleased { - policy_id, - amount: lock.amount, - }, - ); - } - - /// Release the capital lock for `policy_id` when the policy expires without a payout. - /// 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() - .get(&StorageKey::Lock(policy_id)) - .unwrap_or_else(|| panic_with_error!(&env, Error::LockNotFound)); - 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)), - ); - } - - // ── Queries ─────────────────────────────────────────────────────────────── - - /// 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), - } - } - - /// 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)) - } - - /// 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) - .and_then(|v| v.checked_div(deposited)) - .unwrap_or(0); - // Saturate to u32::MAX if the result exceeds u32 range - if util_bps > u32::MAX as i128 { - u32::MAX - } else { - util_bps as u32 - } - } - - /// 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) - .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) - } - - /// 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) - } - - /// 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 offset_val = offset.unwrap_or(0); - let limit_val = core::cmp::min(limit.unwrap_or(100), 500); - - let mut paginated = Vec::new(&env); - 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() - .get::<_, Address>(&StorageKey::LpAddress(i)) - { - if let Some(position) = env - .storage() - .persistent() - .get::<_, LpPosition>(&StorageKey::LpPosition(addr.clone())) - { - if position.shares > 0 { - paginated.push_back(addr); - } - } - } - } - } - - PaginatedLps { - lps: paginated, - total_count, - } - } - - /// 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); - deposited.saturating_sub(locked) - } - - // ── Admin ───────────────────────────────────────────────────────────────── - // - // Admin Powers: - // - `pause` / `resume` — halt or resume deposits (no effect on withdrawals) - // - `lock_for_policy` — earmark capital for an active policy - // - `release_for_claim` / `release_for_expiry` — unlock earmarked capital - // - `request_admin_withdrawal` / `execute_admin_withdrawal` — - // 7-day-timelocked emergency withdrawal of unlocked liquidity - // - `cancel_admin_withdrawal` — abort a pending withdrawal - // - // Admin Limitations: - // - Admin CANNOT withdraw LP shares directly. Only the LP who owns - // shares may call `withdraw()` (gated by `provider.require_auth()`). - // - Admin CANNOT transfer pool USDC to any address except via the - // 7-day timelock path, and only to the treasury address. - // - Admin CANNOT modify LP positions, shares, or yield entitlements. - // - // Timelock Policy: - // Any withdrawal of pool funds by the admin requires a 7-day waiting - // period so LPs have time to exit if they disagree with the decision. - - /// 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.events().publish( - (Symbol::new(&env, "pool_paused"),), - 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.events().publish( - (Symbol::new(&env, "pool_resumed"),), - PoolResumed { - admin: admin.clone(), - }, - ); - } - - /// Request an emergency withdrawal of unlocked liquidity. - /// A 7-day timelock begins; LPs can exit before it matures. - /// 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); - } - - let available = Self::get_available_liquidity(env.clone()); - if amount > available { - panic_with_error!(&env, Error::Undercollateralized); - } - - if env - .storage() - .persistent() - .has(&StorageKey::AdminWithdrawalRequest) - { - panic_with_error!(&env, Error::TimelockPending); - } - - let now = env.ledger().timestamp(); - env.storage().persistent().set( - &StorageKey::AdminWithdrawalRequest, - &AdminWithdrawalRequest { - amount, - requested_at: now, - executed: false, - }, - ); - - env.events().publish( - (Symbol::new(&env, "admin_withdrawal_scheduled"),), - AdminWithdrawalScheduled { - admin: admin.clone(), - amount, - execute_after: now + TIMELOCK_SECONDS, - }, - ); - } - - /// Execute a previously requested admin withdrawal after the 7-day timelock. - /// 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() - .get(&StorageKey::AdminWithdrawalRequest) - .unwrap_or_else(|| panic_with_error!(&env, Error::NoPendingWithdrawal)); - - 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 treasury: Address = env.storage().instance().get(&StorageKey::Treasury).unwrap(); - 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.events().publish( - (Symbol::new(&env, "admin_withdrawal_executed"),), - AdminWithdrawalExecuted { - admin: admin.clone(), - amount: req.amount, - }, - ); - } - - /// 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) - { - panic_with_error!(&env, Error::NoPendingWithdrawal); - } - env.storage() - .persistent() - .remove(&StorageKey::AdminWithdrawalRequest); - - env.events().publish( - (Symbol::new(&env, "admin_withdrawal_cancelled"),), - 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, - ) { - Self::require_admin(&env, &admin); - 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); - - // Perform the actual WASM upgrade - env.deployer().update_current_contract_wasm(new_wasm_hash); - - env.events().publish( - (Symbol::new(&env, "contract_upgraded"),), - ContractUpgraded { - old_version: current_version, - new_version, - }, - ); - } - - /// 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); } - } - - /// 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 (zero address means no proposal) - 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 admin != pending_admin { - panic_with_error!(&env, Error::Unauthorized); - } - admin.require_auth(); - // 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 }, - ); - } - - /// 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) - .unwrap_or_else(|| panic_with_error!(env, Error::NotInitialized)); - 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) - .unwrap_or_else(|| panic_with_error!(env, Error::NotInitialized)); - if *caller != admin && *caller != pe && *caller != cp { - panic_with_error!(env, Error::Unauthorized); - } - caller.require_auth(); - } - - fn require_admin(env: &Env, caller: &Address) { - 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); - } - 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); - } - } - - 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); - if total_shares == 0 { - return; - } - - 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, - ); - - position.yield_claimed += claimable; - position.last_yield_claim = env.ledger().timestamp(); - position.yield_debt = entitled; - - env.events().publish( - (Symbol::new(env, "yield_claimed"),), - YieldClaimed { - provider: position.provider.clone(), - amount: claimable, - }, - ); - } - } -} - -#[cfg(test)] -mod test; -#[cfg(test)] -mod test_advanced; -#[cfg(test)] -mod test_edge; +//! Parashield Risk Pool +//! +//! Liquidity providers deposit USDC into category-specific risk pools. +//! Pool-share tokens represent proportional ownership. +//! +//! Economics +//! ────────── +//! - Premium flow: 80% pool yield, 10% protocol treasury, 10% backstop fund +//! - Claims flow: coverage settled from pool balance; LP share value decreases +//! - Utilization rate = total_locked / total_deposited +//! - Target APY: 8-40% depending on risk category +//! +//! v2 — full implementation; Risk Pool is now deployable and testable. +#![no_std] +extern crate alloc; +use alloc::string::ToString; + +use soroban_sdk::{ + contract, contractimpl, contracttype, contracterror, panic_with_error, + token, Address, Env, Symbol, Vec, +}; + +pub mod types; +pub use types::*; + +/// Cross-contract interface for an external reinsurer this pool cedes +/// catastrophic risk to. The reinsurer is any contract implementing this +/// single entry point — how it sources the payout (its own reserves, +/// retrocession, etc.) is entirely its own concern. +#[soroban_sdk::contractclient(name = "ReinsurerClient")] +trait IReinsurer { + /// Pay out up to `amount` of the pool's USDC to `caller` (the ceding + /// pool), attributed to `policy_id`. Returns the amount actually paid, + /// which may be less than requested if the reinsurer's own capacity is + /// exhausted. + fn recover(env: Env, caller: Address, policy_id: u128, amount: i128) -> i128; +} + +/// Default premium split, in effect until the admin calls `update_premium_split`. +const DEFAULT_PREMIUM_LP_BPS: i128 = 8_000; // 80% of premium to LP pool +const DEFAULT_PREMIUM_TREAS_BPS: i128 = 1_000; // 10% to treasury +const DEFAULT_PREMIUM_BACKSTOP_BPS: i128 = 1_000; // 10% to backstop fund +const _: () = assert!(DEFAULT_PREMIUM_LP_BPS + DEFAULT_PREMIUM_TREAS_BPS + DEFAULT_PREMIUM_BACKSTOP_BPS == 10_000); + +/// Upper bound on cumulative deposits (7-decimal USDC stroops). +/// 10^15 stroops == 100,000,000 USDC. Caps total pool size so share value +/// cannot become infinitesimal and total_shares cannot overflow. +const MAX_TOTAL_DEPOSITED: i128 = 1_000_000_000_000_000; + +/// Minimum deposit amount (1_000_000 stroops). +const MIN_DEPOSIT: i128 = 1_000_000; + +/// Default ceiling on `total_locked / total_deposited`, in basis points. +/// +/// 10_000 (100%) preserves the historical behaviour exactly: before this was +/// configurable the pool would commit every unit of capital it held. Lowering +/// it is how an admin expresses "keep a buffer against correlated risk", and +/// that has to be an explicit choice rather than a silent change to how much +/// coverage an existing pool can write. +const DEFAULT_MAX_UTILIZATION_BPS: u32 = 10_000; + +/// Longest withdrawal delay an admin may configure (30 days). +/// +/// An unbounded delay is indistinguishable from freezing LP funds, so the +/// ceiling is part of what makes the queue safe to hand to an admin at all. +const MAX_EXIT_DELAY: u64 = 30 * 24 * 60 * 60; + +/// Timelock duration for admin withdrawals: 7 days in seconds. +const TIMELOCK_SECONDS: u64 = 7 * 24 * 60 * 60; + +/// Timelock duration for parameter changes: 2 days in seconds. +/// Shorter than withdrawal timelock since parameter changes are less risky. +const PARAMETER_TIMELOCK_SECONDS: u64 = 2 * 24 * 60 * 60; + +/// Grace period between an admin transfer being fully proposed/approved and the +/// proposed admin being able to `accept_admin` (issue #356). Hand-synced across +/// the 4 contracts that expose admin rotation (policy-engine, risk-pool, +/// oracle-verifier, claims-processor). +const ADMIN_TRANSFER_TIMELOCK: u64 = 48 * 60 * 60; + +/// Extend a persistent entry's TTL once it has fewer than ~30 days of life left +/// (at ~5s/ledger). +// Issue #342: kept in sync by hand across all 5 contracts (governance-dao, +// risk-pool, policy-engine, oracle-verifier, claims-processor) — extracting +// to a shared crate is a real follow-up, not done here to avoid touching +// every contract's Cargo.toml in one pass. +const TTL_THRESHOLD: u32 = 518_400; +/// Extend persistent entries out to ~1 year (at ~5s/ledger) so capital locks +/// backing long-dated policies don't expire from storage before maturity. +const TTL_EXTEND_TO: u32 = 6_312_000; + +#[contracttype] +enum StorageKey { + Initialized, + Admin, + Treasury, + Backstop, + UsdcToken, + Category, + TotalDeposited, + TotalLocked, + TotalShares, + AccumulatedPremium, + AccumulatedBackstop, + AccumulatedPerShare, + Status, + LpPosition(Address), + LpCount, + LpAddress(u32), + Lock(u128), + AdminWithdrawalRequest, + PendingAdmin, + /// Ledger timestamp (u64) at which the current `PendingAdmin` was set, + /// used to enforce `ADMIN_TRANSFER_TIMELOCK` before `accept_admin`. + PendingAdminSince, + PolicyEngine, + ClaimsProcessor, + /// Contract version (u32) for storage migration tracking + Version, + /// Premium split ratios (lp_bps, treas_bps, backstop_bps), admin-adjustable + PremiumSplit, + /// Guardian addresses authorized to approve critical actions (Vec
). + Guardians, + /// Number of guardian approvals required to execute a critical action + /// (u32). 0 means guardian multisig is disabled (admin acts alone). + GuardianThreshold, + /// A pending, not-yet-executed contract upgrade awaiting guardian approvals. + PendingUpgrade, + /// A pending admin-transfer proposal awaiting guardian approvals. + PendingAdminChange, + /// A time-locked premium-split change awaiting execution. + PendingParameterChange, + /// Next LP NFT token ID (u64), sequential minting counter. + NextNftId, + /// LP NFT record by token ID — u64 → LpNft. + LpNft(u64), + /// Maps provider address to their LP NFT token ID — Address → u64. + ProviderNft(Address), + /// Admin-configured capacity limits (`PoolCapacity`). Falls back to the + /// compile-time defaults when unset. + Capacity, + /// Withdrawal delay in seconds (u64). 0 = instant withdrawals, the + /// historical behaviour and the default. + ExitDelay, + /// A provider's outstanding queued exit — Address → ExitRequest. + ExitReq(Address), + /// Total shares currently reserved by queued exits (i128), so the pool can + /// see committed outflow before it happens. + QueuedExitShares, + /// Dynamic fee adjustment configuration (DynamicFeeConfig). + /// Allows pool fees to automatically adjust based on market conditions and utilization. + DynamicFeeConfig, + /// Reinsurance arrangement backstopping this pool (`ReinsuranceConfig`). + Reinsurance, + /// Cumulative premium ceded to the reinsurer so far (i128). + ReinsurancePremiumPaid, + /// Cumulative claims recovered from the reinsurer so far (i128). + ReinsuranceRecovered, + /// Fee tier configuration — Symbol (tier name) → FeeTier. + FeeTier(Symbol), + /// Names of all registered fee tiers (Vec). + FeeTierList, +} + +#[contracterror] +#[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, + Undercollateralized = 11, + PoolCapExceeded = 12, + InvalidToken = 13, + TimelockPending = 14, + TimelockNotReady = 15, + NoPendingWithdrawal = 16, + InsufficientShares = 17, + DepositTooSmall = 18, + Overflow = 19, + InvalidAddress = 20, + InvalidVersion = 21, + InvalidSplit = 22, + NotGuardian = 23, + AlreadyApprovedAction = 24, + NoPendingUpgrade = 25, + InvalidThreshold = 26, + ParameterChangePending = 27, + NoPendingParameterChange = 28, + ParameterChangeNotReady = 29, + AdminTimelockNotExpired = 30, + UtilizationCapExceeded = 31, + InvalidCapacity = 32, + ExitAlreadyQueued = 33, + NoExitRequest = 34, + ExitDelayNotElapsed = 35, + ReinsuranceNotConfigured = 36, + InvalidReinsuranceConfig = 37, + InvalidParameter = 38, + InvalidFeeTier = 39, +} + +#[contract] +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. + pub fn initialize( + env: Env, + admin: Address, + usdc_token: Address, + treasury: Address, + backstop: Address, + category: Symbol, + policy_engine: Address, + claims_processor: Address, + ) { + if env.storage().instance().has(&StorageKey::Initialized) { + panic_with_error!(&env, Error::AlreadyInitialized); + } + // 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 { + panic_with_error!(&env, Error::InvalidAddress); + } + let mut admin_buf = [0u8; 56]; + admin_str.copy_into_slice(&mut admin_buf); + if admin_buf[0] != b'G' && admin_buf[0] != b'C' { + panic_with_error!(&env, Error::InvalidAddress); + } + + let usdc_str = usdc_token.to_string(); + + let balance_res = env.try_invoke_contract::( + &usdc_token, + &Symbol::new(&env, "balance"), + soroban_sdk::vec![&env, env.current_contract_address().to_val()], + ); + if balance_res.is_err() { + panic_with_error!(&env, Error::InvalidToken); + } + + if usdc_str.len() != 56 { + panic_with_error!(&env, Error::InvalidAddress); + } + let mut usdc_buf = [0u8; 56]; + usdc_str.copy_into_slice(&mut usdc_buf); + if usdc_buf[0] != b'C' { + panic_with_error!(&env, Error::InvalidAddress); + } + + let treasury_str = treasury.to_string(); + if treasury_str.len() != 56 { + panic_with_error!(&env, Error::InvalidAddress); + } + let mut treasury_buf = [0u8; 56]; + treasury_str.copy_into_slice(&mut treasury_buf); + if treasury_buf[0] != b'C' { + panic_with_error!(&env, Error::InvalidAddress); + } + + 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); + // 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(), + claims_processor: claims_processor.clone(), + }, + ); + } + + // ── Deposits ────────────────────────────────────────────────────────────── + + /// Deposit USDC into the pool and receive LP shares. Returns the number of shares minted. + /// `min_shares` is a slippage guard — the transaction reverts if fewer shares would be issued. + /// `compound_enabled` — if true, accrued yield is automatically reinvested (compounded) + /// instead of being paid out on each deposit. LPs can toggle this later via `toggle_compound`. + pub fn deposit( + env: Env, + provider: Address, + amount: i128, + min_shares: i128, + compound_enabled: bool, + ) -> i128 { + provider.require_auth(); + 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); + + // Enforce the configured pool size cap before accepting new liquidity. + let capacity = Self::capacity(&env); + if total_deposited + amount > capacity.max_total_deposited { + panic_with_error!(&env, Error::PoolCapExceeded); + } + + let new_shares = if total_deposited == 0 { + amount * 1_000_000_000 // 1 share = 1 USDC * 1e9 precision + } else { + amount.checked_mul(total_shares) + .and_then(|v| v.checked_div(total_deposited)) + .unwrap_or_else(|| panic_with_error!(&env, Error::Overflow)) + }; + + if new_shares == 0 { + panic_with_error!(&env, Error::ZeroAmount); + } + + if new_shares < min_shares { + 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 now = env.ledger().timestamp(); + let lp_key = StorageKey::LpPosition(provider.clone()); + let mut pending_yield: i128 = 0; + let mut is_new_lp = false; + let mut position: LpPosition = match env.storage().persistent().get::<_, LpPosition>(&lp_key) { + Some(mut pos) => { + pending_yield = Self::settle_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.compound_enabled = compound_enabled; + pos + } + None => { + is_new_lp = true; + let count: u32 = env.storage().instance() + .get(&StorageKey::LpCount).unwrap_or(0); + let lp_address_key = StorageKey::LpAddress(count); + env.storage().persistent().set(&lp_address_key, &provider); + Self::extend_to_max(&env, &lp_address_key); + 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, + compound_enabled, + insurance_enabled: false, + insurance_paid: 0, + } + } + }; + env.storage().persistent().set(&lp_key, &position); + Self::extend_to_max(&env, &lp_key); + 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, "liquidity_deposited"),), + LiquidityDeposited { + provider: provider.clone(), + amount, + shares_minted: new_shares, + }, + ); + + // Mint LP NFT on first deposit, or update existing NFT. + let category: Symbol = env.storage().instance().get(&StorageKey::Category).unwrap(); + if is_new_lp { + Self::mint_lp_nft(&env, &provider, &category, now, &position); + } else { + Self::update_lp_nft(&env, &provider, &position); + } + + // Compound yield: if enabled, reinvest yield as additional deposit instead of paying out. + if compound_enabled && pending_yield > 0 { + // Yield is already reflected in position.shares via settle_yield; + // skip external transfer — the yield stays in the pool backing the LP's shares. + } else { + // All deposit state is now persisted; safe to move the yield owed to + // the provider, if any, as the last step (checks-effects-interactions). + Self::pay_out_yield(&env, &provider, pending_yield); + } + + new_shares + } + + /// Burn `shares` and return the proportional USDC to `provider`. Returns the USDC amount + /// transferred. Panics with `Undercollateralized` if the available (unlocked) liquidity + /// is insufficient to cover the redemption. + pub fn withdraw(env: Env, provider: Address, shares: i128) -> i128 { + provider.require_auth(); + Self::withdraw_inner(env, provider, shares) + } + + /// Settlement body shared by `withdraw` and `claim_exit`. + /// + /// Takes no authorization of its own: the caller has already established + /// it. Calling `withdraw` from `claim_exit` instead would re-authorize a + /// frame that is already authorized, which the host rejects outright. + fn withdraw_inner(env: Env, provider: Address, shares: i128) -> i128 { + // Guard: check for zero or negative shares input + if shares <= 0 { panic_with_error!(&env, Error::ZeroAmount); } + Self::assert_withdrawable(&env); + + 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); + + // Guard: prevent division by zero if total_shares == 0 + if total_shares == 0 { panic_with_error!(&env, Error::NoShares); } + + 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 pending_yield = Self::settle_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); + Self::extend_to_max(&env, &lp_key); + 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)); + + // Update LP NFT after withdrawal + Self::update_lp_nft(&env, &provider, &position); + + env.events().publish( + (Symbol::new(&env, "liquidity_withdrawn"),), + LiquidityWithdrawn { + provider: provider.clone(), + shares_burned: shares, + amount_returned: amount, + }, + ); + + // All withdrawal state is persisted before either external transfer + // runs: the principal amount, then any yield owed (checks-effects-interactions). + let usdc: Address = env.storage().instance().get(&StorageKey::UsdcToken).unwrap(); + token::Client::new(&env, &usdc) + .transfer(&env.current_contract_address(), &provider, &amount); + Self::pay_out_yield(&env, &provider, pending_yield); + + amount + } + + /// Transfer `shares` from `from` address to `to` address. + /// Returns the proportional USDC deposit amount transferred. + pub fn transfer_position(env: Env, from: Address, to: Address, shares: i128) -> i128 { + from.require_auth(); + if shares <= 0 { panic_with_error!(&env, Error::ZeroAmount); } + if from == to { panic_with_error!(&env, Error::InvalidAddress); } + Self::assert_active(&env); + + let from_key = StorageKey::LpPosition(from.clone()); + let mut from_pos: LpPosition = env.storage().persistent() + .get(&from_key) + .unwrap_or_else(|| panic_with_error!(&env, Error::NoShares)); + + if from_pos.shares < shares { + panic_with_error!(&env, Error::InsufficientShares); + } + + let amount = shares.checked_mul(from_pos.deposited) + .and_then(|v| v.checked_div(from_pos.shares)) + .unwrap_or_else(|| panic_with_error!(&env, Error::Overflow)); + + let pending_yield_from = Self::settle_yield(&env, &mut from_pos); + from_pos.deposited = from_pos.deposited.saturating_sub(amount); + from_pos.shares -= shares; + let acc_per_share: i128 = env.storage().instance().get(&StorageKey::AccumulatedPerShare).unwrap_or(0); + from_pos.yield_debt = (acc_per_share * from_pos.shares) / 1_000_000_000_000; + env.storage().persistent().set(&from_key, &from_pos); + Self::extend_to_max(&env, &from_key); + + Self::update_lp_nft(&env, &from, &from_pos); + Self::pay_out_yield(&env, &from, pending_yield_from); + + let now = env.ledger().timestamp(); + let to_key = StorageKey::LpPosition(to.clone()); + let mut is_new_lp = false; + let mut to_pos: LpPosition = match env.storage().persistent().get::<_, LpPosition>(&to_key) { + Some(mut pos) => { + let pending_yield_to = Self::settle_yield(&env, &mut pos); + pos.deposited += amount; + pos.shares += shares; + pos.yield_debt = (acc_per_share * pos.shares) / 1_000_000_000_000; + env.storage().persistent().set(&to_key, &pos); + Self::extend_to_max(&env, &to_key); + Self::pay_out_yield(&env, &to, pending_yield_to); + pos + } + None => { + is_new_lp = true; + let count: u32 = env.storage().instance() + .get(&StorageKey::LpCount).unwrap_or(0); + let lp_address_key = StorageKey::LpAddress(count); + env.storage().persistent().set(&lp_address_key, &to); + Self::extend_to_max(&env, &lp_address_key); + env.storage().instance().set(&StorageKey::LpCount, &(count + 1)); + let pos = LpPosition { + provider: to.clone(), + deposited: amount, + shares, + yield_claimed: 0, + yield_debt: (acc_per_share * shares) / 1_000_000_000_000, + deposited_at: now, + last_yield_claim: now, + compound_enabled: false, + insurance_enabled: false, + insurance_paid: 0, + }; + env.storage().persistent().set(&to_key, &pos); + Self::extend_to_max(&env, &to_key); + pos + } + }; + + let category: Symbol = env.storage().instance().get(&StorageKey::Category).unwrap(); + if is_new_lp { + Self::mint_lp_nft(&env, &to, &category, now, &to_pos); + } else { + Self::update_lp_nft(&env, &to, &to_pos); + } + + env.events().publish( + (Symbol::new(&env, "lp_position_transferred"),), + LpPositionTransferred { + from, + to, + shares, + amount, + }, + ); + + 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_bps, treas_bps, backstop_bps) = Self::get_premium_split_bps(&env); + let lp_share = amount * lp_bps / 10_000; + let treas_share = amount * treas_bps / 10_000; + let backstop_share = amount * 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); + + 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)); + + 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 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)); + } + + 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"),), + PremiumDistributed { + amount, + lp_share, + treasury_share: treas_share, + backstop_share, + }, + ); + } + + /// Send a specified amount of USDC from the pool to the treasury address. + /// 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(); + let treasury: Address = env.storage().instance().get(&StorageKey::Treasury).unwrap(); + token::Client::new(&env, &usdc) + .transfer(&env.current_contract_address(), &treasury, &amount); + env.events().publish( + (Symbol::new(&env, "treasury_funded"),), + TreasuryFunded { amount, recipient: treasury }, + ); + } + + /// Send a specified amount of USDC from the pool to the backstop address. + /// 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(); + let backstop: Address = env.storage().instance().get(&StorageKey::Backstop).unwrap(); + token::Client::new(&env, &usdc) + .transfer(&env.current_contract_address(), &backstop, &amount); + env.events().publish( + (Symbol::new(&env, "backstop_funded"),), + BackstopFunded { amount, recipient: backstop }, + ); + } + + /// Returns the configured backstop address. + pub fn get_backstop(env: Env) -> Address { + env.storage().instance().get(&StorageKey::Backstop) + .unwrap_or_else(|| panic_with_error!(env, Error::NotInitialized)) + } + + /// Collect accumulated premium yield for `provider` and transfer it in USDC. + /// Returns the amount claimed. No-op (returns 0) if no yield has accrued. + 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() + .get(&lp_key) + .unwrap_or_else(|| panic_with_error!(&env, Error::NoShares)); + + let claimed = Self::settle_yield(&env, &mut position); + + if claimed > 0 { + env.storage().persistent().set(&lp_key, &position); + Self::extend_to_max(&env, &lp_key); + } + + Self::pay_out_yield(&env, &provider, claimed); + + claimed + } + + // ── Capacity limits (issue #373) ────────────────────────────────────────── + + /// Set the pool's capacity limits. + /// + /// Two ceilings, because two different things can go wrong: + /// + /// - `max_total_deposited` bounds the capital held, so share value cannot + /// become infinitesimal and `total_shares` cannot overflow. + /// - `max_utilization_bps` bounds how much of that capital may be + /// committed to active coverage at once. This is the correlated-risk + /// limit: a pool fully committed to one category in one region is a + /// single event away from insolvency however much capital it holds, and + /// nothing in the deposit ceiling prevents that. + /// + /// Lowering a limit below current usage is allowed and does not claw + /// anything back — existing deposits and locks stand, and the pool simply + /// accepts nothing further until it is back under the line. Refusing the + /// change would leave an admin unable to stop an overexposed pool from + /// growing, which is the opposite of what this exists for. + pub fn set_capacity( + env: Env, + admin: Address, + max_total_deposited: i128, + max_utilization_bps: u32, + ) { + Self::require_admin(&env, &admin); + + if max_total_deposited <= 0 || max_utilization_bps == 0 || max_utilization_bps > 10_000 { + panic_with_error!(&env, Error::InvalidCapacity); + } + + env.storage().instance().set( + &StorageKey::Capacity, + &PoolCapacity { + max_total_deposited, + max_utilization_bps, + }, + ); + + env.events().publish( + (Symbol::new(&env, "capacity_updated"),), + PoolCapacityUpdated { + max_total_deposited, + max_utilization_bps, + }, + ); + } + + /// The configured capacity limits, or the built-in defaults. + pub fn get_capacity(env: Env) -> PoolCapacity { + Self::capacity(&env) + } + + /// How much of the pool's capacity is currently consumed, and how much + /// room is left on each limit. + /// + /// Exposed so a front-end can tell an LP "this pool is full" and a policy + /// engine can tell a buyer "this coverage cannot be written right now" + /// before either submits a transaction that would revert. + pub fn get_capacity_status(env: Env) -> CapacityStatus { + 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 cap = Self::capacity(&env); + + let utilization_bps = Self::utilization_bps(total_locked, total_deposited); + + // What may still be underwritten before the utilization cap binds. + let max_lockable = total_deposited + .saturating_mul(cap.max_utilization_bps as i128) + / 10_000; + + CapacityStatus { + total_deposited, + total_locked, + max_total_deposited: cap.max_total_deposited, + utilization_bps, + max_utilization_bps: cap.max_utilization_bps, + remaining_deposit_capacity: cap + .max_total_deposited + .saturating_sub(total_deposited) + .max(0), + remaining_coverage_capacity: max_lockable.saturating_sub(total_locked).max(0), + } + } + + // ── Exit queue (issue #377) ─────────────────────────────────────────────── + + /// Set the withdrawal delay in seconds. 0 restores instant withdrawals. + /// + /// The delay exists because instant exit is a bank run waiting to happen: + /// when a pool looks stressed, the LPs who move first are made whole out + /// of the liquidity the remaining LPs were relying on. A queue makes every + /// LP wait the same interval, which removes the advantage of panicking and + /// gives the pool a window to see committed outflow coming. + /// + /// Capped at `MAX_EXIT_DELAY` (30 days). An unbounded delay would be + /// indistinguishable from freezing LP funds. + pub fn set_exit_delay(env: Env, admin: Address, delay_seconds: u64) { + Self::require_admin(&env, &admin); + + if delay_seconds > MAX_EXIT_DELAY { + panic_with_error!(&env, Error::InvalidCapacity); + } + + env.storage() + .instance() + .set(&StorageKey::ExitDelay, &delay_seconds); + + env.events().publish( + (Symbol::new(&env, "exit_delay_updated"),), + ExitDelayUpdated { delay_seconds }, + ); + } + + /// The configured withdrawal delay in seconds (default 0 — instant). + pub fn get_exit_delay(env: Env) -> u64 { + Self::exit_delay(&env) + } + + /// Queue a withdrawal of `shares`, claimable once the delay has elapsed. + /// + /// The shares stay in the LP's position and keep earning yield until the + /// exit is claimed — queuing is a commitment to leave, not a forfeiture. + /// What it does prevent is queuing twice: one outstanding request per + /// provider, so the reserved total cannot be inflated past what the LP + /// actually holds. + /// + /// Panics with `ExitAlreadyQueued` if a request is already outstanding; + /// cancel it first to change the amount. + pub fn request_exit(env: Env, provider: Address, shares: i128) { + provider.require_auth(); + if shares <= 0 { + panic_with_error!(&env, Error::ZeroAmount); + } + Self::assert_withdrawable(&env); + + let position: LpPosition = env + .storage() + .persistent() + .get(&StorageKey::LpPosition(provider.clone())) + .unwrap_or_else(|| panic_with_error!(&env, Error::NoShares)); + if position.shares < shares { + panic_with_error!(&env, Error::InsufficientFunds); + } + + let req_key = StorageKey::ExitReq(provider.clone()); + if env.storage().persistent().has(&req_key) { + panic_with_error!(&env, Error::ExitAlreadyQueued); + } + + let now = env.ledger().timestamp(); + let claimable_at = now.saturating_add(Self::exit_delay(&env)); + + env.storage().persistent().set( + &req_key, + &ExitRequest { + provider: provider.clone(), + shares, + requested_at: now, + claimable_at, + }, + ); + Self::extend_to_max(&env, &req_key); + + let queued: i128 = env + .storage() + .instance() + .get(&StorageKey::QueuedExitShares) + .unwrap_or(0); + env.storage() + .instance() + .set(&StorageKey::QueuedExitShares, &queued.saturating_add(shares)); + + env.events().publish( + (Symbol::new(&env, "exit_requested"),), + ExitRequested { + provider, + shares, + claimable_at, + }, + ); + } + + /// Withdraw a queued request whose delay has elapsed. + /// + /// Settlement runs through the same path as a direct `withdraw`, so the + /// amount is priced at the share value *at claim time* rather than at + /// request time. An LP cannot lock in a favourable price and wait to see + /// whether the pool takes a loss in the meantime — that would hand queued + /// LPs an option paid for by everyone else. + pub fn claim_exit(env: Env, provider: Address) -> i128 { + provider.require_auth(); + + let req_key = StorageKey::ExitReq(provider.clone()); + let request: ExitRequest = env + .storage() + .persistent() + .get(&req_key) + .unwrap_or_else(|| panic_with_error!(&env, Error::NoExitRequest)); + + let now = env.ledger().timestamp(); + if now < request.claimable_at { + panic_with_error!(&env, Error::ExitDelayNotElapsed); + } + + // Release the reservation before settling, so a failed withdrawal + // cannot leave the request stuck and double-counted. + Self::clear_exit_reservation(&env, &provider, request.shares); + + let amount = Self::withdraw_inner(env.clone(), provider.clone(), request.shares); + + env.events().publish( + (Symbol::new(&env, "exit_claimed"),), + ExitClaimed { + provider, + shares_burned: request.shares, + amount_returned: amount, + waited: now.saturating_sub(request.requested_at), + }, + ); + + amount + } + + /// Cancel an outstanding exit request and release its reservation. + /// + /// Always available, including before the delay elapses: an LP who changes + /// their mind should not be forced out, and a queue nobody can leave is a + /// worse trap than the instant withdrawals it replaced. + pub fn cancel_exit(env: Env, provider: Address) { + provider.require_auth(); + + let req_key = StorageKey::ExitReq(provider.clone()); + let request: ExitRequest = env + .storage() + .persistent() + .get(&req_key) + .unwrap_or_else(|| panic_with_error!(&env, Error::NoExitRequest)); + + Self::clear_exit_reservation(&env, &provider, request.shares); + + env.events().publish( + (Symbol::new(&env, "exit_cancelled"),), + ExitCancelled { + provider, + shares: request.shares, + }, + ); + } + + /// Where a provider's exit stands. Safe to call for any address — an + /// address with no request reports `ExitStatus::None` rather than panicking. + pub fn get_exit_info(env: Env, provider: Address) -> ExitInfo { + let request: Option = env + .storage() + .persistent() + .get(&StorageKey::ExitReq(provider.clone())); + + match request { + None => ExitInfo { + provider, + status: ExitStatus::None, + shares: 0, + requested_at: 0, + claimable_at: 0, + seconds_remaining: 0, + }, + Some(req) => { + let now = env.ledger().timestamp(); + let claimable = now >= req.claimable_at; + ExitInfo { + provider, + status: if claimable { + ExitStatus::Claimable + } else { + ExitStatus::Pending + }, + shares: req.shares, + requested_at: req.requested_at, + claimable_at: req.claimable_at, + seconds_remaining: req.claimable_at.saturating_sub(now), + } + } + } + } + + /// Total shares reserved by all outstanding exit requests. + /// + /// This is the pool's forward view of committed outflow — the number that + /// makes a queue useful for anything beyond delay. + pub fn get_queued_exit_shares(env: Env) -> i128 { + env.storage() + .instance() + .get(&StorageKey::QueuedExitShares) + .unwrap_or(0) + } + + // ── Capital locks ───────────────────────────────────────────────────────── + + /// Earmark `amount` USDC as collateral for `policy_id`. Only the policy engine or + /// claims processor may call this. Panics if the pool is under-collateralised or if + /// a lock for this policy already exists. + + pub fn lock_for_policy(env: Env, caller: Address, policy_id: u128, amount: i128) { + 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); } + + 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); } + + // Enforce the utilization ceiling. `available` alone only asks whether + // the pool *can* commit the capital; this asks whether it *should* — + // a pool committed to the last unit has no buffer left for correlated + // losses across the policies it already wrote. + let capacity = Self::capacity(&env); + if capacity.max_utilization_bps < 10_000 { + let max_lockable = total_deposited + .saturating_mul(capacity.max_utilization_bps as i128) + / 10_000; + if total_locked.saturating_add(amount) > max_lockable { + panic_with_error!(&env, Error::UtilizationCapExceeded); + } + } + + if env.storage().persistent().has(&StorageKey::Lock(policy_id)) { panic_with_error!(&env, Error::AlreadyLocked); } + + let lock_key = StorageKey::Lock(policy_id); + env.storage().persistent().set(&lock_key, &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.checked_add(amount).unwrap_or_else(|| panic_with_error!(&env, Error::Overflow))); + + env.events().publish( + (Symbol::new(&env, "capital_locked"),), + CapitalLocked { + policy_id, + amount, + }, + ); + } + + /// Release the capital lock for `policy_id` after a successful claim payout. + /// + /// ### Flow & Caller + /// This function is called by the `claims-processor` contract immediately after the policy + /// engine successfully transfers the coverage payout to the policyholder. + /// + /// ### Capital Effect + /// This reduces `total_locked` in the pool, reflecting that the coverage lock is now removed. + /// Since the claim was paid, the underwriting capital has been disbursed to the policyholder. + /// + /// ### Design Rationale + /// Having separate functions (`release_for_claim` and `release_for_expiry`) instead of a single + /// `release(policy_id, reason)` endpoint serves several key purposes: + /// 1. **Access Control & Security**: Allows fine-grained tracking of capital outflows due to claims + /// vs. standard policy expirations. + /// 2. **Auditability & Logging**: Distinct event paths make off-chain monitoring, analytics, + /// and accounting of paid claims vs. expired policies trivial. + /// 3. **Gas Optimization**: Avoids the instruction overhead of parsing and branching on an enum/string + /// reason within the contract. + 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() + .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); } + + 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.events().publish( + (Symbol::new(&env, "capital_released"),), + CapitalReleased { + policy_id, + amount: lock.amount, + }, + ); + } + + /// Release the capital lock for `policy_id` when the policy expires without a payout. + /// + /// ### Flow & Caller + /// This function is called by the `claims-processor` contract when a policy reaches its + /// expiration timestamp without triggering a payout. + /// + /// ### Capital Effect + /// This reduces `total_locked` in the pool, releasing the locked capital back into the + /// pool's available liquidity. Unlike `release_for_claim`, the capital remains in the pool + /// and is available to underwrite new policies, while the premium paid by the policyholder + /// is fully earned by the pool. + /// + /// ### Design Rationale + /// Having separate functions (`release_for_claim` and `release_for_expiry`) instead of a single + /// `release(policy_id, reason)` endpoint serves several key purposes: + /// 1. **Access Control & Security**: Allows fine-grained tracking of capital outflows due to claims + /// vs. standard policy expirations. + /// 2. **Auditability & Logging**: Distinct event paths make off-chain monitoring, analytics, + /// and accounting of paid claims vs. expired policies trivial. + /// 3. **Gas Optimization**: Avoids the instruction overhead of parsing and branching on an enum/string + /// reason within the contract. + 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() + .get(&StorageKey::Lock(policy_id)) + .unwrap_or_else(|| panic_with_error!(&env, Error::LockNotFound)); + 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))); + } + + // ── Reinsurance ────────────────────────────────────────────────────────── + + /// Configure (or replace) the pool's reinsurance arrangement. + /// + /// `attachment_point` and `coverage_limit` are per-claim and lifetime + /// caps respectively — see [`ReinsuranceConfig`]. Replacing an existing + /// arrangement does not reset `ReinsuranceStats`: prior premium paid and + /// claims recovered still count against the new `coverage_limit`, since + /// a re-negotiated arrangement with the same or a new reinsurer is a + /// continuation, not a fresh start. + pub fn set_reinsurance( + env: Env, + admin: Address, + reinsurer: Address, + attachment_point: i128, + coverage_limit: i128, + ) { + Self::require_admin(&env, &admin); + if attachment_point < 0 || coverage_limit < 0 { + panic_with_error!(&env, Error::InvalidReinsuranceConfig); + } + let config = ReinsuranceConfig { + reinsurer: reinsurer.clone(), + attachment_point, + coverage_limit, + active: true, + }; + env.storage().instance().set(&StorageKey::Reinsurance, &config); + env.events().publish( + (Symbol::new(&env, "reinsurance_configured"),), + ReinsuranceConfigured { reinsurer, attachment_point, coverage_limit }, + ); + } + + /// Pause or resume the reinsurance arrangement without discarding its + /// configuration or accumulated stats. While inactive, + /// `request_reinsurance_recovery` always returns 0. + pub fn set_reinsurance_active(env: Env, admin: Address, active: bool) { + Self::require_admin(&env, &admin); + let mut config: ReinsuranceConfig = env.storage().instance() + .get(&StorageKey::Reinsurance) + .unwrap_or_else(|| panic_with_error!(&env, Error::ReinsuranceNotConfigured)); + config.active = active; + env.storage().instance().set(&StorageKey::Reinsurance, &config); + env.events().publish( + (Symbol::new(&env, "reinsurance_active_set"),), + ReinsuranceActiveSet { active }, + ); + } + + /// Cede `amount` of USDC from the pool's own balance to the reinsurer as + /// reinsurance premium. Mirrors `send_premium_to_treasury`/ + /// `send_premium_to_backstop` — called by the admin (typically from an + /// off-chain job) after premium income has accrued in the pool. + pub fn send_premium_to_reinsurer(env: Env, caller: Address, amount: i128) { + Self::require_admin(&env, &caller); + if amount <= 0 { panic_with_error!(&env, Error::ZeroAmount); } + let config: ReinsuranceConfig = env.storage().instance() + .get(&StorageKey::Reinsurance) + .unwrap_or_else(|| panic_with_error!(&env, Error::ReinsuranceNotConfigured)); + + let usdc: Address = env.storage().instance().get(&StorageKey::UsdcToken).unwrap(); + token::Client::new(&env, &usdc) + .transfer(&env.current_contract_address(), &config.reinsurer, &amount); + + let paid: i128 = env.storage().instance() + .get(&StorageKey::ReinsurancePremiumPaid).unwrap_or(0); + let total_premium_paid = paid.saturating_add(amount); + env.storage().instance().set(&StorageKey::ReinsurancePremiumPaid, &total_premium_paid); + + env.events().publish( + (Symbol::new(&env, "reinsurance_premium_paid"),), + ReinsurancePremiumPaid { reinsurer: config.reinsurer, amount, total_premium_paid }, + ); + } + + /// Request recovery of a catastrophic claim loss from the reinsurer. + /// + /// Callable only by the admin, policy engine, or claims processor (the + /// same gate as `release_for_claim`), so it slots directly into the + /// existing claim-settlement flow. `loss_amount` is the size of the + /// claim payout being recovered against; only the portion above + /// `attachment_point` is eligible, and the total ever recovered is + /// capped at `coverage_limit`. + /// + /// This never panics on "no coverage available" — an unconfigured, + /// inactive, or exhausted arrangement simply recovers nothing (returns + /// 0), so a claim payout is never blocked by the state of a reinsurance + /// side-arrangement. Returns the amount actually recovered, which is + /// credited to `total_deposited` (replenishing pool capital) and may be + /// less than the eligible excess if the reinsurer's own capacity is + /// exhausted. + pub fn request_reinsurance_recovery( + env: Env, + caller: Address, + policy_id: u128, + loss_amount: i128, + ) -> i128 { + Self::require_protocol_caller(&env, &caller); + if loss_amount <= 0 { return 0; } + + let config: ReinsuranceConfig = match env.storage().instance().get(&StorageKey::Reinsurance) { + Some(c) => c, + None => return 0, + }; + if !config.active || loss_amount <= config.attachment_point { + return 0; + } + + let excess = loss_amount - config.attachment_point; + let already_recovered: i128 = env.storage().instance() + .get(&StorageKey::ReinsuranceRecovered).unwrap_or(0); + let coverage_remaining = config.coverage_limit.saturating_sub(already_recovered).max(0); + if coverage_remaining <= 0 { return 0; } + + let requested = excess.min(coverage_remaining); + + let recovered = ReinsurerClient::new(&env, &config.reinsurer) + .recover(&env.current_contract_address(), &policy_id, &requested) + // The reinsurer's own report is trusted for *what* it paid, but + // never for *how much* — clamp to what was actually requested so + // a misbehaving or buggy reinsurer cannot inflate this pool's + // coverage accounting past what it asked for. + .clamp(0, requested); + + if recovered > 0 { + let total_deposited: i128 = env.storage().instance() + .get(&StorageKey::TotalDeposited).unwrap_or(0); + env.storage().instance().set( + &StorageKey::TotalDeposited, + &total_deposited.checked_add(recovered).unwrap_or_else(|| panic_with_error!(&env, Error::Overflow)), + ); + + let total_recovered = already_recovered.saturating_add(recovered); + env.storage().instance().set(&StorageKey::ReinsuranceRecovered, &total_recovered); + + env.events().publish( + (Symbol::new(&env, "reinsurance_recovered"),), + ReinsuranceRecovered { + policy_id, + loss_amount, + recovered_amount: recovered, + coverage_remaining: config.coverage_limit.saturating_sub(total_recovered).max(0), + }, + ); + } + + recovered + } + + /// Returns the pool's reinsurance configuration, or `None` if never set. + pub fn get_reinsurance(env: Env) -> Option { + env.storage().instance().get(&StorageKey::Reinsurance) + } + + /// Returns cumulative reinsurance premium paid, claims recovered, and + /// remaining coverage. Coverage remaining is 0 (not an error) when no + /// arrangement has ever been configured. + pub fn get_reinsurance_stats(env: Env) -> ReinsuranceStats { + let total_premium_paid: i128 = env.storage().instance() + .get(&StorageKey::ReinsurancePremiumPaid).unwrap_or(0); + let total_recovered: i128 = env.storage().instance() + .get(&StorageKey::ReinsuranceRecovered).unwrap_or(0); + let coverage_limit = env.storage().instance() + .get::<_, ReinsuranceConfig>(&StorageKey::Reinsurance) + .map(|c| c.coverage_limit) + .unwrap_or(0); + ReinsuranceStats { + total_premium_paid, + total_recovered, + coverage_remaining: coverage_limit.saturating_sub(total_recovered).max(0), + } + } + + // ── Queries ─────────────────────────────────────────────────────────────── + + /// 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), + } + } + + /// 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)) + } + + /// 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) + .and_then(|v| v.checked_div(deposited)) + .unwrap_or(0); + // Saturate to u32::MAX if the result exceeds u32 range + if util_bps > u32::MAX as i128 { + u32::MAX + } else { + util_bps as u32 + } + } + + /// Return the dynamic premium rate (basis points) for a given base rate, + /// adjusted for the pool's current risk as measured by utilization. + /// + /// Premiums are static by default (`InsuranceProduct.premium_rate_bps`), + /// which means the protocol charges the same rate whether the pool is + /// nearly empty or almost fully committed. A pool running hot has a thinner + /// buffer to absorb a correlated payout, so its coverage is objectively + /// riskier — and should cost more (issue #386). + /// + /// The adjustment scales the base rate linearly with utilization: at 0% + /// utilization the rate is unchanged, and at 100% utilization it doubles + /// (`rate * (10_000 + utilization_bps) / 10_000`). Utilization is capped at + /// 10_000 bps so the multiplier never exceeds 2x. The policy engine calls + /// this on every purchase and uses the result in place of the static rate. + pub fn get_dynamic_premium_rate(env: Env, base_rate_bps: u32) -> u32 { + let status = Self::get_capacity_status(env); + let util = status.utilization_bps.min(10_000); + let scaled = (base_rate_bps as u128) + .saturating_mul((10_000u128).saturating_add(util as u128)) + / 10_000u128; + u32::try_from(scaled).unwrap_or(u32::MAX) + } + + /// Set dynamic fee adjustment configuration based on market conditions. + /// Allows pool fees to automatically adjust based on pool utilization. + /// + /// Parameters: + /// - `base_fee_bps`: Base fee in basis points + /// - `max_fee_bps`: Maximum fee cap in basis points + /// - `min_fee_bps`: Minimum fee floor in basis points + /// - `utilization_threshold_bps`: Utilization threshold (in bps) at which fees start increasing + /// - `fee_adjustment_per_1pct_bps`: Fee increase per 1% utilization above threshold + /// - `enabled`: Whether dynamic fee adjustment is active + pub fn set_dynamic_fee_config( + env: Env, + admin: Address, + base_fee_bps: u32, + max_fee_bps: u32, + min_fee_bps: u32, + utilization_threshold_bps: u32, + fee_adjustment_per_1pct_bps: u32, + enabled: bool, + ) { + Self::require_admin(&env, &admin); + + // Validate thresholds + if base_fee_bps > 10000 || max_fee_bps > 10000 || min_fee_bps > 10000 { + panic_with_error!(&env, Error::InvalidParameter); + } + if min_fee_bps > base_fee_bps || base_fee_bps > max_fee_bps { + panic_with_error!(&env, Error::InvalidParameter); + } + if utilization_threshold_bps > 10000 { + panic_with_error!(&env, Error::InvalidParameter); + } + + let config = DynamicFeeConfig { + base_fee_bps, + max_fee_bps, + min_fee_bps, + utilization_threshold_bps, + fee_adjustment_per_1pct_bps, + enabled, + last_updated: env.ledger().timestamp(), + }; + + env.storage() + .instance() + .set(&StorageKey::DynamicFeeConfig, &config); + + env.events().publish( + (Symbol::new(&env, "dynamic_fee_config_updated"),), + DynamicFeeConfigUpdated { + base_fee_bps, + max_fee_bps, + min_fee_bps, + utilization_threshold_bps, + fee_adjustment_per_1pct_bps, + enabled, + }, + ); + } + + /// Get current dynamic fee configuration. + pub fn get_dynamic_fee_config(env: Env) -> DynamicFeeConfig { + env.storage() + .instance() + .get(&StorageKey::DynamicFeeConfig) + .unwrap_or_else(|| DynamicFeeConfig { + base_fee_bps: 0, + max_fee_bps: 1000, + min_fee_bps: 0, + utilization_threshold_bps: 7000, + fee_adjustment_per_1pct_bps: 10, + enabled: false, + last_updated: 0, + }) + } + + /// Calculate the current dynamic fee based on pool utilization. + /// Returns adjusted fee in basis points within the configured min/max bounds. + pub fn calculate_dynamic_fee(env: Env) -> u32 { + let config = Self::get_dynamic_fee_config(env.clone()); + if !config.enabled { + return config.base_fee_bps; + } + + let status = Self::get_capacity_status(env); + let util_bps = status.utilization_bps; + + // If below threshold, use base fee + if util_bps <= config.utilization_threshold_bps { + return config.base_fee_bps; + } + + // Calculate fee increase based on utilization above threshold + let util_above_threshold = util_bps.saturating_sub(config.utilization_threshold_bps); + // Convert basis points (1/100th of 1%) to 1% increments + let pct_above_threshold = util_above_threshold / 100; + let fee_increase = (pct_above_threshold as u32).saturating_mul(config.fee_adjustment_per_1pct_bps); + + let adjusted_fee = config.base_fee_bps.saturating_add(fee_increase); + adjusted_fee.min(config.max_fee_bps).max(config.min_fee_bps) + } + + // ── Fee Tiers (issue #429) ──────────────────────────────────────────────── + + /// Add or update an LP fee tier. LPs meeting the tier's requirements + /// (minimum deposit and/or minimum lock duration) receive the discount + /// on protocol fees. + /// + /// `discount_bps` is in basis points: 500 = 5% discount, 1000 = 10%, etc. + /// Maximum 10000 (100% discount, i.e. zero fees). + pub fn set_fee_tier( + env: Env, + admin: Address, + name: Symbol, + min_deposit: i128, + min_lock_duration: u64, + discount_bps: u32, + ) { + Self::require_admin(&env, &admin); + if discount_bps > 10_000 { + panic_with_error!(&env, Error::InvalidFeeTier); + } + if min_deposit < 0 { + panic_with_error!(&env, Error::InvalidFeeTier); + } + + let tier = FeeTier { + min_deposit, + min_lock_duration, + discount_bps, + name: name.clone(), + }; + env.storage().instance().set(&StorageKey::FeeTier(name.clone()), &tier); + + // Track tier names + let mut names: Vec = env + .storage() + .instance() + .get(&StorageKey::FeeTierList) + .unwrap_or_else(|| Vec::new(&env)); + let mut found = false; + for i in 0..names.len() { + if names.get_unchecked(i) == name { + found = true; + break; + } + } + if !found { + names.push_back(name.clone()); + env.storage().instance().set(&StorageKey::FeeTierList, &names); + } + + env.events().publish( + (Symbol::new(&env, "fee_tier_updated"),), + FeeTierUpdated { + tier_name: name, + min_deposit, + min_lock_duration, + discount_bps, + }, + ); + } + + /// Remove an LP fee tier by name. + pub fn remove_fee_tier(env: Env, admin: Address, name: Symbol) { + Self::require_admin(&env, &admin); + let key = StorageKey::FeeTier(name.clone()); + if !env.storage().instance().has(&key) { + panic_with_error!(&env, Error::InvalidFeeTier); + } + env.storage().instance().remove(&key); + + let mut names: Vec = env + .storage() + .instance() + .get(&StorageKey::FeeTierList) + .unwrap_or_else(|| Vec::new(&env)); + let mut pruned: Vec = Vec::new(&env); + for i in 0..names.len() { + if names.get_unchecked(i) != name { + pruned.push_back(names.get_unchecked(i)); + } + } + env.storage().instance().set(&StorageKey::FeeTierList, &pruned); + } + + /// Get a specific fee tier by name. + pub fn get_fee_tier(env: Env, name: Symbol) -> Option { + env.storage().instance().get(&StorageKey::FeeTier(name)) + } + + /// List all registered fee tier names. + pub fn get_fee_tier_names(env: Env) -> Vec { + env.storage().instance() + .get(&StorageKey::FeeTierList) + .unwrap_or_else(|| Vec::new(&env)) + } + + /// Get the effective fee discount for a provider based on their deposit + /// amount and lock duration. Returns the highest applicable discount. + pub fn get_lp_fee_discount(env: Env, provider: Address) -> u32 { + let position: LpPosition = match env.storage().persistent() + .get(&StorageKey::LpPosition(provider.clone())) + { + Some(p) => p, + None => return 0, + }; + + let names: Vec = env + .storage() + .instance() + .get(&StorageKey::FeeTierList) + .unwrap_or_else(|| Vec::new(&env)); + + let now = env.ledger().timestamp(); + let mut best_discount: u32 = 0; + + for i in 0..names.len() { + let tier_name = names.get_unchecked(i); + if let Some(tier) = env.storage().instance().get::<_, FeeTier>(&StorageKey::FeeTier(tier_name.clone())) { + let deposit_met = position.deposited >= tier.min_deposit; + let lock_duration = now.saturating_sub(position.deposited_at); + let lock_met = tier.min_lock_duration == 0 || lock_duration >= tier.min_lock_duration; + if deposit_met && lock_met && tier.discount_bps > best_discount { + best_discount = tier.discount_bps; + } + } + } + + best_discount + } + + /// 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) + .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) + } + + /// 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) + } + + /// Return the current premium split ratios in basis points: (lp, treasury, backstop). + pub fn get_premium_split(env: Env) -> PremiumSplit { + let (lp_bps, treas_bps, backstop_bps) = Self::get_premium_split_bps(&env); + PremiumSplit { lp_bps, treas_bps, backstop_bps } + } + + /// 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 offset_val = offset.unwrap_or(0); + let limit_val = core::cmp::min(limit.unwrap_or(100), 500); + + let mut paginated = Vec::new(&env); + 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() + .get::<_, Address>(&StorageKey::LpAddress(i)) + { + if let Some(position) = env.storage().persistent() + .get::<_, LpPosition>(&StorageKey::LpPosition(addr.clone())) + { + if position.shares > 0 { + paginated.push_back(addr); + } + } + } + } + } + + PaginatedLps { + lps: paginated, + total_count, + } + } + + /// 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); + deposited.saturating_sub(locked) + } + + // ── Admin ───────────────────────────────────────────────────────────────── + // + // Admin Powers: + // - `pause` / `resume` — halt or resume deposits (no effect on withdrawals) + // - `lock_for_policy` — earmark capital for an active policy + // - `release_for_claim` / `release_for_expiry` — unlock earmarked capital + // - `request_admin_withdrawal` / `execute_admin_withdrawal` — + // 7-day-timelocked emergency withdrawal of unlocked liquidity + // - `cancel_admin_withdrawal` — abort a pending withdrawal + // + // Admin Limitations: + // - Admin CANNOT withdraw LP shares directly. Only the LP who owns + // shares may call `withdraw()` (gated by `provider.require_auth()`). + // - Admin CANNOT transfer pool USDC to any address except via the + // 7-day timelock path, and only to the treasury address. + // - Admin CANNOT modify LP positions, shares, or yield entitlements. + // + // Timelock Policy: + // Any withdrawal of pool funds by the admin requires a 7-day waiting + // period so LPs have time to exit if they disagree with the decision. + + /// 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.events().publish( + (Symbol::new(&env, "pool_paused"),), + 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.events().publish( + (Symbol::new(&env, "pool_resumed"),), + PoolResumed { admin: admin.clone() }, + ); + } + + /// Admin-only: begin graceful wind-down of the pool. Blocks new deposits while + /// letting existing LPs continue to withdraw and claim yield. Only callable + /// while the pool is `Active`. + pub fn start_winding_down(env: Env, admin: Address) { + Self::require_admin(&env, &admin); + Self::assert_active(&env); + env.storage().instance().set(&StorageKey::Status, &PoolStatus::WindingDown); + env.events().publish( + (Symbol::new(&env, "pool_winding_down"),), + PoolWindingDown { admin: admin.clone() }, + ); + } + + /// Admin-only: update the premium split ratios (in basis points). The three + /// values must sum to 10,000 (100%). Applies to premiums received after this call. + /// + /// Issue #341: this and the other admin-only setters below are callable + /// only by the plain `admin` address today — governance-dao has no path + /// to call them on the DAO's behalf. Wiring that requires either + /// pointing `admin` at the DAO contract address (needs the DAO to gain + /// an "execute arbitrary cross-contract call" ability first) or adding + /// a parallel `update_premium_split_via_dao` entrypoint gated on a + /// passed/executed proposal — a real design decision, not made here. + pub fn update_premium_split(env: Env, admin: Address, lp_bps: i128, treas_bps: i128, backstop_bps: i128) { + Self::require_admin(&env, &admin); + if lp_bps < 0 || treas_bps < 0 || backstop_bps < 0 { + panic_with_error!(&env, Error::InvalidSplit); + } + if lp_bps + treas_bps + backstop_bps != 10_000 { + panic_with_error!(&env, Error::InvalidSplit); + } + let split = PremiumSplit { lp_bps, treas_bps, backstop_bps }; + env.storage().instance().set(&StorageKey::PremiumSplit, &split); + env.events().publish( + (Symbol::new(&env, "premium_split_updated"),), + PremiumSplitUpdated { lp_bps, treas_bps, backstop_bps }, + ); + } + + /// Admin-only: propose a time-locked change to premium split ratios. + /// LPs have `PARAMETER_TIMELOCK_SECONDS` (2 days) to exit before the change takes effect. + pub fn propose_parameter_change( + env: Env, + admin: Address, + lp_bps: i128, + treas_bps: i128, + backstop_bps: i128, + ) { + Self::require_admin(&env, &admin); + if lp_bps < 0 || treas_bps < 0 || backstop_bps < 0 { + panic_with_error!(&env, Error::InvalidSplit); + } + if lp_bps + treas_bps + backstop_bps != 10_000 { + panic_with_error!(&env, Error::InvalidSplit); + } + if env.storage().persistent().has(&StorageKey::PendingParameterChange) { + panic_with_error!(&env, Error::ParameterChangePending); + } + + let now = env.ledger().timestamp(); + let executable_after = now + PARAMETER_TIMELOCK_SECONDS; + + let pending = PendingParameterChange { + new_lp_bps: lp_bps, + new_treas_bps: treas_bps, + new_backstop_bps: backstop_bps, + proposed_at: now, + executable_after, + executed: false, + }; + env.storage().persistent().set(&StorageKey::PendingParameterChange, &pending); + Self::extend_to_max(&env, &StorageKey::PendingParameterChange); + + env.events().publish( + (Symbol::new(&env, "parameter_change_scheduled"),), + ParameterChangeScheduled { + admin: admin.clone(), + new_lp_bps: lp_bps, + new_treas_bps: treas_bps, + new_backstop_bps: backstop_bps, + executable_after, + }, + ); + } + + /// Execute a previously proposed parameter change after the timelock expires. + pub fn execute_parameter_change(env: Env, admin: Address) { + Self::require_admin(&env, &admin); + let mut pending: PendingParameterChange = env.storage().persistent() + .get(&StorageKey::PendingParameterChange) + .unwrap_or_else(|| panic_with_error!(&env, Error::NoPendingParameterChange)); + + if pending.executed { + panic_with_error!(&env, Error::AlreadyReleased); + } + + let now = env.ledger().timestamp(); + if now < pending.executable_after { + panic_with_error!(&env, Error::ParameterChangeNotReady); + } + + // Apply the parameter change + let split = PremiumSplit { + lp_bps: pending.new_lp_bps, + treas_bps: pending.new_treas_bps, + backstop_bps: pending.new_backstop_bps, + }; + env.storage().instance().set(&StorageKey::PremiumSplit, &split); + + // Mark as executed + pending.executed = true; + env.storage().persistent().set(&StorageKey::PendingParameterChange, &pending); + + env.events().publish( + (Symbol::new(&env, "parameter_change_executed"),), + ParameterChangeExecuted { + admin: admin.clone(), + lp_bps: pending.new_lp_bps, + treas_bps: pending.new_treas_bps, + backstop_bps: pending.new_backstop_bps, + }, + ); + } + + /// Cancel a pending parameter change. + pub fn cancel_parameter_change(env: Env, admin: Address) { + Self::require_admin(&env, &admin); + if !env.storage().persistent().has(&StorageKey::PendingParameterChange) { + panic_with_error!(&env, Error::NoPendingParameterChange); + } + env.storage().persistent().remove(&StorageKey::PendingParameterChange); + + env.events().publish( + (Symbol::new(&env, "parameter_change_cancelled"),), + ParameterChangeCancelled { admin: admin.clone() }, + ); + } + + /// Get the pending parameter change, if any. + pub fn get_pending_parameter_change(env: Env) -> Option { + env.storage().persistent().get(&StorageKey::PendingParameterChange) + } + + /// Request an emergency withdrawal of unlocked liquidity. + /// A 7-day timelock begins; LPs can exit before it matures. + /// 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); } + + let available = Self::get_available_liquidity(env.clone()); + if amount > available { panic_with_error!(&env, Error::Undercollateralized); } + + if env.storage().persistent().has(&StorageKey::AdminWithdrawalRequest) { + panic_with_error!(&env, Error::TimelockPending); + } + + let now = env.ledger().timestamp(); + // Freeze the deadline now. Recomputing it at execution time would let a + // contract upgrade that shortens TIMELOCK_SECONDS cut the wait on a + // request LPs have already seen published. + let execute_after = now + TIMELOCK_SECONDS; + env.storage().persistent().set( + &StorageKey::AdminWithdrawalRequest, + &AdminWithdrawalRequest { + amount, + requested_at: now, + execute_after, + executed: false, + }, + ); + Self::extend_withdrawal_ttl(&env, &StorageKey::AdminWithdrawalRequest); + + env.events().publish( + (Symbol::new(&env, "admin_withdrawal_scheduled"),), + AdminWithdrawalScheduled { + admin: admin.clone(), + amount, + execute_after, + }, + ); + } + + /// Execute a previously requested admin withdrawal after the 7-day timelock. + /// 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() + .get(&StorageKey::AdminWithdrawalRequest) + .unwrap_or_else(|| panic_with_error!(&env, Error::NoPendingWithdrawal)); + + if req.executed { panic_with_error!(&env, Error::AlreadyReleased); } + + let now = env.ledger().timestamp(); + if now < req.execute_after { + panic_with_error!(&env, Error::TimelockNotReady); + } + + // Re-check unlocked liquidity with fresh totals. `request_admin_withdrawal` + // validated the amount at request time, but new capital locks created during + // the timelock can shrink the available balance — executing blindly would + // drain funds earmarked as policy collateral. + let available = Self::get_available_liquidity(env.clone()); + if req.amount > available { panic_with_error!(&env, Error::Undercollateralized); } + + 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); + + let mut req = req; + req.executed = true; + env.storage().persistent().set(&StorageKey::AdminWithdrawalRequest, &req); + Self::extend_withdrawal_ttl(&env, &StorageKey::AdminWithdrawalRequest); + + 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"),), + AdminWithdrawalExecuted { + admin: admin.clone(), + amount: req.amount, + }, + ); + } + + /// 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) { + panic_with_error!(&env, Error::NoPendingWithdrawal); + } + env.storage().persistent().remove(&StorageKey::AdminWithdrawalRequest); + + env.events().publish( + (Symbol::new(&env, "admin_withdrawal_cancelled"),), + 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. + /// + /// If a guardian threshold > 0 is configured (`set_guardians`), this call + /// does not upgrade immediately — it registers the upgrade as pending and + /// requires `threshold` guardians to call `approve_upgrade` before the + /// WASM is actually replaced, guarding this irreversible operation + /// against a single compromised admin key. + 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); + if new_version <= current_version { + panic_with_error!(&env, Error::InvalidVersion); + } + + let threshold: u32 = env + .storage() + .instance() + .get(&StorageKey::GuardianThreshold) + .unwrap_or(0); + if threshold == 0 { + Self::run_migrations(&env, current_version, new_version); + env.storage().instance().set(&StorageKey::Version, &new_version); + env.deployer().update_current_contract_wasm(new_wasm_hash); + + env.events().publish( + (Symbol::new(&env, "contract_upgraded"),), + ContractUpgraded { + old_version: current_version, + new_version, + }, + ); + return; + } + + let pending = PendingUpgrade { + new_wasm_hash, + new_version, + approvals: Vec::new(&env), + }; + env.storage().instance().set(&StorageKey::PendingUpgrade, &pending); + } + + /// 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); } + } + + /// Configure the guardian set and approval threshold required for + /// critical actions (upgrades, admin transfer). Admin-only. + /// `threshold == 0` disables the guardian requirement (default), so the + /// admin alone can act — preserves existing single-admin behavior until + /// guardians are explicitly configured. + pub fn set_guardians(env: Env, admin: Address, guardians: Vec
, threshold: u32) { + Self::require_admin(&env, &admin); + if threshold > guardians.len() { + panic_with_error!(&env, Error::InvalidThreshold); + } + env.storage().instance().set(&StorageKey::Guardians, &guardians); + env.storage() + .instance() + .set(&StorageKey::GuardianThreshold, &threshold); + env.events().publish( + (Symbol::new(&env, "guardians_updated"),), + GuardiansUpdated { guardians, threshold }, + ); + } + + /// Return the current guardian set. + pub fn get_guardians(env: Env) -> Vec
{ + env.storage() + .instance() + .get(&StorageKey::Guardians) + .unwrap_or_else(|| Vec::new(&env)) + } + + /// Return the current guardian approval threshold (0 = disabled). + pub fn get_guardian_threshold(env: Env) -> u32 { + env.storage() + .instance() + .get(&StorageKey::GuardianThreshold) + .unwrap_or(0) + } + + /// Return the pending upgrade awaiting guardian approvals, if any. + pub fn get_pending_upgrade(env: Env) -> Option { + env.storage().instance().get(&StorageKey::PendingUpgrade) + } + + /// Ledger timestamp at which the current pending admin transfer was + /// registered, or `0` if none. `accept_admin` succeeds only once + /// `now >= this + ADMIN_TRANSFER_TIMELOCK` (issue #356). + pub fn get_pending_admin_since(env: Env) -> u64 { + env.storage().instance().get(&StorageKey::PendingAdminSince).unwrap_or(0) + } + + /// Guardian approval for the pending upgrade. Once enough guardians have + /// approved (>= threshold), the upgrade executes immediately. + pub fn approve_upgrade(env: Env, guardian: Address, new_wasm_hash: soroban_sdk::BytesN<32>) { + guardian.require_auth(); + + let guardians: Vec
= env + .storage() + .instance() + .get(&StorageKey::Guardians) + .unwrap_or_else(|| Vec::new(&env)); + let mut is_guardian = false; + for g in guardians.iter() { + if g == guardian { + is_guardian = true; + break; + } + } + if !is_guardian { + panic_with_error!(&env, Error::NotGuardian); + } + + let mut pending: PendingUpgrade = env + .storage() + .instance() + .get(&StorageKey::PendingUpgrade) + .unwrap_or_else(|| panic_with_error!(&env, Error::NoPendingUpgrade)); + if pending.new_wasm_hash != new_wasm_hash { + panic_with_error!(&env, Error::NoPendingUpgrade); + } + for a in pending.approvals.iter() { + if a == guardian { + panic_with_error!(&env, Error::AlreadyApprovedAction); + } + } + pending.approvals.push_back(guardian.clone()); + + let threshold: u32 = env + .storage() + .instance() + .get(&StorageKey::GuardianThreshold) + .unwrap_or(0); + + env.events().publish( + (Symbol::new(&env, "upgrade_approved"),), + UpgradeApproved { + new_wasm_hash: new_wasm_hash.clone(), + approver: guardian, + approvals: pending.approvals.len(), + threshold, + }, + ); + + if pending.approvals.len() >= threshold { + let current_version: u32 = + env.storage().instance().get(&StorageKey::Version).unwrap_or(1); + env.storage().instance().remove(&StorageKey::PendingUpgrade); + Self::run_migrations(&env, current_version, pending.new_version); + env.storage() + .instance() + .set(&StorageKey::Version, &pending.new_version); + env.deployer().update_current_contract_wasm(new_wasm_hash); + + env.events().publish( + (Symbol::new(&env, "contract_upgraded"),), + ContractUpgraded { + old_version: current_version, + new_version: pending.new_version, + }, + ); + } else { + env.storage().instance().set(&StorageKey::PendingUpgrade, &pending); + } + } + + /// Admin-only: cancel a pending upgrade before it collects enough + /// guardian approvals. + pub fn cancel_pending_upgrade(env: Env, admin: Address) { + Self::require_admin(&env, &admin); + if !env.storage().instance().has(&StorageKey::PendingUpgrade) { + panic_with_error!(&env, Error::NoPendingUpgrade); + } + env.storage().instance().remove(&StorageKey::PendingUpgrade); + } + + /// Propose a new admin. Only the current admin can call this. + /// + /// If a guardian threshold > 0 is configured (`set_guardians`), this does + /// not activate the proposal immediately — it requires `threshold` + /// guardians to call `approve_admin_change` first, guarding this + /// takeover-capable operation against a single compromised admin key. + /// With no guardians configured (default), behavior is unchanged. + pub fn propose_new_admin(env: Env, admin: Address, new_admin: Address) { + Self::require_admin(&env, &admin); + Self::validate_stellar_address(&env, &new_admin); + + let threshold: u32 = env + .storage() + .instance() + .get(&StorageKey::GuardianThreshold) + .unwrap_or(0); + if threshold == 0 { + env.storage().instance().set(&StorageKey::PendingAdmin, &new_admin); + env.storage() + .instance() + .set(&StorageKey::PendingAdminSince, &env.ledger().timestamp()); + return; + } + + let pending = PendingAdminChange { + new_admin, + approvals: Vec::new(&env), + }; + env.storage() + .instance() + .set(&StorageKey::PendingAdminChange, &pending); + } + + /// Guardian approval for a pending admin-change proposal. Once enough + /// guardians have approved (>= threshold), the change is activated — + /// `new_admin` must then still call `accept_admin` to take effect. + pub fn approve_admin_change(env: Env, guardian: Address, new_admin: Address) { + guardian.require_auth(); + + let guardians: Vec
= env + .storage() + .instance() + .get(&StorageKey::Guardians) + .unwrap_or_else(|| Vec::new(&env)); + let mut is_guardian = false; + for g in guardians.iter() { + if g == guardian { + is_guardian = true; + break; + } + } + if !is_guardian { + panic_with_error!(&env, Error::NotGuardian); + } + + let mut pending: PendingAdminChange = env + .storage() + .instance() + .get(&StorageKey::PendingAdminChange) + .unwrap_or_else(|| panic_with_error!(&env, Error::NoPendingUpgrade)); + if pending.new_admin != new_admin { + panic_with_error!(&env, Error::NoPendingUpgrade); + } + for a in pending.approvals.iter() { + if a == guardian { + panic_with_error!(&env, Error::AlreadyApprovedAction); + } + } + pending.approvals.push_back(guardian); + + let threshold: u32 = env + .storage() + .instance() + .get(&StorageKey::GuardianThreshold) + .unwrap_or(0); + + if pending.approvals.len() >= threshold { + env.storage().instance().remove(&StorageKey::PendingAdminChange); + env.storage().instance().set(&StorageKey::PendingAdmin, &new_admin); + env.storage() + .instance() + .set(&StorageKey::PendingAdminSince, &env.ledger().timestamp()); + } else { + env.storage() + .instance() + .set(&StorageKey::PendingAdminChange, &pending); + } + } + + /// Accept the proposed admin. Only the proposed admin can call this, and + /// only once `ADMIN_TRANSFER_TIMELOCK` has elapsed since the transfer was + /// registered (issue #356). + 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 admin != pending_admin { + panic_with_error!(&env, Error::Unauthorized); + } + admin.require_auth(); + let since: u64 = env.storage().instance() + .get(&StorageKey::PendingAdminSince).unwrap_or(0); + if env.ledger().timestamp() < since.saturating_add(ADMIN_TRANSFER_TIMELOCK) { + panic_with_error!(&env, Error::AdminTimelockNotExpired); + } + // Update admin + env.storage().instance().set(&StorageKey::Admin, &admin); + // Clear the proposal + env.storage().instance().remove(&StorageKey::PendingAdmin); + env.storage().instance().remove(&StorageKey::PendingAdminSince); + // Emit event + env.events().publish( + (Symbol::new(&env, "admin_updated"),), + 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) + .unwrap_or_else(|| panic_with_error!(env, Error::NotInitialized)); + 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) + .unwrap_or_else(|| panic_with_error!(env, Error::NotInitialized)); + if *caller != admin && *caller != pe && *caller != cp { + panic_with_error!(env, Error::Unauthorized); + } + caller.require_auth(); + } + + fn require_admin(env: &Env, caller: &Address) { + 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); } + caller.require_auth(); + } + + /// Validate that an address has a valid Stellar format (56-char, starts with G or C). + fn validate_stellar_address(env: &Env, address: &Address) { + let addr_str = address.to_string(); + if addr_str.len() != 56 { + panic_with_error!(env, Error::InvalidAddress); + } + let mut buf = [0u8; 56]; + addr_str.copy_into_slice(&mut buf); + if buf[0] != b'G' && buf[0] != b'C' { + panic_with_error!(env, Error::InvalidAddress); + } + } + + /// Return the current premium split ratios in basis points, falling back to the + /// compiled-in defaults if the admin has never called `update_premium_split`. + fn get_premium_split_bps(env: &Env) -> (i128, i128, i128) { + match env.storage().instance().get::<_, PremiumSplit>(&StorageKey::PremiumSplit) { + Some(split) => (split.lp_bps, split.treas_bps, split.backstop_bps), + None => (DEFAULT_PREMIUM_LP_BPS, DEFAULT_PREMIUM_TREAS_BPS, DEFAULT_PREMIUM_BACKSTOP_BPS), + } + } + + 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); } + } + + /// Withdrawals are allowed while the pool is `Active` or `WindingDown`, but not + /// while `Paused`. + /// Configured capacity limits, or the built-in defaults. + /// + /// The default `max_total_deposited` is the historical `MAX_TOTAL_DEPOSITED` + /// constant and the default utilization ceiling is 100%, so an existing + /// pool behaves exactly as before until an admin sets a limit. + fn capacity(env: &Env) -> PoolCapacity { + env.storage() + .instance() + .get(&StorageKey::Capacity) + .unwrap_or(PoolCapacity { + max_total_deposited: MAX_TOTAL_DEPOSITED, + max_utilization_bps: DEFAULT_MAX_UTILIZATION_BPS, + }) + } + + /// Configured withdrawal delay in seconds (default 0 — instant). + fn exit_delay(env: &Env) -> u64 { + env.storage() + .instance() + .get(&StorageKey::ExitDelay) + .unwrap_or(0) + } + + /// `total_locked / total_deposited` in basis points. An empty pool is 0% + /// utilized rather than a division by zero. + fn utilization_bps(total_locked: i128, total_deposited: i128) -> u32 { + if total_deposited <= 0 { + return 0; + } + let bps = total_locked.saturating_mul(10_000) / total_deposited; + bps.clamp(0, 10_000) as u32 + } + + /// Remove a provider's exit request and release its share reservation. + fn clear_exit_reservation(env: &Env, provider: &Address, shares: i128) { + env.storage() + .persistent() + .remove(&StorageKey::ExitReq(provider.clone())); + + let queued: i128 = env + .storage() + .instance() + .get(&StorageKey::QueuedExitShares) + .unwrap_or(0); + env.storage() + .instance() + .set(&StorageKey::QueuedExitShares, &queued.saturating_sub(shares).max(0)); + } + + fn assert_withdrawable(env: &Env) { + let status: PoolStatus = env.storage().instance() + .get(&StorageKey::Status).unwrap_or(PoolStatus::Active); + if status == PoolStatus::Paused { panic_with_error!(env, Error::PoolNotActive); } + } + + /// Extend a persistent entry's TTL to the network maximum. Used for + /// LP position/index records, which have no natural expiry (issue #244). + fn extend_to_max(env: &Env, key: &StorageKey) { + let max_ttl = env.storage().max_ttl(); + env.storage().persistent().extend_ttl(key, max_ttl, max_ttl); + } + + /// Extend an `AdminWithdrawalRequest` entry's TTL. `TTL_THRESHOLD`/`TTL_EXTEND_TO` + /// (~30 days / ~1 year) comfortably cover the `TIMELOCK_SECONDS` (7-day) wait + /// between requesting and executing a withdrawal (issue #244). + fn extend_withdrawal_ttl(env: &Env, key: &StorageKey) { + env.storage().persistent().extend_ttl(key, TTL_THRESHOLD, TTL_EXTEND_TO); + } + + /// Settle `position`'s accrued yield in memory (updates `yield_claimed`, + /// `last_yield_claim`, and `yield_debt`) and return the amount owed to the + /// provider, if any. Does **not** transfer tokens or emit events — callers + /// must finish persisting all state first, then call + /// [`Self::pay_out_yield`] last, so the external token transfer happens + /// only after every state mutation for the operation has landed + /// (checks-effects-interactions; avoids reentering mid-deposit/withdraw). + fn settle_yield(env: &Env, position: &mut LpPosition) -> i128 { + 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 0; + } + + let entitled = (acc_per_share * position.shares) / 1_000_000_000_000; + let claimable = entitled.saturating_sub(position.yield_debt); + if claimable > 0 { + position.yield_claimed += claimable; + position.last_yield_claim = env.ledger().timestamp(); + position.yield_debt = entitled; + } + claimable + } + + // ── LP NFT (Soulbound Token) ───────────────────────────────────────────── + + /// Toggle compound yield on/off for the caller's LP position. + /// When enabled, accrued yield is reinvested into additional shares instead + /// of being paid out as USDC on deposit/claim. + pub fn toggle_compound(env: Env, provider: Address, enabled: bool) { + provider.require_auth(); + 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)); + position.compound_enabled = enabled; + env.storage().persistent().set(&lp_key, &position); + Self::extend_to_max(&env, &lp_key); + + env.events().publish( + (Symbol::new(&env, "compound_yield_toggled"),), + CompoundYieldToggled { + provider, + enabled, + }, + ); + } + + /// Return the collateralization ratio for an LP position. + /// + /// The ratio measures how well-backed the LP's position is by available + /// liquidity relative to their share of locked capital. A ratio below + /// 10000 (100%) indicates the position is undercollateralized. + /// + /// - `position_value` = LP's proportional share of available liquidity + /// (total_deposited - total_locked) based on their share ratio. + /// - `position_liability` = LP's proportional share of locked capital. + /// - `collateralization_bps` = (position_value / position_liability) * 10000, + /// or u32::MAX if there is no locked liability. + pub fn get_collateralization_ratio(env: Env, provider: Address) -> CollateralizationInfo { + let position: LpPosition = env + .storage() + .persistent() + .get(&StorageKey::LpPosition(provider.clone())) + .unwrap_or_else(|| panic_with_error!(&env, Error::NoShares)); + + 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_shares: i128 = env + .storage().instance().get(&StorageKey::TotalShares).unwrap_or(0); + + if total_shares == 0 || position.shares == 0 { + return CollateralizationInfo { + provider, + position_value: 0, + position_liability: 0, + collateralization_bps: u32::MAX, + }; + } + + // LP's proportional share of available liquidity (deposited - locked) + let available = total_deposited.saturating_sub(total_locked); + let position_value = position.shares + .checked_mul(available) + .and_then(|v| v.checked_div(total_shares)) + .unwrap_or(0); + + // LP's proportional share of locked capital + let position_liability = position.shares + .checked_mul(total_locked) + .and_then(|v| v.checked_div(total_shares)) + .unwrap_or(0); + + // Calculate collateralization ratio in basis points + let collateralization_bps = if position_liability <= 0 { + u32::MAX // No liability = fully collateralized + } else { + let ratio = position_value + .checked_mul(10_000) + .and_then(|v| v.checked_div(position_liability)) + .unwrap_or(0); + if ratio > u32::MAX as i128 { u32::MAX } else { ratio as u32 } + }; + + CollateralizationInfo { + provider, + position_value, + position_liability, + collateralization_bps, + } + } + + /// Liquidate an undercollateralized LP position. + /// + /// When an LP's collateralization ratio falls below 100% (10000 bps), + /// their position poses a risk to other LPs. This function seizes a + /// portion of their shares to restore pool health. + /// + /// The seizure is proportional to the undercollateralization程度: + /// - At 50% collateralization, ~50% of shares are seized. + /// - At 0% collateralization, all shares are seized. + /// + /// Only callable by the admin or the claims processor. + pub fn liquidate_position(env: Env, caller: Address, provider: Address) -> LiquidationResult { + Self::require_protocol_caller(&env, &caller); + + let position: LpPosition = env + .storage() + .persistent() + .get(&StorageKey::LpPosition(provider.clone())) + .unwrap_or_else(|| panic_with_error!(&env, Error::NoShares)); + + let info = Self::get_collateralization_ratio(env.clone(), provider.clone()); + + // Cannot liquidate if fully collateralized or no liability + if info.collateralization_bps >= 10_000 || info.position_liability <= 0 { + panic_with_error!(&env, Error::Undercollateralized); + } + + // Calculate shares to seize: proportional to undercollateralization + // At 0 bps ratio, seize all shares. At 5000 bps (50%), seize ~50%. + let undercollateral_bps = 10_000u32.saturating_sub(info.collateralization_bps); + let shares_seized = position.shares + .checked_mul(undercollateral_bps as i128) + .and_then(|v| v.checked_div(10_000)) + .unwrap_or(0); + + if shares_seized <= 0 { + panic_with_error!(&env, Error::Undercollateralized); + } + + // Calculate amount recovered (proportional to shares seized) + 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 amount_recovered = if total_shares > 0 { + shares_seized + .checked_mul(total_deposited) + .and_then(|v| v.checked_div(total_shares)) + .unwrap_or(0) + } else { + 0 + }; + + // Update position + let mut new_position = position.clone(); + new_position.shares = new_position.shares.saturating_sub(shares_seized); + new_position.deposited = new_position.deposited.saturating_sub(amount_recovered); + + if new_position.shares <= 0 { + env.storage().persistent().remove(&StorageKey::LpPosition(provider.clone())); + } else { + env.storage().persistent().set( + &StorageKey::LpPosition(provider.clone()), + &new_position, + ); + } + + // Update pool totals + let current_total_shares: i128 = env + .storage().instance().get(&StorageKey::TotalShares).unwrap_or(0); + let current_total_deposited: i128 = env + .storage().instance().get(&StorageKey::TotalDeposited).unwrap_or(0); + env.storage().instance().set( + &StorageKey::TotalShares, + ¤t_total_shares.saturating_sub(shares_seized), + ); + env.storage().instance().set( + &StorageKey::TotalDeposited, + ¤t_total_deposited.saturating_sub(amount_recovered), + ); + + // Return seized funds to pool (they stay as available liquidity) + // The recovered amount stays in the contract as pool liquidity + + env.events().publish( + (Symbol::new(&env, "position_liquidated"),), + PositionLiquidated { + provider: provider.clone(), + shares_seized, + amount_recovered, + collateralization_bps: info.collateralization_bps, + }, + ); + + LiquidationResult { + provider, + shares_seized, + amount_recovered, + previous_ratio_bps: info.collateralization_bps, + } + } + + /// Return the LP NFT for a given token ID, if it exists. + pub fn get_lp_nft(env: Env, token_id: u64) -> Option { + env.storage().persistent().get(&StorageKey::LpNft(token_id)) + } + + /// Return the LP NFT token ID for a given provider address, if they have one. + pub fn get_provider_nft(env: Env, provider: Address) -> Option { + env.storage().persistent().get(&StorageKey::ProviderNft(provider)) + } + + /// Return the next LP NFT token ID that will be minted. + pub fn get_next_nft_id(env: Env) -> u64 { + env.storage().instance().get(&StorageKey::NextNftId).unwrap_or(1) + } + + /// Mint a soulbound LP NFT for a new provider. Called internally on first deposit. + fn mint_lp_nft( + env: &Env, + provider: &Address, + category: &Symbol, + minted_at: u64, + position: &LpPosition, + ) { + let token_id: u64 = env.storage().instance() + .get(&StorageKey::NextNftId).unwrap_or(1); + env.storage().instance().set(&StorageKey::NextNftId, &(token_id + 1)); + + let nft = LpNft { + token_id, + provider: provider.clone(), + category: category.clone(), + minted_at, + shares: position.shares, + deposited: position.deposited, + active: true, + }; + env.storage().persistent().set(&StorageKey::LpNft(token_id), &nft); + Self::extend_to_max(env, &StorageKey::LpNft(token_id)); + env.storage().persistent().set(&StorageKey::ProviderNft(provider.clone()), &token_id); + Self::extend_to_max(env, &StorageKey::ProviderNft(provider.clone())); + + env.events().publish( + (Symbol::new(env, "lp_nft_minted"),), + LpNftMinted { + token_id, + provider: provider.clone(), + category: category.clone(), + minted_at, + }, + ); + } + + /// Update an existing LP NFT after deposit/withdraw. Called internally. + fn update_lp_nft(env: &Env, provider: &Address, position: &LpPosition) { + let token_id: u64 = match env.storage().persistent().get::<_, u64>(&StorageKey::ProviderNft(provider.clone())) { + Some(id) => id, + None => return, // No NFT yet (shouldn't happen after mint) + }; + let mut nft: LpNft = match env.storage().persistent().get(&StorageKey::LpNft(token_id)) { + Some(n) => n, + None => return, + }; + nft.shares = position.shares; + nft.deposited = position.deposited; + nft.active = position.shares > 0; + env.storage().persistent().set(&StorageKey::LpNft(token_id), &nft); + + env.events().publish( + (Symbol::new(env, "lp_nft_updated"),), + LpNftUpdated { + token_id, + provider: provider.clone(), + shares: position.shares, + deposited: position.deposited, + active: nft.active, + }, + ); + } + + /// Transfer a previously-settled yield `amount` to `provider` and emit + /// `yield_claimed`. Must be called only after all other state for the + /// current operation has been persisted — see [`Self::settle_yield`]. + fn pay_out_yield(env: &Env, provider: &Address, amount: i128) { + if amount <= 0 { + return; + } + let usdc: Address = env.storage().instance().get(&StorageKey::UsdcToken).unwrap(); + token::Client::new(env, &usdc) + .transfer(&env.current_contract_address(), provider, &amount); + + env.events().publish( + (Symbol::new(env, "yield_claimed"),), + YieldClaimed { + provider: provider.clone(), + amount, + }, + ); + } +} + +#[cfg(test)] +mod test; +#[cfg(test)] +mod test_advanced; +#[cfg(test)] +mod test_edge; +#[cfg(test)] +mod test_reinsurance; diff --git a/contracts/risk-pool/src/test_reinsurance.rs b/contracts/risk-pool/src/test_reinsurance.rs new file mode 100644 index 0000000..93c4280 --- /dev/null +++ b/contracts/risk-pool/src/test_reinsurance.rs @@ -0,0 +1,304 @@ +#![allow(clippy::inconsistent_digit_grouping)] +//! Reinsurance tests (issue #382): configuring an external reinsurance +//! arrangement, ceding premium to it, and recovering catastrophic claim +//! losses from it. + +#![cfg(test)] + +extern crate std; + +use soroban_sdk::{testutils::Address as _, token, Address, Env, Symbol}; + +use crate::{RiskPool, RiskPoolClient}; + +// Each mock reinsurer lives in its own module: `#[contractimpl]` generates +// module-scoped spec constants keyed only by function name (e.g. +// `__SPEC_XDR_FN_RECOVER`), so two `#[contract]` types in the same module +// both exposing `recover`/`init` collide at compile time. The function +// *names* still have to match across all three (`init`, `recover`) since +// `recover` is the on-chain entry point `ReinsurerClient` dispatches to. + +/// A well-behaved reinsurer: pays out exactly what was requested and +/// reports having paid exactly that. +mod mock_reinsurer { + use soroban_sdk::{token, Address, Env, Symbol}; + + #[soroban_sdk::contract] + pub struct MockReinsurer; + + #[soroban_sdk::contractimpl] + impl MockReinsurer { + pub fn init(env: Env, usdc: Address) { + env.storage().instance().set(&Symbol::new(&env, "usdc"), &usdc); + } + + pub fn recover(env: Env, caller: Address, _policy_id: u128, amount: i128) -> i128 { + let usdc: Address = env.storage().instance().get(&Symbol::new(&env, "usdc")).unwrap(); + token::Client::new(&env, &usdc).transfer(&env.current_contract_address(), &caller, &amount); + amount + } + } +} +pub use mock_reinsurer::{MockReinsurer, MockReinsurerClient}; + +/// A reinsurer whose own capacity is exhausted: it only ever pays out (and +/// reports) half of what is requested. +mod mock_stingy_reinsurer { + use soroban_sdk::{token, Address, Env, Symbol}; + + #[soroban_sdk::contract] + pub struct MockStingyReinsurer; + + #[soroban_sdk::contractimpl] + impl MockStingyReinsurer { + pub fn init(env: Env, usdc: Address) { + env.storage().instance().set(&Symbol::new(&env, "usdc"), &usdc); + } + + pub fn recover(env: Env, caller: Address, _policy_id: u128, amount: i128) -> i128 { + let usdc: Address = env.storage().instance().get(&Symbol::new(&env, "usdc")).unwrap(); + let half = amount / 2; + token::Client::new(&env, &usdc).transfer(&env.current_contract_address(), &caller, &half); + half + } + } +} +pub use mock_stingy_reinsurer::{MockStingyReinsurer, MockStingyReinsurerClient}; + +/// A misbehaving (or buggy) reinsurer: transfers only what was requested +/// but *reports* having paid double. Exercises the pool's defensive clamp. +mod mock_over_reporting_reinsurer { + use soroban_sdk::{token, Address, Env, Symbol}; + + #[soroban_sdk::contract] + pub struct MockOverReportingReinsurer; + + #[soroban_sdk::contractimpl] + impl MockOverReportingReinsurer { + pub fn init(env: Env, usdc: Address) { + env.storage().instance().set(&Symbol::new(&env, "usdc"), &usdc); + } + + pub fn recover(env: Env, caller: Address, _policy_id: u128, amount: i128) -> i128 { + let usdc: Address = env.storage().instance().get(&Symbol::new(&env, "usdc")).unwrap(); + token::Client::new(&env, &usdc).transfer(&env.current_contract_address(), &caller, &amount); + amount * 2 + } + } +} +pub use mock_over_reporting_reinsurer::{MockOverReportingReinsurer, MockOverReportingReinsurerClient}; + +fn setup() -> (Env, RiskPoolClient<'static>, Address, Address, Address) { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let treasury = 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); + + pool.initialize( + &admin, + &usdc_id, + &treasury, + &backstop_id, + &Symbol::new(&env, "crop"), + &policy_engine, + &claims_processor, + ); + + (env, pool, usdc_id, admin, claims_processor) +} + +#[test] +fn set_reinsurance_requires_admin() { + let (env, pool, _usdc, _admin, _cp) = setup(); + let reinsurer = Address::generate(&env); + let impostor = Address::generate(&env); + let result = pool.try_set_reinsurance(&impostor, &reinsurer, &1_000_0000000i128, &100_000_0000000i128); + assert!(result.is_err()); +} + +#[test] +fn get_reinsurance_is_none_until_configured() { + let (_env, pool, _usdc, _admin, _cp) = setup(); + assert_eq!(pool.get_reinsurance(), None); + let stats = pool.get_reinsurance_stats(); + assert_eq!(stats.total_premium_paid, 0); + assert_eq!(stats.total_recovered, 0); + assert_eq!(stats.coverage_remaining, 0); +} + +#[test] +fn admin_can_configure_reinsurance() { + let (env, pool, _usdc, admin, _cp) = setup(); + let reinsurer = Address::generate(&env); + pool.set_reinsurance(&admin, &reinsurer, &1_000_0000000i128, &100_000_0000000i128); + + let cfg = pool.get_reinsurance().unwrap(); + assert_eq!(cfg.reinsurer, reinsurer); + assert_eq!(cfg.attachment_point, 1_000_0000000i128); + assert_eq!(cfg.coverage_limit, 100_000_0000000i128); + assert!(cfg.active); +} + +#[test] +fn send_premium_to_reinsurer_requires_configuration() { + let (_env, pool, _usdc, admin, _cp) = setup(); + let result = pool.try_send_premium_to_reinsurer(&admin, &1_000_0000000i128); + assert!(result.is_err()); +} + +#[test] +fn send_premium_to_reinsurer_transfers_and_tracks_total() { + let (env, pool, usdc, admin, _cp) = setup(); + let reinsurer = Address::generate(&env); + pool.set_reinsurance(&admin, &reinsurer, &1_000_0000000i128, &100_000_0000000i128); + + // Fund the pool itself, as premium income would. + token::StellarAssetClient::new(&env, &usdc).mint(&pool.address, &50_000_0000000i128); + + pool.send_premium_to_reinsurer(&admin, &10_000_0000000i128); + + assert_eq!(token::Client::new(&env, &usdc).balance(&reinsurer), 10_000_0000000i128); + assert_eq!(pool.get_reinsurance_stats().total_premium_paid, 10_000_0000000i128); +} + +/// A claim loss below the attachment point is retained by the pool +/// entirely — no recovery is requested or paid. +#[test] +fn loss_below_attachment_point_recovers_nothing() { + let (env, pool, usdc, admin, claims_processor) = setup(); + let reinsurer_id = env.register(MockReinsurer, ()); + MockReinsurerClient::new(&env, &reinsurer_id).init(&usdc); + token::StellarAssetClient::new(&env, &usdc).mint(&reinsurer_id, &1_000_000_0000000i128); + + pool.set_reinsurance(&admin, &reinsurer_id, &10_000_0000000i128, &1_000_000_0000000i128); + + let recovered = pool.request_reinsurance_recovery(&claims_processor, &1u128, &5_000_0000000i128); + assert_eq!(recovered, 0); + assert_eq!(pool.get_reinsurance_stats().total_recovered, 0); +} + +/// A claim loss above the attachment point recovers the excess from the +/// reinsurer, and that recovery replenishes `total_deposited`. +#[test] +fn loss_above_attachment_point_recovers_the_excess() { + let (env, pool, usdc, admin, claims_processor) = setup(); + let reinsurer_id = env.register(MockReinsurer, ()); + MockReinsurerClient::new(&env, &reinsurer_id).init(&usdc); + token::StellarAssetClient::new(&env, &usdc).mint(&reinsurer_id, &1_000_000_0000000i128); + + pool.set_reinsurance(&admin, &reinsurer_id, &10_000_0000000i128, &1_000_000_0000000i128); + + let deposited_before = pool.get_stats().total_deposited; + + // Loss of 50,000; attachment point 10,000 → excess of 40,000 eligible. + let recovered = pool.request_reinsurance_recovery(&claims_processor, &1u128, &50_000_0000000i128); + assert_eq!(recovered, 40_000_0000000i128); + + let stats = pool.get_reinsurance_stats(); + assert_eq!(stats.total_recovered, 40_000_0000000i128); + assert_eq!(stats.coverage_remaining, 1_000_000_0000000i128 - 40_000_0000000i128); + assert_eq!(pool.get_stats().total_deposited, deposited_before + 40_000_0000000i128); + assert_eq!(token::Client::new(&env, &usdc).balance(&pool.address), 40_000_0000000i128); +} + +/// Recovery never exceeds the cumulative `coverage_limit`, even across +/// multiple claims. +#[test] +fn recovery_is_capped_by_coverage_limit() { + let (env, pool, usdc, admin, claims_processor) = setup(); + let reinsurer_id = env.register(MockReinsurer, ()); + MockReinsurerClient::new(&env, &reinsurer_id).init(&usdc); + token::StellarAssetClient::new(&env, &usdc).mint(&reinsurer_id, &1_000_000_0000000i128); + + // Small lifetime coverage limit: 15,000. + pool.set_reinsurance(&admin, &reinsurer_id, &0i128, &15_000_0000000i128); + + let first = pool.request_reinsurance_recovery(&claims_processor, &1u128, &10_000_0000000i128); + assert_eq!(first, 10_000_0000000i128); + + // Second claim requests 10,000 more, but only 5,000 of coverage remains. + let second = pool.request_reinsurance_recovery(&claims_processor, &2u128, &10_000_0000000i128); + assert_eq!(second, 5_000_0000000i128); + assert_eq!(pool.get_reinsurance_stats().coverage_remaining, 0); + + // A third claim recovers nothing further — coverage is exhausted. + let third = pool.request_reinsurance_recovery(&claims_processor, &3u128, &10_000_0000000i128); + assert_eq!(third, 0); +} + +/// A reinsurer whose own capacity is short pays (and reports) less than +/// requested — the pool records only what actually came back. +#[test] +fn reinsurer_short_capacity_partial_recovery_is_recorded_honestly() { + let (env, pool, usdc, admin, claims_processor) = setup(); + let reinsurer_id = env.register(MockStingyReinsurer, ()); + MockStingyReinsurerClient::new(&env, &reinsurer_id).init(&usdc); + token::StellarAssetClient::new(&env, &usdc).mint(&reinsurer_id, &1_000_000_0000000i128); + + pool.set_reinsurance(&admin, &reinsurer_id, &0i128, &1_000_000_0000000i128); + + let recovered = pool.request_reinsurance_recovery(&claims_processor, &1u128, &10_000_0000000i128); + assert_eq!(recovered, 5_000_0000000i128); // stingy reinsurer only pays half + assert_eq!(pool.get_reinsurance_stats().total_recovered, 5_000_0000000i128); +} + +/// A reinsurer that reports paying more than was requested is clamped to +/// the requested amount — its self-report is never trusted past that. +#[test] +fn over_reporting_reinsurer_is_clamped_to_requested_amount() { + let (env, pool, usdc, admin, claims_processor) = setup(); + let reinsurer_id = env.register(MockOverReportingReinsurer, ()); + MockOverReportingReinsurerClient::new(&env, &reinsurer_id).init(&usdc); + token::StellarAssetClient::new(&env, &usdc).mint(&reinsurer_id, &1_000_000_0000000i128); + + // Coverage limit smaller than what the reinsurer would over-report, + // so a bug that trusted the return value verbatim would blow past it. + pool.set_reinsurance(&admin, &reinsurer_id, &0i128, &12_000_0000000i128); + + let recovered = pool.request_reinsurance_recovery(&claims_processor, &1u128, &10_000_0000000i128); + // Requested 10,000; reinsurer reports 20,000 — clamped to 10,000. + assert_eq!(recovered, 10_000_0000000i128); + assert_eq!(pool.get_reinsurance_stats().total_recovered, 10_000_0000000i128); + assert_eq!(pool.get_reinsurance_stats().coverage_remaining, 2_000_0000000i128); +} + +/// Pausing the arrangement preserves its configuration and stats but +/// blocks further recoveries until it is resumed. +#[test] +fn inactive_reinsurance_recovers_nothing_but_keeps_config() { + let (env, pool, usdc, admin, claims_processor) = setup(); + let reinsurer_id = env.register(MockReinsurer, ()); + MockReinsurerClient::new(&env, &reinsurer_id).init(&usdc); + token::StellarAssetClient::new(&env, &usdc).mint(&reinsurer_id, &1_000_000_0000000i128); + + pool.set_reinsurance(&admin, &reinsurer_id, &0i128, &1_000_000_0000000i128); + pool.set_reinsurance_active(&admin, &false); + + let recovered = pool.request_reinsurance_recovery(&claims_processor, &1u128, &10_000_0000000i128); + assert_eq!(recovered, 0); + assert!(!pool.get_reinsurance().unwrap().active); + + pool.set_reinsurance_active(&admin, &true); + let recovered = pool.request_reinsurance_recovery(&claims_processor, &2u128, &10_000_0000000i128); + assert_eq!(recovered, 10_000_0000000i128); +} + +#[test] +#[should_panic(expected = "Error(Contract, #3)")] +fn request_reinsurance_recovery_requires_protocol_caller() { + let (env, pool, usdc, admin, _cp) = setup(); + let reinsurer_id = env.register(MockReinsurer, ()); + MockReinsurerClient::new(&env, &reinsurer_id).init(&usdc); + pool.set_reinsurance(&admin, &reinsurer_id, &0i128, &1_000_000_0000000i128); + + let impostor = Address::generate(&env); + pool.request_reinsurance_recovery(&impostor, &1u128, &10_000_0000000i128); +} diff --git a/contracts/risk-pool/src/types.rs b/contracts/risk-pool/src/types.rs index 8073da3..cd76a82 100644 --- a/contracts/risk-pool/src/types.rs +++ b/contracts/risk-pool/src/types.rs @@ -1,189 +1,725 @@ -use soroban_sdk::{contracttype, Address, Symbol, Vec}; - -/// Paginated result from `get_lp_list`. -#[contracttype] -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct PaginatedLps { - pub lps: Vec
, - pub total_count: u32, -} - -/// Status of a risk pool. -#[contracttype] -#[derive(Clone, Debug, Eq, PartialEq)] -pub enum PoolStatus { - Active, - Paused, - /// No new deposits; existing LPs can withdraw - WindingDown, -} - -/// A liquidity provider's position in the pool. -#[contracttype] -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct LpPosition { - pub provider: Address, - /// Amount of USDC deposited (7-decimal stroops) - pub deposited: i128, - /// Pool-share tokens held (7-decimal, proportional to ownership) - 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, -} - -/// 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, -} - -/// Aggregate pool stats exposed via queries. -#[contracttype] -#[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 accumulated_backstop: i128, - pub status: PoolStatus, -} - -// ─── Events ────────────────────────────────────────────────────────────────── - -#[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, -} - -#[contracttype] -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct LiquidityDeposited { - pub provider: Address, - pub amount: i128, - pub shares_minted: i128, -} - -#[contracttype] -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct LiquidityWithdrawn { - pub provider: Address, - pub shares_burned: i128, - pub amount_returned: i128, -} - -#[contracttype] -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct PremiumDistributed { - pub amount: i128, - pub lp_share: i128, - pub treasury_share: i128, - pub backstop_share: i128, -} - -#[contracttype] -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct TreasuryFunded { - pub amount: i128, - pub recipient: Address, -} - -#[contracttype] -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct BackstopFunded { - pub amount: i128, - pub recipient: Address, -} - -#[contracttype] -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct YieldClaimed { - pub provider: Address, - pub amount: i128, -} - -#[contracttype] -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct CapitalLocked { - pub policy_id: u128, - pub amount: i128, -} - -#[contracttype] -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct CapitalReleased { - pub policy_id: u128, - pub amount: i128, -} - -#[contracttype] -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct PoolPaused { - pub admin: Address, -} - -#[contracttype] -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct PoolResumed { - pub admin: Address, -} - -/// A timelocked admin withdrawal request. -/// Once created, the admin must wait TIMELOCK_SECONDS before executing. -#[contracttype] -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct AdminWithdrawalRequest { - pub amount: i128, - pub requested_at: u64, - pub executed: bool, -} - -#[contracttype] -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct AdminWithdrawalScheduled { - pub admin: Address, - pub amount: i128, - pub execute_after: u64, -} - -#[contracttype] -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct AdminWithdrawalExecuted { - pub admin: Address, - pub amount: i128, -} - -#[contracttype] -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct AdminWithdrawalCancelled { - pub admin: Address, -} - -#[contracttype] -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct AdminUpdated { - pub new_admin: Address, -} - -#[contracttype] -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct ContractUpgraded { - pub old_version: u32, - pub new_version: u32, -} +use soroban_sdk::{contracttype, Address, BytesN, Symbol, Vec}; + +/// Paginated result from `get_lp_list`. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PaginatedLps { + pub lps: Vec
, + pub total_count: u32, +} + +/// Status of a risk pool. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum PoolStatus { + Active, + Paused, + /// No new deposits; existing LPs can withdraw + WindingDown, +} + +/// Admin-adjustable premium split ratios, in basis points, summing to 10,000. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PremiumSplit { + pub lp_bps: i128, + pub treas_bps: i128, + pub backstop_bps: i128, +} + +/// A liquidity provider's position in the pool. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct LpPosition { + pub provider: Address, + /// Amount of USDC deposited (7-decimal stroops) + pub deposited: i128, + /// Pool-share tokens held (7-decimal, proportional to ownership) + 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, + /// Whether this LP has opted into compound yield (reinvest instead of claim). + pub compound_enabled: bool, + /// Whether this LP has purchased optional insurance coverage for smart contract risk. + /// Insured positions are protected against losses from contract bugs (e.g., infinite mints). + /// Insurance premium is paid via reduced yield allocation. + pub insurance_enabled: bool, + /// Total insurance premium paid by this LP in stroops. + pub insurance_paid: i128, +} + +/// A soulbound NFT representing an LP's position in the pool. +/// Non-transferable by design — only the provider can interact with it. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct LpNft { + /// Unique token ID (sequential, minted on first deposit). + pub token_id: u64, + /// The LP provider address. + pub provider: Address, + /// Pool category this NFT represents. + pub category: Symbol, + /// Timestamp when the NFT was minted (first deposit). + pub minted_at: u64, + /// Current share count held by this position. + pub shares: i128, + /// Total deposited amount. + pub deposited: i128, + /// Whether the position is still active (shares > 0). + pub active: bool, +} + +/// 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, +} + +/// Aggregate pool stats exposed via queries. +#[contracttype] +#[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 accumulated_backstop: i128, + pub status: PoolStatus, +} + +/// Capacity limits governing how much risk the pool will take on. +/// +/// A pool with no ceiling is a pool that can be talked into insuring more than +/// it can pay. Two different limits matter, and conflating them hides the +/// second one: +/// +/// - `max_total_deposited` bounds how much capital the pool holds. This is a +/// solvency-of-share-value concern. +/// - `max_utilization_bps` bounds how much of that capital may be committed to +/// active coverage at once. This is the correlated-risk concern: a pool 100% +/// committed to weather policies in one region is one storm away from +/// insolvency regardless of how much capital it holds. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PoolCapacity { + /// Ceiling on cumulative deposits, in 7-decimal USDC stroops. + pub max_total_deposited: i128, + /// Ceiling on `total_locked / total_deposited`, in basis points. + /// 10_000 = the pool may commit every unit of capital it holds. + pub max_utilization_bps: u32, +} + +/// A snapshot of how much of the pool's capacity is currently consumed. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CapacityStatus { + pub total_deposited: i128, + pub total_locked: i128, + pub max_total_deposited: i128, + /// Current `total_locked / total_deposited` in basis points. + pub utilization_bps: u32, + pub max_utilization_bps: u32, + /// Capital that may still be deposited before the ceiling is reached. + pub remaining_deposit_capacity: i128, + /// Coverage that may still be underwritten before the utilization cap + /// is reached. + pub remaining_coverage_capacity: i128, +} + +/// One LP's queued exit request. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ExitRequest { + pub provider: Address, + /// Shares reserved for this exit. They stay in the LP's position and keep + /// earning until the exit is claimed, but cannot be queued twice. + pub shares: i128, + /// When the request was made. + pub requested_at: u64, + /// Earliest timestamp at which `claim_exit` will succeed. + pub claimable_at: u64, +} + +/// Where a queued exit stands. +#[contracttype] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ExitStatus { + /// No request outstanding for this provider. + None, + /// Requested, still inside the delay window. + Pending, + /// Delay elapsed — `claim_exit` will settle it. + Claimable, +} + +/// Full view of a provider's exit request, safe to call for any address. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ExitInfo { + pub provider: Address, + pub status: ExitStatus, + pub shares: i128, + pub requested_at: u64, + pub claimable_at: u64, + /// Seconds still to wait, or 0 once claimable. + pub seconds_remaining: u64, +} + +// ─── Events ────────────────────────────────────────────────────────────────── + +#[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, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct LiquidityDeposited { + pub provider: Address, + pub amount: i128, + pub shares_minted: i128, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct LiquidityWithdrawn { + pub provider: Address, + pub shares_burned: i128, + pub amount_returned: i128, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PremiumDistributed { + pub amount: i128, + pub lp_share: i128, + pub treasury_share: i128, + pub backstop_share: i128, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TreasuryFunded { + pub amount: i128, + pub recipient: Address, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct BackstopFunded { + pub amount: i128, + pub recipient: Address, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct YieldClaimed { + pub provider: Address, + pub amount: i128, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CapitalLocked { + pub policy_id: u128, + pub amount: i128, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CapitalReleased { + pub policy_id: u128, + pub amount: i128, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PoolPaused { + pub admin: Address, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PoolResumed { + pub admin: Address, +} + +/// A timelocked admin withdrawal request. +/// Once created, the admin must wait until `execute_after` before executing. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct AdminWithdrawalRequest { + pub amount: i128, + pub requested_at: u64, + /// Absolute timestamp the request becomes executable, fixed when the + /// request is made. + /// + /// The deadline is stored rather than recomputed from `requested_at + + /// TIMELOCK_SECONDS` at execution time, so that upgrading the contract + /// with a shorter `TIMELOCK_SECONDS` cannot retroactively shorten the + /// wait on a request that is already in flight — the guarantee LPs relied + /// on when the request was published. + pub execute_after: u64, + pub executed: bool, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct AdminWithdrawalScheduled { + pub admin: Address, + pub amount: i128, + pub execute_after: u64, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct AdminWithdrawalExecuted { + pub admin: Address, + pub amount: i128, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct AdminWithdrawalCancelled { + pub admin: Address, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct AdminUpdated { + pub new_admin: Address, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ContractUpgraded { + pub old_version: u32, + pub new_version: u32, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PremiumSplitUpdated { + pub lp_bps: i128, + pub treas_bps: i128, + pub backstop_bps: i128, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PoolWindingDown { + pub admin: Address, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct GuardiansUpdated { + pub guardians: Vec
, + pub threshold: u32, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct UpgradeApproved { + pub new_wasm_hash: BytesN<32>, + pub approver: Address, + pub approvals: u32, + pub threshold: u32, +} + +/// A pending contract-upgrade action awaiting guardian approvals. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PendingUpgrade { + pub new_wasm_hash: BytesN<32>, + pub new_version: u32, + pub approvals: Vec
, +} + +/// A pending admin-transfer proposal awaiting guardian approvals. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PendingAdminChange { + pub new_admin: Address, + pub approvals: Vec
, +} + +/// A time-locked change to the premium-split ratios, awaiting execution once +/// `executable_after` has passed. LPs can exit during the timelock window. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PendingParameterChange { + pub new_lp_bps: i128, + pub new_treas_bps: i128, + pub new_backstop_bps: i128, + pub proposed_at: u64, + pub executable_after: u64, + pub executed: bool, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ParameterChangeScheduled { + pub admin: Address, + pub new_lp_bps: i128, + pub new_treas_bps: i128, + pub new_backstop_bps: i128, + pub executable_after: u64, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ParameterChangeExecuted { + pub admin: Address, + pub lp_bps: i128, + pub treas_bps: i128, + pub backstop_bps: i128, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ParameterChangeCancelled { + pub admin: Address, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct LpNftMinted { + pub token_id: u64, + pub provider: Address, + pub category: Symbol, + pub minted_at: u64, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct LpNftUpdated { + pub token_id: u64, + pub provider: Address, + pub shares: i128, + pub deposited: i128, + pub active: bool, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CompoundYieldToggled { + pub provider: Address, + pub enabled: bool, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct LpPositionTransferred { + pub from: Address, + pub to: Address, + pub shares: i128, + pub amount: i128, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PoolCapacityUpdated { + pub max_total_deposited: i128, + pub max_utilization_bps: u32, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ExitDelayUpdated { + pub delay_seconds: u64, +} + +/// Dynamic fee adjustment configuration based on market conditions. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct DynamicFeeConfig { + /// Base fee in basis points (0-10000). + pub base_fee_bps: u32, + /// Maximum fee in basis points (0-10000). + pub max_fee_bps: u32, + /// Minimum fee in basis points (0-10000). + pub min_fee_bps: u32, + /// Utilization threshold at which fees start increasing (basis points). + pub utilization_threshold_bps: u32, + /// Fee adjustment per 1% increase in utilization above threshold (basis points). + pub fee_adjustment_per_1pct_bps: u32, + /// Whether dynamic fee adjustment is enabled. + pub enabled: bool, + /// Last time the dynamic fee was updated. + pub last_updated: u64, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ExitRequested { + pub provider: Address, + pub shares: i128, + pub claimable_at: u64, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ExitCancelled { + pub provider: Address, + pub shares: i128, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ExitClaimed { + pub provider: Address, + pub shares_burned: i128, + pub amount_returned: i128, + /// Seconds the provider actually waited between request and claim. + pub waited: u64, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MinReserveUpdated { + pub min_reserve: i128, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ReserveFundUpdated { + pub amount: i128, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct DynamicFeeAdjusted { + pub previous_fee_bps: u32, + pub new_fee_bps: u32, + pub utilization_bps: u32, + pub adjusted_at: u64, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct DynamicFeeConfigUpdated { + pub base_fee_bps: u32, + pub max_fee_bps: u32, + pub min_fee_bps: u32, + pub utilization_threshold_bps: u32, + pub fee_adjustment_per_1pct_bps: u32, + pub enabled: bool, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct VotesDelegated { + pub provider: Address, + pub delegate: Address, +} + +/// An external reinsurance arrangement that backstops this pool against +/// catastrophic (large single-claim) losses. +/// +/// The pool retains every loss up to `attachment_point` itself; only the +/// portion of a claim above that point is eligible for recovery from +/// `reinsurer`, capped over the arrangement's lifetime by `coverage_limit`. +/// This is a per-claim ("excess of loss") layer, not aggregate stop-loss — +/// each claim is evaluated against `attachment_point` independently. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ReinsuranceConfig { + /// Contract this pool cedes premium to and recovers claims from. + pub reinsurer: Address, + /// Per-claim loss threshold below which the pool bears the loss alone. + pub attachment_point: i128, + /// Cumulative lifetime cap on reinsurance recoveries this pool can draw. + pub coverage_limit: i128, + /// Whether recovery requests are currently honored. Config (and prior + /// usage) is preserved when set to `false` — this pauses the + /// arrangement without discarding it. + pub active: bool, +} + +/// Running totals for the pool's reinsurance arrangement. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ReinsuranceStats { + /// Total premium ceded to the reinsurer so far. + pub total_premium_paid: i128, + /// Total claims recovered from the reinsurer so far. + pub total_recovered: i128, + /// `coverage_limit - total_recovered`, floored at 0. + pub coverage_remaining: i128, +} + +/// Collateralization ratio for an LP position. +/// +/// Measures how well-backed an LP's position is by available liquidity +/// relative to their share of locked capital. A ratio below 10000 (100%) +/// means the position is undercollateralized and may be subject to +/// liquidation. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CollateralizationInfo { + pub provider: Address, + /// LP's share of available pool liquidity (deposited - locked). + pub position_value: i128, + /// LP's proportional share of locked capital. + pub position_liability: i128, + /// Collateralization ratio in basis points (position_value / position_liability * 10000). + /// 10000 = 100% collateralized. 0 = no locked liability (fully collateralized). + pub collateralization_bps: u32, +} + +/// Result of a liquidation attempt on an undercollateralized LP position. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct LiquidationResult { + pub provider: Address, + pub shares_seized: i128, + pub amount_recovered: i128, + pub previous_ratio_bps: u32, +} + +/// An LP fee tier based on deposit amount and/or lock duration. +/// LPs with larger deposits or longer commitment get lower fees. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct FeeTier { + /// Minimum deposit amount for this tier (7-decimal stroops). 0 = no minimum. + pub min_deposit: i128, + /// Minimum lock duration in seconds for this tier. 0 = no lock required. + pub min_lock_duration: u64, + /// Fee discount in basis points (0-10000). 0 = no discount, 10000 = 100% off. + pub discount_bps: u32, + /// Human-readable tier name. + pub name: Symbol, +} + +/// Optional vesting schedule for LP deposits to prevent large LPs from exiting immediately. +/// Vesting ensures LPs gradually unlock their shares over time, reducing exit risk. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct VestingSchedule { + /// Total number of vesting periods. + pub total_periods: u32, + /// Duration of each vesting period in seconds. + pub period_duration: u64, + /// Shares that vest per period (can be 0 for cliff vesting). + pub amount_per_period: i128, + /// Cliff period (shares locked until this many periods have passed). + /// 0 = no cliff, vesting starts immediately. + pub cliff_periods: u32, + /// Absolute timestamp when vesting started. + pub vesting_start: u64, + /// Shares already vested and available for withdrawal. + pub vested_amount: i128, + /// Total shares subject to this vesting schedule. + pub total_amount: i128, +} + +/// LP position with optional vesting constraint. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct VestedLpPosition { + pub provider: Address, + pub position: LpPosition, + /// Optional vesting schedule. If present (non-empty), constrains + /// withdrawals. `Vec` instead of `Option` because the + /// soroban-sdk XDR (`ScVal`) conversion generated for a contracttype + /// struct does not support `Option` fields — only `Vec` + /// round-trips a custom struct through both the WASM `Val` path and the + /// host-side `ScVal` path used by test tooling. + pub vesting: Vec, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ReinsuranceConfigured { + pub reinsurer: Address, + pub attachment_point: i128, + pub coverage_limit: i128, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct FeeTierUpdated { + pub tier_name: Symbol, + pub min_deposit: i128, + pub min_lock_duration: u64, + pub discount_bps: u32, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ReinsuranceActiveSet { + pub active: bool, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PositionLiquidated { + pub provider: Address, + pub shares_seized: i128, + pub amount_recovered: i128, + pub collateralization_bps: u32, +} + + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct VestingScheduleCreated { + pub provider: Address, + pub total_periods: u32, + pub period_duration: u64, + pub cliff_periods: u32, + pub vesting_start: u64, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ReinsurancePremiumPaid { + pub reinsurer: Address, + pub amount: i128, + pub total_premium_paid: i128, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct VestingSharesReleased { + pub provider: Address, + pub newly_vested_amount: i128, + pub total_vested: i128, + pub vesting_period: u32, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ReinsuranceRecovered { + pub policy_id: u128, + pub loss_amount: i128, + pub recovered_amount: i128, + pub coverage_remaining: i128, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct WithdrawalBlockedByVesting { + pub provider: Address, + pub requested_shares: i128, + pub available_shares: i128, + pub vesting_period: u32, +}