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..a95263a4 100644 --- a/contracts/predictify-hybrid/src/events.rs +++ b/contracts/predictify-hybrid/src/events.rs @@ -26,6 +26,15 @@ pub enum AdminRole { /// - Event testing utilities and examples /// - Event documentation and examples + +/// Wrapper to embed a replay protection nonce into event payloads +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct EventPayload

{ + pub nonce: u64, + pub payload: P, +} + // ===== EVENT TYPES ===== /// Event emitted when a new prediction market is successfully created. @@ -576,6 +585,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 @@ -2135,6 +2156,20 @@ pub struct DeprecatedCall { pub struct EventEmitter; impl EventEmitter { + + pub fn publish_with_nonce(env: &Env, topics: T, primary_topic: Symbol, payload: P) + where + T: soroban_sdk::IntoVal, + P: soroban_sdk::IntoVal, + { + let key = crate::storage::DataKey::EventNonce(primary_topic.clone()); + let nonce: u64 = env.storage().persistent().get(&key).unwrap_or(0); + env.storage().persistent().set(&key, &(nonce + 1)); + + let wrapped = EventPayload { nonce, payload }; + env.events().publish(topics, wrapped); + } + /// Emit market created event pub fn emit_market_created( env: &Env, @@ -2154,8 +2189,7 @@ impl EventEmitter { }; Self::store_event(env, &symbol_short!("mkt_crt"), &event); - env.events() - .publish((symbol_short!("mkt_crt"), market_id.clone()), event); + Self::publish_with_nonce(env, (symbol_short!("mkt_crt"), market_id.clone()), symbol_short!("mkt_crt").clone(), event); } /// Emit fallback used event @@ -2173,8 +2207,7 @@ impl EventEmitter { }; Self::store_event(env, &symbol_short!("fbk_used"), &event); - env.events() - .publish((symbol_short!("fbk_used"), market_id.clone()), event); + Self::publish_with_nonce(env, (symbol_short!("fbk_used"), market_id.clone()), symbol_short!("fbk_used").clone(), event); } /// Emit resolution timeout event @@ -2185,8 +2218,7 @@ impl EventEmitter { }; Self::store_event(env, &symbol_short!("res_tmo"), &event); - env.events() - .publish((symbol_short!("res_tmo"), market_id.clone()), event); + Self::publish_with_nonce(env, (symbol_short!("res_tmo"), market_id.clone()), symbol_short!("res_tmo").clone(), event); } /// Emit event created event @@ -2209,8 +2241,7 @@ impl EventEmitter { }; Self::store_event(env, &symbol_short!("evt_crt"), &event); - env.events() - .publish((symbol_short!("evt_crt"), event_id.clone()), event); + Self::publish_with_nonce(env, (symbol_short!("evt_crt"), event_id.clone()), symbol_short!("evt_crt").clone(), event); } /// Emit vote cast event @@ -2230,8 +2261,7 @@ impl EventEmitter { }; Self::store_event(env, &symbol_short!("vote"), &event); - env.events() - .publish((symbol_short!("vote"), market_id.clone()), event); + Self::publish_with_nonce(env, (symbol_short!("vote"), market_id.clone()), symbol_short!("vote").clone(), event); } /// Emit statistics updated event @@ -2249,7 +2279,7 @@ impl EventEmitter { }; Self::store_event(env, &symbol_short!("stats_upd"), &event); - env.events().publish((symbol_short!("stats_upd"),), event); + Self::publish_with_nonce(env, (symbol_short!("stats_upd"),), symbol_short!("stats_upd").clone(), event); } /// Emit bet placed event when a user places a bet on a market @@ -2292,8 +2322,7 @@ impl EventEmitter { }; Self::store_event(env, &symbol_short!("bet_plc"), &event); - env.events() - .publish((symbol_short!("bet_plc"), market_id.clone()), event); + Self::publish_with_nonce(env, (symbol_short!("bet_plc"), market_id.clone()), symbol_short!("bet_plc").clone(), event); } /// Emit bet status updated event when a bet's status changes @@ -2340,8 +2369,7 @@ impl EventEmitter { }; Self::store_event(env, &symbol_short!("bet_upd"), &event); - env.events() - .publish((symbol_short!("bet_upd"), market_id.clone()), event); + Self::publish_with_nonce(env, (symbol_short!("bet_upd"), market_id.clone()), symbol_short!("bet_upd").clone(), event); } /// Emit oracle result event. @@ -2375,8 +2403,7 @@ impl EventEmitter { }; Self::store_event(env, &schema.topic, &event); - env.events() - .publish((schema.topic, market_id.clone(), schema.schema_version), event); + Self::publish_with_nonce(env, (schema.topic, market_id.clone(), schema.schema_version), schema.topic.clone(), event); } // ===== ORACLE RESULT VERIFICATION EVENT EMISSION METHODS ===== @@ -2409,8 +2436,7 @@ impl EventEmitter { }; Self::store_event(env, &symbol_short!("orc_init"), &event); - env.events() - .publish((symbol_short!("orc_init"), market_id.clone()), event); + Self::publish_with_nonce(env, (symbol_short!("orc_init"), market_id.clone()), symbol_short!("orc_init").clone(), event); } /// Emit oracle result verified event @@ -2461,8 +2487,7 @@ impl EventEmitter { }; Self::store_event(env, &symbol_short!("orc_ver"), &event); - env.events() - .publish((symbol_short!("orc_ver"), market_id.clone()), event); + Self::publish_with_nonce(env, (symbol_short!("orc_ver"), market_id.clone()), symbol_short!("orc_ver").clone(), event); } /// Emit oracle verification failed event @@ -2496,8 +2521,7 @@ impl EventEmitter { }; Self::store_event(env, &symbol_short!("orc_fail"), &event); - env.events() - .publish((symbol_short!("orc_fail"), market_id.clone()), event); + Self::publish_with_nonce(env, (symbol_short!("orc_fail"), market_id.clone()), symbol_short!("orc_fail").clone(), event); } /// Emit oracle validation failed event. @@ -2525,8 +2549,7 @@ impl EventEmitter { }; Self::store_event(env, &symbol_short!("orc_val"), &event); - env.events() - .publish((symbol_short!("orc_val"), market_id.clone()), event); + Self::publish_with_nonce(env, (symbol_short!("orc_val"), market_id.clone()), symbol_short!("orc_val").clone(), event); } /// Emit oracle consensus reached event @@ -2570,8 +2593,7 @@ impl EventEmitter { }; Self::store_event(env, &symbol_short!("orc_cons"), &event); - env.events() - .publish((symbol_short!("orc_cons"), market_id.clone()), event); + Self::publish_with_nonce(env, (symbol_short!("orc_cons"), market_id.clone()), symbol_short!("orc_cons").clone(), event); } /// Emit oracle health status event @@ -2605,8 +2627,7 @@ impl EventEmitter { }; Self::store_event(env, &symbol_short!("orc_hlth"), &event); - env.events() - .publish((symbol_short!("orc_hlth"), oracle_address.clone()), event); + Self::publish_with_nonce(env, (symbol_short!("orc_hlth"), oracle_address.clone()), symbol_short!("orc_hlth").clone(), event); } /// Emit market resolved event @@ -2630,9 +2651,7 @@ impl EventEmitter { }; Self::store_event(env, &symbol_short!("mkt_res"), &event); - env.events().publish( - ( - symbol_short!("mkt_res"), + Self::publish_with_nonce(env, (symbol_short!("mkt_res"), market_id.clone(), resolution_method.clone(), ), @@ -2655,7 +2674,7 @@ impl EventEmitter { }; Self::store_event(env, &symbol_short!("pool_lo"), &event); env.events() - .publish((symbol_short!("pool_lo"), market_id.clone()), event); + .publish((symbol_short!("pool_lo"), market_id.clone()), symbol_short!("mkt_res").clone(), event); } /// Emit dispute opened event. @@ -2682,8 +2701,29 @@ impl EventEmitter { }; Self::store_event(env, &schema.topic, &event); - env.events() - .publish((schema.topic, market_id.clone(), schema.schema_version), event); + Self::publish_with_nonce(env, (schema.topic, market_id.clone(), schema.schema_version), schema.topic.clone(), 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); + Self::publish_with_nonce(env, (symbol_short!("sus_col"), market_id.clone()), symbol_short!("sus_col").clone(), event); } /// Emit dispute resolved event @@ -2705,8 +2745,7 @@ impl EventEmitter { }; Self::store_event(env, &symbol_short!("dispt_res"), &event); - env.events() - .publish((symbol_short!("dispt_res"), market_id.clone()), event); + Self::publish_with_nonce(env, (symbol_short!("dispt_res"), market_id.clone()), symbol_short!("dispt_res").clone(), event); } /// Emit dispute history evicted event @@ -2722,8 +2761,7 @@ impl EventEmitter { }; Self::store_event(env, &symbol_short!("dh_evct"), &event); - env.events() - .publish((symbol_short!("dh_evct"), market_id.clone()), event); + Self::publish_with_nonce(env, (symbol_short!("dh_evct"), market_id.clone()), symbol_short!("dh_evct").clone(), event); } /// Emit fee collected event @@ -2743,8 +2781,7 @@ impl EventEmitter { }; Self::store_event(env, &symbol_short!("fee_col"), &event); - env.events() - .publish((symbol_short!("fee_col"), market_id.clone()), event); + Self::publish_with_nonce(env, (symbol_short!("fee_col"), market_id.clone()), symbol_short!("fee_col").clone(), event); } /// Emit an admin fee withdrawal attempt event. @@ -2776,8 +2813,7 @@ impl EventEmitter { }; // Publish to the Soroban event stream - env.events() - .publish((symbol_short!("fwd_att"), admin.clone()), event.clone()); + Self::publish_with_nonce(env, (symbol_short!("fwd_att"), admin.clone()), symbol_short!("fwd_att").clone(), event.clone()); // Also store the last event for simple on-chain querying/debugging Self::store_event(env, &symbol_short!("fwd_att"), &event); @@ -2798,8 +2834,7 @@ impl EventEmitter { timestamp, }; - env.events() - .publish((symbol_short!("fwd_ok"), admin.clone()), event.clone()); + Self::publish_with_nonce(env, (symbol_short!("fwd_ok"), admin.clone()), symbol_short!("fwd_ok").clone(), event.clone()); Self::store_event(env, &symbol_short!("fwd_ok"), &event); } @@ -2822,8 +2857,7 @@ impl EventEmitter { }; Self::store_event(env, &symbol_short!("ext_req"), &event); - env.events() - .publish((symbol_short!("ext_req"), market_id.clone()), event); + Self::publish_with_nonce(env, (symbol_short!("ext_req"), market_id.clone()), symbol_short!("ext_req").clone(), event); } /// Emit configuration updated event @@ -2843,8 +2877,7 @@ impl EventEmitter { }; Self::store_event(env, &symbol_short!("cfg_upd"), &event); - env.events() - .publish((symbol_short!("cfg_upd"), updated_by.clone()), event); + Self::publish_with_nonce(env, (symbol_short!("cfg_upd"), updated_by.clone()), symbol_short!("cfg_upd").clone(), event); } /// Emit bet limits updated event (global or per-event). @@ -2863,8 +2896,7 @@ impl EventEmitter { timestamp: env.ledger().timestamp(), }; Self::store_event(env, &symbol_short!("bet_lim"), &event); - env.events() - .publish((symbol_short!("bet_lim"), scope.clone()), event); + Self::publish_with_nonce(env, (symbol_short!("bet_lim"), scope.clone()), symbol_short!("bet_lim").clone(), event); } /// Emit error logged event @@ -2886,7 +2918,7 @@ impl EventEmitter { }; Self::store_event(env, &symbol_short!("err_log"), &event); - env.events().publish((symbol_short!("err_log"),), event); + Self::publish_with_nonce(env, (symbol_short!("err_log"),), symbol_short!("err_log").clone(), event); } /// Emit error recovery event @@ -2910,7 +2942,7 @@ impl EventEmitter { }; Self::store_event(env, &symbol_short!("err_rec"), &event); - env.events().publish((symbol_short!("err_rec"),), event); + Self::publish_with_nonce(env, (symbol_short!("err_rec"),), symbol_short!("err_rec").clone(), event); } /// Emit performance metric event @@ -2930,7 +2962,7 @@ impl EventEmitter { }; Self::store_event(env, &symbol_short!("perf_met"), &event); - env.events().publish((symbol_short!("perf_met"),), event); + Self::publish_with_nonce(env, (symbol_short!("perf_met"),), symbol_short!("perf_met").clone(), event); } /// Emit admin action logged event @@ -2944,8 +2976,7 @@ impl EventEmitter { }; Self::store_event(env, &symbol_short!("adm_act"), &event); - env.events() - .publish((symbol_short!("adm_act"), admin.clone()), event); + Self::publish_with_nonce(env, (symbol_short!("adm_act"), admin.clone()), symbol_short!("adm_act").clone(), event); } /// Emit admin initialized event @@ -2956,8 +2987,7 @@ impl EventEmitter { }; Self::store_event(env, &symbol_short!("adm_init"), &event); - env.events() - .publish((symbol_short!("adm_init"), admin.clone()), event); + Self::publish_with_nonce(env, (symbol_short!("adm_init"), admin.clone()), symbol_short!("adm_init").clone(), event); } /// Emit admin transferred event (primary admin role transferred to new address). @@ -2968,8 +2998,7 @@ impl EventEmitter { timestamp: env.ledger().timestamp(), }; Self::store_event(env, &symbol_short!("adm_xfer"), &event); - env.events() - .publish((symbol_short!("adm_xfer"), new_admin.clone()), event); + Self::publish_with_nonce(env, (symbol_short!("adm_xfer"), new_admin.clone()), symbol_short!("adm_xfer").clone(), event); } /// Emit contract paused event. @@ -2979,8 +3008,7 @@ impl EventEmitter { timestamp: env.ledger().timestamp(), }; Self::store_event(env, &symbol_short!("ctr_pause"), &event); - env.events() - .publish((symbol_short!("ctr_pause"), admin.clone()), event); + Self::publish_with_nonce(env, (symbol_short!("ctr_pause"), admin.clone()), symbol_short!("ctr_pause").clone(), event); } /// Emit contract unpaused event. @@ -2990,8 +3018,7 @@ impl EventEmitter { timestamp: env.ledger().timestamp(), }; Self::store_event(env, &symbol_short!("ctr_unp"), &event); - env.events() - .publish((symbol_short!("ctr_unp"), admin.clone()), event); + Self::publish_with_nonce(env, (symbol_short!("ctr_unp"), admin.clone()), symbol_short!("ctr_unp").clone(), event); } /// Emit contract initialized event (full initialization with platform fee) @@ -3001,8 +3028,7 @@ impl EventEmitter { platform_fee_percentage: fee, timestamp: env.ledger().timestamp(), }; - env.events() - .publish((Symbol::new(env, "contract_initialized"),), event); + Self::publish_with_nonce(env, (Symbol::new(env, "contract_initialized"),), Symbol::new(env, "contract_initialized").clone(), event); } pub fn emit_platform_fee_set(env: &Env, fee: i128, admin: &Address) { @@ -3011,8 +3037,7 @@ impl EventEmitter { set_by: admin.clone(), timestamp: env.ledger().timestamp(), }; - env.events() - .publish((Symbol::new(env, "platform_fee_set"),), event); + Self::publish_with_nonce(env, (Symbol::new(env, "platform_fee_set"),), Symbol::new(env, "platform_fee_set").clone(), event); } /// Emit admin emergency broadcast event @@ -3028,8 +3053,7 @@ impl EventEmitter { reason, timestamp: env.ledger().timestamp(), }; - env.events() - .publish((Symbol::new(env, "admin_broadcast"),), event); + Self::publish_with_nonce(env, (Symbol::new(env, "admin_broadcast"),), Symbol::new(env, "admin_broadcast").clone(), event); } /// Emit config initialized event @@ -3049,8 +3073,7 @@ impl EventEmitter { }; Self::store_event(env, &symbol_short!("cfg_init"), &event); - env.events() - .publish((symbol_short!("cfg_init"), admin.clone()), event); + Self::publish_with_nonce(env, (symbol_short!("cfg_init"), admin.clone()), symbol_short!("cfg_init").clone(), event); } /// Emit admin role assigned event @@ -3075,8 +3098,7 @@ impl EventEmitter { }; Self::store_event(env, &symbol_short!("adm_role"), &event); - env.events() - .publish((symbol_short!("adm_role"), admin.clone()), event); + Self::publish_with_nonce(env, (symbol_short!("adm_role"), admin.clone()), symbol_short!("adm_role").clone(), event); } /// Emit admin role deactivated event @@ -3089,8 +3111,7 @@ impl EventEmitter { }; Self::store_event(env, &symbol_short!("adm_deact"), &event); - env.events() - .publish((symbol_short!("adm_deact"), admin.clone()), event); + Self::publish_with_nonce(env, (symbol_short!("adm_deact"), admin.clone()), symbol_short!("adm_deact").clone(), event); } /// Emit market closed event @@ -3102,8 +3123,7 @@ impl EventEmitter { }; Self::store_event(env, &symbol_short!("mkt_close"), &event); - env.events() - .publish((symbol_short!("mkt_close"), market_id.clone()), event); + Self::publish_with_nonce(env, (symbol_short!("mkt_close"), market_id.clone()), symbol_short!("mkt_close").clone(), event); } /// Emit refund on oracle failure event (market cancelled, all bets refunded in full). @@ -3114,8 +3134,7 @@ impl EventEmitter { timestamp: env.ledger().timestamp(), }; Self::store_event(env, &symbol_short!("ref_oracl"), &event); - env.events() - .publish((symbol_short!("ref_oracl"), market_id.clone()), event); + Self::publish_with_nonce(env, (symbol_short!("ref_oracl"), market_id.clone()), symbol_short!("ref_oracl").clone(), event); } /// Emit market finalized event @@ -3128,8 +3147,7 @@ impl EventEmitter { }; Self::store_event(env, &symbol_short!("mkt_final"), &event); - env.events() - .publish((symbol_short!("mkt_final"), market_id.clone()), event); + Self::publish_with_nonce(env, (symbol_short!("mkt_final"), market_id.clone()), symbol_short!("mkt_final").clone(), event); } /// Emit dispute timeout set event @@ -3149,8 +3167,7 @@ impl EventEmitter { }; Self::store_event(env, &symbol_short!("tout_set"), &event); - env.events() - .publish((symbol_short!("tout_set"), dispute_id.clone()), event); + Self::publish_with_nonce(env, (symbol_short!("tout_set"), dispute_id.clone()), symbol_short!("tout_set").clone(), event); } /// Emit dispute timeout expired event @@ -3170,8 +3187,7 @@ impl EventEmitter { }; Self::store_event(env, &symbol_short!("tout_exp"), &event); - env.events() - .publish((symbol_short!("tout_exp"), dispute_id.clone()), event); + Self::publish_with_nonce(env, (symbol_short!("tout_exp"), dispute_id.clone()), symbol_short!("tout_exp").clone(), event); } /// Emit dispute timeout extended event @@ -3191,8 +3207,7 @@ impl EventEmitter { }; Self::store_event(env, &symbol_short!("tout_ext"), &event); - env.events() - .publish((symbol_short!("tout_ext"), dispute_id.clone()), event); + Self::publish_with_nonce(env, (symbol_short!("tout_ext"), dispute_id.clone()), symbol_short!("tout_ext").clone(), event); } /// Emit dispute vote rejected event @@ -3210,8 +3225,7 @@ impl EventEmitter { }; Self::store_event(env, &symbol_short!("d_v_rej"), &event); - env.events() - .publish((symbol_short!("d_v_rej"), dispute_id.clone()), event); + Self::publish_with_nonce(env, (symbol_short!("d_v_rej"), dispute_id.clone()), symbol_short!("d_v_rej").clone(), event); } /// Emit dispute auto-resolved event @@ -3231,8 +3245,7 @@ impl EventEmitter { }; Self::store_event(env, &symbol_short!("auto_res"), &event); - env.events() - .publish((symbol_short!("auto_res"), dispute_id.clone()), event); + Self::publish_with_nonce(env, (symbol_short!("auto_res"), dispute_id.clone()), symbol_short!("auto_res").clone(), event); } /// Emit storage cleanup event @@ -3244,8 +3257,7 @@ impl EventEmitter { }; Self::store_event(env, &symbol_short!("stor_cln"), &event); - env.events() - .publish((symbol_short!("stor_cln"), market_id.clone()), event); + Self::publish_with_nonce(env, (symbol_short!("stor_cln"), market_id.clone()), symbol_short!("stor_cln").clone(), event); } /// Emit storage optimization event @@ -3261,8 +3273,7 @@ impl EventEmitter { }; Self::store_event(env, &symbol_short!("stor_opt"), &event); - env.events() - .publish((symbol_short!("stor_opt"), market_id.clone()), event); + Self::publish_with_nonce(env, (symbol_short!("stor_opt"), market_id.clone()), symbol_short!("stor_opt").clone(), event); } /// Emit storage migration event @@ -3282,8 +3293,7 @@ impl EventEmitter { }; Self::store_event(env, &symbol_short!("stor_mig"), &event); - env.events() - .publish((symbol_short!("stor_mig"), migration_id.clone()), event); + Self::publish_with_nonce(env, (symbol_short!("stor_mig"), migration_id.clone()), symbol_short!("stor_mig").clone(), event); } /// Emit market archived event @@ -3301,8 +3311,7 @@ impl EventEmitter { }; Self::store_event(env, &symbol_short!("mkt_arch"), &event); - env.events() - .publish((symbol_short!("mkt_arch"), market_id.clone()), event); + Self::publish_with_nonce(env, (symbol_short!("mkt_arch"), market_id.clone()), symbol_short!("mkt_arch").clone(), event); } /// Emit circuit breaker event @@ -3318,7 +3327,7 @@ impl EventEmitter { timestamp: env.ledger().timestamp(), }; Self::store_event(env, &symbol_short!("ora_deg"), &event); - env.events().publish((symbol_short!("ora_deg"),), event); + Self::publish_with_nonce(env, (symbol_short!("ora_deg"),), symbol_short!("ora_deg").clone(), event); } /// Emit oracle recovery event when oracle service recovers @@ -3329,7 +3338,7 @@ impl EventEmitter { timestamp: env.ledger().timestamp(), }; Self::store_event(env, &symbol_short!("ora_rec"), &event); - env.events().publish((symbol_short!("ora_rec"),), event); + Self::publish_with_nonce(env, (symbol_short!("ora_rec"),), symbol_short!("ora_rec").clone(), event); } /// Emit manual resolution required event when automatic resolution fails @@ -3340,8 +3349,7 @@ impl EventEmitter { timestamp: env.ledger().timestamp(), }; Self::store_event(env, &symbol_short!("man_res"), &event); - env.events() - .publish((symbol_short!("man_res"), market_id.clone()), event); + Self::publish_with_nonce(env, (symbol_short!("man_res"), market_id.clone()), symbol_short!("man_res").clone(), event); } /// Emit state change event when market state transitions @@ -3383,8 +3391,7 @@ impl EventEmitter { timestamp: env.ledger().timestamp(), }; Self::store_event(env, &symbol_short!("st_chng"), &event); - env.events() - .publish((symbol_short!("st_chng"), market_id.clone()), event); + Self::publish_with_nonce(env, (symbol_short!("st_chng"), market_id.clone()), symbol_short!("st_chng").clone(), event); } /// Emit winnings claimed event when user claims payout @@ -3417,8 +3424,7 @@ impl EventEmitter { timestamp: env.ledger().timestamp(), }; Self::store_event(env, &symbol_short!("win_clm"), &event); - env.events() - .publish((symbol_short!("win_clm"), market_id.clone()), event); + Self::publish_with_nonce(env, (symbol_short!("win_clm"), market_id.clone()), symbol_short!("win_clm").clone(), event); } /// Emit winnings claimed batch event @@ -3445,8 +3451,7 @@ impl EventEmitter { timestamp: env.ledger().timestamp(), }; Self::store_event(env, &symbol_short!("win_btc"), &event); - env.events() - .publish((symbol_short!("win_btc"), user.clone()), event); + Self::publish_with_nonce(env, (symbol_short!("win_btc"), user.clone()), symbol_short!("win_btc").clone(), event); } /// Emit global claim period updated event. pub fn emit_claim_period_updated(env: &Env, admin: &Address, claim_period_seconds: u64) { @@ -3456,8 +3461,7 @@ impl EventEmitter { timestamp: env.ledger().timestamp(), }; Self::store_event(env, &symbol_short!("clm_prd"), &event); - env.events() - .publish((symbol_short!("clm_prd"), admin.clone()), event); + Self::publish_with_nonce(env, (symbol_short!("clm_prd"), admin.clone()), symbol_short!("clm_prd").clone(), event); } /// Emit market claim period updated event. @@ -3474,8 +3478,7 @@ impl EventEmitter { timestamp: env.ledger().timestamp(), }; Self::store_event(env, &symbol_short!("m_clm_pd"), &event); - env.events() - .publish((symbol_short!("m_clm_pd"), market_id.clone()), event); + Self::publish_with_nonce(env, (symbol_short!("m_clm_pd"), market_id.clone()), symbol_short!("m_clm_pd").clone(), event); } /// Emit treasury updated event. @@ -3486,8 +3489,7 @@ impl EventEmitter { timestamp: env.ledger().timestamp(), }; Self::store_event(env, &symbol_short!("treas_up"), &event); - env.events() - .publish((symbol_short!("treas_up"), admin.clone()), event); + Self::publish_with_nonce(env, (symbol_short!("treas_up"), admin.clone()), symbol_short!("treas_up").clone(), event); } /// Emit unclaimed winnings swept event. @@ -3508,8 +3510,7 @@ impl EventEmitter { timestamp: env.ledger().timestamp(), }; Self::store_event(env, &symbol_short!("unc_swip"), &event); - env.events() - .publish((symbol_short!("unc_swip"), market_id.clone()), event); + Self::publish_with_nonce(env, (symbol_short!("unc_swip"), market_id.clone()), symbol_short!("unc_swip").clone(), event); } /// Emit market deadline extended event @@ -3563,8 +3564,7 @@ impl EventEmitter { timestamp: env.ledger().timestamp(), }; Self::store_event(env, &symbol_short!("mkt_ext"), &event); - env.events() - .publish((symbol_short!("mkt_ext"), market_id.clone()), event); + Self::publish_with_nonce(env, (symbol_short!("mkt_ext"), market_id.clone()), symbol_short!("mkt_ext").clone(), event); } /// Emit market description updated event @@ -3606,8 +3606,7 @@ impl EventEmitter { timestamp: env.ledger().timestamp(), }; Self::store_event(env, &symbol_short!("mkt_dsc"), &event); - env.events() - .publish((symbol_short!("mkt_dsc"), market_id.clone()), event); + Self::publish_with_nonce(env, (symbol_short!("mkt_dsc"), market_id.clone()), symbol_short!("mkt_dsc").clone(), event); } /// Emit market outcomes updated event @@ -3649,8 +3648,7 @@ impl EventEmitter { timestamp: env.ledger().timestamp(), }; Self::store_event(env, &symbol_short!("mkt_out"), &event); - env.events() - .publish((symbol_short!("mkt_out"), market_id.clone()), event); + Self::publish_with_nonce(env, (symbol_short!("mkt_out"), market_id.clone()), symbol_short!("mkt_out").clone(), event); } /// Emit market category updated event @@ -3692,8 +3690,7 @@ impl EventEmitter { timestamp: env.ledger().timestamp(), }; Self::store_event(env, &symbol_short!("mkt_cat"), &event); - env.events() - .publish((symbol_short!("mkt_cat"), market_id.clone()), event); + Self::publish_with_nonce(env, (symbol_short!("mkt_cat"), market_id.clone()), symbol_short!("mkt_cat").clone(), event); } /// Emit market tags updated event @@ -3735,8 +3732,7 @@ impl EventEmitter { timestamp: env.ledger().timestamp(), }; Self::store_event(env, &symbol_short!("mkt_tag"), &event); - env.events() - .publish((symbol_short!("mkt_tag"), market_id.clone()), event); + Self::publish_with_nonce(env, (symbol_short!("mkt_tag"), market_id.clone()), symbol_short!("mkt_tag").clone(), event); } /// Emit error event with full error context @@ -3792,7 +3788,7 @@ impl EventEmitter { }; Self::store_event(env, &symbol_short!("err_evt"), &event); - env.events().publish((symbol_short!("err_evt"),), event); + Self::publish_with_nonce(env, (symbol_short!("err_evt"),), symbol_short!("err_evt").clone(), event); } /// Emit governance proposal created event @@ -3812,8 +3808,7 @@ impl EventEmitter { }; Self::store_event(env, &symbol_short!("gov_prop"), &event); - env.events() - .publish((symbol_short!("gov_prop"), proposal_id.clone()), event); + Self::publish_with_nonce(env, (symbol_short!("gov_prop"), proposal_id.clone()), symbol_short!("gov_prop").clone(), event); } /// Emit governance vote cast event @@ -3832,8 +3827,7 @@ impl EventEmitter { }; Self::store_event(env, &symbol_short!("gov_vote"), &event); - env.events() - .publish((symbol_short!("gov_vote"), proposal_id.clone()), event); + Self::publish_with_nonce(env, (symbol_short!("gov_vote"), proposal_id.clone()), symbol_short!("gov_vote").clone(), event); } /// Emit governance vote committed event (commit phase of commit-reveal) @@ -3844,8 +3838,7 @@ impl EventEmitter { timestamp: env.ledger().timestamp(), }; Self::store_event(env, &symbol_short!("gov_cmit"), &event); - env.events() - .publish((symbol_short!("gov_cmit"), proposal_id.clone()), event); + Self::publish_with_nonce(env, (symbol_short!("gov_cmit"), proposal_id.clone()), symbol_short!("gov_cmit").clone(), event); } /// Emit governance proposal executed event @@ -3858,8 +3851,7 @@ impl EventEmitter { }; Self::store_event(env, &symbol_short!("gov_exec"), &event); - env.events() - .publish((symbol_short!("gov_exec"), proposal_id.clone()), event); + Self::publish_with_nonce(env, (symbol_short!("gov_exec"), proposal_id.clone()), symbol_short!("gov_exec").clone(), event); } /// Emit governance proposal auto-rejected event @@ -3880,8 +3872,7 @@ impl EventEmitter { }; Self::store_event(env, &symbol_short!("gov_rej"), &event); - env.events() - .publish((symbol_short!("gov_rej"), proposal_id.clone()), event); + Self::publish_with_nonce(env, (symbol_short!("gov_rej"), proposal_id.clone()), symbol_short!("gov_rej").clone(), event); } /// Emit contract upgraded event when contract Wasm is upgraded @@ -3899,8 +3890,7 @@ impl EventEmitter { }; Self::store_event(env, &symbol_short!("up_grade"), &event); - env.events() - .publish((symbol_short!("up_grade"), upgrade_id.clone()), event); + Self::publish_with_nonce(env, (symbol_short!("up_grade"), upgrade_id.clone()), symbol_short!("up_grade").clone(), event); } /// Emit contract rollback event when contract is rolled back @@ -3916,7 +3906,7 @@ impl EventEmitter { }; Self::store_event(env, &symbol_short!("rollback"), &event); - env.events().publish((symbol_short!("rollback"),), event); + Self::publish_with_nonce(env, (symbol_short!("rollback"),), symbol_short!("rollback").clone(), event); } /// Emit upgrade chain mismatch event when hash verification fails @@ -3936,8 +3926,7 @@ impl EventEmitter { }; Self::store_event(env, &symbol_short!("chain_mm"), &event); - env.events() - .publish((symbol_short!("chain_mm"), admin.clone()), event); + Self::publish_with_nonce(env, (symbol_short!("chain_mm"), admin.clone()), symbol_short!("chain_mm").clone(), event); } /// Emit upgrade proposal created event @@ -3955,8 +3944,7 @@ impl EventEmitter { }; Self::store_event(env, &symbol_short!("up_prop"), &event); - env.events() - .publish((symbol_short!("up_prop"), proposal_id.clone()), event); + Self::publish_with_nonce(env, (symbol_short!("up_prop"), proposal_id.clone()), symbol_short!("up_prop").clone(), event); } /// Emit balance changed event for deposits and withdrawals @@ -3968,8 +3956,7 @@ impl EventEmitter { amount: i128, new_balance: i128, ) { - env.events().publish( - (symbol_short!("bal_chg"), user, asset.clone()), + Self::publish_with_nonce(env, (symbol_short!("bal_chg"), user, asset.clone()), ( operation.clone(), amount, @@ -4740,7 +4727,7 @@ pub fn emit_deprecated(env: &Env, entrypoint: &Symbol) { timestamp: env.ledger().timestamp(), }; env.events() - .publish((symbol_short!("depr_call"), entrypoint.clone()), event); + .publish((symbol_short!("depr_call"), entrypoint.clone()), symbol_short!("bal_chg").clone(), event); } #[cfg(test)] @@ -4846,6 +4833,20 @@ mod event_schema_registry_tests { } impl EventEmitter { + + pub fn publish_with_nonce(env: &Env, topics: T, primary_topic: Symbol, payload: P) + where + T: soroban_sdk::IntoVal, + P: soroban_sdk::IntoVal, + { + let key = crate::storage::DataKey::EventNonce(primary_topic.clone()); + let nonce: u64 = env.storage().persistent().get(&key).unwrap_or(0); + env.storage().persistent().set(&key, &(nonce + 1)); + + let wrapped = EventPayload { nonce, payload }; + env.events().publish(topics, wrapped); + } + pub fn emit_threshold_proposed( env: &Env, admin: &Address, @@ -4862,8 +4863,7 @@ impl EventEmitter { }; Self::store_event(env, &symbol_short!("thld_prop"), &event); - env.events().publish( - (symbol_short!("thld_prop"), admin.clone()), + Self::publish_with_nonce(env, (symbol_short!("thld_prop"), admin.clone()), event, ); } @@ -5009,7 +5009,7 @@ impl EventEmitter { Self::store_event(env, &symbol_short!("adm_ovrd"), &event); env.events() - .publish((symbol_short!("adm_ovrd"), market_id.clone()), event); + .publish((symbol_short!("adm_ovrd"), market_id.clone()), symbol_short!("thld_prop").clone(), event); } /// Emit force-resolve event when an admin force-resolves a market. @@ -5031,8 +5031,7 @@ impl EventEmitter { }; Self::store_event(env, &symbol_short!("frc_rs"), &event); - env.events() - .publish((symbol_short!("frc_rs"), market_id.clone()), event); + Self::publish_with_nonce(env, (symbol_short!("frc_rs"), market_id.clone()), symbol_short!("frc_rs").clone(), event); } /// Emit fee config queued event when a time-locked config update is proposed. @@ -5053,8 +5052,7 @@ impl EventEmitter { fees_enabled: config.fees_enabled, timestamp: env.ledger().timestamp(), }; - env.events() - .publish((symbol_short!("fee_qd"), admin.clone()), event); + Self::publish_with_nonce(env, (symbol_short!("fee_qd"), admin.clone()), symbol_short!("fee_qd").clone(), event); } /// Emit fee config applied event when a queued update becomes effective. @@ -5069,8 +5067,7 @@ impl EventEmitter { fees_enabled: config.fees_enabled, timestamp: env.ledger().timestamp(), }; - env.events() - .publish((symbol_short!("fee_apd"), admin.clone()), event); + Self::publish_with_nonce(env, (symbol_short!("fee_apd"), admin.clone()), symbol_short!("fee_apd").clone(), event); } /// Emit fee config cancelled event when a queued update is cancelled. @@ -5079,8 +5076,7 @@ impl EventEmitter { admin: admin.clone(), timestamp: env.ledger().timestamp(), }; - env.events() - .publish((symbol_short!("fee_ccl"), admin.clone()), event); + Self::publish_with_nonce(env, (symbol_short!("fee_ccl"), admin.clone()), symbol_short!("fee_ccl").clone(), event); } /// Emit cumulative dispute stake cap exceeded event. @@ -5099,8 +5095,7 @@ impl EventEmitter { timestamp: env.ledger().timestamp(), }; Self::store_event(env, &symbol_short!("cum_cap"), &event); - env.events() - .publish((symbol_short!("cum_cap"), user.clone()), event); + Self::publish_with_nonce(env, (symbol_short!("cum_cap"), user.clone()), symbol_short!("cum_cap").clone(), event); } /// Emit cumulative dispute stake cap set event. @@ -5111,8 +5106,7 @@ impl EventEmitter { timestamp: env.ledger().timestamp(), }; Self::store_event(env, &symbol_short!("cum_set"), &event); - env.events() - .publish((symbol_short!("cum_set"), user.clone()), event); + Self::publish_with_nonce(env, (symbol_short!("cum_set"), user.clone()), symbol_short!("cum_set").clone(), event); } } diff --git a/contracts/predictify-hybrid/src/storage.rs b/contracts/predictify-hybrid/src/storage.rs index 589cb3c1..157ba30e 100644 --- a/contracts/predictify-hybrid/src/storage.rs +++ b/contracts/predictify-hybrid/src/storage.rs @@ -87,6 +87,10 @@ pub enum DataKey { MarketCache(Symbol), /// Nonce for admin override replay protection. AdminOverrideNonce(Address), + /// Configuration for dispute collusion detector + CollusionDetectorConfig, + /// Nonce for event replay protection + EventNonce(Symbol), } /// 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/event_replay_nonce_tests.rs b/contracts/predictify-hybrid/src/tests/event_replay_nonce_tests.rs new file mode 100644 index 00000000..ad2a06d6 --- /dev/null +++ b/contracts/predictify-hybrid/src/tests/event_replay_nonce_tests.rs @@ -0,0 +1,57 @@ +#![cfg(test)] +extern crate std; + +use alloc::vec; +use soroban_sdk::{ + testutils::Address as _, + Address, Env, String, Symbol, vec as svec, IntoVal, +}; +use crate::events::{EventEmitter, EventPayload, MarketCreatedEvent}; + +#[test] +fn test_event_replay_nonce() { + let env = Env::default(); + let admin = Address::generate(&env); + let market_id = Symbol::new(&env, "BTC_50K"); + + let contract_id = env.register(crate::PredictifyHybrid, ()); + + env.as_contract(&contract_id, || { + let question = String::from_str(&env, "Q1"); + let outcomes = svec![&env, String::from_str(&env, "O1"), String::from_str(&env, "O2")]; + + // Emit first event on topic mkt_crt + EventEmitter::emit_market_created(&env, &market_id, &question, &outcomes, &admin, 1000); + + // Emit second event on the same topic mkt_crt + EventEmitter::emit_market_created(&env, &market_id, &question, &outcomes, &admin, 1000); + + // Emit event on a different topic res_tmo + EventEmitter::emit_resolution_timeout(&env, &market_id, 2000); + }); + + let events = env.events().all(); + let mut mkt_crt_nonces = std::vec::Vec::new(); + let mut res_tmo_nonces = std::vec::Vec::new(); + + 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 { + let primary_topic = topic_vec.get(0).unwrap(); + if primary_topic == Symbol::new(&env, "mkt_crt") { + let payload: EventPayload = event_val.try_into_val(&env).unwrap(); + mkt_crt_nonces.push(payload.nonce); + } else if primary_topic == Symbol::new(&env, "res_tmo") { + let payload: EventPayload = event_val.try_into_val(&env).unwrap(); + res_tmo_nonces.push(payload.nonce); + } + } + } + } + + // Two consecutive emissions on same topic increment monotonically + assert_eq!(mkt_crt_nonces, std::vec![0, 1], "Nonces for the same topic should increment monotonically"); + + // Topic isolation: nonce of topic A does not affect topic B + assert_eq!(res_tmo_nonces, std::vec![0], "Different topics should have isolated nonces"); +} diff --git a/contracts/predictify-hybrid/src/tests/mod.rs b/contracts/predictify-hybrid/src/tests/mod.rs index b5051898..a7632135 100644 --- a/contracts/predictify-hybrid/src/tests/mod.rs +++ b/contracts/predictify-hybrid/src/tests/mod.rs @@ -27,4 +27,7 @@ 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; +pub mod event_replay_nonce_tests; \ No newline at end of file