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