Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 74 additions & 5 deletions contracts/predictify-hybrid/src/bets.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 =====

Expand Down Expand Up @@ -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<Symbol, i128> = 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<Symbol, i128> = 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<i128> {
let key = Symbol::new(env, PER_MARKET_MAX_BET_CAP_KEY);
let caps: soroban_sdk::Map<Symbol, i128> = 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 {
Expand Down Expand Up @@ -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,
Expand All @@ -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(())
}

Expand Down
14 changes: 14 additions & 0 deletions contracts/predictify-hybrid/src/err.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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,
}
Expand Down Expand Up @@ -1364,6 +1371,11 @@ impl ErrorHandler {
ErrorCategory::Financial,
RecoveryStrategy::Retry,
),
Error::BetExceedsCap => (
ErrorSeverity::Low,
ErrorCategory::Financial,
RecoveryStrategy::NoRecovery,
),
Error::OperationWouldExceedBudget => (
ErrorSeverity::Critical,
ErrorCategory::System,
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
84 changes: 84 additions & 0 deletions contracts/predictify-hybrid/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<i128> {
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.
Expand Down
Loading