diff --git a/contracts/predictify-hybrid/src/lib.rs b/contracts/predictify-hybrid/src/lib.rs index 84e65274..aafb1ba3 100644 --- a/contracts/predictify-hybrid/src/lib.rs +++ b/contracts/predictify-hybrid/src/lib.rs @@ -193,6 +193,9 @@ mod fee_config_commit_reveal_tests; // #[cfg(test)] // mod event_creation_tests; +#[cfg(test)] +mod voting_snapshot_stability_tests; + // Re-export commonly used items use admin::{ AdminAnalyticsResult, AdminFunctions, AdminInitializer, AdminManager, AdminPermission, diff --git a/contracts/predictify-hybrid/src/markets.rs b/contracts/predictify-hybrid/src/markets.rs index 57c360d7..ff8a513a 100644 --- a/contracts/predictify-hybrid/src/markets.rs +++ b/contracts/predictify-hybrid/src/markets.rs @@ -1725,6 +1725,15 @@ impl MarketAnalytics { /// } /// ``` pub fn calculate_community_consensus(market: &Market) -> CommunityConsensus { + // Determine the consensus outcome deterministically so that the computed + // snapshot is stable across repeated reads. + // + // INVARIANT: Soroban `Map` iteration order is not guaranteed to be stable + // across reads, so tie-breaking must NOT rely on `Map` iteration order. + // We aggregate vote counts first (summation is order-independent), then + // select the consensus outcome by walking the market's canonical, + // application-ordered `outcomes` list. In a tie, the first-listed outcome + // (in canonical order) is deterministically selected as consensus. let mut vote_counts: Map = Map::new(&market.votes.env()); for (_, outcome) in market.votes.iter() { @@ -1732,15 +1741,39 @@ impl MarketAnalytics { vote_counts.set(outcome.clone(), count + 1); } - let mut consensus_outcome = String::from_str(&market.votes.env(), ""); let mut max_votes = 0; let mut total_votes = 0; - - for (outcome, count) in vote_counts.iter() { + for (_, count) in vote_counts.iter() { total_votes += count; if count > max_votes { max_votes = count; - consensus_outcome = outcome.clone(); + } + } + + // Select consensus outcome using canonical outcome ordering. Only relevant + // when there is at least one vote; with no votes we return the empty + // outcome (matching legacy behavior). + let mut consensus_outcome = String::from_str(&market.votes.env(), ""); + if max_votes > 0 { + let mut found = false; + for outcome in market.outcomes.iter() { + let count = vote_counts.get(outcome.clone()).unwrap_or(0); + if count == max_votes { + consensus_outcome = outcome.clone(); + found = true; + break; + } + } + // Defensive fallback for a malformed market whose voted outcomes do not + // appear in `market.outcomes`: deterministically select the first voted + // outcome reached in counts map order (aggregation, counts are exact). + if !found { + for (outcome, count) in vote_counts.iter() { + if count == max_votes { + consensus_outcome = outcome.clone(); + break; + } + } } } @@ -2206,6 +2239,11 @@ impl MarketUtils { outcome_stakes.set(outcome.clone(), current_stake + stake); } + // INVARIANT: Soroban `Map` iteration order is not guaranteed to be stable + // across reads. Vote/stake aggregation above is order-independent (sums), + // but winner selection below must use the market's canonical outcome + // ordering so the resolved winner set is deterministic across repeated + // reads and consistent with `calculate_community_consensus` tie-breaks. // Find outcomes with maximum votes let mut max_votes = 0; for (_, count) in outcome_votes.iter() { @@ -2214,9 +2252,11 @@ impl MarketUtils { } } - // Find all outcomes with max_votes (within tie threshold) + // Find all outcomes within the tie threshold of max_votes, walking the + // market's canonical `outcomes` list (not the Map) for deterministic order. let mut tied_outcomes = Vec::new(env); - for (outcome, count) in outcome_votes.iter() { + for outcome in market.outcomes.iter() { + let count = outcome_votes.get(outcome.clone()).unwrap_or(0); // Check if this outcome is within tie threshold of max if count >= max_votes.saturating_sub(tie_threshold) { tied_outcomes.push_back(outcome.clone()); @@ -2235,7 +2275,8 @@ impl MarketUtils { } } - // Filter to outcomes with max stake (or within threshold) + // Filter to outcomes with max stake (or within threshold); iterate the + // already canonically-ordered `tied_outcomes` to keep the order stable. let mut final_winners = Vec::new(env); for outcome in tied_outcomes.iter() { let stake = outcome_stakes.get(outcome.clone()).unwrap_or(0); diff --git a/contracts/predictify-hybrid/src/voting_snapshot_stability_tests.rs b/contracts/predictify-hybrid/src/voting_snapshot_stability_tests.rs new file mode 100644 index 00000000..f40bdc9e --- /dev/null +++ b/contracts/predictify-hybrid/src/voting_snapshot_stability_tests.rs @@ -0,0 +1,248 @@ +//! Focused tests for voting snapshot stability across repeated reads. +//! +//! The community-consensus "snapshot" is computed (not persisted) by +//! [`crate::markets::MarketAnalytics::calculate_community_consensus`] and consumed +//! by [`crate::markets::MarketUtils::determine_winning_outcomes`]. These tests pin +//! the two critical determinism invariants: +//! +//! 1. Repeated reads of the consensus yield an identical value for the same vote +//! census (stable snapshot). +//! 2. Tie-breaking does not depend on Soroban `Map` iteration order; ties resolve +//! against the market's canonical, application-ordered `outcomes` list. + +#![cfg(test)] + +use soroban_sdk::testutils::Address as _; +use soroban_sdk::{vec, Address, Env, Map, String, Vec}; + +use crate::markets::{MarketAnalytics, MarketUtils}; +use crate::types::{Market, MarketState, OracleConfig, OracleProvider}; + +/// Builds a Market with the given outcomes and no votes. +fn make_market(env: &Env, outcomes: Vec) -> Market { + Market::new( + env, + Address::generate(env), + String::from_str(env, "Will test outcomes be deterministically selected?"), + outcomes, + env.ledger().timestamp() + 86400, + OracleConfig::new( + OracleProvider::pyth(), + Address::from_str( + env, + "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF", + ), + String::from_str(env, "TEST/USD"), + 2_500_000, + String::from_str(env, "gt"), + ), + None, + 86400, + MarketState::Active, + ) +} + +fn outcomes(env: &Env, names: &[&str]) -> Vec { + let mut out = Vec::new(env); + for name in names { + out.push_back(String::from_str(env, name)); + } + out +} + +/// Casts one vote of `stake` for each `(outcome, stake)` entry under a fresh +/// address. Outcomes are the canonical winners candidates; votes are keyed by +/// generated addresses (the contract logic keys votes by Address). +fn cast_votes(env: &Env, market: &mut Market, entries: &[(&str, i128)]) { + for (outcome, stake) in entries { + let address = Address::generate(env); + market + .votes + .set(address.clone(), String::from_str(env, outcome)); + market.stakes.set(address, (*stake).into()); + } +} + +// ── Repeated-read stability ───────────────────────────────────── + +#[test] +fn test_consensus_is_stable_across_repeated_reads() { + let env = Env::default(); + let mut market = make_market(&env, outcomes(&env, &["a", "b", "c"])); + cast_votes( + &env, + &mut market, + &[("a", 100), ("a", 100), ("b", 100), ("c", 100)], + ); + + let first = MarketAnalytics::calculate_community_consensus(&market); + for _ in 0..10 { + let repeated = MarketAnalytics::calculate_community_consensus(&market); + assert_eq!(repeated.outcome, first.outcome, "consensus outcome drifted"); + assert_eq!(repeated.votes, first.votes, "consensus vote count drifted"); + assert_eq!( + repeated.total_votes, first.total_votes, + "consensus total drifted" + ); + assert_eq!( + repeated.percentage, first.percentage, + "consensus percentage drifted" + ); + } + + // Clear majority: "a" wins with 2/4 votes = 50%. + assert_eq!(first.outcome, String::from_str(&env, "a")); + assert_eq!(first.votes, 2); + assert_eq!(first.total_votes, 4); + assert_eq!(first.percentage, 50); +} + +// ── Tie-breaking determinism ──────────────────────────────────── + +#[test] +fn test_tie_breaks_deterministically_by_canonical_outcome_order() { + let env = Env::default(); + // Exact three-way structure: "a" and "b" tie for the lead (2 each), "c" has 1. + let mut market = make_market(&env, outcomes(&env, &["a", "b", "c"])); + cast_votes( + &env, + &mut market, + &[("a", 100), ("a", 100), ("b", 100), ("b", 100), ("c", 100)], + ); + + // "a" is first-listed among the tied leaders, so it must win the tie. + let consensus = MarketAnalytics::calculate_community_consensus(&market); + assert_eq!(consensus.outcome, String::from_str(&env, "a")); + assert_eq!(consensus.votes, 2); + assert_eq!(consensus.total_votes, 5); + assert_eq!(consensus.percentage, 40); + + // Repeated reads never flip the chosen outcome. + for _ in 0..10 { + let repeated = MarketAnalytics::calculate_community_consensus(&market); + assert_eq!(repeated.outcome, String::from_str(&env, "a")); + } +} + +#[test] +fn test_tie_break_is_independent_of_vote_insertion_order() { + let env = Env::default(); + + // Same final census, different insertion order into the underlying Map. + let insert_orderings: [&[(&str, i128)]; 2] = [ + &[("a", 100), ("b", 100), ("a", 100), ("b", 100)], + &[("b", 100), ("a", 100), ("b", 100), ("a", 100)], + ]; + + let mut outcomes_seen: Vec = Vec::new(&env); + for entries in insert_orderings.iter() { + let mut market = make_market(&env, outcomes(&env, &["a", "b"])); + cast_votes(&env, &mut market, entries); + let consensus = MarketAnalytics::calculate_community_consensus(&market); + outcomes_seen.push_back(consensus.outcome.clone()); + } + + // Both insertion orders must resolve to the same deterministic winner ("a"). + assert_eq!(outcomes_seen.len(), 2); + assert_eq!(outcomes_seen.get(0).unwrap(), String::from_str(&env, "a")); + assert_eq!(outcomes_seen.get(1).unwrap(), String::from_str(&env, "a")); +} + +// ── Consistency between consensus and winning-outcome selection ─ + +#[test] +fn test_consensus_and_winning_outcomes_agree_on_ties() { + let env = Env::default(); + let mut market = make_market(&env, outcomes(&env, &["a", "b", "c"])); + cast_votes( + &env, + &mut market, + &[("a", 100), ("b", 100), ("a", 50), ("b", 50)], + ); + + let consensus = MarketAnalytics::calculate_community_consensus(&market); + let oracle = String::from_str(&env, "b"); + + // With an exact vote tie between "a" and "b", the leader is "a" (canonical + // order), but stakes are tied (150 each). The stake-based tie-break yields + // BOTH as winners. Regardless of exact selection, repeated resolution must be + // identical. + let first = MarketUtils::determine_winning_outcomes(&env, &market, &oracle, &consensus, 0); + assert_eq!(first.len(), 2, "vote-tied + stake-tied outcomes both win"); + for _ in 0..10 { + let repeated = + MarketUtils::determine_winning_outcomes(&env, &market, &oracle, &consensus, 0); + assert_eq!(repeated.len(), first.len(), "winning outcome count drifted"); + for i in 0..first.len() { + assert_eq!( + repeated.get(i).unwrap(), + first.get(i).unwrap(), + "winning outcome set/order drifted" + ); + } + } +} + +// ── Boundary cases ────────────────────────────────────────────── + +#[test] +fn test_no_votes_yields_empty_deterministic_consensus() { + let env = Env::default(); + let market = make_market(&env, outcomes(&env, &["yes", "no"])); + + let first = MarketAnalytics::calculate_community_consensus(&market); + assert_eq!(first.total_votes, 0); + assert_eq!(first.votes, 0); + assert_eq!(first.percentage, 0); + // No votes => no consensus outcome. + assert!(first.outcome.is_empty()); + + let second = MarketAnalytics::calculate_community_consensus(&market); + assert_eq!(first.outcome, second.outcome); + assert_eq!(first.votes, second.votes); +} + +#[test] +fn test_single_outcome_unanimous_vote() { + let env = Env::default(); + let mut market = make_market(&env, outcomes(&env, &["only"])); + cast_votes( + &env, + &mut market, + &[("only", 100), ("only", 100), ("only", 100)], + ); + + let consensus = MarketAnalytics::calculate_community_consensus(&market); + assert_eq!(consensus.outcome, String::from_str(&env, "only")); + assert_eq!(consensus.votes, 3); + assert_eq!(consensus.total_votes, 3); + assert_eq!(consensus.percentage, 100); + + // Regression: unanimous vote is stable across reads. + let oracle = String::from_str(&env, "only"); + let first = MarketUtils::determine_winning_outcomes(&env, &market, &oracle, &consensus, 0); + let second = MarketUtils::determine_winning_outcomes(&env, &market, &oracle, &consensus, 0); + assert_eq!(first.len(), second.len()); + assert_eq!(first.len(), 1); + assert_eq!(first.get(0).unwrap(), String::from_str(&env, "only")); + assert_eq!(second.get(0).unwrap(), String::from_str(&env, "only")); +} + +// ── Regression: clear majority behavior is unchanged ──────────── + +#[test] +fn test_clear_majority_still_selects_leader() { + let env = Env::default(); + let mut market = make_market(&env, outcomes(&env, &["a", "b"])); + cast_votes( + &env, + &mut market, + &[("a", 100), ("a", 100), ("a", 100), ("b", 100)], + ); + + let consensus = MarketAnalytics::calculate_community_consensus(&market); + assert_eq!(consensus.outcome, String::from_str(&env, "a")); + assert_eq!(consensus.votes, 3); + assert_eq!(consensus.total_votes, 4); + assert_eq!(consensus.percentage, 75); +}