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
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# Seeds for failure cases proptest has generated in the past. It is
# automatically read and these particular cases re-run before any
# novel cases are generated.
#
# It is recommended to check this file in to source control so that
# everyone who runs the test benefits from these saved cases.
cc d5beda84e86a79af46fd30e3b919c1b5e26f7dd893272eaff176639eb01f9b82 # shrinks to question = "Will BTC reach $100k by year end?", duration_days = 1
cc ec287376804edf904d828c9deab125362ba03a6a97487fe2fcf8fd95a56abf2c # shrinks to question = "Will BTC reach $100k by year end?", outcome_count = 2, user_index = 0, stake_amount = 1000000, outcome_choice = 0
cc 683c735e4e7a0d574dd602cdce52927faac4d2f500788735556d60e47c330bec # shrinks to question = "Will BTC reach $100k by year end?", outcome_count = 2, duration_days = 1, threshold = 1, comparison = "gt"
cc 37decdae6683c73fab564d279240ad9de15b9e6edd3871d740ae4a0e26c62b23 # shrinks to question = "Will BTC reach $100k by year end?", outcome_count = 2, duration_days = 1, threshold = 1, comparison = "gt"
cc 7ec0c802dcefd5fa82fa4ee7cbbde7e177ea6d94bd772961214529af44625848 # shrinks to question = "Will BTC reach $100k by year end?", user_count = 2, stakes = [1000000, 1000000]
49 changes: 47 additions & 2 deletions contracts/predictify-hybrid/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -556,6 +556,16 @@ impl PredictifyHybrid {
panic_with_error!(env, Error::InvalidInput);
}

// Rate limit: max events per admin per time window (when config set)
match rate_limiter::RateLimiter::new(env.clone()).rate_limit_admin_events(admin.clone()) {
Ok(()) => {}
Err(rate_limiter::RateLimiterError::ConfigNotFound) => {}
Err(rate_limiter::RateLimiterError::RateLimitExceeded) => {
panic_with_error!(env, Error::InvalidInput);
}
Err(_) => panic_with_error!(env, Error::InvalidInput),
}

// Validate metadata using InputValidator
if let Err(_) = crate::validation::InputValidator::validate_question_length(&question) {
panic_with_error!(env, Error::InvalidQuestion);
Expand Down Expand Up @@ -1210,6 +1220,15 @@ impl PredictifyHybrid {
if ReentrancyGuard::check_reentrancy_state(&env).is_err() {
panic_with_error!(env, Error::InvalidState);
}
// Rate limit: max bets per user per time window (when config set)
match rate_limiter::RateLimiter::new(env.clone()).rate_limit_bets(user.clone()) {
Ok(()) => {}
Err(rate_limiter::RateLimiterError::ConfigNotFound) => {}
Err(rate_limiter::RateLimiterError::RateLimitExceeded) => {
panic_with_error!(env, Error::InvalidInput);
}
Err(_) => panic_with_error!(env, Error::InvalidInput),
}
// Use the BetManager to handle the bet placement
match bets::BetManager::place_bet(&env, user.clone(), market_id, outcome, amount) {
Ok(bet) => {
Expand Down Expand Up @@ -4774,8 +4793,12 @@ impl PredictifyHybrid {
let stored_admin: Option<Address> =
env.storage().persistent().get(&Symbol::new(&env, "Admin"));
let is_admin = stored_admin.as_ref().map_or(false, |a| a == &caller);
let timeout_passed = current_time.saturating_sub(market.end_time)
>= config::DEFAULT_RESOLUTION_TIMEOUT_SECONDS;
let effective_timeout = if market.resolution_timeout == 0 {
config::DEFAULT_RESOLUTION_TIMEOUT_SECONDS
} else {
market.resolution_timeout
};
let timeout_passed = current_time.saturating_sub(market.end_time) >= effective_timeout;
if !is_admin && !timeout_passed {
return Err(Error::Unauthorized);
}
Expand Down Expand Up @@ -5325,6 +5348,28 @@ impl PredictifyHybrid {
admin::ContractPauseManager::unpause(&env, &admin)
}

/// Set or update rate limits (admin only). Configures max bets per user per time window,
/// max events per admin per time window, and existing voting/dispute/oracle limits.
/// When config is set, place_bet and create_market enforce these limits.
pub fn set_rate_limits(
env: Env,
admin: Address,
config: rate_limiter::RateLimitConfig,
) -> Result<(), Error> {
admin.require_auth();
let stored_admin: Address = env
.storage()
.persistent()
.get(&Symbol::new(&env, "Admin"))
.ok_or(Error::AdminNotSet)?;
if admin != stored_admin {
return Err(Error::Unauthorized);
}
rate_limiter::RateLimiter::new(env)
.update_rate_limits(admin, config)
.map_err(|_| Error::InvalidInput)
}

/// Returns true if the contract is currently paused.
pub fn is_contract_paused(env: Env) -> bool {
admin::ContractPauseManager::is_contract_paused(&env)
Expand Down
68 changes: 58 additions & 10 deletions contracts/predictify-hybrid/src/rate_limiter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,12 @@ use soroban_sdk::{contract, contracterror, contractimpl, contracttype, Address,
#[contracttype]
#[derive(Clone, Debug, PartialEq)]
pub struct RateLimitConfig {
pub voting_limit: u32, // Max votes per time window
pub dispute_limit: u32, // Max disputes per time window
pub oracle_call_limit: u32, // Max oracle calls per time window
pub time_window_seconds: u64, // Time window in seconds
pub voting_limit: u32, // Max votes per time window
pub dispute_limit: u32, // Max disputes per time window
pub oracle_call_limit: u32, // Max oracle calls per time window
pub bet_limit: u32, // Max bets per user per time window (0 = no limit)
pub events_per_admin_limit: u32, // Max events per admin per time window (0 = no limit)
pub time_window_seconds: u64, // Time window in seconds
}

// Rate limit tracking
Expand All @@ -21,9 +23,11 @@ pub struct RateLimit {
#[contracttype]
pub enum RateLimiterData {
Config,
UserVoting(Address, Symbol), // user, market_id
UserDisputes(Address, Symbol), // user, market_id
UserVoting(Address, Symbol), // user, market_id
UserDisputes(Address, Symbol), // user, market_id
OracleCalls(Symbol), // market_id
UserBets(Address), // user (global bet count per window)
AdminEvents(Address), // admin (events created per window)
}

pub struct RateLimiter {
Expand Down Expand Up @@ -154,14 +158,41 @@ impl RateLimiter {
Ok(())
}

// Update rate limits (admin only)
/// Rate limit bets: max bets per user per time window (global across markets).
/// Returns Ok(()) if within limit or config not set; ConfigNotFound is used by caller to skip check.
/// Caller (e.g. place_bet) must have already authenticated user.
pub fn rate_limit_bets(&self, user: Address) -> Result<(), RateLimiterError> {
let config = self.get_config()?;
if config.bet_limit == 0 {
return Ok(());
}
let key = RateLimiterData::UserBets(user.clone());
let limit = self.get_or_create_limit(&key);
self.check_limit(limit.count, config.bet_limit)?;
self.update_limit(&key, limit, config.time_window_seconds)?;
Ok(())
}

/// Rate limit event creation: max events per admin per time window.
/// Caller (e.g. create_market) must have already authenticated admin.
pub fn rate_limit_admin_events(&self, admin: Address) -> Result<(), RateLimiterError> {
let config = self.get_config()?;
if config.events_per_admin_limit == 0 {
return Ok(());
}
let key = RateLimiterData::AdminEvents(admin.clone());
let limit = self.get_or_create_limit(&key);
self.check_limit(limit.count, config.events_per_admin_limit)?;
self.update_limit(&key, limit, config.time_window_seconds)?;
Ok(())
}

// Update rate limits (admin only). Caller must have already authenticated admin.
pub fn update_rate_limits(
&self,
admin: Address,
_admin: Address,
limits: RateLimitConfig,
) -> Result<(), RateLimiterError> {
admin.require_auth();

self.validate_rate_limit_configuration(&limits)?;

self.env
Expand Down Expand Up @@ -213,6 +244,13 @@ impl RateLimiter {
return Err(RateLimiterError::InvalidOracleCallLimit);
}

if config.bet_limit > 10000 {
return Err(RateLimiterError::InvalidBetLimit);
}
if config.events_per_admin_limit > 1000 {
return Err(RateLimiterError::InvalidEventsLimit);
}

// Time window should be between 1 minute and 30 days
if config.time_window_seconds < 60 || config.time_window_seconds > 2592000 {
return Err(RateLimiterError::InvalidTimeWindow);
Expand Down Expand Up @@ -244,6 +282,8 @@ pub enum RateLimiterError {
InvalidOracleCallLimit = 5,
InvalidTimeWindow = 6,
Unauthorized = 7,
InvalidBetLimit = 8,
InvalidEventsLimit = 9,
}

#[contract]
Expand Down Expand Up @@ -334,6 +374,8 @@ mod tests {
voting_limit: 10,
dispute_limit: 5,
oracle_call_limit: 20,
bet_limit: 50,
events_per_admin_limit: 10,
time_window_seconds: 3600, // 1 hour
}
}
Expand Down Expand Up @@ -426,6 +468,8 @@ mod tests {
voting_limit: 20000,
dispute_limit: 5,
oracle_call_limit: 20,
bet_limit: 0,
events_per_admin_limit: 0,
time_window_seconds: 3600,
};
let result = RateLimiterContract::validate_rate_limit_config(env.clone(), invalid_config);
Expand All @@ -436,6 +480,8 @@ mod tests {
voting_limit: 10,
dispute_limit: 5,
oracle_call_limit: 20,
bet_limit: 0,
events_per_admin_limit: 0,
time_window_seconds: 30, // Less than 60
};
let result = RateLimiterContract::validate_rate_limit_config(env.clone(), invalid_config);
Expand All @@ -461,6 +507,8 @@ mod tests {
voting_limit: 20,
dispute_limit: 10,
oracle_call_limit: 30,
bet_limit: 100,
events_per_admin_limit: 20,
time_window_seconds: 7200,
};

Expand Down
Loading
Loading