From 5d845bc163fb1e01ff1b08d8eb78c51d43ab18ca Mon Sep 17 00:00:00 2001 From: KRIS <307999362+kris-nana@users.noreply.github.com> Date: Fri, 28 Aug 2026 02:18:28 +0100 Subject: [PATCH 1/3] fix: exclude Abstain votes from majority calculation (#385) Abstain voting existed but its majority math was wrong: finalize() divided votes_for by total_votes (For + Against + Abstain), so a large abstaining bloc diluted the For share and could sink a proposal every partisan voter backed. Majority is now decided by votes_for / (votes_for + votes_against) only; Abstain still counts toward quorum, matching the documented and tested intent. Also fixes pre-existing compile errors on main unrelated to this issue (introduced by an earlier merge): missing Error::InvalidInput variant, and create_proposal_from_template calling create_proposal with one argument short after impact_analysis was added. Without these the crate does not build at all. --- contracts/governance-dao/src/lib.rs | 36 +++++--- contracts/governance-dao/src/test.rs | 37 +++++++++ contracts/governance-dao/src/test_advanced.rs | 82 +++++++++++++++++++ 3 files changed, 145 insertions(+), 10 deletions(-) diff --git a/contracts/governance-dao/src/lib.rs b/contracts/governance-dao/src/lib.rs index 142d472..924a2af 100644 --- a/contracts/governance-dao/src/lib.rs +++ b/contracts/governance-dao/src/lib.rs @@ -153,6 +153,7 @@ pub enum Error { DiscussionPeriodNotRequired = 39, /// `vote_batch` was called with an empty proposal list. NoProposals = 40, + InvalidInput = 41, } #[contract] @@ -871,12 +872,19 @@ impl GovernanceDao { /// Callable by anyone once `vote_end + FINALIZE_DELAY` has passed (the /// delay buffer prevents finalize being raced at the exact close of /// voting). Quorum is `total_votes >= total_supply * quorum_bps / - /// 10_000`, using the `total_supply` snapshotted at proposal creation. - /// If quorum is met, the proposal Passes only when `votes_for * 10_000 / - /// total_votes >= majority_bps`; otherwise (including the exact-tie - /// case, which lands at 50%) it Fails. Passing also starts the - /// execution timelock (`execution_time = now + proposal_timelock`). - /// The proposer's original deposit is refunded in both outcomes. + /// 10_000`, using the `total_supply` snapshotted at proposal creation, + /// where `total_votes` includes For, Against, *and* Abstain — an + /// abstaining holder still shows up for participation purposes. If + /// quorum is met, the proposal Passes only when `votes_for * 10_000 / + /// (votes_for + votes_against) >= majority_bps`; Abstain is excluded + /// from this ratio entirely, so it counts toward quorum but never + /// drags down (or props up) the For/Against split (issue #385). A + /// proposal with no partisan (For/Against) votes at all — e.g. + /// everyone abstained — has no majority to speak of and Fails, as does + /// the exact-tie case (which lands at exactly 50%). Passing also + /// starts the execution timelock (`execution_time = now + + /// proposal_timelock`). The proposer's original deposit is refunded in + /// both outcomes. pub fn finalize(env: Env, proposal_id: u64) { let mut proposal: Proposal = env .storage() @@ -926,10 +934,17 @@ impl GovernanceDao { } else if total_votes < quorum_needed { proposal.status = ProposalStatus::Failed; } else { - // Guard: prevent division by zero if total_votes == 0 - let for_bps = if total_votes > 0 { - proposal.votes_for.checked_mul(10_000).map(|v| v / total_votes).unwrap_or(0) + // Majority is decided by the partisan (For/Against) split only — + // Abstain already did its job by counting toward quorum above and + // must not also dilute the For share here, otherwise a large + // abstaining bloc could sink a proposal that every partisan voter + // supported (issue #385). + let partisan_votes = proposal.votes_for + proposal.votes_against; + let for_bps = if partisan_votes > 0 { + proposal.votes_for.checked_mul(10_000).map(|v| v / partisan_votes).unwrap_or(0) } else { + // Nobody voted For or Against — an all-Abstain proposal has no + // majority to speak of, regardless of quorum. 0 }; if for_bps >= config.majority_bps as i128 { @@ -1333,6 +1348,7 @@ impl GovernanceDao { target: Address, function: Symbol, args: Vec, + impact_analysis: Bytes, ) -> u64 { let template: ProposalTemplate = env .storage() @@ -1349,7 +1365,7 @@ impl GovernanceDao { panic_with_error!(&env, Error::ArgCountMismatch); } - let proposal_id = Self::create_proposal(env.clone(), proposer, title, target, function, args); + let proposal_id = Self::create_proposal(env.clone(), proposer, title, target, function, args, impact_analysis); env.events().publish( (Symbol::new(&env, "proposal_created_from_template"),), diff --git a/contracts/governance-dao/src/test.rs b/contracts/governance-dao/src/test.rs index d92fa35..b4ac5e8 100644 --- a/contracts/governance-dao/src/test.rs +++ b/contracts/governance-dao/src/test.rs @@ -63,6 +63,7 @@ pub fn setup() -> ( majority_bps: 5_100u32, // 51% voting_period: VOTING_PERIOD, proposal_timelock: 0, + discussion_period: 0, }, ); @@ -97,6 +98,7 @@ fn cannot_initialize_twice() { majority_bps: 0, voting_period: 0, proposal_timelock: 0, + discussion_period: 0, }, ); } @@ -113,6 +115,7 @@ fn create_proposal_increments_counter() { &target, &Symbol::new(&env, "update"), &args, + &Bytes::from_slice(&env, b"Impact analysis: no material risk identified."), ); assert_eq!(id, 0u64); assert_eq!(dao.proposal_count(), 1); @@ -130,6 +133,7 @@ fn create_proposal_below_threshold_fails() { &target, &Symbol::new(&env, "update"), &args, + &Bytes::from_slice(&env, b"Impact analysis: no material risk identified."), ); } @@ -145,6 +149,7 @@ fn vote_for_records_weight() { &target, &Symbol::new(&env, "update"), &args, + &Bytes::from_slice(&env, b"Impact analysis: no material risk identified."), ); dao.vote(&voter1, &pid, &VoteChoice::For); let rec = dao.get_vote(&pid, &voter1).unwrap(); @@ -163,6 +168,7 @@ fn double_vote_fails() { &target, &Symbol::new(&env, "update"), &args, + &Bytes::from_slice(&env, b"Impact analysis: no material risk identified."), ); dao.vote(&voter1, &pid, &VoteChoice::For); dao.vote(&voter1, &pid, &VoteChoice::Against); @@ -179,6 +185,7 @@ fn vote_after_period_fails() { &target, &Symbol::new(&env, "update"), &args, + &Bytes::from_slice(&env, b"Impact analysis: no material risk identified."), ); env.ledger().with_mut(|l| l.timestamp += VOTING_PERIOD + 1); dao.vote(&voter1, &pid, &VoteChoice::For); @@ -196,6 +203,7 @@ fn proposal_passes_with_quorum_and_majority() { &target, &Symbol::new(&env, "update"), &args, + &Bytes::from_slice(&env, b"Impact analysis: no material risk identified."), ); dao.vote(&voter1, &pid, &VoteChoice::For); dao.vote(&voter2, &pid, &VoteChoice::For); @@ -218,6 +226,7 @@ fn proposal_fails_without_quorum() { &target, &Symbol::new(&env, "update"), &args, + &Bytes::from_slice(&env, b"Impact analysis: no material risk identified."), ); env.ledger() @@ -239,6 +248,7 @@ fn finalize_while_voting_open_fails() { &target, &Symbol::new(&env, "update"), &args, + &Bytes::from_slice(&env, b"Impact analysis: no material risk identified."), ); dao.finalize(&pid); } @@ -255,6 +265,7 @@ fn execute_passed_proposal() { &target, &Symbol::new(&env, "update"), &args, + &Bytes::from_slice(&env, b"Impact analysis: no material risk identified."), ); dao.vote(&voter1, &pid, &VoteChoice::For); dao.vote(&voter2, &pid, &VoteChoice::For); @@ -280,6 +291,7 @@ fn execute_failed_proposal_panics() { &target, &Symbol::new(&env, "update"), &args, + &Bytes::from_slice(&env, b"Impact analysis: no material risk identified."), ); // Fast-forward past both voting period AND the 24-hour finalize delay buffer @@ -300,6 +312,7 @@ fn admin_can_cancel_active_proposal() { &target, &Symbol::new(&env, "update"), &args, + &Bytes::from_slice(&env, b"Impact analysis: no material risk identified."), ); dao.cancel(&admin, &pid); let p = dao.get_proposal(&pid); @@ -317,6 +330,7 @@ fn non_admin_cannot_cancel() { &target, &Symbol::new(&env, "update"), &args, + &Bytes::from_slice(&env, b"Impact analysis: no material risk identified."), ); dao.cancel(&voter2, &pid); } @@ -359,6 +373,7 @@ fn test_proposal_timelock_execution() { majority_bps: 5_100u32, voting_period: 604800, proposal_timelock: 604800, + discussion_period: 0, }, ); @@ -369,6 +384,7 @@ fn test_proposal_timelock_execution() { &target, &Symbol::new(&env, "upgrade"), &args, + &Bytes::from_slice(&env, b"Impact analysis: no material risk identified."), ); dao.vote(&voter1, &pid, &VoteChoice::For); @@ -395,6 +411,7 @@ fn test_finalize_cooldown_delay_enforced() { &target, &Symbol::new(&env, "update"), &args, + &Bytes::from_slice(&env, b"Impact analysis: no material risk identified."), ); dao.vote(&voter1, &pid, &VoteChoice::For); @@ -435,6 +452,7 @@ fn test_execute_active_proposal_without_voting_panics() { &target, &Symbol::new(&env, "update"), &args, + &Bytes::from_slice(&env, b"Impact analysis: no material risk identified."), ); // Attempt to execute without any voting - should panic with ProposalNotPassed @@ -454,6 +472,7 @@ fn test_execute_rejected_proposal_panics() { &target, &Symbol::new(&env, "update"), &args, + &Bytes::from_slice(&env, b"Impact analysis: no material risk identified."), ); // Vote against the proposal @@ -497,6 +516,7 @@ fn finalize_with_exactly_tied_votes_fails() { &target, &Symbol::new(&env, "update"), &args, + &Bytes::from_slice(&env, b"Impact analysis: no material risk identified."), ); dao.vote(&voter_a, &pid, &VoteChoice::For); @@ -530,6 +550,7 @@ fn test_finalize_refunds_deposit_locked_at_creation_not_live_config() { &target, &Symbol::new(&env, "update"), &args, + &Bytes::from_slice(&env, b"Impact analysis: no material risk identified."), ); // Deposit was actually taken. @@ -572,6 +593,7 @@ fn test_finalize_does_not_pull_raised_live_threshold() { &target, &Symbol::new(&env, "update"), &args, + &Bytes::from_slice(&env, b"Impact analysis: no material risk identified."), ); // Admin raises the live threshold well above what the contract holds. @@ -602,6 +624,7 @@ fn test_proposal_expires_after_voting_period() { &target, &Symbol::new(&env, "update"), &args, + &Bytes::from_slice(&env, b"Impact analysis: no material risk identified."), ); // Move time past the voting period AND finalize delay buffer (24h) env.ledger().with_mut(|l| l.timestamp += VOTING_PERIOD + (24 * 3600) + 1); @@ -628,6 +651,7 @@ fn test_cancel_refunds_deposit_to_proposer() { &target, &Symbol::new(&env, "update"), &args, + &Bytes::from_slice(&env, b"Impact analysis: no material risk identified."), ); assert_eq!(gov_token.balance(&voter1), balance_before - config.proposal_threshold); @@ -654,6 +678,7 @@ fn run_proposal( target, &Symbol::new(env, "update"), &args, + &Bytes::from_slice(&env, b"Impact analysis: no material risk identified."), ); for (voter, choice) in votes.iter() { @@ -1005,6 +1030,7 @@ fn a_delegate_votes_with_the_combined_weight() { &target, &Symbol::new(&env, "update"), &args, + &Bytes::from_slice(&env, b"Impact analysis: no material risk identified."), ); dao.vote(&voter2, &pid, &VoteChoice::For); @@ -1033,6 +1059,7 @@ fn a_delegator_cannot_also_vote() { &target, &Symbol::new(&env, "update"), &args, + &Bytes::from_slice(&env, b"Impact analysis: no material risk identified."), ); // Authority was handed over; voting too would count the same tokens twice. @@ -1053,6 +1080,7 @@ fn a_delegator_cannot_vote_after_their_delegate_has() { &target, &Symbol::new(&env, "update"), &args, + &Bytes::from_slice(&env, b"Impact analysis: no material risk identified."), ); dao.vote(&voter2, &pid, &VoteChoice::For); @@ -1077,6 +1105,7 @@ fn revoking_before_the_delegate_votes_restores_the_holder_s_vote() { &target, &Symbol::new(&env, "update"), &args, + &Bytes::from_slice(&env, b"Impact analysis: no material risk identified."), ); dao.vote(&voter1, &pid, &VoteChoice::For); @@ -1100,6 +1129,7 @@ fn only_the_delegate_s_own_tokens_are_locked() { &target, &Symbol::new(&env, "update"), &args, + &Bytes::from_slice(&env, b"Impact analysis: no material risk identified."), ); dao.vote(&voter2, &pid, &VoteChoice::For); @@ -1125,6 +1155,7 @@ fn reclaim_deposit_refunds_proposer_after_timeout() { &target, &Symbol::new(&env, "update"), &args, + &Bytes::from_slice(&env, b"Impact analysis: no material risk identified."), ); assert_eq!(gov_token.balance(&voter1), balance_before - config.proposal_threshold); @@ -1149,6 +1180,7 @@ fn reclaim_deposit_before_timeout_fails() { &target, &Symbol::new(&env, "update"), &args, + &Bytes::from_slice(&env, b"Impact analysis: no material risk identified."), ); env.ledger().with_mut(|l| l.timestamp += VOTING_PERIOD + 1); dao.reclaim_deposit(&pid); @@ -1165,6 +1197,7 @@ fn reclaim_deposit_after_finalize_fails() { &target, &Symbol::new(&env, "update"), &args, + &Bytes::from_slice(&env, b"Impact analysis: no material risk identified."), ); env.ledger() .with_mut(|l| l.timestamp += VOTING_PERIOD + 301); @@ -1215,6 +1248,7 @@ fn create_proposal_from_template_enforces_structure() { &target, &Symbol::new(&env, "update"), &args, + &Bytes::from_slice(&env, b"Impact analysis: no material risk identified."), ); assert_eq!(dao.proposal_count(), 1); let _ = pid; @@ -1239,6 +1273,7 @@ fn create_proposal_from_template_rejects_short_title() { &target, &Symbol::new(&env, "update"), &args, + &Bytes::from_slice(&env, b"Impact analysis: no material risk identified."), ); } @@ -1261,6 +1296,7 @@ fn create_proposal_from_template_rejects_wrong_arg_count() { &target, &Symbol::new(&env, "update"), &args, + &Bytes::from_slice(&env, b"Impact analysis: no material risk identified."), ); } @@ -1284,6 +1320,7 @@ fn deactivated_template_cannot_be_used() { &target, &Symbol::new(&env, "update"), &args, + &Bytes::from_slice(&env, b"Impact analysis: no material risk identified."), ); assert!(result.is_err()); } diff --git a/contracts/governance-dao/src/test_advanced.rs b/contracts/governance-dao/src/test_advanced.rs index c4fae31..4521de5 100644 --- a/contracts/governance-dao/src/test_advanced.rs +++ b/contracts/governance-dao/src/test_advanced.rs @@ -24,6 +24,7 @@ fn base_config(gov_token: Address) -> DaoConfig { majority_bps: 5_100u32, voting_period: VOTING_PERIOD, proposal_timelock: 0, + discussion_period: 0, } } @@ -90,6 +91,7 @@ fn abstain_contributes_to_quorum_but_not_majority() { &target, &Symbol::new(&env, "update"), &args, + &Bytes::from_slice(&env, b"Impact analysis: no material risk identified."), ); dao.vote(&voter, &pid, &VoteChoice::Abstain); @@ -103,6 +105,78 @@ fn abstain_contributes_to_quorum_but_not_majority() { assert_eq!(p.votes_for, 0); } +#[test] +fn large_abstain_bloc_does_not_sink_a_partisan_majority() { + // Regression test for issue #385: majority must be decided by the + // For/Against split alone. Computing it against total_votes (including + // Abstain) would let a large abstaining bloc dilute the For share below + // majority_bps even when every partisan voter backed the proposal. + let (env, dao, admin, voter1, voter2, target) = setup(); + + let args: Vec = Vec::new(&env); + let pid = dao.create_proposal( + &voter1, + &Bytes::from_slice(&env, b"Large abstain bloc"), + &target, + &Symbol::new(&env, "update"), + &args, + &Bytes::from_slice(&env, b"Impact analysis: no material risk identified."), + ); + + // voter1 abstains with 990,000 (their 1,000,000 balance minus the + // 10,000 proposal_threshold deposit locked when they created this very + // proposal); voter2 (500,000) votes For; admin (100,000) votes Against. + // Partisan split is 500k For / 100k Against = 83.3% For, comfortably + // above the 51% majority_bps. Naively dividing by total_votes + // (1,590,000, including the abstain bloc) would instead give ~31.4%, + // well under majority_bps, and wrongly fail the proposal. + dao.vote(&voter1, &pid, &VoteChoice::Abstain); + dao.vote(&voter2, &pid, &VoteChoice::For); + dao.vote(&admin, &pid, &VoteChoice::Against); + + env.ledger() + .with_mut(|l| l.timestamp += VOTING_PERIOD + (24 * 3600) + 1); + dao.finalize(&pid); + + let p = dao.get_proposal(&pid); + assert_eq!(p.status, ProposalStatus::Passed); + assert_eq!(p.votes_abstain, 990_000_0000000i128); + assert_eq!(p.votes_for, 500_000_0000000i128); + assert_eq!(p.votes_against, 100_000_0000000i128); +} + +#[test] +fn abstain_still_counts_toward_quorum() { + // Quorum is decided by total_votes, which does include Abstain — an + // abstain-only turnout is still turnout, even though it can never pass. + let (env, dao, _admin, voter1, _voter2, target) = setup(); + + let args: Vec = Vec::new(&env); + let pid = dao.create_proposal( + &voter1, + &Bytes::from_slice(&env, b"Abstain quorum test"), + &target, + &Symbol::new(&env, "update"), + &args, + &Bytes::from_slice(&env, b"Impact analysis: no material risk identified."), + ); + + // voter1 alone (990,000 of 1,600,000 total_supply after their 10,000 + // proposal_threshold deposit — still 61.9%) clears the 10% quorum_bps + // configured in setup() purely via Abstain. + dao.vote(&voter1, &pid, &VoteChoice::Abstain); + + env.ledger() + .with_mut(|l| l.timestamp += VOTING_PERIOD + (24 * 3600) + 1); + dao.finalize(&pid); + + // Quorum was met, but with zero partisan votes there is no majority to + // speak of, so the proposal still fails. + let p = dao.get_proposal(&pid); + assert_eq!(p.status, ProposalStatus::Failed); + assert_eq!(p.votes_abstain, 990_000_0000000i128); +} + // ── proposal_count ──────────────────────────────────────────────────────────── #[test] @@ -119,6 +193,7 @@ fn proposal_count_increments_per_proposal() { &target, &Symbol::new(&env, "fn1"), &args, + &Bytes::from_slice(&env, b"Impact analysis: no material risk identified."), ); // <--- Added args assert_eq!(dao.proposal_count(), 1); dao.create_proposal( @@ -127,6 +202,7 @@ fn proposal_count_increments_per_proposal() { &target, &Symbol::new(&env, "fn2"), &args, + &Bytes::from_slice(&env, b"Impact analysis: no material risk identified."), ); // <--- Added args assert_eq!(dao.proposal_count(), 2); } @@ -147,6 +223,7 @@ fn execute_twice_fails() { &target, &Symbol::new(&env, "update"), &args, + &Bytes::from_slice(&env, b"Impact analysis: no material risk identified."), ); dao.vote(&voter, &pid, &VoteChoice::For); @@ -179,6 +256,7 @@ fn test_double_voting_attack_prevention() { &target, &Symbol::new(&env, "drain"), &args, + &Bytes::from_slice(&env, b"Impact analysis: no material risk identified."), ); // 1. Attacker (voter1) votes FOR @@ -222,6 +300,7 @@ fn test_successful_token_withdrawal_post_finalize() { &target, &Symbol::new(&env, "update"), &args, + &Bytes::from_slice(&env, b"Impact analysis: no material risk identified."), ); dao.vote(&voter1, &pid, &VoteChoice::For); @@ -256,6 +335,7 @@ fn admin_cannot_manipulate_total_supply_during_active_vote() { &target, &Symbol::new(&env, "update"), &args, + &Bytes::from_slice(&env, b"Impact analysis: no material risk identified."), ); // Voter has 1,000,000 tokens, votes FOR (after 10k threshold lock) @@ -310,6 +390,7 @@ fn test_proposal_id_overflow_panics_with_limit_reached() { &target, &Symbol::new(&env, "update"), &args, + &Bytes::from_slice(&env, b"Impact analysis: no material risk identified."), ); } @@ -355,6 +436,7 @@ fn finalize_fails_proposal_with_zero_votes() { &target, &Symbol::new(&env, "update"), &args, + &Bytes::from_slice(&env, b"Impact analysis: no material risk identified."), ); // Nobody votes. From 7873b0b2580cf5876fda3984b1366f2a13a2afb8 Mon Sep 17 00:00:00 2001 From: KRIS <307999362+kris-nana@users.noreply.github.com> Date: Fri, 28 Aug 2026 02:18:42 +0100 Subject: [PATCH 2/3] feat: add outlier detection to oracle aggregation (#383) Oracle data aggregation had no outlier rejection. WeightedMedian resists a minority of outliers by construction, but Mean, WeightedAverage, and TimeWeightedAverage had no protection at all, and even WeightedMedian can still be dragged if close to half the submissions for a key are bad. Adds a per-data-type OutlierConfig (disabled by default) using a MAD-based (median absolute deviation) modified z-score, applied as a preprocessing step ahead of whichever aggregation method is configured. Filtering never drops below min_sample_size live entries, and is skipped entirely below that many submissions, since neither a median nor a MAD is a meaningful reference with too few points. New admin entry points: set_outlier_config / get_outlier_config. Also fixes a pre-existing compile error on main unrelated to this issue (introduced by an earlier merge): a missing Error::InvalidInput variant that set_consensus_threshold already referenced. Without this the crate does not build at all. --- contracts/oracle-verifier/src/lib.rs | 184 ++++++++++++++++++ .../oracle-verifier/src/test_advanced.rs | 113 +++++++++++ contracts/oracle-verifier/src/types.rs | 30 +++ 3 files changed, 327 insertions(+) diff --git a/contracts/oracle-verifier/src/lib.rs b/contracts/oracle-verifier/src/lib.rs index e8333d2..cb21e12 100644 --- a/contracts/oracle-verifier/src/lib.rs +++ b/contracts/oracle-verifier/src/lib.rs @@ -51,6 +51,20 @@ const MAX_ORACLES: u32 = 100; /// Maximum number of data points stored per (data_type, key). const MAX_DATA_POINTS: u32 = 100; +/// Default outlier-rejection threshold, in basis points of the median +/// absolute deviation (MAD): a submission is flagged once its distance from +/// the median exceeds 5.0x the MAD. This approximates the standard +/// Iglewicz-Hoaglin modified z-score cutoff of 3.5 (which is expressed in +/// MAD-equivalent standard-deviation units via a 0.6745 consistency +/// constant: 3.5 / 0.6745 ≈ 5.19, rounded down for a slightly more +/// conservative default that keeps borderline genuine variation in). +const DEFAULT_OUTLIER_THRESHOLD_BPS: u32 = 50_000; +/// Outlier filtering is skipped below this many eligible submissions — +/// with too few points neither the median nor the MAD used to judge +/// deviation is a meaningful reference, so filtering would be as likely to +/// discard a legitimate value as a bad one. +const DEFAULT_OUTLIER_MIN_SAMPLE_SIZE: u32 = 4; + /// Default minimum number of seconds a single oracle must wait between /// submissions for the same data_type, used when no admin override has been /// set via `set_min_submit_interval`. Chosen well below any realistic @@ -138,6 +152,10 @@ enum StorageKey { /// Per-product consensus threshold configuration (ConsensusThreshold). /// Specifies different oracle agreement levels for different data types/products. ConsensusThreshold(Symbol), + /// Per-data-type outlier-detection configuration (OutlierConfig). Falls + /// back to disabled when unset, so an existing data type's aggregation + /// does not change until an admin opts it in. + OutlierConfig(Symbol), } // ─── Errors ─────────────────────────────────────────────────────────────────── @@ -172,6 +190,8 @@ pub enum Error { InvalidMaxAge = 24, EncryptionRequiredForType = 25, TimestampOutOfRange = 26, + InvalidOutlierConfig = 27, + InvalidInput = 28, } // ─── Contract ───────────────────────────────────────────────────────────────── @@ -620,6 +640,50 @@ impl OracleVerifier { Self::effective_aggregation_method(&env, &data_type) } + /// Configure outlier rejection for a data type, applied before + /// aggregation regardless of `AggregationMethod` (issue #383). + /// + /// `WeightedMedian` already resists a minority of outliers by + /// construction, but `Mean`/`WeightedAverage`/`TimeWeightedAverage` + /// have no such protection, and even a median can still be dragged if + /// close to half the submissions for a key are bad. This runs as a + /// preprocessing step ahead of whichever method is configured. + /// + /// `threshold_bps` is basis points of the median absolute deviation + /// (MAD) — see [`OutlierConfig`]. `min_sample_size` must be at least 3; + /// below that, neither a median nor a MAD is a meaningful reference to + /// judge deviation against. + pub fn set_outlier_config( + env: Env, + admin: Address, + data_type: Symbol, + enabled: bool, + threshold_bps: u32, + min_sample_size: u32, + ) { + Self::require_admin(&env, &admin); + if threshold_bps == 0 || min_sample_size < 3 { + panic_with_error!(&env, Error::InvalidOutlierConfig); + } + + env.storage().instance().set( + &StorageKey::OutlierConfig(data_type.clone()), + &OutlierConfig { enabled, threshold_bps, min_sample_size }, + ); + + env.events().publish( + (Symbol::new(&env, "outlier_config_updated"),), + OutlierConfigUpdated { data_type, enabled, threshold_bps, min_sample_size }, + ); + } + + /// The outlier-rejection configuration applied to a data type. Disabled + /// with the compiled-in defaults until an admin calls + /// `set_outlier_config`. + pub fn get_outlier_config(env: Env, data_type: Symbol) -> OutlierConfig { + Self::effective_outlier_config(&env, &data_type) + } + /// Report whether the data for `(data_type, key)` is fresh enough to use, /// without panicking. /// @@ -1635,6 +1699,9 @@ impl OracleVerifier { } } + n = Self::filter_outliers(&env, &data_type, &mut values, &mut timestamps, n); + total_weight = values[0..n].iter().map(|&(_, wt)| wt).sum(); + let min_oracle_count: u32 = env .storage() .instance() @@ -2165,6 +2232,120 @@ impl OracleVerifier { .unwrap_or(AggregationMethod::WeightedMedian) } + /// The outlier-rejection config for a data type, or disabled with the + /// compiled-in defaults if never configured. + fn effective_outlier_config(env: &Env, data_type: &Symbol) -> OutlierConfig { + env.storage() + .instance() + .get(&StorageKey::OutlierConfig(data_type.clone())) + .unwrap_or(OutlierConfig { + enabled: false, + threshold_bps: DEFAULT_OUTLIER_THRESHOLD_BPS, + min_sample_size: DEFAULT_OUTLIER_MIN_SAMPLE_SIZE, + }) + } + + /// Drop statistical outliers from a batch of collected oracle values + /// ahead of aggregation, using a MAD-based (median absolute deviation) + /// modified z-score (issue #383). `values`/`timestamps` are the same + /// parallel, stack-allocated buffers the aggregation methods consume; + /// only the first `n` entries of each are live. + /// + /// A no-op when outlier detection is disabled for `data_type` or below + /// `min_sample_size` live entries. Never drops enough points to leave + /// fewer than `min_sample_size` behind — the worst offenders are + /// removed first, so a data type in genuine turmoil still yields some + /// aggregate rather than an empty set. + /// + /// Returns the number of live entries remaining at the front of each + /// buffer; relative order among the survivors is otherwise preserved, + /// which does not matter since every aggregation method sorts (or sums + /// order-independently) afterward. + fn filter_outliers( + env: &Env, + data_type: &Symbol, + values: &mut [(i128, u32)], + timestamps: &mut [u64], + n: usize, + ) -> usize { + let config = Self::effective_outlier_config(env, data_type); + if !config.enabled || n < 3 || n < config.min_sample_size as usize { + return n; + } + + let mut sorted_vals = [0i128; 100]; + for i in 0..n { + sorted_vals[i] = values[i].0; + } + let sorted_vals = &mut sorted_vals[0..n]; + sorted_vals.sort_unstable(); + let median = if n % 2 == 1 { + sorted_vals[n / 2] + } else { + (sorted_vals[n / 2 - 1] + sorted_vals[n / 2]) / 2 + }; + + let mut deviations = [0i128; 100]; + for i in 0..n { + deviations[i] = (values[i].0 - median).abs(); + } + let dev_slice = &mut deviations[0..n]; + dev_slice.sort_unstable(); + let mad = if n % 2 == 1 { + dev_slice[n / 2] + } else { + (dev_slice[n / 2 - 1] + dev_slice[n / 2]) / 2 + }; + // Deliberately no `mad == 0` short-circuit: a MAD of 0 means at + // least half the live submissions agree exactly, which is the + // clearest possible signal, not a reason to abstain. With + // threshold * mad == 0, any submission that differs from the + // median at all is flagged below — exactly right when the + // majority is unanimous and one value is clearly not. + + let threshold = config.threshold_bps as i128; + let min_keep = (config.min_sample_size as usize).max(1); + + // Rank live entries by deviation, worst first, so trimming toward + // `min_keep` always drops the most extreme values. + let mut order = [0usize; 100]; + for i in 0..n { + order[i] = i; + } + let order_slice = &mut order[0..n]; + order_slice.sort_unstable_by_key(|&i| core::cmp::Reverse((values[i].0 - median).abs())); + + let mut is_outlier = [false; 100]; + let mut flagged = 0usize; + for &i in order_slice.iter() { + if n - flagged <= min_keep { + break; + } + let deviation = (values[i].0 - median).abs(); + if deviation.saturating_mul(10_000) > threshold.saturating_mul(mad) { + is_outlier[i] = true; + flagged += 1; + } else { + // Sorted by deviation descending — once one entry is inside + // the threshold, every entry after it is too. + break; + } + } + if flagged == 0 { + return n; + } + + let mut write = 0usize; + for read in 0..n { + if !is_outlier[read] { + values[write] = values[read]; + timestamps[write] = timestamps[read]; + write += 1; + } + } + write + } + /// The effective minimum oracle count for a data type: the per-type /// override when set, otherwise the global value (default 1). fn effective_min_oracle_count(env: &Env, data_type: &Symbol) -> u32 { @@ -2227,6 +2408,9 @@ impl OracleVerifier { } } + n = Self::filter_outliers(env, data_type, &mut values, &mut timestamps, n); + total_weight = values[0..n].iter().map(|&(_, wt)| wt).sum(); + let min_oracle_count: u32 = env .storage() .instance() diff --git a/contracts/oracle-verifier/src/test_advanced.rs b/contracts/oracle-verifier/src/test_advanced.rs index 361b1af..acb1707 100644 --- a/contracts/oracle-verifier/src/test_advanced.rs +++ b/contracts/oracle-verifier/src/test_advanced.rs @@ -752,3 +752,116 @@ fn test_set_data_type_max_age_requires_admin() { let impostor = Address::generate(&env); client.set_data_type_max_age(&impostor, &wt(), &600u64); } + +// ── outlier detection (issue #383) ────────────────────────────────────────── + +#[test] +fn outlier_config_disabled_by_default() { + let (env, _admin, cid) = setup(); + let c = OracleVerifierClient::new(&env, &cid); + let cfg = c.get_outlier_config(&wt()); + assert!(!cfg.enabled); + assert_eq!(cfg.threshold_bps, DEFAULT_OUTLIER_THRESHOLD_BPS); + assert_eq!(cfg.min_sample_size, DEFAULT_OUTLIER_MIN_SAMPLE_SIZE); +} + +#[test] +#[should_panic(expected = "Error(Contract, #27)")] +fn set_outlier_config_rejects_zero_threshold() { + let (env, admin, cid) = setup(); + let c = OracleVerifierClient::new(&env, &cid); + c.set_outlier_config(&admin, &wt(), &true, &0u32, &4u32); +} + +#[test] +#[should_panic(expected = "Error(Contract, #27)")] +fn set_outlier_config_rejects_tiny_sample_size() { + let (env, admin, cid) = setup(); + let c = OracleVerifierClient::new(&env, &cid); + c.set_outlier_config(&admin, &wt(), &true, &50_000u32, &2u32); +} + +/// Five oracles, four in tight agreement and one reporting a value 1000x +/// larger. With outlier detection off, `Mean` (which has no built-in +/// resistance to outliers the way `WeightedMedian` does) is dragged far +/// above what every well-behaved oracle actually reported. +#[test] +fn mean_without_outlier_filtering_is_skewed_by_one_bad_oracle() { + let (env, admin, cid) = setup(); + let c = OracleVerifierClient::new(&env, &cid); + c.set_aggregation_method(&admin, &wt(), &AggregationMethod::Mean); + + let oracles: std::vec::Vec
= (0..5).map(|_| Address::generate(&env)).collect(); + for o in oracles.iter() { + c.add_oracle(&admin, o, &wt(), &50u32); + } + + env.ledger().set_timestamp(1); + let good = 100_0000000i128; + let bad = 100_000_0000000i128; // 1000x the honest value + for o in oracles.iter().take(4) { + c.submit_data(o, &wt(), &kk(), &good, &90u32, &1u64); + } + c.submit_data(&oracles[4], &wt(), &kk(), &bad, &90u32, &1u64); + + let agg = c.get_aggregated(&wt(), &kk()); + // (4*100 + 100_000) / 5 = 20_080 — nowhere near the honest value of 100. + assert!(agg.median_value > good * 100); +} + +/// Same five submissions as above, but with outlier detection enabled for +/// the data type. The lone extreme submission is dropped before +/// aggregation, so `Mean` reflects only the four honest oracles. +#[test] +fn outlier_filtering_protects_mean_from_one_bad_oracle() { + let (env, admin, cid) = setup(); + let c = OracleVerifierClient::new(&env, &cid); + c.set_aggregation_method(&admin, &wt(), &AggregationMethod::Mean); + c.set_outlier_config(&admin, &wt(), &true, &DEFAULT_OUTLIER_THRESHOLD_BPS, &DEFAULT_OUTLIER_MIN_SAMPLE_SIZE); + + let oracles: std::vec::Vec
= (0..5).map(|_| Address::generate(&env)).collect(); + for o in oracles.iter() { + c.add_oracle(&admin, o, &wt(), &50u32); + } + + env.ledger().set_timestamp(1); + let good = 100_0000000i128; + let bad = 100_000_0000000i128; + for o in oracles.iter().take(4) { + c.submit_data(o, &wt(), &kk(), &good, &90u32, &1u64); + } + c.submit_data(&oracles[4], &wt(), &kk(), &bad, &90u32, &1u64); + + let agg = c.get_aggregated(&wt(), &kk()); + assert_eq!(agg.median_value, good); +} + +/// Outlier detection never removes enough entries to drop below +/// `min_sample_size` — with exactly four submissions and a `min_sample_size` +/// of 4, the arrangement has no slack to trim, so even a wild outlier +/// survives into the aggregate untouched. +#[test] +fn outlier_filtering_never_drops_below_min_sample_size() { + let (env, admin, cid) = setup(); + let c = OracleVerifierClient::new(&env, &cid); + c.set_aggregation_method(&admin, &wt(), &AggregationMethod::Mean); + c.set_outlier_config(&admin, &wt(), &true, &DEFAULT_OUTLIER_THRESHOLD_BPS, &4u32); + + let oracles: std::vec::Vec
= (0..4).map(|_| Address::generate(&env)).collect(); + for o in oracles.iter() { + c.add_oracle(&admin, o, &wt(), &50u32); + } + + env.ledger().set_timestamp(1); + let good = 100_0000000i128; + let bad = 100_000_0000000i128; + for o in oracles.iter().take(3) { + c.submit_data(o, &wt(), &kk(), &good, &90u32, &1u64); + } + c.submit_data(&oracles[3], &wt(), &kk(), &bad, &90u32, &1u64); + + let agg = c.get_aggregated(&wt(), &kk()); + // All 4 submissions survive (below the slack needed to trim), so the + // outlier still drags the mean up. + assert!(agg.median_value > good * 100); +} diff --git a/contracts/oracle-verifier/src/types.rs b/contracts/oracle-verifier/src/types.rs index e0becaa..75b94a3 100644 --- a/contracts/oracle-verifier/src/types.rs +++ b/contracts/oracle-verifier/src/types.rs @@ -420,4 +420,34 @@ pub struct OracleEncryptedDataSubmitted { #[derive(Clone, Debug, Eq, PartialEq)] pub struct TimestampFutureBufferUpdated { pub seconds: u64, +} + +/// Outlier-rejection settings applied before aggregation for a data type. +/// +/// Filtering uses a MAD-based (median absolute deviation) modified +/// z-score: robust to the same kind of extreme-minority manipulation the +/// submissions themselves represent, unlike a mean/standard-deviation +/// approach whose own spread is skewed by the very outlier it is trying to +/// catch. +#[contracttype] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct OutlierConfig { + /// Off by default — an existing data type's aggregation does not + /// change until an admin opts it in. + pub enabled: bool, + /// Rejection threshold in basis points of the MAD: a submission is + /// flagged once `|value - median| * 10_000 > threshold_bps * MAD`. + pub threshold_bps: u32, + /// Filtering is skipped below this many eligible submissions, and never + /// removes enough points to leave fewer than this many behind. + pub min_sample_size: u32, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct OutlierConfigUpdated { + pub data_type: Symbol, + pub enabled: bool, + pub threshold_bps: u32, + pub min_sample_size: u32, } \ No newline at end of file From 7df892400ad4f9a628f49b4590e9183928c3b4ab Mon Sep 17 00:00:00 2001 From: KRIS <307999362+kris-nana@users.noreply.github.com> Date: Fri, 28 Aug 2026 02:18:57 +0100 Subject: [PATCH 3/3] feat: add reinsurance contract integration to risk pool (#382) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The risk pool had no way to cede catastrophic risk to a reinsurer — every claim was absorbed entirely by pool capital, no matter how large. Adds a per-pool ReinsuranceConfig (reinsurer address, per-claim attachment_point, lifetime coverage_limit) plus: - set_reinsurance / set_reinsurance_active — admin configures or pauses the arrangement without discarding accumulated stats. - send_premium_to_reinsurer — cedes premium to the reinsurer, mirroring the existing send_premium_to_treasury/backstop pattern. - request_reinsurance_recovery — callable by the same protocol callers as release_for_claim; recovers the portion of a claim loss above attachment_point, capped at the remaining coverage_limit, and replenishes total_deposited with whatever the reinsurer actually paid. Never panics on unconfigured/inactive/exhausted coverage, so a claim payout is never blocked by reinsurance state. - get_reinsurance / get_reinsurance_stats — read-only views. The reinsurer is an external contract behind a minimal IReinsurer cross-contract interface (ReinsurerClient), matching how this codebase already calls out to other Parashield contracts. Its self-reported recovered amount is clamped to what was actually requested, so a misbehaving or buggy reinsurer can't inflate this pool's coverage accounting. Also fixes pre-existing compile errors on main unrelated to this issue (introduced by an earlier merge): a missing Error::InvalidParameter variant, and two call sites passing &Env where Env is expected. Without these the crate does not build at all. --- contracts/risk-pool/src/lib.rs | 202 ++++++++++++- contracts/risk-pool/src/test_reinsurance.rs | 304 ++++++++++++++++++++ contracts/risk-pool/src/types.rs | 66 +++++ 3 files changed, 570 insertions(+), 2 deletions(-) create mode 100644 contracts/risk-pool/src/test_reinsurance.rs diff --git a/contracts/risk-pool/src/lib.rs b/contracts/risk-pool/src/lib.rs index f602b4c..5eae113 100644 --- a/contracts/risk-pool/src/lib.rs +++ b/contracts/risk-pool/src/lib.rs @@ -23,6 +23,19 @@ use soroban_sdk::{ pub mod types; pub use types::*; +/// Cross-contract interface for an external reinsurer this pool cedes +/// catastrophic risk to. The reinsurer is any contract implementing this +/// single entry point — how it sources the payout (its own reserves, +/// retrocession, etc.) is entirely its own concern. +#[soroban_sdk::contractclient(name = "ReinsurerClient")] +trait IReinsurer { + /// Pay out up to `amount` of the pool's USDC to `caller` (the ceding + /// pool), attributed to `policy_id`. Returns the amount actually paid, + /// which may be less than requested if the reinsurer's own capacity is + /// exhausted. + fn recover(env: Env, caller: Address, policy_id: u128, amount: i128) -> i128; +} + /// Default premium split, in effect until the admin calls `update_premium_split`. const DEFAULT_PREMIUM_LP_BPS: i128 = 8_000; // 80% of premium to LP pool const DEFAULT_PREMIUM_TREAS_BPS: i128 = 1_000; // 10% to treasury @@ -137,6 +150,12 @@ enum StorageKey { /// 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, } #[contracterror] @@ -178,6 +197,9 @@ pub enum Error { ExitAlreadyQueued = 33, NoExitRequest = 34, ExitDelayNotElapsed = 35, + ReinsuranceNotConfigured = 36, + InvalidReinsuranceConfig = 37, + InvalidParameter = 38, } #[contract] @@ -1014,6 +1036,180 @@ impl RiskPool { env.storage().instance().set(&StorageKey::TotalLocked, &(total_locked.saturating_sub(lock.amount))); } + // ── Reinsurance ────────────────────────────────────────────────────────── + + /// Configure (or replace) the pool's reinsurance arrangement. + /// + /// `attachment_point` and `coverage_limit` are per-claim and lifetime + /// caps respectively — see [`ReinsuranceConfig`]. Replacing an existing + /// arrangement does not reset `ReinsuranceStats`: prior premium paid and + /// claims recovered still count against the new `coverage_limit`, since + /// a re-negotiated arrangement with the same or a new reinsurer is a + /// continuation, not a fresh start. + pub fn set_reinsurance( + env: Env, + admin: Address, + reinsurer: Address, + attachment_point: i128, + coverage_limit: i128, + ) { + Self::require_admin(&env, &admin); + if attachment_point < 0 || coverage_limit < 0 { + panic_with_error!(&env, Error::InvalidReinsuranceConfig); + } + let config = ReinsuranceConfig { + reinsurer: reinsurer.clone(), + attachment_point, + coverage_limit, + active: true, + }; + env.storage().instance().set(&StorageKey::Reinsurance, &config); + env.events().publish( + (Symbol::new(&env, "reinsurance_configured"),), + ReinsuranceConfigured { reinsurer, attachment_point, coverage_limit }, + ); + } + + /// Pause or resume the reinsurance arrangement without discarding its + /// configuration or accumulated stats. While inactive, + /// `request_reinsurance_recovery` always returns 0. + pub fn set_reinsurance_active(env: Env, admin: Address, active: bool) { + Self::require_admin(&env, &admin); + let mut config: ReinsuranceConfig = env.storage().instance() + .get(&StorageKey::Reinsurance) + .unwrap_or_else(|| panic_with_error!(&env, Error::ReinsuranceNotConfigured)); + config.active = active; + env.storage().instance().set(&StorageKey::Reinsurance, &config); + env.events().publish( + (Symbol::new(&env, "reinsurance_active_set"),), + ReinsuranceActiveSet { active }, + ); + } + + /// Cede `amount` of USDC from the pool's own balance to the reinsurer as + /// reinsurance premium. Mirrors `send_premium_to_treasury`/ + /// `send_premium_to_backstop` — called by the admin (typically from an + /// off-chain job) after premium income has accrued in the pool. + pub fn send_premium_to_reinsurer(env: Env, caller: Address, amount: i128) { + Self::require_admin(&env, &caller); + if amount <= 0 { panic_with_error!(&env, Error::ZeroAmount); } + let config: ReinsuranceConfig = env.storage().instance() + .get(&StorageKey::Reinsurance) + .unwrap_or_else(|| panic_with_error!(&env, Error::ReinsuranceNotConfigured)); + + let usdc: Address = env.storage().instance().get(&StorageKey::UsdcToken).unwrap(); + token::Client::new(&env, &usdc) + .transfer(&env.current_contract_address(), &config.reinsurer, &amount); + + let paid: i128 = env.storage().instance() + .get(&StorageKey::ReinsurancePremiumPaid).unwrap_or(0); + let total_premium_paid = paid.saturating_add(amount); + env.storage().instance().set(&StorageKey::ReinsurancePremiumPaid, &total_premium_paid); + + env.events().publish( + (Symbol::new(&env, "reinsurance_premium_paid"),), + ReinsurancePremiumPaid { reinsurer: config.reinsurer, amount, total_premium_paid }, + ); + } + + /// Request recovery of a catastrophic claim loss from the reinsurer. + /// + /// Callable only by the admin, policy engine, or claims processor (the + /// same gate as `release_for_claim`), so it slots directly into the + /// existing claim-settlement flow. `loss_amount` is the size of the + /// claim payout being recovered against; only the portion above + /// `attachment_point` is eligible, and the total ever recovered is + /// capped at `coverage_limit`. + /// + /// This never panics on "no coverage available" — an unconfigured, + /// inactive, or exhausted arrangement simply recovers nothing (returns + /// 0), so a claim payout is never blocked by the state of a reinsurance + /// side-arrangement. Returns the amount actually recovered, which is + /// credited to `total_deposited` (replenishing pool capital) and may be + /// less than the eligible excess if the reinsurer's own capacity is + /// exhausted. + pub fn request_reinsurance_recovery( + env: Env, + caller: Address, + policy_id: u128, + loss_amount: i128, + ) -> i128 { + Self::require_protocol_caller(&env, &caller); + if loss_amount <= 0 { return 0; } + + let config: ReinsuranceConfig = match env.storage().instance().get(&StorageKey::Reinsurance) { + Some(c) => c, + None => return 0, + }; + if !config.active || loss_amount <= config.attachment_point { + return 0; + } + + let excess = loss_amount - config.attachment_point; + let already_recovered: i128 = env.storage().instance() + .get(&StorageKey::ReinsuranceRecovered).unwrap_or(0); + let coverage_remaining = config.coverage_limit.saturating_sub(already_recovered).max(0); + if coverage_remaining <= 0 { return 0; } + + let requested = excess.min(coverage_remaining); + + let recovered = ReinsurerClient::new(&env, &config.reinsurer) + .recover(&env.current_contract_address(), &policy_id, &requested) + // The reinsurer's own report is trusted for *what* it paid, but + // never for *how much* — clamp to what was actually requested so + // a misbehaving or buggy reinsurer cannot inflate this pool's + // coverage accounting past what it asked for. + .clamp(0, requested); + + if recovered > 0 { + let total_deposited: i128 = env.storage().instance() + .get(&StorageKey::TotalDeposited).unwrap_or(0); + env.storage().instance().set( + &StorageKey::TotalDeposited, + &total_deposited.checked_add(recovered).unwrap_or_else(|| panic_with_error!(&env, Error::Overflow)), + ); + + let total_recovered = already_recovered.saturating_add(recovered); + env.storage().instance().set(&StorageKey::ReinsuranceRecovered, &total_recovered); + + env.events().publish( + (Symbol::new(&env, "reinsurance_recovered"),), + ReinsuranceRecovered { + policy_id, + loss_amount, + recovered_amount: recovered, + coverage_remaining: config.coverage_limit.saturating_sub(total_recovered).max(0), + }, + ); + } + + recovered + } + + /// Returns the pool's reinsurance configuration, or `None` if never set. + pub fn get_reinsurance(env: Env) -> Option { + env.storage().instance().get(&StorageKey::Reinsurance) + } + + /// Returns cumulative reinsurance premium paid, claims recovered, and + /// remaining coverage. Coverage remaining is 0 (not an error) when no + /// arrangement has ever been configured. + pub fn get_reinsurance_stats(env: Env) -> ReinsuranceStats { + let total_premium_paid: i128 = env.storage().instance() + .get(&StorageKey::ReinsurancePremiumPaid).unwrap_or(0); + let total_recovered: i128 = env.storage().instance() + .get(&StorageKey::ReinsuranceRecovered).unwrap_or(0); + let coverage_limit = env.storage().instance() + .get::<_, ReinsuranceConfig>(&StorageKey::Reinsurance) + .map(|c| c.coverage_limit) + .unwrap_or(0); + ReinsuranceStats { + total_premium_paid, + total_recovered, + coverage_remaining: coverage_limit.saturating_sub(total_recovered).max(0), + } + } + // ── Queries ─────────────────────────────────────────────────────────────── /// Return aggregate pool statistics: total deposited, locked, shares, and premium accumulators. @@ -1153,12 +1349,12 @@ impl RiskPool { /// 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); + 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 status = Self::get_capacity_status(env); let util_bps = status.utilization_bps; // If below threshold, use base fee @@ -2104,3 +2300,5 @@ mod test; mod test_advanced; #[cfg(test)] mod test_edge; +#[cfg(test)] +mod test_reinsurance; diff --git a/contracts/risk-pool/src/test_reinsurance.rs b/contracts/risk-pool/src/test_reinsurance.rs new file mode 100644 index 0000000..93c4280 --- /dev/null +++ b/contracts/risk-pool/src/test_reinsurance.rs @@ -0,0 +1,304 @@ +#![allow(clippy::inconsistent_digit_grouping)] +//! Reinsurance tests (issue #382): configuring an external reinsurance +//! arrangement, ceding premium to it, and recovering catastrophic claim +//! losses from it. + +#![cfg(test)] + +extern crate std; + +use soroban_sdk::{testutils::Address as _, token, Address, Env, Symbol}; + +use crate::{RiskPool, RiskPoolClient}; + +// Each mock reinsurer lives in its own module: `#[contractimpl]` generates +// module-scoped spec constants keyed only by function name (e.g. +// `__SPEC_XDR_FN_RECOVER`), so two `#[contract]` types in the same module +// both exposing `recover`/`init` collide at compile time. The function +// *names* still have to match across all three (`init`, `recover`) since +// `recover` is the on-chain entry point `ReinsurerClient` dispatches to. + +/// A well-behaved reinsurer: pays out exactly what was requested and +/// reports having paid exactly that. +mod mock_reinsurer { + use soroban_sdk::{token, Address, Env, Symbol}; + + #[soroban_sdk::contract] + pub struct MockReinsurer; + + #[soroban_sdk::contractimpl] + impl MockReinsurer { + pub fn init(env: Env, usdc: Address) { + env.storage().instance().set(&Symbol::new(&env, "usdc"), &usdc); + } + + pub fn recover(env: Env, caller: Address, _policy_id: u128, amount: i128) -> i128 { + let usdc: Address = env.storage().instance().get(&Symbol::new(&env, "usdc")).unwrap(); + token::Client::new(&env, &usdc).transfer(&env.current_contract_address(), &caller, &amount); + amount + } + } +} +pub use mock_reinsurer::{MockReinsurer, MockReinsurerClient}; + +/// A reinsurer whose own capacity is exhausted: it only ever pays out (and +/// reports) half of what is requested. +mod mock_stingy_reinsurer { + use soroban_sdk::{token, Address, Env, Symbol}; + + #[soroban_sdk::contract] + pub struct MockStingyReinsurer; + + #[soroban_sdk::contractimpl] + impl MockStingyReinsurer { + pub fn init(env: Env, usdc: Address) { + env.storage().instance().set(&Symbol::new(&env, "usdc"), &usdc); + } + + pub fn recover(env: Env, caller: Address, _policy_id: u128, amount: i128) -> i128 { + let usdc: Address = env.storage().instance().get(&Symbol::new(&env, "usdc")).unwrap(); + let half = amount / 2; + token::Client::new(&env, &usdc).transfer(&env.current_contract_address(), &caller, &half); + half + } + } +} +pub use mock_stingy_reinsurer::{MockStingyReinsurer, MockStingyReinsurerClient}; + +/// A misbehaving (or buggy) reinsurer: transfers only what was requested +/// but *reports* having paid double. Exercises the pool's defensive clamp. +mod mock_over_reporting_reinsurer { + use soroban_sdk::{token, Address, Env, Symbol}; + + #[soroban_sdk::contract] + pub struct MockOverReportingReinsurer; + + #[soroban_sdk::contractimpl] + impl MockOverReportingReinsurer { + pub fn init(env: Env, usdc: Address) { + env.storage().instance().set(&Symbol::new(&env, "usdc"), &usdc); + } + + pub fn recover(env: Env, caller: Address, _policy_id: u128, amount: i128) -> i128 { + let usdc: Address = env.storage().instance().get(&Symbol::new(&env, "usdc")).unwrap(); + token::Client::new(&env, &usdc).transfer(&env.current_contract_address(), &caller, &amount); + amount * 2 + } + } +} +pub use mock_over_reporting_reinsurer::{MockOverReportingReinsurer, MockOverReportingReinsurerClient}; + +fn setup() -> (Env, RiskPoolClient<'static>, Address, Address, Address) { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let treasury = Address::generate(&env); + let policy_engine = Address::generate(&env); + let claims_processor = Address::generate(&env); + + let usdc_id = env.register_stellar_asset_contract_v2(admin.clone()).address(); + let backstop_id = env.register_stellar_asset_contract_v2(admin.clone()).address(); + let pool_id = env.register(RiskPool, ()); + let pool = RiskPoolClient::new(&env, &pool_id); + + pool.initialize( + &admin, + &usdc_id, + &treasury, + &backstop_id, + &Symbol::new(&env, "crop"), + &policy_engine, + &claims_processor, + ); + + (env, pool, usdc_id, admin, claims_processor) +} + +#[test] +fn set_reinsurance_requires_admin() { + let (env, pool, _usdc, _admin, _cp) = setup(); + let reinsurer = Address::generate(&env); + let impostor = Address::generate(&env); + let result = pool.try_set_reinsurance(&impostor, &reinsurer, &1_000_0000000i128, &100_000_0000000i128); + assert!(result.is_err()); +} + +#[test] +fn get_reinsurance_is_none_until_configured() { + let (_env, pool, _usdc, _admin, _cp) = setup(); + assert_eq!(pool.get_reinsurance(), None); + let stats = pool.get_reinsurance_stats(); + assert_eq!(stats.total_premium_paid, 0); + assert_eq!(stats.total_recovered, 0); + assert_eq!(stats.coverage_remaining, 0); +} + +#[test] +fn admin_can_configure_reinsurance() { + let (env, pool, _usdc, admin, _cp) = setup(); + let reinsurer = Address::generate(&env); + pool.set_reinsurance(&admin, &reinsurer, &1_000_0000000i128, &100_000_0000000i128); + + let cfg = pool.get_reinsurance().unwrap(); + assert_eq!(cfg.reinsurer, reinsurer); + assert_eq!(cfg.attachment_point, 1_000_0000000i128); + assert_eq!(cfg.coverage_limit, 100_000_0000000i128); + assert!(cfg.active); +} + +#[test] +fn send_premium_to_reinsurer_requires_configuration() { + let (_env, pool, _usdc, admin, _cp) = setup(); + let result = pool.try_send_premium_to_reinsurer(&admin, &1_000_0000000i128); + assert!(result.is_err()); +} + +#[test] +fn send_premium_to_reinsurer_transfers_and_tracks_total() { + let (env, pool, usdc, admin, _cp) = setup(); + let reinsurer = Address::generate(&env); + pool.set_reinsurance(&admin, &reinsurer, &1_000_0000000i128, &100_000_0000000i128); + + // Fund the pool itself, as premium income would. + token::StellarAssetClient::new(&env, &usdc).mint(&pool.address, &50_000_0000000i128); + + pool.send_premium_to_reinsurer(&admin, &10_000_0000000i128); + + assert_eq!(token::Client::new(&env, &usdc).balance(&reinsurer), 10_000_0000000i128); + assert_eq!(pool.get_reinsurance_stats().total_premium_paid, 10_000_0000000i128); +} + +/// A claim loss below the attachment point is retained by the pool +/// entirely — no recovery is requested or paid. +#[test] +fn loss_below_attachment_point_recovers_nothing() { + let (env, pool, usdc, admin, claims_processor) = setup(); + let reinsurer_id = env.register(MockReinsurer, ()); + MockReinsurerClient::new(&env, &reinsurer_id).init(&usdc); + token::StellarAssetClient::new(&env, &usdc).mint(&reinsurer_id, &1_000_000_0000000i128); + + pool.set_reinsurance(&admin, &reinsurer_id, &10_000_0000000i128, &1_000_000_0000000i128); + + let recovered = pool.request_reinsurance_recovery(&claims_processor, &1u128, &5_000_0000000i128); + assert_eq!(recovered, 0); + assert_eq!(pool.get_reinsurance_stats().total_recovered, 0); +} + +/// A claim loss above the attachment point recovers the excess from the +/// reinsurer, and that recovery replenishes `total_deposited`. +#[test] +fn loss_above_attachment_point_recovers_the_excess() { + let (env, pool, usdc, admin, claims_processor) = setup(); + let reinsurer_id = env.register(MockReinsurer, ()); + MockReinsurerClient::new(&env, &reinsurer_id).init(&usdc); + token::StellarAssetClient::new(&env, &usdc).mint(&reinsurer_id, &1_000_000_0000000i128); + + pool.set_reinsurance(&admin, &reinsurer_id, &10_000_0000000i128, &1_000_000_0000000i128); + + let deposited_before = pool.get_stats().total_deposited; + + // Loss of 50,000; attachment point 10,000 → excess of 40,000 eligible. + let recovered = pool.request_reinsurance_recovery(&claims_processor, &1u128, &50_000_0000000i128); + assert_eq!(recovered, 40_000_0000000i128); + + let stats = pool.get_reinsurance_stats(); + assert_eq!(stats.total_recovered, 40_000_0000000i128); + assert_eq!(stats.coverage_remaining, 1_000_000_0000000i128 - 40_000_0000000i128); + assert_eq!(pool.get_stats().total_deposited, deposited_before + 40_000_0000000i128); + assert_eq!(token::Client::new(&env, &usdc).balance(&pool.address), 40_000_0000000i128); +} + +/// Recovery never exceeds the cumulative `coverage_limit`, even across +/// multiple claims. +#[test] +fn recovery_is_capped_by_coverage_limit() { + let (env, pool, usdc, admin, claims_processor) = setup(); + let reinsurer_id = env.register(MockReinsurer, ()); + MockReinsurerClient::new(&env, &reinsurer_id).init(&usdc); + token::StellarAssetClient::new(&env, &usdc).mint(&reinsurer_id, &1_000_000_0000000i128); + + // Small lifetime coverage limit: 15,000. + pool.set_reinsurance(&admin, &reinsurer_id, &0i128, &15_000_0000000i128); + + let first = pool.request_reinsurance_recovery(&claims_processor, &1u128, &10_000_0000000i128); + assert_eq!(first, 10_000_0000000i128); + + // Second claim requests 10,000 more, but only 5,000 of coverage remains. + let second = pool.request_reinsurance_recovery(&claims_processor, &2u128, &10_000_0000000i128); + assert_eq!(second, 5_000_0000000i128); + assert_eq!(pool.get_reinsurance_stats().coverage_remaining, 0); + + // A third claim recovers nothing further — coverage is exhausted. + let third = pool.request_reinsurance_recovery(&claims_processor, &3u128, &10_000_0000000i128); + assert_eq!(third, 0); +} + +/// A reinsurer whose own capacity is short pays (and reports) less than +/// requested — the pool records only what actually came back. +#[test] +fn reinsurer_short_capacity_partial_recovery_is_recorded_honestly() { + let (env, pool, usdc, admin, claims_processor) = setup(); + let reinsurer_id = env.register(MockStingyReinsurer, ()); + MockStingyReinsurerClient::new(&env, &reinsurer_id).init(&usdc); + token::StellarAssetClient::new(&env, &usdc).mint(&reinsurer_id, &1_000_000_0000000i128); + + pool.set_reinsurance(&admin, &reinsurer_id, &0i128, &1_000_000_0000000i128); + + let recovered = pool.request_reinsurance_recovery(&claims_processor, &1u128, &10_000_0000000i128); + assert_eq!(recovered, 5_000_0000000i128); // stingy reinsurer only pays half + assert_eq!(pool.get_reinsurance_stats().total_recovered, 5_000_0000000i128); +} + +/// A reinsurer that reports paying more than was requested is clamped to +/// the requested amount — its self-report is never trusted past that. +#[test] +fn over_reporting_reinsurer_is_clamped_to_requested_amount() { + let (env, pool, usdc, admin, claims_processor) = setup(); + let reinsurer_id = env.register(MockOverReportingReinsurer, ()); + MockOverReportingReinsurerClient::new(&env, &reinsurer_id).init(&usdc); + token::StellarAssetClient::new(&env, &usdc).mint(&reinsurer_id, &1_000_000_0000000i128); + + // Coverage limit smaller than what the reinsurer would over-report, + // so a bug that trusted the return value verbatim would blow past it. + pool.set_reinsurance(&admin, &reinsurer_id, &0i128, &12_000_0000000i128); + + let recovered = pool.request_reinsurance_recovery(&claims_processor, &1u128, &10_000_0000000i128); + // Requested 10,000; reinsurer reports 20,000 — clamped to 10,000. + assert_eq!(recovered, 10_000_0000000i128); + assert_eq!(pool.get_reinsurance_stats().total_recovered, 10_000_0000000i128); + assert_eq!(pool.get_reinsurance_stats().coverage_remaining, 2_000_0000000i128); +} + +/// Pausing the arrangement preserves its configuration and stats but +/// blocks further recoveries until it is resumed. +#[test] +fn inactive_reinsurance_recovers_nothing_but_keeps_config() { + let (env, pool, usdc, admin, claims_processor) = setup(); + let reinsurer_id = env.register(MockReinsurer, ()); + MockReinsurerClient::new(&env, &reinsurer_id).init(&usdc); + token::StellarAssetClient::new(&env, &usdc).mint(&reinsurer_id, &1_000_000_0000000i128); + + pool.set_reinsurance(&admin, &reinsurer_id, &0i128, &1_000_000_0000000i128); + pool.set_reinsurance_active(&admin, &false); + + let recovered = pool.request_reinsurance_recovery(&claims_processor, &1u128, &10_000_0000000i128); + assert_eq!(recovered, 0); + assert!(!pool.get_reinsurance().unwrap().active); + + pool.set_reinsurance_active(&admin, &true); + let recovered = pool.request_reinsurance_recovery(&claims_processor, &2u128, &10_000_0000000i128); + assert_eq!(recovered, 10_000_0000000i128); +} + +#[test] +#[should_panic(expected = "Error(Contract, #3)")] +fn request_reinsurance_recovery_requires_protocol_caller() { + let (env, pool, usdc, admin, _cp) = setup(); + let reinsurer_id = env.register(MockReinsurer, ()); + MockReinsurerClient::new(&env, &reinsurer_id).init(&usdc); + pool.set_reinsurance(&admin, &reinsurer_id, &0i128, &1_000_000_0000000i128); + + let impostor = Address::generate(&env); + pool.request_reinsurance_recovery(&impostor, &1u128, &10_000_0000000i128); +} diff --git a/contracts/risk-pool/src/types.rs b/contracts/risk-pool/src/types.rs index f61dc20..9b6378e 100644 --- a/contracts/risk-pool/src/types.rs +++ b/contracts/risk-pool/src/types.rs @@ -514,3 +514,69 @@ pub struct VotesDelegated { pub provider: Address, pub delegate: Address, } + +/// An external reinsurance arrangement that backstops this pool against +/// catastrophic (large single-claim) losses. +/// +/// The pool retains every loss up to `attachment_point` itself; only the +/// portion of a claim above that point is eligible for recovery from +/// `reinsurer`, capped over the arrangement's lifetime by `coverage_limit`. +/// This is a per-claim ("excess of loss") layer, not aggregate stop-loss — +/// each claim is evaluated against `attachment_point` independently. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ReinsuranceConfig { + /// Contract this pool cedes premium to and recovers claims from. + pub reinsurer: Address, + /// Per-claim loss threshold below which the pool bears the loss alone. + pub attachment_point: i128, + /// Cumulative lifetime cap on reinsurance recoveries this pool can draw. + pub coverage_limit: i128, + /// Whether recovery requests are currently honored. Config (and prior + /// usage) is preserved when set to `false` — this pauses the + /// arrangement without discarding it. + pub active: bool, +} + +/// Running totals for the pool's reinsurance arrangement. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ReinsuranceStats { + /// Total premium ceded to the reinsurer so far. + pub total_premium_paid: i128, + /// Total claims recovered from the reinsurer so far. + pub total_recovered: i128, + /// `coverage_limit - total_recovered`, floored at 0. + pub coverage_remaining: i128, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ReinsuranceConfigured { + pub reinsurer: Address, + pub attachment_point: i128, + pub coverage_limit: i128, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ReinsuranceActiveSet { + pub active: bool, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ReinsurancePremiumPaid { + pub reinsurer: Address, + pub amount: i128, + pub total_premium_paid: i128, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ReinsuranceRecovered { + pub policy_id: u128, + pub loss_amount: i128, + pub recovered_amount: i128, + pub coverage_remaining: i128, +}