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/err.rs b/contracts/predictify-hybrid/src/err.rs index 29996cd1..fe0e66a8 100644 --- a/contracts/predictify-hybrid/src/err.rs +++ b/contracts/predictify-hybrid/src/err.rs @@ -242,7 +242,7 @@ pub enum Error { /// The upgrade chain predecessor hash does not match the expected value. UpgradeChainMismatch = 525, /// An admin override nonce was replayed; reject to prevent replay attacks. - ReplayedOverride = 526, + ReplayedAdminOverride = 526, /// Oracle quote is an outlier relative to the rolling median history. OracleQuoteOutlier = 527, } 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/err_code_stability_tests.rs b/contracts/predictify-hybrid/src/tests/err_code_stability_tests.rs new file mode 100644 index 00000000..91b2afa1 --- /dev/null +++ b/contracts/predictify-hybrid/src/tests/err_code_stability_tests.rs @@ -0,0 +1,126 @@ +#![cfg(test)] +extern crate std; + +use alloc::vec; +use crate::err::Error; + +/// This snapshot test asserts a frozen mapping of variant names to numeric codes. +/// `err.rs` defines many error variants whose numeric codes are part of the client-facing API. +/// If you are adding a new variant, append it to this table explicitly. +/// If you remove an existing variant, remove it from this table. +/// This prevents accidental reordering or insertion in the middle of the enum +/// which would cause silent breaking changes to clients. +#[test] +fn test_error_code_stability() { + let expected_codes = vec![ + (Error::Unauthorized, 100), + (Error::MarketNotFound, 101), + (Error::MarketClosed, 102), + (Error::MarketResolved, 103), + (Error::MarketNotResolved, 104), + (Error::NothingToClaim, 105), + (Error::AlreadyClaimed, 106), + (Error::InsufficientStake, 107), + (Error::InvalidOutcome, 108), + (Error::AlreadyVoted, 109), + (Error::AlreadyBet, 110), + (Error::BetsAlreadyPlaced, 111), + (Error::InsufficientBalance, 112), + + (Error::OracleUnavailable, 200), + (Error::InvalidOracleConfig, 201), + (Error::OracleStale, 202), + (Error::OracleNoConsensus, 203), + (Error::OracleVerified, 204), + (Error::MarketNotReady, 205), + (Error::FallbackOracleUnavailable, 206), + (Error::ResolutionTimeoutReached, 207), + (Error::OracleConfidenceTooWide, 208), + (Error::InvalidOracleFeed, 209), + (Error::OracleCallbackAuthFailed, 210), + (Error::OracleCallbackUnauthorized, 211), + (Error::OracleCallbackInvalidSignature, 212), + (Error::OracleCallbackReplayDetected, 213), + (Error::OracleCallbackTimeout, 214), + + (Error::InvalidQuestion, 300), + (Error::InvalidOutcomes, 301), + (Error::InvalidDuration, 302), + (Error::InvalidThreshold, 303), + (Error::InvalidComparison, 304), + + (Error::InvalidState, 400), + (Error::InvalidInput, 401), + (Error::InvalidFeeConfig, 402), + (Error::ConfigNotFound, 403), + (Error::AlreadyDisputed, 404), + (Error::DisputeVoteExpired, 405), + (Error::DisputeVoteDenied, 406), + (Error::DisputeAlreadyVoted, 407), + (Error::DisputeCondNotMet, 408), + (Error::DisputeFeeFailed, 409), + (Error::DisputeError, 410), + (Error::SweepAlreadyDone, 411), + (Error::FeeArithmeticOverflow, 412), + (Error::FeeAlreadyCollected, 413), + (Error::NoFeesToCollect, 414), + (Error::InvalidExtensionDays, 415), + (Error::ExtensionDenied, 416), + (Error::GasBudgetExceeded, 417), + (Error::OperationWouldExceedBudget, 418), + (Error::AdminNotSet, 419), + (Error::QuestionTooLong, 420), + (Error::OutcomeTooLong, 421), + (Error::TooManyOutcomes, 422), + (Error::FeedIdTooLong, 423), + (Error::ComparisonTooLong, 424), + (Error::CategoryTooLong, 425), + (Error::TagTooLong, 426), + (Error::TooManyTags, 427), + (Error::ExtensionReasonTooLong, 428), + (Error::SourceTooLong, 429), + (Error::ErrorMessageTooLong, 430), + (Error::SignatureTooLong, 431), + (Error::TooManyExtensions, 432), + (Error::TooManyOracleResults, 433), + (Error::TooManyWinningOutcomes, 434), + (Error::ForceResolveAlreadyUsed, 435), + (Error::CategoryTooShort, 436), + (Error::TagTooShort, 437), + (Error::DisputerCannotVote, 438), + (Error::AssetDecimalsMismatch, 439), + (Error::ArchiveFull, 440), + (Error::DuplicateMarketId, 441), + (Error::ReplayedOverride, 442), + + (Error::CBNotInitialized, 500), + (Error::CBAlreadyOpen, 501), + (Error::CBNotOpen, 502), + (Error::CBOpen, 503), + (Error::CBError, 504), + (Error::RateLimitExceeded, 505), + (Error::CumulativeExtensionCapHit, 506), + (Error::IllegalMarketStateTransition, 507), + (Error::FeeExceedsMax, 508), + (Error::NoPendingFeeCommit, 519), + (Error::FeeRevealTooEarly, 520), + (Error::FeePreimageMismatch, 521), + (Error::DisputeStakeCapExceeded, 522), + (Error::InsufficientStorageRentBudget, 523), + (Error::ExtensionCapExceeded, 524), + (Error::UpgradeChainMismatch, 525), + (Error::ReplayedAdminOverride, 526), + (Error::OracleQuoteOutlier, 527), + ]; + + for (error, expected_code) in expected_codes { + assert_eq!( + error as u32, + expected_code, + "Error variant {:?} has a mismatched numeric code. Expected {}, but got {}. This breaks client-facing API stability.", + error, + expected_code, + error as u32 + ); + } +} diff --git a/contracts/predictify-hybrid/src/tests/mod.rs b/contracts/predictify-hybrid/src/tests/mod.rs index b5051898..d24f809f 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 dispute_collusion_tests; +pub mod err_code_stability_tests; \ No newline at end of file