diff --git a/contracts/predictify-hybrid/src/bets.rs b/contracts/predictify-hybrid/src/bets.rs index c0750af7..6294a711 100644 --- a/contracts/predictify-hybrid/src/bets.rs +++ b/contracts/predictify-hybrid/src/bets.rs @@ -700,7 +700,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); @@ -776,6 +784,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 bf8f5ebf..6333e07d 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 d519406b..ffed8149 100644 --- a/contracts/predictify-hybrid/src/events.rs +++ b/contracts/predictify-hybrid/src/events.rs @@ -3663,3 +3663,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); + }); + } +} 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) + } +} 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}