diff --git a/contracts/predictify-hybrid/src/audit_trail.rs b/contracts/predictify-hybrid/src/audit_trail.rs index 50b52a35..657cbe5a 100644 --- a/contracts/predictify-hybrid/src/audit_trail.rs +++ b/contracts/predictify-hybrid/src/audit_trail.rs @@ -64,6 +64,50 @@ pub struct AuditRecord { pub override_nonce: Option, } +/// Head of the audit trail, tracking the latest state. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct AuditEntryV2 { + pub action: Symbol, + pub reason_idx: u8, + pub actor: Address, + pub ts: u64, + pub ref_id: BytesN<32>, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum AuditRecordVersioned { + V1(AuditRecord), + V2(AuditEntryV2), +} + +pub struct AuditReasonTable; + +impl AuditReasonTable { + pub fn get_reasons(env: &Env) -> Vec { + env.storage() + .persistent() + .get(&crate::storage::DataKey::AuditReasonTable) + .unwrap_or_else(|| Vec::new(env)) + } + + pub fn add_reason(env: &Env, admin: &Address, reason: String) -> u8 { + admin.require_auth(); + crate::admin::AdminManager::require_admin(env, admin); + let mut reasons = Self::get_reasons(env); + let idx = reasons.len() as u8; + reasons.push_back(reason); + env.storage() + .persistent() + .set(&crate::storage::DataKey::AuditReasonTable, &reasons); + idx + } +} + /// Head of the audit trail, tracking the latest state. #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] @@ -127,13 +171,65 @@ impl AuditTrailManager { } /// Retrieves a specific audit record by index. - pub fn get_record(env: &Env, index: u64) -> Option { + + pub fn append_record_v2( + env: &Env, + action: Symbol, + actor: Address, + reason_idx: u8, + ) -> u64 { + let mut head: AuditTrailHead = env + .storage() + .persistent() + .get(&Self::head_key(env)) + .unwrap_or(AuditTrailHead { + latest_index: 0, + latest_hash: BytesN::from_array(env, &[0u8; 32]), + }); + + let new_index = head.latest_index + 1; + + let record = AuditEntryV2 { + action, + reason_idx, + actor, + ts: env.ledger().timestamp(), + ref_id: head.latest_hash.clone(), + }; + + let versioned = AuditRecordVersioned::V2(record); + let record_key = (Symbol::new(env, "AUDIT_REC"), new_index); + env.storage().persistent().set(&record_key, &versioned); + + use soroban_sdk::xdr::ToXdr; + let record_bytes = versioned.clone().to_xdr(env); + let new_hash: BytesN<32> = env.crypto().sha256(&record_bytes).into(); + + head.latest_index = new_index; + head.latest_hash = new_hash; + env.storage().persistent().set(&Self::head_key(env), &head); + + new_index + } + + /// Retrieves a specific audit record by index. + pub fn get_record(env: &Env, index: u64) -> Option { let record_key = (Symbol::new(env, "AUDIT_REC"), index); - env.storage().persistent().get(&record_key) + let val_opt: Option = env.storage().persistent().get(&record_key); + if let Some(val) = val_opt { + use soroban_sdk::TryFromVal; + if let Ok(versioned) = AuditRecordVersioned::try_from_val(env, &val) { + return Some(versioned); + } + if let Ok(v1) = AuditRecord::try_from_val(env, &val) { + return Some(AuditRecordVersioned::V1(v1)); + } + } + None } /// Retrieves the latest records from the audit trail. - pub fn get_latest_records(env: &Env, limit: u64) -> Vec { + pub fn get_latest_records(env: &Env, limit: u64) -> Vec { let head_opt = Self::get_head(env); if head_opt.is_none() { return Vec::new(env); @@ -180,15 +276,21 @@ impl AuditTrailManager { return false; } - let record = record_opt.unwrap(); - let record_bytes = record.clone().to_xdr(env); + let versioned_record = record_opt.unwrap(); + let record_bytes = match &versioned_record { + AuditRecordVersioned::V1(v1) => v1.clone().to_xdr(env), + AuditRecordVersioned::V2(_) => versioned_record.clone().to_xdr(env), + }; let actual_hash: BytesN<32> = env.crypto().sha256(&record_bytes).into(); if actual_hash != expected_hash { return false; } - expected_hash = record.prev_record_hash; + expected_hash = match &versioned_record { + AuditRecordVersioned::V1(v1) => v1.prev_record_hash.clone(), + AuditRecordVersioned::V2(v2) => v2.ref_id.clone(), + }; current_index -= 1; checked += 1; } 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/override_audit_tests.rs b/contracts/predictify-hybrid/src/override_audit_tests.rs index f681860a..e042c535 100644 --- a/contracts/predictify-hybrid/src/override_audit_tests.rs +++ b/contracts/predictify-hybrid/src/override_audit_tests.rs @@ -1,6 +1,6 @@ #![cfg(test)] -use crate::audit_trail::{AuditAction, AuditTrailManager}; +use crate::audit_trail::{AuditAction, AuditTrailManager, AuditRecordVersioned}; use crate::err::Error; use crate::types::{MarketState, OracleConfig, OracleProvider}; use crate::{PredictifyHybrid, PredictifyHybridClient}; @@ -95,7 +95,7 @@ fn test_override_appends_audit_record() { let head = AuditTrailManager::get_head(&ctx.env).unwrap(); assert!(head.latest_index >= 1); - let record = AuditTrailManager::get_record(&ctx.env, head.latest_index).unwrap(); + let AuditRecordVersioned::V1(record) = AuditTrailManager::get_record(&ctx.env, head.latest_index).unwrap() else { panic!() }; assert_eq!(record.action, AuditAction::OracleVerificationOverride); assert_eq!(record.actor, ctx.admin); @@ -377,7 +377,7 @@ fn test_override_nonce_persisted_in_audit() { ctx.env.as_contract(&ctx.contract_id, || { let head = AuditTrailManager::get_head(&ctx.env).unwrap(); - let record = AuditTrailManager::get_record(&ctx.env, head.latest_index).unwrap(); + let AuditRecordVersioned::V1(record) = AuditTrailManager::get_record(&ctx.env, head.latest_index).unwrap() else { panic!() }; assert_eq!(record.override_nonce, Some(42u64)); }); } diff --git a/contracts/predictify-hybrid/src/storage.rs b/contracts/predictify-hybrid/src/storage.rs index 589cb3c1..4a69fdda 100644 --- a/contracts/predictify-hybrid/src/storage.rs +++ b/contracts/predictify-hybrid/src/storage.rs @@ -71,7 +71,6 @@ enum StorageTtlTier { pub enum DataKey { Whitelisted(Address), Blacklisted(Address), - AdminOverrideNonce(Address), ArchivedMarket(Symbol, u64), /// Cumulative days extended for a given market (u32). MarketExtensionTotal(Symbol), @@ -87,6 +86,12 @@ 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), + /// Audit reason table for compact encoding + AuditReasonTable, } /// Storage format version for migration tracking diff --git a/contracts/predictify-hybrid/src/test_audit_trail.rs b/contracts/predictify-hybrid/src/test_audit_trail.rs index 3e9e4500..f7116347 100644 --- a/contracts/predictify-hybrid/src/test_audit_trail.rs +++ b/contracts/predictify-hybrid/src/test_audit_trail.rs @@ -30,7 +30,7 @@ fn test_append_and_get_record() { ); assert_eq!(index1, 1); - let record1 = AuditTrailManager::get_record(&env, 1).unwrap(); + let AuditRecordVersioned::V1(record1) = AuditTrailManager::get_record(&env, 1).unwrap() else { panic!() }; assert_eq!(record1.index, 1); assert_eq!(record1.action, AuditAction::ContractInitialized); assert_eq!(record1.actor, actor.clone()); @@ -50,7 +50,7 @@ fn test_append_and_get_record() { ); assert_eq!(index2, 2); - let record2 = AuditTrailManager::get_record(&env, 2).unwrap(); + let AuditRecordVersioned::V1(record2) = AuditTrailManager::get_record(&env, 2).unwrap() else { panic!() }; assert_eq!(record2.index, 2); assert_eq!(record2.action, AuditAction::MarketCreated); @@ -113,7 +113,7 @@ fn test_verify_integrity_tampering() { ); // Tamper with record 1 - let mut record1 = AuditTrailManager::get_record(&env, 1).unwrap(); + let AuditRecordVersioned::V1(mut record1) = AuditTrailManager::get_record(&env, 1).unwrap() else { panic!() }; record1.action = AuditAction::AdminAdded; // Mutate action env.storage() .persistent() diff --git a/contracts/predictify-hybrid/src/tests/audit_compact_encoding_tests.rs b/contracts/predictify-hybrid/src/tests/audit_compact_encoding_tests.rs new file mode 100644 index 00000000..3aa77990 --- /dev/null +++ b/contracts/predictify-hybrid/src/tests/audit_compact_encoding_tests.rs @@ -0,0 +1,92 @@ +#![cfg(test)] +extern crate std; + +use crate::audit_trail::{AuditAction, AuditEntryV2, AuditReasonTable, AuditRecordVersioned, AuditTrailManager}; +use soroban_sdk::{testutils::Address as _, Address, Env, Symbol, String}; + +#[test] +fn test_audit_compact_encoding() { + let env = Env::default(); + let admin = Address::generate(&env); + + // Setup contract + let contract_id = env.register(crate::PredictifyHybrid, ()); + + env.as_contract(&contract_id, || { + // Initialize admin so require_admin passes + crate::admin::AdminManager::initialize(&env, &admin, &None, &None); + + let reason_str = String::from_str(&env, "Test Compact Reason"); + let reason_idx = AuditReasonTable::add_reason(&env, &admin, reason_str.clone()); + + assert_eq!(reason_idx, 0, "First reason should have index 0"); + + let reasons = AuditReasonTable::get_reasons(&env); + assert_eq!(reasons.len(), 1); + assert_eq!(reasons.get(0).unwrap(), reason_str); + + let action = Symbol::new(&env, "CMPT_ACT"); + + // Append V2 Record + let index_v2 = AuditTrailManager::append_record_v2(&env, action.clone(), admin.clone(), reason_idx); + + // Retrieve and check backward compatibility + let record_opt = AuditTrailManager::get_record(&env, index_v2); + assert!(record_opt.is_some()); + + if let AuditRecordVersioned::V2(v2) = record_opt.unwrap() { + assert_eq!(v2.action, action); + assert_eq!(v2.reason_idx, reason_idx); + assert_eq!(v2.actor, admin.clone()); + } else { + panic!("Expected V2 record"); + } + + // Test Integrity for V2 + assert!(AuditTrailManager::verify_integrity(&env, 1)); + + // Append V1 Record and check backward compatibility + let details = soroban_sdk::Map::new(&env); + let index_v1 = AuditTrailManager::append_record(&env, AuditAction::ContractInitialized, admin.clone(), details, None); + + let v1_opt = AuditTrailManager::get_record(&env, index_v1); + assert!(v1_opt.is_some()); + if let AuditRecordVersioned::V1(v1) = v1_opt.unwrap() { + assert_eq!(v1.action, AuditAction::ContractInitialized); + } else { + panic!("Expected V1 record"); + } + + // Test Integrity for both V1 and V2 in the same chain + assert!(AuditTrailManager::verify_integrity(&env, 2)); + + // Test measurable storage size reduction is done in test_audit_compact_size_reduction + }); +} + +#[test] +fn test_audit_compact_size_reduction() { + let env = Env::default(); + let admin = Address::generate(&env); + let contract_id = env.register(crate::PredictifyHybrid, ()); + + env.as_contract(&contract_id, || { + crate::admin::AdminManager::initialize(&env, &admin, &None, &None); + let reason_idx = AuditReasonTable::add_reason(&env, &admin, String::from_str(&env, "Reason")); + + let mut details = soroban_sdk::Map::new(&env); + details.set(Symbol::new(&env, "reason"), String::from_str(&env, "Reason")); + let index_v1 = AuditTrailManager::append_record(&env, AuditAction::MarketCreated, admin.clone(), details, None); + + let index_v2 = AuditTrailManager::append_record_v2(&env, Symbol::new(&env, "MktCrt"), admin.clone(), reason_idx); + + let v1_record = AuditTrailManager::get_record(&env, index_v1).unwrap(); + let v2_record = AuditTrailManager::get_record(&env, index_v2).unwrap(); + + use soroban_sdk::xdr::ToXdr; + let len_v1 = v1_record.to_xdr(&env).len(); + let len_v2 = v2_record.to_xdr(&env).len(); + + assert!(len_v2 < len_v1, "V2 record must be strictly smaller than V1 record"); + }); +} 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..2004a40c 100644 --- a/contracts/predictify-hybrid/src/tests/mod.rs +++ b/contracts/predictify-hybrid/src/tests/mod.rs @@ -27,4 +27,8 @@ 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; +pub mod audit_compact_encoding_tests; \ No newline at end of file