From 7a08f9e58dc250b252f291537a5fad83bdc8278e Mon Sep 17 00:00:00 2001 From: Michael Victor Date: Thu, 28 May 2026 04:36:20 +0100 Subject: [PATCH] feat: require reason and audit trail for verification override - Reject empty reason in admin_override_verification (Error::InvalidInput) - Append immutable AuditRecord with OracleVerificationOverride action, capturing old_result, new_result, and reason in the details map - Emit AdminOverrideEvent (market_id, admin, old/new result, reason, timestamp) via a dedicated 'adm_ovrd' topic for off-chain monitors - Add AdminOverrideEvent struct and emit_admin_override to events.rs - Add OracleVerificationOverride variant to AuditAction enum - Auth (require_primary_admin) precedes all storage writes - 6 tests in override_audit_tests.rs covering: empty reason rejection, audit record content, chain integrity, market state, non-admin rejection, and no-partial-state guarantee on auth failure --- .../predictify-hybrid/src/audit_trail.rs | 1 + contracts/predictify-hybrid/src/events.rs | 35 +++ contracts/predictify-hybrid/src/lib.rs | 54 +++- .../src/override_audit_tests.rs | 267 ++++++++++++++++++ 4 files changed, 348 insertions(+), 9 deletions(-) create mode 100644 contracts/predictify-hybrid/src/override_audit_tests.rs diff --git a/contracts/predictify-hybrid/src/audit_trail.rs b/contracts/predictify-hybrid/src/audit_trail.rs index 46e7268e..b73fcb40 100644 --- a/contracts/predictify-hybrid/src/audit_trail.rs +++ b/contracts/predictify-hybrid/src/audit_trail.rs @@ -36,6 +36,7 @@ pub enum AuditAction { MarketResolved, DisputeCreated, DisputeResolved, + OracleVerificationOverride, // Storage & System StorageOptimized, diff --git a/contracts/predictify-hybrid/src/events.rs b/contracts/predictify-hybrid/src/events.rs index 0cbf71ac..ee6b1de4 100644 --- a/contracts/predictify-hybrid/src/events.rs +++ b/contracts/predictify-hybrid/src/events.rs @@ -1823,6 +1823,18 @@ pub struct MinPoolSizeNotMetEvent { // ===== EVENT EMISSION UTILITIES ===== +/// Emitted when an admin manually overrides an oracle-verified market result. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct AdminOverrideEvent { + pub market_id: Symbol, + pub admin: Address, + pub old_result: String, + pub new_result: String, + pub reason: String, + pub timestamp: u64, +} + /// Event emission utilities pub struct EventEmitter; @@ -4302,4 +4314,27 @@ impl EventEmitter { (), ); } + + /// Emit admin override event when an admin manually overrides an oracle-verified result. + pub fn emit_admin_override( + env: &Env, + market_id: &Symbol, + admin: &Address, + old_result: &String, + new_result: &String, + reason: &String, + ) { + let event = AdminOverrideEvent { + market_id: market_id.clone(), + admin: admin.clone(), + old_result: old_result.clone(), + new_result: new_result.clone(), + reason: reason.clone(), + timestamp: env.ledger().timestamp(), + }; + + Self::store_event(env, &symbol_short!("adm_ovrd"), &event); + env.events() + .publish((symbol_short!("adm_ovrd"), market_id.clone()), event); + } } diff --git a/contracts/predictify-hybrid/src/lib.rs b/contracts/predictify-hybrid/src/lib.rs index 882f0210..d053d1d5 100644 --- a/contracts/predictify-hybrid/src/lib.rs +++ b/contracts/predictify-hybrid/src/lib.rs @@ -76,6 +76,8 @@ mod voting; #[cfg(any())] mod test_audit_trail; +#[cfg(test)] +mod override_audit_tests; // #[cfg(any())] // mod utils_tests; // THis is the band protocol wasm std_reference.wasm @@ -2841,15 +2843,49 @@ impl PredictifyHybrid { reason: String, ) -> Result<(), Error> { Self::require_primary_admin(&env, &admin)?; - // Temporarily disabled due to oracles module being disabled - // oracles::OracleIntegrationManager::admin_override_result( - // &env, - // &admin, - // &market_id, - // &outcome, - // &reason, - // ) - Err(Error::OracleUnavailable) + + // Reject empty reason — every override must be justified + if reason.is_empty() { + return Err(Error::InvalidInput); + } + + // Load the market + let mut market = markets::MarketStateManager::get_market(&env, &market_id)?; + + // Capture the previous oracle result for the audit record and event + let old_result = market + .oracle_result + .clone() + .unwrap_or_else(|| String::from_str(&env, "none")); + + // Apply the override + market.oracle_result = Some(outcome.clone()); + market.state = crate::types::MarketState::Resolved; + markets::MarketStateManager::update_market(&env, &market_id, &market); + + // Append an immutable audit record + let mut details = Map::new(&env); + details.set(Symbol::new(&env, "old_result"), old_result.clone()); + details.set(Symbol::new(&env, "new_result"), outcome.clone()); + details.set(Symbol::new(&env, "reason"), reason.clone()); + AuditTrailManager::append_record( + &env, + AuditAction::OracleVerificationOverride, + admin.clone(), + details, + ); + + // Emit the dedicated override event for off-chain monitors + EventEmitter::emit_admin_override( + &env, + &market_id, + &admin, + &old_result, + &outcome, + &reason, + ); + + Ok(()) } /// Resolves a market automatically using oracle data and community consensus. diff --git a/contracts/predictify-hybrid/src/override_audit_tests.rs b/contracts/predictify-hybrid/src/override_audit_tests.rs new file mode 100644 index 00000000..5c70bc4d --- /dev/null +++ b/contracts/predictify-hybrid/src/override_audit_tests.rs @@ -0,0 +1,267 @@ +#![cfg(test)] + +use crate::audit_trail::{AuditAction, AuditTrailManager}; +use crate::errors::Error; +use crate::types::{MarketState, OracleConfig, OracleProvider}; +use crate::{PredictifyHybrid, PredictifyHybridClient}; +use soroban_sdk::{ + testutils::Address as _, vec, Address, Env, String, Symbol, +}; + +// ── shared setup ───────────────────────────────────────────────────────────── + +struct Ctx { + env: Env, + contract_id: Address, + admin: Address, +} + +impl Ctx { + fn new() -> Self { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let contract_id = env.register(PredictifyHybrid, ()); + PredictifyHybridClient::new(&env, &contract_id).initialize(&admin, &None, &None); + Self { env, contract_id, admin } + } + + fn client(&self) -> PredictifyHybridClient<'_> { + PredictifyHybridClient::new(&self.env, &self.contract_id) + } + + /// Creates a minimal market and returns its id. + fn create_market(&self) -> Symbol { + self.client().create_market( + &self.admin, + &String::from_str(&self.env, "Will BTC exceed $100k?"), + &vec![ + &self.env, + String::from_str(&self.env, "yes"), + String::from_str(&self.env, "no"), + ], + &30u32, + &OracleConfig { + provider: OracleProvider::reflector(), + oracle_address: Address::generate(&self.env), + feed_id: String::from_str(&self.env, "BTC"), + threshold: 100_000_00, + comparison: String::from_str(&self.env, "gt"), + }, + &None, + &0u64, + &None, + &None, + &None, + ) + } +} + +// ── empty reason is rejected ────────────────────────────────────────────────── + +#[test] +fn test_override_rejects_empty_reason() { + let ctx = Ctx::new(); + let market_id = ctx.create_market(); + + let result = ctx.client().try_admin_override_verification( + &ctx.admin, + &market_id, + &String::from_str(&ctx.env, "yes"), + &String::from_str(&ctx.env, ""), + ); + + assert_eq!(result, Err(Ok(Error::InvalidInput))); +} + +// ── successful override writes audit record ─────────────────────────────────── + +#[test] +fn test_override_appends_audit_record() { + let ctx = Ctx::new(); + let market_id = ctx.create_market(); + + ctx.client().admin_override_verification( + &ctx.admin, + &market_id, + &String::from_str(&ctx.env, "yes"), + &String::from_str(&ctx.env, "Oracle feed was stale; manual data confirmed"), + ); + + ctx.env.as_contract(&ctx.contract_id, || { + let head = AuditTrailManager::get_head(&ctx.env).unwrap(); + assert!(head.latest_index >= 1); + + let record = AuditTrailManager::get_record(&ctx.env, head.latest_index).unwrap(); + assert_eq!(record.action, AuditAction::OracleVerificationOverride); + assert_eq!(record.actor, ctx.admin); + + let recorded_reason = record + .details + .get(Symbol::new(&ctx.env, "reason")) + .unwrap(); + assert_eq!( + recorded_reason, + String::from_str(&ctx.env, "Oracle feed was stale; manual data confirmed") + ); + }); +} + +// ── audit chain integrity holds after override ──────────────────────────────── + +#[test] +fn test_override_preserves_audit_integrity() { + let ctx = Ctx::new(); + let market_id = ctx.create_market(); + + ctx.client().admin_override_verification( + &ctx.admin, + &market_id, + &String::from_str(&ctx.env, "no"), + &String::from_str(&ctx.env, "Community consensus contradicted oracle"), + ); + + ctx.env.as_contract(&ctx.contract_id, || { + assert!(AuditTrailManager::verify_integrity(&ctx.env, 10)); + }); +} + +// ── market state is updated to Resolved ────────────────────────────────────── + +#[test] +fn test_override_resolves_market() { + let ctx = Ctx::new(); + let market_id = ctx.create_market(); + + ctx.client().admin_override_verification( + &ctx.admin, + &market_id, + &String::from_str(&ctx.env, "yes"), + &String::from_str(&ctx.env, "Verified via secondary source"), + ); + + let market = ctx.client().get_market(&market_id).unwrap(); + assert_eq!(market.state, MarketState::Resolved); + assert_eq!( + market.oracle_result, + Some(String::from_str(&ctx.env, "yes")) + ); +} + +// ── non-admin cannot override ───────────────────────────────────────────────── +// +// We do NOT call mock_all_auths() here — the stranger has no auth mocked, +// so require_auth() panics and try_ returns Err before any storage write. + +#[test] +fn test_override_rejects_non_admin() { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let contract_id = env.register(PredictifyHybrid, ()); + let client = PredictifyHybridClient::new(&env, &contract_id); + client.initialize(&admin, &None, &None); + + // Create market while auths are still mocked + let market_id = client.create_market( + &admin, + &String::from_str(&env, "Will BTC exceed $100k?"), + &vec![ + &env, + String::from_str(&env, "yes"), + String::from_str(&env, "no"), + ], + &30u32, + &OracleConfig { + provider: OracleProvider::reflector(), + oracle_address: Address::generate(&env), + feed_id: String::from_str(&env, "BTC"), + threshold: 100_000_00, + comparison: String::from_str(&env, "gt"), + }, + &None, + &0u64, + &None, + &None, + &None, + ); + + // Now attempt override as a stranger — no auths mocked for this address + let stranger = Address::generate(&env); + let result = client.try_admin_override_verification( + &stranger, + &market_id, + &String::from_str(&env, "yes"), + &String::from_str(&env, "Trying to cheat"), + ); + + assert!(result.is_err()); +} + +// ── unknown market returns MarketNotFound ───────────────────────────────────── + +#[test] +fn test_override_unknown_market() { + let ctx = Ctx::new(); + + let result = ctx.client().try_admin_override_verification( + &ctx.admin, + &Symbol::new(&ctx.env, "ghost"), + &String::from_str(&ctx.env, "yes"), + &String::from_str(&ctx.env, "Some reason"), + ); + + assert_eq!(result, Err(Ok(Error::MarketNotFound))); +} + +// ── no partial state on auth failure ───────────────────────────────────────── +// +// After a failed auth attempt the market must be unchanged. + +#[test] +fn test_override_no_partial_state_on_auth_failure() { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let contract_id = env.register(PredictifyHybrid, ()); + let client = PredictifyHybridClient::new(&env, &contract_id); + client.initialize(&admin, &None, &None); + + let market_id = client.create_market( + &admin, + &String::from_str(&env, "Will BTC exceed $100k?"), + &vec![ + &env, + String::from_str(&env, "yes"), + String::from_str(&env, "no"), + ], + &30u32, + &OracleConfig { + provider: OracleProvider::reflector(), + oracle_address: Address::generate(&env), + feed_id: String::from_str(&env, "BTC"), + threshold: 100_000_00, + comparison: String::from_str(&env, "gt"), + }, + &None, + &0u64, + &None, + &None, + &None, + ); + + let before = client.get_market(&market_id).unwrap(); + + // Attempt override without auth — should fail + let stranger = Address::generate(&env); + let _ = client.try_admin_override_verification( + &stranger, + &market_id, + &String::from_str(&env, "yes"), + &String::from_str(&env, "Sneaky"), + ); + + let after = client.get_market(&market_id).unwrap(); + assert_eq!(before.state, after.state); + assert_eq!(before.oracle_result, after.oracle_result); +}