Skip to content

Commit 509f8f9

Browse files
Merge pull request Predictify-org#278 from Jagadeeshftw/min-max
feat: implement minimum and maximum bet limits per event
2 parents 9f453bd + 5220223 commit 509f8f9

File tree

6 files changed

+360
-42
lines changed

6 files changed

+360
-42
lines changed

contracts/predictify-hybrid/src/bet_tests.rs

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -793,3 +793,158 @@ fn test_bet_equality() {
793793
assert_eq!(bet1.amount, bet2.amount);
794794
assert_eq!(bet1.status, bet2.status);
795795
}
796+
797+
// ===== BET LIMITS TESTS =====
798+
799+
#[test]
800+
fn test_set_global_bet_limits_and_place_bet_exactly_min_max() {
801+
let setup = BetTestSetup::new();
802+
let client = setup.client();
803+
let min = 5_000000i128; // 0.5 XLM
804+
let max = 50_000000i128; // 5 XLM
805+
806+
setup.env.mock_all_auths();
807+
client.set_global_bet_limits(&setup.admin, &min, &max);
808+
809+
// Exactly min: must succeed
810+
let bet_min = client.place_bet(
811+
&setup.user,
812+
&setup.market_id,
813+
&String::from_str(&setup.env, "yes"),
814+
&min,
815+
);
816+
assert_eq!(bet_min.amount, min);
817+
818+
// Exactly max: need second user (first already bet)
819+
let stellar_client = StellarAssetClient::new(&setup.env, &setup.token_id);
820+
stellar_client.mint(&setup.user2, &max);
821+
setup.env.mock_all_auths();
822+
let bet_max = client.place_bet(
823+
&setup.user2,
824+
&setup.market_id,
825+
&String::from_str(&setup.env, "no"),
826+
&max,
827+
);
828+
assert_eq!(bet_max.amount, max);
829+
}
830+
831+
#[test]
832+
#[should_panic]
833+
fn test_place_bet_below_configured_min_rejects() {
834+
let setup = BetTestSetup::new();
835+
let client = setup.client();
836+
let min = 10_000000i128;
837+
let max = 100_000000i128;
838+
839+
setup.env.mock_all_auths();
840+
client.set_global_bet_limits(&setup.admin, &min, &max);
841+
842+
setup.env.mock_all_auths();
843+
client.place_bet(
844+
&setup.user,
845+
&setup.market_id,
846+
&String::from_str(&setup.env, "yes"),
847+
&(min - 1),
848+
);
849+
}
850+
851+
#[test]
852+
#[should_panic]
853+
fn test_place_bet_above_configured_max_rejects() {
854+
let setup = BetTestSetup::new();
855+
let client = setup.client();
856+
let min = 1_000000i128;
857+
let max = 20_000000i128;
858+
859+
setup.env.mock_all_auths();
860+
client.set_global_bet_limits(&setup.admin, &min, &max);
861+
862+
setup.env.mock_all_auths();
863+
client.place_bet(
864+
&setup.user,
865+
&setup.market_id,
866+
&String::from_str(&setup.env, "yes"),
867+
&(max + 1),
868+
);
869+
}
870+
871+
#[test]
872+
fn test_set_event_bet_limits_overrides_global() {
873+
let setup = BetTestSetup::new();
874+
let client = setup.client();
875+
let global_min = 1_000000i128;
876+
let global_max = 100_000000i128;
877+
let event_min = 15_000000i128;
878+
let event_max = 25_000000i128;
879+
880+
setup.env.mock_all_auths();
881+
client.set_global_bet_limits(&setup.admin, &global_min, &global_max);
882+
setup.env.mock_all_auths();
883+
client.set_event_bet_limits(&setup.admin, &setup.market_id, &event_min, &event_max);
884+
885+
// Exactly event min must succeed (below event min tested in separate should_panic test)
886+
setup.env.mock_all_auths();
887+
let bet = client.place_bet(
888+
&setup.user,
889+
&setup.market_id,
890+
&String::from_str(&setup.env, "yes"),
891+
&event_min,
892+
);
893+
assert_eq!(bet.amount, event_min);
894+
}
895+
896+
#[test]
897+
#[should_panic]
898+
fn test_place_bet_below_event_min_rejects() {
899+
let setup = BetTestSetup::new();
900+
let client = setup.client();
901+
let event_min = 15_000000i128;
902+
let event_max = 25_000000i128;
903+
904+
setup.env.mock_all_auths();
905+
client.set_event_bet_limits(&setup.admin, &setup.market_id, &event_min, &event_max);
906+
907+
setup.env.mock_all_auths();
908+
client.place_bet(
909+
&setup.user,
910+
&setup.market_id,
911+
&String::from_str(&setup.env, "yes"),
912+
&(event_min - 1),
913+
);
914+
}
915+
916+
#[test]
917+
#[should_panic]
918+
fn test_set_global_bet_limits_unauthorized() {
919+
let setup = BetTestSetup::new();
920+
let client = setup.client();
921+
setup.env.mock_all_auths();
922+
client.set_global_bet_limits(&setup.user, &MIN_BET_AMOUNT, &MAX_BET_AMOUNT);
923+
}
924+
925+
#[test]
926+
#[should_panic]
927+
fn test_set_global_bet_limits_min_above_max_rejects() {
928+
let setup = BetTestSetup::new();
929+
let client = setup.client();
930+
setup.env.mock_all_auths();
931+
client.set_global_bet_limits(&setup.admin, &10_000000i128, &5_000000i128);
932+
}
933+
934+
#[test]
935+
#[should_panic]
936+
fn test_set_global_bet_limits_below_absolute_min_rejects() {
937+
let setup = BetTestSetup::new();
938+
let client = setup.client();
939+
setup.env.mock_all_auths();
940+
client.set_global_bet_limits(&setup.admin, &(MIN_BET_AMOUNT - 1), &MAX_BET_AMOUNT);
941+
}
942+
943+
#[test]
944+
#[should_panic]
945+
fn test_set_global_bet_limits_above_absolute_max_rejects() {
946+
let setup = BetTestSetup::new();
947+
let client = setup.client();
948+
setup.env.mock_all_auths();
949+
client.set_global_bet_limits(&setup.admin, &MIN_BET_AMOUNT, &(MAX_BET_AMOUNT + 1));
950+
}

contracts/predictify-hybrid/src/bets.rs

Lines changed: 84 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -24,16 +24,22 @@ use soroban_sdk::{contracttype, symbol_short, Address, Env, Map, String, Symbol}
2424
use crate::errors::Error;
2525
use crate::events::EventEmitter;
2626
use crate::markets::{MarketStateManager, MarketUtils, MarketValidator};
27-
use crate::types::{Bet, BetStats, BetStatus, Market, MarketState};
27+
use crate::types::{Bet, BetLimits, BetStats, BetStatus, Market, MarketState};
28+
use crate::validation;
2829

2930
// ===== CONSTANTS =====
3031

31-
/// Minimum bet amount (0.1 XLM = 1,000,000 stroops)
32+
/// Minimum bet amount (0.1 XLM = 1,000,000 stroops). Absolute floor for any configured limit.
3233
pub const MIN_BET_AMOUNT: i128 = 1_000_000;
3334

34-
/// Maximum bet amount (10,000 XLM = 100,000,000,000 stroops)
35+
/// Maximum bet amount (10,000 XLM = 100,000,000,000 stroops). Absolute ceiling for any configured limit.
3536
pub const MAX_BET_AMOUNT: i128 = 100_000_000_000;
3637

38+
/// Storage key for global bet limits.
39+
const GLOBAL_BET_LIMITS_KEY: &str = "bet_limits_global";
40+
/// Storage key for per-event bet limits map (Symbol -> BetLimits).
41+
const PER_EVENT_BET_LIMITS_KEY: &str = "bet_limits_evt";
42+
3743
// ===== STORAGE KEY TYPES =====
3844

3945
/// Storage key for user bets on a specific market
@@ -59,6 +65,65 @@ pub struct BetRegistryKey {
5965
pub market_id: Symbol,
6066
}
6167

68+
// ===== BET LIMITS STORAGE =====
69+
70+
/// Get effective bet limits for a market: per-event if set, else global, else default constants.
71+
pub fn get_effective_bet_limits(env: &Env, market_id: &Symbol) -> BetLimits {
72+
let key_evt = Symbol::new(env, PER_EVENT_BET_LIMITS_KEY);
73+
let per_event: soroban_sdk::Map<Symbol, BetLimits> = env
74+
.storage()
75+
.persistent()
76+
.get(&key_evt)
77+
.unwrap_or(soroban_sdk::Map::new(env));
78+
if let Some(limits) = per_event.get(market_id.clone()) {
79+
return limits;
80+
}
81+
let key_global = Symbol::new(env, GLOBAL_BET_LIMITS_KEY);
82+
env.storage()
83+
.persistent()
84+
.get::<Symbol, BetLimits>(&key_global)
85+
.unwrap_or(BetLimits {
86+
min_bet: MIN_BET_AMOUNT,
87+
max_bet: MAX_BET_AMOUNT,
88+
})
89+
}
90+
91+
/// Set global bet limits (admin only; validation of bounds done by caller).
92+
pub fn set_global_bet_limits(env: &Env, limits: &BetLimits) -> Result<(), Error> {
93+
validate_limits_bounds(limits)?;
94+
let key = Symbol::new(env, GLOBAL_BET_LIMITS_KEY);
95+
env.storage().persistent().set(&key, limits);
96+
Ok(())
97+
}
98+
99+
/// Set per-event bet limits (admin only; validation of bounds done by caller).
100+
pub fn set_event_bet_limits(env: &Env, market_id: &Symbol, limits: &BetLimits) -> Result<(), Error> {
101+
validate_limits_bounds(limits)?;
102+
let key = Symbol::new(env, PER_EVENT_BET_LIMITS_KEY);
103+
let mut per_event: soroban_sdk::Map<Symbol, BetLimits> = env
104+
.storage()
105+
.persistent()
106+
.get(&key)
107+
.unwrap_or(soroban_sdk::Map::new(env));
108+
per_event.set(market_id.clone(), limits.clone());
109+
env.storage().persistent().set(&key, &per_event);
110+
Ok(())
111+
}
112+
113+
/// Validate that min <= max and both are within absolute bounds.
114+
fn validate_limits_bounds(limits: &BetLimits) -> Result<(), Error> {
115+
if limits.min_bet > limits.max_bet {
116+
return Err(Error::InvalidInput);
117+
}
118+
if limits.min_bet < MIN_BET_AMOUNT {
119+
return Err(Error::InsufficientStake);
120+
}
121+
if limits.max_bet > MAX_BET_AMOUNT {
122+
return Err(Error::InvalidInput);
123+
}
124+
Ok(())
125+
}
126+
62127
// ===== BET MANAGER =====
63128

64129
/// Comprehensive bet manager for prediction market betting operations.
@@ -181,8 +246,8 @@ impl BetManager {
181246
let mut market = MarketStateManager::get_market(env, &market_id)?;
182247
BetValidator::validate_market_for_betting(env, &market)?;
183248

184-
// Validate bet parameters
185-
BetValidator::validate_bet_parameters(env, &outcome, &market.outcomes, amount)?;
249+
// Validate bet parameters (uses configurable min/max limits per event or global)
250+
BetValidator::validate_bet_parameters(env, &market_id, &outcome, &market.outcomes, amount)?;
186251

187252
// Check if user has already bet on this market
188253
if Self::has_user_bet(env, &market_id, &user) {
@@ -599,59 +664,37 @@ impl BetValidator {
599664

600665
/// Validate bet parameters.
601666
///
602-
/// # Validation Rules
603-
///
604-
/// - Outcome must be one of the valid market outcomes
605-
/// - Amount must be within allowed range
606-
///
607-
/// # Parameters
608-
///
609-
/// - `env` - The Soroban environment
610-
/// - `outcome` - The selected outcome
611-
/// - `valid_outcomes` - List of valid outcomes for the market
612-
/// - `amount` - The bet amount
613-
///
614-
/// # Returns
615-
///
616-
/// Returns `Ok(())` if parameters are valid, `Err(Error)` otherwise.
667+
/// Uses effective bet limits (per-event if set, else global, else default min/max).
668+
/// Rejects bets below min with InsufficientStake, above max with InvalidInput.
617669
pub fn validate_bet_parameters(
618670
env: &Env,
671+
market_id: &Symbol,
619672
outcome: &String,
620673
valid_outcomes: &soroban_sdk::Vec<String>,
621674
amount: i128,
622675
) -> Result<(), Error> {
623-
// Validate outcome
624676
MarketValidator::validate_outcome(env, outcome, valid_outcomes)?;
677+
Self::validate_bet_amount_against_limits(env, market_id, amount)
678+
}
625679

626-
// Validate amount
627-
Self::validate_bet_amount(amount)?;
628-
629-
Ok(())
680+
/// Validate bet amount against effective limits (per-event or global or defaults).
681+
pub fn validate_bet_amount_against_limits(
682+
env: &Env,
683+
market_id: &Symbol,
684+
amount: i128,
685+
) -> Result<(), Error> {
686+
let limits = get_effective_bet_limits(env, market_id);
687+
validation::validate_bet_amount_against_limits(amount, &limits)
630688
}
631689

632-
/// Validate bet amount.
633-
///
634-
/// # Validation Rules
635-
///
636-
/// - Amount must be greater than or equal to MIN_BET_AMOUNT
637-
/// - Amount must be less than or equal to MAX_BET_AMOUNT
638-
///
639-
/// # Parameters
640-
///
641-
/// - `amount` - The bet amount to validate
642-
///
643-
/// # Returns
644-
///
645-
/// Returns `Ok(())` if amount is valid, `Err(Error)` otherwise.
690+
/// Validate bet amount using default constants (for tests / backward compatibility).
646691
pub fn validate_bet_amount(amount: i128) -> Result<(), Error> {
647692
if amount < MIN_BET_AMOUNT {
648693
return Err(Error::InsufficientStake);
649694
}
650-
651695
if amount > MAX_BET_AMOUNT {
652696
return Err(Error::InvalidInput);
653697
}
654-
655698
Ok(())
656699
}
657700
}

contracts/predictify-hybrid/src/events.rs

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -695,6 +695,22 @@ pub struct ConfigUpdatedEvent {
695695
pub timestamp: u64,
696696
}
697697

698+
/// Event emitted when bet limits are updated (global or per-event).
699+
#[contracttype]
700+
#[derive(Clone, Debug, Eq, PartialEq)]
701+
pub struct BetLimitsUpdatedEvent {
702+
/// Admin who updated the limits
703+
pub admin: Address,
704+
/// Market ID or "global" for global limits
705+
pub scope: Symbol,
706+
/// New minimum bet amount
707+
pub min_bet: i128,
708+
/// New maximum bet amount
709+
pub max_bet: i128,
710+
/// Update timestamp
711+
pub timestamp: u64,
712+
}
713+
698714
/// Error logged event
699715
#[contracttype]
700716
#[derive(Clone, Debug, Eq, PartialEq)]
@@ -1581,6 +1597,24 @@ impl EventEmitter {
15811597
Self::store_event(env, &symbol_short!("cfg_upd"), &event);
15821598
}
15831599

1600+
/// Emit bet limits updated event (global or per-event).
1601+
pub fn emit_bet_limits_updated(
1602+
env: &Env,
1603+
admin: &Address,
1604+
scope: &Symbol,
1605+
min_bet: i128,
1606+
max_bet: i128,
1607+
) {
1608+
let event = BetLimitsUpdatedEvent {
1609+
admin: admin.clone(),
1610+
scope: scope.clone(),
1611+
min_bet,
1612+
max_bet,
1613+
timestamp: env.ledger().timestamp(),
1614+
};
1615+
Self::store_event(env, &symbol_short!("bet_lim"), &event);
1616+
}
1617+
15841618
/// Emit error logged event
15851619
pub fn emit_error_logged(
15861620
env: &Env,

0 commit comments

Comments
 (0)