diff --git a/contracts/predictify-hybrid/src/governance.rs b/contracts/predictify-hybrid/src/governance.rs index 38fe0208..85a7c547 100644 --- a/contracts/predictify-hybrid/src/governance.rs +++ b/contracts/predictify-hybrid/src/governance.rs @@ -31,6 +31,17 @@ pub struct GovernanceProposal { pub for_votes: u128, pub against_votes: u128, pub executed: bool, + /// Per-proposal random salt generated at creation time using `Env::prng`. + /// + /// The salt **must** be included in the canonical vote message signed by + /// each voter. This binds each signature to a specific proposal instance + /// and prevents vote-replay: an off-chain signature authorising a vote on + /// proposal P₁ cannot be replayed against a re-submitted proposal P₂ + /// that happens to share the same `id`, `title`, and `description`. + /// + /// Entropy source: `env.prng().gen::>()` at creation — never + /// derived from the block timestamp or any other predictable value. + pub salt: BytesN<32>, } // Key namespaces used in storage @@ -220,6 +231,10 @@ impl GovernanceContract { for_votes: 0, against_votes: 0, executed: false, + // Generate a cryptographically random salt using Soroban's PRNG. + // Using env.prng() here — never the block timestamp — ensures that + // entropy is not predictable by the proposer or any observer. + salt: env.prng().gen::>(), }; env.storage() @@ -244,11 +259,24 @@ impl GovernanceContract { /// Vote on a proposal. `support = true` means FOR, false means AGAINST. /// Each address counts as 1 vote plus 1 for each address that has delegated to it. + /// + /// # Vote-replay prevention + /// + /// The caller **must** supply the proposal's `salt` value. The contract + /// compares it against the salt stored in the proposal and rejects the vote + /// with `GovernanceError::SaltMismatch` if they differ. + /// + /// Off-chain signers should include the salt in the canonical message they + /// sign, e.g. `sha256(proposal_id || salt || voter || support)`. This + /// ensures that a valid signature for one proposal instance cannot be + /// replayed against a different instance that happens to share the same + /// payload. pub fn vote( env: Env, voter: Address, proposal_id: Symbol, support: bool, + salt: BytesN<32>, ) -> Result<(), GovernanceError> { voter.require_auth(); @@ -262,6 +290,12 @@ impl GovernanceContract { } let mut p = p_opt.unwrap(); + // Verify that the salt supplied by the voter matches the stored salt. + // This prevents vote-replay across re-submitted proposals. + if salt != p.salt { + return Err(GovernanceError::SaltMismatch); + } + let now = env.ledger().timestamp(); if now < p.start_time { return Err(GovernanceError::VotingNotStarted); @@ -705,6 +739,23 @@ impl GovernanceContract { Ok(p_opt.unwrap()) } + /// Return the salt for a proposal. + /// + /// Off-chain clients use this to build the canonical vote message: + /// `sha256(proposal_id || salt || voter || support)`. + /// + /// The salt is generated by `Env::prng` at proposal creation and never + /// derived from predictable data, so it cannot be forged or pre-computed + /// by an attacker before the proposal is submitted. + pub fn get_proposal_salt(env: Env, id: Symbol) -> Result, GovernanceError> { + let p: GovernanceProposal = env + .storage() + .persistent() + .get(&StorageKey::Proposal(id.clone())) + .ok_or(GovernanceError::ProposalNotFound)?; + Ok(p.salt) + } + /// Admin-only: set voting period (seconds) pub fn set_voting_period( env: Env, diff --git a/contracts/predictify-hybrid/src/governance_tests.rs b/contracts/predictify-hybrid/src/governance_tests.rs index 441da27e..a2036650 100644 --- a/contracts/predictify-hybrid/src/governance_tests.rs +++ b/contracts/predictify-hybrid/src/governance_tests.rs @@ -78,8 +78,15 @@ impl GovernanceFixture { } fn vote(&self, voter: Address, proposal_id: Symbol, support: bool) -> Result<(), GovernanceError> { + // Fetch the proposal's salt from storage and include it in the vote call. + // This mirrors how a real off-chain client would obtain the salt before + // submitting a signed vote transaction. + let salt = self.env.as_contract(&self.contract_id, || { + GovernanceContract::get_proposal_salt(self.env.clone(), proposal_id.clone()) + .expect("proposal salt must be readable before voting") + }); self.env.as_contract(&self.contract_id, || { - GovernanceContract::vote(self.env.clone(), voter, proposal_id, support) + GovernanceContract::vote(self.env.clone(), voter, proposal_id, support, salt) }) } diff --git a/contracts/predictify-hybrid/src/market_id_generator.rs b/contracts/predictify-hybrid/src/market_id_generator.rs index 726d6803..359d463f 100644 --- a/contracts/predictify-hybrid/src/market_id_generator.rs +++ b/contracts/predictify-hybrid/src/market_id_generator.rs @@ -771,3 +771,4 @@ mod tests { assert_eq!(all_ids.len(), 25); } } +} // close impl MarketIdGenerator diff --git a/contracts/predictify-hybrid/src/rate_limiter.rs b/contracts/predictify-hybrid/src/rate_limiter.rs index 03618776..02f2826a 100644 --- a/contracts/predictify-hybrid/src/rate_limiter.rs +++ b/contracts/predictify-hybrid/src/rate_limiter.rs @@ -1,5 +1,45 @@ use soroban_sdk::{contract, contracterror, contractimpl, contracttype, Address, Env, Symbol}; +/// How the token bucket refills between requests. +/// +/// ## Linear (default) +/// The bucket resets fully at the start of each new time window. This is +/// the original behaviour and remains the default for backwards compatibility. +/// +/// ## HalfLife +/// Tokens are replenished using an exponential (half-life) model: +/// +/// ``` +/// available = capacity - (remaining_used >> (elapsed / half_life_seconds)) +/// ``` +/// +/// In plain terms: the number of *consumed* tokens halves every +/// `half_life_seconds` of idle time, causing the available count to +/// approach `capacity` asymptotically. All arithmetic uses pure integer +/// math (no floats) via repeated right-shifts, which are equivalent to +/// division by powers of two. +/// +/// ### Integer-safe formula +/// Let `used = capacity - available_at_last_check`. +/// After `elapsed` seconds the new used amount is: +/// +/// ``` +/// half_lives_elapsed = elapsed / half_life_seconds (integer division) +/// new_used = used >> half_lives_elapsed (saturates to 0) +/// new_available = capacity - new_used +/// ``` +/// +/// Because `new_used >= 0` and `new_used <= capacity`, there is **no +/// overflow** for any combination of inputs. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub enum RefillMode { + /// Reset bucket fully at the start of every new time window (original behaviour). + Linear, + /// Exponential decay: consumed tokens halve every `half_life_seconds`. + HalfLife(u64), // half_life_seconds stored as the inner value +} + #[contracttype] #[derive(Clone, Debug, PartialEq)] pub struct RateLimitConfig { @@ -9,8 +49,21 @@ pub struct RateLimitConfig { 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 + /// Refill strategy. Defaults to `RefillMode::Linear` for backwards + /// compatibility with callers that do not set this field explicitly. + pub refill_mode: RefillMode, } +/// Token-bucket state kept in temporary storage per (key, user/market). +/// +/// For **Linear** mode: +/// - `count` is the number of actions taken in the current window. +/// - `window_start` is the start of the current window. +/// +/// For **HalfLife** mode: +/// - `count` is treated as the number of *consumed* tokens (i.e. `capacity - available`). +/// - `window_start` is the timestamp of the last time the bucket was updated, +/// used to compute how much decay to apply before the next check. // Rate limit tracking #[contracttype] #[derive(Clone, Debug)] @@ -64,6 +117,38 @@ impl RateLimiter { .ok_or(RateLimiterError::ConfigNotFound) } + // Check if rate limit is exceeded, respecting the configured RefillMode. + // + // For Linear mode: exceeded when count >= limit (same as before). + // For HalfLife mode: exceeded when the number of available tokens after + // decay is 0 (i.e. decayed_used >= capacity). + fn check_limit_with_config( + &self, + limit_state: &RateLimit, + capacity: u32, + config: &RateLimitConfig, + ) -> Result<(), RateLimiterError> { + match &config.refill_mode { + RefillMode::Linear => { + // Original window-reset semantics. + let current_time = self.env.ledger().timestamp(); + let active = current_time < limit_state.window_start.saturating_add(config.time_window_seconds); + let effective_count = if active { limit_state.count } else { 0 }; + if effective_count >= capacity { + return Err(RateLimiterError::RateLimitExceeded); + } + } + RefillMode::HalfLife(half_life_secs) => { + let now = self.env.ledger().timestamp(); + let available = Self::halflife_available(limit_state, capacity, *half_life_secs, now); + if available == 0 { + return Err(RateLimiterError::RateLimitExceeded); + } + } + } + Ok(()) + } + // Check if rate limit is exceeded fn check_limit(&self, current_count: u32, limit: u32) -> Result<(), RateLimiterError> { if current_count >= limit { @@ -89,15 +174,59 @@ impl RateLimiter { &self, key: &RateLimiterData, mut limit: RateLimit, - time_window: u64, + config: &RateLimitConfig, + capacity: u32, ) -> Result<(), RateLimiterError> { let current_time = self.env.ledger().timestamp(); - - if current_time >= limit.window_start + time_window { - limit.count = 1; - limit.window_start = current_time; - } else { - limit.count += 1; + let time_window = config.time_window_seconds; + + match &config.refill_mode { + RefillMode::Linear => { + // Original behaviour: reset the bucket at the start of a new window. + if current_time >= limit.window_start.saturating_add(time_window) { + limit.count = 1; + limit.window_start = current_time; + } else { + limit.count = limit.count.saturating_add(1); + } + } + RefillMode::HalfLife(half_life_secs) => { + // Half-life (exponential decay) refill. + // + // `limit.count` stores consumed tokens (= capacity - available). + // Each `half_life_secs` of elapsed time halves the consumed amount, + // so the bucket asymptotically approaches full capacity. + // + // elapsed may be 0 if called multiple times in the same ledger + // second, which is fine: no decay happens, consumed stays the same. + let half_life = *half_life_secs; + if half_life == 0 { + // Safety: treat zero half-life as instant full refill (linear reset). + limit.count = 1; + limit.window_start = current_time; + } else { + let elapsed = current_time.saturating_sub(limit.window_start); + // Number of full half-lives elapsed (integer division). + let half_lives = elapsed / half_life; + + // Decay consumed tokens: right-shift by half_lives (capped at 31 + // to avoid shifting a u32 by >= 32, which would be UB in Rust). + let decayed_used = if half_lives >= 32 { + 0u32 + } else { + limit.count >> half_lives + }; + + // Now charge one more token. Use saturating_add to avoid wrap. + // If decayed_used >= capacity, this means we're already over — + // the check_limit call before us would have blocked it; but we + // use saturating_add defensively anyway. + let new_used = decayed_used.saturating_add(1).min(capacity); + + limit.count = new_used; + limit.window_start = current_time; + } + } } self.env.storage().temporary().set(key, &limit); @@ -110,6 +239,27 @@ impl RateLimiter { Ok(()) } + /// Compute the number of *available* tokens for a bucket in HalfLife mode, + /// given the stored state and current timestamp. + /// + /// Returns `capacity` when there is no stored state (first use). + /// In Linear mode this is unused; callers use the window-reset logic. + /// + /// Pure function — does not touch storage. + fn halflife_available(limit: &RateLimit, capacity: u32, half_life_secs: u64, now: u64) -> u32 { + if half_life_secs == 0 { + return capacity; + } + let elapsed = now.saturating_sub(limit.window_start); + let half_lives = elapsed / half_life_secs; + let decayed_used = if half_lives >= 32 { + 0u32 + } else { + limit.count >> half_lives + }; + capacity.saturating_sub(decayed_used) + } + // Rate limit voting operations pub fn rate_limit_voting( &self, @@ -122,8 +272,8 @@ impl RateLimiter { let key = RateLimiterData::UserVoting(user.clone(), market_id.clone()); let limit = self.get_or_create_limit(&key); - self.check_limit(limit.count, config.voting_limit)?; - self.update_limit(&key, limit, config.time_window_seconds)?; + self.check_limit_with_config(&limit, config.voting_limit, &config)?; + self.update_limit(&key, limit, &config, config.voting_limit)?; Ok(()) } @@ -140,8 +290,8 @@ impl RateLimiter { let key = RateLimiterData::UserDisputes(user.clone(), market_id.clone()); let limit = self.get_or_create_limit(&key); - self.check_limit(limit.count, config.dispute_limit)?; - self.update_limit(&key, limit, config.time_window_seconds)?; + self.check_limit_with_config(&limit, config.dispute_limit, &config)?; + self.update_limit(&key, limit, &config, config.dispute_limit)?; Ok(()) } @@ -152,8 +302,8 @@ impl RateLimiter { let key = RateLimiterData::OracleCalls(market_id.clone()); let limit = self.get_or_create_limit(&key); - self.check_limit(limit.count, config.oracle_call_limit)?; - self.update_limit(&key, limit, config.time_window_seconds)?; + self.check_limit_with_config(&limit, config.oracle_call_limit, &config)?; + self.update_limit(&key, limit, &config, config.oracle_call_limit)?; Ok(()) } @@ -168,8 +318,8 @@ impl RateLimiter { } 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)?; + self.check_limit_with_config(&limit, config.bet_limit, &config)?; + self.update_limit(&key, limit, &config, config.bet_limit)?; Ok(()) } @@ -182,8 +332,8 @@ impl RateLimiter { } 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)?; + self.check_limit_with_config(&limit, config.events_per_admin_limit, &config)?; + self.update_limit(&key, limit, &config, config.events_per_admin_limit)?; Ok(()) } @@ -219,9 +369,27 @@ impl RateLimiter { let current_time = self.env.ledger().timestamp(); + let (voting_remaining, dispute_remaining) = match &config.refill_mode { + RefillMode::Linear => { + let v_active = current_time < voting_limit.window_start.saturating_add(config.time_window_seconds); + let d_active = current_time < dispute_limit.window_start.saturating_add(config.time_window_seconds); + let v_count = if v_active { voting_limit.count } else { 0 }; + let d_count = if d_active { dispute_limit.count } else { 0 }; + ( + config.voting_limit.saturating_sub(v_count), + config.dispute_limit.saturating_sub(d_count), + ) + } + RefillMode::HalfLife(half_life_secs) => { + let v_avail = Self::halflife_available(&voting_limit, config.voting_limit, *half_life_secs, current_time); + let d_avail = Self::halflife_available(&dispute_limit, config.dispute_limit, *half_life_secs, current_time); + (v_avail, d_avail) + } + }; + Ok(RateLimitStatus { - voting_remaining: config.voting_limit.saturating_sub(voting_limit.count), - dispute_remaining: config.dispute_limit.saturating_sub(dispute_limit.count), + voting_remaining, + dispute_remaining, window_reset_time: voting_limit.window_start + config.time_window_seconds, current_time, }) @@ -256,6 +424,17 @@ impl RateLimiter { return Err(RateLimiterError::InvalidTimeWindow); } + // Validate HalfLife parameter: half_life_seconds must be > 0 and fit within the time window. + if let RefillMode::HalfLife(half_life_secs) = config.refill_mode { + if half_life_secs == 0 { + return Err(RateLimiterError::InvalidHalfLife); + } + // half_life_seconds must not exceed the time window (it would be meaningless). + if half_life_secs > config.time_window_seconds { + return Err(RateLimiterError::InvalidHalfLife); + } + } + Ok(()) } } @@ -284,6 +463,8 @@ pub enum RateLimiterError { Unauthorized = 7, InvalidBetLimit = 8, InvalidEventsLimit = 9, + /// `half_life_seconds` is zero or exceeds the configured `time_window_seconds`. + InvalidHalfLife = 10, } #[contract] @@ -383,6 +564,7 @@ mod tests { bet_limit: 50, events_per_admin_limit: 10, time_window_seconds: 3600, // 1 hour + refill_mode: RefillMode::Linear, } } @@ -477,6 +659,7 @@ mod tests { bet_limit: 0, events_per_admin_limit: 0, time_window_seconds: 3600, + refill_mode: RefillMode::Linear, }; let result = RateLimiterContract::validate_rate_limit_config(env.clone(), invalid_config); assert_eq!(result, Err(RateLimiterError::InvalidVotingLimit)); @@ -489,6 +672,7 @@ mod tests { bet_limit: 0, events_per_admin_limit: 0, time_window_seconds: 30, // Less than 60 + refill_mode: RefillMode::Linear, }; let result = RateLimiterContract::validate_rate_limit_config(env.clone(), invalid_config); assert_eq!(result, Err(RateLimiterError::InvalidTimeWindow)); @@ -516,6 +700,7 @@ mod tests { bet_limit: 100, events_per_admin_limit: 20, time_window_seconds: 7200, + refill_mode: RefillMode::Linear, }; client.update_rate_limits(&admin, &new_config); diff --git a/contracts/predictify-hybrid/src/test.rs b/contracts/predictify-hybrid/src/test.rs index ca994d8f..51010293 100644 --- a/contracts/predictify-hybrid/src/test.rs +++ b/contracts/predictify-hybrid/src/test.rs @@ -3069,6 +3069,7 @@ fn test_bet_rate_limit_enforced_when_config_set() { bet_limit: 2, events_per_admin_limit: 10, time_window_seconds: 3600, + refill_mode: crate::rate_limiter::RefillMode::Linear, }; test.env.mock_all_auths(); client.set_rate_limits(&test.admin, &config); @@ -3113,6 +3114,7 @@ fn test_bet_rate_limit_at_limit_ok() { bet_limit: 2, events_per_admin_limit: 10, time_window_seconds: 3600, + refill_mode: crate::rate_limiter::RefillMode::Linear, }; test.env.mock_all_auths(); client.set_rate_limits(&test.admin, &config); diff --git a/contracts/predictify-hybrid/src/tests/contracts/predictify-hybrid/src/tests/rate_limiter_invariants.rs b/contracts/predictify-hybrid/src/tests/contracts/predictify-hybrid/src/tests/rate_limiter_invariants.rs index 76517cca..0752df6f 100644 --- a/contracts/predictify-hybrid/src/tests/contracts/predictify-hybrid/src/tests/rate_limiter_invariants.rs +++ b/contracts/predictify-hybrid/src/tests/contracts/predictify-hybrid/src/tests/rate_limiter_invariants.rs @@ -43,6 +43,7 @@ mod rate_limiter_invariants { bet_limit: 50, events_per_admin_limit: 10, time_window_seconds, + refill_mode: crate::rate_limiter::RefillMode::Linear, } } @@ -278,6 +279,7 @@ mod rate_limiter_invariants { bet_limit: 0, // disabled events_per_admin_limit: 0, time_window_seconds: 3600, + refill_mode: crate::rate_limiter::RefillMode::Linear, }; let contract_id = env.register_contract(None, RateLimiterContract); let client = RateLimiterContractClient::new(&env, &contract_id); diff --git a/contracts/predictify-hybrid/src/tests/mod.rs b/contracts/predictify-hybrid/src/tests/mod.rs index b5051898..3b76f15e 100644 --- a/contracts/predictify-hybrid/src/tests/mod.rs +++ b/contracts/predictify-hybrid/src/tests/mod.rs @@ -18,6 +18,7 @@ pub mod fee_calculator_proptest; // mod fee_idempotency_tests; mod rate_limiter_tests; mod rate_limiter_invariants; +mod rate_limiter_halflife_tests; // mod metadata_validation_tests; // mod oracle_provider_compatibility_tests; // mod oracle_validation_tests; diff --git a/contracts/predictify-hybrid/src/tests/rate_limiter_halflife_tests.rs b/contracts/predictify-hybrid/src/tests/rate_limiter_halflife_tests.rs new file mode 100644 index 00000000..2955789a --- /dev/null +++ b/contracts/predictify-hybrid/src/tests/rate_limiter_halflife_tests.rs @@ -0,0 +1,485 @@ +#![cfg(test)] + +//! Half-life rate-limiter tests (`rate_limiter_halflife`). +//! +//! These tests exercise the `RefillMode::HalfLife` token-bucket implementation. +//! +//! # What is tested +//! +//! - Basic capacity enforcement: a full bucket blocks the next action +//! - Monotone recovery: available tokens increase (never decrease) as time passes +//! - Asymptotic saturation: after many half-lives the bucket is fully available +//! - Saturation is exact — no overflow past capacity +//! - Half-life zero is rejected by the validator +//! - Half-life > time_window is rejected by the validator +//! - Multiple users / markets have independent buckets +//! - HalfLife and Linear configs are interchangeable at the config level +//! - Edge case: 31+ half-lives saturate the bucket via shift-saturation +//! - Edge case: time doesn't move (elapsed=0) — no phantom refill +//! +//! # Running +//! +//! ```bash +//! cargo test -p predictify-hybrid halflife -- --nocapture +//! ``` + +use crate::rate_limiter::{ + RateLimitConfig, RateLimiterContract, RateLimiterContractClient, RateLimiterError, RefillMode, +}; +use soroban_sdk::{testutils::Address as _, Address, Env, Symbol}; + +// ───────────────────────────────────────────────────────────────────────────── +// Helpers +// ───────────────────────────────────────────────────────────────────────────── + +/// Construct a HalfLife config with small values for fast-forwarding in tests. +/// +/// - `capacity` is used as `voting_limit`; other limits are set large so they +/// never interfere with voting-focused tests. +/// - `half_life_secs` is the half-life period. +/// - `time_window_seconds` is set to `half_life_secs * 32` so the HalfLife +/// validation rule (half_life ≤ time_window) always passes. +fn halflife_config(capacity: u32, half_life_secs: u64) -> RateLimitConfig { + // time_window must be >= half_life for validation to pass. + let time_window = half_life_secs * 32; + // Clamp to the allowed max (2 592 000 s = 30 days) if needed. + let time_window = if time_window < 60 { 3600 } else { time_window }; + let time_window = time_window.min(2_592_000); + RateLimitConfig { + voting_limit: capacity, + dispute_limit: 1000, + oracle_call_limit: 1000, + bet_limit: 0, + events_per_admin_limit: 0, + time_window_seconds: time_window, + refill_mode: RefillMode::HalfLife(half_life_secs), + } +} + +/// Deploy the standalone `RateLimiterContract` and return a client pre-seeded +/// with the given config. +fn deploy(env: &Env, config: RateLimitConfig) -> RateLimiterContractClient { + env.mock_all_auths(); + let contract_id = env.register_contract(None, RateLimiterContract); + let client = RateLimiterContractClient::new(env, &contract_id); + let admin = Address::generate(env); + client.init_rate_limiter(&admin, &config); + client +} + +/// Advance the ledger timestamp by `delta` seconds. +fn advance(env: &Env, delta: u64) { + let ts = env.ledger().timestamp(); + env.ledger().with_mut(|li| li.timestamp = ts.saturating_add(delta)); +} + +/// Set the ledger timestamp to an absolute value. +fn set_time(env: &Env, ts: u64) { + env.ledger().with_mut(|li| li.timestamp = ts); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Basic enforcement +// ───────────────────────────────────────────────────────────────────────────── + +/// A bucket with capacity 3 should allow 3 actions then block the 4th. +#[test] +fn halflife_bucket_blocks_after_capacity_exhausted() { + let env = Env::default(); + env.mock_all_auths(); + set_time(&env, 1_000); + + // capacity=3, half_life=300 s + let client = deploy(&env, halflife_config(3, 300)); + let user = Address::generate(&env); + let mkt = Symbol::new(&env, "market"); + + // 3 actions succeed + for i in 0..3 { + assert!( + client.try_check_voting_rate_limit(&user, &mkt).is_ok(), + "action {} should succeed", + i + 1 + ); + } + + // 4th action must fail + let result = client.try_check_voting_rate_limit(&user, &mkt); + assert_eq!( + result, + Err(Ok(RateLimiterError::RateLimitExceeded)), + "4th action should be blocked" + ); +} + +/// After one half-life half the consumed tokens are returned, +/// so a bucket that was blocked can accept new actions again. +#[test] +fn halflife_refill_after_one_half_life() { + let env = Env::default(); + env.mock_all_auths(); + set_time(&env, 0); + + // capacity=4, half_life=100 s + let client = deploy(&env, halflife_config(4, 100)); + let user = Address::generate(&env); + let mkt = Symbol::new(&env, "market"); + + // Exhaust the bucket (4 actions) + for _ in 0..4 { + client.check_voting_rate_limit(&user, &mkt); + } + + // Should be blocked + assert_eq!( + client.try_check_voting_rate_limit(&user, &mkt), + Err(Ok(RateLimiterError::RateLimitExceeded)), + "bucket should be exhausted" + ); + + // Advance exactly one half-life — used tokens halve from 4 → 2. + // Available = 4 - 2 = 2 → should unblock. + advance(&env, 100); + + assert!( + client.try_check_voting_rate_limit(&user, &mkt).is_ok(), + "should accept after one half-life" + ); +} + +/// After many half-lives the bucket should be (effectively) fully available. +#[test] +fn halflife_bucket_fully_saturates_after_many_half_lives() { + let env = Env::default(); + env.mock_all_auths(); + set_time(&env, 0); + + // capacity=8, half_life=60 s + let client = deploy(&env, halflife_config(8, 60)); + let user = Address::generate(&env); + let mkt = Symbol::new(&env, "market"); + + // Exhaust the bucket + for _ in 0..8 { + client.check_voting_rate_limit(&user, &mkt); + } + + // After 32 half-lives (32 × 60 = 1920 s) any remaining used tokens + // will be shifted to 0 (u32::MAX >> 32 saturates to 0). + advance(&env, 60 * 32); + + // All 8 slots should be available again + for i in 0..8 { + assert!( + client.try_check_voting_rate_limit(&user, &mkt).is_ok(), + "slot {} should be available after full saturation", + i + 1 + ); + } +} + +/// Verify that available token count never exceeds capacity (no overflow). +#[test] +fn halflife_no_overflow_past_capacity() { + let env = Env::default(); + env.mock_all_auths(); + set_time(&env, 0); + + let capacity = 5u32; + // half_life=60 s + let client = deploy(&env, halflife_config(capacity, 60)); + let user = Address::generate(&env); + let mkt = Symbol::new(&env, "market"); + + // Use one action + client.check_voting_rate_limit(&user, &mkt); + + // Advance far into the future + advance(&env, 60 * 100); + + // Use one more — should not panic, must succeed + assert!(client.try_check_voting_rate_limit(&user, &mkt).is_ok()); + + // Check status: remaining must be ≤ capacity + let status = client.get_rate_limit_status(&user, &mkt); + assert!( + status.voting_remaining <= capacity, + "remaining {} must not exceed capacity {}", + status.voting_remaining, + capacity + ); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Monotone recovery +// ───────────────────────────────────────────────────────────────────────────── + +/// `available` must be monotone non-decreasing as time passes. +#[test] +fn halflife_available_monotone_non_decreasing() { + let env = Env::default(); + env.mock_all_auths(); + set_time(&env, 0); + + let capacity = 10u32; + let half_life = 100u64; + let client = deploy(&env, halflife_config(capacity, half_life)); + let user = Address::generate(&env); + let mkt = Symbol::new(&env, "market"); + + // Exhaust the bucket + for _ in 0..capacity { + client.check_voting_rate_limit(&user, &mkt); + } + + let mut prev_remaining = 0u32; + // Sample at increasing time offsets + for &elapsed in &[0u64, 25, 50, 75, 100, 150, 200, 300, 500, 1000, 3200] { + set_time(&env, elapsed); + let status = client.get_rate_limit_status(&user, &mkt); + assert!( + status.voting_remaining >= prev_remaining, + "remaining decreased from {} to {} at elapsed={}", + prev_remaining, + status.voting_remaining, + elapsed + ); + prev_remaining = status.voting_remaining; + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Independence: different users / markets +// ───────────────────────────────────────────────────────────────────────────── + +/// Two different users should have completely independent buckets. +#[test] +fn halflife_different_users_are_independent() { + let env = Env::default(); + env.mock_all_auths(); + set_time(&env, 0); + + let client = deploy(&env, halflife_config(2, 60)); + let user1 = Address::generate(&env); + let user2 = Address::generate(&env); + let mkt = Symbol::new(&env, "market"); + + // Exhaust user1's bucket + for _ in 0..2 { + client.check_voting_rate_limit(&user1, &mkt); + } + assert_eq!( + client.try_check_voting_rate_limit(&user1, &mkt), + Err(Ok(RateLimiterError::RateLimitExceeded)) + ); + + // user2 is untouched — must still be free + assert!( + client.try_check_voting_rate_limit(&user2, &mkt).is_ok(), + "user2 bucket must be independent" + ); +} + +/// Two different markets should have completely independent buckets. +#[test] +fn halflife_different_markets_are_independent() { + let env = Env::default(); + env.mock_all_auths(); + set_time(&env, 0); + + let client = deploy(&env, halflife_config(2, 60)); + let user = Address::generate(&env); + let mkt1 = Symbol::new(&env, "mktA"); + let mkt2 = Symbol::new(&env, "mktB"); + + // Exhaust mkt1 + for _ in 0..2 { + client.check_voting_rate_limit(&user, &mkt1); + } + assert_eq!( + client.try_check_voting_rate_limit(&user, &mkt1), + Err(Ok(RateLimiterError::RateLimitExceeded)) + ); + + // mkt2 bucket is independent + assert!( + client.try_check_voting_rate_limit(&user, &mkt2).is_ok(), + "mkt2 bucket must be independent" + ); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Validation +// ───────────────────────────────────────────────────────────────────────────── + +/// `half_life_seconds = 0` must be rejected. +#[test] +fn halflife_zero_half_life_rejected_by_validator() { + let env = Env::default(); + let config = RateLimitConfig { + voting_limit: 10, + dispute_limit: 5, + oracle_call_limit: 20, + bet_limit: 0, + events_per_admin_limit: 0, + time_window_seconds: 3600, + refill_mode: RefillMode::HalfLife(0), // invalid + }; + let result = RateLimiterContract::validate_rate_limit_config(env.clone(), config); + assert_eq!(result, Err(RateLimiterError::InvalidHalfLife)); +} + +/// `half_life_seconds > time_window_seconds` must be rejected. +#[test] +fn halflife_greater_than_time_window_rejected_by_validator() { + let env = Env::default(); + let config = RateLimitConfig { + voting_limit: 10, + dispute_limit: 5, + oracle_call_limit: 20, + bet_limit: 0, + events_per_admin_limit: 0, + time_window_seconds: 3600, + refill_mode: RefillMode::HalfLife(7200), // > time_window + }; + let result = RateLimiterContract::validate_rate_limit_config(env.clone(), config); + assert_eq!(result, Err(RateLimiterError::InvalidHalfLife)); +} + +/// A valid HalfLife config must pass validation. +#[test] +fn halflife_valid_config_passes_validation() { + let env = Env::default(); + let config = halflife_config(10, 60); + let result = RateLimiterContract::validate_rate_limit_config(env.clone(), config); + assert!(result.is_ok(), "valid HalfLife config should pass"); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Edge-cases +// ───────────────────────────────────────────────────────────────────────────── + +/// When time does not advance (elapsed = 0) no phantom refill occurs. +#[test] +fn halflife_no_refill_when_time_static() { + let env = Env::default(); + env.mock_all_auths(); + set_time(&env, 1_000); + + let client = deploy(&env, halflife_config(2, 60)); + let user = Address::generate(&env); + let mkt = Symbol::new(&env, "market"); + + // Fill the bucket + client.check_voting_rate_limit(&user, &mkt); + client.check_voting_rate_limit(&user, &mkt); + + // Same timestamp — no time has elapsed + assert_eq!( + client.try_check_voting_rate_limit(&user, &mkt), + Err(Ok(RateLimiterError::RateLimitExceeded)), + "no refill should occur when time hasn't advanced" + ); +} + +/// After 31+ half-lives the right-shift saturates to 0 used tokens (full bucket). +#[test] +fn halflife_shift_saturation_at_31_or_more_half_lives() { + let env = Env::default(); + env.mock_all_auths(); + set_time(&env, 0); + + // capacity=4, half_life=60 s → time_window=60*32=1920 s + let client = deploy(&env, halflife_config(4, 60)); + let user = Address::generate(&env); + let mkt = Symbol::new(&env, "market"); + + // Exhaust bucket + for _ in 0..4 { + client.check_voting_rate_limit(&user, &mkt); + } + + // Advance 32 half-lives + advance(&env, 60 * 32); + + let status = client.get_rate_limit_status(&user, &mkt); + assert_eq!( + status.voting_remaining, 4, + "after 32+ half-lives all 4 tokens should be available" + ); +} + +/// A HalfLife bucket with capacity=1 blocks immediately and recovers after one half-life. +#[test] +fn halflife_capacity_one_blocks_and_recovers() { + let env = Env::default(); + env.mock_all_auths(); + set_time(&env, 0); + + let client = deploy(&env, halflife_config(1, 120)); + let user = Address::generate(&env); + let mkt = Symbol::new(&env, "market"); + + // First action succeeds + assert!(client.try_check_voting_rate_limit(&user, &mkt).is_ok()); + + // Immediately blocked + assert_eq!( + client.try_check_voting_rate_limit(&user, &mkt), + Err(Ok(RateLimiterError::RateLimitExceeded)) + ); + + // After one half-life (120 s): used = 1 >> 1 = 0 → available = 1 + advance(&env, 120); + assert!( + client.try_check_voting_rate_limit(&user, &mkt).is_ok(), + "should recover after one half-life" + ); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Interoperability: HalfLife and Linear modes coexist at config level +// ───────────────────────────────────────────────────────────────────────────── + +/// `update_rate_limits` can switch from Linear to HalfLife. +#[test] +fn halflife_can_switch_from_linear_to_halflife_via_update() { + let env = Env::default(); + env.mock_all_auths(); + set_time(&env, 0); + + // Start with Linear config + let linear_config = RateLimitConfig { + voting_limit: 5, + dispute_limit: 5, + oracle_call_limit: 100, + bet_limit: 0, + events_per_admin_limit: 0, + time_window_seconds: 3600, + refill_mode: RefillMode::Linear, + }; + let client = deploy(&env, linear_config); + + // Switch to HalfLife via update + let hl_config = halflife_config(5, 60); + let admin = Address::generate(&env); + client.update_rate_limits(&admin, &hl_config); + + // HalfLife now active — exhaust and verify decay behaviour + let user = Address::generate(&env); + let mkt = Symbol::new(&env, "market"); + for _ in 0..5 { + client.check_voting_rate_limit(&user, &mkt); + } + assert_eq!( + client.try_check_voting_rate_limit(&user, &mkt), + Err(Ok(RateLimiterError::RateLimitExceeded)) + ); + + // After one half-life some tokens should be back + advance(&env, 60); + assert!( + client.try_check_voting_rate_limit(&user, &mkt).is_ok(), + "HalfLife recovery should work after config switch" + ); +} diff --git a/contracts/predictify-hybrid/src/tests/rate_limiter_tests.rs b/contracts/predictify-hybrid/src/tests/rate_limiter_tests.rs index 5ecf14cc..06e29e2f 100644 --- a/contracts/predictify-hybrid/src/tests/rate_limiter_tests.rs +++ b/contracts/predictify-hybrid/src/tests/rate_limiter_tests.rs @@ -15,6 +15,7 @@ use crate::rate_limiter::{ RateLimitConfig, RateLimiter, RateLimiterContract, RateLimiterContractClient, RateLimiterData, + RefillMode, }; use soroban_sdk::{ testutils::{Address as _, AuthorizedInvocation}, @@ -29,6 +30,7 @@ fn create_test_config() -> RateLimitConfig { bet_limit: 20, events_per_admin_limit: 5, time_window_seconds: 3600, + refill_mode: RefillMode::Linear, } } @@ -268,6 +270,7 @@ mod configuration_validation { bet_limit: 20, events_per_admin_limit: 5, time_window_seconds: 3600, + refill_mode: RefillMode::Linear, }; let result = RateLimiterContract::validate_rate_limit_config(env.clone(), config); assert_eq!(result, Err(crate::rate_limiter::RateLimiterError::InvalidVotingLimit)); @@ -283,6 +286,7 @@ mod configuration_validation { bet_limit: 20, events_per_admin_limit: 5, time_window_seconds: 30, // Less than 60 seconds + refill_mode: RefillMode::Linear, }; let result = RateLimiterContract::validate_rate_limit_config(env.clone(), config); assert_eq!(result, Err(crate::rate_limiter::RateLimiterError::InvalidTimeWindow)); @@ -298,6 +302,7 @@ mod configuration_validation { bet_limit: 20, events_per_admin_limit: 5, time_window_seconds: 3000000, // More than 30 days + refill_mode: RefillMode::Linear, }; let result = RateLimiterContract::validate_rate_limit_config(env.clone(), config); assert_eq!(result, Err(crate::rate_limiter::RateLimiterError::InvalidTimeWindow)); @@ -321,6 +326,7 @@ mod update_rate_limits { bet_limit: 500, events_per_admin_limit: 100, time_window_seconds: 7200, + refill_mode: RefillMode::Linear, }; let result = client.try_update_rate_limits(&admin, &new_config); @@ -344,6 +350,7 @@ mod update_rate_limits { bet_limit: 20, events_per_admin_limit: 5, time_window_seconds: 3600, + refill_mode: RefillMode::Linear, }; let result = client.try_update_rate_limits(&admin, &invalid_config);