From fb787d1720a2c188380909b1c8bb90a4c0ee0cbe Mon Sep 17 00:00:00 2001 From: ponmileleke54-dev Date: Mon, 31 Aug 2026 10:24:05 +0000 Subject: [PATCH] Hardening: exposure caps cover commit+precision without bypass --- contracts/src/betting.rs | 90 ++++++++++++++++++++++------------ contracts/src/tests/betting.rs | 25 +++++++++- 2 files changed, 84 insertions(+), 31 deletions(-) diff --git a/contracts/src/betting.rs b/contracts/src/betting.rs index f919dbe0..77f2eff4 100644 --- a/contracts/src/betting.rs +++ b/contracts/src/betting.rs @@ -56,6 +56,63 @@ fn salt_has_minimum_entropy(salt: &BytesN<32>) -> bool { saw_nonzero && saw_different } +fn _enforce_round_user_exposure( + env: &Env, + user: &Address, + round: &Round, + incoming_amount: i128, +) -> Result<(), ContractError> { + let Some(max_exposure) = env + .storage() + .persistent() + .get::<_, i128>(&DataKeyCore::MaxUserRoundExposure) + else { + return Ok(()); + }; + + let mut existing_exposure: i128 = 0; + + if let Some(position) = env + .storage() + .persistent() + .get::<_, UserPosition>(&DataKeyScoped::Position(round.round_id, user.clone())) + { + existing_exposure = existing_exposure + .checked_add(position.amount) + .ok_or(ContractError::Overflow)?; + } + + if let Some(prediction) = env + .storage() + .persistent() + .get::<_, PrecisionPrediction>(&DataKeyScoped::PrecisionPosition(round.round_id, user.clone())) + { + existing_exposure = existing_exposure + .checked_add(prediction.amount) + .ok_or(ContractError::Overflow)?; + } + + if let Some(commitment) = env + .storage() + .persistent() + .get::<_, PrecisionCommitment>(&DataKeyScoped::PrecisionCommitment(round.round_id, user.clone())) + { + existing_exposure = existing_exposure + .checked_add(commitment.amount) + .ok_or(ContractError::Overflow)?; + } + + let next_exposure = existing_exposure + .checked_add(incoming_amount) + .ok_or(ContractError::Overflow)?; + + if next_exposure > max_exposure { + return Err(ContractError::ExposureCapExceeded); + } + + Ok(()) +} + /// Creates a new prediction round (admin only) pub fn create_round(env: Env, start_price: u128, mode: Option) -> Result<(), ContractError> { _require_supported_schema(&env)?; @@ -275,16 +332,7 @@ pub fn place_bet( .get(&DataKeyCore::ActiveRound) .ok_or(ContractError::NoActiveRound)?; - // Enforce per-user round exposure cap - if let Some(max_exposure) = env - .storage() - .persistent() - .get::<_, i128>(&DataKeyCore::MaxUserRoundExposure) - { - if amount > max_exposure { - return Err(ContractError::ExposureCapExceeded); - } - } + _enforce_round_user_exposure(&env, &user, &round, amount)?; // Verify round is in Up/Down mode if round.mode != RoundMode::UpDown { @@ -411,16 +459,7 @@ pub fn place_precision_prediction( .get(&DataKeyCore::ActiveRound) .ok_or(ContractError::NoActiveRound)?; - // Enforce per-user round exposure cap - if let Some(max_exposure) = env - .storage() - .persistent() - .get::<_, i128>(&DataKeyCore::MaxUserRoundExposure) - { - if amount > max_exposure { - return Err(ContractError::ExposureCapExceeded); - } - } + _enforce_round_user_exposure(&env, &user, &round, amount)?; // Verify round is in Precision mode if round.mode != RoundMode::Precision { @@ -540,16 +579,7 @@ pub fn commit_prediction( .get(&DataKeyCore::ActiveRound) .ok_or(ContractError::NoActiveRound)?; - // Enforce per-user round exposure cap - if let Some(max_exposure) = env - .storage() - .persistent() - .get::<_, i128>(&DataKeyCore::MaxUserRoundExposure) - { - if amount > max_exposure { - return Err(ContractError::ExposureCapExceeded); - } - } + _enforce_round_user_exposure(&env, &user, &round, amount)?; // Verify round is in Precision mode if round.mode != RoundMode::Precision { diff --git a/contracts/src/tests/betting.rs b/contracts/src/tests/betting.rs index 642b51f0..c49a12a1 100644 --- a/contracts/src/tests/betting.rs +++ b/contracts/src/tests/betting.rs @@ -8,7 +8,7 @@ use crate::types::BetSide; use soroban_sdk::{ symbol_short, testutils::{Address as _, Events, Ledger as _}, - Address, Env, TryIntoVal, + Address, BytesN, Env, TryIntoVal, }; #[test] @@ -363,6 +363,29 @@ fn test_exposure_cap_at_boundary_succeeds() { assert_eq!(client.balance(&user), 900_0000000); } +#[test] +fn test_exposure_cap_counts_existing_precision_commitment() { + let env = Env::default(); + let contract_id = env.register(VirtualTokenContract, ()); + let client = VirtualTokenContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + let oracle = Address::generate(&env); + let user = Address::generate(&env); + + env.mock_all_auths(); + client.initialize(&admin, &oracle); + client.update_oracle_heartbeat(&0u32); + client.mint_initial(&user); + apply_max_user_exposure(&env, &client, Some(100_0000000i128)); + client.create_round(&1_0000000, &Some(1)); + + client.commit_prediction(&user, &BytesN::from_array(&env, &[7; 32]), &60_0000000); + let result = client.try_place_precision_prediction(&user, &41_0000000, &2297u128); + assert_eq!(result, Err(Ok(ContractError::ExposureCapExceeded))); + assert_eq!(client.balance(&user), 40_0000000); +} + #[test] fn test_get_max_stake_returns_configured_value() { let env = Env::default();