diff --git a/contracts/predictify-hybrid/src/admin.rs b/contracts/predictify-hybrid/src/admin.rs index 92118d0f..584f2353 100644 --- a/contracts/predictify-hybrid/src/admin.rs +++ b/contracts/predictify-hybrid/src/admin.rs @@ -11,6 +11,7 @@ use crate::fees::{FeeConfig, FeeManager}; use crate::markets::MarketStateManager; use crate::resolution::MarketResolutionManager; use alloc::string::ToString; +use crate::audit_trail::{AuditAction, AuditTrailManager}; /// Admin management system for Predictify Hybrid contract /// @@ -245,6 +246,8 @@ impl AdminInitializer { // Log admin action AdminActionLogger::log_action(env, admin, "initialize", None, Map::new(env), true, None)?; + AuditTrailManager::append_record(env, AuditAction::ContractInitialized, admin.clone(), Map::new(env)); + Ok(()) } @@ -621,6 +624,7 @@ impl ContractPauseManager { .persistent() .set(&Symbol::new(env, CONTRACT_PAUSED_KEY), &true); EventEmitter::emit_contract_paused(env, admin); + AuditTrailManager::append_record(env, AuditAction::ContractPaused, admin.clone(), Map::new(env)); Ok(()) } @@ -639,6 +643,7 @@ impl ContractPauseManager { .persistent() .set(&Symbol::new(env, CONTRACT_PAUSED_KEY), &false); EventEmitter::emit_contract_unpaused(env, admin); + AuditTrailManager::append_record(env, AuditAction::ContractUnpaused, admin.clone(), Map::new(env)); Ok(()) } @@ -670,6 +675,7 @@ impl ContractPauseManager { .persistent() .set(&Symbol::new(env, "Admin"), new_admin); EventEmitter::emit_admin_transferred(env, current_admin, new_admin); + AuditTrailManager::append_record(env, AuditAction::AdminTransferred, current_admin.clone(), Map::new(env)); Ok(()) } } @@ -987,6 +993,13 @@ impl AdminRoleManager { }; EventEmitter::emit_admin_role_assigned(env, admin, &events_role, assigned_by); + let action = if role == AdminRole::SuperAdmin && !env.storage().persistent().has(&key) { + AuditAction::ContractInitialized // Fallback or logic + } else { + AuditAction::AdminAdded + }; + AuditTrailManager::append_record(env, AuditAction::AdminRoleUpdated, assigned_by.clone(), Map::new(env)); + Ok(()) } diff --git a/contracts/predictify-hybrid/src/audit_trail.rs b/contracts/predictify-hybrid/src/audit_trail.rs new file mode 100644 index 00000000..43c3e2c3 --- /dev/null +++ b/contracts/predictify-hybrid/src/audit_trail.rs @@ -0,0 +1,192 @@ +use soroban_sdk::{contracttype, Address, BytesN, Env, Map, Symbol, String, Vec}; + +/// Represents the type of sensitive action recorded in the audit trail. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum AuditAction { + // Admin Actions + ContractInitialized, + AdminAdded, + AdminRemoved, + AdminRoleUpdated, + ContractPaused, + ContractUnpaused, + AdminTransferred, + + // Market/Event Actions + MarketCreated, + EventCreated, + EventDescriptionUpdated, + EventOutcomesUpdated, + EventCategoryUpdated, + EventTagsUpdated, + EventCancelled, + MarketUpdated, + + // Fee Actions + FeesCollected, + FeesWithdrawn, + FeeConfigUpdated, + + // Oracle & Config Actions + OracleConfigUpdated, + BetLimitsUpdated, + + // Resolution & Disputes + MarketResolved, + DisputeCreated, + DisputeResolved, + + // Storage & System + StorageOptimized, + StorageMigrated, + ContractUpgraded, + UpgradeRolledBack, + + // Recovery + ErrorRecovered, + PartialRefundExecuted, +} + +/// A single record in the immutable, tamper-evident audit trail. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct AuditRecord { + pub index: u64, + pub action: AuditAction, + pub actor: Address, + pub timestamp: u64, + pub details: Map, + pub prev_record_hash: BytesN<32>, +} + +/// Head of the audit trail, tracking the latest state. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct AuditTrailHead { + pub latest_index: u64, + pub latest_hash: BytesN<32>, +} + +pub struct AuditTrailManager; + +impl AuditTrailManager { + /// Storage key for the audit trail head + fn head_key(env: &Env) -> Symbol { + Symbol::new(env, "AUDIT_HEAD") + } + + /// Appends a new record to the audit trail. + pub fn append_record( + env: &Env, + action: AuditAction, + actor: Address, + details: Map, + ) -> 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 = AuditRecord { + index: new_index, + action, + actor, + timestamp: env.ledger().timestamp(), + details, + prev_record_hash: head.latest_hash.clone(), + }; + + // Use a tuple key for distinct storage namespace (Symbol, index) + let record_key = (Symbol::new(env, "AUDIT_REC"), new_index); + env.storage().persistent().set(&record_key, &record); + + // Instead of xdr, let's just use the Soroban bytes macro or hash a simple representation + // Since we want tamper evidence of the payload, we use ToXdr implemented by the SDK. + use soroban_sdk::xdr::ToXdr; + let record_bytes = record.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) + } + + /// Retrieves the latest records from the audit trail. + 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); + } + + let head = head_opt.unwrap(); + let mut records = Vec::new(env); + let mut current_index = head.latest_index; + let mut count = 0; + + while current_index > 0 && count < limit { + if let Some(record) = Self::get_record(env, current_index) { + records.push_back(record); + } + current_index -= 1; + count += 1; + } + + records + } + + /// Retrieves the head of the audit trail. + pub fn get_head(env: &Env) -> Option { + env.storage().persistent().get(&Self::head_key(env)) + } + + /// Verifies the integrity of the trail from the current head back to a certain depth. + pub fn verify_integrity(env: &Env, depth: u64) -> bool { + let head_opt: Option = env.storage().persistent().get(&Self::head_key(env)); + if head_opt.is_none() { + return true; + } + + let head = head_opt.unwrap(); + let mut current_index = head.latest_index; + let mut expected_hash = head.latest_hash; + let mut checked = 0; + + use soroban_sdk::xdr::ToXdr; + + while current_index > 0 && checked < depth { + let record_opt = Self::get_record(env, current_index); + if record_opt.is_none() { + return false; + } + + let record = record_opt.unwrap(); + let record_bytes = 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; + current_index -= 1; + checked += 1; + } + + true + } +} diff --git a/contracts/predictify-hybrid/src/disputes.rs b/contracts/predictify-hybrid/src/disputes.rs index 16f79f69..c8fdc8fe 100644 --- a/contracts/predictify-hybrid/src/disputes.rs +++ b/contracts/predictify-hybrid/src/disputes.rs @@ -855,6 +855,13 @@ impl DisputeManager { reason_for_event, ); + crate::audit_trail::AuditTrailManager::append_record( + env, + crate::audit_trail::AuditAction::DisputeCreated, + user.clone(), + Map::new(env) + ); + Ok(()) } @@ -976,6 +983,13 @@ impl DisputeManager { DisputeUtils::finalize_market_with_resolution(&mut market, final_outcome)?; MarketStateManager::update_market(env, &market_id, &market); + crate::audit_trail::AuditTrailManager::append_record( + env, + crate::audit_trail::AuditAction::DisputeResolved, + admin.clone(), + Map::new(env) + ); + Ok(resolution) } diff --git a/contracts/predictify-hybrid/src/fees.rs b/contracts/predictify-hybrid/src/fees.rs index 48660da2..81d54fe2 100644 --- a/contracts/predictify-hybrid/src/fees.rs +++ b/contracts/predictify-hybrid/src/fees.rs @@ -757,6 +757,13 @@ impl FeeManager { &soroban_sdk::String::from_str(env, "platform_fee"), ); + crate::audit_trail::AuditTrailManager::append_record( + env, + crate::audit_trail::AuditAction::FeesCollected, + admin.clone(), + Map::new(env) + ); + Ok(fee_amount) } @@ -816,6 +823,13 @@ impl FeeManager { // Record configuration change FeeTracker::record_config_change(env, &admin, &new_config)?; + crate::audit_trail::AuditTrailManager::append_record( + env, + crate::audit_trail::AuditAction::FeeConfigUpdated, + admin.clone(), + Map::new(env) + ); + Ok(new_config) } @@ -859,6 +873,13 @@ impl FeeManager { // Record fee structure update FeeTracker::record_fee_structure_update(env, &admin, &new_fee_tiers)?; + crate::audit_trail::AuditTrailManager::append_record( + env, + crate::audit_trail::AuditAction::FeeConfigUpdated, + admin.clone(), + Map::new(env) + ); + Ok(()) } @@ -1661,6 +1682,13 @@ impl FeeWithdrawalManager { now, ); + crate::audit_trail::AuditTrailManager::append_record( + env, + crate::audit_trail::AuditAction::FeesWithdrawn, + admin.clone(), + Map::new(env) + ); + Ok(withdrawal_amount) } } diff --git a/contracts/predictify-hybrid/src/lib.rs b/contracts/predictify-hybrid/src/lib.rs index 13d00b1a..fac7516d 100644 --- a/contracts/predictify-hybrid/src/lib.rs +++ b/contracts/predictify-hybrid/src/lib.rs @@ -48,6 +48,10 @@ mod validation; mod validation_tests; mod versioning; mod voting; +pub mod audit_trail; + +#[cfg(test)] +mod test_audit_trail; // THis is the band protocol wasm std_reference.wasm mod bandprotocol { soroban_sdk::contractimport!(file = "./std_reference.wasm"); @@ -102,6 +106,7 @@ pub mod errors { pub use crate::err::*; } pub use queries::QueryManager; +pub use audit_trail::{AuditAction, AuditRecord, AuditTrailHead, AuditTrailManager}; pub use types::*; use crate::config::{ @@ -249,6 +254,26 @@ impl PredictifyHybrid { storage::BalanceStorage::get_balance(&env, &user, &asset) } + /// Retrieves a specific audit record by index. + pub fn get_audit_record(env: Env, index: u64) -> Option { + AuditTrailManager::get_record(&env, index) + } + + /// Retrieves the latest audit records (up to limit). + pub fn get_latest_audit_records(env: Env, limit: u64) -> Vec { + AuditTrailManager::get_latest_records(&env, limit) + } + + /// Retrieves the current head of the audit trail. + pub fn get_audit_trail_head(env: Env) -> Option { + AuditTrailManager::get_head(&env) + } + + /// Verifies the integrity of the audit trail up to a certain depth. + pub fn verify_audit_integrity(env: Env, depth: u64) -> bool { + AuditTrailManager::verify_integrity(&env, depth) + } + /// Creates a new prediction market with specified parameters and oracle configuration. /// /// This function allows authorized administrators to create prediction markets @@ -437,6 +462,8 @@ impl PredictifyHybrid { // Record statistics statistics::StatisticsManager::record_market_created(&env); + crate::audit_trail::AuditTrailManager::append_record(&env, crate::audit_trail::AuditAction::MarketCreated, admin.clone(), Map::new(&env)); + market_id } @@ -541,6 +568,8 @@ impl PredictifyHybrid { // Record statistics (optional, can reuse market stats for now) // statistics::StatisticsManager::record_market_created(&env); + crate::audit_trail::AuditTrailManager::append_record(&env, crate::audit_trail::AuditAction::EventCreated, admin.clone(), Map::new(&env)); + event_id } @@ -2695,6 +2724,13 @@ impl PredictifyHybrid { let fee_key = Symbol::new(&env, "platform_fee"); env.storage().persistent().set(&fee_key, &fee_percentage); + crate::audit_trail::AuditTrailManager::append_record( + &env, + crate::audit_trail::AuditAction::FeeConfigUpdated, + admin.clone(), + Map::new(&env), + ); + Ok(()) } @@ -2716,10 +2752,18 @@ impl PredictifyHybrid { if admin != stored_admin { return Err(Error::Unauthorized); } - let limits = BetLimits { min_bet, max_bet }; + let limits = crate::types::BetLimits { min_bet, max_bet }; crate::bets::set_global_bet_limits(&env, &limits)?; let scope = Symbol::new(&env, "global"); EventEmitter::emit_bet_limits_updated(&env, &admin, &scope, min_bet, max_bet); + + crate::audit_trail::AuditTrailManager::append_record( + &env, + crate::audit_trail::AuditAction::BetLimitsUpdated, + admin.clone(), + Map::new(&env), + ); + Ok(()) } @@ -2778,6 +2822,14 @@ impl PredictifyHybrid { max_confidence_bps, }; crate::oracles::OracleValidationConfigManager::set_global_config(&env, &config)?; + + crate::audit_trail::AuditTrailManager::append_record( + &env, + crate::audit_trail::AuditAction::OracleConfigUpdated, + admin.clone(), + Map::new(&env), + ); + Ok(()) } @@ -2810,6 +2862,17 @@ impl PredictifyHybrid { &market_id, &config, )?; + + let mut details = Map::new(&env); + details.set(Symbol::new(&env, "market_id"), String::from_str(&env, "market_updated")); + + crate::audit_trail::AuditTrailManager::append_record( + &env, + crate::audit_trail::AuditAction::OracleConfigUpdated, + admin.clone(), + details, + ); + Ok(()) } @@ -3183,6 +3246,10 @@ impl PredictifyHybrid { &admin, ); + let mut details = Map::new(&env); + details.set(Symbol::new(&env, "update"), String::from_str(&env, "description")); + crate::audit_trail::AuditTrailManager::append_record(&env, crate::audit_trail::AuditAction::MarketUpdated, admin.clone(), details); + Ok(()) } @@ -3327,6 +3394,10 @@ impl PredictifyHybrid { &admin, ); + let mut details = Map::new(&env); + details.set(Symbol::new(&env, "update"), String::from_str(&env, "outcomes")); + crate::audit_trail::AuditTrailManager::append_record(&env, crate::audit_trail::AuditAction::MarketUpdated, admin.clone(), details); + Ok(()) } @@ -3431,6 +3502,10 @@ impl PredictifyHybrid { // Emit category update event EventEmitter::emit_category_updated(&env, &market_id, &old_category, &category, &admin); + let mut details = Map::new(&env); + details.set(Symbol::new(&env, "update"), String::from_str(&env, "category")); + crate::audit_trail::AuditTrailManager::append_record(&env, crate::audit_trail::AuditAction::MarketUpdated, admin.clone(), details); + Ok(()) } @@ -3550,6 +3625,10 @@ impl PredictifyHybrid { // Emit tags update event EventEmitter::emit_tags_updated(&env, &market_id, &old_tags, &tags, &admin); + let mut details = Map::new(&env); + details.set(Symbol::new(&env, "update"), String::from_str(&env, "tags")); + crate::audit_trail::AuditTrailManager::append_record(&env, crate::audit_trail::AuditAction::MarketUpdated, admin.clone(), details); + Ok(()) } @@ -3696,6 +3775,12 @@ impl PredictifyHybrid { // Calculate total refunded (sum of all bets) let total_refunded = market.total_staked; + let mut details = Map::new(&env); + if let Some(r) = &reason { + details.set(Symbol::new(&env, "reason"), r.clone()); + } + crate::audit_trail::AuditTrailManager::append_record(&env, crate::audit_trail::AuditAction::EventCancelled, admin.clone(), details); + // Emit cancellation event EventEmitter::emit_state_change_event( &env, @@ -3832,7 +3917,16 @@ impl PredictifyHybrid { from_format: storage::StorageFormat, to_format: storage::StorageFormat, ) -> Result { - storage::StorageOptimizer::migrate_storage_format(&env, from_format, to_format) + let result = storage::StorageOptimizer::migrate_storage_format(&env, from_format, to_format); + + crate::audit_trail::AuditTrailManager::append_record( + &env, + crate::audit_trail::AuditAction::StorageMigrated, + env.current_contract_address(), + Map::new(&env), + ); + + result } /// Monitor storage usage and return statistics @@ -3994,10 +4088,19 @@ impl PredictifyHybrid { if let Err(e) = crate::recovery::RecoveryManager::assert_is_admin(&env, &admin) { panic_with_error!(env, e); } - match crate::recovery::RecoveryManager::recover_market_state(&env, &market_id) { + let result = match crate::recovery::RecoveryManager::recover_market_state(&env, &market_id) { Ok(res) => res, Err(e) => panic_with_error!(env, e), - } + }; + + crate::audit_trail::AuditTrailManager::append_record( + &env, + crate::audit_trail::AuditAction::ErrorRecovered, + admin.clone(), + Map::new(&env), + ); + + result } /// Executes partial refund mechanism for selected users in a failed/corrupted market. Only admin. @@ -4011,10 +4114,19 @@ impl PredictifyHybrid { if let Err(e) = crate::recovery::RecoveryManager::assert_is_admin(&env, &admin) { panic_with_error!(env, e); } - match crate::recovery::RecoveryManager::partial_refund_mechanism(&env, &market_id, &users) { + let result = match crate::recovery::RecoveryManager::partial_refund_mechanism(&env, &market_id, &users) { Ok(total_refunded) => total_refunded, Err(e) => panic_with_error!(env, e), - } + }; + + crate::audit_trail::AuditTrailManager::append_record( + &env, + crate::audit_trail::AuditAction::PartialRefundExecuted, + admin.clone(), + Map::new(&env), + ); + + result } /// Validates market state integrity; returns true if consistent. @@ -4335,7 +4447,16 @@ impl PredictifyHybrid { new_wasm_hash: soroban_sdk::BytesN<32>, ) -> Result<(), Error> { admin.require_auth(); - upgrade_manager::UpgradeManager::upgrade_contract(&env, &admin, new_wasm_hash) + let result = upgrade_manager::UpgradeManager::upgrade_contract(&env, &admin, new_wasm_hash); + + crate::audit_trail::AuditTrailManager::append_record( + &env, + crate::audit_trail::AuditAction::ContractUpgraded, + admin.clone(), + Map::new(&env), + ); + + result } /// Rollback contract to previous version @@ -4359,7 +4480,16 @@ impl PredictifyHybrid { rollback_wasm_hash: soroban_sdk::BytesN<32>, ) -> Result<(), Error> { admin.require_auth(); - upgrade_manager::UpgradeManager::rollback_upgrade(&env, &admin, rollback_wasm_hash) + let result = upgrade_manager::UpgradeManager::rollback_upgrade(&env, &admin, rollback_wasm_hash); + + crate::audit_trail::AuditTrailManager::append_record( + &env, + crate::audit_trail::AuditAction::UpgradeRolledBack, + admin.clone(), + Map::new(&env), + ); + + result } /// Get current contract version diff --git a/contracts/predictify-hybrid/src/test_audit_trail.rs b/contracts/predictify-hybrid/src/test_audit_trail.rs new file mode 100644 index 00000000..b7a06ada --- /dev/null +++ b/contracts/predictify-hybrid/src/test_audit_trail.rs @@ -0,0 +1,145 @@ +#![cfg(test)] + +use soroban_sdk::{testutils::Address as _, Address, Env, Map, Symbol, String, BytesN}; +use crate::audit_trail::{AuditAction, AuditRecord, AuditTrailHead, AuditTrailManager}; +use crate::PredictifyHybrid; +use crate::PredictifyHybridClient; + +fn create_env() -> Env { + let env = Env::default(); + env.mock_all_auths(); + env +} + +#[test] +fn test_append_and_get_record() { + let env = create_env(); + let contract_id = env.register(PredictifyHybrid {}, ()); + + let actor = Address::generate(&env); + + env.as_contract(&contract_id, || { + let mut details = Map::new(&env); + details.set(Symbol::new(&env, "key1"), String::from_str(&env, "value1")); + + let index1 = AuditTrailManager::append_record( + &env, + AuditAction::ContractInitialized, + actor.clone(), + details.clone() + ); + assert_eq!(index1, 1); + + let record1 = AuditTrailManager::get_record(&env, 1).unwrap(); + assert_eq!(record1.index, 1); + assert_eq!(record1.action, AuditAction::ContractInitialized); + assert_eq!(record1.actor, actor.clone()); + assert_eq!(record1.details, details); + assert_eq!(record1.prev_record_hash, BytesN::from_array(&env, &[0u8; 32])); + + // Append second record + let index2 = AuditTrailManager::append_record( + &env, + AuditAction::MarketCreated, + actor.clone(), + Map::new(&env) + ); + assert_eq!(index2, 2); + + let record2 = AuditTrailManager::get_record(&env, 2).unwrap(); + assert_eq!(record2.index, 2); + assert_eq!(record2.action, AuditAction::MarketCreated); + + // Check hash links + let head = AuditTrailManager::get_head(&env).unwrap(); + assert_eq!(head.latest_index, 2); + + use soroban_sdk::xdr::ToXdr; + let record1_bytes = record1.clone().to_xdr(&env); + let expected_hash1: BytesN<32> = env.crypto().sha256(&record1_bytes).into(); + assert_eq!(record2.prev_record_hash, expected_hash1); + }); +} + +#[test] +fn test_verify_integrity() { + let env = create_env(); + let contract_id = env.register(PredictifyHybrid {}, ()); + let actor = Address::generate(&env); + + env.as_contract(&contract_id, || { + // Initial verify should be true (empty trail) + assert!(AuditTrailManager::verify_integrity(&env, 10)); + + for _ in 0..5 { + AuditTrailManager::append_record( + &env, + AuditAction::ContractPaused, + actor.clone(), + Map::new(&env) + ); + } + + assert!(AuditTrailManager::verify_integrity(&env, 5)); + assert!(AuditTrailManager::verify_integrity(&env, 10)); + }); +} + +#[test] +fn test_verify_integrity_tampering() { + let env = create_env(); + let contract_id = env.register(PredictifyHybrid {}, ()); + let actor = Address::generate(&env); + + env.as_contract(&contract_id, || { + AuditTrailManager::append_record( + &env, + AuditAction::ContractPaused, + actor.clone(), + Map::new(&env) + ); + AuditTrailManager::append_record( + &env, + AuditAction::ContractUnpaused, + actor.clone(), + Map::new(&env) + ); + + // Tamper with record 1 + let mut record1 = AuditTrailManager::get_record(&env, 1).unwrap(); + record1.action = AuditAction::AdminAdded; // Mutate action + env.storage().persistent().set(&(Symbol::new(&env, "AUDIT_REC"), 1u64), &record1); + + // Verification should fail because hash of tampered record1 won't match record2.prev_record_hash + assert!(!AuditTrailManager::verify_integrity(&env, 2)); + }); +} + +#[test] +fn test_public_queries() { + let env = create_env(); + let contract_id = env.register(PredictifyHybrid {}, ()); + let client = PredictifyHybridClient::new(&env, &contract_id); + let actor = Address::generate(&env); + + env.as_contract(&contract_id, || { + for _ in 1..=3 { + AuditTrailManager::append_record( + &env, + AuditAction::AdminRoleUpdated, + actor.clone(), + Map::new(&env) + ); + } + }); + + let record1 = client.get_audit_record(&1).unwrap(); + assert_eq!(record1.index, 1); + + let latest = client.get_latest_audit_records(&2); + assert_eq!(latest.len(), 2); + assert_eq!(latest.get(0).unwrap().index, 3); + assert_eq!(latest.get(1).unwrap().index, 2); + + assert!(client.verify_audit_integrity(&5)); +}