diff --git a/contracts/predictify-hybrid/src/bet_tests.rs b/contracts/predictify-hybrid/src/bet_tests.rs index 991816cc..870c1ae5 100644 --- a/contracts/predictify-hybrid/src/bet_tests.rs +++ b/contracts/predictify-hybrid/src/bet_tests.rs @@ -1212,3 +1212,320 @@ fn test_get_live_fee_percentage() { } + + +// ===== MAX BET CAP TESTS ===== + +/// Test setting and getting the per-user max bet cap +#[test] +fn test_set_max_bet_cap() { + let env = Env::default(); + let admin = Address::generate(&env); + + env.mock_all_auths(); + env.as_contract(&admin, || { + let cap = 50_000_000_000i128; // 50 XLM + let result = BetValidator::set_max_bet_cap(&env, &admin, cap); + assert!(result.is_ok()); + + // Verify cap was stored + let retrieved = BetValidator::get_max_bet_cap(&env); + assert_eq!(retrieved, Some(cap)); + }); +} + +/// Test that invalid cap (zero or negative) is rejected +#[test] +fn test_set_max_bet_cap_invalid() { + let env = Env::default(); + let admin = Address::generate(&env); + + env.mock_all_auths(); + env.as_contract(&admin, || { + // Test zero cap + let result = BetValidator::set_max_bet_cap(&env, &admin, 0); + assert_eq!(result, Err(Error::InvalidCap)); + + // Test negative cap + let result = BetValidator::set_max_bet_cap(&env, &admin, -1); + assert_eq!(result, Err(Error::InvalidCap)); + }); +} + +/// Test that non-admin cannot set max bet cap +#[test] +fn test_set_max_bet_cap_unauthorized() { + let env = Env::default(); + let admin = Address::generate(&env); + let non_admin = Address::generate(&env); + + // Set admin first + env.mock_all_auths(); + env.as_contract(&admin, || { + crate::admin::AdminManager::initialize(&env, &admin).ok(); + }); + + // Try to set cap as non-admin (should fail on auth check) + let cap = 50_000_000_000i128; + let result = BetValidator::set_max_bet_cap(&env, &non_admin, cap); + // This will fail due to require_auth or admin check + assert!(result.is_err()); +} + +/// Test that user stake is tracked correctly +#[test] +fn test_user_stake_tracking() { + let env = Env::default(); + let market_id = Symbol::new(&env, "BTC_50K"); + let user = Address::generate(&env); + + // Initial stake should be 0 + let initial = BetValidator::get_user_stake(&env, &market_id, &user); + assert_eq!(initial, 0); + + // Update stake + let amount = 10_000_000i128; + let result = BetValidator::update_user_stake(&env, &market_id, &user, amount); + assert!(result.is_ok()); + + // Verify stake was updated + let updated = BetValidator::get_user_stake(&env, &market_id, &user); + assert_eq!(updated, amount); + + // Update again + let result = BetValidator::update_user_stake(&env, &market_id, &user, amount); + assert!(result.is_ok()); + + let final_stake = BetValidator::get_user_stake(&env, &market_id, &user); + assert_eq!(final_stake, amount * 2); +} + +/// Test that stake overflow is properly detected +#[test] +fn test_user_stake_overflow() { + let env = Env::default(); + let market_id = Symbol::new(&env, "BTC_50K"); + let user = Address::generate(&env); + + // Set stake to near i128::MAX + let near_max = i128::MAX - 100; + let _ = BetValidator::update_user_stake(&env, &market_id, &user, near_max); + + // Try to add more (should overflow) + let result = BetValidator::update_user_stake(&env, &market_id, &user, 200); + assert_eq!(result, Err(Error::Overflow)); +} + +/// Test single bet within cap succeeds +#[test] +fn test_single_bet_within_cap() { + let env = Env::default(); + let market_id = Symbol::new(&env, "BTC_50K"); + let user = Address::generate(&env); + + // Set cap to 100 XLM + let cap = 100_000_000_000i128; + env.mock_all_auths(); + env.as_contract(&user, || { + BetValidator::set_max_bet_cap(&env, &user, cap).ok(); + }); + + // Bet with 50 XLM (within cap) + let amount = 50_000_000_000i128; + let result = BetValidator::validate_user_stake_under_cap(&env, &market_id, &user, amount); + assert!(result.is_ok()); +} + +/// Test bet exactly at cap succeeds +#[test] +fn test_bet_exactly_at_cap() { + let env = Env::default(); + let market_id = Symbol::new(&env, "BTC_50K"); + let user = Address::generate(&env); + + // Set cap to 100 XLM + let cap = 100_000_000_000i128; + env.mock_all_auths(); + env.as_contract(&user, || { + BetValidator::set_max_bet_cap(&env, &user, cap).ok(); + }); + + // Bet with exactly 100 XLM (at cap boundary, should succeed) + let amount = 100_000_000_000i128; + let result = BetValidator::validate_user_stake_under_cap(&env, &market_id, &user, amount); + assert!(result.is_ok()); +} + +/// Test bet one unit over cap fails +#[test] +fn test_bet_one_unit_over_cap() { + let env = Env::default(); + let market_id = Symbol::new(&env, "BTC_50K"); + let user = Address::generate(&env); + + // Set cap to 100 XLM + let cap = 100_000_000_000i128; + env.mock_all_auths(); + env.as_contract(&user, || { + BetValidator::set_max_bet_cap(&env, &user, cap).ok(); + }); + + // Bet with 100 XLM + 1 stroop (over cap, should fail) + let amount = 100_000_000_001i128; + let result = BetValidator::validate_user_stake_under_cap(&env, &market_id, &user, amount); + assert_eq!(result, Err(Error::MaxBetCapExceeded)); +} + +/// Test bet with no cap set succeeds (uncapped) +#[test] +fn test_bet_with_no_cap_set_uncapped() { + let env = Env::default(); + let market_id = Symbol::new(&env, "BTC_50K"); + let user = Address::generate(&env); + + // Do NOT set a cap (uncapped by default) + let cap = BetValidator::get_max_bet_cap(&env); + assert_eq!(cap, None); + + // Very large bet should succeed (no cap to limit it) + let amount = 1_000_000_000_000i128; + let result = BetValidator::validate_user_stake_under_cap(&env, &market_id, &user, amount); + assert!(result.is_ok()); +} + +/// Test two bets that individually fit but together exceed cap +#[test] +fn test_cumulative_bets_exceed_cap() { + let env = Env::default(); + let market_id = Symbol::new(&env, "BTC_50K"); + let user = Address::generate(&env); + + // Set cap to 100 XLM + let cap = 100_000_000_000i128; + env.mock_all_auths(); + env.as_contract(&user, || { + BetValidator::set_max_bet_cap(&env, &user, cap).ok(); + }); + + // First bet: 60 XLM + let bet1 = 60_000_000_000i128; + let result1 = BetValidator::validate_user_stake_under_cap(&env, &market_id, &user, bet1); + assert!(result1.is_ok()); + + // Simulate first bet being recorded + let _ = BetValidator::update_user_stake(&env, &market_id, &user, bet1); + + // Second bet: 50 XLM (total would be 110 XLM, over cap of 100) + let bet2 = 50_000_000_000i128; + let result2 = BetValidator::validate_user_stake_under_cap(&env, &market_id, &user, bet2); + assert_eq!(result2, Err(Error::MaxBetCapExceeded)); +} + +/// Test two bets that together exactly hit cap both succeed +#[test] +fn test_cumulative_bets_exactly_hit_cap() { + let env = Env::default(); + let market_id = Symbol::new(&env, "BTC_50K"); + let user = Address::generate(&env); + + // Set cap to 100 XLM + let cap = 100_000_000_000i128; + env.mock_all_auths(); + env.as_contract(&user, || { + BetValidator::set_max_bet_cap(&env, &user, cap).ok(); + }); + + // First bet: 60 XLM + let bet1 = 60_000_000_000i128; + let result1 = BetValidator::validate_user_stake_under_cap(&env, &market_id, &user, bet1); + assert!(result1.is_ok()); + + // Record first bet + let _ = BetValidator::update_user_stake(&env, &market_id, &user, bet1); + + // Second bet: 40 XLM (total = 100 XLM, exactly at cap, should succeed) + let bet2 = 40_000_000_000i128; + let result2 = BetValidator::validate_user_stake_under_cap(&env, &market_id, &user, bet2); + assert!(result2.is_ok()); +} + +/// Test each user's cap is tracked independently on the same market +#[test] +fn test_independent_per_user_cap_same_market() { + let env = Env::default(); + let market_id = Symbol::new(&env, "BTC_50K"); + let user_a = Address::generate(&env); + let user_b = Address::generate(&env); + + // Set cap to 100 XLM + let cap = 100_000_000_000i128; + env.mock_all_auths(); + env.as_contract(&user_a, || { + BetValidator::set_max_bet_cap(&env, &user_a, cap).ok(); + }); + + // User A bets 100 XLM (hits cap) + let bet_a = 100_000_000_000i128; + let result_a = BetValidator::validate_user_stake_under_cap(&env, &market_id, &user_a, bet_a); + assert!(result_a.is_ok()); + let _ = BetValidator::update_user_stake(&env, &market_id, &user_a, bet_a); + + // User A tries to bet more (should fail) + let result_a2 = BetValidator::validate_user_stake_under_cap(&env, &market_id, &user_a, 1); + assert_eq!(result_a2, Err(Error::MaxBetCapExceeded)); + + // User B bets 100 XLM on same market (should succeed - independent cap) + let bet_b = 100_000_000_000i128; + let result_b = BetValidator::validate_user_stake_under_cap(&env, &market_id, &user_b, bet_b); + assert!(result_b.is_ok()); +} + +/// Test same user's cap is independent across different markets +#[test] +fn test_independent_per_market_cap_same_user() { + let env = Env::default(); + let market_a = Symbol::new(&env, "BTC_50K"); + let market_b = Symbol::new(&env, "ETH_3K"); + let user = Address::generate(&env); + + // Set cap to 100 XLM + let cap = 100_000_000_000i128; + env.mock_all_auths(); + env.as_contract(&user, || { + BetValidator::set_max_bet_cap(&env, &user, cap).ok(); + }); + + // User bets 100 XLM on market A (hits cap for that market) + let bet_a = 100_000_000_000i128; + let result_a = BetValidator::validate_user_stake_under_cap(&env, &market_a, &user, bet_a); + assert!(result_a.is_ok()); + let _ = BetValidator::update_user_stake(&env, &market_a, &user, bet_a); + + // User tries to bet more on market A (should fail - at cap) + let result_a2 = BetValidator::validate_user_stake_under_cap(&env, &market_a, &user, 1); + assert_eq!(result_a2, Err(Error::MaxBetCapExceeded)); + + // User bets 100 XLM on market B (should succeed - independent market cap) + let bet_b = 100_000_000_000i128; + let result_b = BetValidator::validate_user_stake_under_cap(&env, &market_b, &user, bet_b); + assert!(result_b.is_ok()); +} + +/// Test that amount = 0 fails (should be caught by earlier validation) +#[test] +fn test_bet_zero_amount() { + let env = Env::default(); + let market_id = Symbol::new(&env, "BTC_50K"); + let user = Address::generate(&env); + + // Set cap + let cap = 100_000_000_000i128; + env.mock_all_auths(); + env.as_contract(&user, || { + BetValidator::set_max_bet_cap(&env, &user, cap).ok(); + }); + + // Amount 0 should still pass cap validation (cap check runs after amount validation) + let result = BetValidator::validate_user_stake_under_cap(&env, &market_id, &user, 0); + assert!(result.is_ok()); +} diff --git a/contracts/predictify-hybrid/src/bets.rs b/contracts/predictify-hybrid/src/bets.rs index 9a14364a..74b2b7c8 100644 --- a/contracts/predictify-hybrid/src/bets.rs +++ b/contracts/predictify-hybrid/src/bets.rs @@ -336,6 +336,10 @@ impl BetManager { } } + // ===== PER-USER MAX BET CAP CHECK (BEFORE funds are locked) ===== + // Load current user stake and validate it won't exceed the cap + BetValidator::validate_user_stake_under_cap(env, &market_id, &user, amount)?; + // Lock funds (transfer from user to contract) BetUtils::lock_funds(env, &user, amount)?; @@ -351,6 +355,9 @@ impl BetManager { // Store bet BetStorage::store_bet(env, &bet)?; + // Update user stake for per-user max bet cap tracking + BetValidator::update_user_stake(env, &market_id, &user, amount)?; + // Update market betting stats Self::update_market_bet_stats(env, &market_id, &outcome, amount)?; @@ -1171,6 +1178,153 @@ impl BetValidator { Ok(()) } + + /// Get the global per-user max bet cap across all markets. + /// + /// # Parameters + /// + /// - `env` - The Soroban environment + /// + /// # Returns + /// + /// Returns `Some(cap)` if a cap is set, `None` if uncapped (no limit). + pub fn get_max_bet_cap(env: &Env) -> Option { + let key = crate::storage::DataKey::MaxBetCap; + env.storage().persistent().get::<_, i128>(&key) + } + + /// Set the global per-user max bet cap (admin only). + /// + /// # Parameters + /// + /// - `env` - The Soroban environment + /// - `caller` - Address of the caller (must be admin) + /// - `cap` - The new cap value (must be positive) + /// + /// # Errors + /// + /// - `Error::Unauthorized` - Caller is not the admin + /// - `Error::InvalidCap` - Cap is zero or negative + /// + /// # Returns + /// + /// Returns `Ok(())` on success. + pub fn set_max_bet_cap(env: &Env, caller: &Address, cap: i128) -> Result<(), Error> { + // Require admin authentication + caller.require_auth(); + + // Verify caller is admin + let admin = crate::admin::AdminManager::get_admin(env)?; + if *caller != admin { + return Err(Error::Unauthorized); + } + + // Validate cap is positive + if cap <= 0 { + return Err(Error::InvalidCap); + } + + // Store the cap in persistent storage + let key = crate::storage::DataKey::MaxBetCap; + env.storage().persistent().set(&key, &cap); + env.storage().persistent().extend_ttl(&key, crate::storage::MARKET_TTL_LEDGERS, crate::storage::MARKET_TTL_LEDGERS); + + // Emit event + crate::events::EventEmitter::emit_max_bet_cap_set(env, cap); + + Ok(()) + } + + /// Get the current cumulative stake for a user on a specific market. + /// + /// # Parameters + /// + /// - `env` - The Soroban environment + /// - `market_id` - The market ID + /// - `user` - The user address + /// + /// # Returns + /// + /// Returns the cumulative amount the user has bet on this market (0 if no prior bets). + pub fn get_user_stake(env: &Env, market_id: &Symbol, user: &Address) -> i128 { + let key = crate::storage::DataKey::UserStake(market_id.clone(), user.clone()); + env.storage() + .persistent() + .get::<_, i128>(&key) + .unwrap_or(0) + } + + /// Update the cumulative stake for a user on a specific market. + /// + /// # Parameters + /// + /// - `env` - The Soroban environment + /// - `market_id` - The market ID + /// - `user` - The user address + /// - `amount` - The amount to add to the cumulative stake + /// + /// # Errors + /// + /// - `Error::Overflow` - The new stake would overflow i128 + /// + /// # Returns + /// + /// Returns `Ok(())` on success. + pub fn update_user_stake(env: &Env, market_id: &Symbol, user: &Address, amount: i128) -> Result<(), Error> { + let current_stake = Self::get_user_stake(env, market_id, user); + let new_stake = current_stake + .checked_add(amount) + .ok_or(Error::Overflow)?; + + let key = crate::storage::DataKey::UserStake(market_id.clone(), user.clone()); + env.storage().persistent().set(&key, &new_stake); + // Extend TTL to match market (365 days) + env.storage().persistent().extend_ttl(&key, crate::storage::MARKET_TTL_LEDGERS, crate::storage::MARKET_TTL_LEDGERS); + + Ok(()) + } + + /// Validate that a user's new bet would not exceed the per-user max bet cap. + /// + /// This function checks if the user's cumulative stake on a market (including the new bet) + /// would exceed the global per-user max bet cap. If a cap is not set, this always succeeds (uncapped). + /// + /// # Parameters + /// + /// - `env` - The Soroban environment + /// - `market_id` - The market ID + /// - `user` - The user address + /// - `amount` - The amount of the new bet + /// + /// # Errors + /// + /// - `Error::MaxBetCapExceeded` - The new cumulative stake would exceed the cap + /// - `Error::Overflow` - Arithmetic overflow during checked_add + /// + /// # Returns + /// + /// Returns `Ok(())` if the bet is allowed, `Err(Error)` otherwise. + /// Cap check applies to cumulative stake per market, not globally across markets. + pub fn validate_user_stake_under_cap( + env: &Env, + market_id: &Symbol, + user: &Address, + amount: i128, + ) -> Result<(), Error> { + // Check if a cap is set; if not, no validation needed (uncapped) + if let Some(cap) = Self::get_max_bet_cap(env) { + let current_stake = Self::get_user_stake(env, market_id, user); + let new_total = current_stake + .checked_add(amount) + .ok_or(Error::Overflow)?; + + if new_total > cap { + return Err(Error::MaxBetCapExceeded); + } + } + + Ok(()) + } } // ===== BET UTILITIES ===== diff --git a/contracts/predictify-hybrid/src/err.rs b/contracts/predictify-hybrid/src/err.rs index 29996cd1..ec0ea042 100644 --- a/contracts/predictify-hybrid/src/err.rs +++ b/contracts/predictify-hybrid/src/err.rs @@ -245,6 +245,12 @@ pub enum Error { ReplayedOverride = 526, /// Oracle quote is an outlier relative to the rolling median history. OracleQuoteOutlier = 527, + /// User's cumulative stake on this market would exceed the per-user max bet cap. + MaxBetCapExceeded = 528, + /// The max bet cap value is invalid (must be positive). + InvalidCap = 529, + /// Arithmetic overflow occurred during checked operation (e.g., checked_add on i128). + Overflow = 530, } // ===== ERROR CATEGORIZATION AND RECOVERY SYSTEM ===== diff --git a/contracts/predictify-hybrid/src/events.rs b/contracts/predictify-hybrid/src/events.rs index e0daa171..2e0fa75e 100644 --- a/contracts/predictify-hybrid/src/events.rs +++ b/contracts/predictify-hybrid/src/events.rs @@ -324,6 +324,42 @@ pub struct BetStatusUpdatedEvent { pub timestamp: u64, } +/// Event emitted when the per-user max bet cap is set by an admin. +/// +/// This event provides transparency about bet cap enforcement rules. The cap applies +/// to the cumulative amount any single user can bet on a given market, not globally +/// across all markets. +/// +/// # Cap Semantics +/// +/// - Cap is per-user, per-market (e.g., user A and user B each have their own limit on market X) +/// - Different users on the same market are tracked independently +/// - A user hitting the cap on market A does not affect their betting on market B +/// - When a cap is not set, betting is uncapped +/// +/// # Example Usage +/// +/// ```rust +/// # use soroban_sdk::{Env, Symbol}; +/// # use predictify_hybrid::events::MaxBetCapSetEvent; +/// # let env = Env::default(); +/// +/// // Max bet cap set to 100 XLM (100,000,000,000 stroops) +/// let event = MaxBetCapSetEvent { +/// cap: 100_000_000_000, +/// timestamp: env.ledger().timestamp(), +/// }; +/// ``` +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MaxBetCapSetEvent { + /// The per-user max bet cap (in stroops). + /// Applies to cumulative stake per user per market. + pub cap: i128, + /// Event timestamp + pub timestamp: u64, +} + /// Event emitted when oracle data is successfully fetched for market resolution. /// /// This event captures comprehensive oracle data retrieval information, including @@ -2867,6 +2903,22 @@ impl EventEmitter { .publish((symbol_short!("bet_lim"), scope.clone()), event); } + /// Emit max bet cap set event. + /// + /// # Parameters + /// + /// - `env` - The Soroban environment + /// - `cap` - The per-user max bet cap in stroops + pub fn emit_max_bet_cap_set(env: &Env, cap: i128) { + let event = MaxBetCapSetEvent { + cap, + timestamp: env.ledger().timestamp(), + }; + Self::store_event(env, &symbol_short!("max_bet_cap"), &event); + env.events() + .publish((symbol_short!("max_bet_cap"),), event); + } + /// Emit error logged event pub fn emit_error_logged( env: &Env, diff --git a/contracts/predictify-hybrid/src/storage.rs b/contracts/predictify-hybrid/src/storage.rs index 589cb3c1..e462b462 100644 --- a/contracts/predictify-hybrid/src/storage.rs +++ b/contracts/predictify-hybrid/src/storage.rs @@ -87,6 +87,12 @@ pub enum DataKey { MarketCache(Symbol), /// Nonce for admin override replay protection. AdminOverrideNonce(Address), + /// Per-user cumulative stake on a specific market (Symbol market_id, Address user) -> i128 cumulative_amount + /// Tracks the total amount a user has bet on a market across all bets, enforcing per-user max bet cap. + UserStake(Symbol, Address), + /// Global per-user max bet cap across all markets (i128). + /// When set, no single user can have cumulative bets exceeding this amount on any given market. + MaxBetCap, } /// Storage format version for migration tracking