diff --git a/contracts/markets/Cargo.toml b/contracts/markets/Cargo.toml
new file mode 100644
index 00000000..5576b146
--- /dev/null
+++ b/contracts/markets/Cargo.toml
@@ -0,0 +1,20 @@
+[package]
+name = "markets"
+version = "0.0.0"
+edition = "2021"
+publish = false
+
+[lib]
+crate-type = ["lib"]
+doctest = false
+
+[dependencies]
+soroban-sdk = { workspace = true }
+predictify-hybrid = { path = "../predictify-hybrid" }
+
+[dev-dependencies]
+soroban-sdk = { workspace = true, features = ["testutils"] }
+
+[[test]]
+name = "auth_snap"
+path = "tests/auth_snap.rs"
diff --git a/contracts/markets/src/lib.rs b/contracts/markets/src/lib.rs
new file mode 100644
index 00000000..a591a937
--- /dev/null
+++ b/contracts/markets/src/lib.rs
@@ -0,0 +1,2 @@
+#![no_std]
+// Markets test utility package placeholder
diff --git a/contracts/markets/tests/auth_snap.rs b/contracts/markets/tests/auth_snap.rs
new file mode 100644
index 00000000..72991f86
--- /dev/null
+++ b/contracts/markets/tests/auth_snap.rs
@@ -0,0 +1,536 @@
+//! Per-entrypoint authorization snapshot tests.
+//!
+//! This integration suite *snapshots* the Soroban authorization required by
+//! every state-changing entrypoint of [`PredictifyHybrid`]. Instead of only
+//! checking that an authorized call succeeds and an unauthorized one fails,
+//! these tests inspect [`Env::auths`] immediately after each call and assert
+//! **which address the host actually required an authorization from**.
+//!
+//! Why a snapshot?
+//!
+//! * If a `require_auth` is ever dropped from an entrypoint, `env.auths()` for
+//! that call becomes empty and the matching test fails.
+//! * If the auth subject is ever rebound to the wrong argument (e.g. an admin
+//! setter that starts authorizing an attacker-controlled address), the
+//! captured subject no longer matches the expected one and the test fails.
+//! * Read-only entrypoints are pinned to *require no auth at all*, documenting
+//! the read/write authorization boundary.
+//!
+//! ## Why some entrypoints use a different check
+//!
+//! The Soroban host records an authorization only for an invocation that
+//! commits. When a call traps or returns `Err`, its recorded auths are rolled
+//! back and `env.auths()` comes back empty. Most snapshots below therefore
+//! drive the entrypoint through a *fully satisfied* happy path — including a
+//! real Stellar Asset Contract for stake transfers — and then read the auth
+//! back ("committed snapshot").
+//!
+//! Three entrypoints need runtime state this fixture deliberately does not
+//! build. For those the auth boundary is pinned directly instead
+//! ("auth boundary"): authorized calls must get *past* `require_auth` and fail
+//! on domain logic (`Err(Ok(..))`), while unauthorized calls must trap in
+//! `require_auth` (a matching `#[should_panic]` test).
+//!
+//! ## Entrypoint auth matrix
+//!
+//! | Entrypoint | Required auth subject | Verified by |
+//! |----------------------------|-----------------------|--------------------|
+//! | `create_market` | admin | committed snapshot |
+//! | `resolve_market_manual` | admin | committed snapshot |
+//! | `collect_fees` | admin | committed snapshot |
+//! | `set_platform_fee` | admin | committed snapshot |
+//! | `set_treasury` | admin | committed snapshot |
+//! | `set_global_claim_period` | admin | committed snapshot |
+//! | `set_market_claim_period` | admin | committed snapshot |
+//! | `extend_deadline` | admin | committed snapshot |
+//! | `sweep_unclaimed_winnings` | admin | auth boundary |
+//! | `vote` | user | committed snapshot |
+//! | `place_bet` | user | committed snapshot |
+//! | `cancel_bet` | user | committed snapshot |
+//! | `claim_winnings` | user | auth boundary |
+//! | `dispute_market` | user | auth boundary |
+//! | `get_market` (read-only) | none | committed snapshot |
+//! | `get_market_bet_stats` | none | committed snapshot |
+
+use predictify_hybrid::{
+ Error, OracleConfig, OracleProvider, PredictifyHybrid, PredictifyHybridClient,
+};
+use soroban_sdk::{
+ testutils::{Address as _, Ledger},
+ token::StellarAssetClient,
+ Address, Env, String, Symbol, Vec,
+};
+
+// ============================================================
+// Fixture
+// ============================================================
+
+/// A fully wired contract: registered Stellar Asset Contract for stake
+/// transfers, `TokenID` stored in contract state, and an initialized admin.
+struct Fixture {
+ env: Env,
+ cid: Address,
+ admin: Address,
+ token_id: Address,
+}
+
+impl Fixture {
+ fn new() -> Self {
+ let env = Env::default();
+ env.mock_all_auths();
+
+ let admin = Address::generate(&env);
+ let cid = env.register(PredictifyHybrid, ());
+
+ // Register a Stellar asset so the contract's token client resolves.
+ let token_id = env
+ .register_stellar_asset_contract_v2(Address::generate(&env))
+ .address();
+
+ // Wire the token before initializing so stake transfers work.
+ env.as_contract(&cid, || {
+ env.storage()
+ .persistent()
+ .set(&Symbol::new(&env, "TokenID"), &token_id);
+ });
+
+ PredictifyHybridClient::new(&env, &cid).initialize(&admin, &Some(200i128), &None);
+
+ Fixture {
+ env,
+ cid,
+ admin,
+ token_id,
+ }
+ }
+
+ fn client(&self) -> PredictifyHybridClient<'_> {
+ PredictifyHybridClient::new(&self.env, &self.cid)
+ }
+
+ /// Create a funded user able to cover any stake used in these tests.
+ fn user(&self) -> Address {
+ let u = Address::generate(&self.env);
+ StellarAssetClient::new(&self.env, &self.token_id).mint(&u, &100_000_000_000i128);
+ u
+ }
+
+ fn oracle(&self) -> OracleConfig {
+ OracleConfig {
+ provider: OracleProvider::reflector(),
+ oracle_address: Address::from_str(
+ &self.env,
+ "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF",
+ ),
+ feed_id: String::from_str(&self.env, "BTC/USD"),
+ threshold: 50_000,
+ comparison: String::from_str(&self.env, "gt"),
+ }
+ }
+
+ /// Create a standard two-outcome, 30-day market owned by `admin`.
+ fn market(&self) -> Symbol {
+ let mut outcomes = Vec::new(&self.env);
+ outcomes.push_back(String::from_str(&self.env, "yes"));
+ outcomes.push_back(String::from_str(&self.env, "no"));
+ self.client().create_market(
+ &self.admin,
+ &String::from_str(&self.env, "Will BTC reach 100k?"),
+ &outcomes,
+ &30u32,
+ &self.oracle(),
+ &None,
+ &86_400u64,
+ &None,
+ &None,
+ &None,
+ )
+ }
+
+ fn yes(&self) -> String {
+ String::from_str(&self.env, "yes")
+ }
+
+ /// Advance ~31 days so a 30-day market is past its end time.
+ fn advance_past_end(&self) {
+ self.env
+ .ledger()
+ .with_mut(|l| l.timestamp += 31 * 24 * 60 * 60);
+ }
+
+ /// Advance past the default 86_400 s dispute window.
+ fn advance_past_dispute(&self) {
+ self.env.ledger().with_mut(|l| l.timestamp += 86_401);
+ }
+
+ /// Addresses the most recent top-level invocation required auth from.
+ fn required_auth(&self) -> std::vec::Vec
{
+ self.env
+ .auths()
+ .iter()
+ .map(|(addr, _)| addr.clone())
+ .collect()
+ }
+
+ /// Assert the last invocation required an authorization from `expected`.
+ ///
+ /// An empty auth set means either the entrypoint performed no
+ /// `require_auth`, or the call failed and the host rolled its auths back.
+ fn assert_requires_auth(&self, expected: &Address, label: &str) {
+ let required = self.required_auth();
+ assert!(
+ !required.is_empty(),
+ "{label}: no auth recorded — the entrypoint either skipped require_auth \
+ or the call failed before committing"
+ );
+ assert!(
+ required.contains(expected),
+ "{label}: expected auth from {expected:?}, captured {required:?}"
+ );
+ }
+
+ /// Assert the last invocation required *no* authorization (read-only).
+ fn assert_no_auth(&self, label: &str) {
+ let required = self.required_auth();
+ assert!(
+ required.is_empty(),
+ "{label}: expected no auth for a read-only entrypoint, captured {required:?}"
+ );
+ }
+}
+
+// ============================================================
+// Auth-boundary assertions for entrypoints whose happy path needs
+// state this fixture does not construct
+// ============================================================
+
+/// Assert the call got *past* `require_auth`.
+///
+/// A `try_*` invocation reports a contract-level failure as `Err(Ok(..))` and a
+/// host-level trap — which is how a rejected `require_auth` surfaces — as
+/// `Err(Err(InvokeError))`. So reaching a contract error proves authorization
+/// succeeded and the call failed later, on domain logic.
+fn assert_auth_passed(
+ result: &Result, Result>,
+ label: &str,
+) {
+ if let Err(Err(invoke_err)) = result {
+ panic!(
+ "{label}: expected the call to pass require_auth and fail on domain \
+ logic, but it trapped at the host level: {invoke_err:?}"
+ );
+ }
+}
+
+// ============================================================
+// Admin-scoped entrypoints — snapshot: requires admin auth
+// ============================================================
+
+#[test]
+fn snapshot_create_market_requires_admin_auth() {
+ let f = Fixture::new();
+ let _ = f.market();
+ f.assert_requires_auth(&f.admin, "create_market");
+}
+
+#[test]
+fn snapshot_resolve_market_manual_requires_admin_auth() {
+ let f = Fixture::new();
+ let market_id = f.market();
+ f.advance_past_end();
+ f.client()
+ .resolve_market_manual(&f.admin, &market_id, &f.yes());
+ f.assert_requires_auth(&f.admin, "resolve_market_manual");
+}
+
+#[test]
+fn snapshot_set_platform_fee_requires_admin_auth() {
+ let f = Fixture::new();
+ f.client().set_platform_fee(&f.admin, &300i128);
+ f.assert_requires_auth(&f.admin, "set_platform_fee");
+}
+
+#[test]
+fn snapshot_set_treasury_requires_admin_auth() {
+ let f = Fixture::new();
+ let treasury = Address::generate(&f.env);
+ f.client().set_treasury(&f.admin, &treasury);
+ f.assert_requires_auth(&f.admin, "set_treasury");
+}
+
+#[test]
+fn snapshot_set_global_claim_period_requires_admin_auth() {
+ let f = Fixture::new();
+ f.client().set_global_claim_period(&f.admin, &604_800u64);
+ f.assert_requires_auth(&f.admin, "set_global_claim_period");
+}
+
+#[test]
+fn snapshot_set_market_claim_period_requires_admin_auth() {
+ let f = Fixture::new();
+ let market_id = f.market();
+ f.client()
+ .set_market_claim_period(&f.admin, &market_id, &604_800u64);
+ f.assert_requires_auth(&f.admin, "set_market_claim_period");
+}
+
+#[test]
+fn snapshot_extend_deadline_requires_admin_auth() {
+ let f = Fixture::new();
+ let market_id = f.market();
+ f.client().extend_deadline(
+ &f.admin,
+ &market_id,
+ &7u32,
+ &String::from_str(&f.env, "More time needed"),
+ );
+ f.assert_requires_auth(&f.admin, "extend_deadline");
+}
+
+#[test]
+fn snapshot_collect_fees_requires_admin_auth() {
+ let f = Fixture::new();
+ let market_id = f.market();
+
+ // Stake enough that the 2% platform fee clears MIN_FEE_AMOUNT.
+ let user = f.user();
+ f.client()
+ .vote(&user, &market_id, &f.yes(), &1_000_000_000i128);
+
+ f.advance_past_end();
+ f.client()
+ .resolve_market_manual(&f.admin, &market_id, &f.yes());
+
+ f.client().collect_fees(&f.admin, &market_id);
+ f.assert_requires_auth(&f.admin, "collect_fees");
+}
+
+/// `sweep_unclaimed_winnings` needs runtime state this fixture does not build,
+/// so instead of a committed-call snapshot we pin the auth boundary directly:
+/// authorized -> gets past `require_auth` and fails on domain logic,
+/// unauthorized -> trapped by `require_auth`.
+#[test]
+fn snapshot_sweep_unclaimed_winnings_requires_admin_auth() {
+ let f = Fixture::new();
+ let market_id = f.market();
+
+ let result = f
+ .client()
+ .try_sweep_unclaimed_winnings(&f.admin, &market_id, &false);
+ assert_auth_passed(&result, "sweep_unclaimed_winnings");
+}
+
+#[test]
+#[should_panic]
+fn edge_sweep_unclaimed_winnings_without_auth_panics() {
+ let f = Fixture::new();
+ let market_id = f.market();
+ f.env.set_auths(&[]);
+ f.client()
+ .sweep_unclaimed_winnings(&f.admin, &market_id, &false);
+}
+
+// ============================================================
+// User-scoped entrypoints — snapshot: requires user auth
+// ============================================================
+
+#[test]
+fn snapshot_vote_requires_user_auth() {
+ let f = Fixture::new();
+ let market_id = f.market();
+ let user = f.user();
+ f.client().vote(&user, &market_id, &f.yes(), &1_000_000i128);
+ f.assert_requires_auth(&user, "vote");
+}
+
+#[test]
+fn snapshot_place_bet_requires_user_auth() {
+ let f = Fixture::new();
+ let market_id = f.market();
+ let user = f.user();
+ f.client()
+ .place_bet(&user, &market_id, &f.yes(), &1_000_000i128, &250i128);
+ f.assert_requires_auth(&user, "place_bet");
+}
+
+#[test]
+fn snapshot_cancel_bet_requires_user_auth() {
+ let f = Fixture::new();
+ let market_id = f.market();
+ let user = f.user();
+ f.client()
+ .place_bet(&user, &market_id, &f.yes(), &1_000_000i128, &250i128);
+ f.client().cancel_bet(&user, &market_id);
+ f.assert_requires_auth(&user, "cancel_bet");
+}
+
+/// `claim_winnings` reaches `AlreadyClaimed` in this fixture, so the committed
+/// snapshot is unavailable; pin the auth boundary instead (see
+/// `snapshot_sweep_unclaimed_winnings_requires_admin_auth`).
+#[test]
+fn snapshot_claim_winnings_requires_user_auth() {
+ let f = Fixture::new();
+ let market_id = f.market();
+ let user = f.user();
+ f.client()
+ .vote(&user, &market_id, &f.yes(), &10_000_000i128);
+
+ f.advance_past_end();
+ f.client()
+ .resolve_market_manual(&f.admin, &market_id, &f.yes());
+ f.advance_past_dispute();
+
+ let result = f.client().try_claim_winnings(&user, &market_id);
+ assert_auth_passed(&result, "claim_winnings");
+}
+
+#[test]
+#[should_panic]
+fn edge_claim_winnings_without_auth_panics() {
+ let f = Fixture::new();
+ let market_id = f.market();
+ let user = f.user();
+ f.env.set_auths(&[]);
+ f.client().claim_winnings(&user, &market_id);
+}
+
+/// `dispute_market` is rejected by its market-state validator in this fixture,
+/// so the committed snapshot is unavailable; pin the auth boundary instead
+/// (see `snapshot_sweep_unclaimed_winnings_requires_admin_auth`).
+#[test]
+fn snapshot_dispute_market_requires_user_auth() {
+ let f = Fixture::new();
+ let market_id = f.market();
+ let user = f.user();
+ f.client()
+ .vote(&user, &market_id, &f.yes(), &10_000_000i128);
+ f.advance_past_end();
+
+ let result = f
+ .client()
+ .try_dispute_market(&user, &market_id, &10_000_000i128, &None);
+ assert_auth_passed(&result, "dispute_market");
+}
+
+#[test]
+#[should_panic]
+fn edge_dispute_market_without_auth_panics() {
+ let f = Fixture::new();
+ let market_id = f.market();
+ let user = f.user();
+ f.env.set_auths(&[]);
+ f.client()
+ .dispute_market(&user, &market_id, &10_000_000i128, &None);
+}
+
+// ============================================================
+// Read-only entrypoints — snapshot: requires no auth
+// ============================================================
+
+#[test]
+fn snapshot_get_market_requires_no_auth() {
+ let f = Fixture::new();
+ let market_id = f.market();
+ let _ = f.client().get_market(&market_id);
+ f.assert_no_auth("get_market");
+}
+
+#[test]
+fn snapshot_get_market_bet_stats_requires_no_auth() {
+ let f = Fixture::new();
+ let market_id = f.market();
+ let _ = f.client().get_market_bet_stats(&market_id);
+ f.assert_no_auth("get_market_bet_stats");
+}
+
+// ============================================================
+// Edge cases
+// ============================================================
+
+/// A user entrypoint must bind `require_auth` to the acting user, never to the
+/// admin — even though the admin is authorized in the same environment.
+#[test]
+fn snapshot_vote_subject_is_user_not_admin() {
+ let f = Fixture::new();
+ let market_id = f.market();
+ let user = f.user();
+ f.client().vote(&user, &market_id, &f.yes(), &1_000_000i128);
+
+ let required = f.required_auth();
+ assert!(
+ required.contains(&user),
+ "vote must require the acting user"
+ );
+ assert!(
+ !required.contains(&f.admin),
+ "vote must not require admin auth: {required:?}"
+ );
+}
+
+/// Two distinct users are authorized independently: user B's vote must record
+/// B's auth, not A's, proving the subject tracks the argument.
+#[test]
+fn snapshot_vote_subject_tracks_argument_across_users() {
+ let f = Fixture::new();
+ let market_id = f.market();
+ let user_a = f.user();
+ let user_b = f.user();
+
+ f.client()
+ .vote(&user_a, &market_id, &f.yes(), &1_000_000i128);
+ f.assert_requires_auth(&user_a, "vote(user_a)");
+
+ f.client().vote(
+ &user_b,
+ &market_id,
+ &String::from_str(&f.env, "no"),
+ &1_000_000i128,
+ );
+ let required = f.required_auth();
+ assert!(
+ required.contains(&user_b),
+ "vote must require user_b, captured {required:?}"
+ );
+ assert!(
+ !required.contains(&user_a),
+ "user_a's auth must not satisfy user_b's vote: {required:?}"
+ );
+}
+
+/// A non-admin caller is rejected with `Error::Unauthorized` even while
+/// `mock_all_auths` is active, because admin entrypoints check the caller
+/// against the stored admin identity rather than trusting a bare signature.
+#[test]
+fn edge_non_admin_set_platform_fee_is_unauthorized() {
+ let f = Fixture::new();
+ let attacker = Address::generate(&f.env);
+ let result = f.client().try_set_platform_fee(&attacker, &300i128);
+ assert_eq!(
+ result,
+ Err(Ok(Error::Unauthorized)),
+ "non-admin must not be able to set the platform fee"
+ );
+}
+
+/// With no auths mocked, a user entrypoint must fail in `require_auth` rather
+/// than silently proceeding.
+#[test]
+#[should_panic]
+fn edge_vote_without_auth_panics() {
+ let f = Fixture::new();
+ let market_id = f.market();
+ let user = f.user();
+
+ // Clear all mocked auths: the user's require_auth can no longer be satisfied.
+ f.env.set_auths(&[]);
+ f.client().vote(&user, &market_id, &f.yes(), &1_000_000i128);
+}
+
+/// With no auths mocked, an admin entrypoint must fail in `require_auth`.
+#[test]
+#[should_panic]
+fn edge_set_platform_fee_without_auth_panics() {
+ let f = Fixture::new();
+ f.env.set_auths(&[]);
+ f.client().set_platform_fee(&f.admin, &300i128);
+}
diff --git a/contracts/predictify-hybrid/src/admin.rs b/contracts/predictify-hybrid/src/admin.rs
index 85b1f30c..8fc8ede9 100644
--- a/contracts/predictify-hybrid/src/admin.rs
+++ b/contracts/predictify-hybrid/src/admin.rs
@@ -1749,6 +1749,56 @@ impl OracleAdminCooldownManager {
}
}
+/// State for betting admin cooldowns
+#[derive(Clone, Debug, Eq, PartialEq)]
+#[contracttype]
+pub struct BettingAdminCooldownState {
+ pub cooldown_seconds: u64,
+ pub last_action_timestamp: u64,
+}
+
+pub struct BettingAdminCooldownManager;
+
+impl BettingAdminCooldownManager {
+ pub fn get_state(env: &Env) -> BettingAdminCooldownState {
+ env.storage().persistent().get(&crate::storage::DataKey::BettingAdminCooldownState)
+ .unwrap_or(BettingAdminCooldownState {
+ cooldown_seconds: 0,
+ last_action_timestamp: 0,
+ })
+ }
+
+ pub fn set_cooldown(env: &Env, admin: &Address, cooldown_seconds: u64) -> Result<(), Error> {
+ admin.require_auth();
+ AdminAccessControl::validate_permission(env, admin, &AdminPermission::ConfigAdmin)?;
+ let mut state = Self::get_state(env);
+ state.cooldown_seconds = cooldown_seconds;
+ env.storage().persistent().set(&crate::storage::DataKey::BettingAdminCooldownState, &state);
+ Ok(())
+ }
+
+ pub fn enforce_cooldown(env: &Env, admin: &Address) -> Result<(), Error> {
+ let mut state = Self::get_state(env);
+ if state.cooldown_seconds == 0 {
+ return Ok(());
+ }
+ let current_time = env.ledger().timestamp();
+
+ let cooldown_end = state.last_action_timestamp.checked_add(state.cooldown_seconds)
+ .ok_or(Error::Overflow)?;
+
+ if current_time < cooldown_end {
+ EventEmitter::emit_betting_admin_cooldown_hit(env, admin, state.last_action_timestamp, state.cooldown_seconds);
+ return Err(Error::BettingAdminCooldownActive);
+ }
+
+ state.last_action_timestamp = current_time;
+ env.storage().persistent().set(&crate::storage::DataKey::BettingAdminCooldownState, &state);
+
+ Ok(())
+ }
+}
+
pub struct MultisigManager;
impl MultisigManager {
diff --git a/contracts/predictify-hybrid/src/betting_cooldown_tests.rs b/contracts/predictify-hybrid/src/betting_cooldown_tests.rs
new file mode 100644
index 00000000..413c453e
--- /dev/null
+++ b/contracts/predictify-hybrid/src/betting_cooldown_tests.rs
@@ -0,0 +1,97 @@
+#![cfg(test)]
+
+use soroban_sdk::{testutils::{Address as _, Ledger}, Address, Env, Symbol, String};
+use crate::PredictifyHybridContract;
+use crate::PredictifyHybridContractClient;
+use crate::err::Error;
+
+fn setup_test() -> (Env, PredictifyHybridContractClient<'static>, Address) {
+ let env = Env::default();
+ let contract_id = env.register_contract(None, PredictifyHybridContract);
+ let client = PredictifyHybridContractClient::new(&env, &contract_id);
+ let admin = Address::generate(&env);
+
+ // Setup admin
+ client.initialize(
+ &admin,
+ &String::from_str(&env, "Test Token"),
+ &String::from_str(&env, "TTK"),
+ &18,
+ );
+
+ (env, client, admin)
+}
+
+#[test]
+fn test_betting_admin_cooldown() {
+ let (env, client, admin) = setup_test();
+
+ env.mock_all_auths();
+
+ // Set betting admin cooldown to 100 seconds
+ client.set_betting_admin_cooldown(&admin, &100);
+
+ // First call to set_global_bet_limits should succeed
+ client.set_global_bet_limits(&admin, &100_000000i128, &10_000_000000i128);
+
+ // Doing any critical action immediately again should fail
+ let res = client.try_set_global_bet_limits(
+ &admin,
+ &100_000000i128,
+ &10_000_000000i128,
+ );
+ assert_eq!(res.unwrap_err().unwrap(), Error::BettingAdminCooldownActive);
+
+ // Advance time by 50 seconds (cooldown still active)
+ env.ledger().with_mut(|l| l.timestamp += 50);
+
+ let res2 = client.try_set_global_bet_limits(
+ &admin,
+ &100_000000i128,
+ &10_000_000000i128,
+ );
+ assert_eq!(res2.unwrap_err().unwrap(), Error::BettingAdminCooldownActive);
+
+ // Advance time by another 51 seconds (total 101, cooldown elapsed)
+ env.ledger().with_mut(|l| l.timestamp += 51);
+
+ client.set_global_bet_limits(&admin, &100_000000i128, &10_000_000000i128); // Should succeed
+}
+
+#[test]
+fn test_betting_admin_cooldown_cross_functions() {
+ let (env, client, admin) = setup_test();
+
+ env.mock_all_auths();
+
+ // Set betting admin cooldown to 60 seconds
+ client.set_betting_admin_cooldown(&admin, &60);
+
+ // Call set_global_bet_limits
+ client.set_global_bet_limits(&admin, &100_000000i128, &10_000_000000i128);
+
+ // Call set_event_bet_limits immediately on a market (should fail)
+ let market_id = Symbol::new(&env, "m1");
+ let res = client.try_set_event_bet_limits(
+ &admin,
+ &market_id,
+ &100_000000i128,
+ &10_000_000000i128,
+ );
+ assert_eq!(res.unwrap_err().unwrap(), Error::BettingAdminCooldownActive);
+
+ // Advance time by 61 seconds
+ env.ledger().with_mut(|l| l.timestamp += 61);
+
+ // Now call set_event_bet_limits (should succeed)
+ client.set_event_bet_limits(
+ &admin,
+ &market_id,
+ &100_000000i128,
+ &10_000_000000i128,
+ );
+
+ // Call set_market_max_bet_cap immediately (should fail)
+ let res2 = client.try_set_market_max_bet_cap(&admin, &market_id, &5_000_000000i128);
+ assert_eq!(res2.unwrap_err().unwrap(), Error::BettingAdminCooldownActive);
+}
diff --git a/contracts/predictify-hybrid/src/err.rs b/contracts/predictify-hybrid/src/err.rs
index 456b8583..57ea6bef 100644
--- a/contracts/predictify-hybrid/src/err.rs
+++ b/contracts/predictify-hybrid/src/err.rs
@@ -260,6 +260,10 @@ pub enum Error {
OracleQuoteOutlier = 527,
/// Maximum number of unique participants has been reached for this market.
MaxParticipantsReached = 528,
+ /// Oracle admin action was attempted while the cooldown is active.
+ OracleAdminCooldownActive = 529,
+ /// Betting admin action was attempted while the cooldown is active.
+ BettingAdminCooldownActive = 530,
}
// ===== ERROR CATEGORIZATION AND RECOVERY SYSTEM =====
diff --git a/contracts/predictify-hybrid/src/events.rs b/contracts/predictify-hybrid/src/events.rs
index c3f563f3..67dd60a5 100644
--- a/contracts/predictify-hybrid/src/events.rs
+++ b/contracts/predictify-hybrid/src/events.rs
@@ -3426,6 +3426,16 @@ impl EventEmitter {
env.events().publish(topics, data);
}
+ /// Emit betting admin cooldown hit event
+ pub fn emit_betting_admin_cooldown_hit(env: &Env, admin: &Address, last_action: u64, cooldown: u64) {
+ let topics = (Symbol::new(env, "BettingAdmin"), Symbol::new(env, "CooldownHit"));
+ let mut data = Map::new(env);
+ data.set(String::from_str(env, "admin"), admin.to_val());
+ data.set(String::from_str(env, "last_action"), last_action);
+ data.set(String::from_str(env, "cooldown"), cooldown);
+ env.events().publish(topics, data);
+ }
+
/// Emit market closed event
pub fn emit_market_closed(env: &Env, market_id: &Symbol, admin: &Address) {
let event = MarketClosedEvent {
@@ -5610,6 +5620,46 @@ impl EventEmitter {
env.events()
.publish((symbol_short!("cum_set"), user.clone()), event);
}
+
+ /// Emit dispute vote cast event.
+ pub fn emit_dispute_vote_cast(
+ env: &Env,
+ dispute_id: &Symbol,
+ voter: &Address,
+ vote: bool,
+ stake: i128,
+ ) {
+ let event = DisputeVoteCastEvent {
+ dispute_id: dispute_id.clone(),
+ voter: voter.clone(),
+ vote,
+ stake,
+ nonce: Self::get_and_increment_nonce(env, symbol_short!("disp_vt").clone()),
+ timestamp: env.ledger().timestamp(),
+ };
+ Self::store_event(env, &symbol_short!("disp_vt"), &event);
+ env.events()
+ .publish((symbol_short!("disp_vt"), dispute_id.clone()), event);
+ }
+
+ /// Emit dispute fee distributed event.
+ pub fn emit_dispute_fee_distributed(
+ env: &Env,
+ dispute_id: &Symbol,
+ total_fees: i128,
+ fees_distributed: bool,
+ ) {
+ let event = DisputeFeeDistributedEvent {
+ dispute_id: dispute_id.clone(),
+ total_fees,
+ fees_distributed,
+ nonce: Self::get_and_increment_nonce(env, symbol_short!("disp_fee").clone()),
+ timestamp: env.ledger().timestamp(),
+ };
+ Self::store_event(env, &symbol_short!("disp_fee"), &event);
+ env.events()
+ .publish((symbol_short!("disp_fee"), dispute_id.clone()), event);
+ }
}
#[cfg(test)]
@@ -5632,10 +5682,6 @@ mod focused_dispute_tests {
});
let events = env.events().all();
- // Expect at least one event with 3 topics: (topic0, topic1, topic2)
- // topic0 = dispt_opn
- // topic1 = mkt_123
- // topic2 = 1 (schema version)
let mut found = false;
for event in events.events().iter() {
@@ -5651,4 +5697,131 @@ mod focused_dispute_tests {
}
assert!(found, "DisputeOpenedEvent not found with correct topic structure");
}
+
+ #[test]
+ fn test_dispute_vote_cast_event_topics() {
+ let env = Env::default();
+ let contract_id = env.register(crate::PredictifyHybrid, ());
+
+ let dispute_id = Symbol::new(&env, "disp_123");
+ let voter = Address::generate(&env);
+ let vote = true;
+ let stake = 100_000_000i128;
+
+ env.as_contract(&contract_id, || {
+ EventEmitter::emit_dispute_vote_cast(&env, &dispute_id, &voter, vote, stake);
+ });
+
+ let events = env.events().all();
+ let mut found = false;
+ for event in events.events().iter() {
+ if event.2.len() == 2 {
+ let topic0: Symbol = event.2.get(0).unwrap().try_into_val(&env).unwrap();
+ let topic1: Symbol = event.2.get(1).unwrap().try_into_val(&env).unwrap();
+
+ if topic0 == symbol_short!("disp_vt") {
+ assert_eq!(topic1, dispute_id, "Dispute ID must be topic1");
+ found = true;
+ }
+ }
+ }
+ assert!(found, "DisputeVoteCastEvent not found with correct topic structure");
+ }
+
+ #[test]
+ fn test_dispute_fee_distributed_event_topics() {
+ let env = Env::default();
+ let contract_id = env.register(crate::PredictifyHybrid, ());
+
+ let dispute_id = Symbol::new(&env, "disp_123");
+ let total_fees = 1_000_000i128;
+ let fees_distributed = true;
+
+ env.as_contract(&contract_id, || {
+ EventEmitter::emit_dispute_fee_distributed(&env, &dispute_id, total_fees, fees_distributed);
+ });
+
+ let events = env.events().all();
+ let mut found = false;
+ for event in events.events().iter() {
+ if event.2.len() == 2 {
+ let topic0: Symbol = event.2.get(0).unwrap().try_into_val(&env).unwrap();
+ let topic1: Symbol = event.2.get(1).unwrap().try_into_val(&env).unwrap();
+
+ if topic0 == symbol_short!("disp_fee") {
+ assert_eq!(topic1, dispute_id, "Dispute ID must be topic1");
+ found = true;
+ }
+ }
+ }
+ assert!(found, "DisputeFeeDistributedEvent not found with correct topic structure");
+ }
}
+
+#[cfg(test)]
+mod focused_betting_tests {
+ use super::*;
+ use soroban_sdk::{testutils::{Address as _, Events}, Address, Env, IntoVal, Symbol, TryIntoVal, Val, String};
+
+ #[test]
+ fn test_bet_placed_event_topics() {
+ let env = Env::default();
+ let contract_id = env.register(crate::PredictifyHybrid, ());
+
+ let market_id = Symbol::new(&env, "mkt_123");
+ let bettor = Address::generate(&env);
+ let outcome = String::from_str(&env, "Yes");
+ let amount = 10_000_000i128;
+
+ env.as_contract(&contract_id, || {
+ EventEmitter::emit_bet_placed(&env, &market_id, &bettor, &outcome, amount);
+ });
+
+ let events = env.events().all();
+ let mut found = false;
+ for event in events.events().iter() {
+ if event.2.len() == 2 {
+ let topic0: Symbol = event.2.get(0).unwrap().try_into_val(&env).unwrap();
+ let topic1: Symbol = event.2.get(1).unwrap().try_into_val(&env).unwrap();
+
+ if topic0 == symbol_short!("bet_plc") {
+ assert_eq!(topic1, market_id, "Market ID must be topic1");
+ found = true;
+ }
+ }
+ }
+ assert!(found, "BetPlacedEvent not found with correct topic structure");
+ }
+
+ #[test]
+ fn test_bet_status_updated_event_topics() {
+ let env = Env::default();
+ let contract_id = env.register(crate::PredictifyHybrid, ());
+
+ let market_id = Symbol::new(&env, "mkt_123");
+ let bettor = Address::generate(&env);
+ let old_status = String::from_str(&env, "Active");
+ let new_status = String::from_str(&env, "Won");
+ let payout = Some(15_000_000i128);
+
+ env.as_contract(&contract_id, || {
+ EventEmitter::emit_bet_status_updated(&env, &market_id, &bettor, &old_status, &new_status, payout);
+ });
+
+ let events = env.events().all();
+ let mut found = false;
+ for event in events.events().iter() {
+ if event.2.len() == 2 {
+ let topic0: Symbol = event.2.get(0).unwrap().try_into_val(&env).unwrap();
+ let topic1: Symbol = event.2.get(1).unwrap().try_into_val(&env).unwrap();
+
+ if topic0 == symbol_short!("bet_upd") {
+ assert_eq!(topic1, market_id, "Market ID must be topic1");
+ found = true;
+ }
+ }
+ }
+ assert!(found, "BetStatusUpdatedEvent not found with correct topic structure");
+ }
+}
+
diff --git a/contracts/predictify-hybrid/src/lib.rs b/contracts/predictify-hybrid/src/lib.rs
index e63b03f0..58c61867 100644
--- a/contracts/predictify-hybrid/src/lib.rs
+++ b/contracts/predictify-hybrid/src/lib.rs
@@ -1830,6 +1830,8 @@ impl PredictifyHybrid {
if admin != stored_admin {
panic_with_error!(env, Error::Unauthorized);
}
+ crate::admin::BettingAdminCooldownManager::enforce_cooldown(&env, &admin)
+ .unwrap_or_else(|e| panic_with_error!(env, e));
env.storage()
.instance()
.set(&Symbol::new(&env, "gov_min_bps"), &min_bet_bps);
@@ -1860,6 +1862,9 @@ impl PredictifyHybrid {
panic_with_error!(env, Error::Unauthorized);
}
+ crate::admin::BettingAdminCooldownManager::enforce_cooldown(&env, &admin)
+ .unwrap_or_else(|e| panic_with_error!(env, e));
+
recovery::UnclaimedWinningsPolicy::set_global_claim_period(&env, claim_period_seconds);
EventEmitter::emit_claim_period_updated(&env, &admin, claim_period_seconds);
}
@@ -1889,6 +1894,9 @@ impl PredictifyHybrid {
panic_with_error!(env, Error::Unauthorized);
}
+ crate::admin::BettingAdminCooldownManager::enforce_cooldown(&env, &admin)
+ .unwrap_or_else(|e| panic_with_error!(env, e));
+
if markets::MarketStateManager::get_market(&env, &market_id).is_err() {
panic_with_error!(env, Error::MarketNotFound);
}
@@ -1920,6 +1928,9 @@ impl PredictifyHybrid {
panic_with_error!(env, Error::Unauthorized);
}
+ crate::admin::BettingAdminCooldownManager::enforce_cooldown(&env, &admin)
+ .unwrap_or_else(|e| panic_with_error!(env, e));
+
recovery::UnclaimedWinningsPolicy::set_treasury(&env, &treasury);
EventEmitter::emit_treasury_updated(&env, &admin, &treasury);
}
@@ -1946,6 +1957,8 @@ impl PredictifyHybrid {
return Err(Error::Unauthorized);
}
+ crate::admin::BettingAdminCooldownManager::enforce_cooldown(&env, &admin)?;
+
let mut market: Market = env
.storage()
.persistent()
@@ -2284,6 +2297,20 @@ impl PredictifyHybrid {
Ok(())
}
+ /// Sets the cooldown period for oracle admin actions.
+ pub fn set_oracle_admin_cooldown(env: Env, admin: Address, seconds: u64) -> Result<(), Error> {
+ Self::require_primary_admin(&env, &admin)?;
+ crate::admin::OracleAdminCooldownManager::set_cooldown(&env, &admin, seconds)?;
+ Ok(())
+ }
+
+ /// Sets the cooldown period for betting admin actions.
+ pub fn set_betting_admin_cooldown(env: Env, admin: Address, seconds: u64) -> Result<(), Error> {
+ Self::require_primary_admin(&env, &admin)?;
+ crate::admin::BettingAdminCooldownManager::set_cooldown(&env, &admin, seconds)?;
+ Ok(())
+ }
+
pub fn resolve_market_manual(
env: Env,
admin: Address,
@@ -4567,6 +4594,7 @@ impl PredictifyHybrid {
max_bet: i128,
) -> Result<(), Error> {
Self::require_primary_admin(&env, &admin)?;
+ crate::admin::BettingAdminCooldownManager::enforce_cooldown(&env, &admin)?;
let limits = crate::types::BetLimits { min_bet, max_bet };
crate::bets::set_global_bet_limits(&env, &limits)?;
let scope = Symbol::new(&env, "global");
@@ -4601,6 +4629,7 @@ impl PredictifyHybrid {
max_bet: i128,
) -> Result<(), Error> {
Self::require_primary_admin(&env, &admin)?;
+ crate::admin::BettingAdminCooldownManager::enforce_cooldown(&env, &admin)?;
let limits = BetLimits { min_bet, max_bet };
crate::bets::set_event_bet_limits(&env, &market_id, &limits)?;
EventEmitter::emit_bet_limits_updated(&env, &admin, &market_id, min_bet, max_bet);
@@ -4652,6 +4681,7 @@ impl PredictifyHybrid {
cap: i128,
) -> Result<(), Error> {
Self::require_primary_admin(&env, &admin)?;
+ crate::admin::BettingAdminCooldownManager::enforce_cooldown(&env, &admin)?;
// cap == 0 is treated as "remove the cap"
if cap == 0 {
crate::bets::remove_market_max_bet_cap(&env, &market_id);
@@ -4699,6 +4729,7 @@ impl PredictifyHybrid {
market_id: Symbol,
) -> Result<(), Error> {
Self::require_primary_admin(&env, &admin)?;
+ crate::admin::BettingAdminCooldownManager::enforce_cooldown(&env, &admin)?;
crate::bets::remove_market_max_bet_cap(&env, &market_id);
EventEmitter::emit_bet_limits_updated(&env, &admin, &market_id, 0, 0);
Ok(())
diff --git a/contracts/predictify-hybrid/src/storage.rs b/contracts/predictify-hybrid/src/storage.rs
index 281d2207..4ac461de 100644
--- a/contracts/predictify-hybrid/src/storage.rs
+++ b/contracts/predictify-hybrid/src/storage.rs
@@ -159,6 +159,12 @@ pub enum DataKey {
MaxBetCap,
/// Per-user total stake in a market.
UserStake(Address, Symbol),
+ /// State for oracle admin cooldowns
+ OracleAdminCooldownState,
+ /// State for multisig rotation cooldowns
+ MultisigRotationState,
+ /// State for betting admin cooldowns
+ BettingAdminCooldownState,
}
/// Storage format version for migration tracking
diff --git a/contracts/predictify-hybrid/src/test.rs b/contracts/predictify-hybrid/src/test.rs
index e8521f7c..4ccbb580 100644
--- a/contracts/predictify-hybrid/src/test.rs
+++ b/contracts/predictify-hybrid/src/test.rs
@@ -7821,3 +7821,4 @@ fn test_empty_lists_allow_access() {
assert!(res.is_ok(), "Sin restricciones, el acceso debe ser libre");
}
mod oracle_cooldown_tests;
+mod betting_cooldown_tests;