diff --git a/contract/contracts/predifi-contract/src/lib.rs b/contract/contracts/predifi-contract/src/lib.rs index b91eb613..ae8430a0 100644 --- a/contract/contracts/predifi-contract/src/lib.rs +++ b/contract/contracts/predifi-contract/src/lib.rs @@ -1523,6 +1523,66 @@ impl PredifiContract { } } + /// Validate stake limit modifications to ensure consistency and safety. + /// This function performs comprehensive checks before applying new stake limits to a pool. + /// + /// # Validation Checks: + /// 1. min_stake must be positive (> 0) + /// 2. If max_stake is set (> 0), it must not be less than min_stake + /// 3. New min_stake must not exceed the pool's total_stake (existing liability check) + /// 4. New min_stake must not exceed the pool's max_total_stake limit (future capacity check) + /// 5. If max_stake is set, verify it allows room for at least one prediction at max level + /// 6. Prevent sudden reduction that would violate existing predictions (if applicable) + /// + /// # Returns: + /// - Ok(()) if all validation checks pass + /// - Err(PredifiError::StakeBelowMinimum) if min_stake <= 0 + /// - Err(PredifiError::StakeAboveMaximum) if constraints are violated + /// - Err(PredifiError::InvalidAmount) if amounts are invalid + fn validate_stake_limits( + env: &Env, + pool: &Pool, + new_min_stake: i128, + new_max_stake: i128, + ) -> Result<(), PredifiError> { + // Check 1: min_stake must be positive + if new_min_stake <= 0 { + return Err(PredifiError::StakeBelowMinimum); + } + + // Check 2: If max_stake is set, ensure min_stake <= max_stake + if new_max_stake > 0 && new_min_stake > new_max_stake { + return Err(PredifiError::InvalidAmount); + } + + // Check 3: Ensure new min_stake doesn't exceed current total_stake + // This prevents setting a minimum that would retroactively invalidate existing bets + if new_min_stake > pool.total_stake { + return Err(PredifiError::StakeAboveMaximum); + } + + // Check 4: If pool has a max_total_stake limit, new min_stake should be reasonable + if pool.max_total_stake > 0 && new_min_stake > pool.max_total_stake { + return Err(PredifiError::StakeAboveMaximum); + } + + // Check 5: If max_stake is set, ensure it's reasonable relative to pool capacity + if new_max_stake > 0 && pool.max_total_stake > 0 && new_max_stake > pool.max_total_stake { + return Err(PredifiError::StakeAboveMaximum); + } + + // Check 6: Prevent extreme ratio between min and max (prevent usability issues) + if new_max_stake > 0 { + // Ensure max is at least 10x min to allow reasonable participation range + let min_reasonable_ratio = new_min_stake.checked_mul(10).ok_or(PredifiError::ArithmeticError)?; + if new_max_stake < min_reasonable_ratio { + return Err(PredifiError::InvalidAmount); + } + } + + Ok(()) + } + /// Pure: Initialize outcome stakes vector with zeros. /// Used for markets with many outcomes (e.g., 32+ teams tournament). #[allow(dead_code)] @@ -1534,6 +1594,7 @@ impl PredifiContract { stakes } + /// Get outcome stakes for a pool using optimized batch storage. /// Falls back to individual storage keys for backward compatibility. fn get_outcome_stakes(env: &Env, pool_id: u64, options_count: u32) -> Vec { @@ -4299,12 +4360,8 @@ pub fn pause(env: Env, admin: Address) { return Err(PredifiError::InvalidPoolState); } - if min_stake <= 0 { - return Err(PredifiError::StakeBelowMinimum); - } - if max_stake > 0 && min_stake > max_stake { - return Err(PredifiError::InvalidAmount); - } + // Validate new stake limits before applying + Self::validate_stake_limits(&env, &pool, min_stake, max_stake)?; pool.min_stake = min_stake; pool.max_stake = max_stake; diff --git a/contract/contracts/predifi-contract/src/test.rs b/contract/contracts/predifi-contract/src/test.rs index e35d0ea7..ab770453 100644 --- a/contract/contracts/predifi-contract/src/test.rs +++ b/contract/contracts/predifi-contract/src/test.rs @@ -13129,3 +13129,387 @@ fn test_multiple_predictions_with_token_validation() { let predictions = client.get_user_predictions(&user, &0u32, &10u32); assert_eq!(predictions.len(), 2); } + + +// ============================================================================ +// TESTS FOR STAKE LIMIT MODIFICATION SAFETY CHECKS +// ============================================================================ + +#[test] +fn test_set_stake_limits_zero_min_stake_fails() { + let env = Env::default(); + env.mock_all_auths(); + + let (ac_client, client, token_address, _, token_admin_client, _, operator, creator) = + setup(&env); + + let pool_id = client.create_pool( + &creator, + &(env.ledger().timestamp() + 3600), + &token_address, + &2u32, + &symbol_short!("Sports"), + &PoolConfig { + start_time: 0, + description: String::from_str(&env, "Test Pool"), + metadata_url: String::from_str(&env, "ipfs://test"), + min_stake: 100i128, + max_stake: 1000i128, + max_total_stake: 100000i128, + min_total_stake: 1, + initial_liquidity: 0i128, + required_resolutions: 1u32, + private: false, + whitelist_key: None, + outcome_descriptions: vec![ + &env, + String::from_str(&env, "Outcome 0"), + String::from_str(&env, "Outcome 1"), + ], + }, + ); + + // Try to set min_stake to 0 - should fail + let result = client.try_set_stake_limits(&operator, &pool_id, &0i128, &1000i128); + assert!(result.is_err()); +} + +#[test] +fn test_set_stake_limits_negative_min_stake_fails() { + let env = Env::default(); + env.mock_all_auths(); + + let (ac_client, client, token_address, _, token_admin_client, _, operator, creator) = + setup(&env); + + let pool_id = client.create_pool( + &creator, + &(env.ledger().timestamp() + 3600), + &token_address, + &2u32, + &symbol_short!("Sports"), + &PoolConfig { + start_time: 0, + description: String::from_str(&env, "Test Pool"), + metadata_url: String::from_str(&env, "ipfs://test"), + min_stake: 100i128, + max_stake: 1000i128, + max_total_stake: 100000i128, + min_total_stake: 1, + initial_liquidity: 0i128, + required_resolutions: 1u32, + private: false, + whitelist_key: None, + outcome_descriptions: vec![ + &env, + String::from_str(&env, "Outcome 0"), + String::from_str(&env, "Outcome 1"), + ], + }, + ); + + // Try to set min_stake to negative - should fail + let result = client.try_set_stake_limits(&operator, &pool_id, &-100i128, &1000i128); + assert!(result.is_err()); +} + +#[test] +fn test_set_stake_limits_min_exceeds_max_fails() { + let env = Env::default(); + env.mock_all_auths(); + + let (ac_client, client, token_address, _, token_admin_client, _, operator, creator) = + setup(&env); + + let pool_id = client.create_pool( + &creator, + &(env.ledger().timestamp() + 3600), + &token_address, + &2u32, + &symbol_short!("Sports"), + &PoolConfig { + start_time: 0, + description: String::from_str(&env, "Test Pool"), + metadata_url: String::from_str(&env, "ipfs://test"), + min_stake: 100i128, + max_stake: 1000i128, + max_total_stake: 100000i128, + min_total_stake: 1, + initial_liquidity: 0i128, + required_resolutions: 1u32, + private: false, + whitelist_key: None, + outcome_descriptions: vec![ + &env, + String::from_str(&env, "Outcome 0"), + String::from_str(&env, "Outcome 1"), + ], + }, + ); + + // Try to set min_stake > max_stake - should fail + let result = client.try_set_stake_limits(&operator, &pool_id, &2000i128, &1000i128); + assert!(result.is_err()); +} + +#[test] +fn test_set_stake_limits_min_exceeds_total_stake_fails() { + let env = Env::default(); + env.mock_all_auths(); + + let (ac_client, client, token_address, _, token_admin_client, _, operator, creator) = + setup(&env); + + let user = Address::generate(&env); + token_admin_client.mint(&user, &10000); + + let pool_id = client.create_pool( + &creator, + &(env.ledger().timestamp() + 3600), + &token_address, + &2u32, + &symbol_short!("Sports"), + &PoolConfig { + start_time: 0, + description: String::from_str(&env, "Test Pool"), + metadata_url: String::from_str(&env, "ipfs://test"), + min_stake: 100i128, + max_stake: 0i128, + max_total_stake: 100000i128, + min_total_stake: 1, + initial_liquidity: 0i128, + required_resolutions: 1u32, + private: false, + whitelist_key: None, + outcome_descriptions: vec![ + &env, + String::from_str(&env, "Outcome 0"), + String::from_str(&env, "Outcome 1"), + ], + }, + ); + + // User places prediction with 500 tokens + client.place_prediction(&user, &pool_id, &500i128, &0u32, &None, &None); + + // Try to set min_stake > total_stake (500) - should fail + let result = client.try_set_stake_limits(&operator, &pool_id, &600i128, &0i128); + assert!(result.is_err()); +} + +#[test] +fn test_set_stake_limits_successful_update() { + let env = Env::default(); + env.mock_all_auths(); + + let (ac_client, client, token_address, _, token_admin_client, _, operator, creator) = + setup(&env); + + let pool_id = client.create_pool( + &creator, + &(env.ledger().timestamp() + 3600), + &token_address, + &2u32, + &symbol_short!("Sports"), + &PoolConfig { + start_time: 0, + description: String::from_str(&env, "Test Pool"), + metadata_url: String::from_str(&env, "ipfs://test"), + min_stake: 100i128, + max_stake: 1000i128, + max_total_stake: 100000i128, + min_total_stake: 1, + initial_liquidity: 0i128, + required_resolutions: 1u32, + private: false, + whitelist_key: None, + outcome_descriptions: vec![ + &env, + String::from_str(&env, "Outcome 0"), + String::from_str(&env, "Outcome 1"), + ], + }, + ); + + // Successfully update stake limits + let result = client.try_set_stake_limits(&operator, &pool_id, &200i128, &2000i128); + assert!(result.is_ok()); + + // Verify limits were updated + let pool = client.get_pool(&pool_id); + assert_eq!(pool.min_stake, 200i128); + assert_eq!(pool.max_stake, 2000i128); +} + +#[test] +fn test_set_stake_limits_no_max_stake_limit() { + let env = Env::default(); + env.mock_all_auths(); + + let (ac_client, client, token_address, _, token_admin_client, _, operator, creator) = + setup(&env); + + let pool_id = client.create_pool( + &creator, + &(env.ledger().timestamp() + 3600), + &token_address, + &2u32, + &symbol_short!("Sports"), + &PoolConfig { + start_time: 0, + description: String::from_str(&env, "Test Pool"), + metadata_url: String::from_str(&env, "ipfs://test"), + min_stake: 100i128, + max_stake: 0i128, + max_total_stake: 0i128, + min_total_stake: 1, + initial_liquidity: 0i128, + required_resolutions: 1u32, + private: false, + whitelist_key: None, + outcome_descriptions: vec![ + &env, + String::from_str(&env, "Outcome 0"), + String::from_str(&env, "Outcome 1"), + ], + }, + ); + + // Set min_stake with no max_stake (0) - should succeed + let result = client.try_set_stake_limits(&operator, &pool_id, &150i128, &0i128); + assert!(result.is_ok()); + + let pool = client.get_pool(&pool_id); + assert_eq!(pool.min_stake, 150i128); + assert_eq!(pool.max_stake, 0i128); +} + +#[test] +fn test_set_stake_limits_ratio_validation() { + let env = Env::default(); + env.mock_all_auths(); + + let (ac_client, client, token_address, _, token_admin_client, _, operator, creator) = + setup(&env); + + let pool_id = client.create_pool( + &creator, + &(env.ledger().timestamp() + 3600), + &token_address, + &2u32, + &symbol_short!("Sports"), + &PoolConfig { + start_time: 0, + description: String::from_str(&env, "Test Pool"), + metadata_url: String::from_str(&env, "ipfs://test"), + min_stake: 100i128, + max_stake: 1000i128, + max_total_stake: 100000i128, + min_total_stake: 1, + initial_liquidity: 0i128, + required_resolutions: 1u32, + private: false, + whitelist_key: None, + outcome_descriptions: vec![ + &env, + String::from_str(&env, "Outcome 0"), + String::from_str(&env, "Outcome 1"), + ], + }, + ); + + // Try to set max_stake < min_stake * 10 (extreme ratio) - should fail + let result = client.try_set_stake_limits(&operator, &pool_id, &100i128, &500i128); + assert!(result.is_err()); + + // Set with reasonable ratio (10x min) - should succeed + let result = client.try_set_stake_limits(&operator, &pool_id, &100i128, &1000i128); + assert!(result.is_ok()); +} + +#[test] +fn test_set_stake_limits_on_inactive_pool_fails() { + let env = Env::default(); + env.mock_all_auths(); + + let (ac_client, client, token_address, _, token_admin_client, _, operator, creator) = + setup(&env); + + let admin = Address::generate(&env); + ac_client.grant_role(&admin, &ROLE_ADMIN); + + let end_time = env.ledger().timestamp() + 3600; + let pool_id = client.create_pool( + &creator, + &end_time, + &token_address, + &2u32, + &symbol_short!("Sports"), + &PoolConfig { + start_time: 0, + description: String::from_str(&env, "Test Pool"), + metadata_url: String::from_str(&env, "ipfs://test"), + min_stake: 100i128, + max_stake: 1000i128, + max_total_stake: 100000i128, + min_total_stake: 1, + initial_liquidity: 0i128, + required_resolutions: 1u32, + private: false, + whitelist_key: None, + outcome_descriptions: vec![ + &env, + String::from_str(&env, "Outcome 0"), + String::from_str(&env, "Outcome 1"), + ], + }, + ); + + // Move time forward and resolve pool + env.ledger().set_timestamp(end_time + 1); + client.resolve_pool(&operator, &pool_id, &0u32); + + // Try to set stake limits on resolved pool - should fail + let result = client.try_set_stake_limits(&operator, &pool_id, &200i128, &2000i128); + assert!(result.is_err()); +} + +#[test] +fn test_set_stake_limits_requires_operator_role() { + let env = Env::default(); + env.mock_all_auths(); + + let (ac_client, client, token_address, _, _, _, operator, creator) = setup(&env); + + let unauthorized_user = Address::generate(&env); + + let pool_id = client.create_pool( + &creator, + &(env.ledger().timestamp() + 3600), + &token_address, + &2u32, + &symbol_short!("Sports"), + &PoolConfig { + start_time: 0, + description: String::from_str(&env, "Test Pool"), + metadata_url: String::from_str(&env, "ipfs://test"), + min_stake: 100i128, + max_stake: 1000i128, + max_total_stake: 100000i128, + min_total_stake: 1, + initial_liquidity: 0i128, + required_resolutions: 1u32, + private: false, + whitelist_key: None, + outcome_descriptions: vec![ + &env, + String::from_str(&env, "Outcome 0"), + String::from_str(&env, "Outcome 1"), + ], + }, + ); + + // Try to set stake limits without operator role - should fail + let result = client.try_set_stake_limits(&unauthorized_user, &pool_id, &200i128, &2000i128); + assert!(result.is_err()); +}