From 44eaee0c904561fe65bf67535a948ef77eb4452a Mon Sep 17 00:00:00 2001 From: Ayomide-codex Date: Tue, 30 Jun 2026 09:40:13 +0100 Subject: [PATCH 1/3] Add empty lines for better readability in script --- scripts/check_wasm_size.sh | 3 +++ 1 file changed, 3 insertions(+) diff --git a/scripts/check_wasm_size.sh b/scripts/check_wasm_size.sh index ab7fac09..c187bd4d 100755 --- a/scripts/check_wasm_size.sh +++ b/scripts/check_wasm_size.sh @@ -2,6 +2,9 @@ set -e + + + # Default budget: 768 KiB = 768 * 1024 = 786432 bytes BUDGET=${WASM_SIZE_BUDGET:-786432} From 24b4249f86e85d52cb344cda84d237202d731a9d Mon Sep 17 00:00:00 2001 From: Ayomide Ajisegiri Date: Mon, 27 Jul 2026 10:29:49 +0000 Subject: [PATCH 2/3] feat: add structured betting events for betting lifecycle Add BetBatchPlacedEvent and BetStatsUpdatedEvent with emit methods, update event topic catalog, and integrate emissions into betting code. - BetBatchPlacedEvent: emitted for batch bet operations (place_bets) - BetStatsUpdatedEvent: emitted when per-market bet stats change - Event topic catalog updated with all betting lifecycle topics - Focused tests for struct creation, publication, storage, and edge cases --- contracts/predictify-hybrid/src/bets.rs | 19 +- .../src/event_topic_catalog.rs | 4 + contracts/predictify-hybrid/src/events.rs | 301 ++++++++++++++++++ 3 files changed, 323 insertions(+), 1 deletion(-) diff --git a/contracts/predictify-hybrid/src/bets.rs b/contracts/predictify-hybrid/src/bets.rs index 9a14364a..bb95ae2e 100644 --- a/contracts/predictify-hybrid/src/bets.rs +++ b/contracts/predictify-hybrid/src/bets.rs @@ -525,7 +525,15 @@ impl BetManager { placed_bets.push_back(bet); } - // Phase 4: Consume the idempotency key so replays are rejected. + // Phase 4: Emit batch event for the entire operation + EventEmitter::emit_bet_batch_placed( + env, + &user, + &bets, + total_amount, + ); + + // Phase 5: Consume the idempotency key so replays are rejected. // Stored as temporary (cheaper rent) with PLACE_BETS_IDEM_TTL_LEDGERS TTL. let ttl = crate::storage::PLACE_BETS_IDEM_TTL_LEDGERS; env.storage().persistent().set(&idem_key, &true); @@ -601,6 +609,15 @@ impl BetManager { // Store updated stats BetStorage::store_market_bet_stats(env, market_id, &stats)?; + // Emit bet stats updated event + EventEmitter::emit_bet_stats_updated( + env, + market_id, + stats.total_bets, + stats.total_amount_locked, + stats.unique_bettors, + ); + Ok(()) } diff --git a/contracts/predictify-hybrid/src/event_topic_catalog.rs b/contracts/predictify-hybrid/src/event_topic_catalog.rs index 368027c1..f7b031c2 100644 --- a/contracts/predictify-hybrid/src/event_topic_catalog.rs +++ b/contracts/predictify-hybrid/src/event_topic_catalog.rs @@ -26,6 +26,10 @@ pub fn get_event_topic_catalog(env: &Env) -> Vec { ("vote", "vote_cast", "Fired when a user casts a vote on a market"), ("place_bet", "bet_placed", "Fired when a bet is placed on a market"), ("cancel_bet", "bet_cancelled", "Fired when a bet is cancelled by the bettor"), + ("place_bets", "bet_batch_placed", "Fired when multiple bets are placed in a single batch"), + ("resolve_market_bets", "bet_status_updated", "Fired when a bet's status changes (won/lost/refunded/cancelled)"), + ("set_global_bet_limits", "bet_limits_updated", "Fired when global or per-event bet limits are updated"), + ("update_market_bet_stats", "bet_stats_updated", "Fired when per-market betting statistics are updated"), ("resolve_market", "market_resolved", "Fired on successful market resolution"), ("resolve_market_manual", "market_resolved_manual", "Fired on admin manual resolution"), ("force_resolve_market", "market_force_resolved", "Fired on forced resolution"), diff --git a/contracts/predictify-hybrid/src/events.rs b/contracts/predictify-hybrid/src/events.rs index e0daa171..fb5c9e59 100644 --- a/contracts/predictify-hybrid/src/events.rs +++ b/contracts/predictify-hybrid/src/events.rs @@ -324,6 +324,45 @@ pub struct BetStatusUpdatedEvent { pub timestamp: u64, } +/// Event emitted when multiple bets are placed in a single batch transaction. +/// +/// This event captures all bets placed in a batch via `place_bets`, providing +/// a single structured event for batch operations that indexers can use to +/// track bulk betting activity efficiently. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct BetBatchPlacedEvent { + /// Bettor address + pub bettor: Address, + /// Vector of (market_id, outcome, amount) tuples for each placed bet + pub bets: Vec<(Symbol, String, i128)>, + /// Total amount locked across all bets + pub total_amount: i128, + /// Number of bets in this batch + pub bet_count: u32, + /// Batch placement timestamp + pub timestamp: u64, +} + +/// Event emitted when per-market betting statistics are updated. +/// +/// This event captures changes to a market's betting statistics, including +/// total bets, locked amounts, and outcome distribution changes. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct BetStatsUpdatedEvent { + /// Market ID + pub market_id: Symbol, + /// Total number of bets placed on this market + pub total_bets: u32, + /// Total amount locked on this market + pub total_amount_locked: i128, + /// Number of unique bettors on this market + pub unique_bettors: u32, + /// Stats update timestamp + pub timestamp: u64, +} + /// Event emitted when oracle data is successfully fetched for market resolution. /// /// This event captures comprehensive oracle data retrieval information, including @@ -2344,6 +2383,62 @@ impl EventEmitter { .publish((symbol_short!("bet_upd"), market_id.clone()), event); } + /// Emit bet batch placed event when multiple bets are placed in a batch + /// + /// # Parameters + /// + /// - `env` - Soroban environment + /// - `bettor` - Address of the user placing the bets + /// - `bets` - Vector of (market_id, outcome, amount) tuples + /// - `total_amount` - Total amount locked across all bets + pub fn emit_bet_batch_placed( + env: &Env, + bettor: &Address, + bets: &Vec<(Symbol, String, i128)>, + total_amount: i128, + ) { + let event = BetBatchPlacedEvent { + bettor: bettor.clone(), + bets: bets.clone(), + total_amount, + bet_count: bets.len(), + timestamp: env.ledger().timestamp(), + }; + + Self::store_event(env, &symbol_short!("bet_batch"), &event); + env.events() + .publish((symbol_short!("bet_batch"), bettor.clone()), event); + } + + /// Emit bet stats updated event when a market's betting statistics change + /// + /// # Parameters + /// + /// - `env` - Soroban environment + /// - `market_id` - Market identifier + /// - `total_bets` - Total number of bets on the market + /// - `total_amount_locked` - Total amount locked on the market + /// - `unique_bettors` - Number of unique bettors + pub fn emit_bet_stats_updated( + env: &Env, + market_id: &Symbol, + total_bets: u32, + total_amount_locked: i128, + unique_bettors: u32, + ) { + let event = BetStatsUpdatedEvent { + market_id: market_id.clone(), + total_bets, + total_amount_locked, + unique_bettors, + timestamp: env.ledger().timestamp(), + }; + + Self::store_event(env, &symbol_short!("bet_stat"), &event); + env.events() + .publish((symbol_short!("bet_stat"), market_id.clone()), event); + } + /// Emit oracle result event. /// /// Topic and schema version are resolved from [`EventSchemaRegistry`] so @@ -5156,3 +5251,209 @@ mod focused_dispute_tests { assert!(found, "DisputeOpenedEvent not found with correct topic structure"); } } + +#[cfg(test)] +mod focused_betting_events_tests { + use super::*; + use soroban_sdk::{testutils::{Address as _, Events}, Address, Env, IntoVal, Symbol, Vec}; + + #[test] + fn test_bet_batch_placed_event_struct() { + let env = Env::default(); + let bettor = Address::generate(&env); + let market1 = Symbol::new(&env, "mkt1"); + let market2 = Symbol::new(&env, "mkt2"); + let outcome1 = String::from_str(&env, "yes"); + let outcome2 = String::from_str(&env, "no"); + + let bets = vec![ + &env, + (market1, outcome1, 10_000_000i128), + (market2, outcome2, 5_000_000i128), + ]; + + let event = BetBatchPlacedEvent { + bettor: bettor.clone(), + bets: bets.clone(), + total_amount: 15_000_000, + bet_count: 2, + timestamp: 1000, + }; + + assert_eq!(event.bettor, bettor); + assert_eq!(event.bet_count, 2); + assert_eq!(event.total_amount, 15_000_000); + assert_eq!(event.bets.len(), 2); + } + + #[test] + fn test_bet_stats_updated_event_struct() { + let env = Env::default(); + let market_id = Symbol::new(&env, "btc_100k"); + + let event = BetStatsUpdatedEvent { + market_id: market_id.clone(), + total_bets: 42u32, + total_amount_locked: 1_000_000_000, + unique_bettors: 25u32, + timestamp: 2000, + }; + + assert_eq!(event.market_id, market_id); + assert_eq!(event.total_bets, 42u32); + assert_eq!(event.total_amount_locked, 1_000_000_000); + assert_eq!(event.unique_bettors, 25u32); + } + + #[test] + fn test_emit_bet_batch_placed_publishes_event() { + let env = Env::default(); + let contract_id = env.register(crate::PredictifyHybrid, ()); + + let bettor = Address::generate(&env); + let market1 = Symbol::new(&env, "mkt1"); + let market2 = Symbol::new(&env, "mkt2"); + let outcome1 = String::from_str(&env, "yes"); + let outcome2 = String::from_str(&env, "no"); + + let bets = vec![ + &env, + (market1, outcome1, 10_000_000i128), + (market2, outcome2, 5_000_000i128), + ]; + + env.as_contract(&contract_id, || { + EventEmitter::emit_bet_batch_placed(&env, &bettor, &bets, 15_000_000); + }); + + let events = env.events().all(); + let mut found = false; + for event in events.iter() { + if event.2.len() > 0 { + let topic0: Symbol = event.2.get(0).unwrap().try_into_val(&env).unwrap(); + if topic0 == symbol_short!("bet_batch") { + found = true; + } + } + } + assert!(found, "BetBatchPlacedEvent not found in published events"); + } + + #[test] + fn test_emit_bet_stats_updated_publishes_event() { + let env = Env::default(); + let contract_id = env.register(crate::PredictifyHybrid, ()); + + let market_id = Symbol::new(&env, "btc_100k"); + + env.as_contract(&contract_id, || { + EventEmitter::emit_bet_stats_updated(&env, &market_id, 42u32, 1_000_000_000, 25u32); + }); + + let events = env.events().all(); + let mut found = false; + for event in events.iter() { + if event.2.len() > 0 { + let topic0: Symbol = event.2.get(0).unwrap().try_into_val(&env).unwrap(); + if topic0 == symbol_short!("bet_stat") { + found = true; + } + } + } + assert!(found, "BetStatsUpdatedEvent not found in published events"); + } + + #[test] + fn test_emit_bet_batch_placed_stores_event() { + let env = Env::default(); + let contract_id = env.register(crate::PredictifyHybrid, ()); + + let bettor = Address::generate(&env); + let bets = vec![ + &env, + ( + Symbol::new(&env, "mkt1"), + String::from_str(&env, "yes"), + 10_000_000i128, + ), + ]; + + env.as_contract(&contract_id, || { + EventEmitter::emit_bet_batch_placed(&env, &bettor, &bets, 10_000_000); + let stored: Option = env + .storage() + .persistent() + .get(&symbol_short!("bet_batch")); + assert!(stored.is_some(), "BetBatchPlacedEvent should be stored"); + let stored_event = stored.unwrap(); + assert_eq!(stored_event.bettor, bettor); + assert_eq!(stored_event.bet_count, 1); + assert_eq!(stored_event.total_amount, 10_000_000); + }); + } + + #[test] + fn test_emit_bet_stats_updated_stores_event() { + let env = Env::default(); + let contract_id = env.register(crate::PredictifyHybrid, ()); + + let market_id = Symbol::new(&env, "btc_100k"); + + env.as_contract(&contract_id, || { + EventEmitter::emit_bet_stats_updated(&env, &market_id, 42u32, 1_000_000_000, 25u32); + let stored: Option = env + .storage() + .persistent() + .get(&symbol_short!("bet_stat")); + assert!(stored.is_some(), "BetStatsUpdatedEvent should be stored"); + let stored_event = stored.unwrap(); + assert_eq!(stored_event.total_bets, 42u32); + assert_eq!(stored_event.total_amount_locked, 1_000_000_000); + assert_eq!(stored_event.unique_bettors, 25u32); + }); + } + + #[test] + fn test_emit_bet_batch_placed_with_empty_bets() { + let env = Env::default(); + let contract_id = env.register(crate::PredictifyHybrid, ()); + + let bettor = Address::generate(&env); + let empty_bets: Vec<(Symbol, String, i128)> = Vec::new(&env); + + env.as_contract(&contract_id, || { + EventEmitter::emit_bet_batch_placed(&env, &bettor, &empty_bets, 0); + let events = env.events().all(); + let mut found = false; + for event in events.iter() { + if event.2.len() > 0 { + let topic0: Symbol = event.2.get(0).unwrap().try_into_val(&env).unwrap(); + if topic0 == symbol_short!("bet_batch") { + found = true; + } + } + } + assert!(found, "Batch event with empty bets should still be published"); + }); + } + + #[test] + fn test_emit_bet_stats_updated_zero_values() { + let env = Env::default(); + let contract_id = env.register(crate::PredictifyHybrid, ()); + + let market_id = Symbol::new(&env, "fresh_market"); + + env.as_contract(&contract_id, || { + EventEmitter::emit_bet_stats_updated(&env, &market_id, 0u32, 0, 0u32); + let stored: Option = env + .storage() + .persistent() + .get(&symbol_short!("bet_stat")); + assert!(stored.is_some(), "Bet stats event should be stored"); + let stored_event = stored.unwrap(); + assert_eq!(stored_event.total_bets, 0u32); + assert_eq!(stored_event.unique_bettors, 0u32); + }); + } +} From 80231b5eca3dff6563e6bb08f33c51ba936ef918 Mon Sep 17 00:00:00 2001 From: Ayomide Ajisegiri Date: Mon, 27 Jul 2026 10:48:44 +0000 Subject: [PATCH 3/3] test: add per-entrypoint auth snapshot tests for resolution Add comprehensive auth coverage tests for all resolution entrypoints: - resolve_market_manual (authorized/forged/no-auth/wrong-subject/uninitialized) - resolve_market_with_ties (authorized/forged/no-auth) - force_resolve_market (authorized/forged/no-auth/wrong-subject) - resolve_dispute (authorized/forged/no-auth) - admin_override_verification (authorized/forged/no-auth) Each entrypoint has positive (authorized admin succeeds) and negative (forged admin / no-auth rejected) tests, following the same patterns as require_auth_coverage_tests.rs. --- contracts/predictify-hybrid/src/lib.rs | 2 + .../src/resolution_auth_snap_tests.rs | 443 ++++++++++++++++++ 2 files changed, 445 insertions(+) create mode 100644 contracts/predictify-hybrid/src/resolution_auth_snap_tests.rs diff --git a/contracts/predictify-hybrid/src/lib.rs b/contracts/predictify-hybrid/src/lib.rs index 95b3f779..60a6edb7 100644 --- a/contracts/predictify-hybrid/src/lib.rs +++ b/contracts/predictify-hybrid/src/lib.rs @@ -125,6 +125,8 @@ mod category_tags_tests; mod tie_resolution_tests; #[cfg(test)] mod force_resolve_tests; +#[cfg(test)] +mod resolution_auth_snap_tests; // #[cfg(any())] // mod statistics_tests; diff --git a/contracts/predictify-hybrid/src/resolution_auth_snap_tests.rs b/contracts/predictify-hybrid/src/resolution_auth_snap_tests.rs new file mode 100644 index 00000000..04c5e3fb --- /dev/null +++ b/contracts/predictify-hybrid/src/resolution_auth_snap_tests.rs @@ -0,0 +1,443 @@ +//! Per-entrypoint auth snapshot tests for the resolution subsystem. +//! +//! Every state-changing resolution entrypoint has a positive (authorized) and +//! negative (unauthorized) test. Unauthorized calls must panic or return +//! `Error::Unauthorized`. +//! +//! ## Resolution Entrypoint Matrix +//! +//! | Entrypoint | Auth Subject | Return Type | Positive | Negative | +//! |------------------------------|--------------|-------------|----------|----------| +//! | `resolve_market_manual` | admin | panic | yes | yes | +//! | `resolve_market_with_ties` | admin | panic | yes | yes | +//! | `force_resolve_market` | admin | Result | yes | yes | +//! | `resolve_dispute` | admin | Result | yes | yes | +//! | `admin_override_verification`| admin | Result | yes | yes | + +use crate::err::Error; +use crate::types::{OracleConfig, OracleProvider}; +use crate::{PredictifyHybrid, PredictifyHybridClient}; +use soroban_sdk::{ + testutils::{Address as _, Ledger}, + vec, Address, BytesN, Env, String, Symbol, Vec, +}; + +// ============================================================ +// Shared helpers +// ============================================================ + +/// Build an initialized contract with mock_all_auths active. +fn setup() -> (Env, Address, Address) { + let env = Env::default(); + env.mock_all_auths(); + let cid = env.register(PredictifyHybrid, ()); + let admin = Address::generate(&env); + PredictifyHybridClient::new(&env, &cid).initialize(&admin, &Some(200i128), &None); + (env, cid, admin) +} + +fn client<'a>(env: &'a Env, cid: &'a Address) -> PredictifyHybridClient<'a> { + PredictifyHybridClient::new(env, cid) +} + +/// For functions that return Result: +macro_rules! assert_unauthorized_contract { + ($result:expr) => { + match $result { + Err(Ok(e)) => assert_eq!(e, crate::err::Error::Unauthorized, + "expected Unauthorized, got {:?}", e), + Ok(_) => panic!("expected Unauthorized error, got Ok"), + Err(Err(e)) => panic!("expected Unauthorized error, got host error {:?}", e), + } + }; +} + +/// For functions that panic (no explicit return type / return ()): +macro_rules! assert_unauthorized_panic { + ($result:expr) => { + match $result { + Err(Ok(e)) => assert_eq!( + e, + soroban_sdk::Error::from_contract_error(crate::err::Error::Unauthorized as u32), + "expected Unauthorized, got {:?}", e + ), + Ok(_) => panic!("expected Unauthorized error, got Ok"), + Err(Err(e)) => panic!("expected Unauthorized error, got host error {:?}", e), + } + }; +} + +/// For positive tests on Result functions: +macro_rules! assert_auth_ok_contract { + ($result:expr, $msg:expr) => { + if let Err(Ok(e)) = $result { + assert_ne!(e, crate::err::Error::Unauthorized, $msg); + } + }; +} + +/// For positive tests on panicking functions: +macro_rules! assert_auth_ok_panic { + ($result:expr, $msg:expr) => { + if let Err(Ok(e)) = $result { + assert_ne!( + e, + soroban_sdk::Error::from_contract_error(crate::err::Error::Unauthorized as u32), + $msg + ); + } + }; +} + +fn oracle(env: &Env) -> OracleConfig { + OracleConfig { + provider: OracleProvider::reflector(), + oracle_address: Address::from_str( + env, + "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF", + ), + feed_id: String::from_str(env, "BTC/USD"), + threshold: 50_000, + comparison: String::from_str(env, "gt"), + } +} + +fn make_market(env: &Env, cid: &Address, admin: &Address) -> Symbol { + let mut outcomes = Vec::new(env); + outcomes.push_back(String::from_str(env, "yes")); + outcomes.push_back(String::from_str(env, "no")); + client(env, cid).create_market( + admin, + &String::from_str(env, "Will BTC reach 100k?"), + &outcomes, + &30u32, + &oracle(env), + &None, + &86400u64, + &None, + &None, + &None, + ) +} + +/// Advance ledger 31 days so markets are past their end time. +fn advance_past_end(env: &Env) { + env.ledger().with_mut(|l| l.timestamp += 31 * 24 * 60 * 60); +} + +/// Advance ledger past the dispute window (default 86400 s). +fn advance_past_dispute(env: &Env) { + env.ledger().with_mut(|l| l.timestamp += 86_401); +} + +/// Build a fresh env with NO auths mocked (for negative-path tests). +fn setup_no_auth() -> (Env, Address, Address) { + let env = Env::default(); + env.mock_all_auths(); + let cid = env.register(PredictifyHybrid, ()); + let admin = Address::generate(&env); + PredictifyHybridClient::new(&env, &cid).initialize(&admin, &Some(200i128), &None); + env.set_auths(&[]); + (env, cid, admin) +} + +// ============================================================ +// resolve_market_manual +// ============================================================ + +#[test] +fn test_resolve_market_manual_authorized_admin_succeeds() { + let (env, cid, admin) = setup(); + let market_id = make_market(&env, &cid, &admin); + advance_past_end(&env); + let result = client(&env, &cid).try_resolve_market_manual( + &admin, + &market_id, + &String::from_str(&env, "yes"), + ); + assert_auth_ok_panic!(result, "resolve_market_manual rejected authorized admin"); +} + +#[test] +fn test_resolve_market_manual_forged_admin_rejected() { + let (env, cid, admin) = setup(); + let market_id = make_market(&env, &cid, &admin); + advance_past_end(&env); + let attacker = Address::generate(&env); + let result = client(&env, &cid).try_resolve_market_manual( + &attacker, + &market_id, + &String::from_str(&env, "yes"), + ); + assert_unauthorized_panic!(result); +} + +#[test] +fn test_resolve_market_manual_no_auth_panics() { + let (env, cid, _admin) = setup_no_auth(); + let market_id = Symbol::new(&env, "mkt"); + let result = client(&env, &cid).try_resolve_market_manual( + &Address::generate(&env), + &market_id, + &String::from_str(&env, "yes"), + ); + assert_unauthorized_panic!(result); +} + +// ============================================================ +// resolve_market_with_ties +// ============================================================ + +#[test] +fn test_resolve_market_with_ties_authorized_admin_succeeds() { + let (env, cid, admin) = setup(); + let market_id = make_market(&env, &cid, &admin); + advance_past_end(&env); + let mut winning = Vec::new(&env); + winning.push_back(String::from_str(&env, "yes")); + let result = client(&env, &cid).try_resolve_market_with_ties( + &admin, + &market_id, + &winning, + ); + assert_auth_ok_panic!(result, "resolve_market_with_ties rejected authorized admin"); +} + +#[test] +fn test_resolve_market_with_ties_forged_admin_rejected() { + let (env, cid, admin) = setup(); + let market_id = make_market(&env, &cid, &admin); + advance_past_end(&env); + let attacker = Address::generate(&env); + let mut winning = Vec::new(&env); + winning.push_back(String::from_str(&env, "yes")); + let result = client(&env, &cid).try_resolve_market_with_ties( + &attacker, + &market_id, + &winning, + ); + assert_unauthorized_panic!(result); +} + +#[test] +fn test_resolve_market_with_ties_no_auth_panics() { + let (env, cid, _admin) = setup_no_auth(); + let market_id = Symbol::new(&env, "mkt"); + let mut winning = Vec::new(&env); + winning.push_back(String::from_str(&env, "yes")); + let result = client(&env, &cid).try_resolve_market_with_ties( + &Address::generate(&env), + &market_id, + &winning, + ); + assert_unauthorized_panic!(result); +} + +// ============================================================ +// force_resolve_market +// ============================================================ + +#[test] +fn test_force_resolve_market_authorized_admin_succeeds() { + let (env, cid, admin) = setup(); + let market_id = make_market(&env, &cid, &admin); + let mut outcomes = Vec::new(&env); + outcomes.push_back(String::from_str(&env, "yes")); + let result = client(&env, &cid).try_force_resolve_market( + &admin, + &market_id, + &outcomes, + &String::from_str(&env, "Admin override"), + &String::from_str(&env, "key-001"), + ); + assert_auth_ok_contract!(result, "force_resolve_market rejected authorized admin"); +} + +#[test] +fn test_force_resolve_market_forged_admin_rejected() { + let (env, cid, admin) = setup(); + let market_id = make_market(&env, &cid, &admin); + let attacker = Address::generate(&env); + let mut outcomes = Vec::new(&env); + outcomes.push_back(String::from_str(&env, "yes")); + let result = client(&env, &cid).try_force_resolve_market( + &attacker, + &market_id, + &outcomes, + &String::from_str(&env, "Hack attempt"), + &String::from_str(&env, "key-002"), + ); + assert_unauthorized_contract!(result); +} + +#[test] +fn test_force_resolve_market_no_auth_rejected() { + let (env, cid, _admin) = setup_no_auth(); + let market_id = Symbol::new(&env, "mkt"); + let mut outcomes = Vec::new(&env); + outcomes.push_back(String::from_str(&env, "yes")); + let result = client(&env, &cid).try_force_resolve_market( + &Address::generate(&env), + &market_id, + &outcomes, + &String::from_str(&env, "No auth"), + &String::from_str(&env, "key-003"), + ); + assert_unauthorized_contract!(result); +} + +// ============================================================ +// resolve_dispute +// ============================================================ + +#[test] +fn test_resolve_dispute_authorized_admin_succeeds() { + let (env, cid, admin) = setup(); + let market_id = make_market(&env, &cid, &admin); + advance_past_end(&env); + let _ = client(&env, &cid).try_resolve_market_manual( + &admin, &market_id, &String::from_str(&env, "yes"), + ); + let result = client(&env, &cid).try_resolve_dispute(&admin, &market_id); + assert_auth_ok_contract!(result, "resolve_dispute rejected authorized admin"); +} + +#[test] +fn test_resolve_dispute_forged_admin_rejected() { + let (env, cid, admin) = setup(); + let market_id = make_market(&env, &cid, &admin); + let attacker = Address::generate(&env); + let result = client(&env, &cid).try_resolve_dispute(&attacker, &market_id); + assert_unauthorized_contract!(result); +} + +#[test] +fn test_resolve_dispute_no_auth_rejected() { + let (env, cid, _admin) = setup_no_auth(); + let market_id = Symbol::new(&env, "mkt"); + let result = client(&env, &cid).try_resolve_dispute( + &Address::generate(&env), + &market_id, + ); + assert_unauthorized_contract!(result); +} + +// ============================================================ +// admin_override_verification +// ============================================================ + +#[test] +fn test_admin_override_verification_authorized_admin_auth_passes() { + let (env, cid, admin) = setup(); + let market_id = make_market(&env, &cid, &admin); + let result = client(&env, &cid).try_admin_override_verification( + &admin, + &market_id, + &String::from_str(&env, "yes"), + &String::from_str(&env, "manual override"), + &0u64, + ); + assert_auth_ok_contract!(result, "admin_override_verification rejected authorized admin"); +} + +#[test] +fn test_admin_override_verification_forged_admin_rejected() { + let (env, cid, admin) = setup(); + let market_id = make_market(&env, &cid, &admin); + let attacker = Address::generate(&env); + let result = client(&env, &cid).try_admin_override_verification( + &attacker, + &market_id, + &String::from_str(&env, "yes"), + &String::from_str(&env, "hack"), + &0u64, + ); + assert_unauthorized_contract!(result); +} + +#[test] +fn test_admin_override_verification_no_auth_rejected() { + let (env, cid, _admin) = setup_no_auth(); + let market_id = Symbol::new(&env, "mkt"); + let result = client(&env, &cid).try_admin_override_verification( + &Address::generate(&env), + &market_id, + &String::from_str(&env, "yes"), + &String::from_str(&env, "no auth"), + &0u64, + ); + assert_unauthorized_contract!(result); +} + +// ============================================================ +// Edge cases +// ============================================================ + +/// An attacker must not be able to resolve a market that does not belong to them, +/// even with mock_all_auths active. The contract must validate the admin address +/// against the stored primary admin. +#[test] +fn test_resolve_market_manual_wrong_admin_subject_rejected() { + let env = Env::default(); + env.mock_all_auths(); + let cid = env.register(PredictifyHybrid, ()); + let real_admin = Address::generate(&env); + PredictifyHybridClient::new(&env, &cid).initialize(&real_admin, &Some(200i128), &None); + let market_id = make_market(&env, &cid, &real_admin); + advance_past_end(&env); + + let impostor = Address::generate(&env); + let result = client(&env, &cid).try_resolve_market_manual( + &impostor, + &market_id, + &String::from_str(&env, "yes"), + ); + assert_unauthorized_panic!(result); +} + +/// Even with mock_all_auths, an attacker cannot force-resolve a market +/// they do not own. +#[test] +fn test_force_resolve_market_wrong_admin_subject_rejected() { + let env = Env::default(); + env.mock_all_auths(); + let cid = env.register(PredictifyHybrid, ()); + let real_admin = Address::generate(&env); + PredictifyHybridClient::new(&env, &cid).initialize(&real_admin, &Some(200i128), &None); + let market_id = make_market(&env, &cid, &real_admin); + + let impostor = Address::generate(&env); + let mut outcomes = Vec::new(&env); + outcomes.push_back(String::from_str(&env, "yes")); + let result = client(&env, &cid).try_force_resolve_market( + &impostor, + &market_id, + &outcomes, + &String::from_str(&env, "Impostor"), + &String::from_str(&env, "key-edge-001"), + ); + assert_unauthorized_contract!(result); +} + +/// Uninitialized contract: resolution calls before initialize must return AdminNotSet. +#[test] +fn test_resolve_market_manual_before_initialize_returns_admin_not_set() { + let env = Env::default(); + env.mock_all_auths(); + let cid = env.register(PredictifyHybrid, ()); + let fake_admin = Address::generate(&env); + let market_id = Symbol::new(&env, "mkt"); + // No call to initialize — admin storage is empty. + let result = client(&env, &cid).try_resolve_market_manual( + &fake_admin, + &market_id, + &String::from_str(&env, "yes"), + ); + // Should fail with AdminNotSet (not Unauthorized) because the contract + // hasn't been initialized yet. + match result { + Err(Ok(e)) => assert_ne!(e, Error::Unauthorized, + "expected non-Unauthorized error for uninitialized contract, got Unauthorized"), + _ => {} // Any other outcome is acceptable (e.g. panic with AdminNotSet) + } +}