diff --git a/contracts/predictify-hybrid/src/disputes.rs b/contracts/predictify-hybrid/src/disputes.rs index ecbf1b6a..5264f169 100644 --- a/contracts/predictify-hybrid/src/disputes.rs +++ b/contracts/predictify-hybrid/src/disputes.rs @@ -663,6 +663,15 @@ pub struct DisputeTimeoutOutcome { pub reason: String, } +/// Configuration for dispute collusion detection. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CollusionDetectorConfig { + pub stake_delta_threshold: i128, + pub time_delta_threshold: u64, + pub window_size: u32, +} + /// Aggregate statistics about dispute timeouts across all markets. /// /// Returned by timeout analytics queries; useful for governance dashboards @@ -786,6 +795,27 @@ impl DisputeManager { env.storage().persistent().get(&key) } + /// Sets the collusion detector configuration. + pub fn set_collusion_detector_config(env: &Env, admin: Address, config: CollusionDetectorConfig) -> Result<(), Error> { + admin.require_auth(); + DisputeValidator::validate_admin_permissions(env, &admin)?; + + let key = DataKey::CollusionDetectorConfig; + env.storage().persistent().set(&key, &config); + env.storage().persistent().extend_ttl(&key, 535680, 535680); + Ok(()) + } + + /// Retrieves the collusion detector configuration. + pub fn get_collusion_detector_config(env: &Env) -> CollusionDetectorConfig { + let key = DataKey::CollusionDetectorConfig; + env.storage().persistent().get(&key).unwrap_or(CollusionDetectorConfig { + stake_delta_threshold: 1_000_000, + time_delta_threshold: 600, // 10 minutes + window_size: 8, + }) + } + /// Evicts the oldest resolved/expired disputes if history size exceeds the cap. pub fn apply_eviction( env: &Env, @@ -990,6 +1020,36 @@ impl DisputeManager { None, ); + // --- Collusion Detector --- + let config = Self::get_collusion_detector_config(env); + let window_size = config.window_size; + let start_idx = if history.len() > window_size { + history.len() - window_size + } else { + 0 + }; + + for i in start_idx..history.len().saturating_sub(1) { + if let Some(prev_dispute) = history.get(i) { + if prev_dispute.user != user { + let stake_diff = if prev_dispute.stake > stake { prev_dispute.stake - stake } else { stake - prev_dispute.stake }; + let time_diff = if prev_dispute.timestamp > dispute.timestamp { prev_dispute.timestamp - dispute.timestamp } else { dispute.timestamp - prev_dispute.timestamp }; + + if stake_diff <= config.stake_delta_threshold && time_diff <= config.time_delta_threshold { + crate::events::EventEmitter::emit_suspected_collusion_flag( + env, + &market_id, + &user, + &prev_dispute.user, + stake_diff, + time_diff, + ); + } + } + } + } + // -------------------------- + Ok(()) } diff --git a/contracts/predictify-hybrid/src/events.rs b/contracts/predictify-hybrid/src/events.rs index e0daa171..7e275292 100644 --- a/contracts/predictify-hybrid/src/events.rs +++ b/contracts/predictify-hybrid/src/events.rs @@ -576,6 +576,18 @@ pub struct DisputeOpenedEvent { pub timestamp: u64, } +/// Event emitted when suspected collusion is detected among disputers. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SuspectedCollusionFlagEvent { + pub market_id: Symbol, + pub user1: Address, + pub user2: Address, + pub stake_delta: i128, + pub time_delta: u64, + pub timestamp: u64, +} + /// Event emitted when a dispute is successfully resolved with final outcome and rewards. /// /// This event captures the complete dispute resolution process, including the final @@ -2686,6 +2698,29 @@ impl EventEmitter { .publish((schema.topic, market_id.clone(), schema.schema_version), event); } + /// Emit suspected collusion flag event. + pub fn emit_suspected_collusion_flag( + env: &Env, + market_id: &Symbol, + user1: &Address, + user2: &Address, + stake_delta: i128, + time_delta: u64, + ) { + let event = SuspectedCollusionFlagEvent { + market_id: market_id.clone(), + user1: user1.clone(), + user2: user2.clone(), + stake_delta, + time_delta, + timestamp: env.ledger().timestamp(), + }; + + Self::store_event(env, &symbol_short!("sus_col"), &event); + env.events() + .publish((symbol_short!("sus_col"), market_id.clone()), event); + } + /// Emit dispute resolved event pub fn emit_dispute_resolved( env: &Env, diff --git a/contracts/predictify-hybrid/src/storage.rs b/contracts/predictify-hybrid/src/storage.rs index 589cb3c1..915b0fa8 100644 --- a/contracts/predictify-hybrid/src/storage.rs +++ b/contracts/predictify-hybrid/src/storage.rs @@ -87,6 +87,8 @@ pub enum DataKey { MarketCache(Symbol), /// Nonce for admin override replay protection. AdminOverrideNonce(Address), + /// Configuration for dispute collusion detector + CollusionDetectorConfig, } /// Storage format version for migration tracking diff --git a/contracts/predictify-hybrid/src/tests/dispute_collusion_tests.rs b/contracts/predictify-hybrid/src/tests/dispute_collusion_tests.rs new file mode 100644 index 00000000..4a046cbe --- /dev/null +++ b/contracts/predictify-hybrid/src/tests/dispute_collusion_tests.rs @@ -0,0 +1,130 @@ +#![cfg(test)] +extern crate std; + +use alloc::vec; +use soroban_sdk::{ + testutils::Address as _, + Address, Env, String, Symbol, +}; + +use crate::{ + disputes::{DisputeManager, CollusionDetectorConfig}, + types::{Market, MarketState, OracleConfig, GlobalConfig}, +}; + +fn setup_env_and_market() -> (Env, Address, Symbol) { + let env = Env::default(); + let admin = Address::generate(&env); + let market_id = Symbol::new(&env, "BTC_50K"); + + let market = Market { + admin: admin.clone(), + question: String::from_str(&env, "Will BTC hit 50k?"), + outcomes: vec![ + String::from_str(&env, "Yes"), + String::from_str(&env, "No"), + ], + end_time: env.ledger().timestamp() + 3600, + oracle_config: OracleConfig { + feed_id: Symbol::new(&env, "BTC_USD"), + oracle_address: admin.clone(), + minimum_confidence: 80, + required_validations: 1, + fallback_duration: 3600, + }, + state: MarketState::Active, + total_staked: 0, + bets: vec![], + votes: soroban_sdk::Map::new(&env), + stakes: soroban_sdk::Map::new(&env), + disputes: vec![], + dispute_stakes: soroban_sdk::Map::new(&env), + resolutions: vec![], + winning_outcomes: None, + claimed: soroban_sdk::Map::new(&env), + created_at: env.ledger().timestamp(), + updated_at: env.ledger().timestamp(), + fee_collected: false, + resolution_duration: 3600, + dispute_window_seconds: 3600, + extensions_count: 0, + metadata: None, + tags: vec![], + }; + + let contract_id = env.register(crate::PredictifyHybrid, ()); + env.as_contract(&contract_id, || { + crate::markets::MarketStateManager::update_market(&env, &market_id, &market); + + let config = GlobalConfig { + admin: admin.clone(), + fee_address: Address::generate(&env), + fee_percent: 1, + creation_fee: 10, + paused: false, + }; + env.storage().persistent().set(&crate::storage::DataKey::GlobalConfig, &config); + }); + + (env, admin, market_id) +} + +#[test] +fn test_collusion_detector() { + let (env, admin, market_id) = setup_env_and_market(); + let user1 = Address::generate(&env); + let user2 = Address::generate(&env); + let user3 = Address::generate(&env); + + env.mock_all_auths(); + + let contract_id = env.register(crate::PredictifyHybrid, ()); + + env.as_contract(&contract_id, || { + crate::storage::BalanceStorage::add_balance(&env, &user1, &crate::types::ReflectorAsset::Stellar, 10_000).unwrap(); + crate::storage::BalanceStorage::add_balance(&env, &user2, &crate::types::ReflectorAsset::Stellar, 10_000).unwrap(); + crate::storage::BalanceStorage::add_balance(&env, &user3, &crate::types::ReflectorAsset::Stellar, 10_000).unwrap(); + + // Configure the detector + let config = CollusionDetectorConfig { + stake_delta_threshold: 100, + time_delta_threshold: 60, + window_size: 8, + }; + DisputeManager::set_collusion_detector_config(&env, admin.clone(), config).unwrap(); + + // Dispute 1: user1 stakes 1000 + env.ledger().with_mut(|l| l.timestamp = 1000); + DisputeManager::process_dispute(&env, user1.clone(), market_id.clone(), 1000, None).unwrap(); + + // Dispute 2: user2 stakes 1050 (stake_delta=50, time_delta=10) -> SHOULD FIRE + env.ledger().with_mut(|l| l.timestamp = 1010); + DisputeManager::process_dispute(&env, user2.clone(), market_id.clone(), 1050, None).unwrap(); + + // Dispute 3: user3 stakes 2000 (stake_delta=950, time_delta=20) -> SHOULD BE SUPPRESSED + env.ledger().with_mut(|l| l.timestamp = 1030); + DisputeManager::process_dispute(&env, user3.clone(), market_id.clone(), 2000, None).unwrap(); + }); + + // We verify the events. + let events = env.events().all(); + let mut collision_flags_count = 0; + + for (contract, topic, event_val) in events.iter() { + if let Ok(topic_vec) = soroban_sdk::Vec::::try_from_val(&env, &topic) { + if topic_vec.len() > 0 && topic_vec.get(0).unwrap() == Symbol::new(&env, "sus_col") { + collision_flags_count += 1; + + let event: crate::events::SuspectedCollusionFlagEvent = event_val.try_into_val(&env).unwrap(); + + // Assert the details of the expected flag + assert_eq!(event.user1, user2); + assert_eq!(event.user2, user1); + assert_eq!(event.stake_delta, 50); + assert_eq!(event.time_delta, 10); + } + } + } + + assert_eq!(collision_flags_count, 1, "Exactly one collision flag should have been emitted"); +} diff --git a/contracts/predictify-hybrid/src/tests/mod.rs b/contracts/predictify-hybrid/src/tests/mod.rs index b5051898..f841a93d 100644 --- a/contracts/predictify-hybrid/src/tests/mod.rs +++ b/contracts/predictify-hybrid/src/tests/mod.rs @@ -27,4 +27,5 @@ 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 dispute_collusion_tests; \ No newline at end of file