Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ members = [
[workspace.dependencies]
soroban-sdk = "25.0.0"

[patch.crates-io]
ethnum = { git = "https://github.com/ebfull/ethnum", rev = "8a8c28c" }

[profile.release]
opt-level = "z"
overflow-checks = true
Expand Down
13 changes: 13 additions & 0 deletions contracts/predictify-hybrid/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
122 changes: 122 additions & 0 deletions contracts/predictify-hybrid/src/analytics_snapshot.rs
Original file line number Diff line number Diff line change
@@ -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<OutcomeCount>,
/// 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<MarketAnalyticsSnapshot, Error> {
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<AnalyticsSnapshotEnvelope, Error> {
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<String, u32> = 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))
}
}
74 changes: 74 additions & 0 deletions contracts/predictify-hybrid/src/analytics_snapshot_tests.rs
Original file line number Diff line number Diff line change
@@ -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));
});
}
14 changes: 14 additions & 0 deletions contracts/predictify-hybrid/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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::AnalyticsSnapshotEnvelope, Error> {
analytics_snapshot::AnalyticsSnapshotManager::get_snapshot(&env, market_id)
}

/// Dispute a market resolution
///
/// # Errors
Expand Down
Loading