diff --git a/contracts/predictify-hybrid/src/reporting.rs b/contracts/predictify-hybrid/src/reporting.rs index 388d2841..feb81007 100644 --- a/contracts/predictify-hybrid/src/reporting.rs +++ b/contracts/predictify-hybrid/src/reporting.rs @@ -58,6 +58,89 @@ pub struct EventSnapshot { pub end_time: u64, } +// --------------------------------------------------------------------------- +// Snapshot Diffing +// --------------------------------------------------------------------------- + +/// A snapshot containing multiple event states for offline comparison. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct StateSnapshot { + pub stats: PlatformStats, + pub events: Map, +} + +/// A symmetric diff of two `StateSnapshot`s. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SnapshotDiff { + pub added: Vec, + pub removed: Vec, + pub changed: Vec, + pub fee_delta: i128, + pub total_pool_delta: i128, +} + +impl SnapshotDiff { + /// Inverts the diff such that `diff(a, b).invert(env) == diff(b, a)` + pub fn invert(&self, env: &Env) -> Self { + Self { + added: self.removed.clone(), + removed: self.added.clone(), + changed: self.changed.clone(), + fee_delta: -self.fee_delta, + total_pool_delta: -self.total_pool_delta, + } + } +} + +impl StateSnapshot { + /// Computes a typed difference between `prev` and `next` `StateSnapshot`s. + /// Returns a deterministic, ordered list of market IDs that were added, removed, or changed. + /// Also includes the fee and total-pool deltas. + pub fn diff(env: &Env, prev: &Self, next: &Self) -> SnapshotDiff { + let mut added = Vec::new(env); + let mut removed = Vec::new(env); + let mut changed = Vec::new(env); + + let mut unique_keys: Map = Map::new(env); + for key in prev.events.keys().into_iter() { + unique_keys.set(key.clone(), ()); + } + for key in next.events.keys().into_iter() { + unique_keys.set(key.clone(), ()); + } + + for key in unique_keys.keys().into_iter() { + let val_prev = prev.events.get(key.clone()); + let val_next = next.events.get(key.clone()); + + match (val_prev, val_next) { + (Some(_), None) => removed.push_back(key), + (None, Some(_)) => added.push_back(key), + (Some(p), Some(n)) => { + if p != n { + changed.push_back(key); + } + } + (None, None) => {} + } + } + + let fee_delta = next.stats.total_fees_collected.saturating_sub(prev.stats.total_fees_collected); + let pool_delta = next.stats.total_pool_all_events.saturating_sub(prev.stats.total_pool_all_events); + + SnapshotDiff { + added, + removed, + changed, + fee_delta, + total_pool_delta: pool_delta, + } + } +} + + // --------------------------------------------------------------------------- // SnapshotEnvelope // --------------------------------------------------------------------------- diff --git a/contracts/predictify-hybrid/src/tests/mod.rs b/contracts/predictify-hybrid/src/tests/mod.rs index b5051898..07c561b9 100644 --- a/contracts/predictify-hybrid/src/tests/mod.rs +++ b/contracts/predictify-hybrid/src/tests/mod.rs @@ -27,4 +27,6 @@ pub mod dispute_stake_tests; pub mod fee_config_commit_reveal_tests; pub mod reflector_twap_cache_tests; pub mod dispute_anti_grief_tests; -pub mod oracle_differential_fuzz; \ No newline at end of file +pub mod oracle_differential_fuzz; +pub mod monitoring_mttr_tests; +pub mod snapshot_diffing_tests; \ No newline at end of file diff --git a/contracts/predictify-hybrid/src/tests/snapshot_diffing_tests.rs b/contracts/predictify-hybrid/src/tests/snapshot_diffing_tests.rs new file mode 100644 index 00000000..28cf3280 --- /dev/null +++ b/contracts/predictify-hybrid/src/tests/snapshot_diffing_tests.rs @@ -0,0 +1,93 @@ +#![cfg(test)] + +use crate::reporting::{EventSnapshot, PlatformStats, SnapshotDiff, StateSnapshot}; +use crate::types::MarketState; +use soroban_sdk::{Env, Map, String, Symbol, Vec}; + +fn create_event_snapshot(env: &Env, id: &str, pool: i128) -> EventSnapshot { + EventSnapshot { + id: Symbol::new(env, id), + question: String::from_str(env, "Q"), + outcomes: Vec::new(env), + state: MarketState::Active, + total_pool: pool, + outcome_pools: Map::new(env), + participant_count: 0, + end_time: 0, + } +} + +fn create_platform_stats(env: &Env, fees: i128, pool: i128) -> PlatformStats { + PlatformStats { + total_active_events: 0, + total_resolved_events: 0, + total_pool_all_events: pool, + total_fees_collected: fees, + version: String::from_str(env, "1.0.0"), + } +} + +#[test] +fn test_state_snapshot_diff() { + let env = Env::default(); + + let mut events_a = Map::new(&env); + events_a.set( + Symbol::new(&env, "market1"), + create_event_snapshot(&env, "market1", 100), + ); + events_a.set( + Symbol::new(&env, "market2"), + create_event_snapshot(&env, "market2", 200), + ); + let stats_a = create_platform_stats(&env, 50, 300); + + let mut events_b = Map::new(&env); + events_b.set( + Symbol::new(&env, "market1"), + create_event_snapshot(&env, "market1", 100), // unchanged + ); + events_b.set( + Symbol::new(&env, "market2"), + create_event_snapshot(&env, "market2", 300), // changed + ); + events_b.set( + Symbol::new(&env, "market3"), + create_event_snapshot(&env, "market3", 500), // added + ); + let stats_b = create_platform_stats(&env, 70, 900); + + let snapshot_a = StateSnapshot { stats: stats_a.clone(), events: events_a }; + let snapshot_b = StateSnapshot { stats: stats_b.clone(), events: events_b }; + + // diff(A, B) + let diff_ab = StateSnapshot::diff(&env, &snapshot_a, &snapshot_b); + assert_eq!(diff_ab.added.len(), 1); + assert!(diff_ab.added.contains(&Symbol::new(&env, "market3"))); + assert_eq!(diff_ab.removed.len(), 0); + assert_eq!(diff_ab.changed.len(), 1); + assert!(diff_ab.changed.contains(&Symbol::new(&env, "market2"))); + assert_eq!(diff_ab.fee_delta, 20); // 70 - 50 + assert_eq!(diff_ab.total_pool_delta, 600); // 900 - 300 + + // diff(B, A) + let diff_ba = StateSnapshot::diff(&env, &snapshot_b, &snapshot_a); + assert_eq!(diff_ba.added.len(), 0); + assert_eq!(diff_ba.removed.len(), 1); + assert!(diff_ba.removed.contains(&Symbol::new(&env, "market3"))); + assert_eq!(diff_ba.changed.len(), 1); + assert!(diff_ba.changed.contains(&Symbol::new(&env, "market2"))); + assert_eq!(diff_ba.fee_delta, -20); // 50 - 70 + assert_eq!(diff_ba.total_pool_delta, -600); // 300 - 900 + + // symmetric property: diff(a, b).invert() == diff(b, a) + assert_eq!(diff_ab.invert(&env), diff_ba); + + // identity property: diff(A, A) is empty + let diff_aa = StateSnapshot::diff(&env, &snapshot_a, &snapshot_a); + assert_eq!(diff_aa.added.len(), 0); + assert_eq!(diff_aa.removed.len(), 0); + assert_eq!(diff_aa.changed.len(), 0); + assert_eq!(diff_aa.fee_delta, 0); + assert_eq!(diff_aa.total_pool_delta, 0); +} diff --git a/docs/CAPABILITIES.md b/docs/CAPABILITIES.md index e7c2f650..ab160919 100644 --- a/docs/CAPABILITIES.md +++ b/docs/CAPABILITIES.md @@ -1,5 +1,6 @@ # Contract Capabilities + The Predictify Hybrid contract exposes a **u64 capabilities bitmap** that allows clients to discover which features are available without inspecting the Wasm binary or relying on version-number heuristics.