From 215ab512a0199ab13c64f403e715f7b4483637e9 Mon Sep 17 00:00:00 2001 From: Awointa Date: Thu, 23 Jul 2026 21:51:03 +0100 Subject: [PATCH] Add per-market maximum bet cap functionality - Introduced functions to set, remove, and retrieve the per-market max single-bet cap. - Updated error handling to include BetExceedsCap for exceeding the cap. - Enhanced bet validation to check against the per-market cap. --- contracts/predictify-hybrid/src/bets.rs | 79 +++++++++++++++++++++-- contracts/predictify-hybrid/src/err.rs | 14 +++++ contracts/predictify-hybrid/src/lib.rs | 84 +++++++++++++++++++++++++ 3 files changed, 172 insertions(+), 5 deletions(-) diff --git a/contracts/predictify-hybrid/src/bets.rs b/contracts/predictify-hybrid/src/bets.rs index 9a14364a..b4199dc7 100644 --- a/contracts/predictify-hybrid/src/bets.rs +++ b/contracts/predictify-hybrid/src/bets.rs @@ -55,6 +55,8 @@ fn guard_scope_unlock_funds() -> Symbol { const GLOBAL_BET_LIMITS_KEY: &str = "bet_limits_global"; /// Storage key for per-event bet limits map (Symbol -> BetLimits). const PER_EVENT_BET_LIMITS_KEY: &str = "bet_limits_evt"; +/// Storage key for per-market max single-bet cap map (Symbol -> i128). +const PER_MARKET_MAX_BET_CAP_KEY: &str = "max_bet_cap_mkt"; // ===== STORAGE KEY TYPES ===== @@ -130,6 +132,64 @@ pub fn set_event_bet_limits( Ok(()) } +/// Set a per-market max single-bet cap (admin only). +/// +/// Once set, any individual bet whose `amount` exceeds `cap` will be rejected +/// with [`Error::BetExceedsCap`]. The cap is independent of (and checked in +/// addition to) the global/per-event `max_bet` in [`BetLimits`]. +/// +/// # Parameters +/// +/// - `env` – Soroban environment +/// - `market_id` – Identifies the market +/// - `cap` – Maximum single-bet amount in base token units +/// +/// # Errors +/// +/// Returns [`Error::InvalidInput`] when: +/// - `cap` is zero or negative +/// - `cap` exceeds [`MAX_BET_AMOUNT`] +pub fn set_market_max_bet_cap(env: &Env, market_id: &Symbol, cap: i128) -> Result<(), Error> { + if cap <= 0 || cap > MAX_BET_AMOUNT { + return Err(Error::InvalidInput); + } + let key = Symbol::new(env, PER_MARKET_MAX_BET_CAP_KEY); + let mut caps: soroban_sdk::Map = env + .storage() + .persistent() + .get(&key) + .unwrap_or(soroban_sdk::Map::new(env)); + caps.set(market_id.clone(), cap); + env.storage().persistent().set(&key, &caps); + Ok(()) +} + +/// Remove the per-market max bet cap for a market (admin only). +/// +/// After removal, bets on this market are bounded only by the global/per-event +/// [`BetLimits`] max (or [`MAX_BET_AMOUNT`] when no limits are configured). +pub fn remove_market_max_bet_cap(env: &Env, market_id: &Symbol) { + let key = Symbol::new(env, PER_MARKET_MAX_BET_CAP_KEY); + let mut caps: soroban_sdk::Map = env + .storage() + .persistent() + .get(&key) + .unwrap_or(soroban_sdk::Map::new(env)); + caps.remove(market_id.clone()); + env.storage().persistent().set(&key, &caps); +} + +/// Get the per-market max bet cap, or `None` if no cap has been set. +pub fn get_market_max_bet_cap(env: &Env, market_id: &Symbol) -> Option { + let key = Symbol::new(env, PER_MARKET_MAX_BET_CAP_KEY); + let caps: soroban_sdk::Map = env + .storage() + .persistent() + .get(&key) + .unwrap_or(soroban_sdk::Map::new(env)); + caps.get(market_id.clone()) +} + /// Validate that min <= max and both are within absolute bounds. fn validate_limits_bounds(limits: &BetLimits) -> Result<(), Error> { if limits.min_bet > limits.max_bet { @@ -1099,6 +1159,7 @@ impl BetValidator { /// /// Uses effective bet limits (per-event if set, else global, else default min/max). /// Rejects bets below min with InsufficientStake, above max with InvalidInput. + /// Rejects bets exceeding the per-market cap with BetExceedsCap (when set). pub fn validate_bet_parameters( env: &Env, market_id: &Symbol, @@ -1110,23 +1171,31 @@ impl BetValidator { Self::validate_bet_amount_against_limits(env, market_id, amount) } - /// Validate bet amount against effective limits (per-event or global or defaults). + /// Validate bet amount against effective limits (per-event or global or defaults) + /// and the per-market max bet cap (when set). + /// + /// Checks in order: + /// 1. Amount >= effective `min_bet` (→ `InsufficientStake`) + /// 2. Amount <= effective `max_bet` (→ `InvalidInput`) + /// 3. Amount <= per-market cap when configured (→ `BetExceedsCap`) pub fn validate_bet_amount_against_limits( env: &Env, market_id: &Symbol, amount: i128, ) -> Result<(), Error> { let limits = get_effective_bet_limits(env, market_id); - // Temporarily disabled due to validation module being disabled - // validation::validate_bet_amount_against_limits(amount, &limits) - - // Simple validation for now if amount < limits.min_bet { return Err(Error::InsufficientStake); } if amount > limits.max_bet { return Err(Error::InvalidInput); } + // Check the per-market single-bet cap (most specific check, own error code). + if let Some(cap) = get_market_max_bet_cap(env, market_id) { + if amount > cap { + return Err(Error::BetExceedsCap); + } + } Ok(()) } diff --git a/contracts/predictify-hybrid/src/err.rs b/contracts/predictify-hybrid/src/err.rs index 29996cd1..ef4115e4 100644 --- a/contracts/predictify-hybrid/src/err.rs +++ b/contracts/predictify-hybrid/src/err.rs @@ -227,6 +227,12 @@ pub enum Error { /// The effective fee (in basis points) exceeds the maximum the caller is willing to accept. /// The bet is rejected to protect the caller from unexpected fee changes. FeeExceedsMax = 508, + /// The single-bet amount exceeds the per-market maximum bet cap. + /// + /// An admin can set a per-market cap via `set_market_max_bet_cap`. Once set, + /// any individual bet whose `amount` exceeds the cap will be rejected with this + /// error. Use `get_market_max_bet_cap` to query the current cap before placing. + BetExceedsCap = 509, /// No pending fee config commit was found for reveal or apply. NoPendingFeeCommit = 519, /// Fee config reveal was attempted too early (before timelock expiry). @@ -782,6 +788,7 @@ impl ErrorHandler { Error::AdminNotSet | Error::DisputeFeeFailed => RecoveryStrategy::ManualIntervention, Error::InvalidState | Error::InvalidOracleConfig => RecoveryStrategy::NoRecovery, Error::FeeExceedsMax => RecoveryStrategy::Retry, + Error::BetExceedsCap => RecoveryStrategy::NoRecovery, Error::OperationWouldExceedBudget => RecoveryStrategy::NoRecovery, _ => RecoveryStrategy::Abort, } @@ -1364,6 +1371,11 @@ impl ErrorHandler { ErrorCategory::Financial, RecoveryStrategy::Retry, ), + Error::BetExceedsCap => ( + ErrorSeverity::Low, + ErrorCategory::Financial, + RecoveryStrategy::NoRecovery, + ), Error::OperationWouldExceedBudget => ( ErrorSeverity::Critical, ErrorCategory::System, @@ -1507,6 +1519,7 @@ impl Error { Error::ExtensionDenied => "Market extension not allowed", Error::AdminNotSet => "Admin address not set", Error::FeeExceedsMax => "Fee is above the acceptable threshold", + Error::BetExceedsCap => "Bet amount exceeds the per-market maximum bet cap", Error::OracleStale => "Oracle data is stale", Error::OracleNoConsensus => "Oracle consensus not reached", Error::OracleVerified => "Oracle result already verified", @@ -1618,6 +1631,7 @@ impl Error { Error::ExtensionDenied => "EXTENSION_DENIED", Error::AdminNotSet => "ADMIN_NOT_SET", Error::FeeExceedsMax => "FEE_ABOVE_ACCEPTABLE", + Error::BetExceedsCap => "BET_EXCEEDS_CAP", Error::OracleStale => "ORACLE_STALE", Error::OracleNoConsensus => "ORACLE_NO_CONSENSUS", Error::OracleVerified => "ORACLE_VERIFIED", diff --git a/contracts/predictify-hybrid/src/lib.rs b/contracts/predictify-hybrid/src/lib.rs index 95b3f779..5bafcd6e 100644 --- a/contracts/predictify-hybrid/src/lib.rs +++ b/contracts/predictify-hybrid/src/lib.rs @@ -3883,6 +3883,90 @@ impl PredictifyHybrid { crate::bets::get_effective_bet_limits(&env, &market_id) } + /// Set the per-market maximum single-bet cap (admin only). + /// + /// Once set, any individual bet whose `amount` exceeds `cap` is rejected with + /// [`Error::BetExceedsCap`]. The cap is checked after, and in addition to, the + /// global/per-event `max_bet` in [`BetLimits`]. + /// + /// Pass `cap = 0` to remove the cap (equivalent to calling + /// [`remove_market_max_bet_cap`]). Any other value must satisfy + /// `0 < cap <= MAX_BET_AMOUNT`. + /// + /// # Parameters + /// + /// - `admin` – Must be the primary admin address + /// - `market_id` – Identifies the target market + /// - `cap` – Maximum single-bet amount in base token units (stroops) + /// + /// # Errors + /// + /// - [`Error::Unauthorized`] when `admin` is not the primary admin + /// - [`Error::InvalidInput`] when `cap` is negative or exceeds [`MAX_BET_AMOUNT`] + /// + /// # Events + /// + /// Emits a `bet_limits_updated` event scoped to `market_id` so that indexers + /// can track cap changes. + pub fn set_market_max_bet_cap( + env: Env, + admin: Address, + market_id: Symbol, + cap: i128, + ) -> Result<(), Error> { + Self::require_primary_admin(&env, &admin)?; + // cap == 0 is treated as "remove the cap" + if cap == 0 { + crate::bets::remove_market_max_bet_cap(&env, &market_id); + } else { + crate::bets::set_market_max_bet_cap(&env, &market_id, cap)?; + } + // Emit so indexers can observe + EventEmitter::emit_bet_limits_updated(&env, &admin, &market_id, 0, cap); + + crate::audit_trail::AuditTrailManager::append_record( + &env, + crate::audit_trail::AuditAction::BetLimitsUpdated, + admin.clone(), + Map::new(&env), + None, + ); + + Ok(()) + } + + /// Get the per-market max single-bet cap, or `None` if no cap is configured. + /// + /// # Events + /// + /// State-changing paths may emit events through internal managers; read-only query paths emit no events. + pub fn get_market_max_bet_cap(env: Env, market_id: Symbol) -> Option { + crate::bets::get_market_max_bet_cap(&env, &market_id) + } + + /// Remove the per-market max single-bet cap (admin only). + /// + /// After removal, bets on this market are bounded only by the global/per-event + /// [`BetLimits`] `max_bet` (or [`MAX_BET_AMOUNT`] when no limits are configured). + /// + /// # Errors + /// + /// - [`Error::Unauthorized`] when `admin` is not the primary admin + /// + /// # Events + /// + /// Emits a `bet_limits_updated` event with `max_bet = 0` to signal removal. + pub fn remove_market_max_bet_cap( + env: Env, + admin: Address, + market_id: Symbol, + ) -> Result<(), Error> { + Self::require_primary_admin(&env, &admin)?; + crate::bets::remove_market_max_bet_cap(&env, &market_id); + EventEmitter::emit_bet_limits_updated(&env, &admin, &market_id, 0, 0); + Ok(()) + } + /// Set global oracle validation config (admin only). /// /// - `max_staleness_secs`: maximum allowed age in seconds.