From 254f5013a17c3a5a8a99a2cd25081c1c6e2f1492 Mon Sep 17 00:00:00 2001 From: tobiadewola41-eng Date: Thu, 23 Jul 2026 20:01:44 +0000 Subject: [PATCH 1/6] feat: add deterministic per-market analytics snapshot --- contracts/predictify-hybrid/README.md | 13 ++ .../src/analytics_snapshot.rs | 122 ++++++++++++++++++ .../src/analytics_snapshot_tests.rs | 74 +++++++++++ contracts/predictify-hybrid/src/lib.rs | 14 ++ 4 files changed, 223 insertions(+) create mode 100644 contracts/predictify-hybrid/src/analytics_snapshot.rs create mode 100644 contracts/predictify-hybrid/src/analytics_snapshot_tests.rs diff --git a/contracts/predictify-hybrid/README.md b/contracts/predictify-hybrid/README.md index de1a1510..67f81721 100644 --- a/contracts/predictify-hybrid/README.md +++ b/contracts/predictify-hybrid/README.md @@ -111,6 +111,19 @@ This is a hybrid prediction market contract built on Stellar using Soroban that - **Batch Bet Placement**: Place multiple bets in a single atomic transaction for gas efficiency - **Admin Fee Withdrawal Schedule**: Timelock + optional cap for fee withdrawals to reduce abuse risk +## Deterministic Per-Market Analytics Snapshots + +The contract now exposes `PredictifyHybrid::get_market_analytics_snapshot(env, market_id)` for off-chain analytics and indexers. The returned envelope is versioned and XDR-encoded so consumers can persist a deterministic byte stream for downstream processing without relying on host-side map iteration order. + +### What the snapshot includes +- Market identifier and question +- Current market state +- Vote and stake totals +- Outcome counts in a stable sorted order +- Participant count + +This is intended for read-only analytics and reporting workflows that need a canonical per-market view. + ## Admin Fee Vault & Withdrawal Schedule Collected platform fees accumulate inside the contract and are withdrawn by the admin through a diff --git a/contracts/predictify-hybrid/src/analytics_snapshot.rs b/contracts/predictify-hybrid/src/analytics_snapshot.rs new file mode 100644 index 00000000..fdc99a48 --- /dev/null +++ b/contracts/predictify-hybrid/src/analytics_snapshot.rs @@ -0,0 +1,122 @@ +use crate::err::Error; +use crate::markets::MarketAnalytics; +use crate::types::{Market, MarketState}; +use alloc::vec::Vec as StdVec; +use soroban_sdk::{contracttype, xdr::{FromXdr, ToXdr}, Bytes, Env, Map, String, Symbol, Vec}; + +/// Schema version for per-market analytics snapshots. +pub const ANALYTICS_SNAPSHOT_SCHEMA_VERSION: u32 = 1; + +/// Compact per-market analytics payload for off-chain consumers. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MarketAnalyticsSnapshot { + /// Market identifier used to correlate the payload with storage. + pub market_id: Symbol, + /// Market question for downstream display and debugging. + pub question: String, + /// Current market state. + pub state: MarketState, + /// Total number of votes cast in this market. + pub total_votes: u32, + /// Total stake currently locked in the market. + pub total_staked: i128, + /// Total dispute stake currently locked in the market. + pub total_dispute_stakes: i128, + /// Outcome vote counts in a deterministic order. + pub outcome_counts: Vec, + /// Number of unique participants in the market. + pub participant_count: u32, +} + +/// A single outcome bucket inside a market analytics snapshot. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct OutcomeCount { + /// The outcome label. + pub outcome: String, + /// The number of votes for that outcome. + pub count: u32, +} + +/// Versioned envelope for per-market analytics snapshots. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct AnalyticsSnapshotEnvelope { + /// Schema version of the inner payload. + pub schema_version: u32, + /// Ledger timestamp at snapshot creation time. + pub taken_at: u64, + /// XDR-encoded bytes of the analytics payload. + pub payload: Bytes, +} + +impl AnalyticsSnapshotEnvelope { + /// Encode a market analytics snapshot into a versioned envelope. + pub fn encode(env: &Env, snapshot: &MarketAnalyticsSnapshot) -> Self { + Self { + schema_version: ANALYTICS_SNAPSHOT_SCHEMA_VERSION, + taken_at: env.ledger().timestamp(), + payload: snapshot.clone().to_xdr(env), + } + } + + /// Decode a market analytics snapshot envelope. + pub fn decode(env: &Env, envelope: &Self) -> Result { + if envelope.schema_version != ANALYTICS_SNAPSHOT_SCHEMA_VERSION { + return Err(Error::InvalidInput); + } + MarketAnalyticsSnapshot::from_xdr(env, &envelope.payload).map_err(|_| Error::InvalidInput) + } +} + +/// Manager for deterministic per-market analytics snapshots. +pub struct AnalyticsSnapshotManager; + +impl AnalyticsSnapshotManager { + /// Return the current schema version for this module. + pub fn schema_version() -> u32 { + ANALYTICS_SNAPSHOT_SCHEMA_VERSION + } + + /// Create a deterministic snapshot for a single market. + pub fn get_snapshot(env: &Env, market_id: Symbol) -> Result { + let market: Market = env + .storage() + .persistent() + .get(&market_id) + .ok_or(Error::MarketNotFound)?; + + let stats = MarketAnalytics::get_market_stats(&market); + let mut outcome_counts = Vec::new(env); + let mut counts: Map = Map::new(env); + + for (_, outcome) in market.votes.iter() { + let current = counts.get(outcome.clone()).unwrap_or(0); + counts.set(outcome.clone(), current + 1); + } + + let mut ordered: StdVec<(String, u32)> = StdVec::new(); + for (outcome, count) in counts.iter() { + ordered.push((outcome, count)); + } + ordered.sort_by(|left, right| left.0.to_string().cmp(&right.0.to_string())); + + for (outcome, count) in ordered { + outcome_counts.push_back(OutcomeCount { outcome, count }); + } + + let snapshot = MarketAnalyticsSnapshot { + market_id: market_id.clone(), + question: market.question.clone(), + state: market.state.clone(), + total_votes: stats.total_votes, + total_staked: stats.total_staked, + total_dispute_stakes: stats.total_dispute_stakes, + outcome_counts, + participant_count: market.votes.len(), + }; + + Ok(AnalyticsSnapshotEnvelope::encode(env, &snapshot)) + } +} diff --git a/contracts/predictify-hybrid/src/analytics_snapshot_tests.rs b/contracts/predictify-hybrid/src/analytics_snapshot_tests.rs new file mode 100644 index 00000000..a75cb48d --- /dev/null +++ b/contracts/predictify-hybrid/src/analytics_snapshot_tests.rs @@ -0,0 +1,74 @@ +use crate::analytics_snapshot::{AnalyticsSnapshotEnvelope, AnalyticsSnapshotManager}; +use crate::err::Error; +use crate::types::{Market, MarketState, OracleConfig}; +use crate::PredictifyHybrid; +use soroban_sdk::{symbol_short, Address, Env, String, Symbol, Vec}; + +fn make_market(env: &Env, market_id: Symbol) -> Market { + let admin = Address::generate(env); + let question = String::from_str(env, "Will GrantFox ship by Q4?"); + let outcomes = Vec::from_array(env, [String::from_str(env, "yes"), String::from_str(env, "no")]); + Market::new( + env, + admin, + question, + outcomes, + 1_000_000, + OracleConfig::none_sentinel(env), + None, + 60, + MarketState::Active, + ) +} + +#[test] +fn market_analytics_snapshot_is_deterministic_and_round_trippable() { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register(PredictifyHybrid {}, ()); + let market_id = Symbol::new(&env, "grantfox_market"); + + env.as_contract(&contract_id, || { + let mut market = make_market(&env, market_id.clone()); + let user_a = Address::generate(&env); + let user_b = Address::generate(&env); + market.add_vote(user_a.clone(), String::from_str(&env, "yes"), 10_000); + market.add_vote(user_b.clone(), String::from_str(&env, "no"), 5_000); + env.storage().persistent().set(&market_id, &market); + + let envelope = PredictifyHybrid::get_market_analytics_snapshot(env.clone(), market_id.clone()) + .expect("snapshot should be available for an existing market"); + + assert_eq!(envelope.schema_version, AnalyticsSnapshotManager::schema_version()); + assert_eq!(envelope.taken_at, env.ledger().timestamp()); + + let decoded = AnalyticsSnapshotEnvelope::decode(&env, &envelope) + .expect("snapshot envelope should decode"); + + assert_eq!(decoded.market_id, market_id); + assert_eq!(decoded.total_votes, 2); + assert_eq!(decoded.total_staked, 15_000); + assert_eq!(decoded.total_dispute_stakes, 0); + assert_eq!(decoded.outcome_counts.len(), 2); + assert_eq!(decoded.outcome_counts.get(0).unwrap().outcome, String::from_str(&env, "yes")); + assert_eq!(decoded.outcome_counts.get(0).unwrap().count, 1); + assert_eq!(decoded.outcome_counts.get(1).unwrap().outcome, String::from_str(&env, "no")); + assert_eq!(decoded.outcome_counts.get(1).unwrap().count, 1); + assert_eq!(decoded.participant_count, 2); + + let re_encoded = AnalyticsSnapshotEnvelope::encode(&env, &decoded); + assert_eq!(envelope.payload, re_encoded.payload); + }); +} + +#[test] +fn market_analytics_snapshot_returns_market_not_found_for_unknown_market() { + let env = Env::default(); + let contract_id = env.register(PredictifyHybrid {}, ()); + let market_id = Symbol::new(&env, "missing_market"); + + env.as_contract(&contract_id, || { + let result = PredictifyHybrid::get_market_analytics_snapshot(env.clone(), market_id); + assert_eq!(result, Err(Error::MarketNotFound)); + }); +} diff --git a/contracts/predictify-hybrid/src/lib.rs b/contracts/predictify-hybrid/src/lib.rs index 95b3f779..9738ce3c 100644 --- a/contracts/predictify-hybrid/src/lib.rs +++ b/contracts/predictify-hybrid/src/lib.rs @@ -12,6 +12,7 @@ mod admin; // mod error_code_tests; pub mod audit_trail; mod analytics; +mod analytics_snapshot; mod balances; mod batch_operations; mod bets; @@ -131,6 +132,8 @@ mod force_resolve_tests; // #[cfg(any())] // mod resolution_delay_dispute_window_tests; +#[cfg(test)] +mod analytics_snapshot_tests; #[cfg(test)] mod property_based_tests; @@ -3143,6 +3146,17 @@ impl PredictifyHybrid { Err(Error::MarketNotFound) } + /// Returns a deterministic, versioned snapshot for a single market's analytics. + /// + /// The payload is encoded with Soroban XDR so off-chain analytics services can + /// persist a stable byte stream without relying on host-side ordering. + pub fn get_market_analytics_snapshot( + env: Env, + market_id: Symbol, + ) -> Result { + analytics_snapshot::AnalyticsSnapshotManager::get_snapshot(&env, market_id) + } + /// Dispute a market resolution /// /// # Errors From 56988228c51bf474797511bf558ecd279d6da220 Mon Sep 17 00:00:00 2001 From: tobiadewola41-eng Date: Thu, 23 Jul 2026 22:24:07 +0100 Subject: [PATCH 2/6] fix: update ethnum to 1.6.0 to resolve compiler incompatibility (E0512 transmute error) --- Cargo.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/Cargo.toml b/Cargo.toml index 6708434b..29a33413 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,6 +6,7 @@ members = [ [workspace.dependencies] soroban-sdk = "25.0.0" +ethnum = "1.6.0" [profile.release] opt-level = "z" From 25fc8add1941c6282c05aed5a8b369188a12f0f6 Mon Sep 17 00:00:00 2001 From: tobiadewola41-eng Date: Thu, 23 Jul 2026 22:30:16 +0100 Subject: [PATCH 3/6] fix: use cargo patch to override ethnum 1.5.0 with 1.6.0 to resolve E0512 error --- Cargo.toml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index 29a33413..91f89964 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,7 +6,12 @@ members = [ [workspace.dependencies] soroban-sdk = "25.0.0" -ethnum = "1.6.0" + +[package.metadata.cargo-edit] +resolver = "2" + +[patch.crates-io] +ethnum = { version = "1.6.0" } [profile.release] opt-level = "z" From 918f623f8da308694af7ca4531b5a0f93cd62717 Mon Sep 17 00:00:00 2001 From: tobiadewola41-eng Date: Thu, 23 Jul 2026 22:32:36 +0100 Subject: [PATCH 4/6] fix: correct cargo toml format for patch section --- Cargo.toml | 3 --- 1 file changed, 3 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 91f89964..4310ad24 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,9 +7,6 @@ members = [ [workspace.dependencies] soroban-sdk = "25.0.0" -[package.metadata.cargo-edit] -resolver = "2" - [patch.crates-io] ethnum = { version = "1.6.0" } From 7ba4ef2a9c858cb204a2aff13c201c53e257dcdc Mon Sep 17 00:00:00 2001 From: tobiadewola41-eng Date: Thu, 23 Jul 2026 22:35:27 +0100 Subject: [PATCH 5/6] fix: patch ethnum from git main branch to resolve E0512 transmute error --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index 4310ad24..0069ec5f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,7 +8,7 @@ members = [ soroban-sdk = "25.0.0" [patch.crates-io] -ethnum = { version = "1.6.0" } +ethnum = { git = "https://github.com/ebfull/ethnum.git", branch = "main" } [profile.release] opt-level = "z" From 409c99db62da8bfc2011177eebe8778f4888b759 Mon Sep 17 00:00:00 2001 From: tobiadewola41-eng Date: Thu, 23 Jul 2026 22:38:03 +0100 Subject: [PATCH 6/6] fix: patch ethnum with specific commit that fixes E0512 error --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index 0069ec5f..fa93daf5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,7 +8,7 @@ members = [ soroban-sdk = "25.0.0" [patch.crates-io] -ethnum = { git = "https://github.com/ebfull/ethnum.git", branch = "main" } +ethnum = { git = "https://github.com/ebfull/ethnum", rev = "8a8c28c" } [profile.release] opt-level = "z"