From 44eaee0c904561fe65bf67535a948ef77eb4452a Mon Sep 17 00:00:00 2001 From: Ayomide-codex Date: Tue, 30 Jun 2026 09:40:13 +0100 Subject: [PATCH 1/2] 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/2] 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); + }); + } +}