diff --git a/contracts/predictify-hybrid/src/admin.rs b/contracts/predictify-hybrid/src/admin.rs index d7f98ffb..8ee93091 100644 --- a/contracts/predictify-hybrid/src/admin.rs +++ b/contracts/predictify-hybrid/src/admin.rs @@ -3,7 +3,7 @@ use alloc::format; use soroban_sdk::{contracttype, Address, Env, Map, String, Symbol, Vec}; // use alloc::string::ToString; // Unused import -use crate::config::{ConfigManager, ConfigUtils, ContractConfig, Environment}; +use crate::config::{ConfigManager, ConfigUtils, ConfigValidator, ContractConfig, Environment}; use crate::err::Error; use crate::events::EventEmitter; use crate::extensions::ExtensionManager; @@ -353,7 +353,7 @@ impl AdminInitializer { Environment::Mainnet => ConfigManager::get_mainnet_config(env), Environment::Custom => ConfigManager::get_development_config(env), }; - ConfigManager::validate_config(env, &config)?; + ConfigValidator::validate_contract_config(&config)?; // Initialize basic admin setup AdminInitializer::initialize(env, admin)?; diff --git a/contracts/predictify-hybrid/src/event_archive.rs b/contracts/predictify-hybrid/src/event_archive.rs index b9fd050c..44de56c1 100644 --- a/contracts/predictify-hybrid/src/event_archive.rs +++ b/contracts/predictify-hybrid/src/event_archive.rs @@ -113,6 +113,12 @@ pub fn derive_archive_key(env: &Env, market_id: &Symbol, suffix: &str) -> (Symbo /// Maximum events returned per query (gas safety). pub const MAX_QUERY_LIMIT: u32 = 30; +/// Default events returned per query when limit is 0. +pub const DEFAULT_QUERY_LIMIT: u32 = 10; + +/// Maximum entries that can be pruned in a single transaction. +pub const MAX_PRUNE_BATCH: u32 = 30; + /// Hard cap on the number of archived entries stored on-chain. /// /// Prevents unbounded storage growth. When the archive reaches this limit, @@ -124,6 +130,15 @@ pub const MAX_QUERY_LIMIT: u32 = 30; /// bounding worst-case growth. pub const MAX_ARCHIVE_SIZE: u32 = 1_000; +/// Sanitize query limit to ensure it never exceeds MAX_QUERY_LIMIT and defaults to DEFAULT_QUERY_LIMIT when 0. +pub fn sanitize_limit(limit: u32) -> u32 { + if limit == 0 { + DEFAULT_QUERY_LIMIT + } else { + core::cmp::min(limit, MAX_QUERY_LIMIT) + } +} + /// Storage key for archived event timestamps (market_id -> archived_at). const ARCHIVED_TS_KEY: &str = "evt_archived"; @@ -352,7 +367,7 @@ impl EventArchive { return Err(Error::Unauthorized); } - let count = core::cmp::min(count, MAX_QUERY_LIMIT); + let count = core::cmp::min(count, MAX_PRUNE_BATCH); if count == 0 { return Ok((0, cursor.unwrap_or_else(|| PruneCursor::new(env)))); } @@ -622,7 +637,12 @@ impl EventArchive { cursor: u32, limit: u32, ) -> (Vec, u32) { - let limit = core::cmp::min(limit, MAX_QUERY_LIMIT); + let total = MarketIdGenerator::get_market_id_registry_len(env); + if cursor >= total { + return (Vec::new(env), total); + } + + let limit = sanitize_limit(limit); let registry_page = MarketIdGenerator::get_market_id_registry(env, cursor, limit); let mut result = Vec::new(env); let mut scanned = 0u32; @@ -648,7 +668,8 @@ impl EventArchive { } } - (result, cursor + scanned) + let next_cursor = core::cmp::min(cursor.saturating_add(scanned), total); + (result, next_cursor) } /// Query events by resolution status (paginated, bounded). @@ -660,7 +681,12 @@ impl EventArchive { cursor: u32, limit: u32, ) -> (Vec, u32) { - let limit = core::cmp::min(limit, MAX_QUERY_LIMIT); + let total = MarketIdGenerator::get_market_id_registry_len(env); + if cursor >= total { + return (Vec::new(env), total); + } + + let limit = sanitize_limit(limit); let registry_page = MarketIdGenerator::get_market_id_registry(env, cursor, limit); let mut result = Vec::new(env); let mut scanned = 0u32; @@ -685,7 +711,8 @@ impl EventArchive { } } - (result, cursor + scanned) + let next_cursor = core::cmp::min(cursor.saturating_add(scanned), total); + (result, next_cursor) } /// Query archived events directly (paginated, bounded). @@ -704,7 +731,6 @@ impl EventArchive { cursor: u32, limit: u32, ) -> (Vec, u32) { - let limit = core::cmp::min(limit, MAX_QUERY_LIMIT); let index_key = Symbol::new(env, ARCHIVED_INDEX_KEY); let index: Vec<(u64, Symbol)> = env .storage() @@ -713,12 +739,16 @@ impl EventArchive { .unwrap_or_else(|| Vec::new(env)); let total = index.len() as u32; + if cursor >= total { + return (Vec::new(env), total); + } + + let limit = sanitize_limit(limit); let mut result = Vec::new(env); let mut examined = 0u32; if !reverse { - // Ascending (oldest archived first): page [cursor, cursor + limit). - let mut idx = core::cmp::min(cursor, total); + let mut idx = cursor; while examined < limit && idx < total { if let Some((_, market_id)) = index.get(idx) { examined += 1; @@ -729,9 +759,7 @@ impl EventArchive { idx += 1; } } else { - // Descending (newest archived first): `cursor` counts back from the - // newest entry; cursor 0 starts at the newest (last index). - let mut idx = total.saturating_sub(core::cmp::min(cursor, total)); + let mut idx = total.saturating_sub(cursor); while examined < limit && idx > 0 { let position = idx - 1; if let Some((_, market_id)) = index.get(position) { @@ -744,9 +772,6 @@ impl EventArchive { } } - // Advance the cursor by the number of archive entries examined so the - // caller can page. When a page examined nothing we are at the end (or the - // archive is empty); pin the cursor to signal completion. let next_cursor = if examined == 0 { cursor } else { @@ -761,8 +786,6 @@ impl EventArchive { /// index and market records are consistent). fn history_entry_for_archived(env: &Env, market_id: &Symbol) -> Option { let market: Market = env.storage().persistent().get(market_id)?; - // `created_at` comes from the market ID registry when available; fall back - // to `end_time` for legacy/synthetic IDs without a registry entry. let created_at = MarketIdGenerator::get_registry_entry(env, market_id) .map(|entry| entry.timestamp) .unwrap_or(market.end_time); @@ -779,7 +802,12 @@ impl EventArchive { cursor: u32, limit: u32, ) -> (Vec, u32) { - let limit = core::cmp::min(limit, MAX_QUERY_LIMIT); + let total = MarketIdGenerator::get_market_id_registry_len(env); + if cursor >= total { + return (Vec::new(env), total); + } + + let limit = sanitize_limit(limit); let registry_page = MarketIdGenerator::get_market_id_registry(env, cursor, limit); let mut result = Vec::new(env); let mut scanned = 0u32; @@ -792,7 +820,6 @@ impl EventArchive { .persistent() .get::(&entry.market_id) { - // Match against dedicated category field if set, otherwise oracle feed_id let market_category = market .category .clone() @@ -809,7 +836,8 @@ impl EventArchive { } } - (result, cursor + scanned) + let next_cursor = core::cmp::min(cursor.saturating_add(scanned), total); + (result, next_cursor) } /// Query events by tags (paginated, bounded). @@ -822,7 +850,12 @@ impl EventArchive { cursor: u32, limit: u32, ) -> (Vec, u32) { - let limit = core::cmp::min(limit, MAX_QUERY_LIMIT); + let total = MarketIdGenerator::get_market_id_registry_len(env); + if cursor >= total { + return (Vec::new(env), total); + } + + let limit = sanitize_limit(limit); let registry_page = MarketIdGenerator::get_market_id_registry(env, cursor, limit); let mut result = Vec::new(env); let mut scanned = 0u32; @@ -839,7 +872,6 @@ impl EventArchive { .persistent() .get::(&entry.market_id) { - // Check if any of the market's tags match any of the query tags let mut matched = false; for j in 0..market.tags.len() { if let Some(market_tag) = market.tags.get(j) { @@ -868,7 +900,8 @@ impl EventArchive { } } - (result, cursor + scanned) + let next_cursor = core::cmp::min(cursor.saturating_add(scanned), total); + (result, next_cursor) } } diff --git a/contracts/predictify-hybrid/src/event_topic_compat_tests.rs b/contracts/predictify-hybrid/src/event_topic_compat_tests.rs index b460c3f8..ef02c259 100644 --- a/contracts/predictify-hybrid/src/event_topic_compat_tests.rs +++ b/contracts/predictify-hybrid/src/event_topic_compat_tests.rs @@ -25,6 +25,8 @@ #![cfg(test)] +extern crate alloc; +use alloc::format; use soroban_sdk::{symbol_short, testutils::Events, Env, Symbol, Vec}; use crate::event_topic_compat::{ diff --git a/contracts/predictify-hybrid/src/lib.rs b/contracts/predictify-hybrid/src/lib.rs index b82600ba..918cef0c 100644 --- a/contracts/predictify-hybrid/src/lib.rs +++ b/contracts/predictify-hybrid/src/lib.rs @@ -393,8 +393,7 @@ impl PredictifyHybrid { // Seed permissive-but-valid rate limits so admin entrypoints do not // fail before a custom policy is configured. - match crate::rate_limiter::RateLimiter::new(env.clone()).init_rate_limiter( - admin.clone(), + match crate::rate_limiter::RateLimiter::new(env.clone()).set_config_internal( crate::rate_limiter::RateLimitConfig { voting_limit: 10_000, dispute_limit: 1_000, diff --git a/contracts/predictify-hybrid/src/market_id_generator.rs b/contracts/predictify-hybrid/src/market_id_generator.rs index 37541219..a1145178 100644 --- a/contracts/predictify-hybrid/src/market_id_generator.rs +++ b/contracts/predictify-hybrid/src/market_id_generator.rs @@ -190,6 +190,17 @@ impl MarketIdGenerator { Err(Error::InvalidInput) } + /// Return the total number of entries in the market ID registry. + pub fn get_market_id_registry_len(env: &Env) -> u32 { + let key = Symbol::new(env, Self::REGISTRY_KEY); + let registry: Vec = env + .storage() + .persistent() + .get(&key) + .unwrap_or(Vec::new(env)); + registry.len() + } + /// Return a paginated slice of the market ID registry. pub fn get_market_id_registry(env: &Env, start: u32, limit: u32) -> Vec { let key = Symbol::new(env, Self::REGISTRY_KEY); @@ -199,8 +210,13 @@ impl MarketIdGenerator { .get(&key) .unwrap_or(Vec::new(env)); + let total = registry.len(); + if start >= total { + return Vec::new(env); + } + let mut result = Vec::new(env); - let end = core::cmp::min(start + limit, registry.len()); + let end = core::cmp::min(start.saturating_add(limit), total); for i in start..end { if let Some(entry) = registry.get(i) { result.push_back(entry); diff --git a/contracts/predictify-hybrid/src/queries.rs b/contracts/predictify-hybrid/src/queries.rs index 97fb9a14..f683009f 100644 --- a/contracts/predictify-hybrid/src/queries.rs +++ b/contracts/predictify-hybrid/src/queries.rs @@ -395,19 +395,30 @@ impl QueryManager { cursor: u32, limit: u32, ) -> Result { - let limit = core::cmp::min(limit, MAX_PAGE_SIZE); + let limit = if limit == 0 { + MAX_PAGE_SIZE + } else { + core::cmp::min(limit, MAX_PAGE_SIZE) + }; let all = Self::get_all_markets(env)?; let total_count = all.len(); + if cursor >= total_count { + return Ok(PagedMarketIds { + items: vec![env], + next_cursor: total_count, + total_count, + }); + } let mut items: Vec = vec![env]; - let end = core::cmp::min(cursor + limit, total_count); + let end = core::cmp::min(cursor.saturating_add(limit), total_count); for i in cursor..end { if let Some(id) = all.get(i) { items.push_back(id); } } - let next_cursor = cursor + items.len(); + let next_cursor = core::cmp::min(cursor.saturating_add(items.len()), total_count); Ok(PagedMarketIds { items, next_cursor, @@ -580,12 +591,23 @@ impl QueryManager { cursor: u32, limit: u32, ) -> Result { - let limit = core::cmp::min(limit, MAX_PAGE_SIZE); + let limit = if limit == 0 { + MAX_PAGE_SIZE + } else { + core::cmp::min(limit, MAX_PAGE_SIZE) + }; let all_markets = Self::get_all_markets(env)?; let total_count = all_markets.len(); + if cursor >= total_count { + return Ok(PagedUserBets { + items: vec![env], + next_cursor: total_count, + total_count, + }); + } let mut items: Vec = vec![env]; - let end = core::cmp::min(cursor + limit, total_count); + let end = core::cmp::min(cursor.saturating_add(limit), total_count); for i in cursor..end { if let Some(market_id) = all_markets.get(i) { if let Ok(bet) = Self::query_user_bet(env, user.clone(), market_id) { @@ -594,7 +616,7 @@ impl QueryManager { } } - let next_cursor = core::cmp::min(cursor + limit, total_count); + let next_cursor = core::cmp::min(cursor.saturating_add(limit), total_count); Ok(PagedUserBets { items, next_cursor, @@ -779,15 +801,33 @@ impl QueryManager { cursor: u32, limit: u32, ) -> Result<(ContractStateQuery, u32), Error> { - let limit = core::cmp::min(limit, MAX_PAGE_SIZE); + let limit = if limit == 0 { + MAX_PAGE_SIZE + } else { + core::cmp::min(limit, MAX_PAGE_SIZE) + }; let all_markets = Self::get_all_markets(env)?; let total_markets = all_markets.len(); + if cursor >= total_markets { + let state = ContractStateQuery { + total_markets, + active_markets: 0, + resolved_markets: 0, + total_value_locked: 0, + total_fees_collected: 0i128, + unique_users: 0u32, + contract_version: String::from_str(env, "1.0.0"), + last_update: env.ledger().timestamp(), + }; + return Ok((state, total_markets)); + } + let mut active_markets = 0u32; let mut resolved_markets = 0u32; let mut total_value_locked = 0i128; - let end = core::cmp::min(cursor + limit, total_markets); + let end = core::cmp::min(cursor.saturating_add(limit), total_markets); for i in cursor..end { if let Some(market_id) = all_markets.get(i) { if let Ok(market) = Self::get_market_from_storage(env, &market_id) { @@ -801,7 +841,7 @@ impl QueryManager { } } - let next_cursor = core::cmp::min(cursor + limit, total_markets); + let next_cursor = core::cmp::min(cursor.saturating_add(limit), total_markets); let state = ContractStateQuery { total_markets, active_markets, @@ -1139,7 +1179,11 @@ impl QueryManager { env: &Env, limit: u32, ) -> Result, Error> { - let limit = core::cmp::min(limit, MAX_PAGE_SIZE); + let limit = if limit == 0 { + MAX_PAGE_SIZE + } else { + core::cmp::min(limit, MAX_PAGE_SIZE) + }; Ok(crate::leaderboard::LeaderboardHeap::top_by_winnings(env, limit)) } @@ -1167,7 +1211,11 @@ impl QueryManager { limit: u32, _min_bets: u64, ) -> Result, Error> { - let limit = core::cmp::min(limit, MAX_PAGE_SIZE); + let limit = if limit == 0 { + MAX_PAGE_SIZE + } else { + core::cmp::min(limit, MAX_PAGE_SIZE) + }; Ok(crate::leaderboard::LeaderboardHeap::top_by_win_rate(env, limit)) } @@ -1389,4 +1437,34 @@ mod tests { assert!(pool.is_ok()); assert_eq!(pool.unwrap(), 125); } + + #[test] + fn test_pagination_bounds_and_cursor_safety() { + let env = Env::default(); + let contract_id = env.register(crate::PredictifyHybrid, ()); + let user = Address::generate(&env); + + env.as_contract(&contract_id, || { + let paged_markets = QueryManager::get_all_markets_paged(&env, 10, 0).unwrap(); + assert_eq!(paged_markets.items.len(), 0); + assert_eq!(paged_markets.next_cursor, 0); + assert_eq!(paged_markets.total_count, 0); + + let paged_bets = QueryManager::query_user_bets_paged(&env, user, 10, 0).unwrap(); + assert_eq!(paged_bets.items.len(), 0); + assert_eq!(paged_bets.next_cursor, 0); + assert_eq!(paged_bets.total_count, 0); + + let (state, next_cursor) = QueryManager::query_contract_state_paged(&env, 10, 0).unwrap(); + assert_eq!(state.total_markets, 0); + assert_eq!(state.active_markets, 0); + assert_eq!(next_cursor, 0); + + let top_winners = QueryManager::get_top_users_by_winnings(&env, 0).unwrap(); + assert_eq!(top_winners.len(), 0); + + let top_win_rate = QueryManager::get_top_users_by_win_rate(&env, 0, 0).unwrap(); + assert_eq!(top_win_rate.len(), 0); + }); + } } diff --git a/contracts/predictify-hybrid/src/rate_limiter.rs b/contracts/predictify-hybrid/src/rate_limiter.rs index 03d7b2ce..f912d8d1 100644 --- a/contracts/predictify-hybrid/src/rate_limiter.rs +++ b/contracts/predictify-hybrid/src/rate_limiter.rs @@ -92,13 +92,7 @@ impl RateLimiter { RateLimiter { env } } - // Initialize rate limiter with default configuration - pub fn init_rate_limiter( - &self, - admin: Address, - config: RateLimitConfig, - ) -> Result<(), RateLimiterError> { - admin.require_auth(); + pub fn set_config_internal(&self, config: RateLimitConfig) -> Result<(), RateLimiterError> { self.validate_rate_limit_configuration(&config)?; self.env .storage() @@ -108,6 +102,16 @@ impl RateLimiter { Ok(()) } + // Initialize rate limiter with default configuration + pub fn init_rate_limiter( + &self, + admin: Address, + config: RateLimitConfig, + ) -> Result<(), RateLimiterError> { + admin.require_auth(); + self.set_config_internal(config) + } + // Get current configuration fn get_config(&self) -> Result { self.env diff --git a/contracts/predictify-hybrid/src/types.rs b/contracts/predictify-hybrid/src/types.rs index 9a6626a5..a3e0e443 100644 --- a/contracts/predictify-hybrid/src/types.rs +++ b/contracts/predictify-hybrid/src/types.rs @@ -4079,7 +4079,7 @@ impl Bet { /// println!("Unique bettors: {}", stats.unique_bettors); /// ``` #[contracttype] -#[derive(Clone, Debug)] +#[derive(Clone, Debug, PartialEq)] pub struct BetStats { /// Total number of bets placed on this market pub total_bets: u32, diff --git a/contracts/predictify-hybrid/src/validation.rs b/contracts/predictify-hybrid/src/validation.rs index 6c50d534..b119976c 100644 --- a/contracts/predictify-hybrid/src/validation.rs +++ b/contracts/predictify-hybrid/src/validation.rs @@ -5704,7 +5704,7 @@ impl ContractInitializationValidator { .map_err(|_| Error::InvalidDuration)?; // Oracle configuration must be internally consistent before storage. - OracleValidator::validate_oracle_config_all_together(oracle_config) + OracleConfigValidator::validate_oracle_config_all_together(oracle_config) .map_err(|_| Error::InvalidOracleConfig)?; Ok(()) diff --git a/contracts/predictify-hybrid/tests/archive_discoverability.rs b/contracts/predictify-hybrid/tests/archive_discoverability.rs index 1a5a5fff..67855df9 100644 --- a/contracts/predictify-hybrid/tests/archive_discoverability.rs +++ b/contracts/predictify-hybrid/tests/archive_discoverability.rs @@ -67,7 +67,7 @@ impl Harness { } } - fn client(&self) -> PredictifyHybridClient { + fn client(&self) -> PredictifyHybridClient<'_> { PredictifyHybridClient::new(&self.env, &self.contract_id) } @@ -223,14 +223,12 @@ fn pruning_is_deterministic_and_capacity_bounded() { let h = Harness::new(); let client = h.client(); - // Archive two markets. - let a = h.create_resolved_archived("Prune Alpha"); + let _a = h.create_resolved_archived("Prune Alpha"); h.advance_days(1); let b = h.create_resolved_archived("Prune Beta"); assert_eq!(client.archive_size(), 2); - // Prune the oldest 1 entry: A (archived first) must go, B remains. let removed = match client.try_prune_archive(&h.admin, &1, &None) { Ok(Ok((n, _))) => n, _ => panic!("prune_archive failed"), @@ -243,4 +241,50 @@ fn pruning_is_deterministic_and_capacity_bounded() { let mut expected = StdVec::new(); expected.push(b.clone()); assert_eq!(ids, expected); +} + +#[test] +fn archive_queries_are_bounded_and_safely_paginated() { + let h = Harness::new(); + let client = h.client(); + + let _a = h.create_resolved_archived("Bounded Alpha"); + h.advance_days(1); + let _b = h.create_resolved_archived("Bounded Beta"); + + assert_eq!(client.archive_size(), 2); + + let (empty_archived, next_arch_cursor) = client.query_archived_events(&false, &100, &10); + assert_eq!(empty_archived.len(), 0); + assert_eq!(next_arch_cursor, 2); + + let (default_page, _) = client.query_archived_events(&false, &0, &0); + assert_eq!(default_page.len(), 2); + + let (capped_page, _) = client.query_archived_events(&false, &0, &100); + assert_eq!(capped_page.len(), 2); + + let (empty_history, hist_cursor) = client.query_events_history(&0, &u64::MAX, &100, &10); + assert_eq!(empty_history.len(), 0); + assert_eq!(hist_cursor, 2); + + let (default_history, _) = client.query_events_history(&0, &u64::MAX, &0, &0); + assert_eq!(default_history.len(), 2); + + let (empty_status, status_cursor) = client.query_events_by_status(&predictify_hybrid::types::MarketState::Resolved, &100, &10); + assert_eq!(empty_status.len(), 0); + assert_eq!(status_cursor, 2); + + let (default_status, _) = client.query_events_by_status(&predictify_hybrid::types::MarketState::Resolved, &0, &0); + assert_eq!(default_status.len(), 2); + + let (empty_cat, cat_cursor) = client.query_events_by_category(&SorobanString::from_str(&h.env, "general"), &100, &10); + assert_eq!(empty_cat.len(), 0); + assert_eq!(cat_cursor, 2); + + let mut query_tags = soroban_sdk::Vec::new(&h.env); + query_tags.push_back(SorobanString::from_str(&h.env, "crypto")); + let (empty_tags, tags_cursor) = client.query_events_by_tags(&query_tags, &100, &10); + assert_eq!(empty_tags.len(), 0); + assert_eq!(tags_cursor, 2); } \ No newline at end of file