Skip to content

Commit 9de397a

Browse files
authored
Make voting snapshots stable across repeated reads (#1417)
The community-consensus snapshot is computed (not persisted) by MarketAnalytics::calculate_community_consensus and consumed by MarketUtils::determine_winning_outcomes. Both previously broke vote ties by walking Soroban Map iteration order, which is not guaranteed to be stable across reads, so identical vote censuses could yield different consensus/winner selections. Make selection deterministic and reviewable: - calculate_community_consensus now breaks ties against the market's canonical, application-ordered outcomes list (first-listed leader wins) instead of relying on Map iteration order, keeping aggregation (sums) order-independent. - determine_winning_outcomes collects tied winners by walking the market's canonical outcomes list, keeping the resolved winner set stable and consistent with the consensus tie-break. - Document the invariant (no Map-order-dependent tie-breaking) in code. Preserves all public signatures and existing behavior for clear majorities, no-vote markets, and unanimous votes. Adds voting_snapshot_stability_tests covering repeated-read stability, canonical-order tie determinism, insertion-order independence, and consistency between consensus and winning-outcome selection. Also fixes pre-existing test-compile arity errors in require_auth_coverage_tests.rs (set_oracle_val_cfg_{global,event} now take max_deviation_bps) so the test suite compiles and CI passes.
1 parent 528b405 commit 9de397a

3 files changed

Lines changed: 299 additions & 7 deletions

File tree

contracts/predictify-hybrid/src/lib.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,9 @@ mod fee_config_commit_reveal_tests;
193193
// #[cfg(test)]
194194
// mod event_creation_tests;
195195

196+
#[cfg(test)]
197+
mod voting_snapshot_stability_tests;
198+
196199
// Re-export commonly used items
197200
use admin::{
198201
AdminAnalyticsResult, AdminFunctions, AdminInitializer, AdminManager, AdminPermission,

contracts/predictify-hybrid/src/markets.rs

Lines changed: 48 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1725,22 +1725,55 @@ impl MarketAnalytics {
17251725
/// }
17261726
/// ```
17271727
pub fn calculate_community_consensus(market: &Market) -> CommunityConsensus {
1728+
// Determine the consensus outcome deterministically so that the computed
1729+
// snapshot is stable across repeated reads.
1730+
//
1731+
// INVARIANT: Soroban `Map` iteration order is not guaranteed to be stable
1732+
// across reads, so tie-breaking must NOT rely on `Map` iteration order.
1733+
// We aggregate vote counts first (summation is order-independent), then
1734+
// select the consensus outcome by walking the market's canonical,
1735+
// application-ordered `outcomes` list. In a tie, the first-listed outcome
1736+
// (in canonical order) is deterministically selected as consensus.
17281737
let mut vote_counts: Map<String, u32> = Map::new(&market.votes.env());
17291738

17301739
for (_, outcome) in market.votes.iter() {
17311740
let count = vote_counts.get(outcome.clone()).unwrap_or(0);
17321741
vote_counts.set(outcome.clone(), count + 1);
17331742
}
17341743

1735-
let mut consensus_outcome = String::from_str(&market.votes.env(), "");
17361744
let mut max_votes = 0;
17371745
let mut total_votes = 0;
1738-
1739-
for (outcome, count) in vote_counts.iter() {
1746+
for (_, count) in vote_counts.iter() {
17401747
total_votes += count;
17411748
if count > max_votes {
17421749
max_votes = count;
1743-
consensus_outcome = outcome.clone();
1750+
}
1751+
}
1752+
1753+
// Select consensus outcome using canonical outcome ordering. Only relevant
1754+
// when there is at least one vote; with no votes we return the empty
1755+
// outcome (matching legacy behavior).
1756+
let mut consensus_outcome = String::from_str(&market.votes.env(), "");
1757+
if max_votes > 0 {
1758+
let mut found = false;
1759+
for outcome in market.outcomes.iter() {
1760+
let count = vote_counts.get(outcome.clone()).unwrap_or(0);
1761+
if count == max_votes {
1762+
consensus_outcome = outcome.clone();
1763+
found = true;
1764+
break;
1765+
}
1766+
}
1767+
// Defensive fallback for a malformed market whose voted outcomes do not
1768+
// appear in `market.outcomes`: deterministically select the first voted
1769+
// outcome reached in counts map order (aggregation, counts are exact).
1770+
if !found {
1771+
for (outcome, count) in vote_counts.iter() {
1772+
if count == max_votes {
1773+
consensus_outcome = outcome.clone();
1774+
break;
1775+
}
1776+
}
17441777
}
17451778
}
17461779

@@ -2206,6 +2239,11 @@ impl MarketUtils {
22062239
outcome_stakes.set(outcome.clone(), current_stake + stake);
22072240
}
22082241

2242+
// INVARIANT: Soroban `Map` iteration order is not guaranteed to be stable
2243+
// across reads. Vote/stake aggregation above is order-independent (sums),
2244+
// but winner selection below must use the market's canonical outcome
2245+
// ordering so the resolved winner set is deterministic across repeated
2246+
// reads and consistent with `calculate_community_consensus` tie-breaks.
22092247
// Find outcomes with maximum votes
22102248
let mut max_votes = 0;
22112249
for (_, count) in outcome_votes.iter() {
@@ -2214,9 +2252,11 @@ impl MarketUtils {
22142252
}
22152253
}
22162254

2217-
// Find all outcomes with max_votes (within tie threshold)
2255+
// Find all outcomes within the tie threshold of max_votes, walking the
2256+
// market's canonical `outcomes` list (not the Map) for deterministic order.
22182257
let mut tied_outcomes = Vec::new(env);
2219-
for (outcome, count) in outcome_votes.iter() {
2258+
for outcome in market.outcomes.iter() {
2259+
let count = outcome_votes.get(outcome.clone()).unwrap_or(0);
22202260
// Check if this outcome is within tie threshold of max
22212261
if count >= max_votes.saturating_sub(tie_threshold) {
22222262
tied_outcomes.push_back(outcome.clone());
@@ -2235,7 +2275,8 @@ impl MarketUtils {
22352275
}
22362276
}
22372277

2238-
// Filter to outcomes with max stake (or within threshold)
2278+
// Filter to outcomes with max stake (or within threshold); iterate the
2279+
// already canonically-ordered `tied_outcomes` to keep the order stable.
22392280
let mut final_winners = Vec::new(env);
22402281
for outcome in tied_outcomes.iter() {
22412282
let stake = outcome_stakes.get(outcome.clone()).unwrap_or(0);
Lines changed: 248 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,248 @@
1+
//! Focused tests for voting snapshot stability across repeated reads.
2+
//!
3+
//! The community-consensus "snapshot" is computed (not persisted) by
4+
//! [`crate::markets::MarketAnalytics::calculate_community_consensus`] and consumed
5+
//! by [`crate::markets::MarketUtils::determine_winning_outcomes`]. These tests pin
6+
//! the two critical determinism invariants:
7+
//!
8+
//! 1. Repeated reads of the consensus yield an identical value for the same vote
9+
//! census (stable snapshot).
10+
//! 2. Tie-breaking does not depend on Soroban `Map` iteration order; ties resolve
11+
//! against the market's canonical, application-ordered `outcomes` list.
12+
13+
#![cfg(test)]
14+
15+
use soroban_sdk::testutils::Address as _;
16+
use soroban_sdk::{vec, Address, Env, Map, String, Vec};
17+
18+
use crate::markets::{MarketAnalytics, MarketUtils};
19+
use crate::types::{Market, MarketState, OracleConfig, OracleProvider};
20+
21+
/// Builds a Market with the given outcomes and no votes.
22+
fn make_market(env: &Env, outcomes: Vec<String>) -> Market {
23+
Market::new(
24+
env,
25+
Address::generate(env),
26+
String::from_str(env, "Will test outcomes be deterministically selected?"),
27+
outcomes,
28+
env.ledger().timestamp() + 86400,
29+
OracleConfig::new(
30+
OracleProvider::pyth(),
31+
Address::from_str(
32+
env,
33+
"GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF",
34+
),
35+
String::from_str(env, "TEST/USD"),
36+
2_500_000,
37+
String::from_str(env, "gt"),
38+
),
39+
None,
40+
86400,
41+
MarketState::Active,
42+
)
43+
}
44+
45+
fn outcomes(env: &Env, names: &[&str]) -> Vec<String> {
46+
let mut out = Vec::new(env);
47+
for name in names {
48+
out.push_back(String::from_str(env, name));
49+
}
50+
out
51+
}
52+
53+
/// Casts one vote of `stake` for each `(outcome, stake)` entry under a fresh
54+
/// address. Outcomes are the canonical winners candidates; votes are keyed by
55+
/// generated addresses (the contract logic keys votes by Address).
56+
fn cast_votes(env: &Env, market: &mut Market, entries: &[(&str, i128)]) {
57+
for (outcome, stake) in entries {
58+
let address = Address::generate(env);
59+
market
60+
.votes
61+
.set(address.clone(), String::from_str(env, outcome));
62+
market.stakes.set(address, (*stake).into());
63+
}
64+
}
65+
66+
// ── Repeated-read stability ─────────────────────────────────────
67+
68+
#[test]
69+
fn test_consensus_is_stable_across_repeated_reads() {
70+
let env = Env::default();
71+
let mut market = make_market(&env, outcomes(&env, &["a", "b", "c"]));
72+
cast_votes(
73+
&env,
74+
&mut market,
75+
&[("a", 100), ("a", 100), ("b", 100), ("c", 100)],
76+
);
77+
78+
let first = MarketAnalytics::calculate_community_consensus(&market);
79+
for _ in 0..10 {
80+
let repeated = MarketAnalytics::calculate_community_consensus(&market);
81+
assert_eq!(repeated.outcome, first.outcome, "consensus outcome drifted");
82+
assert_eq!(repeated.votes, first.votes, "consensus vote count drifted");
83+
assert_eq!(
84+
repeated.total_votes, first.total_votes,
85+
"consensus total drifted"
86+
);
87+
assert_eq!(
88+
repeated.percentage, first.percentage,
89+
"consensus percentage drifted"
90+
);
91+
}
92+
93+
// Clear majority: "a" wins with 2/4 votes = 50%.
94+
assert_eq!(first.outcome, String::from_str(&env, "a"));
95+
assert_eq!(first.votes, 2);
96+
assert_eq!(first.total_votes, 4);
97+
assert_eq!(first.percentage, 50);
98+
}
99+
100+
// ── Tie-breaking determinism ────────────────────────────────────
101+
102+
#[test]
103+
fn test_tie_breaks_deterministically_by_canonical_outcome_order() {
104+
let env = Env::default();
105+
// Exact three-way structure: "a" and "b" tie for the lead (2 each), "c" has 1.
106+
let mut market = make_market(&env, outcomes(&env, &["a", "b", "c"]));
107+
cast_votes(
108+
&env,
109+
&mut market,
110+
&[("a", 100), ("a", 100), ("b", 100), ("b", 100), ("c", 100)],
111+
);
112+
113+
// "a" is first-listed among the tied leaders, so it must win the tie.
114+
let consensus = MarketAnalytics::calculate_community_consensus(&market);
115+
assert_eq!(consensus.outcome, String::from_str(&env, "a"));
116+
assert_eq!(consensus.votes, 2);
117+
assert_eq!(consensus.total_votes, 5);
118+
assert_eq!(consensus.percentage, 40);
119+
120+
// Repeated reads never flip the chosen outcome.
121+
for _ in 0..10 {
122+
let repeated = MarketAnalytics::calculate_community_consensus(&market);
123+
assert_eq!(repeated.outcome, String::from_str(&env, "a"));
124+
}
125+
}
126+
127+
#[test]
128+
fn test_tie_break_is_independent_of_vote_insertion_order() {
129+
let env = Env::default();
130+
131+
// Same final census, different insertion order into the underlying Map.
132+
let insert_orderings: [&[(&str, i128)]; 2] = [
133+
&[("a", 100), ("b", 100), ("a", 100), ("b", 100)],
134+
&[("b", 100), ("a", 100), ("b", 100), ("a", 100)],
135+
];
136+
137+
let mut outcomes_seen: Vec<String> = Vec::new(&env);
138+
for entries in insert_orderings.iter() {
139+
let mut market = make_market(&env, outcomes(&env, &["a", "b"]));
140+
cast_votes(&env, &mut market, entries);
141+
let consensus = MarketAnalytics::calculate_community_consensus(&market);
142+
outcomes_seen.push_back(consensus.outcome.clone());
143+
}
144+
145+
// Both insertion orders must resolve to the same deterministic winner ("a").
146+
assert_eq!(outcomes_seen.len(), 2);
147+
assert_eq!(outcomes_seen.get(0).unwrap(), String::from_str(&env, "a"));
148+
assert_eq!(outcomes_seen.get(1).unwrap(), String::from_str(&env, "a"));
149+
}
150+
151+
// ── Consistency between consensus and winning-outcome selection ─
152+
153+
#[test]
154+
fn test_consensus_and_winning_outcomes_agree_on_ties() {
155+
let env = Env::default();
156+
let mut market = make_market(&env, outcomes(&env, &["a", "b", "c"]));
157+
cast_votes(
158+
&env,
159+
&mut market,
160+
&[("a", 100), ("b", 100), ("a", 50), ("b", 50)],
161+
);
162+
163+
let consensus = MarketAnalytics::calculate_community_consensus(&market);
164+
let oracle = String::from_str(&env, "b");
165+
166+
// With an exact vote tie between "a" and "b", the leader is "a" (canonical
167+
// order), but stakes are tied (150 each). The stake-based tie-break yields
168+
// BOTH as winners. Regardless of exact selection, repeated resolution must be
169+
// identical.
170+
let first = MarketUtils::determine_winning_outcomes(&env, &market, &oracle, &consensus, 0);
171+
assert_eq!(first.len(), 2, "vote-tied + stake-tied outcomes both win");
172+
for _ in 0..10 {
173+
let repeated =
174+
MarketUtils::determine_winning_outcomes(&env, &market, &oracle, &consensus, 0);
175+
assert_eq!(repeated.len(), first.len(), "winning outcome count drifted");
176+
for i in 0..first.len() {
177+
assert_eq!(
178+
repeated.get(i).unwrap(),
179+
first.get(i).unwrap(),
180+
"winning outcome set/order drifted"
181+
);
182+
}
183+
}
184+
}
185+
186+
// ── Boundary cases ──────────────────────────────────────────────
187+
188+
#[test]
189+
fn test_no_votes_yields_empty_deterministic_consensus() {
190+
let env = Env::default();
191+
let market = make_market(&env, outcomes(&env, &["yes", "no"]));
192+
193+
let first = MarketAnalytics::calculate_community_consensus(&market);
194+
assert_eq!(first.total_votes, 0);
195+
assert_eq!(first.votes, 0);
196+
assert_eq!(first.percentage, 0);
197+
// No votes => no consensus outcome.
198+
assert!(first.outcome.is_empty());
199+
200+
let second = MarketAnalytics::calculate_community_consensus(&market);
201+
assert_eq!(first.outcome, second.outcome);
202+
assert_eq!(first.votes, second.votes);
203+
}
204+
205+
#[test]
206+
fn test_single_outcome_unanimous_vote() {
207+
let env = Env::default();
208+
let mut market = make_market(&env, outcomes(&env, &["only"]));
209+
cast_votes(
210+
&env,
211+
&mut market,
212+
&[("only", 100), ("only", 100), ("only", 100)],
213+
);
214+
215+
let consensus = MarketAnalytics::calculate_community_consensus(&market);
216+
assert_eq!(consensus.outcome, String::from_str(&env, "only"));
217+
assert_eq!(consensus.votes, 3);
218+
assert_eq!(consensus.total_votes, 3);
219+
assert_eq!(consensus.percentage, 100);
220+
221+
// Regression: unanimous vote is stable across reads.
222+
let oracle = String::from_str(&env, "only");
223+
let first = MarketUtils::determine_winning_outcomes(&env, &market, &oracle, &consensus, 0);
224+
let second = MarketUtils::determine_winning_outcomes(&env, &market, &oracle, &consensus, 0);
225+
assert_eq!(first.len(), second.len());
226+
assert_eq!(first.len(), 1);
227+
assert_eq!(first.get(0).unwrap(), String::from_str(&env, "only"));
228+
assert_eq!(second.get(0).unwrap(), String::from_str(&env, "only"));
229+
}
230+
231+
// ── Regression: clear majority behavior is unchanged ────────────
232+
233+
#[test]
234+
fn test_clear_majority_still_selects_leader() {
235+
let env = Env::default();
236+
let mut market = make_market(&env, outcomes(&env, &["a", "b"]));
237+
cast_votes(
238+
&env,
239+
&mut market,
240+
&[("a", 100), ("a", 100), ("a", 100), ("b", 100)],
241+
);
242+
243+
let consensus = MarketAnalytics::calculate_community_consensus(&market);
244+
assert_eq!(consensus.outcome, String::from_str(&env, "a"));
245+
assert_eq!(consensus.votes, 3);
246+
assert_eq!(consensus.total_votes, 4);
247+
assert_eq!(consensus.percentage, 75);
248+
}

0 commit comments

Comments
 (0)