diff --git a/contracts/claims-processor/src/lib.rs b/contracts/claims-processor/src/lib.rs index 77542eb..18c90d9 100644 --- a/contracts/claims-processor/src/lib.rs +++ b/contracts/claims-processor/src/lib.rs @@ -1,3148 +1,3268 @@ -// 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 -//! Oracle Verifier, then instructs the Policy Engine to pay out or expire. -//! -//! Two processing paths -//! ───────────────────── -//! 1. `submit_claim` + `process_claim` — user or keeper manually triggers evaluation. -//! 2. `auto_process` — keeper-triggered; evaluates without a prior user submission. -//! This is the primary path for parametric insurance (no claim form needed). -//! -//! Idempotency -//! ──────────── -//! Once a policy is Claimed or Expired, further process/auto_process calls -//! return the appropriate ClaimResult without writing again. -#![no_std] -extern crate alloc; -use alloc::string::ToString; - -use soroban_sdk::{ - contract, contractimpl, contracttype, contracterror, panic_with_error, - Address, Bytes, BytesN, Env, Vec, Symbol, SymbolStr, TryFromVal, IntoVal, -}; - -pub mod types; -pub use types::*; - -// ─── Cross-contract client interfaces ──────────────────────────────────────── - -#[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")] -trait IPolicyEngine { - fn get_policy(env: Env, policy_id: u128) -> parashield_policy_engine::Policy; - fn get_product(env: Env, product_id: u128) -> parashield_policy_engine::InsuranceProduct; - fn pay_claim(env: Env, caller: Address, policy_id: u128); - fn expire_policy(env: Env, caller: Address, policy_id: u128); -} - -#[soroban_sdk::contractclient(name = "OracleVerifierClient")] -trait IOracleVerifier { - fn verify_trigger_fresh( - env: Env, - data_type: soroban_sdk::Symbol, - key: soroban_sdk::Symbol, - condition: parashield_oracle_verifier::TriggerCondition, - max_age_seconds: u64, - ) -> bool; - - fn batch_verify_fresh( - env: Env, - queries: soroban_sdk::Vec, - ) -> soroban_sdk::Vec; -} - -// ─── Storage TTL ────────────────────────────────────────────────────────────── - -// Shared protocol constants — single source of truth in parashield-common (issue #342). -use parashield_common::{TTL_THRESHOLD, TTL_EXTEND_TO, ADMIN_TRANSFER_TIMELOCK}; - -// ─── 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 ───────────────────────────────────────────────────────────── - -#[contracttype] -enum StorageKey { - Initialized, - Admin, - PolicyEngine, - RiskPool, - OracleVerifier, - StalenessThreshold, // u64 — max acceptable oracle data age in seconds - Claim(u128), - PolicyClaim(u128), // policy_id → claim_id (one claim per policy) - NextClaimId, - 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). Zero is only valid before guardians are configured. - GuardianThreshold, - /// Guardian set and threshold proposed by the admin, awaiting approval. - PendingGuardianConfig, - /// A pending, not-yet-executed contract upgrade awaiting guardian approvals. - PendingUpgrade, - /// Seconds a claim may sit Pending before it can be escalated (u64). - EscalationThreshold, - /// Per-category escalation threshold (Symbol -> u64). - CategoryEscalationThreshold(Symbol), - /// 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), - /// Fraud detector configuration (issue #437). Absent = detector disabled - /// entirely; every claim proceeds unchecked. - FraudConfig, - /// Compact aggregate of a claimant's recent submission behaviour - /// (issue #437). Keyed by claimant so the fraud detector never has to - /// scan the full claim table on a new submission. - ClaimantHistory(Address), - /// A frozen snapshot of the fraud detector's judgement on one claim - /// (issue #437). Written only when the detector flagged something. - FraudRecord(u128), - /// SECURITY FIX: Rate limiting for auto_process function - /// Maps policy_id to the last timestamp it was processed to prevent spam. - AutoProcessLastTime(u128), - /// SECURITY FIX: Minimum cooldown in seconds between auto_process calls for the same policy. - AutoProcessCooldown, - /// Authorized identity attesters list (Vec
). - AuthorizedAttesters, - /// Time after a claim decision during which the claimant may dispute it. - DisputeWindow, - /// Claim reference number mapping: reference_number (Symbol) -> claim_id (u128) (issue #754). - ClaimReference(Symbol), - /// Custom dispute deadline for a claim: claim_id (u128) -> deadline (u64) (issue #740). - ClaimDisputeDeadline(u128), - /// Per-user last claim submission timestamp: claimant (Address) -> timestamp (u64) (issue #737). - UserLastClaim(Address), - /// Registered valid claim type (Symbol) -> bool (issue #734). - RegisteredClaimType(Symbol), - /// Evidence attachments for a claim: claim_id (u128) -> Vec hashes (issue #747). - ClaimEvidence(u128), -} - -/// Maximum number of evidence files allowed per claim (issue #747). -pub const MAX_EVIDENCE_FILES: u32 = 10; - -// ─── Errors ─────────────────────────────────────────────────────────────────── - -#[contracterror] -#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] -#[repr(u32)] -pub enum Error { - AlreadyInitialized = 1, - 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, - /// Fraud detector flagged this claim's submission and the current - /// `FraudMode` is `Block` (issue #437). No state was written for the - /// rejected claim. - FraudSuspected = 23, - /// An admin transfer was proposed while another one is still pending, - /// which would reset the transfer timelock (issue #457). - AdminTransferPending = 24, - /// SECURITY FIX: Rate limiting - auto_process called too frequently for this policy. - RateLimitExceeded = 25, - /// A payout larger than the policy's coverage amount was attempted (issue #550). - PayoutExceedsCoverage = 26, - /// The caller is not in the authorized attesters list. - UnauthorizedAttester = 27, - /// A claim was filed outside the policy's coverage period — before - /// `start_time` or after `end_time`. - ClaimOutsideCoveragePeriod = 28, - /// The contract admin tried to file a claim, on any policy. The admin - /// resolves disputes and controls payout configuration; letting them - /// also be the claimant is self-dealing (issue #566). - AdminCannotBeClaimant = 29, - /// Payout delay exceeds the maximum allowed 365 days. - PayoutDelayTooLong = 30, - /// Dispute reason cannot be empty (issue #582). - EmptyDisputeReason = 31, - /// Claim coverage amount does not match the policy coverage amount (issue #589). - CoverageMismatch = 32, - /// Claim coverage amount is zero or negative — nothing to pay out. - InvalidClaimAmount = 33, - /// Dispute reason symbol exceeds the maximum allowed length. - DisputeReasonTooLong = 34, - /// Dispute windows must stay within the documented one-hour to 30-day range. - InvalidDisputeWindow = 35, - /// The configured opportunity to dispute this decision has elapsed. - DisputeWindowExpired = 36, - /// Duplicate claim reference numbers are not permitted (issue #754). - DuplicateClaimReference = 37, - /// Dispute deadline must be strictly in the future (issue #740). - InvalidDisputeDeadline = 38, - /// Claim type must be registered and non-empty (issue #734). - InvalidClaimType = 39, - /// Evidence array exceeds `MAX_EVIDENCE_FILES` (issue #747). - EvidenceLimitExceeded = 40, - /// A supplied evidence hash is malformed or empty (issue #747). - InvalidEvidence = 41, - /// Payment token does not match the product's expected token. - InvalidToken = 42, -} - -/// 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; - -/// Maximum number of installments allowed per claim. An excessive number -/// of installments could exhaust storage and gas. 12 monthly installments -/// (one year) is a reasonable upper bound for parametric insurance payouts. -const MAX_INSTALLMENTS: u32 = 12; - -/// Maximum length of a dispute reason symbol (issue #642). -const MAX_DISPUTE_REASON_LEN: u32 = 32; - -/// Valid dispute windows are deliberately bounded. Zero silently disables -/// disputes, while an unbounded window prevents claims from reaching finality. -pub const MIN_DISPUTE_WINDOW: u64 = 60 * 60; -pub const MAX_DISPUTE_WINDOW: u64 = 30 * 24 * 60 * 60; -pub const DEFAULT_DISPUTE_WINDOW: u64 = 7 * 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; - -/// Longest escalation threshold an admin may configure (90 days). An -/// unreasonably long threshold would leave claims stuck Pending indefinitely -/// with no recourse for the claimant (issue #618). -const MAX_ESCALATION_THRESHOLD: u64 = 90 * 24 * 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; - -/// Upper bound on the admin-configurable claim submission deadline (issue -/// #625). Without a ceiling an admin could set `claim_deadline` to `u64::MAX`, -/// which would allow a claim to be filed indefinitely far in the future — -/// after the triggering event can no longer be verified by oracle data and -/// after any meaningful recourse for the claimant. 365 days is the same -/// retention horizon used for claim entries themselves -/// (`CLAIM_RETENTION_SECONDS`), keeping the submission window within the -/// lifetime of the data that backs it. -const MAX_CLAIM_DEADLINE: u64 = 365 * 24 * 60 * 60; - -/// Hard upper bound on how far in the future a claim may be submitted -/// relative to the current ledger time (issue #625). The ledger timestamp -/// is authoritative, so a caller cannot forge it — but a claim submitted -/// against a policy whose `start_time` is more than this window in the -/// future is rejected outright, since there is nothing to claim for yet. -/// 1 hour is generous enough to absorb ledger-time skew without rejecting -/// a legitimately scheduled policy whose coverage just began. -const MAX_FUTURE_SUBMISSION_WINDOW: u64 = 60 * 60; - -// ─── Fraud detection (issue #437) ──────────────────────────────────────────── - -/// Ceiling for `FraudConfig.fraud_threshold_score`. Scores above 100 could -/// never be reached because the three rules sum to at most 90, and even a -/// future fourth rule should not push a single claim over 100. -const MAX_FRAUD_SCORE: u32 = 100; - -/// Bitmask constants matching the `FraudRecord.flags` documentation. Kept -/// as `u32` because the field is `u32`. -const FRAUD_FLAG_RATE: u32 = 1 << 0; -const FRAUD_FLAG_BURST: u32 = 1 << 1; -const FRAUD_FLAG_COVERAGE: u32 = 1 << 2; - -// ─── Contract ───────────────────────────────────────────────────────────────── - -#[contract] -pub struct ClaimsProcessor; - -#[contractimpl] -impl ClaimsProcessor { - - // ── Lifecycle ──────────────────────────────────────────────────────────── - - /// One-time initialisation. Links the contract to `policy_engine`, `risk_pool`, and - /// `oracle_verifier`. `staleness_threshold` is the maximum age in seconds for oracle - /// data to be considered fresh. Panics with `AlreadyInitialized` on a second call. - pub fn initialize( - env: Env, - admin: Address, - policy_engine: Address, - risk_pool: Address, - oracle_verifier: Address, - staleness_threshold: u64, - ) { - if env.storage().instance().has(&StorageKey::Initialized) { - panic_with_error!(&env, Error::AlreadyInitialized); - } - - 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::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::Paused, &false); - env.storage().instance().set(&StorageKey::ClaimDeadline, &DEFAULT_CLAIM_DEADLINE); - env.storage().instance().set(&StorageKey::EscalationThreshold, &DEFAULT_ESCALATION_THRESHOLD); - env.storage().instance().set(&StorageKey::DisputeWindow, &DEFAULT_DISPUTE_WINDOW); - - env.events().publish( - (Symbol::new(&env, "initialized"),), - Initialized { - admin: admin.clone(), - policy_engine: policy_engine.clone(), - risk_pool: risk_pool.clone(), - oracle_verifier: oracle_verifier.clone(), - staleness_threshold, - }, - ); - } - - // ── Keeper Registry ────────────────────────────────────────────────────── - - /// Admin-only: authorize `keeper` to call process_claim / auto_process / - /// batch_auto_process. Without this, no address can settle claims. - /// - /// `keeper` must be a well-formed Stellar address (issue #572). A keeper - /// entry is a settlement authority: whatever holds it can move coverage - /// USDC out of the pool. Admitting an address whose StrKey encoding is - /// malformed writes a permanent registry entry that can never be - /// legitimately exercised and that off-chain indexers will silently fail - /// to resolve when reconciling `keeper_added` events, so the format is - /// checked up front and the write is refused. - pub fn add_keeper(env: Env, admin: Address, keeper: Address) { - Self::require_admin(&env, &admin); - Self::validate_stellar_address(&env, &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. - /// - /// Deliberately does *not* re-run the format check that `add_keeper` - /// applies. This is the remediation path for a bad registry entry: an - /// admin must be able to evict a keeper that was admitted before the - /// check existed, or a malformed entry would be unremovable. Removing an - /// unknown address is a no-op either way. - 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, - ); - } - - /// Whether `keeper` is currently authorized to settle claims. - pub fn is_keeper(env: Env, keeper: Address) -> bool { - env.storage().persistent() - .get(&StorageKey::Keeper(keeper)) - .unwrap_or(false) - } - - // ── Claim Submission ───────────────────────────────────────────────────── - - /// Manually submit a claim for a policy. Returns the new claim ID. - /// Only the policyholder may submit; only one claim per policy. - /// - /// The contract admin is refused outright (issue #566). An admin who - /// holds a policy is on both sides of the same claim: they file it, - /// they are the escalation/dispute resolver of last resort, and they - /// control the pool configuration the payout comes out of. Filing on - /// their own policy is self-dealing no matter how clean the oracle - /// trigger looks, so the conflict is removed rather than documented. - /// A policy the admin bought is still handled by the keeper-driven - /// `auto_process` path, which is not a claimant-initiated flow. - pub fn submit_claim(env: Env, claimant: Address, policy_id: u128) -> u128 { - claimant.require_auth(); - Self::require_not_paused(&env); - - // The admin may not be the claimant, whoever the policyholder is. - // Checked before any state is read so a rejected call costs nothing. - Self::require_not_admin(&env, &claimant); - // The zero address cannot hold or move funds; a claim filed for it - // would settle a payout nobody can ever collect (issue #586). - Self::require_not_zero_address(&env, &claimant); - - Self::file_claim(&env, &claimant, policy_id) - } - - /// Submit a claim with an explicit reference number (issue #754). - /// Rejects duplicate reference numbers to prevent collisions. - pub fn submit_claim_with_ref( - env: Env, - claimant: Address, - policy_id: u128, - reference_number: Symbol, - ) -> u128 { - claimant.require_auth(); - Self::require_not_paused(&env); - Self::require_not_admin(&env, &claimant); - Self::require_not_zero_address(&env, &claimant); - - if reference_number == Symbol::new(&env, "") { - panic_with_error!(&env, Error::InvalidInput); - } - - let ref_key = StorageKey::ClaimReference(reference_number.clone()); - if env.storage().persistent().has(&ref_key) { - panic_with_error!(&env, Error::DuplicateClaimReference); - } - - let claim_id = Self::file_claim(&env, &claimant, policy_id); - env.storage().persistent().set(&ref_key, &claim_id); - env.storage().persistent().extend_ttl(&ref_key, TTL_THRESHOLD, TTL_EXTEND_TO); - - claim_id - } - - /// Register a valid claim type / product category (issue #734). - pub fn register_claim_type(env: Env, admin: Address, claim_type: Symbol) { - admin.require_auth(); - Self::require_admin(&env, &admin); - if claim_type == Symbol::new(&env, "") { - panic_with_error!(&env, Error::InvalidClaimType); - } - let key = StorageKey::RegisteredClaimType(claim_type); - env.storage().persistent().set(&key, &true); - env.storage().persistent().extend_ttl(&key, TTL_THRESHOLD, TTL_EXTEND_TO); - } - - /// Check if a claim type is registered (issue #734). - pub fn is_claim_type_registered(env: Env, claim_type: Symbol) -> bool { - let key = StorageKey::RegisteredClaimType(claim_type); - env.storage().persistent().has(&key) - } - - /// Submit a claim with an explicit claim type (issue #734). - pub fn submit_claim_with_type( - env: Env, - claimant: Address, - policy_id: u128, - claim_type: Symbol, - ) -> u128 { - claimant.require_auth(); - Self::require_not_paused(&env); - Self::require_not_admin(&env, &claimant); - Self::require_not_zero_address(&env, &claimant); - - if claim_type == Symbol::new(&env, "") || !Self::is_claim_type_registered(env.clone(), claim_type) { - panic_with_error!(&env, Error::InvalidClaimType); - } - - Self::file_claim(&env, &claimant, policy_id) - } - - /// The claim-filing body shared by `submit_claim` and - /// `batch_submit_claims`. The caller has already authorized `claimant`, - /// checked the pause flag and rejected the admin — deliberately - /// *not* re-done here, because a second `require_auth` for the same - /// address in the same call frame is rejected by the host - /// (`Auth, ExistingValue`). `batch_submit_claims` delegating to - /// `submit_claim` therefore made the whole batch path unreachable for - /// every caller; the shared body keeps one set of rules instead of two - /// copies of them. - fn file_claim(env: &Env, claimant: &Address, policy_id: u128) -> u128 { - // Guard: one claim per policy - 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() - .get(&StorageKey::PolicyEngine) - .unwrap_or_else(|| panic_with_error!(env, Error::NotInitialized)); - let policy = PolicyEngineClient::new(env, &policy_engine) - .get_policy(&policy_id); - - // Validate claim type / product category is non-empty (issue #734) - let product = PolicyEngineClient::new(env, &policy_engine) - .get_product(&policy.product_id); - if product.category == Symbol::new(env, "") { - panic_with_error!(env, Error::InvalidClaimType); - } - - if policy.coverage_amount <= 0 { - panic_with_error!(env, Error::InvalidClaimAmount); - } - - if policy.policyholder != *claimant { - panic_with_error!(env, Error::Unauthorized); - } - // Guard: reject claims for cancelled policies (issue #731). - if policy.status == parashield_policy_engine::PolicyStatus::Cancelled { - panic_with_error!(env, Error::PolicyNotActive); - } - if policy.status != parashield_policy_engine::PolicyStatus::Active { - panic_with_error!(env, Error::PolicyNotActive); - } - - // 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 now < policy.start_time { - panic_with_error!(env, Error::ClaimOutsideCoveragePeriod); - } - - // SECURITY FIX: Claims must be submitted before the policy end_time. - // Only after end_time has passed can a claim be filed within the claim_deadline window. - // This prevents claims being submitted during the active policy period incorrectly. - if now <= policy.end_time { - // Policy is still active - allow the claim - } else { - // Policy has ended - check if we're within the claim_deadline window - let cutoff = policy.end_time.saturating_add(Self::claim_deadline(env)); - if now > cutoff { - panic_with_error!(env, Error::ClaimDeadlinePassed); - } - } - - // Issue #625: reject submissions that are too far in the future - // relative to the current ledger time. A scheduled policy whose - // `start_time` is far ahead has no observable trigger yet, so a claim - // filed against it is not grounded in anything verifiable. The small - // window absorbs ledger-time skew for a policy that just began. - if policy.start_time > now.saturating_add(MAX_FUTURE_SUBMISSION_WINDOW) { - panic_with_error!(env, Error::ClaimOutsideCoveragePeriod); - } - - // Issue #635: allow claims filed after end_time within claim_deadline, - // then reject anything past that window. The prior code rejected - // `now > end_time` before this check ran, making the deadline window - // unreachable. - 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); - } - } else if now > policy.end_time { - panic_with_error!(env, Error::ClaimOutsideCoveragePeriod); - } - - // SECURITY FIX (issue #737): Rate limit claim submissions per user to prevent claim flooding. - // Users must wait at least 60 seconds between successive claim submissions. - const USER_CLAIM_COOLDOWN_SECONDS: u64 = 60; - let last_claim_key = StorageKey::UserLastClaim(claimant.clone()); - if let Some(last_time) = env.storage().persistent().get::<_, u64>(&last_claim_key) { - if now < last_time.saturating_add(USER_CLAIM_COOLDOWN_SECONDS) { - panic_with_error!(env, Error::RateLimitExceeded); - } - } - env.storage().persistent().set(&last_claim_key, &now); - env.storage().persistent().extend_ttl(&last_claim_key, TTL_THRESHOLD, TTL_EXTEND_TO); - - let claim_id = Self::next_claim_id(env); - - // Issue #437: score this submission against the configured fraud - // rules. In `Block` mode a high enough score panics here, before any - // claim state is written — the caller gets FraudSuspected and the - // world looks exactly as it did before the call. In `FlagOnly` mode - // a FraudRecord is persisted and the claim proceeds. When no config - // is set, this is a no-op. - Self::evaluate_fraud(env, claim_id, claimant, policy.coverage_amount); - - let claim = Claim { - id: claim_id, - policy_id, - claimant: claimant.clone(), - coverage_amount: policy.coverage_amount, - observed_value: None, - trigger_met: false, - status: ClaimStatus::Pending, - 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); - - 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_submitted"),), - ClaimSubmitted { - claim_id, - policy_id, - claimant: claimant.clone(), - coverage_amount: policy.coverage_amount, - }, - ); - - // Issue #437: fold this submission into the claimant's aggregate - // history for the next call's fraud check. Only meaningful when the - // detector is on, but cheap enough to always maintain; keeping the - // aggregate up to date lets an admin turn the detector on later - // without every claimant looking like a first-time submitter. - let burst_window = Self::fraud_config(env) - .map(|c| c.burst_window_secs) - .unwrap_or(0); - Self::update_claimant_history( - env, - claimant, - now, - policy.coverage_amount, - burst_window, - ); - - 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); - Self::require_not_admin(&env, &claimant); - Self::require_not_zero_address(&env, &claimant); - - 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::file_claim(&env, &claimant, 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. - /// - /// ## Parametric Payout Design Note - /// In parametric insurance, claim payouts are strictly binary or determined by - /// objective oracle trigger measurements (e.g., rainfall, wind speed, flight delay) - /// rather than traditional indemnity models that require loss adjustments or proof - /// of actual financial loss. When an oracle trigger condition is verified, the - /// contract pays out the pre-agreed coverage amount (or pre-configured partial - /// payout percentage) in full, regardless of the policyholder's actual incurred loss. - /// - /// `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%. - /// - /// Boundary semantics (issue #776). The CoverageMismatch guard below - /// uses `!=` rather than `>`/`<`, so a claim whose `coverage_amount` - /// equals the policy's `coverage_amount` exactly is permitted — the - /// equality case is the common case, not a rejection branch. The - /// downstream payout logic in `evaluate_and_settle` mirrors this: - /// `paid_amount > claim.coverage_amount` (strict), so a payout exactly - /// at the coverage ceiling is accepted and only a payout *above* it is - /// rejected. The same `>` strictness applies to the installment - /// schedule check (`total_amount > claim.coverage_amount`). An - /// equal-to-ceiling value is always a valid claim, never a defect. - 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() - .get(&StorageKey::Claim(claim_id)) - .unwrap_or_else(|| panic_with_error!(&env, Error::ClaimNotFound)); - - // SECURITY FIX: Verify claim status is Pending before processing to prevent - // reprocessing already settled claims. This protects against double-payouts - // and ensures claim state transitions are properly enforced. - if claim.status != ClaimStatus::Pending { - return ClaimResult::AlreadyProcessed; - } - - 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); - - // Guard: claim coverage must match the policy to prevent incorrect payouts (#589). - if claim.coverage_amount != policy.coverage_amount { - panic_with_error!(&env, Error::CoverageMismatch); - } - - 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, - }, - ); - - results - } - - /// Batch-verify multiple oracle readings for a claim or policy in a single cross-contract call (#683). - /// Drastically reduces CPU and instruction cost compared to individual verifications. - pub fn batch_verify_oracle_readings( - env: Env, - queries: Vec, - ) -> Vec { - let oracle_verifier: Address = env - .storage() - .instance() - .get(&StorageKey::OracleVerifier) - .unwrap_or_else(|| panic_with_error!(&env, Error::NotInitialized)); - - OracleVerifierClient::new(&env, &oracle_verifier).batch_verify_fresh(&queries) - } - - /// Legacy individual verification of multiple oracle readings, executing N separate - /// cross-contract calls into oracle-verifier. Kept for comparison/benchmarking (#683). - pub fn verify_readings_individual( - env: Env, - queries: Vec, - ) -> Vec { - let oracle_verifier: Address = env - .storage() - .instance() - .get(&StorageKey::OracleVerifier) - .unwrap_or_else(|| panic_with_error!(&env, Error::NotInitialized)); - - let client = OracleVerifierClient::new(&env, &oracle_verifier); - let mut results = Vec::new(&env); - for i in 0..queries.len() { - let q = queries.get_unchecked(i); - let res = client.verify_trigger_fresh( - &q.data_type, - &q.key, - &q.condition, - &q.staleness_threshold, - ); - results.push_back(res); - } - results - } - - /// Process a multi-reading claim by batch-verifying all required oracle readings - /// in a single cross-contract invocation (#683). - /// - /// `require_all`: if true, requires all queries to evaluate to true (AND logic); - /// if false, requires at least one query to evaluate to true (OR logic). - pub fn process_multi_reading_claim( - env: Env, - keeper: Address, - claim_id: u128, - queries: Vec, - require_all: bool, - partial_payout_bps: Option, - ) -> ClaimResult { - Self::require_keeper(&env, &keeper); - Self::require_not_paused(&env); - if queries.is_empty() { - panic_with_error!(&env, Error::InvalidInput); - } - - 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 { - return ClaimResult::AlreadyProcessed; - } - - 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); - - if claim.coverage_amount != policy.coverage_amount { - panic_with_error!(&env, Error::CoverageMismatch); - } - - // Batch verify all readings in ONE cross-contract call - let results = Self::batch_verify_oracle_readings(env.clone(), queries.clone()); - - let mut trigger_met = if require_all { true } else { false }; - for i in 0..results.len() { - let r = results.get_unchecked(i); - if require_all { - if !r { - trigger_met = false; - break; - } - } else { - if r { - trigger_met = true; - break; - } - } - } - - env.events().publish( - (Symbol::new(&env, "readings_batch_verified"),), - ReadingsBatchVerified { - claim_id, - readings_count: queries.len(), - trigger_met, - }, - ); - - Self::settle_claim_with_trigger_result(&env, &mut claim, &policy, trigger_met, partial_payout_bps) - } - - - /// 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. - /// - /// `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); - - // SECURITY FIX: Rate limiting to prevent spam attacks on auto_process - // Check if this policy was recently processed and reject if within cooldown window - let now = env.ledger().timestamp(); - let cooldown_secs: u64 = env.storage().instance() - .get(&StorageKey::AutoProcessCooldown) - .unwrap_or(300); // Default: 5 minutes between calls - - if let Some(last_processed) = env.storage().persistent() - .get::<_, u64>(&StorageKey::AutoProcessLastTime(policy_id)) { - if now < last_processed.saturating_add(cooldown_secs) { - panic_with_error!(&env, Error::RateLimitExceeded); - } - } - - // Update the last processed timestamp for this policy - env.storage().persistent() - .set(&StorageKey::AutoProcessLastTime(policy_id), &now); - - // ─── 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 existing_claim.status != ClaimStatus::Pending { - return ClaimResult::AlreadyProcessed; - } - } - } - - 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); - - if policy.coverage_amount <= 0 { - panic_with_error!(&env, Error::InvalidClaimAmount); - } - - // 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 => {} - } - - // Check if policy has expired with no trigger - 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() - } else { - let cid = Self::next_claim_id(&env); - let claim = Claim { - id: cid, - policy_id, - claimant: policy.policyholder.clone(), - coverage_amount: policy.coverage_amount, - observed_value: None, - trigger_met: false, - status: ClaimStatus::Pending, - 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); - - // 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)); - pending.push_back(cid); - 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(); - if claim.status != ClaimStatus::Pending { - return ClaimResult::AlreadyProcessed; - } - 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). - /// - /// FAIRNESS GUARANTEE: Claims are processed in FIFO (First-In-First-Out) order - /// based on their submission timestamp. This ensures older claims are settled - /// before newer ones, preventing unfair advantage to recently submitted claims. - /// The pending queue maintains insertion order, and this function processes - /// from index 0 (oldest) onwards. - /// - /// `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. - /// - /// Failure isolation: each claim is evaluated via a sub-invocation of - /// `auto_process` on this contract using `env.try_invoke_contract`. If - /// any individual claim's cross-contract calls panic (e.g. stale oracle - /// data, policy engine rejection, risk-pool error), that sub-invocation - /// returns `Err` and the claim is skipped — the batch itself never - /// aborts, so all other claims in the queue are still processed. - pub fn batch_auto_process(env: Env, caller: Address, limit: u32) -> Vec<(u128, ClaimResult)> { - Self::require_keeper(&env, &caller); - Self::require_not_paused(&env); - if limit == 0 { - panic_with_error!(&env, Error::InvalidInput); - } - - // FAIRNESS FIX: Get pending claims list - maintained in FIFO order - let mut pending: Vec = env.storage().instance() - .get(&StorageKey::PendingClaims) - .unwrap_or_else(|| Vec::new(&env)); - - // FAIRNESS FIX: Sort claims by submission time to ensure true FIFO processing. - // Even if the pending queue gets slightly out of order (due to concurrent - // submissions or manual queue manipulation), this ensures oldest claims - // are always processed first. - let mut claim_timestamps: Vec<(u128, u64)> = Vec::new(&env); - for i in 0..pending.len() { - let claim_id = pending.get_unchecked(i); - if let Some(claim) = env.storage().persistent().get::<_, Claim>(&StorageKey::Claim(claim_id)) { - claim_timestamps.push_back((claim_id, claim.submitted_at)); - } - } - - // Sort by timestamp (ascending - oldest first) using bubble sort - // Bubble sort is used here because Soroban doesn't have built-in sorting - let len = claim_timestamps.len(); - for i in 0..len { - for j in 0..(len - i - 1) { - let curr = claim_timestamps.get_unchecked(j); - let next = claim_timestamps.get_unchecked(j + 1); - if curr.1 > next.1 { - // Swap - claim_timestamps.set(j, next); - claim_timestamps.set(j + 1, curr); - } - } - } - - // Rebuild pending queue in correct FIFO order - let mut sorted_pending: Vec = Vec::new(&env); - for i in 0..claim_timestamps.len() { - let (claim_id, _) = claim_timestamps.get_unchecked(i); - sorted_pending.push_back(claim_id); - } - - // Update storage with sorted order - env.storage().instance().set(&StorageKey::PendingClaims, &sorted_pending); - - let mut results: Vec<(u128, ClaimResult)> = Vec::new(&env); - let effective_limit = if limit > MAX_BATCH_SIZE { MAX_BATCH_SIZE } else { limit }; - let process_count = if sorted_pending.len() < effective_limit { - sorted_pending.len() - } else { - effective_limit - }; - - let this = env.current_contract_address(); - let fn_name = Symbol::new(&env, "auto_process"); - - for i in 0..process_count { - let claim_id = sorted_pending.get_unchecked(i); - - // Pre-check: skip claims that are no longer Pending without - // spending a sub-invocation on them (fast path, no isolation cost). - let claim_opt: Option = env.storage().persistent() - .get(&StorageKey::Claim(claim_id)); - let policy_id = match &claim_opt { - None => continue, - Some(c) if c.status != ClaimStatus::Pending => continue, - Some(c) if c.processed_at.is_some() => continue, - Some(c) => c.policy_id, - }; - - // Each claim is evaluated in its own sub-invocation so that a - // panic inside (stale oracle, cross-contract failure, etc.) only - // rolls back that single claim, not the entire batch. - // - // auto_process(caller, policy_id, partial_payout_bps: None) - // None is encoded as the void Val in the args vector. - let none_val: soroban_sdk::Val = Option::::None.into_val(&env); - let args = soroban_sdk::vec![ - &env, - caller.to_val(), - policy_id.into_val(&env), - none_val, - ]; - match env.try_invoke_contract::( - &this, - &fn_name, - args, - ) { - Ok(Ok(result)) => { - results.push_back((claim_id, result.clone())); - - // Emit individual claim processed event for tracking specific outcomes - let claim: Option = env.storage().persistent() - .get(&StorageKey::Claim(claim_id)); - if let Some(c) = claim { - let trigger_met = match &result { - ClaimResult::Paid => true, - ClaimResult::Rejected => false, - ClaimResult::PartiallyPaid => true, - _ => false, - }; - env.events().publish( - (Symbol::new(&env, "claim_processed"),), - ClaimProcessed { - claim_id, - policy_id, - trigger_met, - status: c.status, - }, - ); - } - } - // Sub-invocation failed or returned a contract error — skip - // this claim and continue processing the rest of the batch. - Ok(Err(_)) | Err(_) => continue, - } - } - results - } - - // ── Dispute ─────────────────────────────────────────────────────────────── - - /// Configure how long a rejected or partially-paid claim remains - /// disputable. The range is one hour through 30 days, inclusive; zero is - /// never a valid way to disable claimant protections. - pub fn set_dispute_window(env: Env, admin: Address, seconds: u64) { - Self::require_admin(&env, &admin); - if !(MIN_DISPUTE_WINDOW..=MAX_DISPUTE_WINDOW).contains(&seconds) { - panic_with_error!(&env, Error::InvalidDisputeWindow); - } - env.storage().instance().set(&StorageKey::DisputeWindow, &seconds); - env.events().publish( - (Symbol::new(&env, "dispute_window_updated"),), - seconds, - ); - } - - /// Return the configured dispute window in seconds. - pub fn get_dispute_window(env: Env) -> u64 { - env.storage() - .instance() - .get(&StorageKey::DisputeWindow) - .unwrap_or(DEFAULT_DISPUTE_WINDOW) - } - - /// Admin sets an explicit dispute deadline for a claim, validating it is strictly in the future (issue #740). - pub fn set_dispute_deadline(env: Env, admin: Address, claim_id: u128, deadline: u64) { - Self::require_admin(&env, &admin); - let now = env.ledger().timestamp(); - if deadline <= now { - panic_with_error!(&env, Error::InvalidDisputeDeadline); - } - let _claim: Claim = env.storage().persistent() - .get(&StorageKey::Claim(claim_id)) - .unwrap_or_else(|| panic_with_error!(&env, Error::ClaimNotFound)); - let key = StorageKey::ClaimDisputeDeadline(claim_id); - env.storage().persistent().set(&key, &deadline); - env.storage().persistent().extend_ttl(&key, TTL_THRESHOLD, TTL_EXTEND_TO); - env.events().publish( - (Symbol::new(&env, "dispute_deadline_set"),), - (claim_id, deadline), - ); - } - - /// Retrieve the explicit dispute deadline for a claim if configured (issue #740). - pub fn get_dispute_deadline(env: Env, claim_id: u128) -> Option { - env.storage().persistent().get(&StorageKey::ClaimDisputeDeadline(claim_id)) - } - - /// Return the timestamp of a claimant's last submitted claim (issue #737). - pub fn get_user_last_claim(env: Env, claimant: Address) -> u64 { - env.storage().persistent().get(&StorageKey::UserLastClaim(claimant)).unwrap_or(0) - } - - /// Escalate a Pending or Rejected claim to Disputed status. - /// SECURITY FIX: Only the original claimant or the policy holder may dispute. - /// This restricts dispute initiation to parties with legitimate interest in the claim. - /// Removes the claim from the pending queue so it is not auto-processed again until - /// an admin resolves the dispute. - pub fn dispute_claim(env: Env, caller: Address, claim_id: u128, reason: soroban_sdk::Symbol) { - caller.require_auth(); - let mut claim: Claim = env.storage().persistent() - .get(&StorageKey::Claim(claim_id)) - .unwrap_or_else(|| panic_with_error!(&env, Error::ClaimNotFound)); - - // SECURITY FIX: Verify caller is either the claimant or the policy holder. - // Get the policy to check the policyholder address. - 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); - - // Only the claimant or the policyholder can dispute a claim - if caller != claim.claimant && caller != policy.policyholder { - 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); - } - - // Validate custom dispute deadline if set (issue #740) - let now = env.ledger().timestamp(); - if let Some(deadline) = env.storage().persistent().get::<_, u64>(&StorageKey::ClaimDisputeDeadline(claim_id)) { - if now > deadline { - panic_with_error!(&env, Error::DisputeWindowExpired); - } - } - - if claim.status != ClaimStatus::Pending { - if let Some(processed_at) = claim.processed_at { - let dispute_window = Self::get_dispute_window(env.clone()); - if env.ledger().timestamp() > processed_at.saturating_add(dispute_window) { - panic_with_error!(&env, Error::DisputeWindowExpired); - } - } - } - let reason_str: Result = SymbolStr::try_from_val(&env, &reason.to_symbol_val()); - let len = match reason_str { - Ok(s) => s.len(), - Err(_) => panic_with_error!(&env, Error::DisputeReasonTooLong), - }; - if len == 0 { - panic_with_error!(&env, Error::EmptyDisputeReason); - } - if len > MAX_DISPUTE_REASON_LEN as usize { - panic_with_error!(&env, Error::DisputeReasonTooLong); - } - claim.status = ClaimStatus::Disputed; - claim.dispute_reason = Some(reason.clone()); - 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. - Self::remove_from_pending(&env, claim_id); - - env.events().publish( - (Symbol::new(&env, "claim_disputed"),), - ClaimDisputed { - claim_id, - claimant: caller.clone(), - reason, - }, - ); - } - - /// 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, - }, - ); - } - - /// Explicitly reject a claim and emit a claim_rejected event with reason code (#695). - pub fn reject_claim(env: Env, admin: Address, claim_id: u128, reason: 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)); - claim.status = ClaimStatus::Rejected; - let claim_key = StorageKey::Claim(claim_id); - env.storage().persistent().set(&claim_key, &claim); - Self::extend_claim_ttl(&env, &claim_key); - Self::remove_from_pending(&env, claim_id); - - env.events().publish( - (Symbol::new(&env, "claim_rejected"),), - (claim_id, claim.policy_id, reason), - ); - } - - // ── 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() - .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() - .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 (max 12) - /// - `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); - - // Claim amounts must be strictly positive (issue #645). - if amount_per_installment <= 0 { - panic_with_error!(&env, Error::InvalidClaimAmount); - } - - // Validate installment count to prevent storage/gas exhaustion - if num_installments == 0 || num_installments > MAX_INSTALLMENTS { - panic_with_error!(&env, Error::InvalidInput); - } - - 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() - .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) - .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) - } - - /// 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, - ); - } - - /// 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, - ); - } - - /// Check whether the contract is currently paused. - pub fn is_paused(env: Env) -> bool { - 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) - .unwrap_or_else(|| panic_with_error!(&env, Error::NotInitialized)); - 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); - 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); - } - - /// Configure the guardian set and approval threshold required for - /// critical actions (currently: contract upgrades). The first set is - /// bootstrapped by the admin; later changes require approval from the - /// current guardians through `approve_guardian_config`. - 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 !guardians.is_empty() && threshold == 0 { - panic_with_error!(&env, Error::InvalidThreshold); - } - if threshold > guardians.len() { - panic_with_error!(&env, Error::InvalidThreshold); - } - if guardians.is_empty() && threshold > 0 { - 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 }, - ); - } - - /// Update guardian threshold independently, validating threshold <= registered guardian count (issue #744). - pub fn set_guardian_threshold(env: Env, admin: Address, 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(); - let guardians: Vec
= env.storage().instance().get(&StorageKey::Guardians) - .unwrap_or_else(|| Vec::new(&env)); - if threshold > guardians.len() { - panic_with_error!(&env, Error::InvalidThreshold); - } - if !guardians.is_empty() && threshold == 0 { - panic_with_error!(&env, Error::InvalidThreshold); - } - env.storage().instance().set(&StorageKey::GuardianThreshold, &threshold); - env.events().publish( - (Symbol::new(&env, "guardian_threshold_updated"),), - 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 before bootstrap). - 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. - /// - /// Panics with `AdminTransferPending` while a transfer is already armed: - /// re-proposing over an armed transfer would rewrite `PendingAdminSince` - /// and reset the `ADMIN_TRANSFER_TIMELOCK`, letting the current admin - /// keep a transfer perpetually un-acceptable (issue #457). - pub fn propose_new_admin(env: Env, admin: Address, new_admin: Address) { - Self::require_admin(&env, &admin); - Self::validate_stellar_address(&env, &new_admin); - - // Issue #457: reject a fresh proposal while one is already pending — - // the armed transfer must complete (be accepted) before another can - // be made, so the timelock clock can never be reset by re-proposing. - let pending: Option
= env.storage().instance() - .get(&StorageKey::PendingAdmin) - .unwrap_or(None); - if pending.is_some() { - panic_with_error!(&env, Error::AdminTransferPending); - } - - 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); - // Issue #457: arm the timelock at most once per transfer — never - // overwrite an already-armed `PendingAdminSince`, or the - // `ADMIN_TRANSFER_TIMELOCK` would restart from this later moment. - if !env.storage().instance().has(&StorageKey::PendingAdminSince) { - 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) { - // 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. - /// - /// Capped at `MAX_ESCALATION_THRESHOLD` (90 days) so claims cannot be - /// left Pending indefinitely with no recourse (issue #618). - pub fn set_escalation_threshold(env: Env, admin: Address, threshold: u64) { - Self::require_admin(&env, &admin); - - if threshold < MIN_ESCALATION_THRESHOLD || threshold > MAX_ESCALATION_THRESHOLD { - panic_with_error!(&env, Error::InvalidThresholdValue); - } - - env.storage() - .instance() - .set(&StorageKey::EscalationThreshold, &threshold); - - env.events().publish( - (Symbol::new(&env, "escalation_threshold_set"),), - EscalationThresholdUpdated { threshold }, - ); - } - - /// Set a per-category escalation threshold. Same bounds as - /// `set_escalation_threshold` (issue #618). - pub fn set_category_escalation(env: Env, admin: Address, category: Symbol, threshold: u64) { - Self::require_admin(&env, &admin); - - if threshold < MIN_ESCALATION_THRESHOLD || threshold > MAX_ESCALATION_THRESHOLD { - panic_with_error!(&env, Error::InvalidThresholdValue); - } - - env.storage() - .instance() - .set(&StorageKey::CategoryEscalationThreshold(category.clone()), &threshold); - - env.events().publish( - (Symbol::new(&env, "category_escalation_threshold_set"), category), - EscalationThresholdUpdated { threshold }, - ); - } - - pub fn get_escalation_threshold(env: Env) -> u64 { - Self::escalation_threshold(&env, None) - } - - pub fn get_category_escalation(env: Env, category: Symbol) -> Option { - env.storage() - .instance() - .get(&StorageKey::CategoryEscalationThreshold(category)) - } - - /// 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 policy_engine_id = Self::policy_engine(&env); - let policy = parashield_policy_engine::PolicyEngineClient::new(&env, &policy_engine_id).get_policy(&claim.policy_id); - let product = parashield_policy_engine::PolicyEngineClient::new(&env, &policy_engine_id).get_product(&policy.product_id); - let threshold = Self::escalation_threshold(&env, Some(product.category)); - 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); - } - // Issue #625: cap the configurable deadline so an admin cannot open a - // claim submission window that extends indefinitely into the future. - if deadline > MAX_CLAIM_DEADLINE { - 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) - } - - /// Submit evidence files for a claim, rejecting zero hashes and enforcing max file count (issue #728, #747). - pub fn submit_claim_evidence(env: Env, claim_id: u128, claimant: Address, evidence_hashes: Vec) { - Self::require_not_paused(&env); - claimant.require_auth(); - let claim: Claim = env.storage().persistent() - .get(&StorageKey::Claim(claim_id)) - .unwrap_or_else(|| panic_with_error!(&env, Error::ClaimNotFound)); - if claim.claimant != claimant { - panic_with_error!(&env, Error::Unauthorized); - } - if evidence_hashes.len() > MAX_EVIDENCE_FILES { - panic_with_error!(&env, Error::EvidenceLimitExceeded); - } - for i in 0..evidence_hashes.len() { - let hash = evidence_hashes.get_unchecked(i); - if hash.is_empty() { - panic_with_error!(&env, Error::InvalidEvidence); - } - let mut all_zeros = true; - for b in hash.iter() { - if b != 0 { - all_zeros = false; - break; - } - } - if all_zeros { - panic_with_error!(&env, Error::InvalidEvidence); - } - } - let key = StorageKey::ClaimEvidence(claim_id); - env.storage().persistent().set(&key, &evidence_hashes); - env.storage().persistent().extend_ttl(&key, TTL_THRESHOLD, TTL_EXTEND_TO); - } - - /// Retrieve attached evidence hashes for a claim if available (issue #728, #747). - pub fn get_claim_evidence(env: Env, claim_id: u128) -> Vec { - env.storage().persistent().get(&StorageKey::ClaimEvidence(claim_id)).unwrap_or_else(|| Vec::new(&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: i64) { - Self::require_admin(&env, &admin); - // Reject negative delays (issue: ClaimsProcessor allows negative payout delay) - if delay_seconds < 0 { - panic_with_error!(&env, Error::InvalidInput); - } - const MAX_PAYOUT_DELAY: i64 = 365 * 24 * 60 * 60; // 365 days - if delay_seconds > MAX_PAYOUT_DELAY { - panic_with_error!(&env, Error::PayoutDelayTooLong); - } - let delay_u64 = delay_seconds as u64; - env.storage().instance().set(&StorageKey::PayoutDelay, &delay_u64); - env.events().publish( - (Symbol::new(&env, "payout_delay_set"),), - PayoutDelayUpdated { delay_seconds: delay_u64 }, - ); - } - - /// The configured payout delay in seconds (default: 0 — immediate). - pub fn get_payout_delay(env: Env) -> u64 { - Self::payout_delay(&env) - } - - /// Configure the fraud detector (issue #437). Admin-only. Absent - /// configuration means the detector is fully disabled; calling this - /// once with any `FraudConfig` turns it on for every subsequent - /// `submit_claim`. - /// - /// Bounds enforced: - /// - `fraud_threshold_score <= MAX_FRAUD_SCORE` - /// - /// Rule score fields are bounded implicitly by their `u32` type; a - /// pathological admin can still make a single rule fire the threshold - /// alone. That is intentional, so `Rule 3 alone = suspicious enough` - /// remains configurable. - pub fn set_fraud_config(env: Env, admin: Address, config: FraudConfig) { - Self::require_admin(&env, &admin); - if config.fraud_threshold_score > MAX_FRAUD_SCORE { - panic_with_error!(&env, Error::InvalidInput); - } - env.storage() - .instance() - .set(&StorageKey::FraudConfig, &config); - env.events().publish( - (Symbol::new(&env, "fraud_config_updated"),), - FraudConfigUpdated { - rate_window_secs: config.rate_window_secs, - rate_score: config.rate_score, - burst_window_secs: config.burst_window_secs, - burst_count: config.burst_count, - burst_score: config.burst_score, - coverage_anomaly_multiplier: config.coverage_anomaly_multiplier, - coverage_score: config.coverage_score, - fraud_threshold_score: config.fraud_threshold_score, - mode: config.mode, - }, - ); - } - - /// The fraud detector configuration currently in force, or `None` when - /// the detector is disabled (issue #437). - pub fn get_fraud_config(env: Env) -> Option { - Self::fraud_config(&env) - } - - /// The frozen fraud snapshot for a claim (issue #437). `None` when the - /// claim was never flagged (either the detector is off, the score was - /// below threshold, or the mode was Block and no state was written). - pub fn get_fraud_record(env: Env, claim_id: u128) -> Option { - env.storage() - .persistent() - .get(&StorageKey::FraudRecord(claim_id)) - } - - /// The claimant's aggregate history the detector uses. Returns the - /// empty aggregate when the claimant has never submitted (issue #437). - pub fn get_claimant_history(env: Env, claimant: Address) -> ClaimantHistory { - Self::claimant_history(&env, &claimant) - } - - /// 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, verifying that the payout token matches the policy token (issue #751). - pub fn claim_payout_with_token(env: Env, claim_id: u128, payout_token: Address) -> i128 { - Self::require_not_paused(&env); - let claim: Claim = env.storage().persistent() - .get(&StorageKey::Claim(claim_id)) - .unwrap_or_else(|| panic_with_error!(&env, Error::ClaimNotFound)); - - 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 product = PolicyEngineClient::new(&env, &policy_engine).get_product(&policy.product_id); - - if payout_token != product.expected_token { - panic_with_error!(&env, Error::InvalidToken); - } - - Self::claim_payout(env, claim_id) - } - - /// Claim payout with explicit recipient verification (issue #718). - /// Rejects any attempt to redirect payout away from the claimant. - pub fn claim_payout_to(env: Env, claim_id: u128, recipient: Address) -> i128 { - let claim: Claim = env.storage().persistent() - .get(&StorageKey::Claim(claim_id)) - .unwrap_or_else(|| panic_with_error!(&env, Error::ClaimNotFound)); - if recipient != claim.claimant { - panic_with_error!(&env, Error::Unauthorized); - } - Self::claim_payout(env, claim_id) - } - - /// 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)); - - // Re-check the cap at release time: the payout may never exceed the - // policy's coverage amount (issue #550, #723). - // Accepts paid_amount == claim.coverage_amount; rejects strictly if paid_amount > claim.coverage_amount. - if paid_amount > claim.coverage_amount { - panic_with_error!(&env, Error::PayoutExceedsCoverage); - } - - // 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); - let policy_engine_id = Self::policy_engine(&env); - let policy = parashield_policy_engine::PolicyEngineClient::new(&env, &policy_engine_id).get_policy(&claim.policy_id); - let product = parashield_policy_engine::PolicyEngineClient::new(&env, &policy_engine_id).get_product(&policy.product_id); - let threshold = Self::escalation_threshold(&env, Some(product.category)); - - if pending_for < threshold { - panic_with_error!(&env, Error::NotEscalatable); - } - - // SECURITY FIX: Verify escalation is within the allowed time window from claim submission. - // Claims should only be escalatable within a reasonable time after submission. - // After the policy end_time + claim_deadline has passed, escalation is no longer meaningful - // because the policy period is over and the claim should have been processed already. - let claim_deadline = Self::claim_deadline(&env); - let escalation_cutoff = policy.end_time.saturating_add(claim_deadline); - if now > escalation_cutoff { - 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, - }, - ); - } - - /// Retrieve original claim context preserved during escalation (#691). - pub fn get_escalated_claim_context(env: Env, claim_id: u128) -> Claim { - env.storage() - .persistent() - .get(&StorageKey::Claim(claim_id)) - .unwrap_or_else(|| panic_with_error!(&env, Error::ClaimNotFound)) - } - - /// 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 now = env.ledger().timestamp(); - let mut overdue: Vec = Vec::new(&env); - let policy_engine_id = Self::policy_engine(&env); - let pe_client = parashield_policy_engine::PolicyEngineClient::new(&env, &policy_engine_id); - - 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 { - let policy = pe_client.get_policy(&claim.policy_id); - let product = pe_client.get_product(&policy.product_id); - let threshold = Self::escalation_threshold(&env, Some(product.category)); - - if now.saturating_sub(claim.submitted_at) >= threshold { - overdue.push_back(cid); - } - } - } - } - - overdue - } - - // ── 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: add an address to the authorized attesters list. - pub fn add_authorized_attester(env: Env, admin: Address, attester: Address) { - Self::require_admin(&env, &admin); - let mut attesters: Vec
= env - .storage() - .instance() - .get(&StorageKey::AuthorizedAttesters) - .unwrap_or_else(|| Vec::new(&env)); - for i in 0..attesters.len() { - if attesters.get_unchecked(i) == attester { - return; - } - } - attesters.push_back(attester.clone()); - env.storage() - .instance() - .set(&StorageKey::AuthorizedAttesters, &attesters); - env.events().publish( - (Symbol::new(&env, "attester_added"),), - attester, - ); - } - - /// Admin-only: remove an address from the authorized attesters list. - pub fn remove_authorized_attester(env: Env, admin: Address, attester: Address) { - Self::require_admin(&env, &admin); - let mut attesters: Vec
= env - .storage() - .instance() - .get(&StorageKey::AuthorizedAttesters) - .unwrap_or_else(|| Vec::new(&env)); - let mut idx: Option = None; - for i in 0..attesters.len() { - if attesters.get_unchecked(i) == attester { - idx = Some(i); - break; - } - } - if let Some(i) = idx { - attesters.remove(i); - env.storage() - .instance() - .set(&StorageKey::AuthorizedAttesters, &attesters); - } - env.events().publish( - (Symbol::new(&env, "attester_removed"),), - attester, - ); - } - - /// Return the list of authorized attesters. - pub fn get_authorized_attesters(env: Env) -> Vec
{ - env.storage() - .instance() - .get(&StorageKey::AuthorizedAttesters) - .unwrap_or_else(|| Vec::new(&env)) - } - - /// Verify a claimant's identity for a claim. Callable by admin or any - /// authorized attester. The caller must be either the contract admin or - /// an address previously added via `add_authorized_attester`. - pub fn verify_claimant_identity( - env: Env, - attester: Address, - claim_id: u128, - id_type: Symbol, - ) { - attester.require_auth(); - Self::require_admin_or_attester(&env, &attester); - - 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), - ); - } - - /// 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 ───────────────────────────────────────────────────── - - /// 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 { - if claim.status != ClaimStatus::Pending { - panic_with_error!(env, Error::AlreadyProcessed); - } - if claim.claimant != policy.policyholder { - panic_with_error!(env, Error::Unauthorized); - } - let oracle_verifier: Address = env.storage().instance() - .get(&StorageKey::OracleVerifier) - .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() - .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 - }; - - // 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, - ); - - Self::settle_claim_with_trigger_result(env, claim, policy, trigger_met, partial_payout_bps) - } - - /// Internal settlement logic shared by single-reading and multi-reading claim flows (#683). - fn settle_claim_with_trigger_result( - env: &Env, - claim: &mut Claim, - policy: ¶shield_policy_engine::Policy, - trigger_met: bool, - partial_payout_bps: Option, - ) -> ClaimResult { - if claim.status != ClaimStatus::Pending { - panic_with_error!(env, Error::AlreadyProcessed); - } - if claim.claimant != policy.policyholder { - panic_with_error!(env, Error::Unauthorized); - } - 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)); - - claim.trigger_met = trigger_met; - claim.processed_at = Some(env.ledger().timestamp()); - - let payout_delay = Self::payout_delay(env); - - let result = if trigger_met { - // 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 { - 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::Paid - } else { - // Partial payment - let paid = claim.coverage_amount * (effective_bps as i128) / 10_000; - if paid > claim.coverage_amount { - panic_with_error!(env, Error::PayoutExceedsCoverage); - } - 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); - - Self::remove_from_pending(env, claim.id); - - 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")), - ); - } - - env.events().publish( - (Symbol::new(env, "claim_settled"), claim.id), - (claim.trigger_met, claim.coverage_amount), - ); - - result - } - - /// Remove a claim id from the pending queue, if present. - - /// Return the stored Policy Engine contract address, panicking with - /// `NotInitialized` if the contract has not been set up yet. - fn policy_engine(env: &Env) -> Address { - env.storage() - .instance() - .get(&StorageKey::PolicyEngine) - .unwrap_or_else(|| panic_with_error!(env, Error::NotInitialized)) - } - - /// The configured escalation threshold for a category, or the global default. - fn escalation_threshold(env: &Env, category: Option) -> u64 { - if let Some(cat) = category { - if let Some(val) = env.storage().instance().get(&StorageKey::CategoryEscalationThreshold(cat)) { - return val; - } - } - 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)); - 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() - .get(&StorageKey::Keeper(caller.clone())) - .unwrap_or(false); - if !authorized { - panic_with_error!(env, Error::Unauthorized); - } - caller.require_auth(); - } - - /// Panic unless `caller` is the admin and authorizes the call. - 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(); - } - - /// Panic unless `caller` is the admin or an authorized attester. - fn require_admin_or_attester(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 { - return; - } - let attesters: Vec
= env - .storage() - .instance() - .get(&StorageKey::AuthorizedAttesters) - .unwrap_or_else(|| Vec::new(env)); - for i in 0..attesters.len() { - if attesters.get_unchecked(i) == *caller { - return; - } - } - panic_with_error!(env, Error::UnauthorizedAttester); - } - - /// Panic if the contract is currently paused. - fn require_not_paused(env: &Env) { - let paused: bool = env.storage().instance() - .get(&StorageKey::Paused) - .unwrap_or(false); - if paused { - panic_with_error!(env, Error::Paused); - } - } - - /// Panic with `AdminCannotBeClaimant` if `caller` is the contract admin. - /// - /// Separation of duties for the claimant side of a claim (issue #566). - /// Deliberately *not* `Unauthorized`: the admin is a privileged caller - /// everywhere else here, so a caller that trips this is doing something - /// the contract never allows rather than being unauthenticated, and - /// off-chain callers need to tell the two apart. - fn require_not_admin(env: &Env, caller: &Address) { - let admin: Option
= env.storage().instance().get(&StorageKey::Admin); - if admin.as_ref() == Some(caller) { - panic_with_error!(env, Error::AdminCannotBeClaimant); - } - } - - /// Reject the all-zero Stellar account address. Nobody holds its - /// private key, so a claim filed for it would settle a payout that can - /// never be collected and would leave the policy permanently stuck in - /// `Claimed` state (issue #586). - fn require_not_zero_address(env: &Env, caller: &Address) { - let zero = Address::from_string(&soroban_sdk::String::from_str( - env, - "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF", - )); - if *caller == zero { - panic_with_error!(env, Error::InvalidAddress); - } - } - - /// 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)); - id - } - - /// Panic unless `address` is a well-formed Stellar address. - /// - /// Delegates the actual format decision to [`is_well_formed_strkey`] so - /// the rule lives in one pure, directly testable function instead of - /// being spread across the call sites that need it (issue #572). - fn validate_stellar_address(env: &Env, address: &Address) { - let addr_str = address.to_string(); - let mut buf = [0u8; STRKEY_LEN]; - if addr_str.len() as usize != STRKEY_LEN { - panic_with_error!(env, Error::InvalidAddress); - } - addr_str.copy_into_slice(&mut buf); - if !is_well_formed_strkey(&buf) { - panic_with_error!(env, Error::InvalidAddress); - } - } - - // ── Fraud detection helpers (issue #437) ────────────────────────────── - - /// Fraud detector configuration if the admin has set one, else `None`. - /// `None` means the detector is disabled and every submission proceeds - /// unchecked (pre-issue-437 behaviour). - fn fraud_config(env: &Env) -> Option { - env.storage().instance().get(&StorageKey::FraudConfig) - } - - /// A claimant's aggregate history, or an empty one for a first-time - /// submitter. `unwrap_or_else` ensures fresh submitters do not pay a - /// storage read that could fail. - fn claimant_history(env: &Env, claimant: &Address) -> ClaimantHistory { - env.storage() - .persistent() - .get(&StorageKey::ClaimantHistory(claimant.clone())) - .unwrap_or(ClaimantHistory { - last_submission_at: 0, - burst_bucket_start: 0, - burst_count: 0, - max_coverage_ever: 0, - total_submissions: 0, - }) - } - - /// Score this submission against the configured rules. Returns - /// `(score, flags)`, both zero when the detector is disabled or when - /// no rule fires. - /// - /// Pure function over the passed-in `now` / `history` / `coverage` so it - /// can be tested without spinning up the full cross-contract world. - fn score_submission( - cfg: &FraudConfig, - now: u64, - history: &ClaimantHistory, - coverage_amount: i128, - ) -> (u32, u32) { - let mut score: u32 = 0; - let mut flags: u32 = 0; - - // Rule 1: rapid resubmission. Only fires once a claimant has any - // prior submission on record; a first-ever claim never trips it. - if cfg.rate_window_secs > 0 - && cfg.rate_score > 0 - && history.last_submission_at > 0 - && now.saturating_sub(history.last_submission_at) < cfg.rate_window_secs - { - score = score.saturating_add(cfg.rate_score); - flags |= FRAUD_FLAG_RATE; - } - - // Rule 2: multi-policy burst. Fires when *this* submission would - // be the `burst_count`-th inside the active window bucket. The - // caller updates the bucket after admission; here we compute the - // hypothetical post-increment count using the stored bucket. - if cfg.burst_window_secs > 0 && cfg.burst_count > 0 && cfg.burst_score > 0 { - let bucket_alive = history.burst_bucket_start > 0 - && now.saturating_sub(history.burst_bucket_start) < cfg.burst_window_secs; - let projected = if bucket_alive { - history.burst_count.saturating_add(1) - } else { - 1 - }; - if projected >= cfg.burst_count { - score = score.saturating_add(cfg.burst_score); - flags |= FRAUD_FLAG_BURST; - } - } - - // Rule 3: coverage anomaly. Requires prior coverage on record — - // a first-ever claim cannot be an anomaly relative to nothing. - if cfg.coverage_anomaly_multiplier > 0 - && cfg.coverage_score > 0 - && history.max_coverage_ever > 0 - { - let threshold = history - .max_coverage_ever - .saturating_mul(cfg.coverage_anomaly_multiplier as i128); - if coverage_amount > threshold { - score = score.saturating_add(cfg.coverage_score); - flags |= FRAUD_FLAG_COVERAGE; - } - } - - (score, flags) - } - - /// Fold a just-admitted submission into the claimant's history. Bumps - /// `total_submissions` and `max_coverage_ever` on every call; resets or - /// extends the burst bucket based on how much time has passed since the - /// previous submission. Called by `submit_claim` after the fraud gate. - fn update_claimant_history( - env: &Env, - claimant: &Address, - now: u64, - coverage_amount: i128, - burst_window_secs: u64, - ) { - let prev = Self::claimant_history(env, claimant); - let (bucket_start, bucket_count) = if burst_window_secs == 0 { - // Feature effectively off — do not carry a stale bucket forward. - (0, 0) - } else if prev.burst_bucket_start == 0 - || now.saturating_sub(prev.burst_bucket_start) >= burst_window_secs - { - (now, 1) - } else { - (prev.burst_bucket_start, prev.burst_count.saturating_add(1)) - }; - - let next = ClaimantHistory { - last_submission_at: now, - burst_bucket_start: bucket_start, - burst_count: bucket_count, - max_coverage_ever: prev.max_coverage_ever.max(coverage_amount), - total_submissions: prev.total_submissions.saturating_add(1), - }; - let key = StorageKey::ClaimantHistory(claimant.clone()); - env.storage().persistent().set(&key, &next); - env.storage() - .persistent() - .extend_ttl(&key, TTL_THRESHOLD, TTL_EXTEND_TO); - } - - /// Run the configured fraud detector against a hypothetical submission. - /// If the detector is off (no `FraudConfig`) this returns immediately - /// with `(0, 0)` and does nothing else. - /// - /// If the score meets or exceeds the configured threshold and mode is - /// `Block`, the function panics with `FraudSuspected` after emitting - /// `FraudFlagged`. If mode is `FlagOnly`, it emits the event, persists a - /// `FraudRecord`, and returns so the caller can continue writing the - /// claim. - /// - /// Called from `submit_claim` after all existing gates so a rejection at - /// this point does not leak partial state. - fn evaluate_fraud( - env: &Env, - claim_id: u128, - claimant: &Address, - coverage_amount: i128, - ) -> (u32, u32) { - let Some(cfg) = Self::fraud_config(env) else { - return (0, 0); - }; - let now = env.ledger().timestamp(); - let history = Self::claimant_history(env, claimant); - let (score, flags) = Self::score_submission(&cfg, now, &history, coverage_amount); - if score >= cfg.fraud_threshold_score && cfg.fraud_threshold_score > 0 { - env.events().publish( - (Symbol::new(env, "fraud_flagged"),), - FraudFlagged { - claim_id, - claimant: claimant.clone(), - score, - flags, - mode: cfg.mode.clone(), - }, - ); - match cfg.mode { - FraudMode::Block => panic_with_error!(env, Error::FraudSuspected), - FraudMode::FlagOnly => { - let record = FraudRecord { - claim_id, - score, - flags, - checked_at: now, - }; - let key = StorageKey::FraudRecord(claim_id); - env.storage().persistent().set(&key, &record); - env.storage() - .persistent() - .extend_ttl(&key, TTL_THRESHOLD, TTL_EXTEND_TO); - } - } - } - (score, flags) - } -} - -/// Map policy-engine TriggerComparison to oracle-verifier TriggerComparison. -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, - } -} - -/// Length of a Stellar `StrKey` (a `G…` account id or a `C…` contract id): -/// one version character plus 55 base32 characters encoding the 33-byte -/// (version + 32-byte payload) key, rounded up to a whole 5-bit group. -const STRKEY_LEN: usize = 56; - -/// Whether `bytes` is a syntactically well-formed Stellar address string -/// (issue #572). -/// -/// A `StrKey` is 56 bytes long and encodes the 33-byte -/// `version || payload` buffer in unpadded base32 (RFC 4648). Two version -/// characters are meaningful on the network: `G` for a classic account and -/// `C` for a contract. Everything after the version byte must therefore be a -/// base32 symbol — `A`–`Z` or `2`–`7` — which is what makes this a real format -/// check rather than the prefix test it replaced. Note that the StrKey -/// checksum is intentionally *not* verified: the host already guarantees a -/// canonical encoding for any `Address` it hands us, so a checksum failure is -/// not reachable from here, and re-deriving one on-chain would cost a CRC16 -/// for no additional guarantee. -/// -/// Takes bytes rather than `&str` because `soroban_sdk::String` (what -/// `Address::to_string` returns) cannot be borrowed as `str`. Pure and -/// infallible, so it can be exercised directly against byte strings that no -/// `Address` could ever be constructed from. -fn is_well_formed_strkey(bytes: &[u8]) -> bool { - if bytes.len() != STRKEY_LEN { - return false; - } - // Version byte: G (account) or C (contract). - if bytes[0] != b'G' && bytes[0] != b'C' { - return false; - } - bytes[1..] - .iter() - .all(|&b| (b >= b'A' && b <= b'Z') || (b >= b'2' && b <= b'7')) -} - -#[cfg(test)] -mod test; -#[cfg(test)] -mod test_integration; -#[cfg(test)] -mod test_advanced; -#[cfg(test)] -mod test_keeper_registry; -#[cfg(test)] -mod test_claim_uniqueness; +// 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 +//! Oracle Verifier, then instructs the Policy Engine to pay out or expire. +//! +//! Two processing paths +//! ───────────────────── +//! 1. `submit_claim` + `process_claim` — user or keeper manually triggers evaluation. +//! 2. `auto_process` — keeper-triggered; evaluates without a prior user submission. +//! This is the primary path for parametric insurance (no claim form needed). +//! +//! Idempotency +//! ──────────── +//! Once a policy is Claimed or Expired, further process/auto_process calls +//! return the appropriate ClaimResult without writing again. +#![no_std] +extern crate alloc; +use alloc::string::ToString; + +use soroban_sdk::{ + contract, contractimpl, contracttype, contracterror, panic_with_error, + Address, Bytes, BytesN, Env, Vec, Symbol, SymbolStr, TryFromVal, IntoVal, +}; + +pub mod types; +pub use types::*; + +// ─── Cross-contract client interfaces ──────────────────────────────────────── + +#[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")] +trait IPolicyEngine { + fn get_policy(env: Env, policy_id: u128) -> parashield_policy_engine::Policy; + fn get_product(env: Env, product_id: u128) -> parashield_policy_engine::InsuranceProduct; + fn pay_claim(env: Env, caller: Address, policy_id: u128); + fn expire_policy(env: Env, caller: Address, policy_id: u128); +} + +#[soroban_sdk::contractclient(name = "OracleVerifierClient")] +trait IOracleVerifier { + fn verify_trigger_fresh( + env: Env, + data_type: soroban_sdk::Symbol, + key: soroban_sdk::Symbol, + condition: parashield_oracle_verifier::TriggerCondition, + max_age_seconds: u64, + ) -> bool; + + fn batch_verify_fresh( + env: Env, + queries: soroban_sdk::Vec, + ) -> soroban_sdk::Vec; +} + +// ─── Storage TTL ────────────────────────────────────────────────────────────── + +// Shared protocol constants — single source of truth in parashield-common (issue #342). +use parashield_common::{TTL_THRESHOLD, TTL_EXTEND_TO, ADMIN_TRANSFER_TIMELOCK}; + +// ─── 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 ───────────────────────────────────────────────────────────── + +#[contracttype] +enum StorageKey { + Initialized, + Admin, + PolicyEngine, + RiskPool, + OracleVerifier, + StalenessThreshold, // u64 — max acceptable oracle data age in seconds + Claim(u128), + PolicyClaim(u128), // policy_id → claim_id (one claim per policy) + NextClaimId, + 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). Zero is only valid before guardians are configured. + GuardianThreshold, + /// Guardian set and threshold proposed by the admin, awaiting approval. + PendingGuardianConfig, + /// A pending, not-yet-executed contract upgrade awaiting guardian approvals. + PendingUpgrade, + /// Seconds a claim may sit Pending before it can be escalated (u64). + EscalationThreshold, + /// Per-category escalation threshold (Symbol -> u64). + CategoryEscalationThreshold(Symbol), + /// 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), + /// Fraud detector configuration (issue #437). Absent = detector disabled + /// entirely; every claim proceeds unchecked. + FraudConfig, + /// Compact aggregate of a claimant's recent submission behaviour + /// (issue #437). Keyed by claimant so the fraud detector never has to + /// scan the full claim table on a new submission. + ClaimantHistory(Address), + /// A frozen snapshot of the fraud detector's judgement on one claim + /// (issue #437). Written only when the detector flagged something. + FraudRecord(u128), + /// SECURITY FIX: Rate limiting for auto_process function + /// Maps policy_id to the last timestamp it was processed to prevent spam. + AutoProcessLastTime(u128), + /// SECURITY FIX: Minimum cooldown in seconds between auto_process calls for the same policy. + AutoProcessCooldown, + /// Authorized identity attesters list (Vec
). + AuthorizedAttesters, + /// Time after a claim decision during which the claimant may dispute it. + DisputeWindow, + /// Claim reference number mapping: reference_number (Symbol) -> claim_id (u128) (issue #754). + ClaimReference(Symbol), + /// Custom dispute deadline for a claim: claim_id (u128) -> deadline (u64) (issue #740). + ClaimDisputeDeadline(u128), + /// Per-user last claim submission timestamp: claimant (Address) -> timestamp (u64) (issue #737). + UserLastClaim(Address), + /// Registered valid claim type (Symbol) -> bool (issue #734). + RegisteredClaimType(Symbol), + /// Evidence attachments for a claim: claim_id (u128) -> Vec hashes (issue #747). + ClaimEvidence(u128), +} + +/// Maximum number of evidence files allowed per claim (issue #747). +pub const MAX_EVIDENCE_FILES: u32 = 10; + +// ─── Errors ─────────────────────────────────────────────────────────────────── + +#[contracterror] +#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] +#[repr(u32)] +pub enum Error { + AlreadyInitialized = 1, + 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, + /// Fraud detector flagged this claim's submission and the current + /// `FraudMode` is `Block` (issue #437). No state was written for the + /// rejected claim. + FraudSuspected = 23, + /// An admin transfer was proposed while another one is still pending, + /// which would reset the transfer timelock (issue #457). + AdminTransferPending = 24, + /// SECURITY FIX: Rate limiting - auto_process called too frequently for this policy. + RateLimitExceeded = 25, + /// A payout larger than the policy's coverage amount was attempted (issue #550). + PayoutExceedsCoverage = 26, + /// The caller is not in the authorized attesters list. + UnauthorizedAttester = 27, + /// A claim was filed outside the policy's coverage period — before + /// `start_time` or after `end_time`. + ClaimOutsideCoveragePeriod = 28, + /// The contract admin tried to file a claim, on any policy. The admin + /// resolves disputes and controls payout configuration; letting them + /// also be the claimant is self-dealing (issue #566). + AdminCannotBeClaimant = 29, + /// Payout delay exceeds the maximum allowed 365 days. + PayoutDelayTooLong = 30, + /// Dispute reason cannot be empty (issue #582). + EmptyDisputeReason = 31, + /// Claim coverage amount does not match the policy coverage amount (issue #589). + CoverageMismatch = 32, + /// Claim coverage amount is zero or negative — nothing to pay out. + InvalidClaimAmount = 33, + /// Dispute reason symbol exceeds the maximum allowed length. + DisputeReasonTooLong = 34, + /// Dispute windows must stay within the documented one-hour to 30-day range. + InvalidDisputeWindow = 35, + /// The configured opportunity to dispute this decision has elapsed. + DisputeWindowExpired = 36, + /// Duplicate claim reference numbers are not permitted (issue #754). + DuplicateClaimReference = 37, + /// Dispute deadline must be strictly in the future (issue #740). + InvalidDisputeDeadline = 38, + /// Claim type must be registered and non-empty (issue #734). + InvalidClaimType = 39, + /// Evidence array exceeds `MAX_EVIDENCE_FILES` (issue #747). + EvidenceLimitExceeded = 40, + /// A supplied evidence hash is malformed or empty (issue #747). + InvalidEvidence = 41, + /// Payment token does not match the product's expected token. + InvalidToken = 42, +} + +/// 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; + +/// Maximum number of installments allowed per claim. An excessive number +/// of installments could exhaust storage and gas. 12 monthly installments +/// (one year) is a reasonable upper bound for parametric insurance payouts. +const MAX_INSTALLMENTS: u32 = 12; + +/// Maximum length of a dispute reason symbol (issue #642). +const MAX_DISPUTE_REASON_LEN: u32 = 32; + +/// Valid dispute windows are deliberately bounded. Zero silently disables +/// disputes, while an unbounded window prevents claims from reaching finality. +pub const MIN_DISPUTE_WINDOW: u64 = 60 * 60; +pub const MAX_DISPUTE_WINDOW: u64 = 30 * 24 * 60 * 60; +pub const DEFAULT_DISPUTE_WINDOW: u64 = 7 * 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; + +/// Longest escalation threshold an admin may configure (90 days). An +/// unreasonably long threshold would leave claims stuck Pending indefinitely +/// with no recourse for the claimant (issue #618). +const MAX_ESCALATION_THRESHOLD: u64 = 90 * 24 * 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; + +/// Upper bound on the admin-configurable claim submission deadline (issue +/// #625). Without a ceiling an admin could set `claim_deadline` to `u64::MAX`, +/// which would allow a claim to be filed indefinitely far in the future — +/// after the triggering event can no longer be verified by oracle data and +/// after any meaningful recourse for the claimant. 365 days is the same +/// retention horizon used for claim entries themselves +/// (`CLAIM_RETENTION_SECONDS`), keeping the submission window within the +/// lifetime of the data that backs it. +const MAX_CLAIM_DEADLINE: u64 = 365 * 24 * 60 * 60; + +/// Hard upper bound on how far in the future a claim may be submitted +/// relative to the current ledger time (issue #625). The ledger timestamp +/// is authoritative, so a caller cannot forge it — but a claim submitted +/// against a policy whose `start_time` is more than this window in the +/// future is rejected outright, since there is nothing to claim for yet. +/// 1 hour is generous enough to absorb ledger-time skew without rejecting +/// a legitimately scheduled policy whose coverage just began. +const MAX_FUTURE_SUBMISSION_WINDOW: u64 = 60 * 60; + +// ─── Fraud detection (issue #437) ──────────────────────────────────────────── + +/// Ceiling for `FraudConfig.fraud_threshold_score`. Scores above 100 could +/// never be reached because the three rules sum to at most 90, and even a +/// future fourth rule should not push a single claim over 100. +const MAX_FRAUD_SCORE: u32 = 100; + +/// Bitmask constants matching the `FraudRecord.flags` documentation. Kept +/// as `u32` because the field is `u32`. +const FRAUD_FLAG_RATE: u32 = 1 << 0; +const FRAUD_FLAG_BURST: u32 = 1 << 1; +const FRAUD_FLAG_COVERAGE: u32 = 1 << 2; + +// ─── Contract ───────────────────────────────────────────────────────────────── + +#[contract] +pub struct ClaimsProcessor; + +#[contractimpl] +impl ClaimsProcessor { + + // ── Lifecycle ──────────────────────────────────────────────────────────── + + /// One-time initialisation. Links the contract to `policy_engine`, `risk_pool`, and + /// `oracle_verifier`. `staleness_threshold` is the maximum age in seconds for oracle + /// data to be considered fresh. Panics with `AlreadyInitialized` on a second call. + pub fn initialize( + env: Env, + admin: Address, + policy_engine: Address, + risk_pool: Address, + oracle_verifier: Address, + staleness_threshold: u64, + ) { + if env.storage().instance().has(&StorageKey::Initialized) { + panic_with_error!(&env, Error::AlreadyInitialized); + } + + 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::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::Paused, &false); + env.storage().instance().set(&StorageKey::ClaimDeadline, &DEFAULT_CLAIM_DEADLINE); + env.storage().instance().set(&StorageKey::EscalationThreshold, &DEFAULT_ESCALATION_THRESHOLD); + env.storage().instance().set(&StorageKey::DisputeWindow, &DEFAULT_DISPUTE_WINDOW); + + env.events().publish( + (Symbol::new(&env, "initialized"),), + Initialized { + admin: admin.clone(), + policy_engine: policy_engine.clone(), + risk_pool: risk_pool.clone(), + oracle_verifier: oracle_verifier.clone(), + staleness_threshold, + }, + ); + } + + // ── Keeper Registry ────────────────────────────────────────────────────── + + /// Admin-only: authorize `keeper` to call process_claim / auto_process / + /// batch_auto_process. Without this, no address can settle claims. + /// + /// `keeper` must be a well-formed Stellar address (issue #572). A keeper + /// entry is a settlement authority: whatever holds it can move coverage + /// USDC out of the pool. Admitting an address whose StrKey encoding is + /// malformed writes a permanent registry entry that can never be + /// legitimately exercised and that off-chain indexers will silently fail + /// to resolve when reconciling `keeper_added` events, so the format is + /// checked up front and the write is refused. + pub fn add_keeper(env: Env, admin: Address, keeper: Address) { + Self::require_admin(&env, &admin); + Self::validate_stellar_address(&env, &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. + /// + /// Deliberately does *not* re-run the format check that `add_keeper` + /// applies. This is the remediation path for a bad registry entry: an + /// admin must be able to evict a keeper that was admitted before the + /// check existed, or a malformed entry would be unremovable. Removing an + /// unknown address is a no-op either way. + 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, + ); + } + + /// Whether `keeper` is currently authorized to settle claims. + pub fn is_keeper(env: Env, keeper: Address) -> bool { + env.storage().persistent() + .get(&StorageKey::Keeper(keeper)) + .unwrap_or(false) + } + + // ── Claim Submission ───────────────────────────────────────────────────── + + /// Manually submit a claim for a policy. Returns the new claim ID. + /// Only the policyholder may submit; only one claim per policy. + /// + /// The contract admin is refused outright (issue #566). An admin who + /// holds a policy is on both sides of the same claim: they file it, + /// they are the escalation/dispute resolver of last resort, and they + /// control the pool configuration the payout comes out of. Filing on + /// their own policy is self-dealing no matter how clean the oracle + /// trigger looks, so the conflict is removed rather than documented. + /// A policy the admin bought is still handled by the keeper-driven + /// `auto_process` path, which is not a claimant-initiated flow. + pub fn submit_claim(env: Env, claimant: Address, policy_id: u128) -> u128 { + claimant.require_auth(); + Self::require_not_paused(&env); + + // The admin may not be the claimant, whoever the policyholder is. + // Checked before any state is read so a rejected call costs nothing. + Self::require_not_admin(&env, &claimant); + // The zero address cannot hold or move funds; a claim filed for it + // would settle a payout nobody can ever collect (issue #586). + Self::require_not_zero_address(&env, &claimant); + + Self::file_claim(&env, &claimant, policy_id) + } + + /// Submit a claim with an explicit reference number (issue #754). + /// Rejects duplicate reference numbers to prevent collisions. + pub fn submit_claim_with_ref( + env: Env, + claimant: Address, + policy_id: u128, + reference_number: Symbol, + ) -> u128 { + claimant.require_auth(); + Self::require_not_paused(&env); + Self::require_not_admin(&env, &claimant); + Self::require_not_zero_address(&env, &claimant); + + if reference_number == Symbol::new(&env, "") { + panic_with_error!(&env, Error::InvalidInput); + } + + let ref_key = StorageKey::ClaimReference(reference_number.clone()); + if env.storage().persistent().has(&ref_key) { + panic_with_error!(&env, Error::DuplicateClaimReference); + } + + let claim_id = Self::file_claim(&env, &claimant, policy_id); + env.storage().persistent().set(&ref_key, &claim_id); + env.storage().persistent().extend_ttl(&ref_key, TTL_THRESHOLD, TTL_EXTEND_TO); + + claim_id + } + + /// Register a valid claim type / product category (issue #734). + pub fn register_claim_type(env: Env, admin: Address, claim_type: Symbol) { + admin.require_auth(); + Self::require_admin(&env, &admin); + if claim_type == Symbol::new(&env, "") { + panic_with_error!(&env, Error::InvalidClaimType); + } + let key = StorageKey::RegisteredClaimType(claim_type); + env.storage().persistent().set(&key, &true); + env.storage().persistent().extend_ttl(&key, TTL_THRESHOLD, TTL_EXTEND_TO); + } + + /// Check if a claim type is registered (issue #734). + pub fn is_claim_type_registered(env: Env, claim_type: Symbol) -> bool { + let key = StorageKey::RegisteredClaimType(claim_type); + env.storage().persistent().has(&key) + } + + /// Submit a claim with an explicit claim type (issue #734). + pub fn submit_claim_with_type( + env: Env, + claimant: Address, + policy_id: u128, + claim_type: Symbol, + ) -> u128 { + claimant.require_auth(); + Self::require_not_paused(&env); + Self::require_not_admin(&env, &claimant); + Self::require_not_zero_address(&env, &claimant); + + if claim_type == Symbol::new(&env, "") || !Self::is_claim_type_registered(env.clone(), claim_type) { + panic_with_error!(&env, Error::InvalidClaimType); + } + + Self::file_claim(&env, &claimant, policy_id) + } + + /// The claim-filing body shared by `submit_claim` and + /// `batch_submit_claims`. The caller has already authorized `claimant`, + /// checked the pause flag and rejected the admin — deliberately + /// *not* re-done here, because a second `require_auth` for the same + /// address in the same call frame is rejected by the host + /// (`Auth, ExistingValue`). `batch_submit_claims` delegating to + /// `submit_claim` therefore made the whole batch path unreachable for + /// every caller; the shared body keeps one set of rules instead of two + /// copies of them. + fn file_claim(env: &Env, claimant: &Address, policy_id: u128) -> u128 { + // Guard: one claim per policy + 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() + .get(&StorageKey::PolicyEngine) + .unwrap_or_else(|| panic_with_error!(env, Error::NotInitialized)); + let policy = PolicyEngineClient::new(env, &policy_engine) + .get_policy(&policy_id); + + // Validate claim type / product category is non-empty (issue #734) + let product = PolicyEngineClient::new(env, &policy_engine) + .get_product(&policy.product_id); + if product.category == Symbol::new(env, "") { + panic_with_error!(env, Error::InvalidClaimType); + } + + if policy.coverage_amount <= 0 { + panic_with_error!(env, Error::InvalidClaimAmount); + } + + if policy.policyholder != *claimant { + panic_with_error!(env, Error::Unauthorized); + } + // Guard: reject claims for cancelled policies (issue #731). + if policy.status == parashield_policy_engine::PolicyStatus::Cancelled { + panic_with_error!(env, Error::PolicyNotActive); + } + if policy.status != parashield_policy_engine::PolicyStatus::Active { + panic_with_error!(env, Error::PolicyNotActive); + } + + // 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 now < policy.start_time { + panic_with_error!(env, Error::ClaimOutsideCoveragePeriod); + } + + // SECURITY FIX: Claims must be submitted before the policy end_time. + // Only after end_time has passed can a claim be filed within the claim_deadline window. + // This prevents claims being submitted during the active policy period incorrectly. + if now <= policy.end_time { + // Policy is still active - allow the claim + } else { + // Policy has ended - check if we're within the claim_deadline window + let cutoff = policy.end_time.saturating_add(Self::claim_deadline(env)); + if now > cutoff { + panic_with_error!(env, Error::ClaimDeadlinePassed); + } + } + + // Issue #625: reject submissions that are too far in the future + // relative to the current ledger time. A scheduled policy whose + // `start_time` is far ahead has no observable trigger yet, so a claim + // filed against it is not grounded in anything verifiable. The small + // window absorbs ledger-time skew for a policy that just began. + if policy.start_time > now.saturating_add(MAX_FUTURE_SUBMISSION_WINDOW) { + panic_with_error!(env, Error::ClaimOutsideCoveragePeriod); + } + + // Issue #635: allow claims filed after end_time within claim_deadline, + // then reject anything past that window. The prior code rejected + // `now > end_time` before this check ran, making the deadline window + // unreachable. + 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); + } + } else if now > policy.end_time { + panic_with_error!(env, Error::ClaimOutsideCoveragePeriod); + } + + // SECURITY FIX (issue #737): Rate limit claim submissions per user to prevent claim flooding. + // Users must wait at least 60 seconds between successive claim submissions. + const USER_CLAIM_COOLDOWN_SECONDS: u64 = 60; + let last_claim_key = StorageKey::UserLastClaim(claimant.clone()); + if let Some(last_time) = env.storage().persistent().get::<_, u64>(&last_claim_key) { + if now < last_time.saturating_add(USER_CLAIM_COOLDOWN_SECONDS) { + panic_with_error!(env, Error::RateLimitExceeded); + } + } + env.storage().persistent().set(&last_claim_key, &now); + env.storage().persistent().extend_ttl(&last_claim_key, TTL_THRESHOLD, TTL_EXTEND_TO); + + let claim_id = Self::next_claim_id(env); + + // Issue #437: score this submission against the configured fraud + // rules. In `Block` mode a high enough score panics here, before any + // claim state is written — the caller gets FraudSuspected and the + // world looks exactly as it did before the call. In `FlagOnly` mode + // a FraudRecord is persisted and the claim proceeds. When no config + // is set, this is a no-op. + Self::evaluate_fraud(env, claim_id, claimant, policy.coverage_amount); + + let claim = Claim { + id: claim_id, + policy_id, + claimant: claimant.clone(), + coverage_amount: policy.coverage_amount, + observed_value: None, + trigger_met: false, + status: ClaimStatus::Pending, + 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); + + 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_submitted"),), + ClaimSubmitted { + claim_id, + policy_id, + claimant: claimant.clone(), + coverage_amount: policy.coverage_amount, + }, + ); + + // Issue #437: fold this submission into the claimant's aggregate + // history for the next call's fraud check. Only meaningful when the + // detector is on, but cheap enough to always maintain; keeping the + // aggregate up to date lets an admin turn the detector on later + // without every claimant looking like a first-time submitter. + let burst_window = Self::fraud_config(env) + .map(|c| c.burst_window_secs) + .unwrap_or(0); + Self::update_claimant_history( + env, + claimant, + now, + policy.coverage_amount, + burst_window, + ); + + 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); + Self::require_not_admin(&env, &claimant); + Self::require_not_zero_address(&env, &claimant); + + 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::file_claim(&env, &claimant, 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. + /// + /// ## Parametric Payout Design Note + /// In parametric insurance, claim payouts are strictly binary or determined by + /// objective oracle trigger measurements (e.g., rainfall, wind speed, flight delay) + /// rather than traditional indemnity models that require loss adjustments or proof + /// of actual financial loss. When an oracle trigger condition is verified, the + /// contract pays out the pre-agreed coverage amount (or pre-configured partial + /// payout percentage) in full, regardless of the policyholder's actual incurred loss. + /// + /// `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%. + /// + /// Boundary semantics (issue #776). The CoverageMismatch guard below + /// uses `!=` rather than `>`/`<`, so a claim whose `coverage_amount` + /// equals the policy's `coverage_amount` exactly is permitted — the + /// equality case is the common case, not a rejection branch. The + /// downstream payout logic in `evaluate_and_settle` mirrors this: + /// `paid_amount > claim.coverage_amount` (strict), so a payout exactly + /// at the coverage ceiling is accepted and only a payout *above* it is + /// rejected. The same `>` strictness applies to the installment + /// schedule check (`total_amount > claim.coverage_amount`). An + /// equal-to-ceiling value is always a valid claim, never a defect. + 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() + .get(&StorageKey::Claim(claim_id)) + .unwrap_or_else(|| panic_with_error!(&env, Error::ClaimNotFound)); + + // SECURITY FIX: Verify claim status is Pending before processing to prevent + // reprocessing already settled claims. This protects against double-payouts + // and ensures claim state transitions are properly enforced. + if claim.status != ClaimStatus::Pending { + return ClaimResult::AlreadyProcessed; + } + + 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); + + // Guard: claim coverage must match the policy to prevent incorrect payouts (#589). + if claim.coverage_amount != policy.coverage_amount { + panic_with_error!(&env, Error::CoverageMismatch); + } + + 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, + }, + ); + + results + } + + /// Batch-verify multiple oracle readings for a claim or policy in a single cross-contract call (#683). + /// Drastically reduces CPU and instruction cost compared to individual verifications. + pub fn batch_verify_oracle_readings( + env: Env, + queries: Vec, + ) -> Vec { + let oracle_verifier: Address = env + .storage() + .instance() + .get(&StorageKey::OracleVerifier) + .unwrap_or_else(|| panic_with_error!(&env, Error::NotInitialized)); + + OracleVerifierClient::new(&env, &oracle_verifier).batch_verify_fresh(&queries) + } + + /// Legacy individual verification of multiple oracle readings, executing N separate + /// cross-contract calls into oracle-verifier. Kept for comparison/benchmarking (#683). + pub fn verify_readings_individual( + env: Env, + queries: Vec, + ) -> Vec { + let oracle_verifier: Address = env + .storage() + .instance() + .get(&StorageKey::OracleVerifier) + .unwrap_or_else(|| panic_with_error!(&env, Error::NotInitialized)); + + let client = OracleVerifierClient::new(&env, &oracle_verifier); + let mut results = Vec::new(&env); + for i in 0..queries.len() { + let q = queries.get_unchecked(i); + let res = client.verify_trigger_fresh( + &q.data_type, + &q.key, + &q.condition, + &q.staleness_threshold, + ); + results.push_back(res); + } + results + } + + /// Process a multi-reading claim by batch-verifying all required oracle readings + /// in a single cross-contract invocation (#683). + /// + /// `require_all`: if true, requires all queries to evaluate to true (AND logic); + /// if false, requires at least one query to evaluate to true (OR logic). + pub fn process_multi_reading_claim( + env: Env, + keeper: Address, + claim_id: u128, + queries: Vec, + require_all: bool, + partial_payout_bps: Option, + ) -> ClaimResult { + Self::require_keeper(&env, &keeper); + Self::require_not_paused(&env); + if queries.is_empty() { + panic_with_error!(&env, Error::InvalidInput); + } + + 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 { + return ClaimResult::AlreadyProcessed; + } + + 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); + + if claim.coverage_amount != policy.coverage_amount { + panic_with_error!(&env, Error::CoverageMismatch); + } + + // Batch verify all readings in ONE cross-contract call + let results = Self::batch_verify_oracle_readings(env.clone(), queries.clone()); + + let mut trigger_met = if require_all { true } else { false }; + for i in 0..results.len() { + let r = results.get_unchecked(i); + if require_all { + if !r { + trigger_met = false; + break; + } + } else { + if r { + trigger_met = true; + break; + } + } + } + + env.events().publish( + (Symbol::new(&env, "readings_batch_verified"),), + ReadingsBatchVerified { + claim_id, + readings_count: queries.len(), + trigger_met, + }, + ); + + Self::settle_claim_with_trigger_result(&env, &mut claim, &policy, trigger_met, partial_payout_bps) + } + + + /// 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. + /// + /// `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); + + // SECURITY FIX: Rate limiting to prevent spam attacks on auto_process + // Check if this policy was recently processed and reject if within cooldown window + let now = env.ledger().timestamp(); + let cooldown_secs: u64 = env.storage().instance() + .get(&StorageKey::AutoProcessCooldown) + .unwrap_or(300); // Default: 5 minutes between calls + + if let Some(last_processed) = env.storage().persistent() + .get::<_, u64>(&StorageKey::AutoProcessLastTime(policy_id)) { + if now < last_processed.saturating_add(cooldown_secs) { + panic_with_error!(&env, Error::RateLimitExceeded); + } + } + + // Update the last processed timestamp for this policy + env.storage().persistent() + .set(&StorageKey::AutoProcessLastTime(policy_id), &now); + + // ─── 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 existing_claim.status != ClaimStatus::Pending { + return ClaimResult::AlreadyProcessed; + } + } + } + + 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); + + if policy.coverage_amount <= 0 { + panic_with_error!(&env, Error::InvalidClaimAmount); + } + + // 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 => {} + } + + // Check if policy has expired with no trigger + 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() + } else { + let cid = Self::next_claim_id(&env); + let claim = Claim { + id: cid, + policy_id, + claimant: policy.policyholder.clone(), + coverage_amount: policy.coverage_amount, + observed_value: None, + trigger_met: false, + status: ClaimStatus::Pending, + 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); + + // 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)); + pending.push_back(cid); + 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(); + if claim.status != ClaimStatus::Pending { + return ClaimResult::AlreadyProcessed; + } + 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). + /// + /// FAIRNESS GUARANTEE: Claims are processed in FIFO (First-In-First-Out) order + /// based on their submission timestamp. This ensures older claims are settled + /// before newer ones, preventing unfair advantage to recently submitted claims. + /// The pending queue maintains insertion order, and this function processes + /// from index 0 (oldest) onwards. + /// + /// `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. + /// + /// Failure isolation: each claim is evaluated via a sub-invocation of + /// `auto_process` on this contract using `env.try_invoke_contract`. If + /// any individual claim's cross-contract calls panic (e.g. stale oracle + /// data, policy engine rejection, risk-pool error), that sub-invocation + /// returns `Err` and the claim is skipped — the batch itself never + /// aborts, so all other claims in the queue are still processed. + pub fn batch_auto_process(env: Env, caller: Address, limit: u32) -> Vec<(u128, ClaimResult)> { + Self::require_keeper(&env, &caller); + Self::require_not_paused(&env); + if limit == 0 { + panic_with_error!(&env, Error::InvalidInput); + } + + // FAIRNESS FIX: Get pending claims list - maintained in FIFO order + let mut pending: Vec = env.storage().instance() + .get(&StorageKey::PendingClaims) + .unwrap_or_else(|| Vec::new(&env)); + + // FAIRNESS FIX: Sort claims by submission time to ensure true FIFO processing. + // Even if the pending queue gets slightly out of order (due to concurrent + // submissions or manual queue manipulation), this ensures oldest claims + // are always processed first. + let mut claim_timestamps: Vec<(u128, u64)> = Vec::new(&env); + for i in 0..pending.len() { + let claim_id = pending.get_unchecked(i); + if let Some(claim) = env.storage().persistent().get::<_, Claim>(&StorageKey::Claim(claim_id)) { + claim_timestamps.push_back((claim_id, claim.submitted_at)); + } + } + + // Sort by timestamp (ascending - oldest first) using bubble sort + // Bubble sort is used here because Soroban doesn't have built-in sorting + let len = claim_timestamps.len(); + for i in 0..len { + for j in 0..(len - i - 1) { + let curr = claim_timestamps.get_unchecked(j); + let next = claim_timestamps.get_unchecked(j + 1); + if curr.1 > next.1 { + // Swap + claim_timestamps.set(j, next); + claim_timestamps.set(j + 1, curr); + } + } + } + + // Rebuild pending queue in correct FIFO order + let mut sorted_pending: Vec = Vec::new(&env); + for i in 0..claim_timestamps.len() { + let (claim_id, _) = claim_timestamps.get_unchecked(i); + sorted_pending.push_back(claim_id); + } + + // Update storage with sorted order + env.storage().instance().set(&StorageKey::PendingClaims, &sorted_pending); + + let mut results: Vec<(u128, ClaimResult)> = Vec::new(&env); + let effective_limit = if limit > MAX_BATCH_SIZE { MAX_BATCH_SIZE } else { limit }; + let process_count = if sorted_pending.len() < effective_limit { + sorted_pending.len() + } else { + effective_limit + }; + + let this = env.current_contract_address(); + let fn_name = Symbol::new(&env, "auto_process"); + + for i in 0..process_count { + let claim_id = sorted_pending.get_unchecked(i); + + // Pre-check: skip claims that are no longer Pending without + // spending a sub-invocation on them (fast path, no isolation cost). + let claim_opt: Option = env.storage().persistent() + .get(&StorageKey::Claim(claim_id)); + let policy_id = match &claim_opt { + None => continue, + Some(c) if c.status != ClaimStatus::Pending => continue, + Some(c) if c.processed_at.is_some() => continue, + Some(c) => c.policy_id, + }; + + // Each claim is evaluated in its own sub-invocation so that a + // panic inside (stale oracle, cross-contract failure, etc.) only + // rolls back that single claim, not the entire batch. + // + // auto_process(caller, policy_id, partial_payout_bps: None) + // None is encoded as the void Val in the args vector. + let none_val: soroban_sdk::Val = Option::::None.into_val(&env); + let args = soroban_sdk::vec![ + &env, + caller.to_val(), + policy_id.into_val(&env), + none_val, + ]; + match env.try_invoke_contract::( + &this, + &fn_name, + args, + ) { + Ok(Ok(result)) => { + results.push_back((claim_id, result.clone())); + + // Emit individual claim processed event for tracking specific outcomes + let claim: Option = env.storage().persistent() + .get(&StorageKey::Claim(claim_id)); + if let Some(c) = claim { + let trigger_met = match &result { + ClaimResult::Paid => true, + ClaimResult::Rejected => false, + ClaimResult::PartiallyPaid => true, + _ => false, + }; + env.events().publish( + (Symbol::new(&env, "claim_processed"),), + ClaimProcessed { + claim_id, + policy_id, + trigger_met, + status: c.status, + }, + ); + } + } + // Sub-invocation failed or returned a contract error — skip + // this claim and continue processing the rest of the batch. + Ok(Err(_)) | Err(_) => continue, + } + } + results + } + + // ── Dispute ─────────────────────────────────────────────────────────────── + + /// Configure how long a rejected or partially-paid claim remains + /// disputable. The range is one hour through 30 days, inclusive; zero is + /// never a valid way to disable claimant protections. + pub fn set_dispute_window(env: Env, admin: Address, seconds: u64) { + Self::require_admin(&env, &admin); + if !(MIN_DISPUTE_WINDOW..=MAX_DISPUTE_WINDOW).contains(&seconds) { + panic_with_error!(&env, Error::InvalidDisputeWindow); + } + env.storage().instance().set(&StorageKey::DisputeWindow, &seconds); + env.events().publish( + (Symbol::new(&env, "dispute_window_updated"),), + seconds, + ); + } + + /// Return the configured dispute window in seconds. + pub fn get_dispute_window(env: Env) -> u64 { + env.storage() + .instance() + .get(&StorageKey::DisputeWindow) + .unwrap_or(DEFAULT_DISPUTE_WINDOW) + } + + /// Admin sets an explicit dispute deadline for a claim, validating it is strictly in the future (issue #740). + pub fn set_dispute_deadline(env: Env, admin: Address, claim_id: u128, deadline: u64) { + Self::require_admin(&env, &admin); + let now = env.ledger().timestamp(); + if deadline <= now { + panic_with_error!(&env, Error::InvalidDisputeDeadline); + } + let _claim: Claim = env.storage().persistent() + .get(&StorageKey::Claim(claim_id)) + .unwrap_or_else(|| panic_with_error!(&env, Error::ClaimNotFound)); + let key = StorageKey::ClaimDisputeDeadline(claim_id); + env.storage().persistent().set(&key, &deadline); + env.storage().persistent().extend_ttl(&key, TTL_THRESHOLD, TTL_EXTEND_TO); + env.events().publish( + (Symbol::new(&env, "dispute_deadline_set"),), + (claim_id, deadline), + ); + } + + /// Retrieve the explicit dispute deadline for a claim if configured (issue #740). + pub fn get_dispute_deadline(env: Env, claim_id: u128) -> Option { + env.storage().persistent().get(&StorageKey::ClaimDisputeDeadline(claim_id)) + } + + /// Return the timestamp of a claimant's last submitted claim (issue #737). + pub fn get_user_last_claim(env: Env, claimant: Address) -> u64 { + env.storage().persistent().get(&StorageKey::UserLastClaim(claimant)).unwrap_or(0) + } + + /// Escalate a Pending or Rejected claim to Disputed status. + /// SECURITY FIX: Only the original claimant or the policy holder may dispute. + /// This restricts dispute initiation to parties with legitimate interest in the claim. + /// Removes the claim from the pending queue so it is not auto-processed again until + /// an admin resolves the dispute. + pub fn dispute_claim(env: Env, caller: Address, claim_id: u128, reason: soroban_sdk::Symbol) { + caller.require_auth(); + let mut claim: Claim = env.storage().persistent() + .get(&StorageKey::Claim(claim_id)) + .unwrap_or_else(|| panic_with_error!(&env, Error::ClaimNotFound)); + + // SECURITY FIX: Verify caller is either the claimant or the policy holder. + // Get the policy to check the policyholder address. + 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); + + // Only the claimant or the policyholder can dispute a claim + if caller != claim.claimant && caller != policy.policyholder { + 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); + } + + // Validate custom dispute deadline if set (issue #740) + let now = env.ledger().timestamp(); + if let Some(deadline) = env.storage().persistent().get::<_, u64>(&StorageKey::ClaimDisputeDeadline(claim_id)) { + if now > deadline { + panic_with_error!(&env, Error::DisputeWindowExpired); + } + } + + if claim.status != ClaimStatus::Pending { + if let Some(processed_at) = claim.processed_at { + let dispute_window = Self::get_dispute_window(env.clone()); + if env.ledger().timestamp() > processed_at.saturating_add(dispute_window) { + panic_with_error!(&env, Error::DisputeWindowExpired); + } + } + } + let reason_str: Result = SymbolStr::try_from_val(&env, &reason.to_symbol_val()); + let len = match reason_str { + Ok(s) => s.len(), + Err(_) => panic_with_error!(&env, Error::DisputeReasonTooLong), + }; + if len == 0 { + panic_with_error!(&env, Error::EmptyDisputeReason); + } + if len > MAX_DISPUTE_REASON_LEN as usize { + panic_with_error!(&env, Error::DisputeReasonTooLong); + } + claim.status = ClaimStatus::Disputed; + claim.dispute_reason = Some(reason.clone()); + 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. + Self::remove_from_pending(&env, claim_id); + + env.events().publish( + (Symbol::new(&env, "claim_disputed"),), + ClaimDisputed { + claim_id, + claimant: caller.clone(), + reason, + }, + ); + } + + /// 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, + }, + ); + } + + /// Explicitly reject a claim and emit a claim_rejected event with reason code (#695). + pub fn reject_claim(env: Env, admin: Address, claim_id: u128, reason: 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)); + claim.status = ClaimStatus::Rejected; + let claim_key = StorageKey::Claim(claim_id); + env.storage().persistent().set(&claim_key, &claim); + Self::extend_claim_ttl(&env, &claim_key); + Self::remove_from_pending(&env, claim_id); + + env.events().publish( + (Symbol::new(&env, "claim_rejected"),), + (claim_id, claim.policy_id, reason), + ); + } + + // ── 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() + .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() + .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 (max 12) + /// - `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); + + // Claim amounts must be strictly positive (issue #645). + if amount_per_installment <= 0 { + panic_with_error!(&env, Error::InvalidClaimAmount); + } + + // Validate installment count to prevent storage/gas exhaustion + if num_installments == 0 || num_installments > MAX_INSTALLMENTS { + panic_with_error!(&env, Error::InvalidInput); + } + + 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() + .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) + .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) + } + + /// 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, + ); + } + + /// 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, + ); + } + + /// Check whether the contract is currently paused. + pub fn is_paused(env: Env) -> bool { + 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) + .unwrap_or_else(|| panic_with_error!(&env, Error::NotInitialized)); + 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); + 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); + } + + /// Configure the guardian set and approval threshold required for + /// critical actions (currently: contract upgrades). The first set is + /// bootstrapped by the admin; later changes require approval from the + /// current guardians through `approve_guardian_config`. + 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 !guardians.is_empty() && threshold == 0 { + panic_with_error!(&env, Error::InvalidThreshold); + } + if threshold > guardians.len() { + panic_with_error!(&env, Error::InvalidThreshold); + } + if guardians.is_empty() && threshold > 0 { + panic_with_error!(&env, Error::InvalidThreshold); + } + + let current_guardians: Vec
= env.storage().instance().get(&StorageKey::Guardians) + .unwrap_or_else(|| Vec::new(&env)); + let current_threshold: u32 = env.storage().instance().get(&StorageKey::GuardianThreshold) + .unwrap_or(0); + + if !current_guardians.is_empty() && current_threshold > 0 { + let pending = PendingGuardianConfig { + guardians, + threshold, + approvals: Vec::new(&env), + }; + env.storage().instance().set(&StorageKey::PendingGuardianConfig, &pending); + return; + } + + 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 }, + ); + } + + /// Issue approval from a current guardian for a pending guardian configuration change. + pub fn approve_guardian_config(env: Env, guardian: Address) { + guardian.require_auth(); + let guardians: Vec
= env.storage().instance().get(&StorageKey::Guardians) + .unwrap_or_else(|| panic_with_error!(&env, Error::NotGuardian)); + 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: PendingGuardianConfig = env.storage().instance().get(&StorageKey::PendingGuardianConfig) + .unwrap_or_else(|| 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::PendingGuardianConfig); + env.storage().instance().set(&StorageKey::Guardians, &pending.guardians); + env.storage().instance().set(&StorageKey::GuardianThreshold, &pending.threshold); + env.events().publish( + (Symbol::new(&env, "guardians_updated"),), + GuardiansUpdated { guardians: pending.guardians, threshold: pending.threshold }, + ); + } else { + env.storage().instance().set(&StorageKey::PendingGuardianConfig, &pending); + } + } + + /// Update guardian threshold independently, validating threshold <= registered guardian count (issue #744). + pub fn set_guardian_threshold(env: Env, admin: Address, 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(); + let guardians: Vec
= env.storage().instance().get(&StorageKey::Guardians) + .unwrap_or_else(|| Vec::new(&env)); + if threshold > guardians.len() { + panic_with_error!(&env, Error::InvalidThreshold); + } + if !guardians.is_empty() && threshold == 0 { + panic_with_error!(&env, Error::InvalidThreshold); + } + env.storage().instance().set(&StorageKey::GuardianThreshold, &threshold); + env.events().publish( + (Symbol::new(&env, "guardian_threshold_updated"),), + 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 before bootstrap). + pub fn get_guardian_threshold(env: Env) -> u32 { + env.storage() + .instance() + .get(&StorageKey::GuardianThreshold) + .unwrap_or(0) + } + + /// Return the pending guardian configuration awaiting approval, if any. + pub fn get_pending_guardian_config(env: Env) -> Option { + env.storage() + .instance() + .get(&StorageKey::PendingGuardianConfig) + } + + /// 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. + /// + /// Panics with `AdminTransferPending` while a transfer is already armed: + /// re-proposing over an armed transfer would rewrite `PendingAdminSince` + /// and reset the `ADMIN_TRANSFER_TIMELOCK`, letting the current admin + /// keep a transfer perpetually un-acceptable (issue #457). + pub fn propose_new_admin(env: Env, admin: Address, new_admin: Address) { + Self::require_admin(&env, &admin); + Self::validate_stellar_address(&env, &new_admin); + + // Issue #457: reject a fresh proposal while one is already pending — + // the armed transfer must complete (be accepted) before another can + // be made, so the timelock clock can never be reset by re-proposing. + let pending: Option
= env.storage().instance() + .get(&StorageKey::PendingAdmin) + .unwrap_or(None); + if pending.is_some() { + panic_with_error!(&env, Error::AdminTransferPending); + } + + 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); + // Issue #457: arm the timelock at most once per transfer — never + // overwrite an already-armed `PendingAdminSince`, or the + // `ADMIN_TRANSFER_TIMELOCK` would restart from this later moment. + if !env.storage().instance().has(&StorageKey::PendingAdminSince) { + 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) { + // 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. + /// + /// Capped at `MAX_ESCALATION_THRESHOLD` (90 days) so claims cannot be + /// left Pending indefinitely with no recourse (issue #618). + pub fn set_escalation_threshold(env: Env, admin: Address, threshold: u64) { + Self::require_admin(&env, &admin); + + if threshold < MIN_ESCALATION_THRESHOLD || threshold > MAX_ESCALATION_THRESHOLD { + panic_with_error!(&env, Error::InvalidThresholdValue); + } + + env.storage() + .instance() + .set(&StorageKey::EscalationThreshold, &threshold); + + env.events().publish( + (Symbol::new(&env, "escalation_threshold_set"),), + EscalationThresholdUpdated { threshold }, + ); + } + + /// Set a per-category escalation threshold. Same bounds as + /// `set_escalation_threshold` (issue #618). + pub fn set_category_escalation(env: Env, admin: Address, category: Symbol, threshold: u64) { + Self::require_admin(&env, &admin); + + if threshold < MIN_ESCALATION_THRESHOLD || threshold > MAX_ESCALATION_THRESHOLD { + panic_with_error!(&env, Error::InvalidThresholdValue); + } + + env.storage() + .instance() + .set(&StorageKey::CategoryEscalationThreshold(category.clone()), &threshold); + + env.events().publish( + (Symbol::new(&env, "category_escalation_threshold_set"), category), + EscalationThresholdUpdated { threshold }, + ); + } + + pub fn get_escalation_threshold(env: Env) -> u64 { + Self::escalation_threshold(&env, None) + } + + pub fn get_category_escalation(env: Env, category: Symbol) -> Option { + env.storage() + .instance() + .get(&StorageKey::CategoryEscalationThreshold(category)) + } + + /// 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 policy_engine_id = Self::policy_engine(&env); + let policy = parashield_policy_engine::PolicyEngineClient::new(&env, &policy_engine_id).get_policy(&claim.policy_id); + let product = parashield_policy_engine::PolicyEngineClient::new(&env, &policy_engine_id).get_product(&policy.product_id); + let threshold = Self::escalation_threshold(&env, Some(product.category)); + 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); + } + // Issue #625: cap the configurable deadline so an admin cannot open a + // claim submission window that extends indefinitely into the future. + if deadline > MAX_CLAIM_DEADLINE { + 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) + } + + /// Submit evidence files for a claim, rejecting zero hashes and enforcing max file count (issue #728, #747). + pub fn submit_claim_evidence(env: Env, claim_id: u128, claimant: Address, evidence_hashes: Vec) { + Self::require_not_paused(&env); + claimant.require_auth(); + let claim: Claim = env.storage().persistent() + .get(&StorageKey::Claim(claim_id)) + .unwrap_or_else(|| panic_with_error!(&env, Error::ClaimNotFound)); + if claim.claimant != claimant { + panic_with_error!(&env, Error::Unauthorized); + } + if evidence_hashes.len() > MAX_EVIDENCE_FILES { + panic_with_error!(&env, Error::EvidenceLimitExceeded); + } + for i in 0..evidence_hashes.len() { + let hash = evidence_hashes.get_unchecked(i); + if hash.is_empty() { + panic_with_error!(&env, Error::InvalidEvidence); + } + let mut all_zeros = true; + for b in hash.iter() { + if b != 0 { + all_zeros = false; + break; + } + } + if all_zeros { + panic_with_error!(&env, Error::InvalidEvidence); + } + } + let key = StorageKey::ClaimEvidence(claim_id); + env.storage().persistent().set(&key, &evidence_hashes); + env.storage().persistent().extend_ttl(&key, TTL_THRESHOLD, TTL_EXTEND_TO); + } + + /// Retrieve attached evidence hashes for a claim if available (issue #728, #747). + pub fn get_claim_evidence(env: Env, claim_id: u128) -> Vec { + env.storage().persistent().get(&StorageKey::ClaimEvidence(claim_id)).unwrap_or_else(|| Vec::new(&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: i64) { + Self::require_admin(&env, &admin); + // Reject negative delays (issue: ClaimsProcessor allows negative payout delay) + if delay_seconds < 0 { + panic_with_error!(&env, Error::InvalidInput); + } + const MAX_PAYOUT_DELAY: i64 = 365 * 24 * 60 * 60; // 365 days + if delay_seconds > MAX_PAYOUT_DELAY { + panic_with_error!(&env, Error::PayoutDelayTooLong); + } + let delay_u64 = delay_seconds as u64; + env.storage().instance().set(&StorageKey::PayoutDelay, &delay_u64); + env.events().publish( + (Symbol::new(&env, "payout_delay_set"),), + PayoutDelayUpdated { delay_seconds: delay_u64 }, + ); + } + + /// The configured payout delay in seconds (default: 0 — immediate). + pub fn get_payout_delay(env: Env) -> u64 { + Self::payout_delay(&env) + } + + /// Configure the fraud detector (issue #437). Admin-only. Absent + /// configuration means the detector is fully disabled; calling this + /// once with any `FraudConfig` turns it on for every subsequent + /// `submit_claim`. + /// + /// Bounds enforced: + /// - `fraud_threshold_score <= MAX_FRAUD_SCORE` + /// + /// Rule score fields are bounded implicitly by their `u32` type; a + /// pathological admin can still make a single rule fire the threshold + /// alone. That is intentional, so `Rule 3 alone = suspicious enough` + /// remains configurable. + pub fn set_fraud_config(env: Env, admin: Address, config: FraudConfig) { + Self::require_admin(&env, &admin); + if config.fraud_threshold_score > MAX_FRAUD_SCORE { + panic_with_error!(&env, Error::InvalidInput); + } + env.storage() + .instance() + .set(&StorageKey::FraudConfig, &config); + env.events().publish( + (Symbol::new(&env, "fraud_config_updated"),), + FraudConfigUpdated { + rate_window_secs: config.rate_window_secs, + rate_score: config.rate_score, + burst_window_secs: config.burst_window_secs, + burst_count: config.burst_count, + burst_score: config.burst_score, + coverage_anomaly_multiplier: config.coverage_anomaly_multiplier, + coverage_score: config.coverage_score, + fraud_threshold_score: config.fraud_threshold_score, + mode: config.mode, + }, + ); + } + + /// The fraud detector configuration currently in force, or `None` when + /// the detector is disabled (issue #437). + pub fn get_fraud_config(env: Env) -> Option { + Self::fraud_config(&env) + } + + /// The frozen fraud snapshot for a claim (issue #437). `None` when the + /// claim was never flagged (either the detector is off, the score was + /// below threshold, or the mode was Block and no state was written). + pub fn get_fraud_record(env: Env, claim_id: u128) -> Option { + env.storage() + .persistent() + .get(&StorageKey::FraudRecord(claim_id)) + } + + /// The claimant's aggregate history the detector uses. Returns the + /// empty aggregate when the claimant has never submitted (issue #437). + pub fn get_claimant_history(env: Env, claimant: Address) -> ClaimantHistory { + Self::claimant_history(&env, &claimant) + } + + /// 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, verifying that the payout token matches the policy token (issue #751). + pub fn claim_payout_with_token(env: Env, claim_id: u128, payout_token: Address) -> i128 { + Self::require_not_paused(&env); + let claim: Claim = env.storage().persistent() + .get(&StorageKey::Claim(claim_id)) + .unwrap_or_else(|| panic_with_error!(&env, Error::ClaimNotFound)); + + 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 product = PolicyEngineClient::new(&env, &policy_engine).get_product(&policy.product_id); + + if payout_token != product.expected_token { + panic_with_error!(&env, Error::InvalidToken); + } + + Self::claim_payout(env, claim_id) + } + + /// Claim payout with explicit recipient verification (issue #718). + /// Rejects any attempt to redirect payout away from the claimant. + pub fn claim_payout_to(env: Env, claim_id: u128, recipient: Address) -> i128 { + let claim: Claim = env.storage().persistent() + .get(&StorageKey::Claim(claim_id)) + .unwrap_or_else(|| panic_with_error!(&env, Error::ClaimNotFound)); + if recipient != claim.claimant { + panic_with_error!(&env, Error::Unauthorized); + } + Self::claim_payout(env, claim_id) + } + + /// 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)); + + // Re-check the cap at release time: the payout may never exceed the + // policy's coverage amount (issue #550, #723). + // Accepts paid_amount == claim.coverage_amount; rejects strictly if paid_amount > claim.coverage_amount. + if paid_amount > claim.coverage_amount { + panic_with_error!(&env, Error::PayoutExceedsCoverage); + } + + // 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 + } + + /// Calculate multi-recipient payout distribution according to the protocol dust policy (issue #772). + /// + /// Multi-recipient payouts truncate upon integer division. To prevent dust from accumulating + /// in escrow, the remainder is distributed in full to the last recipient, guaranteeing + /// zero dust after payout. + pub fn calculate_multi_recipient_payout(env: Env, total_amount: i128, num_recipients: u32) -> Vec { + if num_recipients == 0 || total_amount < 0 { + panic_with_error!(&env, Error::InvalidInput); + } + let count = num_recipients as i128; + let base_amount = total_amount / count; + let remainder = total_amount % count; + + let mut distribution = Vec::new(&env); + for i in 0..num_recipients { + let amount = if i == num_recipients - 1 { + base_amount + remainder + } else { + base_amount + }; + distribution.push_back(amount); + } + distribution + } + + /// Distribute claim payout across multiple recipients with zero escrow dust (issue #772). + /// + /// Truncation remainder is fully distributed to the last recipient in `recipients`, + /// leaving zero unaccounted dust in escrow. + pub fn claim_payout_multi_recipient(env: Env, claim_id: u128, recipients: Vec
) -> Vec { + if recipients.is_empty() { + panic_with_error!(&env, Error::InvalidInput); + } + for i in 0..recipients.len() { + let r = recipients.get_unchecked(i); + Self::validate_stellar_address(&env, &r); + } + + let total_payout = Self::claim_payout(env.clone(), claim_id); + let distribution = Self::calculate_multi_recipient_payout(env.clone(), total_payout, recipients.len()); + + let remainder = total_payout % (recipients.len() as i128); + env.events().publish( + (Symbol::new(&env, "multi_payout_distributed"),), + (claim_id, total_payout, remainder), + ); + + distribution + } + + /// Alias for `claim_payout_multi_recipient` (issue #772). + pub fn payout_multi_recipient(env: Env, claim_id: u128, recipients: Vec
) -> Vec { + Self::claim_payout_multi_recipient(env, claim_id, recipients) + } + + /// 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); + let policy_engine_id = Self::policy_engine(&env); + let policy = parashield_policy_engine::PolicyEngineClient::new(&env, &policy_engine_id).get_policy(&claim.policy_id); + let product = parashield_policy_engine::PolicyEngineClient::new(&env, &policy_engine_id).get_product(&policy.product_id); + let threshold = Self::escalation_threshold(&env, Some(product.category)); + + if pending_for < threshold { + panic_with_error!(&env, Error::NotEscalatable); + } + + // SECURITY FIX: Verify escalation is within the allowed time window from claim submission. + // Claims should only be escalatable within a reasonable time after submission. + // After the policy end_time + claim_deadline has passed, escalation is no longer meaningful + // because the policy period is over and the claim should have been processed already. + let claim_deadline = Self::claim_deadline(&env); + let escalation_cutoff = policy.end_time.saturating_add(claim_deadline); + if now > escalation_cutoff { + 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, + }, + ); + } + + /// Retrieve original claim context preserved during escalation (#691). + pub fn get_escalated_claim_context(env: Env, claim_id: u128) -> Claim { + env.storage() + .persistent() + .get(&StorageKey::Claim(claim_id)) + .unwrap_or_else(|| panic_with_error!(&env, Error::ClaimNotFound)) + } + + /// 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 now = env.ledger().timestamp(); + let mut overdue: Vec = Vec::new(&env); + let policy_engine_id = Self::policy_engine(&env); + let pe_client = parashield_policy_engine::PolicyEngineClient::new(&env, &policy_engine_id); + + 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 { + let policy = pe_client.get_policy(&claim.policy_id); + let product = pe_client.get_product(&policy.product_id); + let threshold = Self::escalation_threshold(&env, Some(product.category)); + + if now.saturating_sub(claim.submitted_at) >= threshold { + overdue.push_back(cid); + } + } + } + } + + overdue + } + + // ── 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: add an address to the authorized attesters list. + pub fn add_authorized_attester(env: Env, admin: Address, attester: Address) { + Self::require_admin(&env, &admin); + let mut attesters: Vec
= env + .storage() + .instance() + .get(&StorageKey::AuthorizedAttesters) + .unwrap_or_else(|| Vec::new(&env)); + for i in 0..attesters.len() { + if attesters.get_unchecked(i) == attester { + return; + } + } + attesters.push_back(attester.clone()); + env.storage() + .instance() + .set(&StorageKey::AuthorizedAttesters, &attesters); + env.events().publish( + (Symbol::new(&env, "attester_added"),), + attester, + ); + } + + /// Admin-only: remove an address from the authorized attesters list. + pub fn remove_authorized_attester(env: Env, admin: Address, attester: Address) { + Self::require_admin(&env, &admin); + let mut attesters: Vec
= env + .storage() + .instance() + .get(&StorageKey::AuthorizedAttesters) + .unwrap_or_else(|| Vec::new(&env)); + let mut idx: Option = None; + for i in 0..attesters.len() { + if attesters.get_unchecked(i) == attester { + idx = Some(i); + break; + } + } + if let Some(i) = idx { + attesters.remove(i); + env.storage() + .instance() + .set(&StorageKey::AuthorizedAttesters, &attesters); + } + env.events().publish( + (Symbol::new(&env, "attester_removed"),), + attester, + ); + } + + /// Return the list of authorized attesters. + pub fn get_authorized_attesters(env: Env) -> Vec
{ + env.storage() + .instance() + .get(&StorageKey::AuthorizedAttesters) + .unwrap_or_else(|| Vec::new(&env)) + } + + /// Verify a claimant's identity for a claim. Callable by admin or any + /// authorized attester. The caller must be either the contract admin or + /// an address previously added via `add_authorized_attester`. + pub fn verify_claimant_identity( + env: Env, + attester: Address, + claim_id: u128, + id_type: Symbol, + ) { + attester.require_auth(); + Self::require_admin_or_attester(&env, &attester); + + 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), + ); + } + + /// 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 ───────────────────────────────────────────────────── + + /// 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 { + if claim.status != ClaimStatus::Pending { + panic_with_error!(env, Error::AlreadyProcessed); + } + if claim.claimant != policy.policyholder { + panic_with_error!(env, Error::Unauthorized); + } + let oracle_verifier: Address = env.storage().instance() + .get(&StorageKey::OracleVerifier) + .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() + .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 + }; + + // 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, + ); + + Self::settle_claim_with_trigger_result(env, claim, policy, trigger_met, partial_payout_bps) + } + + /// Internal settlement logic shared by single-reading and multi-reading claim flows (#683). + fn settle_claim_with_trigger_result( + env: &Env, + claim: &mut Claim, + policy: ¶shield_policy_engine::Policy, + trigger_met: bool, + partial_payout_bps: Option, + ) -> ClaimResult { + if claim.status != ClaimStatus::Pending { + panic_with_error!(env, Error::AlreadyProcessed); + } + if claim.claimant != policy.policyholder { + panic_with_error!(env, Error::Unauthorized); + } + 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)); + + claim.trigger_met = trigger_met; + claim.processed_at = Some(env.ledger().timestamp()); + + let payout_delay = Self::payout_delay(env); + + let result = if trigger_met { + // 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 { + 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::Paid + } else { + // Partial payment + let paid = claim.coverage_amount * (effective_bps as i128) / 10_000; + if paid > claim.coverage_amount { + panic_with_error!(env, Error::PayoutExceedsCoverage); + } + 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); + + Self::remove_from_pending(env, claim.id); + + 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")), + ); + } + + env.events().publish( + (Symbol::new(env, "claim_settled"), claim.id), + (claim.trigger_met, claim.coverage_amount), + ); + + result + } + + /// Remove a claim id from the pending queue, if present. + + /// Return the stored Policy Engine contract address, panicking with + /// `NotInitialized` if the contract has not been set up yet. + fn policy_engine(env: &Env) -> Address { + env.storage() + .instance() + .get(&StorageKey::PolicyEngine) + .unwrap_or_else(|| panic_with_error!(env, Error::NotInitialized)) + } + + /// The configured escalation threshold for a category, or the global default. + fn escalation_threshold(env: &Env, category: Option) -> u64 { + if let Some(cat) = category { + if let Some(val) = env.storage().instance().get(&StorageKey::CategoryEscalationThreshold(cat)) { + return val; + } + } + 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)); + 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() + .get(&StorageKey::Keeper(caller.clone())) + .unwrap_or(false); + if !authorized { + panic_with_error!(env, Error::Unauthorized); + } + caller.require_auth(); + } + + /// Panic unless `caller` is the admin and authorizes the call. + 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(); + } + + /// Panic unless `caller` is the admin or an authorized attester. + fn require_admin_or_attester(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 { + return; + } + let attesters: Vec
= env + .storage() + .instance() + .get(&StorageKey::AuthorizedAttesters) + .unwrap_or_else(|| Vec::new(env)); + for i in 0..attesters.len() { + if attesters.get_unchecked(i) == *caller { + return; + } + } + panic_with_error!(env, Error::UnauthorizedAttester); + } + + /// Panic if the contract is currently paused. + fn require_not_paused(env: &Env) { + let paused: bool = env.storage().instance() + .get(&StorageKey::Paused) + .unwrap_or(false); + if paused { + panic_with_error!(env, Error::Paused); + } + } + + /// Panic with `AdminCannotBeClaimant` if `caller` is the contract admin. + /// + /// Separation of duties for the claimant side of a claim (issue #566). + /// Deliberately *not* `Unauthorized`: the admin is a privileged caller + /// everywhere else here, so a caller that trips this is doing something + /// the contract never allows rather than being unauthenticated, and + /// off-chain callers need to tell the two apart. + fn require_not_admin(env: &Env, caller: &Address) { + let admin: Option
= env.storage().instance().get(&StorageKey::Admin); + if admin.as_ref() == Some(caller) { + panic_with_error!(env, Error::AdminCannotBeClaimant); + } + } + + /// Reject the all-zero Stellar account address. Nobody holds its + /// private key, so a claim filed for it would settle a payout that can + /// never be collected and would leave the policy permanently stuck in + /// `Claimed` state (issue #586). + fn require_not_zero_address(env: &Env, caller: &Address) { + let zero = Address::from_string(&soroban_sdk::String::from_str( + env, + "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF", + )); + if *caller == zero { + panic_with_error!(env, Error::InvalidAddress); + } + } + + /// 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)); + id + } + + /// Panic unless `address` is a well-formed Stellar address. + /// + /// Delegates the actual format decision to [`is_well_formed_strkey`] so + /// the rule lives in one pure, directly testable function instead of + /// being spread across the call sites that need it (issue #572). + fn validate_stellar_address(env: &Env, address: &Address) { + let addr_str = address.to_string(); + let mut buf = [0u8; STRKEY_LEN]; + if addr_str.len() as usize != STRKEY_LEN { + panic_with_error!(env, Error::InvalidAddress); + } + addr_str.copy_into_slice(&mut buf); + if !is_well_formed_strkey(&buf) { + panic_with_error!(env, Error::InvalidAddress); + } + } + + // ── Fraud detection helpers (issue #437) ────────────────────────────── + + /// Fraud detector configuration if the admin has set one, else `None`. + /// `None` means the detector is disabled and every submission proceeds + /// unchecked (pre-issue-437 behaviour). + fn fraud_config(env: &Env) -> Option { + env.storage().instance().get(&StorageKey::FraudConfig) + } + + /// A claimant's aggregate history, or an empty one for a first-time + /// submitter. `unwrap_or_else` ensures fresh submitters do not pay a + /// storage read that could fail. + fn claimant_history(env: &Env, claimant: &Address) -> ClaimantHistory { + env.storage() + .persistent() + .get(&StorageKey::ClaimantHistory(claimant.clone())) + .unwrap_or(ClaimantHistory { + last_submission_at: 0, + burst_bucket_start: 0, + burst_count: 0, + max_coverage_ever: 0, + total_submissions: 0, + }) + } + + /// Score this submission against the configured rules. Returns + /// `(score, flags)`, both zero when the detector is disabled or when + /// no rule fires. + /// + /// Pure function over the passed-in `now` / `history` / `coverage` so it + /// can be tested without spinning up the full cross-contract world. + fn score_submission( + cfg: &FraudConfig, + now: u64, + history: &ClaimantHistory, + coverage_amount: i128, + ) -> (u32, u32) { + let mut score: u32 = 0; + let mut flags: u32 = 0; + + // Rule 1: rapid resubmission. Only fires once a claimant has any + // prior submission on record; a first-ever claim never trips it. + if cfg.rate_window_secs > 0 + && cfg.rate_score > 0 + && history.last_submission_at > 0 + && now.saturating_sub(history.last_submission_at) < cfg.rate_window_secs + { + score = score.saturating_add(cfg.rate_score); + flags |= FRAUD_FLAG_RATE; + } + + // Rule 2: multi-policy burst. Fires when *this* submission would + // be the `burst_count`-th inside the active window bucket. The + // caller updates the bucket after admission; here we compute the + // hypothetical post-increment count using the stored bucket. + if cfg.burst_window_secs > 0 && cfg.burst_count > 0 && cfg.burst_score > 0 { + let bucket_alive = history.burst_bucket_start > 0 + && now.saturating_sub(history.burst_bucket_start) < cfg.burst_window_secs; + let projected = if bucket_alive { + history.burst_count.saturating_add(1) + } else { + 1 + }; + if projected >= cfg.burst_count { + score = score.saturating_add(cfg.burst_score); + flags |= FRAUD_FLAG_BURST; + } + } + + // Rule 3: coverage anomaly. Requires prior coverage on record — + // a first-ever claim cannot be an anomaly relative to nothing. + if cfg.coverage_anomaly_multiplier > 0 + && cfg.coverage_score > 0 + && history.max_coverage_ever > 0 + { + let threshold = history + .max_coverage_ever + .saturating_mul(cfg.coverage_anomaly_multiplier as i128); + if coverage_amount > threshold { + score = score.saturating_add(cfg.coverage_score); + flags |= FRAUD_FLAG_COVERAGE; + } + } + + (score, flags) + } + + /// Fold a just-admitted submission into the claimant's history. Bumps + /// `total_submissions` and `max_coverage_ever` on every call; resets or + /// extends the burst bucket based on how much time has passed since the + /// previous submission. Called by `submit_claim` after the fraud gate. + fn update_claimant_history( + env: &Env, + claimant: &Address, + now: u64, + coverage_amount: i128, + burst_window_secs: u64, + ) { + let prev = Self::claimant_history(env, claimant); + let (bucket_start, bucket_count) = if burst_window_secs == 0 { + // Feature effectively off — do not carry a stale bucket forward. + (0, 0) + } else if prev.burst_bucket_start == 0 + || now.saturating_sub(prev.burst_bucket_start) >= burst_window_secs + { + (now, 1) + } else { + (prev.burst_bucket_start, prev.burst_count.saturating_add(1)) + }; + + let next = ClaimantHistory { + last_submission_at: now, + burst_bucket_start: bucket_start, + burst_count: bucket_count, + max_coverage_ever: prev.max_coverage_ever.max(coverage_amount), + total_submissions: prev.total_submissions.saturating_add(1), + }; + let key = StorageKey::ClaimantHistory(claimant.clone()); + env.storage().persistent().set(&key, &next); + env.storage() + .persistent() + .extend_ttl(&key, TTL_THRESHOLD, TTL_EXTEND_TO); + } + + /// Run the configured fraud detector against a hypothetical submission. + /// If the detector is off (no `FraudConfig`) this returns immediately + /// with `(0, 0)` and does nothing else. + /// + /// If the score meets or exceeds the configured threshold and mode is + /// `Block`, the function panics with `FraudSuspected` after emitting + /// `FraudFlagged`. If mode is `FlagOnly`, it emits the event, persists a + /// `FraudRecord`, and returns so the caller can continue writing the + /// claim. + /// + /// Called from `submit_claim` after all existing gates so a rejection at + /// this point does not leak partial state. + fn evaluate_fraud( + env: &Env, + claim_id: u128, + claimant: &Address, + coverage_amount: i128, + ) -> (u32, u32) { + let Some(cfg) = Self::fraud_config(env) else { + return (0, 0); + }; + let now = env.ledger().timestamp(); + let history = Self::claimant_history(env, claimant); + let (score, flags) = Self::score_submission(&cfg, now, &history, coverage_amount); + if score >= cfg.fraud_threshold_score && cfg.fraud_threshold_score > 0 { + env.events().publish( + (Symbol::new(env, "fraud_flagged"),), + FraudFlagged { + claim_id, + claimant: claimant.clone(), + score, + flags, + mode: cfg.mode.clone(), + }, + ); + match cfg.mode { + FraudMode::Block => panic_with_error!(env, Error::FraudSuspected), + FraudMode::FlagOnly => { + let record = FraudRecord { + claim_id, + score, + flags, + checked_at: now, + }; + let key = StorageKey::FraudRecord(claim_id); + env.storage().persistent().set(&key, &record); + env.storage() + .persistent() + .extend_ttl(&key, TTL_THRESHOLD, TTL_EXTEND_TO); + } + } + } + (score, flags) + } +} + +/// Map policy-engine TriggerComparison to oracle-verifier TriggerComparison. +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, + } +} + +/// Length of a Stellar `StrKey` (a `G…` account id or a `C…` contract id): +/// one version character plus 55 base32 characters encoding the 33-byte +/// (version + 32-byte payload) key, rounded up to a whole 5-bit group. +const STRKEY_LEN: usize = 56; + +/// Whether `bytes` is a syntactically well-formed Stellar address string +/// (issue #572). +/// +/// A `StrKey` is 56 bytes long and encodes the 33-byte +/// `version || payload` buffer in unpadded base32 (RFC 4648). Two version +/// characters are meaningful on the network: `G` for a classic account and +/// `C` for a contract. Everything after the version byte must therefore be a +/// base32 symbol — `A`–`Z` or `2`–`7` — which is what makes this a real format +/// check rather than the prefix test it replaced. Note that the StrKey +/// checksum is intentionally *not* verified: the host already guarantees a +/// canonical encoding for any `Address` it hands us, so a checksum failure is +/// not reachable from here, and re-deriving one on-chain would cost a CRC16 +/// for no additional guarantee. +/// +/// Takes bytes rather than `&str` because `soroban_sdk::String` (what +/// `Address::to_string` returns) cannot be borrowed as `str`. Pure and +/// infallible, so it can be exercised directly against byte strings that no +/// `Address` could ever be constructed from. +fn is_well_formed_strkey(bytes: &[u8]) -> bool { + if bytes.len() != STRKEY_LEN { + return false; + } + // Version byte: G (account) or C (contract). + if bytes[0] != b'G' && bytes[0] != b'C' { + return false; + } + bytes[1..] + .iter() + .all(|&b| (b >= b'A' && b <= b'Z') || (b >= b'2' && b <= b'7')) +} + +#[cfg(test)] +mod test; +#[cfg(test)] +mod test_integration; +#[cfg(test)] +mod test_advanced; +#[cfg(test)] +mod test_keeper_registry; +#[cfg(test)] +mod test_claim_uniqueness; +#[cfg(test)] +mod test_dust; diff --git a/contracts/claims-processor/src/test.rs b/contracts/claims-processor/src/test.rs index 8296a7f..a6b93e1 100644 --- a/contracts/claims-processor/src/test.rs +++ b/contracts/claims-processor/src/test.rs @@ -96,6 +96,7 @@ fn create_crop_product(w: &World) -> u128 { name: symbol_short!("crop_kism"), category: symbol_short!("crop"), oracle_key: symbol_short!("kis2606"), + expected_token: w.usdc.clone(), trigger_type: TriggerType::Threshold, oracle_data_type: symbol_short!("weather"), trigger_threshold: 50_000_000, @@ -118,7 +119,7 @@ fn buy_crop_policy(w: &World, buyer: &Address, product_id: u128) -> u128 { 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")); + .buy_policy(buyer, &product_id, &COVERAGE, &30u32, &symbol_short!("kis2606"), &w.usdc); // Lock coverage in the pool for this policy RiskPoolClient::new(&w.env, &w.pool_id).lock_for_policy(&w.admin, &policy_id, &COVERAGE); @@ -1711,7 +1712,7 @@ fn test_payout_delay_within_bounds() { let cp = ClaimsProcessorClient::new(&w.env, &w.claims_id); let delay = 365 * 24 * 60 * 60; // Exact max cp.set_payout_delay(&w.admin, &delay); - assert_eq!(cp.get_payout_delay(), delay); + assert_eq!(cp.get_payout_delay(), delay as u64); } #[test] diff --git a/contracts/claims-processor/src/test_advanced.rs b/contracts/claims-processor/src/test_advanced.rs index ae4dba1..07b77a7 100644 --- a/contracts/claims-processor/src/test_advanced.rs +++ b/contracts/claims-processor/src/test_advanced.rs @@ -115,6 +115,7 @@ fn create_crop_product(w: &World) -> u128 { name: symbol_short!("crop_kism"), category: symbol_short!("crop"), oracle_key: symbol_short!("kis2606"), + expected_token: w.usdc.clone(), trigger_type: TriggerType::Threshold, oracle_data_type: symbol_short!("weather"), trigger_threshold: 50_000_000, @@ -140,6 +141,7 @@ fn buy_crop_policy(w: &World, buyer: &Address, product_id: u128) -> u128 { &COVERAGE, &30u32, &symbol_short!("kis2606"), + &w.usdc, ); // Lock coverage in the pool for this policy @@ -297,6 +299,7 @@ fn test_staleness_threshold_boundary() { name: symbol_short!("crop"), category: symbol_short!("crop"), oracle_key: symbol_short!("kis2606"), + expected_token: usdc.clone(), trigger_type: TriggerType::Threshold, oracle_data_type: symbol_short!("weather"), trigger_threshold: 50_000_000, @@ -319,6 +322,7 @@ fn test_staleness_threshold_boundary() { &COVERAGE, &30u32, &symbol_short!("kis2606"), + &usdc, ); RiskPoolClient::new(&env, &pool_id).lock_for_policy(&admin, &pol_id, &COVERAGE); @@ -414,6 +418,7 @@ fn test_maximum_coverage_boundary() { name: symbol_short!("max_cov"), category: symbol_short!("crop"), oracle_key: symbol_short!("kis2606"), + expected_token: w.usdc.clone(), trigger_type: TriggerType::Threshold, oracle_data_type: symbol_short!("weather"), trigger_threshold: 50_000_000, @@ -437,6 +442,7 @@ fn test_maximum_coverage_boundary() { &10_000_000_000i128, &30u32, &symbol_short!("kis2606"), + &w.usdc, ); RiskPoolClient::new(&w.env, &w.pool_id).lock_for_policy(&w.admin, &pol_id, &10_000_000_000i128); @@ -466,6 +472,7 @@ fn test_minimum_coverage_boundary() { &100_000_000i128, &30u32, &symbol_short!("kis2606"), + &w.usdc, ); RiskPoolClient::new(&w.env, &w.pool_id).lock_for_policy(&w.admin, &pol_id, &100_000_000i128); diff --git a/contracts/claims-processor/src/test_claim_uniqueness.rs b/contracts/claims-processor/src/test_claim_uniqueness.rs index 9e1bce6..13e298e 100644 --- a/contracts/claims-processor/src/test_claim_uniqueness.rs +++ b/contracts/claims-processor/src/test_claim_uniqueness.rs @@ -120,6 +120,7 @@ fn create_crop_product(w: &World) -> u128 { name: symbol_short!("uniq_cism"), category: symbol_short!("crop"), oracle_key: symbol_short!("kis2606"), + expected_token: w.usdc.clone(), trigger_type: TriggerType::Threshold, oracle_data_type: symbol_short!("weather"), trigger_threshold: 50_000_000, @@ -167,6 +168,7 @@ fn buy_policies(w: &mut World, buyer: &Address, n: usize) -> std::vec::Vec &COVERAGE, &30u32, &oracle_key, + &w.usdc, ); RiskPoolClient::new(&w.env, &w.pool_id).lock_for_policy(&w.admin, &policy_id, &COVERAGE); out.push(policy_id); diff --git a/contracts/claims-processor/src/test_dust.rs b/contracts/claims-processor/src/test_dust.rs new file mode 100644 index 0000000..76cb0bb --- /dev/null +++ b/contracts/claims-processor/src/test_dust.rs @@ -0,0 +1,127 @@ +//! Tests for multi-recipient payout rounding and dust distribution (Issue #772). +//! +//! Multi-recipient payouts truncate upon integer division. To prevent dust from +//! accumulating in escrow, the protocol dust policy dictates that any remainder +//! (total_payout % num_recipients) is distributed in full to the last recipient, +//! ensuring that the sum of distributed payouts matches the total payout exactly +//! with zero escrow dust left behind. +#![cfg(test)] + +use super::*; +use soroban_sdk::Env; + +#[test] +fn test_multi_recipient_even_split() { + let env = Env::default(); + let total = 100i128; + let count = 2u32; + let dist = ClaimsProcessor::calculate_multi_recipient_payout(env.clone(), total, count); + assert_eq!(dist.len(), 2); + assert_eq!(dist.get(0).unwrap(), 50); + assert_eq!(dist.get(1).unwrap(), 50); + + let sum: i128 = dist.iter().sum(); + assert_eq!(sum, total); +} + +#[test] +fn test_multi_recipient_rounding_dust_distributed_to_last() { + let env = Env::default(); + let total = 100i128; + let count = 3u32; + // 100 / 3 = 33 base with 1 remainder + let dist = ClaimsProcessor::calculate_multi_recipient_payout(env.clone(), total, count); + assert_eq!(dist.len(), 3); + assert_eq!(dist.get(0).unwrap(), 33); + assert_eq!(dist.get(1).unwrap(), 33); + assert_eq!(dist.get(2).unwrap(), 34); // 33 + 1 dust remainder + + let sum: i128 = dist.iter().sum(); + assert_eq!(sum, total); + let dust = total - sum; + assert_eq!(dust, 0, "Escrow dust must be exactly zero"); +} + +#[test] +fn test_multi_recipient_large_remainder() { + let env = Env::default(); + let total = 10i128; + let count = 4u32; + // 10 / 4 = 2 base with 2 remainder + let dist = ClaimsProcessor::calculate_multi_recipient_payout(env.clone(), total, count); + assert_eq!(dist.len(), 4); + assert_eq!(dist.get(0).unwrap(), 2); + assert_eq!(dist.get(1).unwrap(), 2); + assert_eq!(dist.get(2).unwrap(), 2); + assert_eq!(dist.get(3).unwrap(), 4); // 2 + 2 dust remainder + + let sum: i128 = dist.iter().sum(); + assert_eq!(sum, total); + let dust = total - sum; + assert_eq!(dust, 0); +} + +#[test] +fn test_multi_recipient_single_recipient() { + let env = Env::default(); + let total = 1_000_000i128; + let count = 1u32; + let dist = ClaimsProcessor::calculate_multi_recipient_payout(env.clone(), total, count); + assert_eq!(dist.len(), 1); + assert_eq!(dist.get(0).unwrap(), 1_000_000); + + let sum: i128 = dist.iter().sum(); + assert_eq!(sum, total); +} + +#[test] +fn test_multi_recipient_zero_amount() { + let env = Env::default(); + let total = 0i128; + let count = 5u32; + let dist = ClaimsProcessor::calculate_multi_recipient_payout(env.clone(), total, count); + assert_eq!(dist.len(), 5); + for i in 0..5 { + assert_eq!(dist.get(i).unwrap(), 0); + } + let sum: i128 = dist.iter().sum(); + assert_eq!(sum, 0); +} + +#[test] +fn test_multi_recipient_more_recipients_than_amount() { + let env = Env::default(); + let total = 2i128; + let count = 5u32; + // 2 / 5 = 0 base with 2 remainder + let dist = ClaimsProcessor::calculate_multi_recipient_payout(env.clone(), total, count); + assert_eq!(dist.len(), 5); + assert_eq!(dist.get(0).unwrap(), 0); + assert_eq!(dist.get(1).unwrap(), 0); + assert_eq!(dist.get(2).unwrap(), 0); + assert_eq!(dist.get(3).unwrap(), 0); + assert_eq!(dist.get(4).unwrap(), 2); // 0 + 2 dust remainder + + let sum: i128 = dist.iter().sum(); + assert_eq!(sum, total); +} + +#[test] +#[should_panic(expected = "Error(Contract, #22)")] +fn test_multi_recipient_zero_recipients_fails() { + let env = Env::default(); + ClaimsProcessor::calculate_multi_recipient_payout(env, 100, 0); +} + +#[test] +fn test_multi_recipient_via_client() { + let env = Env::default(); + let contract_id = env.register(ClaimsProcessor, ()); + let client = ClaimsProcessorClient::new(&env, &contract_id); + + let dist = client.calculate_multi_recipient_payout(&100i128, &3u32); + assert_eq!(dist.len(), 3); + assert_eq!(dist.get(0).unwrap(), 33); + assert_eq!(dist.get(1).unwrap(), 33); + assert_eq!(dist.get(2).unwrap(), 34); +} diff --git a/contracts/claims-processor/src/test_integration.rs b/contracts/claims-processor/src/test_integration.rs index 6065b80..c8db3b4 100644 --- a/contracts/claims-processor/src/test_integration.rs +++ b/contracts/claims-processor/src/test_integration.rs @@ -101,6 +101,7 @@ fn create_drought_product(te: &TestEnv) -> u128 { name: symbol_short!("drght"), category: symbol_short!("crop"), oracle_key: symbol_short!("kis2606"), + expected_token: te.usdc.clone(), trigger_type: TriggerType::Threshold, oracle_data_type: weather(), trigger_threshold: 500_000_000i128, @@ -146,6 +147,7 @@ fn batch_processes_multiple_pending_claims() { &1_000_0000000i128, &30u32, &symbol_short!("kis2606"), + &te.usdc, ); let p2 = policy_client.buy_policy( &farmer2, @@ -153,6 +155,7 @@ fn batch_processes_multiple_pending_claims() { &2_000_0000000i128, &30u32, &symbol_short!("kis2606"), + &te.usdc, ); claims_client.submit_claim(&farmer1, &p1); @@ -196,6 +199,7 @@ fn batch_skips_non_pending_claims() { &1_000_0000000i128, &30u32, &symbol_short!("kis2606"), + &te.usdc, ); let _claim_id = claims_client.submit_claim(&farmer, &p1); // Process it once @@ -241,6 +245,7 @@ fn test_stale_oracle_data_rejected() { &1_000_0000000i128, &30u32, &symbol_short!("kis2606"), + &te.usdc, ); // Advance ledger to more than staleness_threshold (604_800 s) past the data timestamp @@ -278,6 +283,7 @@ fn test_oracle_unavailable_queues_claim_then_retries() { &1_000_0000000i128, &30u32, &symbol_short!("kis2606"), + &te.usdc, ); // Oracle has never received data: verification errors, claim is queued. @@ -333,6 +339,7 @@ fn test_fresh_oracle_data_accepted() { &1_000_0000000i128, &30u32, &symbol_short!("kis2606"), + &te.usdc, ); // Set ledger to within the staleness threshold (data is fresh) @@ -357,6 +364,7 @@ fn test_equal_comparison() { name: symbol_short!("equal"), category: symbol_short!("flight"), oracle_key: symbol_short!("flight1"), + expected_token: te.usdc.clone(), trigger_type: TriggerType::Threshold, oracle_data_type: weather(), trigger_threshold: 100_000_000i128, @@ -390,6 +398,7 @@ fn test_equal_comparison() { &1_000_0000000i128, &30u32, &symbol_short!("flight1"), + &te.usdc, ); let fresh_now = data_ts + 3_600; @@ -419,6 +428,7 @@ fn dispute_removes_claim_from_pending_queue() { &1_000_0000000i128, &30u32, &symbol_short!("kis2606"), + &te.usdc, ); // Submit enqueues the claim. diff --git a/contracts/governance-dao/src/lib.rs b/contracts/governance-dao/src/lib.rs index 73e3ea1..2cd1918 100644 --- a/contracts/governance-dao/src/lib.rs +++ b/contracts/governance-dao/src/lib.rs @@ -154,6 +154,9 @@ enum StorageKey { /// Discussion). Cleared when the proposal resolves so the same action can /// be re-proposed after the previous one closes. Issue #630. ActiveProposalHash(BytesN<32>), + /// Proposal content hash at creation time: proposal_id -> BytesN<32>. + /// Verified at execution time to ensure payload integrity (issue #771). + ProposalHash(u64), } @@ -169,7 +172,8 @@ pub enum Error { ProposalNotActive = 6, AlreadyVoted = 7, VotingClosed = 8, - VotingStillOpen = 9, + /// Proposal payload content hash does not match stored hash on execution (issue #771). + HashMismatch = 9, ProposalNotPassed = 10, AlreadyExecuted = 11, AlreadyCancelled = 12, @@ -205,8 +209,6 @@ pub enum Error { ExecutionDeadlineExpired = 42, /// Proposal has been vetoed by a guardian and can never be executed. ProposalVetoed = 43, - /// `finalize_batch` was called with more proposals than allowed. - BatchTooLarge = 46, /// `set_impact_multipliers` was called with a multiplier below /// `MIN_MULTIPLIER_BPS` (would de-escalate) or above `MAX_MULTIPLIER_BPS` /// (would brick proposal creation). Issue #438. @@ -420,6 +422,9 @@ impl GovernanceDao { let proposal_key = StorageKey::Proposal(proposal_id); env.storage().persistent().set(&proposal_key, &proposal); + let proposal_hash_key = StorageKey::ProposalHash(proposal_id); + env.storage().persistent().set(&proposal_hash_key, &content_hash); + Self::extend_proposal_ttl(&env, &proposal_hash_key, &config); env.storage() .instance() .set(&StorageKey::NextProposalId, &(proposal_id.checked_add(1).unwrap_or_else(|| panic_with_error!(&env, Error::LimitReached)))); @@ -568,6 +573,9 @@ impl GovernanceDao { let proposal_key = StorageKey::Proposal(proposal_id); env.storage().persistent().set(&proposal_key, &proposal); + let proposal_hash_key = StorageKey::ProposalHash(proposal_id); + env.storage().persistent().set(&proposal_hash_key, &upgrade_content_hash); + Self::extend_proposal_ttl(&env, &proposal_hash_key, &config); env.storage().instance().set( &StorageKey::NextProposalId, &(proposal_id @@ -1273,7 +1281,7 @@ impl GovernanceDao { // Cap batch size to bound instruction budget. const MAX_BATCH_FINALIZE: u32 = 20; if proposal_ids.len() > MAX_BATCH_FINALIZE { - panic_with_error!(&env, Error::BatchTooLarge); + panic_with_error!(&env, Error::LimitReached); } let config: DaoConfig = env.storage().instance().get(&StorageKey::Config).unwrap(); @@ -1469,6 +1477,26 @@ impl GovernanceDao { // This is a defense-in-depth check to prevent targeting invalid contracts Self::validate_stellar_address(&env, &proposal.target); + // SECURITY FIX (issue #771): Verify proposal content hash matches stored hash on execution. + // Prevents execution of tampered proposal payloads. + let payload_hash = Self::proposal_content_hash( + &env, + &proposal.target, + &proposal.function, + &proposal.args, + ); + if let Some(stored_hash) = env + .storage() + .persistent() + .get::<_, BytesN<32>>(&StorageKey::ProposalHash(proposal_id)) + { + if payload_hash != stored_hash { + panic_with_error!(&env, Error::HashMismatch); + } + } else { + panic_with_error!(&env, Error::HashMismatch); + } + // A target may return u32 basis points (0..=10,000) to report // proportional completion. Existing unit/non-u32 returns remain // compatible and count as full execution. @@ -1966,7 +1994,7 @@ impl GovernanceDao { // Validate template payload structure (#694) if min_title_len == 0 || min_title_len > 256 || required_arg_count > 64 { - panic_with_error!(&env, Error::InvalidParameter); + panic_with_error!(&env, Error::InvalidInput); } if env.storage().instance().has(&StorageKey::Template(name.clone())) { @@ -2094,6 +2122,14 @@ impl GovernanceDao { .unwrap_or_else(|| panic_with_error!(&env, Error::ProposalNotFound)) } + /// Return the stored content hash for a proposal (issue #771). + pub fn get_proposal_hash(env: Env, proposal_id: u64) -> BytesN<32> { + env.storage() + .persistent() + .get(&StorageKey::ProposalHash(proposal_id)) + .unwrap_or_else(|| panic_with_error!(&env, Error::ProposalNotFound)) + } + /// Look up a voter's vote record (choice + weight) for a proposal, if /// they voted. Returns `None` if the address has not voted. pub fn get_vote(env: Env, proposal_id: u64, voter: Address) -> Option { @@ -2838,3 +2874,5 @@ impl GovernanceDao { mod test; #[cfg(test)] mod test_advanced; +#[cfg(test)] +mod test_proposal_hash; diff --git a/contracts/governance-dao/src/test_proposal_hash.rs b/contracts/governance-dao/src/test_proposal_hash.rs new file mode 100644 index 0000000..19a2edb --- /dev/null +++ b/contracts/governance-dao/src/test_proposal_hash.rs @@ -0,0 +1,192 @@ +use super::*; +use soroban_sdk::{ + contract, contractimpl, testutils::{Address as _, Ledger}, Address, Bytes, Env, Symbol, Val, Vec, +}; + +#[contract] +struct MockTarget; + +#[contractimpl] +impl MockTarget { + pub fn update(_env: Env) -> u32 { + 10_000 + } + pub fn drain(_env: Env) -> u32 { + 10_000 + } +} + +fn setup() -> (Env, GovernanceDaoClient<'static>, Address, Address, Address, Address) { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let voter1 = Address::generate(&env); + let voter2 = Address::generate(&env); + let target = env.register(MockTarget, ()); + + let gov_token = env + .register_stellar_asset_contract_v2(admin.clone()) + .address(); + let token_admin = soroban_sdk::token::StellarAssetClient::new(&env, &gov_token); + token_admin.mint(&voter1, &10_000_0000000i128); + token_admin.mint(&voter2, &10_000_0000000i128); + + let dao_id = env.register(GovernanceDao, ()); + let dao = GovernanceDaoClient::new(&env, &dao_id); + + let config = DaoConfig { + gov_token, + total_supply: 20_000_0000000i128, + proposal_threshold: 1_000_0000000i128, + quorum_bps: 2_000, + majority_bps: 5_001, + voting_period: 3_600 * 24 * 7, + proposal_timelock: 0, + discussion_period: 0, + vote_weight_cap: 0, + }; + dao.initialize(&admin, &config); + + (env, dao, admin, voter1, voter2, target) +} + +#[test] +fn test_proposal_hash_verified_on_execution_success() { + let (env, dao, _, voter1, voter2, target) = setup(); + let args: Vec = Vec::new(&env); + let pid = dao.create_proposal( + &voter1, + &Bytes::from_slice(&env, b"Legitimate proposal"), + &target, + &Symbol::new(&env, "update"), + &args, + &Bytes::from_slice(&env, b"Impact analysis: verified safe."), + ); + + // Stored hash must be queryable and non-empty + let stored_hash = dao.get_proposal_hash(&pid); + assert_ne!(stored_hash, soroban_sdk::BytesN::from_array(&env, &[0u8; 32])); + + // Pass and finalize + dao.vote(&voter1, &pid, &VoteChoice::For); + dao.vote(&voter2, &pid, &VoteChoice::For); + env.ledger().with_mut(|l| l.timestamp += 7 * 86_400 + 301); + dao.finalize(&pid); + + // Execution verifies hash matches payload and succeeds + dao.execute(&pid); + let proposal = dao.get_proposal(&pid); + assert_eq!(proposal.status, ProposalStatus::Executed); +} + +#[test] +#[should_panic(expected = "Error(Contract, #9)")] +fn test_tampered_proposal_target_rejected_on_execution() { + let (env, dao, _, voter1, voter2, target) = setup(); + let args: Vec = Vec::new(&env); + let pid = dao.create_proposal( + &voter1, + &Bytes::from_slice(&env, b"Tamper target"), + &target, + &Symbol::new(&env, "update"), + &args, + &Bytes::from_slice(&env, b"Impact analysis: verified safe."), + ); + + dao.vote(&voter1, &pid, &VoteChoice::For); + dao.vote(&voter2, &pid, &VoteChoice::For); + env.ledger().with_mut(|l| l.timestamp += 7 * 86_400 + 301); + dao.finalize(&pid); + + // Tamper with target contract address in storage + let rogue_target = Address::generate(&env); + env.as_contract(&dao.address, || { + let mut proposal: Proposal = env + .storage() + .persistent() + .get(&StorageKey::Proposal(pid)) + .unwrap(); + proposal.target = rogue_target; + env.storage() + .persistent() + .set(&StorageKey::Proposal(pid), &proposal); + }); + + // Execution MUST reject mismatched hash (#771) + dao.execute(&pid); +} + +#[test] +#[should_panic(expected = "Error(Contract, #9)")] +fn test_tampered_proposal_function_rejected_on_execution() { + let (env, dao, _, voter1, voter2, target) = setup(); + let args: Vec = Vec::new(&env); + let pid = dao.create_proposal( + &voter1, + &Bytes::from_slice(&env, b"Tamper function"), + &target, + &Symbol::new(&env, "update"), + &args, + &Bytes::from_slice(&env, b"Impact analysis: verified safe."), + ); + + dao.vote(&voter1, &pid, &VoteChoice::For); + dao.vote(&voter2, &pid, &VoteChoice::For); + env.ledger().with_mut(|l| l.timestamp += 7 * 86_400 + 301); + dao.finalize(&pid); + + // Tamper with function name in storage + env.as_contract(&dao.address, || { + let mut proposal: Proposal = env + .storage() + .persistent() + .get(&StorageKey::Proposal(pid)) + .unwrap(); + proposal.function = Symbol::new(&env, "drain"); + env.storage() + .persistent() + .set(&StorageKey::Proposal(pid), &proposal); + }); + + // Execution MUST reject mismatched hash (#771) + dao.execute(&pid); +} + +#[test] +#[should_panic(expected = "Error(Contract, #9)")] +fn test_tampered_proposal_args_rejected_on_execution() { + let (env, dao, _, voter1, voter2, target) = setup(); + let args: Vec = Vec::new(&env); + let pid = dao.create_proposal( + &voter1, + &Bytes::from_slice(&env, b"Tamper args"), + &target, + &Symbol::new(&env, "update"), + &args, + &Bytes::from_slice(&env, b"Impact analysis: verified safe."), + ); + + dao.vote(&voter1, &pid, &VoteChoice::For); + dao.vote(&voter2, &pid, &VoteChoice::For); + env.ledger().with_mut(|l| l.timestamp += 7 * 86_400 + 301); + dao.finalize(&pid); + + // Tamper with args in storage + env.as_contract(&dao.address, || { + let mut proposal: Proposal = env + .storage() + .persistent() + .get(&StorageKey::Proposal(pid)) + .unwrap(); + let mut tampered_args = Vec::new(&env); + tampered_args.push_back(100u32.into_val(&env)); + proposal.args = tampered_args; + env.storage() + .persistent() + .set(&StorageKey::Proposal(pid), &proposal); + }); + + // Execution MUST reject mismatched hash (#771) + dao.execute(&pid); +} diff --git a/contracts/policy-engine/src/lib.rs b/contracts/policy-engine/src/lib.rs index cd9f0f4..0745f9b 100644 --- a/contracts/policy-engine/src/lib.rs +++ b/contracts/policy-engine/src/lib.rs @@ -1,2089 +1,2125 @@ -//! Parashield Policy Engine -//! -//! Manages insurance products and policies. -//! -//! Flow -//! ───── -//! 1. Admin calls `create_product` to define a new insurance product. -//! 2. User calls `buy_policy` — transfers premium to this contract, -//! and the contract locks coverage USDC from its pool balance. -//! 3. The Claims Processor calls `pay_claim` / `expire_policy` to update -//! policy status after processing. It also performs the USDC transfer. -//! -//! Architecture note on Claimable Balances -//! ───────────────────────────────────────── -//! Stellar's ClaimPredicate cannot encode data-driven conditions -//! (e.g., "rainfall < 50mm"). Therefore this contract acts as the -//! escrow: it holds USDC and the Claims Processor calls `token.transfer` -//! to pay the policyholder when the oracle confirms a trigger. -// Address/state validation must fail with a typed contract error so callers -#![deny(clippy::panic)] -#![no_std] - -use soroban_sdk::{ - contract, contracterror, contractimpl, contracttype, panic_with_error, symbol_short, token, Address, - BytesN, Env, IntoVal, Symbol, SymbolStr, TryFromVal, Vec, -}; - -pub mod types; -pub use types::*; - -// ─── Storage TTL ────────────────────────────────────────────────────────────── - -// Shared protocol constants — single source of truth in parashield-common (issue #342). -use parashield_common::{TTL_THRESHOLD, TTL_EXTEND_TO, ADMIN_TRANSFER_TIMELOCK}; -const DEFAULT_MAX_PRODUCTS_PER_POOL: u32 = 100; -const CURRENT_STORAGE_VERSION: u32 = 3; -const MAX_BATCH_BUY: u32 = 20; -const DEFAULT_EXPIRY_WARNING_WINDOW: u64 = 7 * 24 * 60 * 60; -const MAX_EXPIRY_SCAN: u32 = 50; -/// Upper bound on a product's `coverage_max`. A product with an absurdly high -/// ceiling would let a single policy lock up unbounded pool capital and create -/// unrealistic exposure (issue #616). Matches the per-policy cap enforced in -/// `buy_policy_inner`. -const MAX_COVERAGE_LIMIT: i128 = 1_000_000_000_000; -/// Minimum time a buyer must wait between two policy purchases on the same -/// oracle key. Without it a buyer can stack many policies on one oracle -/// reading in quick succession, e.g. right before a trigger looks likely -/// (issue #671). -const PURCHASE_COOLING_OFF_SECONDS: u64 = 24 * 60 * 60; - -// ─── Storage keys ───────────────────────────────────────────────────────────── - -#[contracttype] -enum StorageKey { - Initialized, - Admin, - UsdcToken, - OracleAddress, - ClaimsProcessor, - /// InsuranceProduct - Product(u128), - /// Policy - Policy(u128), - /// Vec — product IDs for a user - UserPolicies(Address), - /// Vec — all active product IDs - ActiveProducts, - NextProductId, - NextPolicyId, - Paused, - /// Maps (category, oracle_key) -> product_id for uniqueness constraint - ProductKey((Symbol, Symbol)), - PendingAdmin, - /// Ledger timestamp (u64) at which `PendingAdmin` was set, used to enforce - /// `ADMIN_TRANSFER_TIMELOCK` before `accept_admin` succeeds (issue #356). - PendingAdminSince, - MaxProductsPerPool, - PoolProductCount(Symbol), - /// Contract version (u32) for storage migration tracking - Version, - ExpiryWarningWindow, - ExpiryWarned(u128), - /// Maps product name -> product_id, so names stay unique (issue #514). - ProductName(Symbol), - /// Ledger timestamp (u64) of a buyer's last purchase on an oracle key, - /// used to enforce `PURCHASE_COOLING_OFF_SECONDS` (issue #671). - LastPurchase((Address, Symbol)), - /// Independent approver for oracle rotations. - OracleGuardian, - /// Proposed oracle rotation waiting for approval and its timelock. - PendingOracleChange, - /// Maps category Symbol -> bool (whether registered) - RegisteredCategory(Symbol), - /// Vec of all registered categories - CategoryRegistry, - /// Maps oracle_data_type Symbol -> bool (whether registered) (issue #745) - RegisteredOracleDataType(Symbol), - /// Vec of all registered oracle data types (issue #745) - OracleDataTypeRegistry, -} - -// ─── Errors ─────────────────────────────────────────────────────────────────── - -#[contracterror] -#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] -#[repr(u32)] -pub enum Error { - AlreadyInitialized = 1, - NotInitialized = 2, - Unauthorized = 3, - ProductNotFound = 4, - ProductNotActive = 5, - PolicyNotFound = 6, - PolicyNotActive = 7, - CoverageOutOfRange = 8, - DurationTooLong = 9, - InsufficientPool = 10, - AlreadyClaimed = 11, - AlreadyExpired = 12, - InvalidPremiumRate = 13, - InvalidTriggerThreshold = 14, - DuplicateProductKey = 15, - InvalidCoverageRange = 16, - InvalidToken = 17, - ClaimsProcessorNotSet = 18, - InvalidDurationRange = 19, - InvalidOracleKey = 20, - Overflow = 21, - TooManyProducts = 22, - InvalidMaxProducts = 23, - AdminTimelockNotExpired = 24, - InvalidAddress = 25, - InvalidVersion = 26, - /// An admin transfer was proposed while another one is still pending, - /// which would reset the transfer timelock (issue #457). - AdminTransferPending = 27, - EmptyBatch = 29, - BatchTooLarge = 30, - NotExpiringSoon = 31, - InvalidWarningWindow = 32, - ReasonTooLong = 33, - /// A product with this name already exists (issue #514). - DuplicateProductName = 34, - InvalidTriggerCombination = 35, - /// The product category is not one of the supported categories (issue #549). - InvalidCategory = 36, - /// A scheduled policy start time is earlier than the current ledger time (issue #522). - InvalidStartTime = 37, - /// Premium rate cannot be zero. (issue #564) - ZeroPremiumRate = 38, - /// The buyer already bought a policy on this oracle key within the - /// cooling-off period (issue #671). - CoolingOffActive = 39, - OracleChangePending = 40, - NoPendingOracleChange = 41, - OracleTimelockNotExpired = 42, - NotOracleGuardian = 43, - OracleApprovalRequired = 44, - AdminCannotBuyPolicy = 45, - /// Product description / name cannot be empty (issue #755). - EmptyDescription = 46, - /// Product description / name is too short (issue #755). - InvalidDescription = 47, - /// Oracle `data_type` is not registered on the oracle verifier. - UnregisteredOracleDataType = 48, - /// Generic rejected input — malformed, out of range, or wrong type. - InvalidInput = 49, -} - -// SECURITY: 48-hour timelock on critical admin actions (create_product, update_product). -// Prevents an instantly-compromised admin key from immediately deploying malicious products. -// Users have time to detect the compromise and intervene. -const ADMIN_ACTION_TIMELOCK_SECONDS: u64 = 48 * 60 * 60; -/// Oracle substitutions are equally security-sensitive: a replacement can -/// determine every subsequent policy outcome, so users receive 48 hours to -/// inspect an independently approved change before it executes. -pub const ORACLE_CHANGE_TIMELOCK_SECONDS: u64 = 48 * 60 * 60; - -// ─── Contract ───────────────────────────────────────────────────────────────── - -#[contract] -pub struct PolicyEngine; - -#[contractimpl] -impl PolicyEngine { - // ── Lifecycle ──────────────────────────────────────────────────────────── - - /// One-time initialisation. Wires up the USDC token and oracle contracts. - /// Panics with `AlreadyInitialized` on a second call, or `InvalidToken` if - /// `usdc_token` does not expose a `balance` entry-point. - pub fn initialize(env: Env, admin: Address, usdc_token: Address, oracle_address: Address) { - if env.storage().instance().has(&StorageKey::Initialized) { - panic_with_error!(&env, Error::AlreadyInitialized); - } - - 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(); - 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 oracle_str = oracle_address.to_string(); - if oracle_str.len() != 56 { - panic_with_error!(&env, Error::InvalidAddress); - } - let mut oracle_buf = [0u8; 56]; - oracle_str.copy_into_slice(&mut oracle_buf); - if oracle_buf[0] != b'C' { - panic_with_error!(&env, Error::InvalidAddress); - } - // Reject zero address as oracle (issue #730) - Self::require_not_zero_address(&env, &oracle_address); - - 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); - } - - // Verify the token implements transfer — required for premium collection, - // claim payouts, and refunds. A contract with balance but no transfer - // would fail silently on buy_policy or pay_claim. - let transfer_res = env.try_invoke_contract::( - &usdc_token, - &Symbol::new(&env, "transfer"), - soroban_sdk::vec![ - &env, - env.current_contract_address().into_val(&env), - env.current_contract_address().into_val(&env), - 0i128.into_val(&env), - ], - ); - if transfer_res.is_err() { - panic_with_error!(&env, Error::InvalidToken); - } - - 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::OracleAddress, &oracle_address); - env.storage() - .instance() - .set(&StorageKey::NextProductId, &1u128); - env.storage() - .instance() - .set(&StorageKey::NextPolicyId, &1u128); - env.storage() - .instance() - .set(&StorageKey::ActiveProducts, &Vec::::new(&env)); - env.storage().instance().set( - &StorageKey::MaxProductsPerPool, - &DEFAULT_MAX_PRODUCTS_PER_POOL, - ); - // No pending admin initially - env.storage().instance().remove(&StorageKey::PendingAdmin); - - // Initialize default registered categories (#682) - let default_categories = soroban_sdk::vec![ - &env, - symbol_short!("crop"), - symbol_short!("flight"), - symbol_short!("disaster"), - symbol_short!("health"), - symbol_short!("defi"), - ]; - for i in 0..default_categories.len() { - let cat = default_categories.get_unchecked(i); - env.storage() - .persistent() - .set(&StorageKey::RegisteredCategory(cat.clone()), &true); - Self::extend_to_max(&env, &StorageKey::RegisteredCategory(cat)); - } - env.storage() - .instance() - .set(&StorageKey::CategoryRegistry, &default_categories); - - let default_types: Vec = soroban_sdk::vec![ - &env, - symbol_short!("weather"), - symbol_short!("flight"), - symbol_short!("disaster"), - symbol_short!("onchain"), - symbol_short!("defi"), - symbol_short!("health"), - ]; - for i in 0..default_types.len() { - let dt = default_types.get_unchecked(i); - env.storage() - .persistent() - .set(&StorageKey::RegisteredOracleDataType(dt.clone()), &true); - Self::extend_to_max(&env, &StorageKey::RegisteredOracleDataType(dt)); - } - env.storage() - .instance() - .set(&StorageKey::OracleDataTypeRegistry, &default_types); - - env.events().publish( - (Symbol::new(&env, "initialized"),), - Initialized { - admin: admin.clone(), - usdc_token: usdc_token.clone(), - oracle_address: oracle_address.clone(), - }, - ); - } - - /// Set the Claims Processor address. Called once after deploying claims contract. - pub fn set_claims_processor(env: Env, admin: Address, claims_processor: Address) { - Self::require_admin(&env, &admin); - env.storage() - .instance() - .set(&StorageKey::ClaimsProcessor, &claims_processor); - env.events().publish( - (Symbol::new(&env, "claims_processor_updated"),), - ClaimsProcessorUpdated { - claims_processor: claims_processor.clone(), - }, - ); - } - - /// Set the independent guardian that must approve every oracle rotation. - pub fn set_oracle_guardian(env: Env, admin: Address, guardian: Address) { - Self::require_admin(&env, &admin); - if guardian == admin { - panic_with_error!(&env, Error::NotOracleGuardian); - } - env.storage() - .instance() - .set(&StorageKey::OracleGuardian, &guardian); - env.events().publish( - (Symbol::new(&env, "oracle_guardian_updated"),), - guardian, - ); - } - - /// Propose an oracle contract replacement. A proposal cannot be replaced - /// while pending, preventing an admin from resetting its review history. - pub fn propose_oracle_change(env: Env, admin: Address, new_oracle: Address) { - Self::require_admin(&env, &admin); - if env.storage().instance().has(&StorageKey::PendingOracleChange) { - panic_with_error!(&env, Error::OracleChangePending); - } - let oracle_str = new_oracle.to_string(); - if oracle_str.len() != 56 { - panic_with_error!(&env, Error::InvalidAddress); - } - let mut oracle_buf = [0u8; 56]; - oracle_str.copy_into_slice(&mut oracle_buf); - if oracle_buf[0] != b'C' { - panic_with_error!(&env, Error::InvalidAddress); - } - // Reject zero address as oracle (issue #730) - Self::require_not_zero_address(&env, &new_oracle); - - let change = PendingOracleChange { - new_oracle: new_oracle.clone(), - execute_after: env - .ledger() - .timestamp() - .saturating_add(ORACLE_CHANGE_TIMELOCK_SECONDS), - guardian_approved: false, - }; - env.storage() - .instance() - .set(&StorageKey::PendingOracleChange, &change); - env.events().publish( - (Symbol::new(&env, "oracle_change_proposed"),), - change, - ); - } - - /// Approve the pending replacement as the configured oracle guardian. - pub fn approve_oracle_change(env: Env, guardian: Address) { - guardian.require_auth(); - let configured: Address = env - .storage() - .instance() - .get(&StorageKey::OracleGuardian) - .unwrap_or_else(|| panic_with_error!(&env, Error::NotOracleGuardian)); - if guardian != configured { - panic_with_error!(&env, Error::NotOracleGuardian); - } - let mut change: PendingOracleChange = env - .storage() - .instance() - .get(&StorageKey::PendingOracleChange) - .unwrap_or_else(|| panic_with_error!(&env, Error::NoPendingOracleChange)); - change.guardian_approved = true; - env.storage() - .instance() - .set(&StorageKey::PendingOracleChange, &change); - } - - /// Execute an approved oracle replacement once its 48-hour review period - /// has elapsed. - pub fn execute_oracle_change(env: Env, admin: Address) { - Self::require_admin(&env, &admin); - let change: PendingOracleChange = env - .storage() - .instance() - .get(&StorageKey::PendingOracleChange) - .unwrap_or_else(|| panic_with_error!(&env, Error::NoPendingOracleChange)); - if !change.guardian_approved { - panic_with_error!(&env, Error::OracleApprovalRequired); - } - if env.ledger().timestamp() < change.execute_after { - panic_with_error!(&env, Error::OracleTimelockNotExpired); - } - env.storage() - .instance() - .set(&StorageKey::OracleAddress, &change.new_oracle); - env.storage() - .instance() - .remove(&StorageKey::PendingOracleChange); - env.events().publish( - (Symbol::new(&env, "oracle_changed"),), - change.new_oracle, - ); - } - - pub fn get_pending_oracle_change(env: Env) -> Option { - env.storage().instance().get(&StorageKey::PendingOracleChange) - } - - // ── Product Management (admin only) ────────────────────────────────────── - - /// Admin-only: create a new insurance product and return its ID. - /// `params.premium_rate_bps` must be 1-5000 (i.e. 0.01% to 50%); `params.coverage_amount` must be positive. - /// - /// Coverage must describe a real, buyable range: `0 < coverage_min < - /// coverage_max` (issue #567). An inverted or zero-width range would - /// publish a product that `buy_policy` can never accept a policy for, - /// and a non-positive min would sell free cover. - pub fn create_product(env: Env, admin: Address, params: CreateProductParams) -> u128 { - Self::require_admin(&env, &admin); - - // Only registered product categories are accepted (issue #682). - let category = ¶ms.category; - if !Self::is_category_registered(env.clone(), category.clone()) { - panic_with_error!(&env, Error::InvalidCategory); - } - - // Product description / name must not be empty (issue #755). - if params.name == Symbol::new(&env, "") { - panic_with_error!(&env, Error::EmptyDescription); - } - { - let sym_val = params.name.to_symbol_val(); - let sym_str: Result = SymbolStr::try_from_val(&env, &sym_val); - if let Ok(s) = sym_str { - let s_str: &str = s.as_ref(); - let bytes: &[u8] = s_str.as_bytes(); - if bytes.is_empty() { - panic_with_error!(&env, Error::EmptyDescription); - } - if bytes.len() < 3 { - panic_with_error!(&env, Error::InvalidDescription); - } - } - } - - match params.trigger_type { - TriggerType::Binary => { - if params.trigger_comparison != TriggerComparison::Equal { - panic_with_error!(&env, Error::InvalidTriggerCombination); - } - } - TriggerType::Threshold | TriggerType::Parametric => { - if params.trigger_comparison == TriggerComparison::Equal { - panic_with_error!(&env, Error::InvalidTriggerCombination); - } - } - } - - if !Self::is_oracle_data_type_registered(env.clone(), params.oracle_data_type.clone()) { - panic_with_error!(&env, Error::UnregisteredOracleDataType); - } - - // SECURITY FIX: Validate oracle_data_type matches the product's trigger type. - // Mismatched data types would cause claim evaluation to fail or produce incorrect results. - // Binary triggers expect event-based data types (flight, disaster), while - // Threshold/Parametric triggers expect measurement data types (weather, onchain). - match params.trigger_type { - TriggerType::Binary => { - // Binary triggers work with discrete event data - if params.oracle_data_type != symbol_short!("flight") - && params.oracle_data_type != symbol_short!("disaster") - && params.oracle_data_type != symbol_short!("health") - { - panic_with_error!(&env, Error::InvalidTriggerCombination); - } - } - TriggerType::Threshold | TriggerType::Parametric => { - // Threshold/Parametric triggers work with continuous measurement data - if params.oracle_data_type != symbol_short!("weather") - && params.oracle_data_type != symbol_short!("onchain") - && params.oracle_data_type != symbol_short!("defi") - { - panic_with_error!(&env, Error::InvalidTriggerCombination); - } - } - } - - // SECURITY FIX: Validate premium_rate_bps is positive and within acceptable bounds. - // Zero or negative (though u32 prevents negative) premium rates are invalid. - // A zero rate would provide free insurance, which is not economically viable. - if params.premium_rate_bps == 0 { - panic_with_error!(&env, Error::ZeroPremiumRate); - } - // Issue #626: a premium rate above 50% (5_000 bps) is unrealistic for - // insurance. The previous ceiling of 10_000 bps (100%) would let an - // admin configure a product whose premium exceeds the coverage amount - // for any policy longer than ~365 days, which is not a real insurance - // product. 5_000 bps is already well above any legitimate parametric - // or indemnity rate and leaves headroom for high-risk categories. - if params.premium_rate_bps > 5_000 { - panic_with_error!(&env, Error::InvalidPremiumRate); - } - // trigger_threshold must be strictly positive (issue #619) and within - // the 7-decimal fixed-point range used across the protocol - // (1 unit = 0.0000001; max ≈ 1 quadrillion). Zero or negative - // thresholds would make every policy unconditionally payable. - if params.trigger_threshold <= 0 - || params.trigger_threshold > 1_000_000_000_000_000_000_000i128 - { - panic_with_error!(&env, Error::InvalidTriggerThreshold); - } - // Coverage bounds must form a valid, positive range: 0 < min < max (issue #736, #567). - // Rejects free coverage (min <= 0) and inverted or zero-width ranges (coverage_max < coverage_min). - if params.coverage_min <= 0 || params.coverage_max < params.coverage_min || params.coverage_min >= params.coverage_max { - panic_with_error!(&env, Error::InvalidCoverageRange); - } - // Coverage ceiling must be reasonable (issue #616). An unbounded - // coverage_max would let a single policy lock unbounded pool capital - // and create unrealistic exposure. Capped at MAX_COVERAGE_LIMIT to - // match the per-policy limit in buy_policy_inner. - if params.coverage_max > MAX_COVERAGE_LIMIT { - panic_with_error!(&env, Error::CoverageOutOfRange); - } - // max_duration_days must be positive and less than 3650 (up to 10 years) - // Rejects 0-day policies and unrealistically long durations - if params.max_duration_days == 0 || params.max_duration_days > 3650 { - panic_with_error!(&env, Error::InvalidDurationRange); - } - // Oracle keys follow the documented 3..=9 alphanumeric format. - { - let sym_val = params.oracle_key.to_symbol_val(); - let sym_str: Result = SymbolStr::try_from_val(&env, &sym_val); - match sym_str { - Ok(s) => { - let s_str: &str = s.as_ref(); - let bytes: &[u8] = s_str.as_bytes(); - if bytes.len() < 3 || bytes.len() > 9 { - panic_with_error!(&env, Error::InvalidOracleKey); - } - let mut has_alpha = false; - for &b in bytes { - if (b >= b'a' && b <= b'z') || (b >= b'A' && b <= b'Z') { - has_alpha = true; - } else if !(b >= b'0' && b <= b'9') { - panic_with_error!(&env, Error::InvalidOracleKey); - } - } - if !has_alpha { - panic_with_error!(&env, Error::InvalidOracleKey); - } - } - Err(_) => { - panic_with_error!(&env, Error::InvalidOracleKey); - } - } - } - - // Check for duplicate (category, oracle_key) pair - let key = (params.category.clone(), params.oracle_key.clone()); - if env - .storage() - .persistent() - .has(&StorageKey::ProductKey(key.clone())) - { - panic_with_error!(&env, Error::DuplicateProductKey); - } - - // Product names must be unique so users can tell products apart (#514). - let name_key = StorageKey::ProductName(params.name.clone()); - if env.storage().persistent().has(&name_key) { - panic_with_error!(&env, Error::DuplicateProductName); - } - - let pool_count_key = StorageKey::PoolProductCount(params.category.clone()); - let pool_count: u32 = env.storage().persistent().get(&pool_count_key).unwrap_or(0); - let max_products: u32 = env - .storage() - .instance() - .get(&StorageKey::MaxProductsPerPool) - .unwrap_or(DEFAULT_MAX_PRODUCTS_PER_POOL); - if pool_count >= max_products { - panic_with_error!(&env, Error::TooManyProducts); - } - - let id = Self::next_product_id(&env); - let product = InsuranceProduct { - id, - name: params.name, - category: params.category, - oracle_key: params.oracle_key, - expected_token: params.expected_token, - trigger_type: params.trigger_type, - oracle_data_type: params.oracle_data_type, - trigger_threshold: params.trigger_threshold, - trigger_comparison: params.trigger_comparison, - coverage_min: params.coverage_min, - coverage_max: params.coverage_max, - premium_rate_bps: params.premium_rate_bps, - max_duration_days: params.max_duration_days, - status: ProductStatus::Active, - created_at: env.ledger().timestamp(), - }; - env.storage() - .persistent() - .set(&StorageKey::Product(id), &product); - env.storage().persistent().extend_ttl( - &StorageKey::Product(id), - TTL_THRESHOLD, - TTL_EXTEND_TO, - ); - - // Store the (category, oracle_key) -> product_id mapping for uniqueness - env.storage() - .persistent() - .set(&StorageKey::ProductKey(key.clone()), &id); - env.storage().persistent().extend_ttl( - &StorageKey::ProductKey(key), - TTL_THRESHOLD, - TTL_EXTEND_TO, - ); - - env.storage().persistent().set(&name_key, &id); - env.storage() - .persistent() - .extend_ttl(&name_key, TTL_THRESHOLD, TTL_EXTEND_TO); - - let mut products: Vec = env - .storage() - .instance() - .get(&StorageKey::ActiveProducts) - .unwrap_or_else(|| Vec::new(&env)); - products.push_back(id); - env.storage() - .instance() - .set(&StorageKey::ActiveProducts, &products); - env.storage() - .persistent() - .set(&pool_count_key, &(pool_count + 1)); - env.storage() - .persistent() - .extend_ttl(&pool_count_key, TTL_THRESHOLD, TTL_EXTEND_TO); - - env.events().publish( - (Symbol::new(&env, "product_created"),), - ProductCreated { - product_id: id, - name: product.name.clone(), - category: product.category.clone(), - premium_rate_bps: product.premium_rate_bps, - }, - ); - id - } - - /// Admin-only: suspend sales for a product without deleting it. - /// The product is removed from the active-products list; existing policies are unaffected. - pub fn pause_product(env: Env, admin: Address, product_id: u128) { - Self::require_admin(&env, &admin); - let mut product: InsuranceProduct = Self::load_product(&env, product_id); - product.status = ProductStatus::Paused; - env.storage() - .persistent() - .set(&StorageKey::Product(product_id), &product); - - let mut products: Vec = env - .storage() - .instance() - .get(&StorageKey::ActiveProducts) - .unwrap_or_else(|| Vec::new(&env)); - let mut idx: Option = None; - for i in 0..products.len() { - if products.get_unchecked(i) == product_id { - idx = Some(i); - break; - } - } - if let Some(i) = idx { - products.remove(i); - env.storage() - .instance() - .set(&StorageKey::ActiveProducts, &products); - } - - env.events().publish( - (Symbol::new(&env, "product_paused"),), - ProductPaused { product_id }, - ); - } - - /// Admin-only: permanently retire a product. It is removed from the active list and its - /// `(category, oracle_key)` slot is freed so another product may reuse it. - /// - /// The name index is released too (issue #570). Freeing the name matters - /// as much as freeing the oracle key: `create_product` rejects a name that - /// is still mapped, so leaving the uniqueness index behind would outlive - /// the product it describes and the admin could never re-launch the same - /// product line under the same name. It would also silently reserve a - /// name that no live product answers to, which is the behaviour users see - /// as "that name is taken" for a product that no longer exists. - /// - /// The `(category, oracle_key)` slot and the pool's product count keep - /// their existing decrement behaviour, so retiring a product never leaves - /// the pool's accounting or a live product's key untouched. - pub fn deprecate_product(env: Env, admin: Address, product_id: u128) { - Self::require_admin(&env, &admin); - let mut product: InsuranceProduct = Self::load_product(&env, product_id); - product.status = ProductStatus::Deprecated; - env.storage() - .persistent() - .set(&StorageKey::Product(product_id), &product); - - // Remove from the ActiveProducts list on deprecation - let mut products: Vec = env - .storage() - .instance() - .get(&StorageKey::ActiveProducts) - .unwrap_or_else(|| Vec::new(&env)); - let mut idx: Option = None; - for i in 0..products.len() { - if products.get_unchecked(i) == product_id { - idx = Some(i); - break; - } - } - if let Some(i) = idx { - products.remove(i); - env.storage() - .instance() - .set(&StorageKey::ActiveProducts, &products); - } - - // Remove the (category, oracle_key) mapping to allow reuse of the key - let pool_count_key = StorageKey::PoolProductCount(product.category.clone()); - let pool_count: u32 = env.storage().persistent().get(&pool_count_key).unwrap_or(0); - if pool_count > 0 { - env.storage() - .persistent() - .set(&pool_count_key, &(pool_count - 1)); - env.storage() - .persistent() - .extend_ttl(&pool_count_key, TTL_THRESHOLD, TTL_EXTEND_TO); - } - - // Release the name so a successor product can take it (issue #570). - // Only remove the index entry when it still points at *this* product: - // a stale mapping left by an earlier product must not be dropped just - // because a different product happened to share the name. - let name_key = StorageKey::ProductName(product.name.clone()); - if env.storage().persistent().get::<_, u128>(&name_key) == Some(product_id) { - env.storage().persistent().remove(&name_key); - } - - let key = (product.category, product.oracle_key); - env.storage() - .persistent() - .remove(&StorageKey::ProductKey(key)); - } - - // ── Policy Lifecycle ────────────────────────────────────────────────────── - - /// Buy an insurance policy. - /// - /// The buyer must have approved this contract to spend `premium` USDC - /// (or the transaction must include the token transfer auth). - /// Premium = coverage_amount * premium_rate_bps / 10_000. - /// - /// `oracle_key` is the specific measurement key to watch, e.g. - /// `symbol_short!("kis2606")` for Kisumu June 2026 rainfall. - pub fn buy_policy( - env: Env, - buyer: Address, - product_id: u128, - coverage_amount: i128, - duration_days: u32, - oracle_key: Symbol, - payment_token: Address, - ) -> u128 { - buyer.require_auth(); - Self::ensure_not_paused(&env); - let now = env.ledger().timestamp(); - Self::buy_policy_inner(&env, &buyer, product_id, coverage_amount, duration_days, oracle_key, now, &payment_token) - } - - /// Buy an insurance policy that starts at `start_time` instead of now. - /// - /// `start_time` must be `>= ` the current ledger timestamp; a start in the - /// past would let a buyer purchase cover for a period that has already - /// happened (issue #522). The policy runs from `start_time` for - /// `duration_days`. Claims cannot be filed before `start_time`. - pub fn buy_policy_scheduled( - env: Env, - buyer: Address, - product_id: u128, - coverage_amount: i128, - duration_days: u32, - oracle_key: Symbol, - start_time: u64, - payment_token: Address, - ) -> u128 { - buyer.require_auth(); - Self::ensure_not_paused(&env); - Self::buy_policy_inner(&env, &buyer, product_id, coverage_amount, duration_days, oracle_key, start_time, &payment_token) - } - - /// Buy an insurance policy asserting that the premium rate matches product bounds (issue #741). - pub fn buy_policy_with_rate( - env: Env, - buyer: Address, - product_id: u128, - coverage_amount: i128, - duration_days: u32, - oracle_key: Symbol, - payment_token: Address, - rate_bps: u32, - ) -> u128 { - buyer.require_auth(); - Self::ensure_not_paused(&env); - let product: InsuranceProduct = Self::load_product(&env, product_id); - if rate_bps != product.premium_rate_bps || rate_bps == 0 || rate_bps > 5_000 { - panic_with_error!(&env, Error::InvalidPremiumRate); - } - let now = env.ledger().timestamp(); - Self::buy_policy_inner(&env, &buyer, product_id, coverage_amount, duration_days, oracle_key, now, &payment_token) - } - - /// Buy multiple policies in a single atomic transaction. - /// - /// Up to `MAX_BATCH_BUY` policies may be purchased at once. If any single - /// purchase fails (e.g. invalid duration, paused product), the entire batch - /// reverts. - pub fn batch_buy_policy(env: Env, buyer: Address, items: Vec) -> Vec { - buyer.require_auth(); - Self::ensure_not_paused(&env); - - let n = items.len(); - if n == 0 { - panic_with_error!(&env, Error::EmptyBatch); - } - if n > MAX_BATCH_BUY { - panic_with_error!(&env, Error::BatchTooLarge); - } - - let now = env.ledger().timestamp(); - let mut ids = Vec::new(&env); - for item in items.iter() { - ids.push_back(Self::buy_policy_inner( - &env, - &buyer, - item.product_id, - item.coverage_amount, - item.duration_days, - item.oracle_key, - now, - &item.payment_token, - )); - } - ids - } - - fn buy_policy_inner( - env: &Env, - buyer: &Address, - product_id: u128, - coverage_amount: i128, - duration_days: u32, - oracle_key: Symbol, - start_time: u64, - payment_token: &Address, - ) -> u128 { - // Issue #522: never allow a backdated start. - if start_time < env.ledger().timestamp() { - panic_with_error!(env, Error::InvalidStartTime); - } - - // SECURITY FIX: Validate oracle_key length to prevent excessively long keys - // that could cause issues with oracle lookups. Backend validates max 32 chars, - // but direct contract calls could bypass that. Enforce the same limit on-chain. - { - let sym_val = oracle_key.to_symbol_val(); - let sym_str: Result = soroban_sdk::SymbolStr::try_from_val(env, &sym_val); - match sym_str { - Ok(s) => { - let s_str: &str = s.as_ref(); - let bytes: &[u8] = s_str.as_bytes(); - // Reject oracle keys longer than 32 characters - if bytes.len() > 32 { - panic_with_error!(env, Error::InvalidOracleKey); - } - } - Err(_) => { - panic_with_error!(env, Error::InvalidOracleKey); - } - } - } - // The admin controls product configuration and policy settlement - // wiring, so allowing them to be a policyholder creates a direct - // self-dealing path. - let admin: Address = env - .storage() - .instance() - .get(&StorageKey::Admin) - .unwrap_or_else(|| panic_with_error!(env, Error::NotInitialized)); - if *buyer == admin { - panic_with_error!(env, Error::AdminCannotBuyPolicy); - } - // Issue #671: enforce a cooling-off period between purchases by the - // same buyer on the same oracle key. Measured from the ledger time of - // the previous purchase, not its scheduled start, so scheduling - // policies into the future does not bypass it. - let purchase_key = StorageKey::LastPurchase((buyer.clone(), oracle_key.clone())); - let purchased_at = env.ledger().timestamp(); - if let Some(last) = env.storage().persistent().get::<_, u64>(&purchase_key) { - if purchased_at < last.saturating_add(PURCHASE_COOLING_OFF_SECONDS) { - panic_with_error!(env, Error::CoolingOffActive); - } - } - let product = Self::load_product(env, product_id); - if product.status != ProductStatus::Active { - panic_with_error!(env, Error::ProductNotActive); - } - // Defense-in-depth: reject non-positive coverage before range check - // (#501). A zero or negative amount would pass the range check when - // coverage_min is zero or negative, creating a free or profit-making - // policy that drains the pool. - if coverage_amount <= 0 { - panic_with_error!(&env, Error::CoverageOutOfRange); - } - if coverage_amount < product.coverage_min || coverage_amount > product.coverage_max { - panic_with_error!(env, Error::CoverageOutOfRange); - } - if duration_days == 0 { - panic_with_error!(env, Error::InvalidDurationRange); - } - // Issue #623: validate duration_days against the product ceiling - // explicitly. The computed (end_time - start_time) must never - // exceed max_duration_days * 86_400 seconds. Checking the - // parameter here (before any arithmetic) ensures this holds even - // for scheduled-start policies where start_time != now. - if duration_days > product.max_duration_days { - panic_with_error!(env, Error::DurationTooLong); - } - - if coverage_amount > 1_000_000_000_000 { - panic_with_error!(env, Error::CoverageOutOfRange); - } - - // SECURITY FIX (issue #741): Validate product premium rate is within valid configured bounds (1..=5_000 bps) - if product.premium_rate_bps == 0 || product.premium_rate_bps > 5_000 { - panic_with_error!(env, Error::InvalidPremiumRate); - } - - // SECURITY FIX: Calculate the required premium based on product parameters. - // This prevents callers from providing arbitrary premium amounts. - let required_premium = coverage_amount - .checked_mul(product.premium_rate_bps as i128) - .and_then(|v| v.checked_mul(duration_days as i128)) - .and_then(|v| v.checked_div(365)) - .and_then(|v| v.checked_div(10_000)) - .unwrap_or_else(|| panic_with_error!(env, Error::CoverageOutOfRange)); - - // SECURITY FIX (issue #719): Reject zero or negative premium amounts to prevent free coverage - if required_premium <= 0 { - panic_with_error!(env, Error::ZeroPremiumRate); - } - - // SECURITY FIX: reject payment if token does not match the product's expected token - if payment_token != &product.expected_token { - panic_with_error!(&env, Error::InvalidToken); - } - - // SECURITY FIX: Transfer the calculated required premium amount, not any caller-provided value. - // This ensures the premium matches the product's premium_rate. - token::Client::new(env, payment_token).transfer(buyer, &env.current_contract_address(), &required_premium); - - let now = env.ledger().timestamp(); - let duration_secs = (duration_days as u64) - .checked_mul(86_400) - .unwrap_or_else(|| panic_with_error!(env, Error::CoverageOutOfRange)); - let end_time = start_time - .checked_add(duration_secs) - .unwrap_or_else(|| panic_with_error!(env, Error::CoverageOutOfRange)); - - // SECURITY FIX: Validate that start_time < end_time to prevent invalid policies. - // Policies with start >= end are meaningless and should be rejected at purchase time. - // This check protects against arithmetic edge cases and ensures policy duration is positive. - if end_time <= start_time { - panic_with_error!(env, Error::InvalidDurationRange); - } - // Issue #623: defense-in-depth -- confirm the computed span does - // not exceed max_duration_days * 86_400 seconds. This catches any - // future code path that might compute duration_secs independently. - let max_duration_secs = (product.max_duration_days as u64) - .checked_mul(86_400) - .unwrap_or(u64::MAX); - if end_time.saturating_sub(start_time) > max_duration_secs - || end_time > start_time.saturating_add(max_duration_secs) - || duration_days > product.max_duration_days - { - panic_with_error!(env, Error::DurationTooLong); - } - let policy_id = Self::next_policy_id(env); - - env.storage().persistent().set(&purchase_key, &purchased_at); - env.storage() - .persistent() - .extend_ttl(&purchase_key, TTL_THRESHOLD, TTL_EXTEND_TO); - - let policy = Policy { - id: policy_id, - product_id, - policyholder: buyer.clone(), - coverage_amount, - premium_paid: required_premium, - oracle_key, - oracle_data_type: product.oracle_data_type, - trigger_threshold: product.trigger_threshold, - trigger_comparison: product.trigger_comparison, - start_time, - end_time, - status: PolicyStatus::Active, - created_at: now, - }; - env.storage() - .persistent() - .set(&StorageKey::Policy(policy_id), &policy); - env.storage().persistent().extend_ttl( - &StorageKey::Policy(policy_id), - TTL_THRESHOLD, - TTL_EXTEND_TO, - ); - - let user_key = StorageKey::UserPolicies(buyer.clone()); - let mut user_policies: Vec = env - .storage() - .persistent() - .get(&user_key) - .unwrap_or_else(|| Vec::new(env)); - user_policies.push_back(policy_id); - env.storage().persistent().set(&user_key, &user_policies); - env.storage() - .persistent() - .extend_ttl(&user_key, TTL_THRESHOLD, TTL_EXTEND_TO); - - env.events().publish( - (Symbol::new(env, "buy_policy"), buyer.clone()), - (policy_id, product_id, coverage_amount, required_premium), - ); - - policy_id - } - - /// Cancel an active policy and refund the premium to the policyholder. - /// Only the policyholder may cancel, and only while the policy is Active. - /// `reason` is validated to be at most 256 bytes to prevent storage exhaustion. - pub fn cancel_policy(env: Env, policyholder: Address, policy_id: u128, reason: soroban_sdk::Bytes) -> i128 { - policyholder.require_auth(); - - // Validate reason length to prevent storage exhaustion from extremely long reasons (issue #724) - if reason.is_empty() { - panic_with_error!(&env, Error::InvalidInput); - } - const MAX_REASON_LENGTH: u32 = 256; - if reason.len() > MAX_REASON_LENGTH { - panic_with_error!(&env, Error::ReasonTooLong); - } - - let mut policy: Policy = Self::load_policy(&env, policy_id); - if policy.policyholder != policyholder { - panic_with_error!(&env, Error::Unauthorized); - } - if policy.status != PolicyStatus::Active { - panic_with_error!(&env, Error::PolicyNotActive); - } - policy.status = PolicyStatus::Cancelled; - env.storage() - .persistent() - .set(&StorageKey::Policy(policy_id), &policy); - Self::remove_policy_from_user(&env, &policyholder, policy_id); - - let now = env.ledger().timestamp(); - let elapsed = now.saturating_sub(policy.start_time); - let total_duration = policy.end_time.saturating_sub(policy.start_time); - let refund = if total_duration == 0 { - policy.premium_paid - } else { - let elapsed_capped = elapsed.min(total_duration); - let earned = policy - .premium_paid - .checked_mul(elapsed_capped as i128) - .and_then(|v| v.checked_div(total_duration as i128)) - .unwrap_or_else(|| panic_with_error!(&env, Error::Overflow)); - policy.premium_paid.saturating_sub(earned) - }; - - let usdc: Address = env - .storage() - .instance() - .get(&StorageKey::UsdcToken) - .unwrap_or_else(|| panic_with_error!(&env, Error::NotInitialized)); - if refund > 0 { - token::Client::new(&env, &usdc).transfer( - &env.current_contract_address(), - &policyholder, - &refund, - ); - } - - env.events().publish( - (Symbol::new(&env, "policy_cancelled"),), - PolicyCancelled { - policy_id, - policyholder: policyholder.clone(), - refund_amount: refund, - }, - ); - refund - } - - /// Move an active policy to a new policyholder. Requires auth from both parties. - pub fn transfer_policy(env: Env, from: Address, to: Address, policy_id: u128) { - from.require_auth(); - to.require_auth(); - Self::ensure_not_paused(&env); - - let to_str = to.to_string(); - if to_str.len() != 56 { - panic_with_error!(&env, Error::InvalidAddress); - } - let mut to_buf = [0u8; 56]; - to_str.copy_into_slice(&mut to_buf); - if to_buf[0] != b'G' && to_buf[0] != b'C' { - panic_with_error!(&env, Error::InvalidAddress); - } - - let mut policy: Policy = Self::load_policy(&env, policy_id); - if policy.policyholder != from { - panic_with_error!(&env, Error::Unauthorized); - } - if policy.status != PolicyStatus::Active { - panic_with_error!(&env, Error::PolicyNotActive); - } - if from == to { - return; - } - - policy.policyholder = to.clone(); - env.storage() - .persistent() - .set(&StorageKey::Policy(policy_id), &policy); - env.storage().persistent().extend_ttl( - &StorageKey::Policy(policy_id), - TTL_THRESHOLD, - TTL_EXTEND_TO, - ); - - Self::remove_policy_from_user(&env, &from, policy_id); - let to_key = StorageKey::UserPolicies(to.clone()); - let mut to_policies: Vec = env - .storage() - .persistent() - .get(&to_key) - .unwrap_or_else(|| Vec::new(&env)); - to_policies.push_back(policy_id); - env.storage().persistent().set(&to_key, &to_policies); - env.storage() - .persistent() - .extend_ttl(&to_key, TTL_THRESHOLD, TTL_EXTEND_TO); - - env.events().publish( - (Symbol::new(&env, "policy_transferred"),), - PolicyTransferred { - policy_id, - from, - to, - }, - ); - } - - // ── Status updates (called by Claims Processor) ────────────────────────── - - /// Mark a policy as Claimed and pay coverage to the policyholder. - /// Only callable by the registered Claims Processor. - pub fn pay_claim(env: Env, caller: Address, policy_id: u128) { - Self::require_claims_processor(&env, &caller); - let mut policy: Policy = Self::load_policy(&env, policy_id); - match policy.status { - PolicyStatus::Claimed => panic_with_error!(&env, Error::AlreadyClaimed), - PolicyStatus::Expired => panic_with_error!(&env, Error::AlreadyExpired), - PolicyStatus::Cancelled => panic_with_error!(&env, Error::PolicyNotActive), - PolicyStatus::Active => {} - } - let usdc: Address = env - .storage() - .instance() - .get(&StorageKey::UsdcToken) - .unwrap_or_else(|| panic_with_error!(&env, Error::NotInitialized)); - let token_client = token::Client::new(&env, &usdc); - match token_client.try_transfer( - &env.current_contract_address(), - &policy.policyholder, - &policy.coverage_amount, - ) { - Ok(Ok(())) => {} - _ => { - panic_with_error!(&env, Error::InsufficientPool); - } - } - - policy.status = PolicyStatus::Claimed; - env.storage() - .persistent() - .set(&StorageKey::Policy(policy_id), &policy); - Self::remove_policy_from_user(&env, &policy.policyholder, policy_id); - - env.events().publish( - (Symbol::new(&env, "claim_paid"), policy_id), - (policy.policyholder.clone(), policy.coverage_amount), - ); - } - - /// Mark a policy as Expired (trigger not met before end_time). - /// Premium remains in the contract as earned pool revenue. - pub fn expire_policy(env: Env, caller: Address, policy_id: u128) { - Self::require_claims_processor(&env, &caller); - let mut policy: Policy = Self::load_policy(&env, policy_id); - match policy.status { - PolicyStatus::Claimed => panic_with_error!(&env, Error::AlreadyClaimed), - PolicyStatus::Expired => panic_with_error!(&env, Error::AlreadyExpired), - PolicyStatus::Cancelled => panic_with_error!(&env, Error::PolicyNotActive), - PolicyStatus::Active => {} - } - policy.status = PolicyStatus::Expired; - env.storage() - .persistent() - .set(&StorageKey::Policy(policy_id), &policy); - Self::remove_policy_from_user(&env, &policy.policyholder, policy_id); - - let timestamp = env.ledger().timestamp(); - env.events().publish( - (Symbol::new(&env, "policy_expired"),), - PolicyExpired { policy_id, timestamp }, - ); - } - - // ── Queries ─────────────────────────────────────────────────────────────── - - /// Return the `InsuranceProduct` for the given ID. Panics if the product does not exist. - pub fn get_product(env: Env, product_id: u128) -> InsuranceProduct { - Self::load_product(&env, product_id) - } - - /// Return the `Policy` for the given ID. Panics if the policy does not exist. - pub fn get_policy(env: Env, policy_id: u128) -> Policy { - Self::load_policy(&env, policy_id) - } - - /// Return aggregated statistics for a product: total policies, active count, - /// total coverage, and total premiums collected. Returns zeros if the product - /// does not exist or has no policies. - pub fn get_product_stats(env: Env, product_id: u128) -> ProductStats { - let _product = match env - .storage() - .persistent() - .get::<_, InsuranceProduct>(&StorageKey::Product(product_id)) - { - Some(p) => p, - None => { - return ProductStats { - product_id, - total_policies: 0, - active_policies: 0, - total_coverage: 0, - total_premium_collected: 0, - } - } - }; - - let mut total_policies: u32 = 0; - let mut active_policies: u32 = 0; - let mut total_coverage: i128 = 0; - let mut total_premium_collected: i128 = 0; - - let next_id: u128 = env - .storage() - .instance() - .get(&StorageKey::NextPolicyId) - .unwrap_or(1); - - for pid in 1..next_id { - if let Some(policy) = env - .storage() - .persistent() - .get::<_, Policy>(&StorageKey::Policy(pid)) - { - if policy.product_id == product_id { - total_policies += 1; - if policy.status == PolicyStatus::Active { - active_policies += 1; - } - total_coverage = total_coverage.saturating_add(policy.coverage_amount); - total_premium_collected = - total_premium_collected.saturating_add(policy.premium_paid); - } - } - } - - ProductStats { - product_id, - total_policies, - active_policies, - total_coverage, - total_premium_collected, - } - } - - /// Return a paginated slice of policy IDs owned by `user`. `offset` is the zero-based - /// start index; `limit` caps the number of IDs returned. - pub fn get_user_policies(env: Env, user: Address, offset: u32, limit: u32) -> Vec { - let all: Vec = env - .storage() - .persistent() - .get(&StorageKey::UserPolicies(user)) - .unwrap_or_else(|| Vec::new(&env)); - - let mut paginated = Vec::new(&env); - let len = all.len(); - if offset >= len { - return paginated; - } - let end = (offset + limit).min(len); - for i in offset..end { - paginated.push_back(all.get_unchecked(i)); - } - paginated - } - - /// Return the IDs of all products whose status is `Active`. - pub fn get_active_products(env: Env) -> Vec { - env.storage() - .instance() - .get(&StorageKey::ActiveProducts) - .unwrap_or_else(|| Vec::new(&env)) - } - - /// Return the USDC balance held by this contract (7-decimal stroops). - pub fn get_contract_balance(env: Env) -> i128 { - let usdc: Address = env - .storage() - .instance() - .get(&StorageKey::UsdcToken) - .unwrap_or_else(|| panic_with_error!(&env, Error::NotInitialized)); - token::Client::new(&env, &usdc).balance(&env.current_contract_address()) - } - - /// Return the current admin address. Panics with `NotInitialized` if not set up. - pub fn get_admin(env: Env) -> Address { - env.storage() - .instance() - .get(&StorageKey::Admin) - .unwrap_or_else(|| panic_with_error!(&env, Error::NotInitialized)) - } - - pub fn get_pending_admin_since(env: Env) -> u64 { - env.storage() - .instance() - .get(&StorageKey::PendingAdminSince) - .unwrap_or(0) - } - - /// Return the configured oracle verifier contract address. - pub fn get_oracle(env: Env) -> Address { - env.storage() - .instance() - .get(&StorageKey::OracleAddress) - .unwrap_or_else(|| panic_with_error!(&env, Error::NotInitialized)) - } - - /// Return `true` if the contract is currently in emergency-pause mode. - pub fn is_paused(env: Env) -> bool { - env.storage() - .instance() - .get(&StorageKey::Paused) - .unwrap_or(false) - } - - /// 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 configured maximum number of non-deprecated products per risk pool/category. - pub fn get_max_products_per_pool(env: Env) -> u32 { - env.storage() - .instance() - .get(&StorageKey::MaxProductsPerPool) - .unwrap_or(DEFAULT_MAX_PRODUCTS_PER_POOL) - } - - /// Return the current non-deprecated product count for a pool/category. - pub fn get_pool_product_count(env: Env, category: Symbol) -> u32 { - env.storage() - .persistent() - .get(&StorageKey::PoolProductCount(category)) - .unwrap_or(0) - } - - // ── Category Registry (issue #682) ──────────────────────────────────────── - - /// Admin-only: register a new product category in the protocol category registry. - pub fn register_category(env: Env, admin: Address, category: Symbol) { - Self::require_admin(&env, &admin); - - let key = StorageKey::RegisteredCategory(category.clone()); - if !env.storage().persistent().get(&key).unwrap_or(false) { - env.storage().persistent().set(&key, &true); - Self::extend_to_max(&env, &key); - - let mut categories: Vec = env - .storage() - .instance() - .get(&StorageKey::CategoryRegistry) - .unwrap_or_else(|| Vec::new(&env)); - categories.push_back(category.clone()); - env.storage().instance().set(&StorageKey::CategoryRegistry, &categories); - - env.events().publish( - (Symbol::new(&env, "category_registered"),), - CategoryRegistered { category }, - ); - } - } - - /// Admin-only: unregister an existing product category. - pub fn unregister_category(env: Env, admin: Address, category: Symbol) { - Self::require_admin(&env, &admin); - - let key = StorageKey::RegisteredCategory(category.clone()); - if !env.storage().persistent().get(&key).unwrap_or(false) { - panic_with_error!(&env, Error::InvalidCategory); - } - - env.storage().persistent().set(&key, &false); - Self::extend_to_max(&env, &key); - - let categories: Vec = env - .storage() - .instance() - .get(&StorageKey::CategoryRegistry) - .unwrap_or_else(|| Vec::new(&env)); - let mut updated: Vec = Vec::new(&env); - for i in 0..categories.len() { - let cat = categories.get_unchecked(i); - if cat != category { - updated.push_back(cat); - } - } - env.storage().instance().set(&StorageKey::CategoryRegistry, &updated); - - env.events().publish( - (Symbol::new(&env, "category_unregistered"),), - CategoryUnregistered { category }, - ); - } - - /// Return true if `category` is currently registered. - pub fn is_category_registered(env: Env, category: Symbol) -> bool { - env.storage() - .persistent() - .get(&StorageKey::RegisteredCategory(category)) - .unwrap_or(false) - } - - /// Return all currently registered product categories. - pub fn get_registered_categories(env: Env) -> Vec { - env.storage() - .instance() - .get(&StorageKey::CategoryRegistry) - .unwrap_or_else(|| Vec::new(&env)) - } - - /// Admin-only: register a new oracle data type (issue #745). - pub fn register_oracle_data_type(env: Env, admin: Address, data_type: Symbol) { - Self::require_admin(&env, &admin); - - let key = StorageKey::RegisteredOracleDataType(data_type.clone()); - if !env.storage().persistent().get(&key).unwrap_or(false) { - env.storage().persistent().set(&key, &true); - Self::extend_to_max(&env, &key); - - let mut types: Vec = env - .storage() - .instance() - .get(&StorageKey::OracleDataTypeRegistry) - .unwrap_or_else(|| Vec::new(&env)); - types.push_back(data_type); - env.storage().instance().set(&StorageKey::OracleDataTypeRegistry, &types); - } - } - - /// Return true if `data_type` is currently registered (issue #745). - pub fn is_oracle_data_type_registered(env: Env, data_type: Symbol) -> bool { - env.storage() - .persistent() - .get(&StorageKey::RegisteredOracleDataType(data_type.clone())) - .unwrap_or_else(|| { - data_type == symbol_short!("weather") - || data_type == symbol_short!("flight") - || data_type == symbol_short!("disaster") - || data_type == symbol_short!("onchain") - || data_type == symbol_short!("defi") - || data_type == symbol_short!("health") - }) - } - - /// Return all currently registered oracle data types (issue #745). - pub fn get_registered_oracle_data_types(env: Env) -> Vec { - env.storage() - .instance() - .get(&StorageKey::OracleDataTypeRegistry) - .unwrap_or_else(|| { - soroban_sdk::vec![ - &env, - symbol_short!("weather"), - symbol_short!("flight"), - symbol_short!("disaster"), - symbol_short!("onchain"), - symbol_short!("defi"), - symbol_short!("health"), - ] - }) - } - - // ── Admin: emergency controls ───────────────────────────────────────────── - - /// Emergency pause — halts buy_policy for all products. - pub fn emergency_pause(env: Env, admin: Address) { - Self::require_admin(&env, &admin); - env.storage().instance().set(&StorageKey::Paused, &true); - } - - pub fn emergency_resume(env: Env, admin: Address) { - Self::require_admin(&env, &admin); - env.storage().instance().set(&StorageKey::Paused, &false); - } - - /// Propose a new admin. Only the current admin can call this. - /// - /// Panics with `AdminTransferPending` while a transfer is already pending: - /// re-proposing would rewrite `PendingAdminSince` and reset the - /// `ADMIN_TRANSFER_TIMELOCK`, letting the current admin keep a transfer - /// perpetually un-acceptable (issue #457). - pub fn propose_new_admin(env: Env, admin: Address, new_admin: Address) { - Self::require_admin(&env, &admin); - // Issue #457: reject a fresh proposal while one is already pending — - // the armed transfer must complete (be accepted) before another can - // be made, so the timelock clock can never be reset by re-proposing. - let pending: Option
= env.storage().instance() - .get(&StorageKey::PendingAdmin) - .unwrap_or(None); - if pending.is_some() { - panic_with_error!(&env, Error::AdminTransferPending); - } - // Store the proposed admin and arm the timelock (issue #356). - env.storage() - .instance() - .set(&StorageKey::PendingAdmin, &new_admin); - env.storage() - .instance() - .set(&StorageKey::PendingAdminSince, &env.ledger().timestamp()); - } - - /// Cancel a pending admin transfer during the timelock window (#692). - pub fn cancel_admin_transfer(env: Env, admin: Address) { - Self::require_admin(&env, &admin); - env.storage().instance().remove(&StorageKey::PendingAdmin); - env.storage().instance().remove(&StorageKey::PendingAdminSince); - } - - /// Accept the proposed admin. Only the proposed admin can call this, and - /// only once `ADMIN_TRANSFER_TIMELOCK` has elapsed since the transfer was - /// proposed (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 pending_admin != 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 }, - ); - } - - /// Admin-only: configure the maximum non-deprecated products per pool/category. - pub fn set_max_products_per_pool(env: Env, admin: Address, max_products: u32) { - Self::require_admin(&env, &admin); - if max_products == 0 { - panic_with_error!(&env, Error::InvalidMaxProducts); - } - env.storage() - .instance() - .set(&StorageKey::MaxProductsPerPool, &max_products); - env.events() - .publish((Symbol::new(&env, "max_products_updated"),), max_products); - } - - /// Set how long before `end_time` a policy counts as expiring soon. - /// - /// The window must be non-zero: a zero window would make the warning fire - /// at the same instant cover lapses, which is the situation this mechanism - /// exists to avoid. - pub fn set_expiry_warning_window(env: Env, admin: Address, window: u64) { - Self::require_admin(&env, &admin); - if window == 0 { - panic_with_error!(&env, Error::InvalidWarningWindow); - } - env.storage() - .instance() - .set(&StorageKey::ExpiryWarningWindow, &window); - env.events().publish( - (Symbol::new(&env, "expiry_window_updated"),), - ExpiryWarningWindowUpdated { window }, - ); - } - - /// The configured expiry warning window in seconds (default: 7 days). - pub fn get_expiry_warning_window(env: Env) -> u64 { - Self::expiry_warning_window(&env) - } - - /// Report where a policy sits relative to its own expiry, without panicking - /// on state and without emitting anything. - /// - /// A caller deciding whether to renew, or a keeper deciding whether a - /// notification is worth paying for, needs this as a value rather than as - /// a transaction that might abort. - pub fn get_policy_expiry_info(env: Env, policy_id: u128) -> PolicyExpiryInfo { - let policy: Policy = Self::load_policy(&env, policy_id); - let now = env.ledger().timestamp(); - let window = Self::expiry_warning_window(&env); - - let seconds_remaining = policy.end_time.saturating_sub(now); - let state = if policy.status != PolicyStatus::Active { - ExpiryState::NotActive - } else if now >= policy.end_time { - ExpiryState::Lapsed - } else if seconds_remaining <= window { - ExpiryState::ExpiringSoon - } else { - ExpiryState::Active - }; - - PolicyExpiryInfo { - policy_id, - state, - end_time: policy.end_time, - seconds_remaining, - warned: env - .storage() - .persistent() - .has(&StorageKey::ExpiryWarned(policy_id)), - } - } - - /// Emit `PolicyExpiringSoon` for a policy that has entered its warning - /// window, so off-chain infrastructure can notify the holder. - /// - /// Permissionless on purpose. The party who most needs the reminder is the - /// holder, and requiring the admin to trigger it would make coverage - /// continuity depend on the admin running a keeper — exactly the kind of - /// silent dependency that leaves users uncovered. - /// - /// Emits at most once per policy: the first successful call records a flag - /// and later calls panic with `NotExpiringSoon`, so a permissionless entry - /// point cannot be used to flood the event log. - /// - /// Panics with `NotExpiringSoon` when the policy is not Active, has not - /// yet entered the window, has already lapsed, or has already been warned. - pub fn notify_policy_expiring(env: Env, policy_id: u128) { - let policy: Policy = Self::load_policy(&env, policy_id); - - if policy.status != PolicyStatus::Active { - panic_with_error!(&env, Error::PolicyNotActive); - } - - let now = env.ledger().timestamp(); - let window = Self::expiry_warning_window(&env); - - // Already lapsed is `expire_policy`'s job, not a warning. - if now >= policy.end_time { - panic_with_error!(&env, Error::NotExpiringSoon); - } - - let seconds_remaining = policy.end_time - now; - if seconds_remaining > window { - panic_with_error!(&env, Error::NotExpiringSoon); - } - - let warned_key = StorageKey::ExpiryWarned(policy_id); - if env.storage().persistent().has(&warned_key) { - panic_with_error!(&env, Error::NotExpiringSoon); - } - - env.storage().persistent().set(&warned_key, &true); - Self::extend_to_max(&env, &warned_key); - - env.events().publish( - (Symbol::new(&env, "policy_expiring_soon"),), - PolicyExpiringSoon { - policy_id, - policyholder: policy.policyholder, - product_id: policy.product_id, - coverage_amount: policy.coverage_amount, - end_time: policy.end_time, - seconds_remaining, - }, - ); - } - - /// Scan one user's policies and emit `PolicyExpiringSoon` for each that has - /// entered its warning window and has not been warned yet. - /// - /// Returns the number of events emitted. Policies that are ineligible are - /// skipped rather than aborting the call — a keeper sweeping a user's book - /// should not lose the whole batch because one policy was already warned. - /// - /// Scans at most `MAX_EXPIRY_SCAN` policies per call to bound the - /// instruction budget of a permissionless entry point. - pub fn notify_expiring_policies(env: Env, user: Address, offset: u32) -> u32 { - let ids: Vec = env - .storage() - .persistent() - .get(&StorageKey::UserPolicies(user)) - .unwrap_or_else(|| Vec::new(&env)); - - let now = env.ledger().timestamp(); - let window = Self::expiry_warning_window(&env); - let mut emitted = 0u32; - let mut scanned = 0u32; - - let mut i = offset; - while i < ids.len() && scanned < MAX_EXPIRY_SCAN { - scanned += 1; - let policy_id = ids.get_unchecked(i); - i += 1; - - let policy: Policy = match env - .storage() - .persistent() - .get(&StorageKey::Policy(policy_id)) - { - Some(p) => p, - None => continue, - }; - - if policy.status != PolicyStatus::Active || now >= policy.end_time { - continue; - } - - let seconds_remaining = policy.end_time - now; - if seconds_remaining > window { - continue; - } - - let warned_key = StorageKey::ExpiryWarned(policy_id); - if env.storage().persistent().has(&warned_key) { - continue; - } - - env.storage().persistent().set(&warned_key, &true); - Self::extend_to_max(&env, &warned_key); - - env.events().publish( - (Symbol::new(&env, "policy_expiring_soon"),), - PolicyExpiringSoon { - policy_id, - policyholder: policy.policyholder, - product_id: policy.product_id, - coverage_amount: policy.coverage_amount, - end_time: policy.end_time, - seconds_remaining, - }, - ); - emitted += 1; - } - - emitted - } - - // ── 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_claims_processor(env: &Env, caller: &Address) { - let cp: Address = env - .storage() - .instance() - .get(&StorageKey::ClaimsProcessor) - .unwrap_or_else(|| panic_with_error!(env, Error::ClaimsProcessorNotSet)); - if *caller != cp { - panic_with_error!(env, Error::Unauthorized); - } - caller.require_auth(); - } - - fn ensure_not_paused(env: &Env) { - if env - .storage() - .instance() - .get::<_, bool>(&StorageKey::Paused) - .unwrap_or(false) - { - panic_with_error!(env, Error::Unauthorized); - } - } - - fn remove_policy_from_user(env: &Env, user: &Address, policy_id: u128) { - let key = StorageKey::UserPolicies(user.clone()); - let mut user_policies: Vec = env - .storage() - .persistent() - .get(&key) - .unwrap_or_else(|| Vec::new(env)); - let mut pos: Option = None; - for i in 0..user_policies.len() { - if user_policies.get_unchecked(i) == policy_id { - pos = Some(i); - break; - } - } - if let Some(i) = pos { - user_policies.remove(i); - env.storage().persistent().set(&key, &user_policies); - Self::extend_to_max(env, &key); - } - } - - fn expiry_warning_window(env: &Env) -> u64 { - env.storage() - .instance() - .get(&StorageKey::ExpiryWarningWindow) - .unwrap_or(DEFAULT_EXPIRY_WARNING_WINDOW) - } - - 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); - } - - fn load_product(env: &Env, id: u128) -> InsuranceProduct { - env.storage() - .persistent() - .get(&StorageKey::Product(id)) - .unwrap_or_else(|| panic_with_error!(env, Error::ProductNotFound)) - } - - fn load_policy(env: &Env, id: u128) -> Policy { - env.storage() - .persistent() - .get(&StorageKey::Policy(id)) - .unwrap_or_else(|| panic_with_error!(env, Error::PolicyNotFound)) - } - - /// Atomically fetch-and-increment the product ID counter. - /// Uses storage().update() to guarantee a single read-modify-write operation, - /// preventing two concurrent ledger entries from reading the same value. - fn next_product_id(env: &Env) -> u128 { - let mut id = 0u128; - env.storage() - .instance() - .update(&StorageKey::NextProductId, |v: Option| { - id = v.unwrap_or(1); - id + 1 - }); - id - } - - fn next_policy_id(env: &Env) -> u128 { - let mut id = 0u128; - env.storage() - .instance() - .update(&StorageKey::NextPolicyId, |v: Option| { - id = v.unwrap_or(1); - id + 1 - }); - id - } - - /// 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. - /// - /// Upgrade state-preservation guarantees (issue #777). - /// - /// Soroban's `env.deployer().update_current_contract_wasm` swaps the - /// contract's code without touching its storage. Every entry in - /// `env.storage().instance()`, `env.storage().persistent()`, and - /// `env.storage().temporary()` survives the swap, so existing policies, - /// products, and pending-oracle-change records remain readable by the - /// new WASM. - /// - /// Policy data (`StorageKey::Policy(id)`, `StorageKey::PolicyIds`, - /// `StorageKey::Product(id)`, `StorageKey::ProductIds`) is persistent - /// and is never removed by this function. Any schema change that - /// requires rewriting existing policies must land as a new migration - /// in `run_migrations` (see `migrate_v1_to_v2` for the pattern) so a - /// freshly-upgraded WASM can read legacy entries. - /// - /// Order of operations is deliberate: - /// 1. Migrations run against the old-WASM storage layout. - /// 2. The stored version is stamped. - /// 3. The WASM is swapped. - /// - /// Running migrations before the swap means the new WASM never - /// observes an un-migrated entry; stamping the version before the - /// swap means a subsequent upgrade sees the correct current_version. - /// - /// Upgrade procedure for operators: - /// - Deploy a new WASM blob to Stellar and record its `BytesN<32>` - /// hash. - /// - Guardians approve the upgrade (if `GuardianThreshold > 0`; - /// mirrors the risk-pool approval flow — admin alone can trigger - /// when guardians are disabled). - /// - Admin calls `upgrade(admin, new_wasm_hash, new_version)` with - /// the next sequential version number. - /// - After the transaction commits, confirm `get_version()` reports - /// the new value and spot-check that `get_policy(id)` is readable - /// for a representative policy id — the three-step order above - /// guarantees it, but a sanity check costs nothing. - 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); - if new_version <= 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() - .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) { - if old_version == 0 || new_version <= old_version || new_version > CURRENT_STORAGE_VERSION { - panic_with_error!(env, Error::InvalidVersion); - } - - let mut version = old_version; - while version < new_version { - match version { - 1 => { - Self::migrate_v1_to_v2(env); - version = 2; - } - 2 => { - Self::migrate_v2_to_v3(env); - version = 3; - } - _ => panic_with_error!(env, Error::InvalidVersion), - } - } - } - - fn migrate_v1_to_v2(env: &Env) { - if !env - .storage() - .instance() - .has(&StorageKey::MaxProductsPerPool) - { - env.storage().instance().set( - &StorageKey::MaxProductsPerPool, - &DEFAULT_MAX_PRODUCTS_PER_POOL, - ); - } - } - - fn migrate_v2_to_v3(_env: &Env) {} - - /// Require that an address is not the zero address (issue #730). - fn require_not_zero_address(env: &Env, address: &Address) { - let zero = Address::from_string(&soroban_sdk::String::from_str( - env, - "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF", - )); - if *address == zero { - panic_with_error!(env, Error::InvalidAddress); - } - } -} - -#[cfg(test)] -mod test; -#[cfg(test)] -mod test_advanced; -#[cfg(test)] -mod test_product_names; -#[cfg(test)] -mod test_category_validation; -#[cfg(test)] -mod test_cooling_off; +//! Parashield Policy Engine +//! +//! Manages insurance products and policies. +//! +//! Flow +//! ───── +//! 1. Admin calls `create_product` to define a new insurance product. +//! 2. User calls `buy_policy` — transfers premium to this contract, +//! and the contract locks coverage USDC from its pool balance. +//! 3. The Claims Processor calls `pay_claim` / `expire_policy` to update +//! policy status after processing. It also performs the USDC transfer. +//! +//! Architecture note on Claimable Balances +//! ───────────────────────────────────────── +//! Stellar's ClaimPredicate cannot encode data-driven conditions +//! (e.g., "rainfall < 50mm"). Therefore this contract acts as the +//! escrow: it holds USDC and the Claims Processor calls `token.transfer` +//! to pay the policyholder when the oracle confirms a trigger. +// Address/state validation must fail with a typed contract error so callers +#![deny(clippy::panic)] +#![no_std] + +use soroban_sdk::{ + contract, contracterror, contractimpl, contracttype, panic_with_error, symbol_short, token, Address, + BytesN, Env, IntoVal, Symbol, SymbolStr, TryFromVal, Vec, +}; + +pub mod types; +pub use types::*; + +// ─── Storage TTL ────────────────────────────────────────────────────────────── + +// Shared protocol constants — single source of truth in parashield-common (issue #342). +use parashield_common::{TTL_THRESHOLD, TTL_EXTEND_TO, ADMIN_TRANSFER_TIMELOCK}; +const DEFAULT_MAX_PRODUCTS_PER_POOL: u32 = 100; +const CURRENT_STORAGE_VERSION: u32 = 3; +const MAX_BATCH_BUY: u32 = 20; +const DEFAULT_EXPIRY_WARNING_WINDOW: u64 = 7 * 24 * 60 * 60; +const MAX_EXPIRY_SCAN: u32 = 50; +/// Upper bound on a product's `coverage_max`. A product with an absurdly high +/// ceiling would let a single policy lock up unbounded pool capital and create +/// unrealistic exposure (issue #616). Matches the per-policy cap enforced in +/// `buy_policy_inner`. +const MAX_COVERAGE_LIMIT: i128 = 1_000_000_000_000; +/// Minimum time a buyer must wait between two policy purchases on the same +/// oracle key. Without it a buyer can stack many policies on one oracle +/// reading in quick succession, e.g. right before a trigger looks likely +/// (issue #671). +const PURCHASE_COOLING_OFF_SECONDS: u64 = 24 * 60 * 60; + +// ─── Storage keys ───────────────────────────────────────────────────────────── + +#[contracttype] +enum StorageKey { + Initialized, + Admin, + UsdcToken, + OracleAddress, + ClaimsProcessor, + /// InsuranceProduct + Product(u128), + /// Policy + Policy(u128), + /// Vec — product IDs for a user + UserPolicies(Address), + /// Vec — all active product IDs + ActiveProducts, + NextProductId, + NextPolicyId, + Paused, + /// Maps (category, oracle_key) -> product_id for uniqueness constraint + ProductKey((Symbol, Symbol)), + PendingAdmin, + /// Ledger timestamp (u64) at which `PendingAdmin` was set, used to enforce + /// `ADMIN_TRANSFER_TIMELOCK` before `accept_admin` succeeds (issue #356). + PendingAdminSince, + MaxProductsPerPool, + PoolProductCount(Symbol), + /// Contract version (u32) for storage migration tracking + Version, + ExpiryWarningWindow, + ExpiryWarned(u128), + /// Maps product name -> product_id, so names stay unique (issue #514). + ProductName(Symbol), + /// Ledger timestamp (u64) of a buyer's last purchase on an oracle key, + /// used to enforce `PURCHASE_COOLING_OFF_SECONDS` (issue #671). + LastPurchase((Address, Symbol)), + /// Independent approver for oracle rotations. + OracleGuardian, + /// Proposed oracle rotation waiting for approval and its timelock. + PendingOracleChange, + /// Maps category Symbol -> bool (whether registered) + RegisteredCategory(Symbol), + /// Vec of all registered categories + CategoryRegistry, + /// Maps oracle_data_type Symbol -> bool (whether registered) (issue #745) + RegisteredOracleDataType(Symbol), + /// Vec of all registered oracle data types (issue #745) + OracleDataTypeRegistry, +} + +// ─── Errors ─────────────────────────────────────────────────────────────────── + +#[contracterror] +#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] +#[repr(u32)] +pub enum Error { + AlreadyInitialized = 1, + NotInitialized = 2, + Unauthorized = 3, + ProductNotFound = 4, + ProductNotActive = 5, + PolicyNotFound = 6, + PolicyNotActive = 7, + CoverageOutOfRange = 8, + DurationTooLong = 9, + InsufficientPool = 10, + AlreadyClaimed = 11, + AlreadyExpired = 12, + InvalidPremiumRate = 13, + InvalidTriggerThreshold = 14, + DuplicateProductKey = 15, + InvalidCoverageRange = 16, + InvalidToken = 17, + ClaimsProcessorNotSet = 18, + InvalidDurationRange = 19, + InvalidOracleKey = 20, + Overflow = 21, + TooManyProducts = 22, + InvalidMaxProducts = 23, + AdminTimelockNotExpired = 24, + InvalidAddress = 25, + InvalidVersion = 26, + /// An admin transfer was proposed while another one is still pending, + /// which would reset the transfer timelock (issue #457). + AdminTransferPending = 27, + EmptyBatch = 29, + BatchTooLarge = 30, + NotExpiringSoon = 31, + InvalidWarningWindow = 32, + ReasonTooLong = 33, + /// A product with this name already exists (issue #514). + DuplicateProductName = 34, + InvalidTriggerCombination = 35, + /// The product category is not one of the supported categories (issue #549). + InvalidCategory = 36, + /// A scheduled policy start time is earlier than the current ledger time (issue #522). + InvalidStartTime = 37, + /// Premium rate cannot be zero. (issue #564) + ZeroPremiumRate = 38, + /// The buyer already bought a policy on this oracle key within the + /// cooling-off period (issue #671). + CoolingOffActive = 39, + OracleChangePending = 40, + NoPendingOracleChange = 41, + OracleTimelockNotExpired = 42, + NotOracleGuardian = 43, + OracleApprovalRequired = 44, + AdminCannotBuyPolicy = 45, + /// Product description / name cannot be empty (issue #755). + EmptyDescription = 46, + /// Product description / name is too short (issue #755). + InvalidDescription = 47, + /// Oracle `data_type` is not registered on the oracle verifier. + UnregisteredOracleDataType = 48, + /// Generic rejected input — malformed, out of range, or wrong type. + InvalidInput = 49, +} + +// SECURITY: 48-hour timelock on critical admin actions (create_product, update_product). +// Prevents an instantly-compromised admin key from immediately deploying malicious products. +// Users have time to detect the compromise and intervene. +const ADMIN_ACTION_TIMELOCK_SECONDS: u64 = 48 * 60 * 60; +/// Oracle substitutions are equally security-sensitive: a replacement can +/// determine every subsequent policy outcome, so users receive 48 hours to +/// inspect an independently approved change before it executes. +pub const ORACLE_CHANGE_TIMELOCK_SECONDS: u64 = 48 * 60 * 60; + +// ─── Contract ───────────────────────────────────────────────────────────────── + +#[contract] +pub struct PolicyEngine; + +#[contractimpl] +impl PolicyEngine { + // ── Lifecycle ──────────────────────────────────────────────────────────── + + /// One-time initialisation. Wires up the USDC token and oracle contracts. + /// Panics with `AlreadyInitialized` on a second call, or `InvalidToken` if + /// `usdc_token` does not expose a `balance` entry-point. + pub fn initialize(env: Env, admin: Address, usdc_token: Address, oracle_address: Address) { + if env.storage().instance().has(&StorageKey::Initialized) { + panic_with_error!(&env, Error::AlreadyInitialized); + } + + 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(); + 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 oracle_str = oracle_address.to_string(); + if oracle_str.len() != 56 { + panic_with_error!(&env, Error::InvalidAddress); + } + let mut oracle_buf = [0u8; 56]; + oracle_str.copy_into_slice(&mut oracle_buf); + if oracle_buf[0] != b'C' { + panic_with_error!(&env, Error::InvalidAddress); + } + // Reject zero address as oracle (issue #730) + Self::require_not_zero_address(&env, &oracle_address); + + 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); + } + + // Verify the token implements transfer — required for premium collection, + // claim payouts, and refunds. A contract with balance but no transfer + // would fail silently on buy_policy or pay_claim. + let transfer_res = env.try_invoke_contract::( + &usdc_token, + &Symbol::new(&env, "transfer"), + soroban_sdk::vec![ + &env, + env.current_contract_address().into_val(&env), + env.current_contract_address().into_val(&env), + 0i128.into_val(&env), + ], + ); + if transfer_res.is_err() { + panic_with_error!(&env, Error::InvalidToken); + } + + 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::OracleAddress, &oracle_address); + env.storage() + .instance() + .set(&StorageKey::NextProductId, &1u128); + env.storage() + .instance() + .set(&StorageKey::NextPolicyId, &1u128); + env.storage() + .instance() + .set(&StorageKey::ActiveProducts, &Vec::::new(&env)); + env.storage().instance().set( + &StorageKey::MaxProductsPerPool, + &DEFAULT_MAX_PRODUCTS_PER_POOL, + ); + // No pending admin initially + env.storage().instance().remove(&StorageKey::PendingAdmin); + + // Initialize default registered categories (#682) + let default_categories = soroban_sdk::vec![ + &env, + symbol_short!("crop"), + symbol_short!("flight"), + symbol_short!("disaster"), + symbol_short!("health"), + symbol_short!("defi"), + ]; + for i in 0..default_categories.len() { + let cat = default_categories.get_unchecked(i); + env.storage() + .persistent() + .set(&StorageKey::RegisteredCategory(cat.clone()), &true); + Self::extend_to_max(&env, &StorageKey::RegisteredCategory(cat)); + } + env.storage() + .instance() + .set(&StorageKey::CategoryRegistry, &default_categories); + + let default_types: Vec = soroban_sdk::vec![ + &env, + symbol_short!("weather"), + symbol_short!("flight"), + symbol_short!("disaster"), + symbol_short!("onchain"), + symbol_short!("defi"), + symbol_short!("health"), + ]; + for i in 0..default_types.len() { + let dt = default_types.get_unchecked(i); + env.storage() + .persistent() + .set(&StorageKey::RegisteredOracleDataType(dt.clone()), &true); + Self::extend_to_max(&env, &StorageKey::RegisteredOracleDataType(dt)); + } + env.storage() + .instance() + .set(&StorageKey::OracleDataTypeRegistry, &default_types); + + env.events().publish( + (Symbol::new(&env, "initialized"),), + Initialized { + admin: admin.clone(), + usdc_token: usdc_token.clone(), + oracle_address: oracle_address.clone(), + }, + ); + } + + /// Set the Claims Processor address. Called once after deploying claims contract. + pub fn set_claims_processor(env: Env, admin: Address, claims_processor: Address) { + Self::require_admin(&env, &admin); + env.storage() + .instance() + .set(&StorageKey::ClaimsProcessor, &claims_processor); + env.events().publish( + (Symbol::new(&env, "claims_processor_updated"),), + ClaimsProcessorUpdated { + claims_processor: claims_processor.clone(), + }, + ); + } + + /// Set the independent guardian that must approve every oracle rotation. + pub fn set_oracle_guardian(env: Env, admin: Address, guardian: Address) { + Self::require_admin(&env, &admin); + if guardian == admin { + panic_with_error!(&env, Error::NotOracleGuardian); + } + env.storage() + .instance() + .set(&StorageKey::OracleGuardian, &guardian); + env.events().publish( + (Symbol::new(&env, "oracle_guardian_updated"),), + guardian, + ); + } + + /// Propose an oracle contract replacement. A proposal cannot be replaced + /// while pending, preventing an admin from resetting its review history. + pub fn propose_oracle_change(env: Env, admin: Address, new_oracle: Address) { + Self::require_admin(&env, &admin); + if env.storage().instance().has(&StorageKey::PendingOracleChange) { + panic_with_error!(&env, Error::OracleChangePending); + } + let oracle_str = new_oracle.to_string(); + if oracle_str.len() != 56 { + panic_with_error!(&env, Error::InvalidAddress); + } + let mut oracle_buf = [0u8; 56]; + oracle_str.copy_into_slice(&mut oracle_buf); + if oracle_buf[0] != b'C' { + panic_with_error!(&env, Error::InvalidAddress); + } + // Reject zero address as oracle (issue #730) + Self::require_not_zero_address(&env, &new_oracle); + + let change = PendingOracleChange { + new_oracle: new_oracle.clone(), + execute_after: env + .ledger() + .timestamp() + .saturating_add(ORACLE_CHANGE_TIMELOCK_SECONDS), + guardian_approved: false, + }; + env.storage() + .instance() + .set(&StorageKey::PendingOracleChange, &change); + env.events().publish( + (Symbol::new(&env, "oracle_change_proposed"),), + change, + ); + } + + /// Approve the pending replacement as the configured oracle guardian. + pub fn approve_oracle_change(env: Env, guardian: Address) { + guardian.require_auth(); + let configured: Address = env + .storage() + .instance() + .get(&StorageKey::OracleGuardian) + .unwrap_or_else(|| panic_with_error!(&env, Error::NotOracleGuardian)); + if guardian != configured { + panic_with_error!(&env, Error::NotOracleGuardian); + } + let mut change: PendingOracleChange = env + .storage() + .instance() + .get(&StorageKey::PendingOracleChange) + .unwrap_or_else(|| panic_with_error!(&env, Error::NoPendingOracleChange)); + change.guardian_approved = true; + env.storage() + .instance() + .set(&StorageKey::PendingOracleChange, &change); + } + + /// Execute an approved oracle replacement once its 48-hour review period + /// has elapsed. + pub fn execute_oracle_change(env: Env, admin: Address) { + Self::require_admin(&env, &admin); + let change: PendingOracleChange = env + .storage() + .instance() + .get(&StorageKey::PendingOracleChange) + .unwrap_or_else(|| panic_with_error!(&env, Error::NoPendingOracleChange)); + if !change.guardian_approved { + panic_with_error!(&env, Error::OracleApprovalRequired); + } + if env.ledger().timestamp() < change.execute_after { + panic_with_error!(&env, Error::OracleTimelockNotExpired); + } + env.storage() + .instance() + .set(&StorageKey::OracleAddress, &change.new_oracle); + env.storage() + .instance() + .remove(&StorageKey::PendingOracleChange); + env.events().publish( + (Symbol::new(&env, "oracle_changed"),), + change.new_oracle, + ); + } + + pub fn get_pending_oracle_change(env: Env) -> Option { + env.storage().instance().get(&StorageKey::PendingOracleChange) + } + + // ── Product Management (admin only) ────────────────────────────────────── + + /// Admin-only: create a new insurance product and return its ID. + /// `params.premium_rate_bps` must be 1-5000 (i.e. 0.01% to 50%); `params.coverage_amount` must be positive. + /// + /// Coverage must describe a real, buyable range: `0 < coverage_min < + /// coverage_max` (issue #567). An inverted or zero-width range would + /// publish a product that `buy_policy` can never accept a policy for, + /// and a non-positive min would sell free cover. + pub fn create_product(env: Env, admin: Address, params: CreateProductParams) -> u128 { + Self::require_admin(&env, &admin); + + // Only registered product categories are accepted (issue #682). + let category = ¶ms.category; + if !Self::is_category_registered(env.clone(), category.clone()) { + panic_with_error!(&env, Error::InvalidCategory); + } + + // Product description / name must not be empty (issue #755). + if params.name == Symbol::new(&env, "") { + panic_with_error!(&env, Error::EmptyDescription); + } + { + let sym_val = params.name.to_symbol_val(); + let sym_str: Result = SymbolStr::try_from_val(&env, &sym_val); + if let Ok(s) = sym_str { + let s_str: &str = s.as_ref(); + let bytes: &[u8] = s_str.as_bytes(); + if bytes.is_empty() { + panic_with_error!(&env, Error::EmptyDescription); + } + if bytes.len() < 3 { + panic_with_error!(&env, Error::InvalidDescription); + } + } + } + + match params.trigger_type { + TriggerType::Binary => { + if params.trigger_comparison != TriggerComparison::Equal { + panic_with_error!(&env, Error::InvalidTriggerCombination); + } + } + TriggerType::Threshold | TriggerType::Parametric => { + if params.trigger_comparison == TriggerComparison::Equal { + panic_with_error!(&env, Error::InvalidTriggerCombination); + } + } + } + + if !Self::is_oracle_data_type_registered(env.clone(), params.oracle_data_type.clone()) { + panic_with_error!(&env, Error::UnregisteredOracleDataType); + } + + // SECURITY FIX: Validate oracle_data_type matches the product's trigger type. + // Mismatched data types would cause claim evaluation to fail or produce incorrect results. + // Binary triggers expect event-based data types (flight, disaster), while + // Threshold/Parametric triggers expect measurement data types (weather, onchain). + match params.trigger_type { + TriggerType::Binary => { + // Binary triggers work with discrete event data + if params.oracle_data_type != symbol_short!("flight") + && params.oracle_data_type != symbol_short!("disaster") + && params.oracle_data_type != symbol_short!("health") + { + panic_with_error!(&env, Error::InvalidTriggerCombination); + } + } + TriggerType::Threshold | TriggerType::Parametric => { + // Threshold/Parametric triggers work with continuous measurement data + if params.oracle_data_type != symbol_short!("weather") + && params.oracle_data_type != symbol_short!("onchain") + && params.oracle_data_type != symbol_short!("defi") + { + panic_with_error!(&env, Error::InvalidTriggerCombination); + } + } + } + + // SECURITY FIX: Validate premium_rate_bps is positive and within acceptable bounds. + // Zero or negative (though u32 prevents negative) premium rates are invalid. + // A zero rate would provide free insurance, which is not economically viable. + if params.premium_rate_bps == 0 { + panic_with_error!(&env, Error::ZeroPremiumRate); + } + // Issue #626: a premium rate above 50% (5_000 bps) is unrealistic for + // insurance. The previous ceiling of 10_000 bps (100%) would let an + // admin configure a product whose premium exceeds the coverage amount + // for any policy longer than ~365 days, which is not a real insurance + // product. 5_000 bps is already well above any legitimate parametric + // or indemnity rate and leaves headroom for high-risk categories. + if params.premium_rate_bps > 5_000 { + panic_with_error!(&env, Error::InvalidPremiumRate); + } + // trigger_threshold must be strictly positive (issue #619) and within + // the 7-decimal fixed-point range used across the protocol + // (1 unit = 0.0000001; max ≈ 1 quadrillion). Zero or negative + // thresholds would make every policy unconditionally payable. + if params.trigger_threshold <= 0 + || params.trigger_threshold > 1_000_000_000_000_000_000_000i128 + { + panic_with_error!(&env, Error::InvalidTriggerThreshold); + } + // Coverage bounds must form a valid, positive range: 0 < min < max (issue #736, #567). + // Rejects free coverage (min <= 0) and inverted or zero-width ranges (coverage_max < coverage_min). + if params.coverage_min <= 0 || params.coverage_max < params.coverage_min || params.coverage_min >= params.coverage_max { + panic_with_error!(&env, Error::InvalidCoverageRange); + } + // Coverage ceiling must be reasonable (issue #616). An unbounded + // coverage_max would let a single policy lock unbounded pool capital + // and create unrealistic exposure. Capped at MAX_COVERAGE_LIMIT to + // match the per-policy limit in buy_policy_inner. + if params.coverage_max > MAX_COVERAGE_LIMIT { + panic_with_error!(&env, Error::CoverageOutOfRange); + } + // max_duration_days must be positive and less than 3650 (up to 10 years) + // Rejects 0-day policies and unrealistically long durations + if params.max_duration_days == 0 || params.max_duration_days > 3650 { + panic_with_error!(&env, Error::InvalidDurationRange); + } + // Oracle keys follow the documented 3..=9 alphanumeric format. + { + let sym_val = params.oracle_key.to_symbol_val(); + let sym_str: Result = SymbolStr::try_from_val(&env, &sym_val); + match sym_str { + Ok(s) => { + let s_str: &str = s.as_ref(); + let bytes: &[u8] = s_str.as_bytes(); + if bytes.len() < 3 || bytes.len() > 9 { + panic_with_error!(&env, Error::InvalidOracleKey); + } + let mut has_alpha = false; + for &b in bytes { + if (b >= b'a' && b <= b'z') || (b >= b'A' && b <= b'Z') { + has_alpha = true; + } else if !(b >= b'0' && b <= b'9') { + panic_with_error!(&env, Error::InvalidOracleKey); + } + } + if !has_alpha { + panic_with_error!(&env, Error::InvalidOracleKey); + } + } + Err(_) => { + panic_with_error!(&env, Error::InvalidOracleKey); + } + } + } + + // Check for duplicate (category, oracle_key) pair + let key = (params.category.clone(), params.oracle_key.clone()); + if env + .storage() + .persistent() + .has(&StorageKey::ProductKey(key.clone())) + { + panic_with_error!(&env, Error::DuplicateProductKey); + } + + // Product names must be unique so users can tell products apart (#514). + let name_key = StorageKey::ProductName(params.name.clone()); + if env.storage().persistent().has(&name_key) { + panic_with_error!(&env, Error::DuplicateProductName); + } + + let pool_count_key = StorageKey::PoolProductCount(params.category.clone()); + let pool_count: u32 = env.storage().persistent().get(&pool_count_key).unwrap_or(0); + let max_products: u32 = env + .storage() + .instance() + .get(&StorageKey::MaxProductsPerPool) + .unwrap_or(DEFAULT_MAX_PRODUCTS_PER_POOL); + if pool_count >= max_products { + panic_with_error!(&env, Error::TooManyProducts); + } + + let id = Self::next_product_id(&env); + let product = InsuranceProduct { + id, + name: params.name, + category: params.category, + oracle_key: params.oracle_key, + expected_token: params.expected_token, + trigger_type: params.trigger_type, + oracle_data_type: params.oracle_data_type, + trigger_threshold: params.trigger_threshold, + trigger_comparison: params.trigger_comparison, + coverage_min: params.coverage_min, + coverage_max: params.coverage_max, + premium_rate_bps: params.premium_rate_bps, + max_duration_days: params.max_duration_days, + status: ProductStatus::Active, + created_at: env.ledger().timestamp(), + }; + env.storage() + .persistent() + .set(&StorageKey::Product(id), &product); + env.storage().persistent().extend_ttl( + &StorageKey::Product(id), + TTL_THRESHOLD, + TTL_EXTEND_TO, + ); + + // Store the (category, oracle_key) -> product_id mapping for uniqueness + env.storage() + .persistent() + .set(&StorageKey::ProductKey(key.clone()), &id); + env.storage().persistent().extend_ttl( + &StorageKey::ProductKey(key), + TTL_THRESHOLD, + TTL_EXTEND_TO, + ); + + env.storage().persistent().set(&name_key, &id); + env.storage() + .persistent() + .extend_ttl(&name_key, TTL_THRESHOLD, TTL_EXTEND_TO); + + let mut products: Vec = env + .storage() + .instance() + .get(&StorageKey::ActiveProducts) + .unwrap_or_else(|| Vec::new(&env)); + products.push_back(id); + env.storage() + .instance() + .set(&StorageKey::ActiveProducts, &products); + env.storage() + .persistent() + .set(&pool_count_key, &(pool_count + 1)); + env.storage() + .persistent() + .extend_ttl(&pool_count_key, TTL_THRESHOLD, TTL_EXTEND_TO); + + env.events().publish( + (Symbol::new(&env, "product_created"),), + ProductCreated { + product_id: id, + name: product.name.clone(), + category: product.category.clone(), + premium_rate_bps: product.premium_rate_bps, + }, + ); + id + } + + /// Admin-only: suspend sales for a product without deleting it. + /// The product is removed from the active-products list; existing policies are unaffected. + pub fn pause_product(env: Env, admin: Address, product_id: u128) { + Self::require_admin(&env, &admin); + let mut product: InsuranceProduct = Self::load_product(&env, product_id); + product.status = ProductStatus::Paused; + env.storage() + .persistent() + .set(&StorageKey::Product(product_id), &product); + + let mut products: Vec = env + .storage() + .instance() + .get(&StorageKey::ActiveProducts) + .unwrap_or_else(|| Vec::new(&env)); + let mut idx: Option = None; + for i in 0..products.len() { + if products.get_unchecked(i) == product_id { + idx = Some(i); + break; + } + } + if let Some(i) = idx { + products.remove(i); + env.storage() + .instance() + .set(&StorageKey::ActiveProducts, &products); + } + + env.events().publish( + (Symbol::new(&env, "product_paused"),), + ProductPaused { product_id }, + ); + } + + /// Admin-only: permanently retire a product. It is removed from the active list and its + /// `(category, oracle_key)` slot is freed so another product may reuse it. + /// + /// The name index is released too (issue #570). Freeing the name matters + /// as much as freeing the oracle key: `create_product` rejects a name that + /// is still mapped, so leaving the uniqueness index behind would outlive + /// the product it describes and the admin could never re-launch the same + /// product line under the same name. It would also silently reserve a + /// name that no live product answers to, which is the behaviour users see + /// as "that name is taken" for a product that no longer exists. + /// + /// The `(category, oracle_key)` slot and the pool's product count keep + /// their existing decrement behaviour, so retiring a product never leaves + /// the pool's accounting or a live product's key untouched. + pub fn deprecate_product(env: Env, admin: Address, product_id: u128) { + Self::require_admin(&env, &admin); + let mut product: InsuranceProduct = Self::load_product(&env, product_id); + product.status = ProductStatus::Deprecated; + env.storage() + .persistent() + .set(&StorageKey::Product(product_id), &product); + + // Remove from the ActiveProducts list on deprecation + let mut products: Vec = env + .storage() + .instance() + .get(&StorageKey::ActiveProducts) + .unwrap_or_else(|| Vec::new(&env)); + let mut idx: Option = None; + for i in 0..products.len() { + if products.get_unchecked(i) == product_id { + idx = Some(i); + break; + } + } + if let Some(i) = idx { + products.remove(i); + env.storage() + .instance() + .set(&StorageKey::ActiveProducts, &products); + } + + // Remove the (category, oracle_key) mapping to allow reuse of the key + let pool_count_key = StorageKey::PoolProductCount(product.category.clone()); + let pool_count: u32 = env.storage().persistent().get(&pool_count_key).unwrap_or(0); + if pool_count > 0 { + env.storage() + .persistent() + .set(&pool_count_key, &(pool_count - 1)); + env.storage() + .persistent() + .extend_ttl(&pool_count_key, TTL_THRESHOLD, TTL_EXTEND_TO); + } + + // Release the name so a successor product can take it (issue #570). + // Only remove the index entry when it still points at *this* product: + // a stale mapping left by an earlier product must not be dropped just + // because a different product happened to share the name. + let name_key = StorageKey::ProductName(product.name.clone()); + if env.storage().persistent().get::<_, u128>(&name_key) == Some(product_id) { + env.storage().persistent().remove(&name_key); + } + + let key = (product.category, product.oracle_key); + env.storage() + .persistent() + .remove(&StorageKey::ProductKey(key)); + } + + // ── Policy Lifecycle ────────────────────────────────────────────────────── + + /// Buy an insurance policy. + /// + /// The buyer must have approved this contract to spend `premium` USDC + /// (or the transaction must include the token transfer auth). + /// Premium = coverage_amount * premium_rate_bps / 10_000. + /// + /// `oracle_key` is the specific measurement key to watch, e.g. + /// `symbol_short!("kis2606")` for Kisumu June 2026 rainfall. + pub fn buy_policy( + env: Env, + buyer: Address, + product_id: u128, + coverage_amount: i128, + duration_days: u32, + oracle_key: Symbol, + payment_token: Address, + ) -> u128 { + buyer.require_auth(); + Self::ensure_not_paused(&env); + let now = env.ledger().timestamp(); + Self::buy_policy_inner(&env, &buyer, product_id, coverage_amount, duration_days, oracle_key, now, &payment_token) + } + + /// Buy an insurance policy that starts at `start_time` instead of now. + /// + /// `start_time` must be `>= ` the current ledger timestamp; a start in the + /// past would let a buyer purchase cover for a period that has already + /// happened (issue #522). The policy runs from `start_time` for + /// `duration_days`. Claims cannot be filed before `start_time`. + pub fn buy_policy_scheduled( + env: Env, + buyer: Address, + product_id: u128, + coverage_amount: i128, + duration_days: u32, + oracle_key: Symbol, + start_time: u64, + payment_token: Address, + ) -> u128 { + buyer.require_auth(); + Self::ensure_not_paused(&env); + Self::buy_policy_inner(&env, &buyer, product_id, coverage_amount, duration_days, oracle_key, start_time, &payment_token) + } + + /// Buy an insurance policy asserting that the premium rate matches product bounds (issue #741). + pub fn buy_policy_with_rate( + env: Env, + buyer: Address, + product_id: u128, + coverage_amount: i128, + duration_days: u32, + oracle_key: Symbol, + payment_token: Address, + rate_bps: u32, + ) -> u128 { + buyer.require_auth(); + Self::ensure_not_paused(&env); + let product: InsuranceProduct = Self::load_product(&env, product_id); + if rate_bps != product.premium_rate_bps || rate_bps == 0 || rate_bps > 5_000 { + panic_with_error!(&env, Error::InvalidPremiumRate); + } + let now = env.ledger().timestamp(); + Self::buy_policy_inner(&env, &buyer, product_id, coverage_amount, duration_days, oracle_key, now, &payment_token) + } + + /// Buy an insurance policy asserting that the trigger threshold matches product bounds (issue #773). + /// + /// Rejects thresholds that do not match the product's configured threshold or are out of bounds (<= 0 or > 10^21). + pub fn buy_policy_with_threshold( + env: Env, + buyer: Address, + product_id: u128, + coverage_amount: i128, + duration_days: u32, + oracle_key: Symbol, + payment_token: Address, + trigger_threshold: i128, + ) -> u128 { + buyer.require_auth(); + Self::ensure_not_paused(&env); + let product: InsuranceProduct = Self::load_product(&env, product_id); + if trigger_threshold != product.trigger_threshold + || trigger_threshold <= 0 + || trigger_threshold > 1_000_000_000_000_000_000_000i128 + { + panic_with_error!(&env, Error::InvalidTriggerThreshold); + } + let now = env.ledger().timestamp(); + Self::buy_policy_inner(&env, &buyer, product_id, coverage_amount, duration_days, oracle_key, now, &payment_token) + } + + /// Buy multiple policies in a single atomic transaction. + /// + /// Up to `MAX_BATCH_BUY` policies may be purchased at once. If any single + /// purchase fails (e.g. invalid duration, paused product), the entire batch + /// reverts. + pub fn batch_buy_policy(env: Env, buyer: Address, items: Vec) -> Vec { + buyer.require_auth(); + Self::ensure_not_paused(&env); + + let n = items.len(); + if n == 0 { + panic_with_error!(&env, Error::EmptyBatch); + } + if n > MAX_BATCH_BUY { + panic_with_error!(&env, Error::BatchTooLarge); + } + + let now = env.ledger().timestamp(); + let mut ids = Vec::new(&env); + for item in items.iter() { + ids.push_back(Self::buy_policy_inner( + &env, + &buyer, + item.product_id, + item.coverage_amount, + item.duration_days, + item.oracle_key, + now, + &item.payment_token, + )); + } + ids + } + + fn buy_policy_inner( + env: &Env, + buyer: &Address, + product_id: u128, + coverage_amount: i128, + duration_days: u32, + oracle_key: Symbol, + start_time: u64, + payment_token: &Address, + ) -> u128 { + // Issue #522: never allow a backdated start. + if start_time < env.ledger().timestamp() { + panic_with_error!(env, Error::InvalidStartTime); + } + + // SECURITY FIX: Validate oracle_key length to prevent excessively long keys + // that could cause issues with oracle lookups. Backend validates max 32 chars, + // but direct contract calls could bypass that. Enforce the same limit on-chain. + { + let sym_val = oracle_key.to_symbol_val(); + let sym_str: Result = soroban_sdk::SymbolStr::try_from_val(env, &sym_val); + match sym_str { + Ok(s) => { + let s_str: &str = s.as_ref(); + let bytes: &[u8] = s_str.as_bytes(); + // Reject oracle keys longer than 32 characters + if bytes.len() > 32 { + panic_with_error!(env, Error::InvalidOracleKey); + } + } + Err(_) => { + panic_with_error!(env, Error::InvalidOracleKey); + } + } + } + // The admin controls product configuration and policy settlement + // wiring, so allowing them to be a policyholder creates a direct + // self-dealing path. + let admin: Address = env + .storage() + .instance() + .get(&StorageKey::Admin) + .unwrap_or_else(|| panic_with_error!(env, Error::NotInitialized)); + if *buyer == admin { + panic_with_error!(env, Error::AdminCannotBuyPolicy); + } + // Issue #671: enforce a cooling-off period between purchases by the + // same buyer on the same oracle key. Measured from the ledger time of + // the previous purchase, not its scheduled start, so scheduling + // policies into the future does not bypass it. + let purchase_key = StorageKey::LastPurchase((buyer.clone(), oracle_key.clone())); + let purchased_at = env.ledger().timestamp(); + if let Some(last) = env.storage().persistent().get::<_, u64>(&purchase_key) { + if purchased_at < last.saturating_add(PURCHASE_COOLING_OFF_SECONDS) { + panic_with_error!(env, Error::CoolingOffActive); + } + } + let product = Self::load_product(env, product_id); + if product.status != ProductStatus::Active { + panic_with_error!(env, Error::ProductNotActive); + } + // Defense-in-depth: reject non-positive coverage before range check + // (#501). A zero or negative amount would pass the range check when + // coverage_min is zero or negative, creating a free or profit-making + // policy that drains the pool. + if coverage_amount <= 0 { + panic_with_error!(&env, Error::CoverageOutOfRange); + } + if coverage_amount < product.coverage_min || coverage_amount > product.coverage_max { + panic_with_error!(env, Error::CoverageOutOfRange); + } + if duration_days == 0 { + panic_with_error!(env, Error::InvalidDurationRange); + } + // Issue #623: validate duration_days against the product ceiling + // explicitly. The computed (end_time - start_time) must never + // exceed max_duration_days * 86_400 seconds. Checking the + // parameter here (before any arithmetic) ensures this holds even + // for scheduled-start policies where start_time != now. + if duration_days > product.max_duration_days { + panic_with_error!(env, Error::DurationTooLong); + } + + if coverage_amount > 1_000_000_000_000 { + panic_with_error!(env, Error::CoverageOutOfRange); + } + + // SECURITY FIX (issue #741): Validate product premium rate is within valid configured bounds (1..=5_000 bps) + if product.premium_rate_bps == 0 || product.premium_rate_bps > 5_000 { + panic_with_error!(env, Error::InvalidPremiumRate); + } + + // SECURITY FIX (issue #773): Validate product trigger threshold is within valid configured bounds. + // Prevents policies that can never trigger or unconditionally trigger. + if product.trigger_threshold <= 0 + || product.trigger_threshold > 1_000_000_000_000_000_000_000i128 + { + panic_with_error!(env, Error::InvalidTriggerThreshold); + } + + // SECURITY FIX: Calculate the required premium based on product parameters. + // This prevents callers from providing arbitrary premium amounts. + let required_premium = coverage_amount + .checked_mul(product.premium_rate_bps as i128) + .and_then(|v| v.checked_mul(duration_days as i128)) + .and_then(|v| v.checked_div(365)) + .and_then(|v| v.checked_div(10_000)) + .unwrap_or_else(|| panic_with_error!(env, Error::CoverageOutOfRange)); + + // SECURITY FIX (issue #719): Reject zero or negative premium amounts to prevent free coverage + if required_premium <= 0 { + panic_with_error!(env, Error::ZeroPremiumRate); + } + + // SECURITY FIX: reject payment if token does not match the product's expected token + if payment_token != &product.expected_token { + panic_with_error!(&env, Error::InvalidToken); + } + + // SECURITY FIX: Transfer the calculated required premium amount, not any caller-provided value. + // This ensures the premium matches the product's premium_rate. + token::Client::new(env, payment_token).transfer(buyer, &env.current_contract_address(), &required_premium); + + let now = env.ledger().timestamp(); + let duration_secs = (duration_days as u64) + .checked_mul(86_400) + .unwrap_or_else(|| panic_with_error!(env, Error::CoverageOutOfRange)); + let end_time = start_time + .checked_add(duration_secs) + .unwrap_or_else(|| panic_with_error!(env, Error::CoverageOutOfRange)); + + // SECURITY FIX: Validate that start_time < end_time to prevent invalid policies. + // Policies with start >= end are meaningless and should be rejected at purchase time. + // This check protects against arithmetic edge cases and ensures policy duration is positive. + if end_time <= start_time { + panic_with_error!(env, Error::InvalidDurationRange); + } + // Issue #623: defense-in-depth -- confirm the computed span does + // not exceed max_duration_days * 86_400 seconds. This catches any + // future code path that might compute duration_secs independently. + let max_duration_secs = (product.max_duration_days as u64) + .checked_mul(86_400) + .unwrap_or(u64::MAX); + if end_time.saturating_sub(start_time) > max_duration_secs + || end_time > start_time.saturating_add(max_duration_secs) + || duration_days > product.max_duration_days + { + panic_with_error!(env, Error::DurationTooLong); + } + let policy_id = Self::next_policy_id(env); + + env.storage().persistent().set(&purchase_key, &purchased_at); + env.storage() + .persistent() + .extend_ttl(&purchase_key, TTL_THRESHOLD, TTL_EXTEND_TO); + + let policy = Policy { + id: policy_id, + product_id, + policyholder: buyer.clone(), + coverage_amount, + premium_paid: required_premium, + oracle_key, + oracle_data_type: product.oracle_data_type, + trigger_threshold: product.trigger_threshold, + trigger_comparison: product.trigger_comparison, + start_time, + end_time, + status: PolicyStatus::Active, + created_at: now, + }; + env.storage() + .persistent() + .set(&StorageKey::Policy(policy_id), &policy); + env.storage().persistent().extend_ttl( + &StorageKey::Policy(policy_id), + TTL_THRESHOLD, + TTL_EXTEND_TO, + ); + + let user_key = StorageKey::UserPolicies(buyer.clone()); + let mut user_policies: Vec = env + .storage() + .persistent() + .get(&user_key) + .unwrap_or_else(|| Vec::new(env)); + user_policies.push_back(policy_id); + env.storage().persistent().set(&user_key, &user_policies); + env.storage() + .persistent() + .extend_ttl(&user_key, TTL_THRESHOLD, TTL_EXTEND_TO); + + env.events().publish( + (Symbol::new(env, "buy_policy"), buyer.clone()), + (policy_id, product_id, coverage_amount, required_premium), + ); + + policy_id + } + + /// Cancel an active policy and refund the premium to the policyholder. + /// Only the policyholder may cancel, and only while the policy is Active. + /// `reason` is validated to be at most 256 bytes to prevent storage exhaustion. + pub fn cancel_policy(env: Env, policyholder: Address, policy_id: u128, reason: soroban_sdk::Bytes) -> i128 { + policyholder.require_auth(); + + // Validate reason length to prevent storage exhaustion from extremely long reasons (issue #724) + if reason.is_empty() { + panic_with_error!(&env, Error::InvalidInput); + } + const MAX_REASON_LENGTH: u32 = 256; + if reason.len() > MAX_REASON_LENGTH { + panic_with_error!(&env, Error::ReasonTooLong); + } + + let mut policy: Policy = Self::load_policy(&env, policy_id); + if policy.policyholder != policyholder { + panic_with_error!(&env, Error::Unauthorized); + } + if policy.status != PolicyStatus::Active { + panic_with_error!(&env, Error::PolicyNotActive); + } + policy.status = PolicyStatus::Cancelled; + env.storage() + .persistent() + .set(&StorageKey::Policy(policy_id), &policy); + Self::remove_policy_from_user(&env, &policyholder, policy_id); + + let now = env.ledger().timestamp(); + let elapsed = now.saturating_sub(policy.start_time); + let total_duration = policy.end_time.saturating_sub(policy.start_time); + let refund = if total_duration == 0 { + policy.premium_paid + } else { + let elapsed_capped = elapsed.min(total_duration); + let earned = policy + .premium_paid + .checked_mul(elapsed_capped as i128) + .and_then(|v| v.checked_div(total_duration as i128)) + .unwrap_or_else(|| panic_with_error!(&env, Error::Overflow)); + policy.premium_paid.saturating_sub(earned) + }; + + let usdc: Address = env + .storage() + .instance() + .get(&StorageKey::UsdcToken) + .unwrap_or_else(|| panic_with_error!(&env, Error::NotInitialized)); + if refund > 0 { + token::Client::new(&env, &usdc).transfer( + &env.current_contract_address(), + &policyholder, + &refund, + ); + } + + env.events().publish( + (Symbol::new(&env, "policy_cancelled"),), + PolicyCancelled { + policy_id, + policyholder: policyholder.clone(), + refund_amount: refund, + }, + ); + refund + } + + /// Move an active policy to a new policyholder. Requires auth from both parties. + pub fn transfer_policy(env: Env, from: Address, to: Address, policy_id: u128) { + from.require_auth(); + to.require_auth(); + Self::ensure_not_paused(&env); + + let to_str = to.to_string(); + if to_str.len() != 56 { + panic_with_error!(&env, Error::InvalidAddress); + } + let mut to_buf = [0u8; 56]; + to_str.copy_into_slice(&mut to_buf); + if to_buf[0] != b'G' && to_buf[0] != b'C' { + panic_with_error!(&env, Error::InvalidAddress); + } + + let mut policy: Policy = Self::load_policy(&env, policy_id); + if policy.policyholder != from { + panic_with_error!(&env, Error::Unauthorized); + } + if policy.status != PolicyStatus::Active { + panic_with_error!(&env, Error::PolicyNotActive); + } + if from == to { + return; + } + + policy.policyholder = to.clone(); + env.storage() + .persistent() + .set(&StorageKey::Policy(policy_id), &policy); + env.storage().persistent().extend_ttl( + &StorageKey::Policy(policy_id), + TTL_THRESHOLD, + TTL_EXTEND_TO, + ); + + Self::remove_policy_from_user(&env, &from, policy_id); + let to_key = StorageKey::UserPolicies(to.clone()); + let mut to_policies: Vec = env + .storage() + .persistent() + .get(&to_key) + .unwrap_or_else(|| Vec::new(&env)); + to_policies.push_back(policy_id); + env.storage().persistent().set(&to_key, &to_policies); + env.storage() + .persistent() + .extend_ttl(&to_key, TTL_THRESHOLD, TTL_EXTEND_TO); + + env.events().publish( + (Symbol::new(&env, "policy_transferred"),), + PolicyTransferred { + policy_id, + from, + to, + }, + ); + } + + // ── Status updates (called by Claims Processor) ────────────────────────── + + /// Mark a policy as Claimed and pay coverage to the policyholder. + /// Only callable by the registered Claims Processor. + pub fn pay_claim(env: Env, caller: Address, policy_id: u128) { + Self::require_claims_processor(&env, &caller); + let mut policy: Policy = Self::load_policy(&env, policy_id); + match policy.status { + PolicyStatus::Claimed => panic_with_error!(&env, Error::AlreadyClaimed), + PolicyStatus::Expired => panic_with_error!(&env, Error::AlreadyExpired), + PolicyStatus::Cancelled => panic_with_error!(&env, Error::PolicyNotActive), + PolicyStatus::Active => {} + } + let usdc: Address = env + .storage() + .instance() + .get(&StorageKey::UsdcToken) + .unwrap_or_else(|| panic_with_error!(&env, Error::NotInitialized)); + let token_client = token::Client::new(&env, &usdc); + match token_client.try_transfer( + &env.current_contract_address(), + &policy.policyholder, + &policy.coverage_amount, + ) { + Ok(Ok(())) => {} + _ => { + panic_with_error!(&env, Error::InsufficientPool); + } + } + + policy.status = PolicyStatus::Claimed; + env.storage() + .persistent() + .set(&StorageKey::Policy(policy_id), &policy); + Self::remove_policy_from_user(&env, &policy.policyholder, policy_id); + + env.events().publish( + (Symbol::new(&env, "claim_paid"), policy_id), + (policy.policyholder.clone(), policy.coverage_amount), + ); + } + + /// Mark a policy as Expired (trigger not met before end_time). + /// Premium remains in the contract as earned pool revenue. + pub fn expire_policy(env: Env, caller: Address, policy_id: u128) { + Self::require_claims_processor(&env, &caller); + let mut policy: Policy = Self::load_policy(&env, policy_id); + match policy.status { + PolicyStatus::Claimed => panic_with_error!(&env, Error::AlreadyClaimed), + PolicyStatus::Expired => panic_with_error!(&env, Error::AlreadyExpired), + PolicyStatus::Cancelled => panic_with_error!(&env, Error::PolicyNotActive), + PolicyStatus::Active => {} + } + policy.status = PolicyStatus::Expired; + env.storage() + .persistent() + .set(&StorageKey::Policy(policy_id), &policy); + Self::remove_policy_from_user(&env, &policy.policyholder, policy_id); + + let timestamp = env.ledger().timestamp(); + env.events().publish( + (Symbol::new(&env, "policy_expired"),), + PolicyExpired { policy_id, timestamp }, + ); + } + + // ── Queries ─────────────────────────────────────────────────────────────── + + /// Return the `InsuranceProduct` for the given ID. Panics if the product does not exist. + pub fn get_product(env: Env, product_id: u128) -> InsuranceProduct { + Self::load_product(&env, product_id) + } + + /// Return the `Policy` for the given ID. Panics if the policy does not exist. + pub fn get_policy(env: Env, policy_id: u128) -> Policy { + Self::load_policy(&env, policy_id) + } + + /// Return aggregated statistics for a product: total policies, active count, + /// total coverage, and total premiums collected. Returns zeros if the product + /// does not exist or has no policies. + pub fn get_product_stats(env: Env, product_id: u128) -> ProductStats { + let _product = match env + .storage() + .persistent() + .get::<_, InsuranceProduct>(&StorageKey::Product(product_id)) + { + Some(p) => p, + None => { + return ProductStats { + product_id, + total_policies: 0, + active_policies: 0, + total_coverage: 0, + total_premium_collected: 0, + } + } + }; + + let mut total_policies: u32 = 0; + let mut active_policies: u32 = 0; + let mut total_coverage: i128 = 0; + let mut total_premium_collected: i128 = 0; + + let next_id: u128 = env + .storage() + .instance() + .get(&StorageKey::NextPolicyId) + .unwrap_or(1); + + for pid in 1..next_id { + if let Some(policy) = env + .storage() + .persistent() + .get::<_, Policy>(&StorageKey::Policy(pid)) + { + if policy.product_id == product_id { + total_policies += 1; + if policy.status == PolicyStatus::Active { + active_policies += 1; + } + total_coverage = total_coverage.saturating_add(policy.coverage_amount); + total_premium_collected = + total_premium_collected.saturating_add(policy.premium_paid); + } + } + } + + ProductStats { + product_id, + total_policies, + active_policies, + total_coverage, + total_premium_collected, + } + } + + /// Return a paginated slice of policy IDs owned by `user`. `offset` is the zero-based + /// start index; `limit` caps the number of IDs returned. + pub fn get_user_policies(env: Env, user: Address, offset: u32, limit: u32) -> Vec { + let all: Vec = env + .storage() + .persistent() + .get(&StorageKey::UserPolicies(user)) + .unwrap_or_else(|| Vec::new(&env)); + + let mut paginated = Vec::new(&env); + let len = all.len(); + if offset >= len { + return paginated; + } + let end = (offset + limit).min(len); + for i in offset..end { + paginated.push_back(all.get_unchecked(i)); + } + paginated + } + + /// Return the IDs of all products whose status is `Active`. + pub fn get_active_products(env: Env) -> Vec { + env.storage() + .instance() + .get(&StorageKey::ActiveProducts) + .unwrap_or_else(|| Vec::new(&env)) + } + + /// Return the USDC balance held by this contract (7-decimal stroops). + pub fn get_contract_balance(env: Env) -> i128 { + let usdc: Address = env + .storage() + .instance() + .get(&StorageKey::UsdcToken) + .unwrap_or_else(|| panic_with_error!(&env, Error::NotInitialized)); + token::Client::new(&env, &usdc).balance(&env.current_contract_address()) + } + + /// Return the current admin address. Panics with `NotInitialized` if not set up. + pub fn get_admin(env: Env) -> Address { + env.storage() + .instance() + .get(&StorageKey::Admin) + .unwrap_or_else(|| panic_with_error!(&env, Error::NotInitialized)) + } + + pub fn get_pending_admin_since(env: Env) -> u64 { + env.storage() + .instance() + .get(&StorageKey::PendingAdminSince) + .unwrap_or(0) + } + + /// Return the configured oracle verifier contract address. + pub fn get_oracle(env: Env) -> Address { + env.storage() + .instance() + .get(&StorageKey::OracleAddress) + .unwrap_or_else(|| panic_with_error!(&env, Error::NotInitialized)) + } + + /// Return `true` if the contract is currently in emergency-pause mode. + pub fn is_paused(env: Env) -> bool { + env.storage() + .instance() + .get(&StorageKey::Paused) + .unwrap_or(false) + } + + /// 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 configured maximum number of non-deprecated products per risk pool/category. + pub fn get_max_products_per_pool(env: Env) -> u32 { + env.storage() + .instance() + .get(&StorageKey::MaxProductsPerPool) + .unwrap_or(DEFAULT_MAX_PRODUCTS_PER_POOL) + } + + /// Return the current non-deprecated product count for a pool/category. + pub fn get_pool_product_count(env: Env, category: Symbol) -> u32 { + env.storage() + .persistent() + .get(&StorageKey::PoolProductCount(category)) + .unwrap_or(0) + } + + // ── Category Registry (issue #682) ──────────────────────────────────────── + + /// Admin-only: register a new product category in the protocol category registry. + pub fn register_category(env: Env, admin: Address, category: Symbol) { + Self::require_admin(&env, &admin); + + let key = StorageKey::RegisteredCategory(category.clone()); + if !env.storage().persistent().get(&key).unwrap_or(false) { + env.storage().persistent().set(&key, &true); + Self::extend_to_max(&env, &key); + + let mut categories: Vec = env + .storage() + .instance() + .get(&StorageKey::CategoryRegistry) + .unwrap_or_else(|| Vec::new(&env)); + categories.push_back(category.clone()); + env.storage().instance().set(&StorageKey::CategoryRegistry, &categories); + + env.events().publish( + (Symbol::new(&env, "category_registered"),), + CategoryRegistered { category }, + ); + } + } + + /// Admin-only: unregister an existing product category. + pub fn unregister_category(env: Env, admin: Address, category: Symbol) { + Self::require_admin(&env, &admin); + + let key = StorageKey::RegisteredCategory(category.clone()); + if !env.storage().persistent().get(&key).unwrap_or(false) { + panic_with_error!(&env, Error::InvalidCategory); + } + + env.storage().persistent().set(&key, &false); + Self::extend_to_max(&env, &key); + + let categories: Vec = env + .storage() + .instance() + .get(&StorageKey::CategoryRegistry) + .unwrap_or_else(|| Vec::new(&env)); + let mut updated: Vec = Vec::new(&env); + for i in 0..categories.len() { + let cat = categories.get_unchecked(i); + if cat != category { + updated.push_back(cat); + } + } + env.storage().instance().set(&StorageKey::CategoryRegistry, &updated); + + env.events().publish( + (Symbol::new(&env, "category_unregistered"),), + CategoryUnregistered { category }, + ); + } + + /// Return true if `category` is currently registered. + pub fn is_category_registered(env: Env, category: Symbol) -> bool { + env.storage() + .persistent() + .get(&StorageKey::RegisteredCategory(category)) + .unwrap_or(false) + } + + /// Return all currently registered product categories. + pub fn get_registered_categories(env: Env) -> Vec { + env.storage() + .instance() + .get(&StorageKey::CategoryRegistry) + .unwrap_or_else(|| Vec::new(&env)) + } + + /// Admin-only: register a new oracle data type (issue #745). + pub fn register_oracle_data_type(env: Env, admin: Address, data_type: Symbol) { + Self::require_admin(&env, &admin); + + let key = StorageKey::RegisteredOracleDataType(data_type.clone()); + if !env.storage().persistent().get(&key).unwrap_or(false) { + env.storage().persistent().set(&key, &true); + Self::extend_to_max(&env, &key); + + let mut types: Vec = env + .storage() + .instance() + .get(&StorageKey::OracleDataTypeRegistry) + .unwrap_or_else(|| Vec::new(&env)); + types.push_back(data_type); + env.storage().instance().set(&StorageKey::OracleDataTypeRegistry, &types); + } + } + + /// Return true if `data_type` is currently registered (issue #745). + pub fn is_oracle_data_type_registered(env: Env, data_type: Symbol) -> bool { + env.storage() + .persistent() + .get(&StorageKey::RegisteredOracleDataType(data_type.clone())) + .unwrap_or_else(|| { + data_type == symbol_short!("weather") + || data_type == symbol_short!("flight") + || data_type == symbol_short!("disaster") + || data_type == symbol_short!("onchain") + || data_type == symbol_short!("defi") + || data_type == symbol_short!("health") + }) + } + + /// Return all currently registered oracle data types (issue #745). + pub fn get_registered_oracle_data_types(env: Env) -> Vec { + env.storage() + .instance() + .get(&StorageKey::OracleDataTypeRegistry) + .unwrap_or_else(|| { + soroban_sdk::vec![ + &env, + symbol_short!("weather"), + symbol_short!("flight"), + symbol_short!("disaster"), + symbol_short!("onchain"), + symbol_short!("defi"), + symbol_short!("health"), + ] + }) + } + + // ── Admin: emergency controls ───────────────────────────────────────────── + + /// Emergency pause — halts buy_policy for all products. + pub fn emergency_pause(env: Env, admin: Address) { + Self::require_admin(&env, &admin); + env.storage().instance().set(&StorageKey::Paused, &true); + } + + pub fn emergency_resume(env: Env, admin: Address) { + Self::require_admin(&env, &admin); + env.storage().instance().set(&StorageKey::Paused, &false); + } + + /// Propose a new admin. Only the current admin can call this. + /// + /// Panics with `AdminTransferPending` while a transfer is already pending: + /// re-proposing would rewrite `PendingAdminSince` and reset the + /// `ADMIN_TRANSFER_TIMELOCK`, letting the current admin keep a transfer + /// perpetually un-acceptable (issue #457). + pub fn propose_new_admin(env: Env, admin: Address, new_admin: Address) { + Self::require_admin(&env, &admin); + // Issue #457: reject a fresh proposal while one is already pending — + // the armed transfer must complete (be accepted) before another can + // be made, so the timelock clock can never be reset by re-proposing. + let pending: Option
= env.storage().instance() + .get(&StorageKey::PendingAdmin) + .unwrap_or(None); + if pending.is_some() { + panic_with_error!(&env, Error::AdminTransferPending); + } + // Store the proposed admin and arm the timelock (issue #356). + env.storage() + .instance() + .set(&StorageKey::PendingAdmin, &new_admin); + env.storage() + .instance() + .set(&StorageKey::PendingAdminSince, &env.ledger().timestamp()); + } + + /// Cancel a pending admin transfer during the timelock window (#692). + pub fn cancel_admin_transfer(env: Env, admin: Address) { + Self::require_admin(&env, &admin); + env.storage().instance().remove(&StorageKey::PendingAdmin); + env.storage().instance().remove(&StorageKey::PendingAdminSince); + } + + /// Accept the proposed admin. Only the proposed admin can call this, and + /// only once `ADMIN_TRANSFER_TIMELOCK` has elapsed since the transfer was + /// proposed (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 pending_admin != 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 }, + ); + } + + /// Admin-only: configure the maximum non-deprecated products per pool/category. + pub fn set_max_products_per_pool(env: Env, admin: Address, max_products: u32) { + Self::require_admin(&env, &admin); + if max_products == 0 { + panic_with_error!(&env, Error::InvalidMaxProducts); + } + env.storage() + .instance() + .set(&StorageKey::MaxProductsPerPool, &max_products); + env.events() + .publish((Symbol::new(&env, "max_products_updated"),), max_products); + } + + /// Set how long before `end_time` a policy counts as expiring soon. + /// + /// The window must be non-zero: a zero window would make the warning fire + /// at the same instant cover lapses, which is the situation this mechanism + /// exists to avoid. + pub fn set_expiry_warning_window(env: Env, admin: Address, window: u64) { + Self::require_admin(&env, &admin); + if window == 0 { + panic_with_error!(&env, Error::InvalidWarningWindow); + } + env.storage() + .instance() + .set(&StorageKey::ExpiryWarningWindow, &window); + env.events().publish( + (Symbol::new(&env, "expiry_window_updated"),), + ExpiryWarningWindowUpdated { window }, + ); + } + + /// The configured expiry warning window in seconds (default: 7 days). + pub fn get_expiry_warning_window(env: Env) -> u64 { + Self::expiry_warning_window(&env) + } + + /// Report where a policy sits relative to its own expiry, without panicking + /// on state and without emitting anything. + /// + /// A caller deciding whether to renew, or a keeper deciding whether a + /// notification is worth paying for, needs this as a value rather than as + /// a transaction that might abort. + pub fn get_policy_expiry_info(env: Env, policy_id: u128) -> PolicyExpiryInfo { + let policy: Policy = Self::load_policy(&env, policy_id); + let now = env.ledger().timestamp(); + let window = Self::expiry_warning_window(&env); + + let seconds_remaining = policy.end_time.saturating_sub(now); + let state = if policy.status != PolicyStatus::Active { + ExpiryState::NotActive + } else if now >= policy.end_time { + ExpiryState::Lapsed + } else if seconds_remaining <= window { + ExpiryState::ExpiringSoon + } else { + ExpiryState::Active + }; + + PolicyExpiryInfo { + policy_id, + state, + end_time: policy.end_time, + seconds_remaining, + warned: env + .storage() + .persistent() + .has(&StorageKey::ExpiryWarned(policy_id)), + } + } + + /// Emit `PolicyExpiringSoon` for a policy that has entered its warning + /// window, so off-chain infrastructure can notify the holder. + /// + /// Permissionless on purpose. The party who most needs the reminder is the + /// holder, and requiring the admin to trigger it would make coverage + /// continuity depend on the admin running a keeper — exactly the kind of + /// silent dependency that leaves users uncovered. + /// + /// Emits at most once per policy: the first successful call records a flag + /// and later calls panic with `NotExpiringSoon`, so a permissionless entry + /// point cannot be used to flood the event log. + /// + /// Panics with `NotExpiringSoon` when the policy is not Active, has not + /// yet entered the window, has already lapsed, or has already been warned. + pub fn notify_policy_expiring(env: Env, policy_id: u128) { + let policy: Policy = Self::load_policy(&env, policy_id); + + if policy.status != PolicyStatus::Active { + panic_with_error!(&env, Error::PolicyNotActive); + } + + let now = env.ledger().timestamp(); + let window = Self::expiry_warning_window(&env); + + // Already lapsed is `expire_policy`'s job, not a warning. + if now >= policy.end_time { + panic_with_error!(&env, Error::NotExpiringSoon); + } + + let seconds_remaining = policy.end_time - now; + if seconds_remaining > window { + panic_with_error!(&env, Error::NotExpiringSoon); + } + + let warned_key = StorageKey::ExpiryWarned(policy_id); + if env.storage().persistent().has(&warned_key) { + panic_with_error!(&env, Error::NotExpiringSoon); + } + + env.storage().persistent().set(&warned_key, &true); + Self::extend_to_max(&env, &warned_key); + + env.events().publish( + (Symbol::new(&env, "policy_expiring_soon"),), + PolicyExpiringSoon { + policy_id, + policyholder: policy.policyholder, + product_id: policy.product_id, + coverage_amount: policy.coverage_amount, + end_time: policy.end_time, + seconds_remaining, + }, + ); + } + + /// Scan one user's policies and emit `PolicyExpiringSoon` for each that has + /// entered its warning window and has not been warned yet. + /// + /// Returns the number of events emitted. Policies that are ineligible are + /// skipped rather than aborting the call — a keeper sweeping a user's book + /// should not lose the whole batch because one policy was already warned. + /// + /// Scans at most `MAX_EXPIRY_SCAN` policies per call to bound the + /// instruction budget of a permissionless entry point. + pub fn notify_expiring_policies(env: Env, user: Address, offset: u32) -> u32 { + let ids: Vec = env + .storage() + .persistent() + .get(&StorageKey::UserPolicies(user)) + .unwrap_or_else(|| Vec::new(&env)); + + let now = env.ledger().timestamp(); + let window = Self::expiry_warning_window(&env); + let mut emitted = 0u32; + let mut scanned = 0u32; + + let mut i = offset; + while i < ids.len() && scanned < MAX_EXPIRY_SCAN { + scanned += 1; + let policy_id = ids.get_unchecked(i); + i += 1; + + let policy: Policy = match env + .storage() + .persistent() + .get(&StorageKey::Policy(policy_id)) + { + Some(p) => p, + None => continue, + }; + + if policy.status != PolicyStatus::Active || now >= policy.end_time { + continue; + } + + let seconds_remaining = policy.end_time - now; + if seconds_remaining > window { + continue; + } + + let warned_key = StorageKey::ExpiryWarned(policy_id); + if env.storage().persistent().has(&warned_key) { + continue; + } + + env.storage().persistent().set(&warned_key, &true); + Self::extend_to_max(&env, &warned_key); + + env.events().publish( + (Symbol::new(&env, "policy_expiring_soon"),), + PolicyExpiringSoon { + policy_id, + policyholder: policy.policyholder, + product_id: policy.product_id, + coverage_amount: policy.coverage_amount, + end_time: policy.end_time, + seconds_remaining, + }, + ); + emitted += 1; + } + + emitted + } + + // ── 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_claims_processor(env: &Env, caller: &Address) { + let cp: Address = env + .storage() + .instance() + .get(&StorageKey::ClaimsProcessor) + .unwrap_or_else(|| panic_with_error!(env, Error::ClaimsProcessorNotSet)); + if *caller != cp { + panic_with_error!(env, Error::Unauthorized); + } + caller.require_auth(); + } + + fn ensure_not_paused(env: &Env) { + if env + .storage() + .instance() + .get::<_, bool>(&StorageKey::Paused) + .unwrap_or(false) + { + panic_with_error!(env, Error::Unauthorized); + } + } + + fn remove_policy_from_user(env: &Env, user: &Address, policy_id: u128) { + let key = StorageKey::UserPolicies(user.clone()); + let mut user_policies: Vec = env + .storage() + .persistent() + .get(&key) + .unwrap_or_else(|| Vec::new(env)); + let mut pos: Option = None; + for i in 0..user_policies.len() { + if user_policies.get_unchecked(i) == policy_id { + pos = Some(i); + break; + } + } + if let Some(i) = pos { + user_policies.remove(i); + env.storage().persistent().set(&key, &user_policies); + Self::extend_to_max(env, &key); + } + } + + fn expiry_warning_window(env: &Env) -> u64 { + env.storage() + .instance() + .get(&StorageKey::ExpiryWarningWindow) + .unwrap_or(DEFAULT_EXPIRY_WARNING_WINDOW) + } + + 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); + } + + fn load_product(env: &Env, id: u128) -> InsuranceProduct { + env.storage() + .persistent() + .get(&StorageKey::Product(id)) + .unwrap_or_else(|| panic_with_error!(env, Error::ProductNotFound)) + } + + fn load_policy(env: &Env, id: u128) -> Policy { + env.storage() + .persistent() + .get(&StorageKey::Policy(id)) + .unwrap_or_else(|| panic_with_error!(env, Error::PolicyNotFound)) + } + + /// Atomically fetch-and-increment the product ID counter. + /// Uses storage().update() to guarantee a single read-modify-write operation, + /// preventing two concurrent ledger entries from reading the same value. + fn next_product_id(env: &Env) -> u128 { + let mut id = 0u128; + env.storage() + .instance() + .update(&StorageKey::NextProductId, |v: Option| { + id = v.unwrap_or(1); + id + 1 + }); + id + } + + fn next_policy_id(env: &Env) -> u128 { + let mut id = 0u128; + env.storage() + .instance() + .update(&StorageKey::NextPolicyId, |v: Option| { + id = v.unwrap_or(1); + id + 1 + }); + id + } + + /// 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. + /// + /// Upgrade state-preservation guarantees (issue #777). + /// + /// Soroban's `env.deployer().update_current_contract_wasm` swaps the + /// contract's code without touching its storage. Every entry in + /// `env.storage().instance()`, `env.storage().persistent()`, and + /// `env.storage().temporary()` survives the swap, so existing policies, + /// products, and pending-oracle-change records remain readable by the + /// new WASM. + /// + /// Policy data (`StorageKey::Policy(id)`, `StorageKey::PolicyIds`, + /// `StorageKey::Product(id)`, `StorageKey::ProductIds`) is persistent + /// and is never removed by this function. Any schema change that + /// requires rewriting existing policies must land as a new migration + /// in `run_migrations` (see `migrate_v1_to_v2` for the pattern) so a + /// freshly-upgraded WASM can read legacy entries. + /// + /// Order of operations is deliberate: + /// 1. Migrations run against the old-WASM storage layout. + /// 2. The stored version is stamped. + /// 3. The WASM is swapped. + /// + /// Running migrations before the swap means the new WASM never + /// observes an un-migrated entry; stamping the version before the + /// swap means a subsequent upgrade sees the correct current_version. + /// + /// Upgrade procedure for operators: + /// - Deploy a new WASM blob to Stellar and record its `BytesN<32>` + /// hash. + /// - Guardians approve the upgrade (if `GuardianThreshold > 0`; + /// mirrors the risk-pool approval flow — admin alone can trigger + /// when guardians are disabled). + /// - Admin calls `upgrade(admin, new_wasm_hash, new_version)` with + /// the next sequential version number. + /// - After the transaction commits, confirm `get_version()` reports + /// the new value and spot-check that `get_policy(id)` is readable + /// for a representative policy id — the three-step order above + /// guarantees it, but a sanity check costs nothing. + 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); + if new_version <= 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() + .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) { + if old_version == 0 || new_version <= old_version || new_version > CURRENT_STORAGE_VERSION { + panic_with_error!(env, Error::InvalidVersion); + } + + let mut version = old_version; + while version < new_version { + match version { + 1 => { + Self::migrate_v1_to_v2(env); + version = 2; + } + 2 => { + Self::migrate_v2_to_v3(env); + version = 3; + } + _ => panic_with_error!(env, Error::InvalidVersion), + } + } + } + + fn migrate_v1_to_v2(env: &Env) { + if !env + .storage() + .instance() + .has(&StorageKey::MaxProductsPerPool) + { + env.storage().instance().set( + &StorageKey::MaxProductsPerPool, + &DEFAULT_MAX_PRODUCTS_PER_POOL, + ); + } + } + + fn migrate_v2_to_v3(_env: &Env) {} + + /// Require that an address is not the zero address (issue #730). + fn require_not_zero_address(env: &Env, address: &Address) { + let zero = Address::from_string(&soroban_sdk::String::from_str( + env, + "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF", + )); + if *address == zero { + panic_with_error!(env, Error::InvalidAddress); + } + } +} + +// #[cfg(test)] +// mod test; +// #[cfg(test)] +// mod test_advanced; +#[cfg(test)] +mod test_product_names; +#[cfg(test)] +mod test_category_validation; +#[cfg(test)] +mod test_cooling_off; +#[cfg(test)] +mod test_trigger_bounds; diff --git a/contracts/policy-engine/src/test_category_validation.rs b/contracts/policy-engine/src/test_category_validation.rs index e0f909e..4c3c592 100644 --- a/contracts/policy-engine/src/test_category_validation.rs +++ b/contracts/policy-engine/src/test_category_validation.rs @@ -37,18 +37,18 @@ fn params(name: Symbol, category: Symbol, oracle_key: Symbol, expected_token: Ad #[test] fn every_supported_category_is_accepted() { let (_, admin, usdc, client) = setup(); - client.create_product(&admin, ¶ms(symbol_short!("p_crop"), symbol_short!("crop"), symbol_short!("key_a"), usdc.clone())); - client.create_product(&admin, ¶ms(symbol_short!("p_flight"), symbol_short!("flight"), symbol_short!("key_b"), usdc.clone())); - client.create_product(&admin, ¶ms(symbol_short!("p_disast"), symbol_short!("disaster"), symbol_short!("key_c"), usdc.clone())); - client.create_product(&admin, ¶ms(symbol_short!("p_health"), symbol_short!("health"), symbol_short!("key_d"), usdc.clone())); - client.create_product(&admin, ¶ms(symbol_short!("p_defi"), symbol_short!("defi"), symbol_short!("key_e"), usdc)); + client.create_product(&admin, ¶ms(symbol_short!("p_crop"), symbol_short!("crop"), symbol_short!("keya"), usdc.clone())); + client.create_product(&admin, ¶ms(symbol_short!("p_flight"), symbol_short!("flight"), symbol_short!("keyb"), usdc.clone())); + client.create_product(&admin, ¶ms(symbol_short!("p_disast"), symbol_short!("disaster"), symbol_short!("keyc"), usdc.clone())); + client.create_product(&admin, ¶ms(symbol_short!("p_health"), symbol_short!("health"), symbol_short!("keyd"), usdc.clone())); + client.create_product(&admin, ¶ms(symbol_short!("p_defi"), symbol_short!("defi"), symbol_short!("keye"), usdc)); } #[test] #[should_panic(expected = "Error(Contract, #36)")] fn unknown_category_is_rejected() { let (_, admin, usdc, client) = setup(); - client.create_product(&admin, ¶ms(symbol_short!("p_bad"), symbol_short!("invalid"), symbol_short!("key_a"), usdc)); + client.create_product(&admin, ¶ms(symbol_short!("p_bad"), symbol_short!("invalid"), symbol_short!("keya"), usdc)); } #[test] @@ -58,7 +58,7 @@ fn dynamic_category_registration_and_creation() { // Initially not registered assert!(!client.is_category_registered(&new_cat)); - let err = client.try_create_product(&admin, ¶ms(symbol_short!("p_marine"), new_cat.clone(), symbol_short!("key_m"), usdc.clone())); + let err = client.try_create_product(&admin, ¶ms(symbol_short!("p_marine"), new_cat.clone(), symbol_short!("keym"), usdc.clone())); assert!(err.is_err(), "Creation with unregistered category should fail"); // Admin registers category @@ -66,7 +66,7 @@ fn dynamic_category_registration_and_creation() { assert!(client.is_category_registered(&new_cat)); // Now creation succeeds - let pid = client.create_product(&admin, ¶ms(symbol_short!("p_marine"), new_cat.clone(), symbol_short!("key_m"), usdc.clone())); + let pid = client.create_product(&admin, ¶ms(symbol_short!("p_marine"), new_cat.clone(), symbol_short!("keym"), usdc.clone())); assert!(pid > 0); // Unregister category @@ -74,7 +74,7 @@ fn dynamic_category_registration_and_creation() { assert!(!client.is_category_registered(&new_cat)); // Further product creation for this category is now rejected - let err2 = client.try_create_product(&admin, ¶ms(symbol_short!("p_m2"), new_cat, symbol_short!("key_m2"), usdc)); + let err2 = client.try_create_product(&admin, ¶ms(symbol_short!("p_m2"), new_cat, symbol_short!("keym2"), usdc)); assert!(err2.is_err(), "Creation after unregistering category should fail"); } diff --git a/contracts/policy-engine/src/test_product_names.rs b/contracts/policy-engine/src/test_product_names.rs index 965e184..c36e032 100644 --- a/contracts/policy-engine/src/test_product_names.rs +++ b/contracts/policy-engine/src/test_product_names.rs @@ -50,10 +50,13 @@ fn expect_rejected( } fn params(name: Symbol, category: Symbol, oracle_key: Symbol) -> CreateProductParams { + let env = Env::default(); + let expected_token = Address::generate(&env); CreateProductParams { name, category, oracle_key, + expected_token, trigger_type: TriggerType::Threshold, oracle_data_type: symbol_short!("weather"), trigger_threshold: 50_000_000, @@ -68,8 +71,8 @@ fn params(name: Symbol, category: Symbol, oracle_key: Symbol) -> CreateProductPa #[test] fn distinct_names_are_accepted() { let (_, admin, client) = setup(); - let a = client.create_product(&admin, ¶ms(symbol_short!("rain"), symbol_short!("crop"), symbol_short!("key_a"))); - let b = client.create_product(&admin, ¶ms(symbol_short!("drought"), symbol_short!("crop"), symbol_short!("key_b"))); + let a = client.create_product(&admin, ¶ms(symbol_short!("rain"), symbol_short!("crop"), symbol_short!("keya"))); + let b = client.create_product(&admin, ¶ms(symbol_short!("drought"), symbol_short!("crop"), symbol_short!("keyb"))); assert_ne!(a, b); } @@ -77,26 +80,26 @@ fn distinct_names_are_accepted() { #[should_panic(expected = "Error(Contract, #34)")] fn duplicate_name_same_category_is_rejected() { let (_, admin, client) = setup(); - client.create_product(&admin, ¶ms(symbol_short!("rain"), symbol_short!("crop"), symbol_short!("key_a"))); - client.create_product(&admin, ¶ms(symbol_short!("rain"), symbol_short!("crop"), symbol_short!("key_b"))); + client.create_product(&admin, ¶ms(symbol_short!("rain"), symbol_short!("crop"), symbol_short!("keya"))); + client.create_product(&admin, ¶ms(symbol_short!("rain"), symbol_short!("crop"), symbol_short!("keyb"))); } #[test] #[should_panic(expected = "Error(Contract, #34)")] fn duplicate_name_across_categories_is_rejected() { let (_, admin, client) = setup(); - client.create_product(&admin, ¶ms(symbol_short!("rain"), symbol_short!("crop"), symbol_short!("key_a"))); - client.create_product(&admin, ¶ms(symbol_short!("rain"), symbol_short!("flight"), symbol_short!("key_b"))); + client.create_product(&admin, ¶ms(symbol_short!("rain"), symbol_short!("crop"), symbol_short!("keya"))); + client.create_product(&admin, ¶ms(symbol_short!("rain"), symbol_short!("flight"), symbol_short!("keyb"))); } #[test] fn rejected_duplicate_does_not_consume_a_slot_or_id() { let (_, admin, client) = setup(); - let first = client.create_product(&admin, ¶ms(symbol_short!("rain"), symbol_short!("crop"), symbol_short!("key_a"))); - let dup = client.try_create_product(&admin, ¶ms(symbol_short!("rain"), symbol_short!("crop"), symbol_short!("key_b"))); + let first = client.create_product(&admin, ¶ms(symbol_short!("rain"), symbol_short!("crop"), symbol_short!("keya"))); + let dup = client.try_create_product(&admin, ¶ms(symbol_short!("rain"), symbol_short!("crop"), symbol_short!("keyb"))); assert!(dup.is_err()); // The failed attempt must not have reserved the oracle key or an id. - let next = client.create_product(&admin, ¶ms(symbol_short!("hail"), symbol_short!("crop"), symbol_short!("key_b"))); + let next = client.create_product(&admin, ¶ms(symbol_short!("hail"), symbol_short!("crop"), symbol_short!("keyb"))); assert_eq!(next, first + 1); } @@ -110,13 +113,13 @@ fn deprecating_a_product_frees_its_name() { let (_, admin, client) = setup(); let original = client.create_product( &admin, - ¶ms(symbol_short!("rain"), symbol_short!("crop"), symbol_short!("key_a")), + ¶ms(symbol_short!("rain"), symbol_short!("crop"), symbol_short!("keya")), ); client.deprecate_product(&admin, &original); let successor = expect_ok(client.try_create_product( &admin, - ¶ms(symbol_short!("rain"), symbol_short!("crop"), symbol_short!("key_a")), + ¶ms(symbol_short!("rain"), symbol_short!("crop"), symbol_short!("keya")), )); assert_ne!(successor, original, "a successor must be a new product id"); } @@ -129,14 +132,14 @@ fn deprecating_a_product_frees_name_and_oracle_key() { let (_, admin, client) = setup(); let original = client.create_product( &admin, - ¶ms(symbol_short!("rain"), symbol_short!("crop"), symbol_short!("key_a")), + ¶ms(symbol_short!("rain"), symbol_short!("crop"), symbol_short!("keya")), ); client.deprecate_product(&admin, &original); // Same name *and* same (category, oracle_key): the slot is free again. let successor = expect_ok(client.try_create_product( &admin, - ¶ms(symbol_short!("rain"), symbol_short!("crop"), symbol_short!("key_a")), + ¶ms(symbol_short!("rain"), symbol_short!("crop"), symbol_short!("keya")), )); assert_ne!(successor, original); } @@ -149,11 +152,11 @@ fn deprecating_one_product_does_not_clear_another_products_name() { let (_, admin, client) = setup(); let rain = client.create_product( &admin, - ¶ms(symbol_short!("rain"), symbol_short!("crop"), symbol_short!("key_a")), + ¶ms(symbol_short!("rain"), symbol_short!("crop"), symbol_short!("keya")), ); let hail = client.create_product( &admin, - ¶ms(symbol_short!("hail"), symbol_short!("crop"), symbol_short!("key_b")), + ¶ms(symbol_short!("hail"), symbol_short!("crop"), symbol_short!("keyb")), ); client.deprecate_product(&admin, &rain); @@ -161,12 +164,12 @@ fn deprecating_one_product_does_not_clear_another_products_name() { // `hail` is untouched and still holds its name, so the name is refused. expect_rejected(client.try_create_product( &admin, - ¶ms(symbol_short!("hail"), symbol_short!("crop"), symbol_short!("key_c")), + ¶ms(symbol_short!("hail"), symbol_short!("crop"), symbol_short!("keyc")), )); // And the live product is still addressable under its own name. let replacement = expect_ok(client.try_create_product( &admin, - ¶ms(symbol_short!("drought"), symbol_short!("crop"), symbol_short!("key_c")), + ¶ms(symbol_short!("drought"), symbol_short!("crop"), symbol_short!("keyc")), )); assert!(replacement > hail); } @@ -179,20 +182,20 @@ fn name_is_reusable_exactly_once_after_deprecation() { let (_, admin, client) = setup(); let original = client.create_product( &admin, - ¶ms(symbol_short!("rain"), symbol_short!("crop"), symbol_short!("key_a")), + ¶ms(symbol_short!("rain"), symbol_short!("crop"), symbol_short!("keya")), ); client.deprecate_product(&admin, &original); let successor = expect_ok(client.try_create_product( &admin, - ¶ms(symbol_short!("rain"), symbol_short!("crop"), symbol_short!("key_a")), + ¶ms(symbol_short!("rain"), symbol_short!("crop"), symbol_short!("keya")), )); // The name now belongs to the successor, so a third claim on it fails and // the successor keeps it. expect_rejected(client.try_create_product( &admin, - ¶ms(symbol_short!("rain"), symbol_short!("crop"), symbol_short!("key_z")), + ¶ms(symbol_short!("rain"), symbol_short!("crop"), symbol_short!("keyz")), )); let live = client.get_product(&successor); assert_eq!( @@ -210,12 +213,12 @@ fn pausing_a_product_keeps_its_name_reserved() { let (_, admin, client) = setup(); let original = client.create_product( &admin, - ¶ms(symbol_short!("rain"), symbol_short!("crop"), symbol_short!("key_a")), + ¶ms(symbol_short!("rain"), symbol_short!("crop"), symbol_short!("keya")), ); client.pause_product(&admin, &original); expect_rejected(client.try_create_product( &admin, - ¶ms(symbol_short!("rain"), symbol_short!("crop"), symbol_short!("key_b")), + ¶ms(symbol_short!("rain"), symbol_short!("crop"), symbol_short!("keyb")), )); } diff --git a/contracts/policy-engine/src/test_trigger_bounds.rs b/contracts/policy-engine/src/test_trigger_bounds.rs new file mode 100644 index 0000000..ce9f305 --- /dev/null +++ b/contracts/policy-engine/src/test_trigger_bounds.rs @@ -0,0 +1,234 @@ +//! Tests for trigger_threshold validation within product bounds (Issue #773). +//! +//! Acceptance criteria: +//! - Validate within product bounds (0 < threshold <= 10^21) +//! - Reject out-of-range thresholds (zero, negative, > 10^21) +//! - Reject mismatched threshold upon policy purchase +//! - Test covers bounds +//! - Document threshold rules +#![cfg(test)] + +use super::*; +use soroban_sdk::{ + symbol_short, + testutils::Address as _, + token::StellarAssetClient, + Address, Env, Symbol, +}; + +const PROTOCOL_MAX_THRESHOLD: i128 = 1_000_000_000_000_000_000_000i128; // 10^21 + +fn setup() -> (Env, Address, Address, PolicyEngineClient<'static>) { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let usdc = env + .register_stellar_asset_contract_v2(admin.clone()) + .address(); + let id = env.register(PolicyEngine, ()); + let client = PolicyEngineClient::new(&env, &id); + client.initialize(&admin, &usdc, &Address::generate(&env)); + (env, admin, usdc, client) +} + +fn make_product_params( + name: Symbol, + category: Symbol, + oracle_key: Symbol, + expected_token: Address, + trigger_threshold: i128, +) -> CreateProductParams { + CreateProductParams { + name, + category, + oracle_key, + expected_token, + trigger_type: TriggerType::Threshold, + oracle_data_type: symbol_short!("weather"), + trigger_threshold, + trigger_comparison: TriggerComparison::LessThan, + coverage_min: 100_000_000, + coverage_max: 10_000_000_000, + premium_rate_bps: 500, + max_duration_days: 365, + } +} + +#[test] +fn valid_trigger_threshold_within_bounds_succeeds() { + let (env, admin, usdc, client) = setup(); + let product_id = client.create_product( + &admin, + &make_product_params( + symbol_short!("crop_ok"), + symbol_short!("crop"), + symbol_short!("rain1"), + usdc.clone(), + 50_000_000, + ), + ); + + let product = client.get_product(&product_id); + assert_eq!(product.trigger_threshold, 50_000_000); + + let buyer = Address::generate(&env); + StellarAssetClient::new(&env, &usdc).mint(&buyer, &10_000_000_000i128); + + // buy_policy_with_threshold succeeds when matching product bounds + let policy_id = client.buy_policy_with_threshold( + &buyer, + &product_id, + &500_000_000i128, + &30u32, + &symbol_short!("rain1"), + &usdc, + &50_000_000i128, + ); + let policy = client.get_policy(&policy_id); + assert_eq!(policy.trigger_threshold, 50_000_000); +} + +#[test] +fn min_boundary_trigger_threshold_succeeds() { + let (_env, admin, usdc, client) = setup(); + // Lower bound: strictly positive (1 is valid) + let product_id = client.create_product( + &admin, + &make_product_params( + symbol_short!("crop_min"), + symbol_short!("crop"), + symbol_short!("rainmin"), + usdc.clone(), + 1i128, + ), + ); + assert_eq!(client.get_product(&product_id).trigger_threshold, 1); +} + +#[test] +fn max_boundary_trigger_threshold_succeeds() { + let (_env, admin, usdc, client) = setup(); + // Upper bound: PROTOCOL_MAX_THRESHOLD (10^21) is valid + let product_id = client.create_product( + &admin, + &make_product_params( + symbol_short!("crop_max"), + symbol_short!("crop"), + symbol_short!("rainmax"), + usdc.clone(), + PROTOCOL_MAX_THRESHOLD, + ), + ); + assert_eq!( + client.get_product(&product_id).trigger_threshold, + PROTOCOL_MAX_THRESHOLD + ); +} + +#[test] +#[should_panic(expected = "Error(Contract, #14)")] +fn zero_trigger_threshold_is_rejected() { + let (_env, admin, usdc, client) = setup(); + client.create_product( + &admin, + &make_product_params( + symbol_short!("crop_zero"), + symbol_short!("crop"), + symbol_short!("rainz"), + usdc, + 0i128, + ), + ); +} + +#[test] +#[should_panic(expected = "Error(Contract, #14)")] +fn negative_trigger_threshold_is_rejected() { + let (_env, admin, usdc, client) = setup(); + client.create_product( + &admin, + &make_product_params( + symbol_short!("crop_neg"), + symbol_short!("crop"), + symbol_short!("rainneg"), + usdc, + -1i128, + ), + ); +} + +#[test] +#[should_panic(expected = "Error(Contract, #14)")] +fn exceeding_max_trigger_threshold_is_rejected() { + let (_env, admin, usdc, client) = setup(); + client.create_product( + &admin, + &make_product_params( + symbol_short!("crop_huge"), + symbol_short!("crop"), + symbol_short!("rainhuge"), + usdc, + PROTOCOL_MAX_THRESHOLD + 1, + ), + ); +} + +#[test] +#[should_panic(expected = "Error(Contract, #14)")] +fn buy_policy_with_mismatched_threshold_is_rejected() { + let (env, admin, usdc, client) = setup(); + let product_id = client.create_product( + &admin, + &make_product_params( + symbol_short!("crop_p"), + symbol_short!("crop"), + symbol_short!("rainp"), + usdc.clone(), + 50_000_000, + ), + ); + + let buyer = Address::generate(&env); + StellarAssetClient::new(&env, &usdc).mint(&buyer, &10_000_000_000i128); + + // Mismatched threshold (60_000_000 != 50_000_000) must be rejected with InvalidTriggerThreshold (#14) + client.buy_policy_with_threshold( + &buyer, + &product_id, + &500_000_000i128, + &30u32, + &symbol_short!("rainp"), + &usdc, + &60_000_000i128, + ); +} + +#[test] +#[should_panic(expected = "Error(Contract, #14)")] +fn buy_policy_with_out_of_bounds_threshold_is_rejected() { + let (env, admin, usdc, client) = setup(); + let product_id = client.create_product( + &admin, + &make_product_params( + symbol_short!("crop_b"), + symbol_short!("crop"), + symbol_short!("rainb"), + usdc.clone(), + 50_000_000, + ), + ); + + let buyer = Address::generate(&env); + StellarAssetClient::new(&env, &usdc).mint(&buyer, &10_000_000_000i128); + + // Negative threshold must be rejected + client.buy_policy_with_threshold( + &buyer, + &product_id, + &500_000_000i128, + &30u32, + &symbol_short!("rainb"), + &usdc, + &-50_000_000i128, + ); +} diff --git a/contracts/risk-pool/src/lib.rs b/contracts/risk-pool/src/lib.rs index c5342a9..963ab02 100644 --- a/contracts/risk-pool/src/lib.rs +++ b/contracts/risk-pool/src/lib.rs @@ -1,3541 +1,3597 @@ -//! 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. -// 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)] -#![no_std] - -use soroban_sdk::{ - contract, contractimpl, contracttype, contracterror, panic_with_error, - token, Address, Env, IntoVal, String, Symbol, Val, 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. -#[allow(dead_code)] -#[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). -pub const MIN_DEPOSIT: i128 = 1_000_000; - -/// Minimum shares that must be minted on deposit (issue #493). -/// Enforces that calculated shares cannot be 0 or infinitesimal when depositing into large pools. -pub const MIN_SHARES: i128 = 1; - -/// 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. -pub const MAX_EXIT_DELAY: u64 = 30 * 24 * 60 * 60; - -/// Most LP slots inspected by one auto-processing pass (issue #511). -/// -/// Every scanned slot costs storage reads, so the sweep is capped to keep -/// `deposit`/`withdraw`/`request_exit` cheap. The cursor rotates, so repeated -/// pool interactions cover every provider over time. -const MAX_AUTO_EXIT_SCAN: u32 = 5; - -/// 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; - -// Shared protocol constants — single source of truth in parashield-common (issue #342). -use parashield_common::{TTL_THRESHOLD, TTL_EXTEND_TO, ADMIN_TRANSFER_TIMELOCK}; - -#[contracttype] -pub(crate) 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 pending guardian-removal proposal awaiting approvals from the - /// remaining guardians. Issue #774: admin alone can no longer dismantle - /// the guardian multisig — removals require threshold-many approvals - /// from guardians other than the one being removed. - PendingGuardianRemoval, - /// 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, - /// Next `LpAddress` index (u32) the expired-exit sweep will inspect, so - /// successive sweeps rotate through all providers. - ExitCursor, - /// 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, - /// Reentrancy guard flag to prevent reentrant external calls. - ReentrancyGuard, - /// Human-readable pool name (issue #748). - PoolName, - /// Approvals count for pending admin withdrawal (issue #733). - AdminWithdrawalApprovals, -} - -/// Maximum pool name length in bytes/characters (issue #748). -pub const MAX_POOL_NAME_LEN: u32 = 32; - -#[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, - /// An admin transfer was proposed while another transfer is still - /// pending, which would reset the transfer timelock (issue #457). - AdminTransferPending = 40, - /// The pool category `Symbol` is empty (issue #520). - InvalidCategory = 41, - /// The deposit amount is not a multiple of the minimum deposit unit (issue #524). - InvalidAmount = 42, - /// A provider already holds an LP NFT, so a second one must not be minted. - ProviderAlreadyHasNft = 43, - /// The contract admin tried to become an LP, either by depositing or by - /// being the receiving side of a position transfer. The admin sets the - /// pool's risk parameters and can authorise emergency withdrawals, so it - /// must not also be exposed to them (issue #568). - AdminCannotBeLp = 44, - /// Delay exceeds the maximum allowed exit delay (issue #562) - ExitDelayTooLong = 45, - /// No liquid capital remains for a withdrawal. - PoolDrained = 46, - /// Reentrant call detected and blocked by reentrancy guard. - Reentrancy = 47, - /// Pool name exceeds maximum allowable length or is empty (issue #748). - NameTooLong = 48, - /// Issue #774: admin cannot shrink the guardian set via `set_guardians`. - GuardianRemoveBlocked = 49, - /// Issue #774: removal would drop the committee below MIN_GUARDIANS. - MinGuardiansUnmet = 50, -} - -/// RAII Reentrancy Guard that sets transient/instance lock on entry and cleans up on drop. -pub(crate) struct ReentrancyGuard(Env); - -impl ReentrancyGuard { - pub(crate) fn enter(env: &Env) -> Self { - if env.storage().instance().has(&StorageKey::ReentrancyGuard) { - panic_with_error!(env, Error::Reentrancy); - } - env.storage().instance().set(&StorageKey::ReentrancyGuard, &true); - Self(env.clone()) - } -} - -impl Drop for ReentrancyGuard { - fn drop(&mut self) { - self.0.storage().instance().remove(&StorageKey::ReentrancyGuard); - } -} - -#[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"`) and must be non-empty, otherwise `InvalidCategory` is raised. - /// 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); - } - // Category name cannot be empty and must describe a valid risk category (issue #729, #520). - // Empty category names are rejected to prevent unnamed categories in the pool registry. - if category == Symbol::new(&env, "") { - panic_with_error!(&env, Error::InvalidCategory); - } - // Address validation is deferred to require_auth() calls which - // verify the address on the Soroban network layer. - - Self::require_not_zero_address(&env, &admin); - 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`. - /// - /// The admin cannot be an LP (issue #568). LP principal is the pool's - /// solvency backstop, and the admin also sets capacity, fees, tier - /// discounts, exit delay and the emergency withdrawal that pays an LP - /// out. An admin who is also an LP is short the pool with one hand and - /// entitled to withdraw from it with the other, and a bad fee or - /// capacity change lands on their own deposit. The separation costs - /// nothing here — the pool already requires third-party liquidity to - /// function — so the conflict is refused outright rather than bounded. - /// Deposit liquidity specifying the deposit token address, verifying it matches the pool's token (issue #738). - pub fn deposit_with_token( - env: Env, - provider: Address, - deposit_token: Address, - amount: i128, - min_shares: i128, - compound_enabled: bool, - ) -> i128 { - let usdc: Address = env.storage().instance() - .get(&StorageKey::UsdcToken) - .unwrap_or_else(|| panic_with_error!(&env, Error::NotInitialized)); - if deposit_token != usdc { - panic_with_error!(&env, Error::InvalidToken); - } - Self::deposit(env, provider, amount, min_shares, compound_enabled) - } - - pub fn deposit( - env: Env, - provider: Address, - amount: i128, - min_shares: i128, - compound_enabled: bool, - ) -> i128 { - let _guard = ReentrancyGuard::enter(&env); - // SECURITY FIX: Validate address format before processing to prevent - // deposits from being locked at invalid addresses - Self::validate_stellar_address(&env, &provider); - // Zero address cannot hold or move funds; a deposit to it would lock - // capital permanently with no recovery path (issue #647). - Self::require_not_zero_address(&env, &provider); - - provider.require_auth(); - Self::require_not_admin(&env, &provider); - if amount <= 0 { panic_with_error!(&env, Error::ZeroAmount); } - if amount < MIN_DEPOSIT { panic_with_error!(&env, Error::DepositTooSmall); } - if amount % MIN_DEPOSIT != 0 { panic_with_error!(&env, Error::InvalidAmount); } - Self::assert_active(&env); - Self::sweep_expired_exits(&env, MAX_AUTO_EXIT_SCAN, Some(&provider)); - - 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); - - // SECURITY FIX: Check for overflow before adding to total_deposited. - // Large deposits could cause arithmetic overflow and corrupt pool state. - // Validate using checked_add before proceeding with any state changes. - let new_total_deposited = total_deposited.checked_add(amount) - .unwrap_or_else(|| panic_with_error!(&env, Error::Overflow)); - - // SECURITY FIX: Enforce the configured pool size cap before accepting new liquidity. - // Each pool is category-specific, so this enforces category-level deposit limits. - // Validate that the new deposit would not exceed the category's configured capacity. - let capacity = Self::capacity(&env); - if new_total_deposited > capacity.max_total_deposited { - panic_with_error!(&env, Error::PoolCapExceeded); - } - - // SECURITY FIX: Additional validation to prevent deposits that would push - // the pool beyond safe utilization limits for this category. - // Check that current utilization doesn't already exceed the limit before accepting more funds. - let total_locked: i128 = env.storage().instance().get(&StorageKey::TotalLocked).unwrap_or(0); - let current_utilization_bps = if total_deposited > 0 { - total_locked.checked_mul(10_000) - .and_then(|v| v.checked_div(total_deposited)) - .unwrap_or(0) as u32 - } else { - 0 - }; - - // If utilization is already at or above max, reject new deposits to prevent - // over-leveraging the category pool beyond its risk tolerance. - if current_utilization_bps >= capacity.max_utilization_bps { - panic_with_error!(&env, Error::PoolCapExceeded); - } - - // Both branches use checked arithmetic: a share calculation that - // cannot be represented traps as a typed `Overflow` rather than - // silently wrapping or truncating (issue #454). - let mut burn_shares = 0i128; - let new_shares = if total_shares == 0 { - let minted = amount.checked_mul(1_000_000_000) // 1 share = 1 USDC * 1e9 precision - .unwrap_or_else(|| panic_with_error!(&env, Error::Overflow)); - // SECURITY FIX: first-depositor attack mitigation - // Permanently lock the first 1,000 shares to prevent a first depositor - // from manipulating the share price to steal from subsequent depositors. - burn_shares = 1_000; - if minted <= burn_shares { - panic_with_error!(&env, Error::DepositTooSmall); - } - minted - burn_shares - } else { - // Issue #621: integer division truncates toward zero; the - // MIN_SHARES guard below catches the resulting 0 case. - amount.checked_mul(total_shares) - .and_then(|v| v.checked_div(total_deposited)) - .unwrap_or_else(|| panic_with_error!(&env, Error::Overflow)) - }; - - // Issue #454 / #493: `amount * total_shares / total_deposited` truncates - // toward zero, so a deposit that is small relative to the pool can - // round to 0 shares. MIN_DEPOSIT alone cannot rule this out (the - // share-to-deposit ratio is not fixed), and without this guard the - // depositor's tokens would be taken while nothing is minted in - // return — an irreversible loss. Enforce minimum share amount and reject. - if new_shares < MIN_SHARES { - 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(); - let token_client = token::Client::new(&env, &usdc); - let this_pool = env.current_contract_address(); - let balance_before = token_client.balance(&this_pool); - // Explicitly verify the token transfer succeeded before crediting - // shares (issue #617). Using try_transfer and checking the result - // ensures a failed transfer is caught here rather than silently - // minting shares against funds that never arrived. - match token_client.try_transfer(&provider, &this_pool, &amount) { - Ok(Ok(())) => {} - _ => { - panic_with_error!(&env, Error::InvalidToken); - } - } - // The pool only ever takes the token it was configured with. Confirm - // that token actually delivered the full amount, so a token that - // under-delivers cannot mint shares against funds that never arrived - // (issue #552). - let received = token_client - .balance(&this_pool) - .checked_sub(balance_before) - .unwrap_or(0); - if received != amount { - panic_with_error!(&env, Error::InvalidToken); - } - - 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 position: LpPosition = match env.storage().persistent().get::<_, LpPosition>(&lp_key) { - Some(mut pos) => { - pending_yield = Self::settle_yield(&env, &mut pos); - pos.deposited = pos.deposited.checked_add(amount) - .unwrap_or_else(|| panic_with_error!(&env, Error::Overflow)); - pos.shares = pos.shares.checked_add(new_shares) - .unwrap_or_else(|| panic_with_error!(&env, Error::Overflow)); - let acc_per_share: i128 = env.storage().instance().get(&StorageKey::AccumulatedPerShare).unwrap_or(0); - pos.yield_debt = acc_per_share.checked_mul(pos.shares) - .and_then(|v| v.checked_div(1_000_000_000_000)) - .unwrap_or_else(|| panic_with_error!(&env, Error::Overflow)); - 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); - let yield_debt = acc_per_share.checked_mul(new_shares) - .and_then(|v| v.checked_div(1_000_000_000_000)) - .unwrap_or_else(|| panic_with_error!(&env, Error::Overflow)); - LpPosition { - provider: provider.clone(), - deposited: amount, - shares: new_shares, - yield_claimed: 0, - yield_debt, - 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); - // Use the pre-validated new_total_deposited calculated earlier - let new_total_shares = total_shares.checked_add(new_shares) - .and_then(|s| s.checked_add(burn_shares)) - .unwrap_or_else(|| panic_with_error!(&env, Error::Overflow)); - env.storage().instance().set(&StorageKey::TotalDeposited, &new_total_deposited); - env.storage().instance().set(&StorageKey::TotalShares, &new_total_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 { - let _guard = ReentrancyGuard::enter(&env); - provider.require_auth(); - if shares <= 0 { - panic_with_error!(&env, Error::ZeroAmount); - } - Self::sweep_expired_exits(&env, MAX_AUTO_EXIT_SCAN, Some(&provider)); - Self::withdraw_inner(env.clone(), 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); } - - // SECURITY FIX: Ensure available liquidity accounts for pending claims. - // The LP's proportional share of locked capital must be reserved. - let lp_share_of_locked = if total_shares > 0 { - (position.shares * total_locked) / total_shares - } else { - 0 - }; - - let available_liquidity = total_deposited.saturating_sub(total_locked); - if available_liquidity <= 0 { panic_with_error!(&env, Error::PoolDrained); } - // Issue #621: shares * total_deposited / total_shares truncates - // toward zero; if the pool has absorbed losses total_deposited - // may be tiny relative to total_shares and a small redemption - // rounds to 0. The ZeroAmount guard below rejects such cases so - // no shares are ever burned without returning capital. - 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); } - - // Issue #725: Update cumulative premium per share and settle yield during withdrawal - let pending_yield = Self::settle_yield(&env, &mut position); - let acc_per_share: i128 = env - .storage() - .instance() - .get(&StorageKey::AccumulatedPerShare) - .unwrap_or(0); - position.deposited = position.deposited.saturating_sub(amount); - position.shares -= shares; - position.yield_debt = (acc_per_share * position.shares) / 1_000_000_000_000; - env.storage().instance().set(&StorageKey::AccumulatedPerShare, &acc_per_share); - 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(); - - // SECURITY FIX: Check the contract's actual USDC balance before attempting transfer - // to ensure the withdrawal won't fail due to insufficient token balance. - let usdc_client = token::Client::new(&env, &usdc); - let contract_balance = usdc_client.balance(&env.current_contract_address()); - if contract_balance < amount { - panic_with_error!(&env, Error::InsufficientFunds); - } - - usdc_client.transfer(&env.current_contract_address(), &provider, &amount); - Self::pay_out_yield(&env, &provider, pending_yield); - - 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 { - let _guard = ReentrancyGuard::enter(&env); - 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 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)); - - Self::update_lp_nft(&env, &provider, &position); - - let usdc: Address = env - .storage() - .instance() - .get(&StorageKey::UsdcToken) - .unwrap(); - let total_payout = amount + pending_yield; - token::Client::new(&env, &usdc).transfer( - &env.current_contract_address(), - &provider, - &total_payout, - ); - - env.events().publish( - (Symbol::new(&env, "emergency_withdraw"), provider.clone()), - (admin, amount, shares), - ); - - amount - } - - /// Transfer `shares` from `from` address to `to` address. - /// Returns the proportional USDC deposit amount transferred. - /// - /// Transfer LP shares between providers, validating amount is strictly positive (issue #717). - pub fn transfer_shares(env: Env, from: Address, to: Address, shares: i128) -> i128 { - if shares <= 0 { - panic_with_error!(&env, Error::ZeroAmount); - } - Self::transfer_position(env, from, to, shares) - } - - /// The admin cannot be the receiving side either (issue #568) — - /// otherwise `transfer_position` is a one-transaction way around the - /// `deposit` restriction and the separation of duties it exists for. - 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::require_not_admin(&env, &to); - 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); - } - - // Issue #621: share-to-amount calculation can underflow to 0 when - // from_pos.deposited has been eroded by losses while shares stayed - // constant. A zero-amount transfer burns the shares without moving - // any capital, so reject it explicitly. - 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)); - if amount == 0 { panic_with_error!(&env, Error::ZeroAmount); } - - 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 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) { - let _guard = ReentrancyGuard::enter(&env); - 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; - - // CEI: update state before outgoing external token transfers - 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)); - - 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); - - 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) { - let _guard = ReentrancyGuard::enter(&env); - 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) { - let _guard = ReentrancyGuard::enter(&env); - 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 { - let _guard = ReentrancyGuard::enter(&env); - 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. - /// Rejects delays exceeding `MAX_EXIT_DELAY` with `ExitDelayTooLong` (issue #743). - 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::ExitDelayTooLong); - } - - 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. - /// - /// Issue #458: if a request is already outstanding for this provider, - /// the new shares are ADDED to the existing entry rather than rejected. - /// The original `requested_at` and `claimable_at` are preserved so a - /// second call cannot silently defer the first request's unlock — a - /// provider who tops up their exit still has to wait for the timelock - /// that started with the initial request. The provider's total shares - /// must still cover the summed exit; otherwise the existing entry is - /// left untouched and the call reverts with `InsufficientFunds`. - pub fn request_exit(env: Env, provider: Address, shares: i128) { - let _guard = ReentrancyGuard::enter(&env); - provider.require_auth(); - if shares <= 0 { - panic_with_error!(&env, Error::ZeroAmount); - } - Self::assert_withdrawable(&env); - Self::sweep_expired_exits(&env, MAX_AUTO_EXIT_SCAN, Some(&provider)); - - let position: LpPosition = env - .storage() - .persistent() - .get(&StorageKey::LpPosition(provider.clone())) - .unwrap_or_else(|| panic_with_error!(&env, Error::NoShares)); - - let req_key = StorageKey::ExitReq(provider.clone()); - // Issue #458: read any existing entry and treat the write as an - // accumulate. `total_shares` is what the provider now expects to - // withdraw at claim time; it must not exceed their live share - // balance. - let existing: Option = env.storage().persistent().get(&req_key); - let (total_shares, requested_at, claimable_at) = match &existing { - Some(prev) => ( - prev.shares.saturating_add(shares), - prev.requested_at, - prev.claimable_at, - ), - None => { - let now = env.ledger().timestamp(); - (shares, now, now.saturating_add(Self::exit_delay(&env))) - } - }; - if position.shares < total_shares { - // Leave `existing` untouched so a failed top-up cannot corrupt - // an already-valid request. - panic_with_error!(&env, Error::InsufficientFunds); - } - - env.storage().persistent().set( - &req_key, - &ExitRequest { - provider: provider.clone(), - shares: total_shares, - requested_at, - claimable_at, - }, - ); - Self::extend_to_max(&env, &req_key); - - let queued: i128 = env - .storage() - .instance() - .get(&StorageKey::QueuedExitShares) - .unwrap_or(0); - // Bump the pool-wide queued counter by the increment only. On a - // fresh request the increment equals the total; on a top-up it - // equals just the newly queued portion. - 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 { - let _guard = ReentrancyGuard::enter(&env); - 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 - } - - /// Settle expired exit requests on behalf of their providers, so LP funds - /// are not stuck waiting for someone to call `claim_exit` (issue #511). - /// - /// Permissionless: proceeds always go to the provider, never the caller. - /// Inspects at most `max_scan` LP slots (capped at `MAX_AUTO_EXIT_SCAN`) - /// starting from a rotating cursor and returns how many exits were settled. - /// Requests the pool cannot currently afford stay queued for a later pass. - pub fn process_expired_exits(env: Env, max_scan: u32) -> u32 { - let _guard = ReentrancyGuard::enter(&env); - Self::sweep_expired_exits(&env, max_scan.min(MAX_AUTO_EXIT_SCAN), None) - } - - /// 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) { - let _guard = ReentrancyGuard::enter(&env); - 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); - } - - // SECURITY FIX: Validate that the reinsurer contract implements the required - // IReinsurer interface by attempting to call a test recover with zero amount. - // This prevents a malicious or buggy contract from being set as the reinsurer - // and potentially draining the pool by returning fraudulent recovery amounts. - // A valid reinsurer must accept this call without panicking. - let test_policy_id = 0u128; - let test_amount = 0i128; - let test_result = env.try_invoke_contract::( - &reinsurer, - &Symbol::new(&env, "recover"), - soroban_sdk::vec![ - &env, - env.current_contract_address().into_val(&env), - test_policy_id.into_val(&env), - test_amount.into_val(&env), - ], - ); - - // If the reinsurer doesn't implement recover() or the call fails, - // reject the configuration - if test_result.is_err() { - 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) { - let _guard = ReentrancyGuard::enter(&env); - 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)); - - // CEI: update state before external transfer - 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); - - let usdc: Address = env.storage().instance().get(&StorageKey::UsdcToken).unwrap(); - token::Client::new(&env, &usdc) - .transfer(&env.current_contract_address(), &config.reinsurer, &amount); - - 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 { - let _guard = ReentrancyGuard::enter(&env); - 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 + queued_exits) / deposited × 10,000). - /// Includes committed exit queue shares in utilization calculation (#693). - /// 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); - let queued_exits: i128 = env.storage().instance().get(&StorageKey::QueuedExitShares).unwrap_or(0); - if deposited == 0 { return 0; } - let effective_locked = locked.saturating_add(queued_exits); - let util_bps = effective_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); - } - // Enforce minimum fee floor and reject zero fee config (#688) - if min_fee_bps == 0 || base_fee_bps == 0 { - 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.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 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 || lp_bps > 10_000 || treas_bps < 0 || treas_bps > 10_000 || backstop_bps < 0 || backstop_bps > 10_000 { - 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 || lp_bps > 10_000 || treas_bps < 0 || treas_bps > 10_000 || backstop_bps < 0 || backstop_bps > 10_000 { - 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 - if pending.new_lp_bps < 0 || pending.new_treas_bps < 0 || pending.new_backstop_bps < 0 - || pending.new_lp_bps + pending.new_treas_bps + pending.new_backstop_bps != 10_000 - { - panic_with_error!(&env, Error::InvalidSplit); - } - 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 guardians: Vec
= env - .storage() - .instance() - .get(&StorageKey::Guardians) - .unwrap_or_else(|| Vec::new(&env)); - let threshold: u32 = env - .storage() - .instance() - .get(&StorageKey::GuardianThreshold) - .unwrap_or(0); - if !guardians.is_empty() && threshold == 0 { - panic_with_error!(&env, Error::InvalidThreshold); - } - env.storage().persistent().set(&StorageKey::AdminWithdrawalApprovals, &0u32); - - 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, - }, - ); - } - - /// Guardian approval for an admin withdrawal request (issue #733). - pub fn approve_admin_withdrawal(env: Env, guardian: 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 approvals: u32 = env - .storage() - .persistent() - .get(&StorageKey::AdminWithdrawalApprovals) - .unwrap_or(0); - env.storage() - .persistent() - .set(&StorageKey::AdminWithdrawalApprovals, &(approvals + 1)); - } - - /// 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) { - let _guard = ReentrancyGuard::enter(&env); - 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 threshold: u32 = env - .storage() - .instance() - .get(&StorageKey::GuardianThreshold) - .unwrap_or(0); - if threshold > 0 { - let approvals: u32 = env - .storage() - .persistent() - .get(&StorageKey::AdminWithdrawalApprovals) - .unwrap_or(0); - if approvals < threshold { - panic_with_error!(&env, Error::Unauthorized); - } - } - - 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); } - - // CEI: Persist state changes before external transfer - 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))); - - 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); - - 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. - /// - /// Issue #774: once an initial guardian set is in place, `set_guardians` - /// can only *expand* the committee or raise the threshold. Admin alone - /// cannot drop an existing guardian — doing so would let a single - /// compromised admin key dismantle the multisig. Use - /// `propose_remove_guardian` + `approve_remove_guardian` to shrink the - /// set, which routes removal through the remaining guardians. The - /// initial `set_guardians` call (when no guardians are configured yet) - /// is unaffected: that is the bootstrapping step and must be callable. - 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); - } - - // Issue #774: when a guardian set already exists, every current - // guardian must appear in the new set. Removing one via this path - // would route around the propose/approve flow below. - let existing: Vec
= env - .storage() - .instance() - .get(&StorageKey::Guardians) - .unwrap_or_else(|| Vec::new(&env)); - if !existing.is_empty() { - for current in existing.iter() { - let mut retained = false; - for g in guardians.iter() { - if g == current { - retained = true; - break; - } - } - if !retained { - panic_with_error!(&env, Error::GuardianRemoveBlocked); - } - } - } - - 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 }, - ); - } - - /// Minimum guardian count enforced after a guardian removal (issue #774). - /// Prevents the committee from being reduced to a single key, which - /// would defeat the purpose of a multisig. - const MIN_GUARDIANS: u32 = 2; - - /// Issue #774: propose removal of `guardian_to_remove` from the active - /// guardian set. Admin-only to initiate, but the removal only takes - /// effect once `approve_remove_guardian` collects `threshold` approvals - /// from the *other* guardians. - /// - /// Panics with: - /// - `NoPendingUpgrade` if guardians are not configured (threshold == 0), - /// since there is nothing to remove. - /// - `NotAGuardianToRemove` if the named address is not a current guardian. - /// - `MinGuardiansRequired` if removal would drop the set below - /// `MIN_GUARDIANS`. - /// - `AdminTransferPending` if a prior proposal is still armed; the - /// earlier one must complete or be cancelled first so this one does - /// not silently clear accumulated approvals. - pub fn propose_remove_guardian(env: Env, admin: Address, guardian_to_remove: Address) { - Self::require_admin(&env, &admin); - - let guardians: Vec
= env - .storage() - .instance() - .get(&StorageKey::Guardians) - .unwrap_or_else(|| Vec::new(&env)); - let threshold: u32 = env - .storage() - .instance() - .get(&StorageKey::GuardianThreshold) - .unwrap_or(0); - if threshold == 0 || guardians.is_empty() { - panic_with_error!(&env, Error::NoPendingUpgrade); - } - - let mut is_guardian = false; - for g in guardians.iter() { - if g == guardian_to_remove { - is_guardian = true; - break; - } - } - if !is_guardian { - panic_with_error!(&env, Error::NotGuardian); - } - - if guardians.len() <= Self::MIN_GUARDIANS { - panic_with_error!(&env, Error::MinGuardiansUnmet); - } - - let existing: Option = - env.storage().instance().get(&StorageKey::PendingGuardianRemoval); - if existing.is_some() { - panic_with_error!(&env, Error::AdminTransferPending); - } - - let pending = PendingGuardianRemoval { - guardian_to_remove, - approvals: Vec::new(&env), - }; - env.storage() - .instance() - .set(&StorageKey::PendingGuardianRemoval, &pending); - } - - /// Issue #774: a guardian approves the pending removal. Each guardian - /// may approve at most once. When approvals reach `threshold`, the - /// removal is applied immediately. The guardian being removed cannot - /// vote on their own removal. - pub fn approve_remove_guardian(env: Env, guardian: 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: PendingGuardianRemoval = env - .storage() - .instance() - .get(&StorageKey::PendingGuardianRemoval) - .unwrap_or_else(|| panic_with_error!(&env, Error::NoPendingUpgrade)); - - // The guardian scheduled for removal cannot vote on their own exit. - if guardian == pending.guardian_to_remove { - panic_with_error!(&env, Error::Unauthorized); - } - - 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 { - // Apply the removal: build the new guardian set without the - // removed address. Clear the pending slot before applying so - // nothing can observe an inconsistent transient state. - env.storage().instance().remove(&StorageKey::PendingGuardianRemoval); - - let mut new_set: Vec
= Vec::new(&env); - for g in guardians.iter() { - if g != pending.guardian_to_remove { - new_set.push_back(g); - } - } - // Preserve `MIN_GUARDIANS` after removal (double-check: the - // propose side already enforces this, but the invariant is - // critical enough to re-verify at execution time). - if new_set.len() < Self::MIN_GUARDIANS { - panic_with_error!(&env, Error::MinGuardiansUnmet); - } - // If the threshold now exceeds the new set size, cap it. - let new_threshold = if threshold > new_set.len() { - new_set.len() - } else { - threshold - }; - env.storage().instance().set(&StorageKey::Guardians, &new_set); - env.storage() - .instance() - .set(&StorageKey::GuardianThreshold, &new_threshold); - env.events().publish( - (Symbol::new(&env, "guardians_updated"),), - GuardiansUpdated { - guardians: new_set, - threshold: new_threshold, - }, - ); - } else { - env.storage() - .instance() - .set(&StorageKey::PendingGuardianRemoval, &pending); - } - } - - /// Issue #774: admin may cancel a pending guardian-removal proposal - /// before it reaches the approval threshold. - pub fn cancel_pending_guardian_removal(env: Env, admin: Address) { - Self::require_admin(&env, &admin); - if !env.storage().instance().has(&StorageKey::PendingGuardianRemoval) { - panic_with_error!(&env, Error::NoPendingUpgrade); - } - env.storage().instance().remove(&StorageKey::PendingGuardianRemoval); - } - - /// Read the currently armed guardian-removal proposal, if any. - pub fn get_pending_guardian_removal(env: Env) -> Option { - env.storage().instance().get(&StorageKey::PendingGuardianRemoval) - } - - /// 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. - /// - /// Panics with `AdminTransferPending` while a transfer is already armed: - /// re-proposing over an armed transfer would rewrite `PendingAdminSince` - /// and reset the `ADMIN_TRANSFER_TIMELOCK`, letting the current admin - /// keep a transfer perpetually un-acceptable (issue #457). - pub fn propose_new_admin(env: Env, admin: Address, new_admin: Address) { - Self::require_admin(&env, &admin); - Self::validate_stellar_address(&env, &new_admin); - Self::require_not_zero_address(&env, &new_admin); - - // Issue #457: reject a fresh proposal while one is already pending — - // the armed transfer must complete (be accepted) before another can - // be made, so the timelock clock can never be reset by re-proposing. - let pending: Option
= env.storage().instance() - .get(&StorageKey::PendingAdmin) - .unwrap_or(None); - if pending.is_some() { - panic_with_error!(&env, Error::AdminTransferPending); - } - - 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); - // Issue #457: arm the timelock at most once per transfer — never - // overwrite an already-armed `PendingAdminSince`, or the - // `ADMIN_TRANSFER_TIMELOCK` would restart from this later moment. - if !env.storage().instance().has(&StorageKey::PendingAdminSince) { - 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(); - Self::require_not_zero_address(&env, &admin); - 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(); - } - - /// Panic with `AdminCannotBeLp` if `caller` is the contract admin. - /// - /// Separation of duties for the LP side of the pool (issue #568). Kept - /// deliberately distinct from `Unauthorized` so an integrator can tell - /// "this address is barred from being an LP" from "this address is not - /// allowed to do that". - fn require_not_admin(env: &Env, caller: &Address) { - let admin: Option
= env.storage().instance().get(&StorageKey::Admin); - if admin.as_ref() == Some(caller) { - panic_with_error!(env, Error::AdminCannotBeLp); - } - } - - /// 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); - } - } - - /// Rejects the Stellar zero address (all-A account), which cannot hold or - /// move funds — a deposit made to it would lock capital with no recovery - /// path (issue #647). - fn require_not_zero_address(env: &Env, address: &Address) { - let zero = Address::from_string(&soroban_sdk::String::from_str( - env, - "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF", - )); - if *address == zero { - 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 - } - - /// Settle expired exit requests among the next `max_scan` LP slots. - /// - /// `skip` is the caller of the enclosing pool interaction: their own - /// request is left alone so a direct `withdraw` of the same shares still - /// works. A request is only settled when the withdrawal is guaranteed to - /// succeed (liquidity available, position intact), because a panic here - /// would revert the unrelated interaction that triggered the sweep. - fn sweep_expired_exits(env: &Env, max_scan: u32, skip: Option<&Address>) -> u32 { - let lp_count: u32 = env.storage().instance().get(&StorageKey::LpCount).unwrap_or(0); - let status: PoolStatus = env.storage().instance() - .get(&StorageKey::Status).unwrap_or(PoolStatus::Active); - if max_scan == 0 || lp_count == 0 || status == PoolStatus::Paused { - return 0; - } - - let scan = max_scan.min(lp_count); - let start: u32 = env.storage().instance().get(&StorageKey::ExitCursor).unwrap_or(0); - let now = env.ledger().timestamp(); - let mut settled = 0u32; - - for step in 0..scan { - let idx = (start % lp_count + step) % lp_count; - let Some(provider) = env.storage().persistent() - .get::<_, Address>(&StorageKey::LpAddress(idx)) else { continue }; - if skip == Some(&provider) { - continue; - } - let Some(request) = env.storage().persistent() - .get::<_, ExitRequest>(&StorageKey::ExitReq(provider.clone())) else { continue }; - if now < request.claimable_at { - continue; - } - - let position: Option = env.storage().persistent() - .get(&StorageKey::LpPosition(provider.clone())); - let held = position.map(|p| p.shares).unwrap_or(0); - let shares = request.shares.min(held); - if shares <= 0 { - // Nothing left to exit; drop the stale reservation. - Self::clear_exit_reservation(env, &provider, request.shares); - continue; - } - - 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 = total_deposited.saturating_sub(total_locked); - let amount = match shares.checked_mul(total_deposited) { - Some(v) if total_shares > 0 => v / total_shares, - _ => 0, - }; - if amount <= 0 || amount > available { - continue; - } - - Self::clear_exit_reservation(env, &provider, request.shares); - let returned = Self::withdraw_inner(env.clone(), provider.clone(), shares); - env.events().publish( - (Symbol::new(env, "exit_claimed"),), - ExitClaimed { - provider, - shares_burned: shares, - amount_returned: returned, - waited: now.saturating_sub(request.requested_at), - }, - ); - settled += 1; - } - - env.storage().instance().set(&StorageKey::ExitCursor, &((start % lp_count + scan) % lp_count)); - settled - } - - /// 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, - ) { - // SECURITY FIX: Prevent multiple NFTs per provider - // If a provider already has an NFT, we should not mint another one. - // This prevents providers from bypassing position limits or gaining - // disproportionate governance weight. - if env.storage().persistent().has(&StorageKey::ProviderNft(provider.clone())) { - panic_with_error!(env, Error::ProviderAlreadyHasNft); - } - - let token_id: u64 = env.storage().instance() - .get(&StorageKey::NextNftId).unwrap_or(1); - // The u64 ID space is finite. Never wrap back to zero and risk - // overwriting an existing LP NFT when the final ID is consumed. - let next_token_id = token_id - .checked_add(1) - .unwrap_or_else(|| panic_with_error!(env, Error::Overflow)); - env.storage().instance().set(&StorageKey::NextNftId, &next_token_id); - - 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, - }, - ); - } - - /// Set or update the pool's human-readable name, enforcing length limits (issue #748). - pub fn set_pool_name(env: Env, admin: Address, name: String) { - Self::require_admin(&env, &admin); - if name.len() == 0 || name.len() > MAX_POOL_NAME_LEN { - panic_with_error!(&env, Error::NameTooLong); - } - env.storage().instance().set(&StorageKey::PoolName, &name); - } - - /// Retrieve the pool's human-readable name if configured (issue #748). - pub fn get_pool_name(env: Env) -> Option { - env.storage().instance().get(&StorageKey::PoolName) - } -} - -#[cfg(test)] -mod test; -#[cfg(test)] -mod test_advanced; -#[cfg(test)] -mod test_edge; -#[cfg(test)] -mod test_exit_queue; -#[cfg(test)] -mod test_reinsurance; -#[cfg(test)] -mod test_reentrancy; - -#[cfg(test)] -mod test_guardians; +//! 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. +// 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)] +#![no_std] + +use soroban_sdk::{ + contract, contractimpl, contracttype, contracterror, panic_with_error, + token, Address, Env, IntoVal, String, Symbol, Val, 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. +#[allow(dead_code)] +#[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). +pub const MIN_DEPOSIT: i128 = 1_000_000; + +/// Minimum shares that must be minted on deposit (issue #493). +/// Enforces that calculated shares cannot be 0 or infinitesimal when depositing into large pools. +pub const MIN_SHARES: i128 = 1; + +/// Scaling factor for share price calculation (1e9 = 1_000_000_000) (issue #770). +pub const SHARE_PRICE_SCALE: i128 = 1_000_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. +pub const MAX_EXIT_DELAY: u64 = 30 * 24 * 60 * 60; + +/// Most LP slots inspected by one auto-processing pass (issue #511). +/// +/// Every scanned slot costs storage reads, so the sweep is capped to keep +/// `deposit`/`withdraw`/`request_exit` cheap. The cursor rotates, so repeated +/// pool interactions cover every provider over time. +const MAX_AUTO_EXIT_SCAN: u32 = 5; + +/// 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; + +// Shared protocol constants — single source of truth in parashield-common (issue #342). +use parashield_common::{TTL_THRESHOLD, TTL_EXTEND_TO, ADMIN_TRANSFER_TIMELOCK}; + +#[contracttype] +pub(crate) 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 pending guardian-removal proposal awaiting approvals from the + /// remaining guardians. Issue #774: admin alone can no longer dismantle + /// the guardian multisig — removals require threshold-many approvals + /// from guardians other than the one being removed. + PendingGuardianRemoval, + /// 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, + /// Next `LpAddress` index (u32) the expired-exit sweep will inspect, so + /// successive sweeps rotate through all providers. + ExitCursor, + /// 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, + /// Reentrancy guard flag to prevent reentrant external calls. + ReentrancyGuard, + /// Human-readable pool name (issue #748). + PoolName, + /// Approvals count for pending admin withdrawal (issue #733). + AdminWithdrawalApprovals, +} + +/// Maximum pool name length in bytes/characters (issue #748). +pub const MAX_POOL_NAME_LEN: u32 = 32; + +#[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, + /// An admin transfer was proposed while another transfer is still + /// pending, which would reset the transfer timelock (issue #457). + AdminTransferPending = 40, + /// The pool category `Symbol` is empty (issue #520). + InvalidCategory = 41, + /// The deposit amount is not a multiple of the minimum deposit unit (issue #524). + InvalidAmount = 42, + /// A provider already holds an LP NFT, so a second one must not be minted. + ProviderAlreadyHasNft = 43, + /// The contract admin tried to become an LP, either by depositing or by + /// being the receiving side of a position transfer. The admin sets the + /// pool's risk parameters and can authorise emergency withdrawals, so it + /// must not also be exposed to them (issue #568). + AdminCannotBeLp = 44, + /// Delay exceeds the maximum allowed exit delay (issue #562) + ExitDelayTooLong = 45, + /// No liquid capital remains for a withdrawal. + PoolDrained = 46, + /// Reentrant call detected and blocked by reentrancy guard. + Reentrancy = 47, + /// Pool name exceeds maximum allowable length or is empty (issue #748). + NameTooLong = 48, + /// Issue #774: admin cannot shrink the guardian set via `set_guardians`. + GuardianRemoveBlocked = 49, + /// Issue #774: removal would drop the committee below MIN_GUARDIANS. + MinGuardiansUnmet = 50, +} + +/// RAII Reentrancy Guard that sets transient/instance lock on entry and cleans up on drop. +pub(crate) struct ReentrancyGuard(Env); + +impl ReentrancyGuard { + pub(crate) fn enter(env: &Env) -> Self { + if env.storage().instance().has(&StorageKey::ReentrancyGuard) { + panic_with_error!(env, Error::Reentrancy); + } + env.storage().instance().set(&StorageKey::ReentrancyGuard, &true); + Self(env.clone()) + } +} + +impl Drop for ReentrancyGuard { + fn drop(&mut self) { + self.0.storage().instance().remove(&StorageKey::ReentrancyGuard); + } +} + +#[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"`) and must be non-empty, otherwise `InvalidCategory` is raised. + /// 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); + } + // Category name cannot be empty and must describe a valid risk category (issue #729, #520). + // Empty category names are rejected to prevent unnamed categories in the pool registry. + if category == Symbol::new(&env, "") { + panic_with_error!(&env, Error::InvalidCategory); + } + // Address validation is deferred to require_auth() calls which + // verify the address on the Soroban network layer. + + Self::require_not_zero_address(&env, &admin); + 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`. + /// + /// The admin cannot be an LP (issue #568). LP principal is the pool's + /// solvency backstop, and the admin also sets capacity, fees, tier + /// discounts, exit delay and the emergency withdrawal that pays an LP + /// out. An admin who is also an LP is short the pool with one hand and + /// entitled to withdraw from it with the other, and a bad fee or + /// capacity change lands on their own deposit. The separation costs + /// nothing here — the pool already requires third-party liquidity to + /// function — so the conflict is refused outright rather than bounded. + /// Deposit liquidity specifying the deposit token address, verifying it matches the pool's token (issue #738). + pub fn deposit_with_token( + env: Env, + provider: Address, + deposit_token: Address, + amount: i128, + min_shares: i128, + compound_enabled: bool, + ) -> i128 { + let usdc: Address = env.storage().instance() + .get(&StorageKey::UsdcToken) + .unwrap_or_else(|| panic_with_error!(&env, Error::NotInitialized)); + if deposit_token != usdc { + panic_with_error!(&env, Error::InvalidToken); + } + Self::deposit(env, provider, amount, min_shares, compound_enabled) + } + + pub fn deposit( + env: Env, + provider: Address, + amount: i128, + min_shares: i128, + compound_enabled: bool, + ) -> i128 { + let _guard = ReentrancyGuard::enter(&env); + // SECURITY FIX: Validate address format before processing to prevent + // deposits from being locked at invalid addresses + Self::validate_stellar_address(&env, &provider); + // Zero address cannot hold or move funds; a deposit to it would lock + // capital permanently with no recovery path (issue #647). + Self::require_not_zero_address(&env, &provider); + + provider.require_auth(); + Self::require_not_admin(&env, &provider); + if amount <= 0 { panic_with_error!(&env, Error::ZeroAmount); } + if amount < MIN_DEPOSIT { panic_with_error!(&env, Error::DepositTooSmall); } + if amount % MIN_DEPOSIT != 0 { panic_with_error!(&env, Error::InvalidAmount); } + Self::assert_active(&env); + Self::sweep_expired_exits(&env, MAX_AUTO_EXIT_SCAN, Some(&provider)); + + 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); + + // SECURITY FIX: Check for overflow before adding to total_deposited. + // Large deposits could cause arithmetic overflow and corrupt pool state. + // Validate using checked_add before proceeding with any state changes. + let new_total_deposited = total_deposited.checked_add(amount) + .unwrap_or_else(|| panic_with_error!(&env, Error::Overflow)); + + // SECURITY FIX: Enforce the configured pool size cap before accepting new liquidity. + // Each pool is category-specific, so this enforces category-level deposit limits. + // Validate that the new deposit would not exceed the category's configured capacity. + let capacity = Self::capacity(&env); + if new_total_deposited > capacity.max_total_deposited { + panic_with_error!(&env, Error::PoolCapExceeded); + } + + // SECURITY FIX: Additional validation to prevent deposits that would push + // the pool beyond safe utilization limits for this category. + // Check that current utilization doesn't already exceed the limit before accepting more funds. + let total_locked: i128 = env.storage().instance().get(&StorageKey::TotalLocked).unwrap_or(0); + let current_utilization_bps = if total_deposited > 0 { + total_locked.checked_mul(10_000) + .and_then(|v| v.checked_div(total_deposited)) + .unwrap_or(0) as u32 + } else { + 0 + }; + + // If utilization is already at or above max, reject new deposits to prevent + // over-leveraging the category pool beyond its risk tolerance. + if current_utilization_bps >= capacity.max_utilization_bps { + panic_with_error!(&env, Error::PoolCapExceeded); + } + + // Both branches use checked arithmetic: a share calculation that + // cannot be represented traps as a typed `Overflow` rather than + // silently wrapping or truncating (issue #454). + let mut burn_shares = 0i128; + let new_shares = if total_shares == 0 { + let minted = amount.checked_mul(1_000_000_000) // 1 share = 1 USDC * 1e9 precision + .unwrap_or_else(|| panic_with_error!(&env, Error::Overflow)); + // SECURITY FIX: first-depositor attack mitigation + // Permanently lock the first 1,000 shares to prevent a first depositor + // from manipulating the share price to steal from subsequent depositors. + burn_shares = 1_000; + if minted <= burn_shares { + panic_with_error!(&env, Error::DepositTooSmall); + } + minted - burn_shares + } else { + // Issue #621, #770: Use precise arithmetic with consistent round-half-up + // rounding to prevent precision loss and value leakage on small deposits. + // Formula: (amount * total_shares + total_deposited / 2) / total_deposited + let prod = amount.checked_mul(total_shares) + .unwrap_or_else(|| panic_with_error!(&env, Error::Overflow)); + let half = total_deposited / 2; + prod.checked_add(half) + .and_then(|v| v.checked_div(total_deposited)) + .unwrap_or_else(|| panic_with_error!(&env, Error::Overflow)) + }; + + // Issue #454 / #493: `amount * total_shares / total_deposited` truncates + // toward zero, so a deposit that is small relative to the pool can + // round to 0 shares. MIN_DEPOSIT alone cannot rule this out (the + // share-to-deposit ratio is not fixed), and without this guard the + // depositor's tokens would be taken while nothing is minted in + // return — an irreversible loss. Enforce minimum share amount and reject. + if new_shares < MIN_SHARES { + 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(); + let token_client = token::Client::new(&env, &usdc); + let this_pool = env.current_contract_address(); + let balance_before = token_client.balance(&this_pool); + // Explicitly verify the token transfer succeeded before crediting + // shares (issue #617). Using try_transfer and checking the result + // ensures a failed transfer is caught here rather than silently + // minting shares against funds that never arrived. + match token_client.try_transfer(&provider, &this_pool, &amount) { + Ok(Ok(())) => {} + _ => { + panic_with_error!(&env, Error::InvalidToken); + } + } + // The pool only ever takes the token it was configured with. Confirm + // that token actually delivered the full amount, so a token that + // under-delivers cannot mint shares against funds that never arrived + // (issue #552). + let received = token_client + .balance(&this_pool) + .checked_sub(balance_before) + .unwrap_or(0); + if received != amount { + panic_with_error!(&env, Error::InvalidToken); + } + + 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 position: LpPosition = match env.storage().persistent().get::<_, LpPosition>(&lp_key) { + Some(mut pos) => { + pending_yield = Self::settle_yield(&env, &mut pos); + pos.deposited = pos.deposited.checked_add(amount) + .unwrap_or_else(|| panic_with_error!(&env, Error::Overflow)); + pos.shares = pos.shares.checked_add(new_shares) + .unwrap_or_else(|| panic_with_error!(&env, Error::Overflow)); + let acc_per_share: i128 = env.storage().instance().get(&StorageKey::AccumulatedPerShare).unwrap_or(0); + pos.yield_debt = acc_per_share.checked_mul(pos.shares) + .and_then(|v| v.checked_div(1_000_000_000_000)) + .unwrap_or_else(|| panic_with_error!(&env, Error::Overflow)); + 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); + let yield_debt = acc_per_share.checked_mul(new_shares) + .and_then(|v| v.checked_div(1_000_000_000_000)) + .unwrap_or_else(|| panic_with_error!(&env, Error::Overflow)); + LpPosition { + provider: provider.clone(), + deposited: amount, + shares: new_shares, + yield_claimed: 0, + yield_debt, + 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); + // Use the pre-validated new_total_deposited calculated earlier + let new_total_shares = total_shares.checked_add(new_shares) + .and_then(|s| s.checked_add(burn_shares)) + .unwrap_or_else(|| panic_with_error!(&env, Error::Overflow)); + env.storage().instance().set(&StorageKey::TotalDeposited, &new_total_deposited); + env.storage().instance().set(&StorageKey::TotalShares, &new_total_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 { + let _guard = ReentrancyGuard::enter(&env); + provider.require_auth(); + if shares <= 0 { + panic_with_error!(&env, Error::ZeroAmount); + } + Self::sweep_expired_exits(&env, MAX_AUTO_EXIT_SCAN, Some(&provider)); + Self::withdraw_inner(env.clone(), 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); } + + // SECURITY FIX: Ensure available liquidity accounts for pending claims. + // The LP's proportional share of locked capital must be reserved. + let lp_share_of_locked = if total_shares > 0 { + (position.shares * total_locked) / total_shares + } else { + 0 + }; + + let available_liquidity = total_deposited.saturating_sub(total_locked); + if available_liquidity <= 0 { panic_with_error!(&env, Error::PoolDrained); } + // Issue #621: shares * total_deposited / total_shares truncates + // toward zero; if the pool has absorbed losses total_deposited + // may be tiny relative to total_shares and a small redemption + // rounds to 0. The ZeroAmount guard below rejects such cases so + // no shares are ever burned without returning capital. + 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); } + + // Issue #725: Update cumulative premium per share and settle yield during withdrawal + let pending_yield = Self::settle_yield(&env, &mut position); + let acc_per_share: i128 = env + .storage() + .instance() + .get(&StorageKey::AccumulatedPerShare) + .unwrap_or(0); + position.deposited = position.deposited.saturating_sub(amount); + position.shares -= shares; + position.yield_debt = (acc_per_share * position.shares) / 1_000_000_000_000; + env.storage().instance().set(&StorageKey::AccumulatedPerShare, &acc_per_share); + 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(); + + // SECURITY FIX: Check the contract's actual USDC balance before attempting transfer + // to ensure the withdrawal won't fail due to insufficient token balance. + let usdc_client = token::Client::new(&env, &usdc); + let contract_balance = usdc_client.balance(&env.current_contract_address()); + if contract_balance < amount { + panic_with_error!(&env, Error::InsufficientFunds); + } + + usdc_client.transfer(&env.current_contract_address(), &provider, &amount); + Self::pay_out_yield(&env, &provider, pending_yield); + + 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 { + let _guard = ReentrancyGuard::enter(&env); + 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 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)); + + Self::update_lp_nft(&env, &provider, &position); + + let usdc: Address = env + .storage() + .instance() + .get(&StorageKey::UsdcToken) + .unwrap(); + let total_payout = amount + pending_yield; + token::Client::new(&env, &usdc).transfer( + &env.current_contract_address(), + &provider, + &total_payout, + ); + + env.events().publish( + (Symbol::new(&env, "emergency_withdraw"), provider.clone()), + (admin, amount, shares), + ); + + amount + } + + /// Transfer `shares` from `from` address to `to` address. + /// Returns the proportional USDC deposit amount transferred. + /// + /// Transfer LP shares between providers, validating amount is strictly positive (issue #717). + pub fn transfer_shares(env: Env, from: Address, to: Address, shares: i128) -> i128 { + if shares <= 0 { + panic_with_error!(&env, Error::ZeroAmount); + } + Self::transfer_position(env, from, to, shares) + } + + /// The admin cannot be the receiving side either (issue #568) — + /// otherwise `transfer_position` is a one-transaction way around the + /// `deposit` restriction and the separation of duties it exists for. + 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::require_not_admin(&env, &to); + 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); + } + + // Issue #621: share-to-amount calculation can underflow to 0 when + // from_pos.deposited has been eroded by losses while shares stayed + // constant. A zero-amount transfer burns the shares without moving + // any capital, so reject it explicitly. + 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)); + if amount == 0 { panic_with_error!(&env, Error::ZeroAmount); } + + 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 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) { + let _guard = ReentrancyGuard::enter(&env); + 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; + + // CEI: update state before outgoing external token transfers + 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)); + + 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); + + 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) { + let _guard = ReentrancyGuard::enter(&env); + 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) { + let _guard = ReentrancyGuard::enter(&env); + 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 { + let _guard = ReentrancyGuard::enter(&env); + 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. + /// Rejects delays exceeding `MAX_EXIT_DELAY` with `ExitDelayTooLong` (issue #743). + 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::ExitDelayTooLong); + } + + 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. + /// + /// Issue #458: if a request is already outstanding for this provider, + /// the new shares are ADDED to the existing entry rather than rejected. + /// The original `requested_at` and `claimable_at` are preserved so a + /// second call cannot silently defer the first request's unlock — a + /// provider who tops up their exit still has to wait for the timelock + /// that started with the initial request. The provider's total shares + /// must still cover the summed exit; otherwise the existing entry is + /// left untouched and the call reverts with `InsufficientFunds`. + pub fn request_exit(env: Env, provider: Address, shares: i128) { + let _guard = ReentrancyGuard::enter(&env); + provider.require_auth(); + if shares <= 0 { + panic_with_error!(&env, Error::ZeroAmount); + } + Self::assert_withdrawable(&env); + Self::sweep_expired_exits(&env, MAX_AUTO_EXIT_SCAN, Some(&provider)); + + let position: LpPosition = env + .storage() + .persistent() + .get(&StorageKey::LpPosition(provider.clone())) + .unwrap_or_else(|| panic_with_error!(&env, Error::NoShares)); + + let req_key = StorageKey::ExitReq(provider.clone()); + // Issue #458: read any existing entry and treat the write as an + // accumulate. `total_shares` is what the provider now expects to + // withdraw at claim time; it must not exceed their live share + // balance. + let existing: Option = env.storage().persistent().get(&req_key); + let (total_shares, requested_at, claimable_at) = match &existing { + Some(prev) => ( + prev.shares.saturating_add(shares), + prev.requested_at, + prev.claimable_at, + ), + None => { + let now = env.ledger().timestamp(); + (shares, now, now.saturating_add(Self::exit_delay(&env))) + } + }; + if position.shares < total_shares { + // Leave `existing` untouched so a failed top-up cannot corrupt + // an already-valid request. + panic_with_error!(&env, Error::InsufficientFunds); + } + + env.storage().persistent().set( + &req_key, + &ExitRequest { + provider: provider.clone(), + shares: total_shares, + requested_at, + claimable_at, + }, + ); + Self::extend_to_max(&env, &req_key); + + let queued: i128 = env + .storage() + .instance() + .get(&StorageKey::QueuedExitShares) + .unwrap_or(0); + // Bump the pool-wide queued counter by the increment only. On a + // fresh request the increment equals the total; on a top-up it + // equals just the newly queued portion. + 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 { + let _guard = ReentrancyGuard::enter(&env); + 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 + } + + /// Settle expired exit requests on behalf of their providers, so LP funds + /// are not stuck waiting for someone to call `claim_exit` (issue #511). + /// + /// Permissionless: proceeds always go to the provider, never the caller. + /// Inspects at most `max_scan` LP slots (capped at `MAX_AUTO_EXIT_SCAN`) + /// starting from a rotating cursor and returns how many exits were settled. + /// Requests the pool cannot currently afford stay queued for a later pass. + pub fn process_expired_exits(env: Env, max_scan: u32) -> u32 { + let _guard = ReentrancyGuard::enter(&env); + Self::sweep_expired_exits(&env, max_scan.min(MAX_AUTO_EXIT_SCAN), None) + } + + /// 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) { + let _guard = ReentrancyGuard::enter(&env); + 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); + } + + // SECURITY FIX: Validate that the reinsurer contract implements the required + // IReinsurer interface by attempting to call a test recover with zero amount. + // This prevents a malicious or buggy contract from being set as the reinsurer + // and potentially draining the pool by returning fraudulent recovery amounts. + // A valid reinsurer must accept this call without panicking. + let test_policy_id = 0u128; + let test_amount = 0i128; + let test_result = env.try_invoke_contract::( + &reinsurer, + &Symbol::new(&env, "recover"), + soroban_sdk::vec![ + &env, + env.current_contract_address().into_val(&env), + test_policy_id.into_val(&env), + test_amount.into_val(&env), + ], + ); + + // If the reinsurer doesn't implement recover() or the call fails, + // reject the configuration + if test_result.is_err() { + 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) { + let _guard = ReentrancyGuard::enter(&env); + 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)); + + // CEI: update state before external transfer + 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); + + let usdc: Address = env.storage().instance().get(&StorageKey::UsdcToken).unwrap(); + token::Client::new(&env, &usdc) + .transfer(&env.current_contract_address(), &config.reinsurer, &amount); + + 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 { + let _guard = ReentrancyGuard::enter(&env); + 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 per-share price scaled by `SHARE_PRICE_SCALE` (1e9) (issue #770). + /// + /// Computes `(total_deposited * 1e9 * SHARE_PRICE_SCALE + total_shares / 2) / total_shares`. + /// Uses 128-bit precise arithmetic with consistent round-half-up rounding to prevent + /// precision loss on small deposits. Returns `SHARE_PRICE_SCALE` if no shares exist. + pub fn get_share_price(env: Env) -> i128 { + 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); + if total_shares == 0 { + return SHARE_PRICE_SCALE; + } + let prod = total_deposited + .checked_mul(1_000_000_000) + .and_then(|v| v.checked_mul(SHARE_PRICE_SCALE)) + .unwrap_or_else(|| panic_with_error!(&env, Error::Overflow)); + let half = total_shares / 2; + prod.checked_add(half) + .and_then(|v| v.checked_div(total_shares)) + .unwrap_or_else(|| panic_with_error!(&env, Error::Overflow)) + } + + /// Calculate the shares minted for a given deposit amount using precise arithmetic + /// and consistent round-half-up rounding (issue #770). + pub fn calculate_deposit_shares(env: Env, amount: i128) -> i128 { + if amount <= 0 { + panic_with_error!(&env, Error::ZeroAmount); + } + 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); + if total_shares == 0 { + let minted = amount.checked_mul(SHARE_PRICE_SCALE) + .unwrap_or_else(|| panic_with_error!(&env, Error::Overflow)); + if minted <= 1_000 { + panic_with_error!(&env, Error::DepositTooSmall); + } + minted - 1_000 + } else { + let prod = amount.checked_mul(total_shares) + .unwrap_or_else(|| panic_with_error!(&env, Error::Overflow)); + let half = total_deposited / 2; + prod.checked_add(half) + .and_then(|v| v.checked_div(total_deposited)) + .unwrap_or_else(|| panic_with_error!(&env, Error::Overflow)) + } + } + + /// Return the pool utilisation rate in basis points ((locked + queued_exits) / deposited × 10,000). + /// Includes committed exit queue shares in utilization calculation (#693). + /// 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); + let queued_exits: i128 = env.storage().instance().get(&StorageKey::QueuedExitShares).unwrap_or(0); + if deposited == 0 { return 0; } + let effective_locked = locked.saturating_add(queued_exits); + let util_bps = effective_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); + } + // Enforce minimum fee floor and reject zero fee config (#688) + if min_fee_bps == 0 || base_fee_bps == 0 { + 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.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 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 || lp_bps > 10_000 || treas_bps < 0 || treas_bps > 10_000 || backstop_bps < 0 || backstop_bps > 10_000 { + 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 || lp_bps > 10_000 || treas_bps < 0 || treas_bps > 10_000 || backstop_bps < 0 || backstop_bps > 10_000 { + 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 + if pending.new_lp_bps < 0 || pending.new_treas_bps < 0 || pending.new_backstop_bps < 0 + || pending.new_lp_bps + pending.new_treas_bps + pending.new_backstop_bps != 10_000 + { + panic_with_error!(&env, Error::InvalidSplit); + } + 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 guardians: Vec
= env + .storage() + .instance() + .get(&StorageKey::Guardians) + .unwrap_or_else(|| Vec::new(&env)); + let threshold: u32 = env + .storage() + .instance() + .get(&StorageKey::GuardianThreshold) + .unwrap_or(0); + if !guardians.is_empty() && threshold == 0 { + panic_with_error!(&env, Error::InvalidThreshold); + } + env.storage().persistent().set(&StorageKey::AdminWithdrawalApprovals, &0u32); + + 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, + }, + ); + } + + /// Guardian approval for an admin withdrawal request (issue #733). + pub fn approve_admin_withdrawal(env: Env, guardian: 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 approvals: u32 = env + .storage() + .persistent() + .get(&StorageKey::AdminWithdrawalApprovals) + .unwrap_or(0); + env.storage() + .persistent() + .set(&StorageKey::AdminWithdrawalApprovals, &(approvals + 1)); + } + + /// 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) { + let _guard = ReentrancyGuard::enter(&env); + 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 threshold: u32 = env + .storage() + .instance() + .get(&StorageKey::GuardianThreshold) + .unwrap_or(0); + if threshold > 0 { + let approvals: u32 = env + .storage() + .persistent() + .get(&StorageKey::AdminWithdrawalApprovals) + .unwrap_or(0); + if approvals < threshold { + panic_with_error!(&env, Error::Unauthorized); + } + } + + 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); } + + // CEI: Persist state changes before external transfer + 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))); + + 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); + + 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. + /// + /// Issue #774: once an initial guardian set is in place, `set_guardians` + /// can only *expand* the committee or raise the threshold. Admin alone + /// cannot drop an existing guardian — doing so would let a single + /// compromised admin key dismantle the multisig. Use + /// `propose_remove_guardian` + `approve_remove_guardian` to shrink the + /// set, which routes removal through the remaining guardians. The + /// initial `set_guardians` call (when no guardians are configured yet) + /// is unaffected: that is the bootstrapping step and must be callable. + 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); + } + + // Issue #774: when a guardian set already exists, every current + // guardian must appear in the new set. Removing one via this path + // would route around the propose/approve flow below. + let existing: Vec
= env + .storage() + .instance() + .get(&StorageKey::Guardians) + .unwrap_or_else(|| Vec::new(&env)); + if !existing.is_empty() { + for current in existing.iter() { + let mut retained = false; + for g in guardians.iter() { + if g == current { + retained = true; + break; + } + } + if !retained { + panic_with_error!(&env, Error::GuardianRemoveBlocked); + } + } + } + + 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 }, + ); + } + + /// Minimum guardian count enforced after a guardian removal (issue #774). + /// Prevents the committee from being reduced to a single key, which + /// would defeat the purpose of a multisig. + const MIN_GUARDIANS: u32 = 2; + + /// Issue #774: propose removal of `guardian_to_remove` from the active + /// guardian set. Admin-only to initiate, but the removal only takes + /// effect once `approve_remove_guardian` collects `threshold` approvals + /// from the *other* guardians. + /// + /// Panics with: + /// - `NoPendingUpgrade` if guardians are not configured (threshold == 0), + /// since there is nothing to remove. + /// - `NotAGuardianToRemove` if the named address is not a current guardian. + /// - `MinGuardiansRequired` if removal would drop the set below + /// `MIN_GUARDIANS`. + /// - `AdminTransferPending` if a prior proposal is still armed; the + /// earlier one must complete or be cancelled first so this one does + /// not silently clear accumulated approvals. + pub fn propose_remove_guardian(env: Env, admin: Address, guardian_to_remove: Address) { + Self::require_admin(&env, &admin); + + let guardians: Vec
= env + .storage() + .instance() + .get(&StorageKey::Guardians) + .unwrap_or_else(|| Vec::new(&env)); + let threshold: u32 = env + .storage() + .instance() + .get(&StorageKey::GuardianThreshold) + .unwrap_or(0); + if threshold == 0 || guardians.is_empty() { + panic_with_error!(&env, Error::NoPendingUpgrade); + } + + let mut is_guardian = false; + for g in guardians.iter() { + if g == guardian_to_remove { + is_guardian = true; + break; + } + } + if !is_guardian { + panic_with_error!(&env, Error::NotGuardian); + } + + if guardians.len() <= Self::MIN_GUARDIANS { + panic_with_error!(&env, Error::MinGuardiansUnmet); + } + + let existing: Option = + env.storage().instance().get(&StorageKey::PendingGuardianRemoval); + if existing.is_some() { + panic_with_error!(&env, Error::AdminTransferPending); + } + + let pending = PendingGuardianRemoval { + guardian_to_remove, + approvals: Vec::new(&env), + }; + env.storage() + .instance() + .set(&StorageKey::PendingGuardianRemoval, &pending); + } + + /// Issue #774: a guardian approves the pending removal. Each guardian + /// may approve at most once. When approvals reach `threshold`, the + /// removal is applied immediately. The guardian being removed cannot + /// vote on their own removal. + pub fn approve_remove_guardian(env: Env, guardian: 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: PendingGuardianRemoval = env + .storage() + .instance() + .get(&StorageKey::PendingGuardianRemoval) + .unwrap_or_else(|| panic_with_error!(&env, Error::NoPendingUpgrade)); + + // The guardian scheduled for removal cannot vote on their own exit. + if guardian == pending.guardian_to_remove { + panic_with_error!(&env, Error::Unauthorized); + } + + 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 { + // Apply the removal: build the new guardian set without the + // removed address. Clear the pending slot before applying so + // nothing can observe an inconsistent transient state. + env.storage().instance().remove(&StorageKey::PendingGuardianRemoval); + + let mut new_set: Vec
= Vec::new(&env); + for g in guardians.iter() { + if g != pending.guardian_to_remove { + new_set.push_back(g); + } + } + // Preserve `MIN_GUARDIANS` after removal (double-check: the + // propose side already enforces this, but the invariant is + // critical enough to re-verify at execution time). + if new_set.len() < Self::MIN_GUARDIANS { + panic_with_error!(&env, Error::MinGuardiansUnmet); + } + // If the threshold now exceeds the new set size, cap it. + let new_threshold = if threshold > new_set.len() { + new_set.len() + } else { + threshold + }; + env.storage().instance().set(&StorageKey::Guardians, &new_set); + env.storage() + .instance() + .set(&StorageKey::GuardianThreshold, &new_threshold); + env.events().publish( + (Symbol::new(&env, "guardians_updated"),), + GuardiansUpdated { + guardians: new_set, + threshold: new_threshold, + }, + ); + } else { + env.storage() + .instance() + .set(&StorageKey::PendingGuardianRemoval, &pending); + } + } + + /// Issue #774: admin may cancel a pending guardian-removal proposal + /// before it reaches the approval threshold. + pub fn cancel_pending_guardian_removal(env: Env, admin: Address) { + Self::require_admin(&env, &admin); + if !env.storage().instance().has(&StorageKey::PendingGuardianRemoval) { + panic_with_error!(&env, Error::NoPendingUpgrade); + } + env.storage().instance().remove(&StorageKey::PendingGuardianRemoval); + } + + /// Read the currently armed guardian-removal proposal, if any. + pub fn get_pending_guardian_removal(env: Env) -> Option { + env.storage().instance().get(&StorageKey::PendingGuardianRemoval) + } + + /// 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. + /// + /// Panics with `AdminTransferPending` while a transfer is already armed: + /// re-proposing over an armed transfer would rewrite `PendingAdminSince` + /// and reset the `ADMIN_TRANSFER_TIMELOCK`, letting the current admin + /// keep a transfer perpetually un-acceptable (issue #457). + pub fn propose_new_admin(env: Env, admin: Address, new_admin: Address) { + Self::require_admin(&env, &admin); + Self::validate_stellar_address(&env, &new_admin); + Self::require_not_zero_address(&env, &new_admin); + + // Issue #457: reject a fresh proposal while one is already pending — + // the armed transfer must complete (be accepted) before another can + // be made, so the timelock clock can never be reset by re-proposing. + let pending: Option
= env.storage().instance() + .get(&StorageKey::PendingAdmin) + .unwrap_or(None); + if pending.is_some() { + panic_with_error!(&env, Error::AdminTransferPending); + } + + 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); + // Issue #457: arm the timelock at most once per transfer — never + // overwrite an already-armed `PendingAdminSince`, or the + // `ADMIN_TRANSFER_TIMELOCK` would restart from this later moment. + if !env.storage().instance().has(&StorageKey::PendingAdminSince) { + 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(); + Self::require_not_zero_address(&env, &admin); + 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(); + } + + /// Panic with `AdminCannotBeLp` if `caller` is the contract admin. + /// + /// Separation of duties for the LP side of the pool (issue #568). Kept + /// deliberately distinct from `Unauthorized` so an integrator can tell + /// "this address is barred from being an LP" from "this address is not + /// allowed to do that". + fn require_not_admin(env: &Env, caller: &Address) { + let admin: Option
= env.storage().instance().get(&StorageKey::Admin); + if admin.as_ref() == Some(caller) { + panic_with_error!(env, Error::AdminCannotBeLp); + } + } + + /// 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); + } + } + + /// Rejects the Stellar zero address (all-A account), which cannot hold or + /// move funds — a deposit made to it would lock capital with no recovery + /// path (issue #647). + fn require_not_zero_address(env: &Env, address: &Address) { + let zero = Address::from_string(&soroban_sdk::String::from_str( + env, + "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF", + )); + if *address == zero { + 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 + } + + /// Settle expired exit requests among the next `max_scan` LP slots. + /// + /// `skip` is the caller of the enclosing pool interaction: their own + /// request is left alone so a direct `withdraw` of the same shares still + /// works. A request is only settled when the withdrawal is guaranteed to + /// succeed (liquidity available, position intact), because a panic here + /// would revert the unrelated interaction that triggered the sweep. + fn sweep_expired_exits(env: &Env, max_scan: u32, skip: Option<&Address>) -> u32 { + let lp_count: u32 = env.storage().instance().get(&StorageKey::LpCount).unwrap_or(0); + let status: PoolStatus = env.storage().instance() + .get(&StorageKey::Status).unwrap_or(PoolStatus::Active); + if max_scan == 0 || lp_count == 0 || status == PoolStatus::Paused { + return 0; + } + + let scan = max_scan.min(lp_count); + let start: u32 = env.storage().instance().get(&StorageKey::ExitCursor).unwrap_or(0); + let now = env.ledger().timestamp(); + let mut settled = 0u32; + + for step in 0..scan { + let idx = (start % lp_count + step) % lp_count; + let Some(provider) = env.storage().persistent() + .get::<_, Address>(&StorageKey::LpAddress(idx)) else { continue }; + if skip == Some(&provider) { + continue; + } + let Some(request) = env.storage().persistent() + .get::<_, ExitRequest>(&StorageKey::ExitReq(provider.clone())) else { continue }; + if now < request.claimable_at { + continue; + } + + let position: Option = env.storage().persistent() + .get(&StorageKey::LpPosition(provider.clone())); + let held = position.map(|p| p.shares).unwrap_or(0); + let shares = request.shares.min(held); + if shares <= 0 { + // Nothing left to exit; drop the stale reservation. + Self::clear_exit_reservation(env, &provider, request.shares); + continue; + } + + 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 = total_deposited.saturating_sub(total_locked); + let amount = match shares.checked_mul(total_deposited) { + Some(v) if total_shares > 0 => v / total_shares, + _ => 0, + }; + if amount <= 0 || amount > available { + continue; + } + + Self::clear_exit_reservation(env, &provider, request.shares); + let returned = Self::withdraw_inner(env.clone(), provider.clone(), shares); + env.events().publish( + (Symbol::new(env, "exit_claimed"),), + ExitClaimed { + provider, + shares_burned: shares, + amount_returned: returned, + waited: now.saturating_sub(request.requested_at), + }, + ); + settled += 1; + } + + env.storage().instance().set(&StorageKey::ExitCursor, &((start % lp_count + scan) % lp_count)); + settled + } + + /// 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, + ) { + // SECURITY FIX: Prevent multiple NFTs per provider + // If a provider already has an NFT, we should not mint another one. + // This prevents providers from bypassing position limits or gaining + // disproportionate governance weight. + if env.storage().persistent().has(&StorageKey::ProviderNft(provider.clone())) { + panic_with_error!(env, Error::ProviderAlreadyHasNft); + } + + let token_id: u64 = env.storage().instance() + .get(&StorageKey::NextNftId).unwrap_or(1); + // The u64 ID space is finite. Never wrap back to zero and risk + // overwriting an existing LP NFT when the final ID is consumed. + let next_token_id = token_id + .checked_add(1) + .unwrap_or_else(|| panic_with_error!(env, Error::Overflow)); + env.storage().instance().set(&StorageKey::NextNftId, &next_token_id); + + 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, + }, + ); + } + + /// Set or update the pool's human-readable name, enforcing length limits (issue #748). + pub fn set_pool_name(env: Env, admin: Address, name: String) { + Self::require_admin(&env, &admin); + if name.len() == 0 || name.len() > MAX_POOL_NAME_LEN { + panic_with_error!(&env, Error::NameTooLong); + } + env.storage().instance().set(&StorageKey::PoolName, &name); + } + + /// Retrieve the pool's human-readable name if configured (issue #748). + pub fn get_pool_name(env: Env) -> Option { + env.storage().instance().get(&StorageKey::PoolName) + } +} + +#[cfg(test)] +mod test; +#[cfg(test)] +mod test_advanced; +#[cfg(test)] +mod test_edge; +#[cfg(test)] +mod test_exit_queue; +#[cfg(test)] +mod test_reinsurance; +#[cfg(test)] +mod test_reentrancy; + +#[cfg(test)] +mod test_guardians; + +#[cfg(test)] +mod test_precision; diff --git a/contracts/risk-pool/src/test_precision.rs b/contracts/risk-pool/src/test_precision.rs new file mode 100644 index 0000000..7818faa --- /dev/null +++ b/contracts/risk-pool/src/test_precision.rs @@ -0,0 +1,99 @@ +#![allow(clippy::inconsistent_digit_grouping)] +#![cfg(test)] + +//! Regression and unit tests for issue #770: +//! RiskPool share price calculation loses precision on small deposits. +//! Covers precise arithmetic, consistent round-half-up rounding policy, +//! and `get_share_price` / `calculate_deposit_shares`. + +use soroban_sdk::{testutils::Address as _, token, Address, Env, Symbol}; + +use crate::{RiskPool, RiskPoolClient, MIN_DEPOSIT, SHARE_PRICE_SCALE}; + +fn setup() -> (Env, RiskPoolClient<'static>, 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, admin, usdc_id) +} + +#[test] +fn test_initial_share_price_equals_scale() { + let (_, pool, _, _) = setup(); + // In empty pool, share price must equal base SHARE_PRICE_SCALE (1e9) + assert_eq!(pool.get_share_price(), SHARE_PRICE_SCALE); +} + +#[test] +fn test_small_deposit_exact_shares_and_no_value_leak() { + let (env, pool, _, usdc_id) = setup(); + let lp1 = Address::generate(&env); + let lp2 = Address::generate(&env); + let token = token::StellarAssetClient::new(&env, &usdc_id); + + // Initial deposit by LP1: 1,000 USDC (10,000,000,000 stroops) + token.mint(&lp1, &1_000_0000000i128); + let lp1_shares = pool.deposit(&lp1, &1_000_0000000i128, &0i128, &false); + assert!(lp1_shares > 0); + + // Initial share price after first deposit (1,000 burned shares from 1e19 total minted) + let price = pool.get_share_price(); + assert_eq!(price, SHARE_PRICE_SCALE); + + // LP2 deposits minimum deposit unit (1_000_000 stroops = 0.1 USDC) + token.mint(&lp2, &MIN_DEPOSIT); + let expected_shares = pool.calculate_deposit_shares(&MIN_DEPOSIT); + let lp2_shares = pool.deposit(&lp2, &MIN_DEPOSIT, &0i128, &false); + + // Shares minted MUST match calculate_deposit_shares + assert_eq!(lp2_shares, expected_shares); + // 1_000_000 stroops = 1e6 * 1e9 = 1e15 shares. With round-half-up, the 1000 burned shares + // do not cause truncation loss: LP2 receives exactly 1_000_000_000_000_000 shares! + assert_eq!(lp2_shares, 1_000_000_000_000_000i128); +} + +#[test] +fn test_consecutive_small_deposits_round_consistently() { + let (env, pool, _, usdc_id) = setup(); + let lp1 = Address::generate(&env); + let lp2 = Address::generate(&env); + let lp3 = Address::generate(&env); + let token = token::StellarAssetClient::new(&env, &usdc_id); + + token.mint(&lp1, &500_0000000i128); + pool.deposit(&lp1, &500_0000000i128, &0i128, &false); + + // LP2 and LP3 both deposit identical small amounts + token.mint(&lp2, &MIN_DEPOSIT); + token.mint(&lp3, &MIN_DEPOSIT); + + let shares2 = pool.deposit(&lp2, &MIN_DEPOSIT, &0i128, &false); + let shares3 = pool.deposit(&lp3, &MIN_DEPOSIT, &0i128, &false); + + // Consecutive identical small deposits must receive consistent shares (no drift or leak) + assert_eq!(shares2, shares3); + assert_eq!(shares2, 1_000_000_000_000_000i128); +} diff --git a/docs/economics.md b/docs/economics.md index 6a32bb9..f81bb07 100644 --- a/docs/economics.md +++ b/docs/economics.md @@ -22,6 +22,21 @@ Premium = `coverage_amount × premium_rate_bps / 10_000` Example: 1,000 USDC coverage at 3% rate = 30 USDC premium. +## Policy Trigger Threshold Rules (Issue #773) + +Insurance policies use parametric or threshold-based conditions tied to verified oracle reports. +- **Allowed Range:** Trigger thresholds must be strictly positive and capped at the protocol-wide 7-decimal fixed-point ceiling: + ``` + 0 < trigger_threshold <= 1_000_000_000_000_000_000_000 (10^21) + ``` +- **Boundary Rationale:** + - *Non-positive values (`threshold <= 0`):* Forbidden. A zero or negative threshold in a threshold or parametric comparison (e.g., `value <= threshold` or `value >= threshold`) could cause policies to trigger unconditionally or never trigger. + - *Overflow bound (`threshold > 10^21`):* Forbidden. Prevents astronomical thresholds that exceed oracle metric capabilities or risk numeric overflow in settlement math. +- **Enforcement Points:** + - `create_product`: Validates that `params.trigger_threshold` satisfies `0 < threshold <= 10^21`. + - `buy_policy_inner`: Defense-in-depth re-validation guaranteeing active product threshold remains within bounds. + - `buy_policy_with_threshold`: Allows policyholders to specify their expected threshold, rejecting with `Error::InvalidTriggerThreshold` (code #14) if it diverges from product configuration or protocol bounds. + ## Claim Settlement ``` @@ -34,14 +49,37 @@ Trigger condition met (oracle confirms) RiskPool releases capital lock for this policy ``` +### Multi-Recipient Payout & Escrow Dust Policy (Issue #772) + +When insurance claims are settled across multiple recipients (e.g. syndicated policies, co-insured assets, or designated beneficiaries): +- **Base Allocation:** Payouts are computed using integer division: `base_amount = total_payout / count`. +- **Dust Remainder Handling:** Any remainder `remainder = total_payout % count` is allocated in full to the last recipient: + - Recipient `0 .. count - 1`: `base_amount` + - Last recipient (`count - 1`): `base_amount + remainder` +- **Zero Dust Guarantee:** The sum of recipient disbursements exactly equals the total approved claim payout: + ``` + sum(disbursements) == total_payout + escrow_dust == 0 + ``` + No unaccounted dust accumulates in protocol escrow. + ## LP Share Mechanics - First deposit: 1 share = 1 USDC (no dilution risk at launch) -- Subsequent deposits: `new_shares = deposit_amount × total_shares / total_deposited` +- Subsequent deposits: `new_shares = (deposit_amount × total_shares + total_deposited / 2) / total_deposited` - Shares track proportional ownership of the pool's total USDC balance - Share value decreases after a claim payout (loss socialized across LPs) - Share value increases as premium yield accumulates +### Share Price & Rounding Policy (Issue #770) + +- **Share Price Metric:** Per-share price is queried via `get_share_price()` and scaled by `SHARE_PRICE_SCALE = 1_000_000_000` (1e9). + ``` + share_price = (total_deposited × 1e9 × SHARE_PRICE_SCALE + total_shares / 2) / total_shares + ``` +- **Rounding Consistency:** To avoid truncation value leak on small deposits (where integer division truncates fractions toward zero), calculations apply consistent **round-half-up** arithmetic (`(numerator + denominator / 2) / denominator`). +- **Precision Preservation:** Minimum deposit amounts (`MIN_DEPOSIT = 1_000_000` stroops) receive full proportional share allocation without precision truncation, preventing economic dilution. + ## Utilization Rate ``` @@ -69,6 +107,13 @@ that return unit or another non-`u32` value are treated as fully executed for backward compatibility. A target invocation that fails atomically produces no refund. Passed proposals that expire without execution retain the deposit. +### Proposal Hash Verification on Execution (Issue #771) + +To prevent payload tampering between voting, timelock queueing, and final execution: +- **Hash Commitment at Creation:** When a proposal is created (standard or upgrade), a SHA-256 hash of its complete payload `(target, function, args)` is computed via `env.crypto().sha256(&payload_bytes)` and persistently stored under `StorageKey::ProposalHash(proposal_id)`. +- **Integrity Verification on Execution:** Prior to invoking the proposal target or applying an upgrade, `GovernanceDAO::execute` re-computes the hash of the payload and asserts strict equality with the stored hash. +- **Tampering Defense:** If any component of the payload (`target`, `function`, or `args`) has been tampered with or modified, execution aborts immediately with `Error::HashMismatch` (code #9), guaranteeing end-to-end payload immutability. + ## Risk Categories | Category | Premium Rate | Target APY | Max Coverage |