diff --git a/Cargo.lock b/Cargo.lock index e5f79e6c..7a1795f3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -18,6 +18,7 @@ dependencies = [ name = "allowlist" version = "0.0.0" dependencies = [ + "proptest", "soroban-sdk", ] diff --git a/contracts/allowlist/Cargo.toml b/contracts/allowlist/Cargo.toml index 93f394bf..1341f0e4 100644 --- a/contracts/allowlist/Cargo.toml +++ b/contracts/allowlist/Cargo.toml @@ -13,6 +13,7 @@ soroban-sdk = { workspace = true } [dev-dependencies] soroban-sdk = { workspace = true, features = ["testutils"] } +proptest = "1.4" [[test]] name = "auth_boundary" diff --git a/contracts/allowlist/tests/proptest.rs b/contracts/allowlist/tests/proptest.rs index 04072e2a..c91c8f59 100644 --- a/contracts/allowlist/tests/proptest.rs +++ b/contracts/allowlist/tests/proptest.rs @@ -16,6 +16,7 @@ use soroban_sdk::{testutils::Address as _, Address, Env, Symbol, Vec}; use allowlist::{AllowlistContract, AllowlistContractClient, AllowlistError}; +use proptest::prelude::*; struct Ctx { env: Env, @@ -308,3 +309,55 @@ fn batch_is_allow_duplicates() { assert!(*is_allowed, "duplicate address should still be allowed"); } } + +proptest! { + #![proptest_config(ProptestConfig { + cases: 32, + .. ProptestConfig::default() + })] + + #[test] + fn batch_is_allowed_properties( + allowlist_len in 1usize..=5, + arbitrary_indices in prop::collection::vec(0usize..10, 0..=10), + absent_len in 1usize..=5, + ) { + let ctx = Ctx::new(10); + let list_id = ctx.sym("batch_props"); + ctx.client.create_allowlist(&ctx.admin, &list_id); + + let mut members = Vec::new(&ctx.env); + for index in 0..allowlist_len { + members.push_back(ctx.user(index as u32)); + } + ctx.client.add_addresses(&ctx.admin, &list_id, &members); + + for scenario in 0u8..5 { + let query_indices: std::vec::Vec = match scenario { + 0 => std::vec::Vec::new(), + // One more address than the allowlist contains. + 1 => (0..=allowlist_len).collect(), + // The query consists entirely of allowlisted addresses. + 2 => (0..allowlist_len).collect(), + // These indices are outside the allowlist's 0..5 member range. + 3 => (5..5 + absent_len).collect(), + // Arbitrary inputs also exercise duplicates and mixed membership. + _ => arbitrary_indices.clone(), + }; + + let mut query = Vec::new(&ctx.env); + for index in &query_indices { + query.push_back(ctx.user(*index as u32)); + } + + let results = ctx.client.batch_is_allowed(&list_id, &query); + prop_assert_eq!(results.len() as usize, query_indices.len()); + + for (position, index) in query_indices.iter().enumerate() { + let (address, allowed) = results.get(position as u32).unwrap(); + prop_assert_eq!(address, ctx.user(*index as u32)); + prop_assert_eq!(allowed, *index < allowlist_len); + } + } + } +} diff --git a/contracts/markets/src/errors.rs b/contracts/markets/src/errors.rs index b1724aee..e340e8ff 100644 --- a/contracts/markets/src/errors.rs +++ b/contracts/markets/src/errors.rs @@ -1,83 +1,83 @@ -use soroban_sdk::contracterror; - -/// A stable catalog of errors for the markets smart contract. -#[contracterror] -#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] -#[repr(u32)] -pub enum ContractError { - /// Action is unauthorized; typically thrown when an admin action is invoked by a non-admin. - Unauthorized = 1, - /// Market not found; thrown when querying or interacting with a non-existent market. - MarketNotFound = 2, - /// Market is closed; thrown when trying to interact with a market that has already ended. - MarketClosed = 3, - /// Market already resolved; thrown when attempting to resolve a market more than once. - MarketAlreadyResolved = 4, - /// Market not resolved; thrown when attempting to claim winnings before resolution. - MarketNotResolved = 5, - /// Invalid outcome; thrown when the provided outcome is not supported by the market. - InvalidOutcome = 6, - /// Invalid configuration; thrown when market setup parameters are out of bounds. - InvalidConfig = 7, - /// Overflow; thrown when math operations overflow, preventing unsafe state changes. - Overflow = 8, - /// Stake too small; thrown when a deposit or bet is below the minimum threshold. - StakeTooSmall = 9, - /// Invalid State; thrown when a generic state transition fails. - InvalidState = 10, - /// Admin action timelocked; thrown when an admin action is invoked before the cooldown has elapsed. - AdminActionTimelocked = 11, - // ===== ADMIN-SPECIFIC ERROR VARIANTS (12-19) ===== - /// Admin cooldown period is still active; the action cannot be executed yet. - AdminCooldownActive = 12, - /// The provided admin address is invalid (e.g., zero address or self-referential). - AdminAddressInvalid = 13, - /// The requested admin operation is not permitted in the current contract state. - AdminOperationNotPermitted = 14, - /// Winnings already claimed; thrown when a winner attempts to claim a second time - /// for the same market. Guards the checks-effects-interactions invariant in - /// `claim_winnings`. - AlreadyClaimed = 15, - /// Proposal not found; thrown when interacting with a non-existent pause proposal. - ProposalNotFound = 16, - /// Duplicate approval; thrown when an admin attempts to approve the same proposal twice. - ProposalAlreadyApproved = 17, - /// Proposal not executable; timelock has not expired or approval threshold not met. - ProposalNotExecutable = 18, - /// Invalid payout curve configuration. - InvalidPayoutCurve = 19, - /// Insufficient balance for withdrawal or operation. - InsufficientBalance = 20, - /// The configured maximum number of markets has been reached. - MarketLimitReached = 21, -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_error_variants() { - assert_eq!(ContractError::Unauthorized as u32, 1); - assert_eq!(ContractError::MarketNotFound as u32, 2); - assert_eq!(ContractError::MarketClosed as u32, 3); - assert_eq!(ContractError::MarketAlreadyResolved as u32, 4); - assert_eq!(ContractError::MarketNotResolved as u32, 5); - assert_eq!(ContractError::InvalidOutcome as u32, 6); - assert_eq!(ContractError::InvalidConfig as u32, 7); - assert_eq!(ContractError::Overflow as u32, 8); - assert_eq!(ContractError::StakeTooSmall as u32, 9); - assert_eq!(ContractError::InvalidState as u32, 10); - assert_eq!(ContractError::AdminActionTimelocked as u32, 11); - assert_eq!(ContractError::AdminCooldownActive as u32, 12); - assert_eq!(ContractError::AdminAddressInvalid as u32, 13); - assert_eq!(ContractError::AdminOperationNotPermitted as u32, 14); - assert_eq!(ContractError::AlreadyClaimed as u32, 15); - assert_eq!(ContractError::ProposalNotFound as u32, 16); - assert_eq!(ContractError::ProposalAlreadyApproved as u32, 17); - assert_eq!(ContractError::ProposalNotExecutable as u32, 18); - assert_eq!(ContractError::InvalidPayoutCurve as u32, 19); - assert_eq!(ContractError::InsufficientBalance as u32, 20); - assert_eq!(ContractError::MarketLimitReached as u32, 21); - } -} +use soroban_sdk::contracterror; + +/// A stable catalog of errors for the markets smart contract. +#[contracterror] +#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] +#[repr(u32)] +pub enum ContractError { + /// Action is unauthorized; typically thrown when an admin action is invoked by a non-admin. + Unauthorized = 1, + /// Market not found; thrown when querying or interacting with a non-existent market. + MarketNotFound = 2, + /// Market is closed; thrown when trying to interact with a market that has already ended. + MarketClosed = 3, + /// Market already resolved; thrown when attempting to resolve a market more than once. + MarketAlreadyResolved = 4, + /// Market not resolved; thrown when attempting to claim winnings before resolution. + MarketNotResolved = 5, + /// Invalid outcome; thrown when the provided outcome is not supported by the market. + InvalidOutcome = 6, + /// Invalid configuration; thrown when market setup parameters are out of bounds. + InvalidConfig = 7, + /// Overflow; thrown when math operations overflow, preventing unsafe state changes. + Overflow = 8, + /// Stake too small; thrown when a deposit or bet is below the minimum threshold. + StakeTooSmall = 9, + /// Invalid State; thrown when a generic state transition fails. + InvalidState = 10, + /// Admin action timelocked; thrown when an admin action is invoked before the cooldown has elapsed. + AdminActionTimelocked = 11, + // ===== ADMIN-SPECIFIC ERROR VARIANTS (12-19) ===== + /// Admin cooldown period is still active; the action cannot be executed yet. + AdminCooldownActive = 12, + /// The provided admin address is invalid (e.g., zero address or self-referential). + AdminAddressInvalid = 13, + /// The requested admin operation is not permitted in the current contract state. + AdminOperationNotPermitted = 14, + /// Winnings already claimed; thrown when a winner attempts to claim a second time + /// for the same market. Guards the checks-effects-interactions invariant in + /// `claim_winnings`. + AlreadyClaimed = 15, + /// Proposal not found; thrown when interacting with a non-existent pause proposal. + ProposalNotFound = 16, + /// Duplicate approval; thrown when an admin attempts to approve the same proposal twice. + ProposalAlreadyApproved = 17, + /// Proposal not executable; timelock has not expired or approval threshold not met. + ProposalNotExecutable = 18, + /// Invalid payout curve configuration. + InvalidPayoutCurve = 19, + /// Insufficient balance for withdrawal or operation. + InsufficientBalance = 20, + /// The configured maximum number of markets has been reached. + MarketLimitReached = 21, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_error_variants() { + assert_eq!(ContractError::Unauthorized as u32, 1); + assert_eq!(ContractError::MarketNotFound as u32, 2); + assert_eq!(ContractError::MarketClosed as u32, 3); + assert_eq!(ContractError::MarketAlreadyResolved as u32, 4); + assert_eq!(ContractError::MarketNotResolved as u32, 5); + assert_eq!(ContractError::InvalidOutcome as u32, 6); + assert_eq!(ContractError::InvalidConfig as u32, 7); + assert_eq!(ContractError::Overflow as u32, 8); + assert_eq!(ContractError::StakeTooSmall as u32, 9); + assert_eq!(ContractError::InvalidState as u32, 10); + assert_eq!(ContractError::AdminActionTimelocked as u32, 11); + assert_eq!(ContractError::AdminCooldownActive as u32, 12); + assert_eq!(ContractError::AdminAddressInvalid as u32, 13); + assert_eq!(ContractError::AdminOperationNotPermitted as u32, 14); + assert_eq!(ContractError::AlreadyClaimed as u32, 15); + assert_eq!(ContractError::ProposalNotFound as u32, 16); + assert_eq!(ContractError::ProposalAlreadyApproved as u32, 17); + assert_eq!(ContractError::ProposalNotExecutable as u32, 18); + assert_eq!(ContractError::InvalidPayoutCurve as u32, 19); + assert_eq!(ContractError::InsufficientBalance as u32, 20); + assert_eq!(ContractError::MarketLimitReached as u32, 21); + } +} diff --git a/contracts/markets/src/lib.rs b/contracts/markets/src/lib.rs index b0cbdc96..599a58a5 100644 --- a/contracts/markets/src/lib.rs +++ b/contracts/markets/src/lib.rs @@ -6,24 +6,6 @@ //! Provides a prediction-market subsystem where every state-changing //! entrypoint enforces `require_auth` on the acting [`Address`]. //! -//! # User Balance Fee Model -//! -//! Deposits are fee-free: `deposit_user_balance` credits the full deposited -//! amount to the user's internal balance. Fees are charged on exit instead. -//! `withdraw_user_balance` charges the greater of the configured minimum fee -//! and the basis-point fee on the requested withdrawal amount, except when -//! dust or full-withdrawal rules waive the fee. It removes the requested amount -//! from the internal balance and returns the post-fee `net_amount`; it does not -//! transfer tokens. `collect_user_fees` is a separate admin-triggered charge -//! based on the user's full current balance; it deducts from that balance and -//! adds to the collected-fees counter, without transferring tokens out of the -//! contract. -//! -//! These two fee paths are not mutually exclusive. An admin collection does -//! not mark funds as already charged, so a later user withdrawal can charge -//! another fee on the remaining balance. Callers should not use both paths for -//! the same funds when only one fee is intended. -//! //! # Auth Matrix //! //! | Function | Required Role | @@ -54,19 +36,21 @@ //! | `deposit_user_balance` | User (any address) | //! | `withdraw_user_balance` | User (any address) | //! | `collect_user_fees` | Admin | +//! | `collect_fees` | Liquidity provider | //! | `set_fee_config` | Admin | //! | `version` | Anyone (read-only) | pub mod errors; -use soroban_sdk::{contract, contractimpl, contracttype, Address, Env, String, Vec}; +use soroban_sdk::{ + contract, contractimpl, contracttype, panic_with_error, Address, Env, Map, String, Vec, +}; pub use errors::ContractError; /// Type alias used by generated client code and test harnesses. pub type Error = ContractError; -/// Finite safety bound for deployments that have not configured a market limit. const DEFAULT_MAX_MARKET_COUNT: u32 = 10_000; /// Persistent-storage keys used by the Markets contract. @@ -75,7 +59,7 @@ const DEFAULT_MAX_MARKET_COUNT: u32 = 10_000; pub enum DataKey { /// Sequential counter for generating unique market IDs. MarketCounter, - /// Maximum number of markets that may be created. + /// Maximum number of markets allowed. MarketLimit, /// Market data keyed by the numeric market ID. Market(u32), @@ -87,15 +71,24 @@ pub enum DataKey { Admin, /// Whether the contract has been initialized. Initialized, + /// List of additional authorized admin addresses. + AdminList, /// Liquidity provided by a user to a specific market. Liquidity(u32, Address), + /// Total liquidity provided to a market. + TotalLiquidity(u32), + /// Cumulative LP reward per unit of liquidity, scaled by `LP_REWARD_SCALE`. + LiquidityRewardIndex(u32), + /// Reward index checkpoint for a liquidity provider. + LiquidityRewardDebt(u32, Address), + /// Uncollected LP rewards for a provider. + LiquidityRewards(u32, Address), /// Records that a winner has already claimed their payout for a market. /// Written **before** any token transfer in `claim_winnings` to uphold the /// checks-effects-interactions pattern and prevent reentrancy double-claims. ClaimedBet(u32, Address), - /// Fractional payout numerator carried forward per market outcome. + /// Fractional payout numerator carried forward per outcome. PayoutRemainder(u32, u32), - /// Winning outcome indices recorded when a market is resolved. WinningOutcomes(u32), @@ -277,6 +270,10 @@ pub mod admin; #[contract] pub struct MarketsContract; +/// Share of winning payouts reserved for liquidity providers (10%). +pub const LP_PAYOUT_SHARE_BPS: i128 = 1_000; +const LP_REWARD_SCALE: i128 = 1_000_000_000_000; + impl MarketsContract { // ----------------------------------------------------------------------- // Internal Helpers @@ -288,12 +285,9 @@ impl MarketsContract { .persistent() .get::<_, Address>(&DataKey::Admin) { - if &admin == caller { + if admin == *caller { return true; } - } else { - // First caller with admin action becomes initial admin if unset. - return true; } let admin_list: Vec
= env @@ -353,196 +347,567 @@ impl MarketsContract { outcomes }) } -} - -#[contractimpl] -impl MarketsContract { - // ----------------------------------------------------------------------- - // Read-only Entrypoints - // ----------------------------------------------------------------------- - - /// @notice Returns the contract version. - pub fn version(_env: Env) -> u32 { - 7 - } - /// @notice Read a market from persistent storage and bump its TTL. - pub fn get_market(env: Env, market_id: soroban_sdk::Symbol) -> Option { - let market: Option = env.storage().persistent().get(&market_id); - if market.is_some() { - env.storage() - .persistent() - .extend_ttl(&market_id, 6307200, 6307200); + fn lp_payout_fee(env: &Env, market_id: u32, gross_payout: i128) -> i128 { + let total_liquidity: i128 = env + .storage() + .persistent() + .get(&DataKey::TotalLiquidity(market_id)) + .unwrap_or(0); + if total_liquidity <= 0 || gross_payout <= 0 { + return 0; } - market + + gross_payout + .checked_mul(LP_PAYOUT_SHARE_BPS) + .unwrap_or_else(|| panic_with_error!(env, ContractError::Overflow)) + / 10_000 } - /// Read the metadata required by the resolution subsystem. - pub fn get_market_for_resolution( - env: Env, + fn settle_liquidity_rewards( + env: &Env, market_id: u32, - ) -> Option<(Address, u64, u32, bool, bool)> { - let key = DataKey::Market(market_id); - let market: Option = env.storage().persistent().get(&key); - if market.is_some() { - let ttl = 6_307_200u32.min(env.storage().max_ttl()); - env.storage().persistent().extend_ttl(&key, ttl, ttl); + provider: &Address, + liquidity_amount: i128, + ) -> i128 { + let reward_index: i128 = env + .storage() + .persistent() + .get(&DataKey::LiquidityRewardIndex(market_id)) + .unwrap_or(0); + let reward_debt: i128 = env + .storage() + .persistent() + .get(&DataKey::LiquidityRewardDebt(market_id, provider.clone())) + .unwrap_or(0); + let accrued = liquidity_amount + .checked_mul(reward_index) + .unwrap_or_else(|| panic_with_error!(env, ContractError::Overflow)) + / LP_REWARD_SCALE; + let newly_earned = accrued + .checked_sub(reward_debt) + .unwrap_or_else(|| panic_with_error!(env, ContractError::Overflow)); + if newly_earned < 0 { + panic_with_error!(env, ContractError::InvalidState); } - market.map(|market| { - ( - market.creator, - market.end_time, - market.outcome_tags.len(), - market.resolved, - market.cancelled, - ) - }) - } - /// Return every winning outcome for a resolved market. - pub fn get_winning_outcomes(env: Env, market_id: u32) -> Option> { - let market_key = DataKey::Market(market_id); - let market: Option = env.storage().persistent().get(&market_key); - market.map(|market| { - let ttl = 6_307_200u32.min(env.storage().max_ttl()); - env.storage().persistent().extend_ttl(&market_key, ttl, ttl); - let outcomes_key = DataKey::WinningOutcomes(market_id); - if env.storage().persistent().has(&outcomes_key) { - env.storage() - .persistent() - .extend_ttl(&outcomes_key, ttl, ttl); - } - Self::winning_outcomes(&env, market_id, &market) - }) + let pending: i128 = env + .storage() + .persistent() + .get(&DataKey::LiquidityRewards(market_id, provider.clone())) + .unwrap_or(0); + let updated_pending = pending + .checked_add(newly_earned) + .unwrap_or_else(|| panic_with_error!(env, ContractError::Overflow)); + env.storage().persistent().set( + &DataKey::LiquidityRewards(market_id, provider.clone()), + &updated_pending, + ); + updated_pending } - /// Returns whether market operations are currently paused globally. - pub fn is_paused(env: Env) -> bool { - env.storage() + fn update_liquidity_reward_debt( + env: &Env, + market_id: u32, + provider: &Address, + liquidity_amount: i128, + ) { + let reward_index: i128 = env + .storage() .persistent() - .get(&DataKey::Paused) - .unwrap_or(false) + .get(&DataKey::LiquidityRewardIndex(market_id)) + .unwrap_or(0); + let reward_debt = liquidity_amount + .checked_mul(reward_index) + .unwrap_or_else(|| panic_with_error!(env, ContractError::Overflow)) + / LP_REWARD_SCALE; + env.storage().persistent().set( + &DataKey::LiquidityRewardDebt(market_id, provider.clone()), + &reward_debt, + ); } - /// Returns the primary admin address if set. - pub fn get_admin(env: Env) -> Option
{ - env.storage().persistent().get(&DataKey::Admin) - } + fn accrue_liquidity_reward(env: &Env, market_id: u32, fee_amount: i128) { + if fee_amount <= 0 { + return; + } + let total_liquidity: i128 = env + .storage() + .persistent() + .get(&DataKey::TotalLiquidity(market_id)) + .unwrap_or(0); + if total_liquidity <= 0 { + return; + } - /// Returns the configured market limit, or the finite default if unset. - pub fn get_market_limit(env: Env) -> u32 { + let scaled_reward = fee_amount + .checked_mul(LP_REWARD_SCALE) + .unwrap_or_else(|| panic_with_error!(env, ContractError::Overflow)); + let index_increment = scaled_reward / total_liquidity; + let reward_index: i128 = env + .storage() + .persistent() + .get(&DataKey::LiquidityRewardIndex(market_id)) + .unwrap_or(0); + let updated_index = reward_index + .checked_add(index_increment) + .unwrap_or_else(|| panic_with_error!(env, ContractError::Overflow)); env.storage() .persistent() - .get(&DataKey::MarketLimit) - .unwrap_or(DEFAULT_MAX_MARKET_COUNT) + .set(&DataKey::LiquidityRewardIndex(market_id), &updated_index); } - // ----------------------------------------------------------------------- - // Market Lifecycle - // ----------------------------------------------------------------------- - - /// Creates a new prediction market with default WinnerTakesAll payout curve. - pub fn create_market( - env: Env, - creator: Address, - question: String, - description: String, - end_time: u64, - resolution_source: String, - outcome_tags: Vec, - ) -> u32 { - Self::create_market_with_curve( - env.clone(), - creator, - question, - description, - end_time, - resolution_source, - outcome_tags, - PayoutCurveConfig::winner_takes_all(&env), - ) + fn scaled_weight_boundary(env: &Env, total_pool: i128, cumulative_weight: u32) -> i128 { + let whole = total_pool / 10_000; + let fraction = total_pool % 10_000; + let whole_share = match whole.checked_mul(cumulative_weight as i128) { + Some(value) => value, + None => panic_with_error!(env, ContractError::Overflow), + }; + whole_share + (fraction * cumulative_weight as i128) / 10_000 } - /// Creates a new prediction market with a specific configurable payout curve (#014). - pub fn create_market_with_curve( - env: Env, - creator: Address, - question: String, - description: String, - end_time: u64, - resolution_source: String, - outcome_tags: Vec, - curve: PayoutCurveConfig, - ) -> u32 { - creator.require_auth(); - - let counter: u32 = env - .storage() - .persistent() - .get(&DataKey::MarketCounter) - .unwrap_or(0u32); - if counter >= Self::get_market_limit(env.clone()) { - panic_with_error!(env, ContractError::MarketLimitReached); + fn custom_outcome_share( + env: &Env, + total_pool: i128, + curve: &PayoutCurveConfig, + outcome_index: u32, + ) -> i128 { + let mut cumulative_weight = 0u32; + let mut index = 0u32; + for weight in curve.custom_weights.iter() { + if index == outcome_index { + let next_weight = match cumulative_weight.checked_add(weight) { + Some(value) => value, + None => panic_with_error!(env, ContractError::InvalidPayoutCurve), + }; + return Self::scaled_weight_boundary(env, total_pool, next_weight) + - Self::scaled_weight_boundary(env, total_pool, cumulative_weight); + } + cumulative_weight = match cumulative_weight.checked_add(weight) { + Some(value) => value, + None => panic_with_error!(env, ContractError::InvalidPayoutCurve), + }; + index += 1; } + 0 + } - if outcome_tags.len() < 2 { - panic_with_error!(env, ContractError::InvalidConfig); + fn proportional_payout( + env: &Env, + market_id: u32, + outcome_index: u32, + stake: i128, + outcome_share: i128, + outcome_pool: i128, + ) -> (i128, Option<(u32, i128)>) { + if outcome_pool <= 0 { + return (0, None); } - Self::validate_payout_curve(&env, &curve, outcome_tags.len()); - - let market_id = match counter.checked_add(1) { - Some(id) => id, + let key = DataKey::PayoutRemainder(market_id, outcome_index); + let remainder: i128 = env.storage().persistent().get(&key).unwrap_or(0); + let numerator = match stake + .checked_mul(outcome_share) + .and_then(|value| value.checked_add(remainder)) + { + Some(value) => value, None => panic_with_error!(env, ContractError::Overflow), }; - - env.storage() - .persistent() - .set(&DataKey::MarketCounter, &market_id); - - let market = MarketData { - creator: creator.clone(), - question, - description, - end_time, - resolution_source, - outcome_tags, - resolved: false, - winning_outcome: 0, - cancelled: false, - }; - env.storage() - .persistent() - .set(&DataKey::Market(market_id), &market); - - env.storage() - .persistent() - .set(&DataKey::MarketPayoutCurve(market_id), &curve); - - market_id + ( + numerator / outcome_pool, + Some((outcome_index, numerator % outcome_pool)), + ) } - /// Configures the payout curve for an existing market before resolution and betting (#014). - pub fn set_market_payout_curve( + fn calculate_payout_with_remainder( env: Env, - caller: Address, market_id: u32, - curve: PayoutCurveConfig, - ) { - caller.require_auth(); - + claimant: Address, + ) -> (i128, Option<(u32, i128)>) { let market: MarketData = match env.storage().persistent().get(&DataKey::Market(market_id)) { Some(m) => m, - None => panic_with_error!(env, ContractError::MarketNotFound), + None => return (0, None), }; - - let now = env.ledger().timestamp(); - if now >= market.end_time { - panic_with_error!(env, ContractError::MarketClosed); - } - - if market.resolved || market.cancelled { - panic_with_error!(env, ContractError::InvalidState); + if !market.resolved { + return (0, None); + } + + let bet: BetData = match env + .storage() + .persistent() + .get(&DataKey::Bet(market_id, claimant)) + { + Some(b) => b, + None => return (0, None), + }; + + let curve: PayoutCurveConfig = env + .storage() + .persistent() + .get(&DataKey::MarketPayoutCurve(market_id)) + .unwrap_or_else(|| PayoutCurveConfig::winner_takes_all(&env)); + let total_pool: i128 = env + .storage() + .persistent() + .get(&DataKey::MarketTotalPool(market_id)) + .unwrap_or(bet.amount); + let win_pool: i128 = env + .storage() + .persistent() + .get(&DataKey::OutcomePool(market_id, market.winning_outcome)) + .unwrap_or(0); + let winning_outcomes = Self::winning_outcomes(&env, market_id, &market); + + if winning_outcomes.len() > 1 { + if !winning_outcomes.contains(bet.outcome_index) { + return (0, None); + } + let outcome_pool: i128 = env + .storage() + .persistent() + .get(&DataKey::OutcomePool(market_id, bet.outcome_index)) + .unwrap_or(0); + if outcome_pool == 0 { + return (bet.amount, None); + } + + let winner_count = winning_outcomes.len() as i128; + let mut rank = 0i128; + for outcome in winning_outcomes.iter() { + if outcome == bet.outcome_index { + break; + } + rank += 1; + } + let base_share = total_pool / winner_count; + let extra_units = total_pool % winner_count; + let outcome_share = base_share + if rank < extra_units { 1 } else { 0 }; + return Self::proportional_payout( + &env, + market_id, + bet.outcome_index, + bet.amount, + outcome_share, + outcome_pool, + ); + } + + match curve.curve_type { + PayoutCurveConfig::WINNER_TAKES_ALL => { + if bet.outcome_index != market.winning_outcome { + return (0, None); + } + if win_pool == 0 { + return (bet.amount, None); + } + Self::proportional_payout( + &env, + market_id, + bet.outcome_index, + bet.amount, + total_pool, + win_pool, + ) + } + PayoutCurveConfig::TIERED => { + let ru_pool: i128 = env + .storage() + .persistent() + .get(&DataKey::OutcomePool(market_id, curve.runner_up_outcome)) + .unwrap_or(0); + + if ru_pool == 0 { + if bet.outcome_index == market.winning_outcome { + if win_pool == 0 { + return (bet.amount, None); + } + return Self::proportional_payout( + &env, + market_id, + bet.outcome_index, + bet.amount, + total_pool, + win_pool, + ); + } + return (0, None); + } + + if win_pool == 0 { + if bet.outcome_index == curve.runner_up_outcome { + return Self::proportional_payout( + &env, + market_id, + bet.outcome_index, + bet.amount, + total_pool, + ru_pool, + ); + } + return (0, None); + } + + let ru_share = + Self::scaled_weight_boundary(&env, total_pool, curve.runner_up_bps); + let win_share = total_pool - ru_share; + if bet.outcome_index == market.winning_outcome { + Self::proportional_payout( + &env, + market_id, + bet.outcome_index, + bet.amount, + win_share, + win_pool, + ) + } else if bet.outcome_index == curve.runner_up_outcome { + Self::proportional_payout( + &env, + market_id, + bet.outcome_index, + bet.amount, + ru_share, + ru_pool, + ) + } else { + (0, None) + } + } + PayoutCurveConfig::CUSTOM_WEIGHTS => { + let weight = curve.custom_weights.get(bet.outcome_index).unwrap_or(0); + if weight == 0 { + return (0, None); + } + let outcome_pool: i128 = env + .storage() + .persistent() + .get(&DataKey::OutcomePool(market_id, bet.outcome_index)) + .unwrap_or(0); + if outcome_pool == 0 { + return (0, None); + } + let outcome_share = + Self::custom_outcome_share(&env, total_pool, &curve, bet.outcome_index); + Self::proportional_payout( + &env, + market_id, + bet.outcome_index, + bet.amount, + outcome_share, + outcome_pool, + ) + } + _ => (0, None), + } + } +} + +#[contractimpl] +impl MarketsContract { + // ----------------------------------------------------------------------- + // Read-only Entrypoints + // ----------------------------------------------------------------------- + + /// @notice Returns the contract version. + pub fn version(_env: Env) -> u32 { + 7 + } + + /// Initializes the contract with its first administrator exactly once. + pub fn initialize(env: Env, admin: Address) -> Result<(), ContractError> { + admin.require_auth(); + if env + .storage() + .persistent() + .get::<_, bool>(&DataKey::Initialized) + .unwrap_or(false) + { + return Err(ContractError::AdminOperationNotPermitted); + } + + env.storage().persistent().set(&DataKey::Initialized, &true); + env.storage().persistent().set(&DataKey::Admin, &admin); + env.storage().persistent().set(&DataKey::Paused, &false); + Ok(()) + } + + /// @notice Read a market from persistent storage and bump its TTL. + pub fn get_market(env: Env, market_id: soroban_sdk::Symbol) -> Option { + let market: Option = env.storage().persistent().get(&market_id); + if market.is_some() { + env.storage() + .persistent() + .extend_ttl(&market_id, 6307200, 6307200); + } + market + } + + /// Read the metadata required by the resolution subsystem. + pub fn get_market_for_resolution( + env: Env, + market_id: u32, + ) -> Option<(Address, u64, u32, bool, bool)> { + let key = DataKey::Market(market_id); + let market: Option = env.storage().persistent().get(&key); + if market.is_some() { + let ttl = 6_307_200u32.min(env.storage().max_ttl()); + env.storage().persistent().extend_ttl(&key, ttl, ttl); + } + market.map(|market| { + ( + market.creator, + market.end_time, + market.outcome_tags.len(), + market.resolved, + market.cancelled, + ) + }) + } + + /// Return every winning outcome for a resolved market. + pub fn get_winning_outcomes(env: Env, market_id: u32) -> Option> { + let market_key = DataKey::Market(market_id); + let market: Option = env.storage().persistent().get(&market_key); + market.map(|market| { + let ttl = 6_307_200u32.min(env.storage().max_ttl()); + env.storage().persistent().extend_ttl(&market_key, ttl, ttl); + let outcomes_key = DataKey::WinningOutcomes(market_id); + if env.storage().persistent().has(&outcomes_key) { + env.storage() + .persistent() + .extend_ttl(&outcomes_key, ttl, ttl); + } + Self::winning_outcomes(&env, market_id, &market) + }) + } + + /// Returns whether market operations are currently paused globally. + pub fn is_paused(env: Env) -> bool { + env.storage() + .persistent() + .get(&DataKey::Paused) + .unwrap_or(false) + } + + /// Returns the primary admin address if set. + pub fn get_admin(env: Env) -> Option
{ + env.storage().persistent().get(&DataKey::Admin) + } + + /// Returns the configured market limit, or the finite default if unset. + pub fn get_market_limit(env: Env) -> u32 { + env.storage() + .persistent() + .get(&DataKey::MarketLimit) + .unwrap_or(DEFAULT_MAX_MARKET_COUNT) + } + + // ----------------------------------------------------------------------- + // Market Lifecycle + // ----------------------------------------------------------------------- + + /// Creates a new prediction market with default WinnerTakesAll payout curve. + pub fn create_market( + env: Env, + creator: Address, + question: String, + description: String, + end_time: u64, + resolution_source: String, + outcome_tags: Vec, + ) -> u32 { + Self::create_market_with_curve( + env.clone(), + creator, + question, + description, + end_time, + resolution_source, + outcome_tags, + PayoutCurveConfig::winner_takes_all(&env), + ) + } + + /// Creates a new prediction market with a specific configurable payout curve (#014). + pub fn create_market_with_curve( + env: Env, + creator: Address, + question: String, + description: String, + end_time: u64, + resolution_source: String, + outcome_tags: Vec, + curve: PayoutCurveConfig, + ) -> u32 { + creator.require_auth(); + + if outcome_tags.len() < 2 { + panic_with_error!(env, ContractError::InvalidConfig); + } + + Self::validate_payout_curve(&env, &curve, outcome_tags.len()); + + let counter: u32 = env + .storage() + .persistent() + .get(&DataKey::MarketCounter) + .unwrap_or(0u32); + if counter >= Self::get_market_limit(env.clone()) { + panic_with_error!(env, ContractError::MarketLimitReached); + } + + let market_id = match counter.checked_add(1) { + Some(id) => id, + None => panic_with_error!(env, ContractError::Overflow), + }; + + env.storage() + .persistent() + .set(&DataKey::MarketCounter, &market_id); + + let market = MarketData { + creator: creator.clone(), + question, + description, + end_time, + resolution_source, + outcome_tags, + resolved: false, + winning_outcome: 0, + cancelled: false, + }; + env.storage() + .persistent() + .set(&DataKey::Market(market_id), &market); + + env.storage() + .persistent() + .set(&DataKey::MarketPayoutCurve(market_id), &curve); + + market_id + } + + /// Configures the payout curve for an existing market before resolution and betting (#014). + pub fn set_market_payout_curve( + env: Env, + caller: Address, + market_id: u32, + curve: PayoutCurveConfig, + ) { + caller.require_auth(); + + let market: MarketData = match env.storage().persistent().get(&DataKey::Market(market_id)) { + Some(m) => m, + None => panic_with_error!(env, ContractError::MarketNotFound), + }; + + let now = env.ledger().timestamp(); + if now >= market.end_time { + panic_with_error!(env, ContractError::MarketClosed); + } + + if market.resolved || market.cancelled { + panic_with_error!(env, ContractError::InvalidState); } if caller != market.creator && !Self::is_admin_internal(&env, &caller) { @@ -745,266 +1110,20 @@ impl MarketsContract { env.storage() .persistent() .set(&DataKey::Market(market_id), &market); - env.storage() - .persistent() - .set(&DataKey::WinningOutcomes(market_id), &winning_outcomes); - let ttl = 6_307_200u32.min(env.storage().max_ttl()); - env.storage() - .persistent() - .extend_ttl(&DataKey::WinningOutcomes(market_id), ttl, ttl); - } - - /// Previews the payout amount for a claimant on a market (#014). - /// - /// Outcome-split remainders are assigned in outcome order. Claim-time - /// proportional remainders carry forward per outcome, preserving the - /// allocated pool across claims; previews reflect prior claims. - pub fn calculate_payout(env: Env, market_id: u32, claimant: Address) -> i128 { - Self::calculate_payout_with_remainder(env, market_id, claimant).0 - } - - fn scaled_weight_boundary(env: &Env, total_pool: i128, cumulative_weight: u32) -> i128 { - let whole = total_pool / 10_000; - let fraction = total_pool % 10_000; - let whole_share = match whole.checked_mul(cumulative_weight as i128) { - Some(value) => value, - None => panic_with_error!(env, ContractError::Overflow), - }; - whole_share + (fraction * cumulative_weight as i128) / 10_000 - } - - fn custom_outcome_share( - env: &Env, - total_pool: i128, - curve: &PayoutCurveConfig, - outcome_index: u32, - ) -> i128 { - let mut cumulative_weight = 0u32; - let mut index = 0u32; - for weight in curve.custom_weights.iter() { - if index == outcome_index { - let next_weight = match cumulative_weight.checked_add(weight) { - Some(value) => value, - None => panic_with_error!(env, ContractError::InvalidPayoutCurve), - }; - return Self::scaled_weight_boundary(env, total_pool, next_weight) - - Self::scaled_weight_boundary(env, total_pool, cumulative_weight); - } - cumulative_weight = match cumulative_weight.checked_add(weight) { - Some(value) => value, - None => panic_with_error!(env, ContractError::InvalidPayoutCurve), - }; - index += 1; - } - 0 - } - - fn proportional_payout( - env: &Env, - market_id: u32, - outcome_index: u32, - stake: i128, - outcome_share: i128, - outcome_pool: i128, - ) -> (i128, Option<(u32, i128)>) { - if outcome_pool <= 0 { - return (0, None); - } - - let key = DataKey::PayoutRemainder(market_id, outcome_index); - let remainder: i128 = env.storage().persistent().get(&key).unwrap_or(0); - let numerator = match stake - .checked_mul(outcome_share) - .and_then(|value| value.checked_add(remainder)) - { - Some(value) => value, - None => panic_with_error!(env, ContractError::Overflow), - }; - ( - numerator / outcome_pool, - Some((outcome_index, numerator % outcome_pool)), - ) - } - - fn calculate_payout_with_remainder( - env: Env, - market_id: u32, - claimant: Address, - ) -> (i128, Option<(u32, i128)>) { - let market: MarketData = match env.storage().persistent().get(&DataKey::Market(market_id)) { - Some(m) => m, - None => return (0, None), - }; - if !market.resolved { - return (0, None); - } - - let bet: BetData = match env - .storage() - .persistent() - .get(&DataKey::Bet(market_id, claimant)) - { - Some(b) => b, - None => return (0, None), - }; - - let curve: PayoutCurveConfig = env - .storage() - .persistent() - .get(&DataKey::MarketPayoutCurve(market_id)) - .unwrap_or_else(|| PayoutCurveConfig::winner_takes_all(&env)); - let total_pool: i128 = env - .storage() - .persistent() - .get(&DataKey::MarketTotalPool(market_id)) - .unwrap_or(bet.amount); - let win_pool: i128 = env - .storage() - .persistent() - .get(&DataKey::OutcomePool(market_id, market.winning_outcome)) - .unwrap_or(0); - let winning_outcomes = Self::winning_outcomes(&env, market_id, &market); - - if winning_outcomes.len() > 1 { - if !winning_outcomes.contains(bet.outcome_index) { - return (0, None); - } - let outcome_pool: i128 = env - .storage() - .persistent() - .get(&DataKey::OutcomePool(market_id, bet.outcome_index)) - .unwrap_or(0); - if outcome_pool == 0 { - return (bet.amount, None); - } - - let winner_count = winning_outcomes.len() as i128; - let mut rank = 0i128; - for outcome in winning_outcomes.iter() { - if outcome == bet.outcome_index { - break; - } - rank += 1; - } - let base_share = total_pool / winner_count; - let extra_units = total_pool % winner_count; - let outcome_share = base_share + if rank < extra_units { 1 } else { 0 }; - return Self::proportional_payout( - &env, - market_id, - bet.outcome_index, - bet.amount, - outcome_share, - outcome_pool, - ); - } - - match curve.curve_type { - PayoutCurveConfig::WINNER_TAKES_ALL => { - if bet.outcome_index != market.winning_outcome { - return (0, None); - } - if win_pool == 0 { - return (bet.amount, None); - } - Self::proportional_payout( - &env, - market_id, - bet.outcome_index, - bet.amount, - total_pool, - win_pool, - ) - } - PayoutCurveConfig::TIERED => { - let ru_pool: i128 = env - .storage() - .persistent() - .get(&DataKey::OutcomePool(market_id, curve.runner_up_outcome)) - .unwrap_or(0); - - if ru_pool == 0 { - if bet.outcome_index == market.winning_outcome { - if win_pool == 0 { - return (bet.amount, None); - } - return Self::proportional_payout( - &env, - market_id, - bet.outcome_index, - bet.amount, - total_pool, - win_pool, - ); - } - return (0, None); - } - - if win_pool == 0 { - if bet.outcome_index == curve.runner_up_outcome { - return Self::proportional_payout( - &env, - market_id, - bet.outcome_index, - bet.amount, - total_pool, - ru_pool, - ); - } - return (0, None); - } - - let ru_share = - Self::scaled_weight_boundary(&env, total_pool, curve.runner_up_bps); - let win_share = total_pool - ru_share; - if bet.outcome_index == market.winning_outcome { - Self::proportional_payout( - &env, - market_id, - bet.outcome_index, - bet.amount, - win_share, - win_pool, - ) - } else if bet.outcome_index == curve.runner_up_outcome { - Self::proportional_payout( - &env, - market_id, - bet.outcome_index, - bet.amount, - ru_share, - ru_pool, - ) - } else { - (0, None) - } - } - PayoutCurveConfig::CUSTOM_WEIGHTS => { - let weight = curve.custom_weights.get(bet.outcome_index).unwrap_or(0); - if weight == 0 { - return (0, None); - } - let outcome_pool: i128 = env - .storage() - .persistent() - .get(&DataKey::OutcomePool(market_id, bet.outcome_index)) - .unwrap_or(0); - if outcome_pool == 0 { - return (0, None); - } - let outcome_share = - Self::custom_outcome_share(&env, total_pool, &curve, bet.outcome_index); - Self::proportional_payout( - &env, - market_id, - bet.outcome_index, - bet.amount, - outcome_share, - outcome_pool, - ) - } - _ => (0, None), - } + env.storage() + .persistent() + .set(&DataKey::WinningOutcomes(market_id), &winning_outcomes); + let ttl = 6_307_200u32.min(env.storage().max_ttl()); + env.storage() + .persistent() + .extend_ttl(&DataKey::WinningOutcomes(market_id), ttl, ttl); + } + + /// Previews the claimant's payout after the liquidity-provider share is reserved. + pub fn calculate_payout(env: Env, market_id: u32, claimant: Address) -> i128 { + let (gross_payout, _) = + Self::calculate_payout_with_remainder(env.clone(), market_id, claimant); + gross_payout - Self::lp_payout_fee(&env, market_id, gross_payout) } /// Claims winnings for a resolved market upholding CEI pattern and applying @@ -1040,11 +1159,13 @@ impl MarketsContract { None => panic_with_error!(env, ContractError::InvalidState), }; - let (payout, remainder) = + let (gross_payout, remainder) = Self::calculate_payout_with_remainder(env.clone(), market_id, claimant.clone()); - if payout < 0 || (payout == 0 && remainder.is_none()) { + if gross_payout < 0 || (gross_payout == 0 && remainder.is_none()) { panic_with_error!(env, ContractError::InvalidOutcome); } + let lp_fee = Self::lp_payout_fee(&env, market_id, gross_payout); + let payout = gross_payout - lp_fee; // ── EFFECTS ────────────────────────────────────────────────────────── if let Some((outcome_index, value)) = remainder { @@ -1057,6 +1178,7 @@ impl MarketsContract { env.storage() .persistent() .set(&DataKey::ClaimedBet(market_id, claimant.clone()), &true); + Self::accrue_liquidity_reward(&env, market_id, lp_fee); // ── INTERACTIONS ───────────────────────────────────────────────────── let _ = bet.amount; @@ -1115,127 +1237,269 @@ impl MarketsContract { } // ----------------------------------------------------------------------- - // Initialization + // Liquidity // ----------------------------------------------------------------------- - /// @notice Initializes the contract by setting the initial admin. - /// @dev Stores `DataKey::Admin` and `DataKey::Initialized` atomically. - /// Must be called exactly once after deployment before any admin - /// entrypoint can be used. - /// @param env The contract environment. - /// @param admin The initial admin/owner address. - /// @return `Ok(())` on success, or a [`ContractError`] variant. - pub fn initialize(env: Env, admin: Address) -> Result<(), ContractError> { - // Require the caller to authenticate before any state mutation. - admin.require_auth(); + /// Adds liquidity to a market. + pub fn add_liquidity(env: Env, provider: Address, market_id: u32, amount: i128) { + provider.require_auth(); - // Prevent re-initialization. - if Self::is_initialized(&env) { - return Err(ContractError::AdminOperationNotPermitted); + if amount <= 0 { + panic_with_error!(env, ContractError::StakeTooSmall); } - env.storage().instance().set(&DataKey::Initialized, &true); - env.storage().instance().set(&DataKey::Admin, &admin); - // Markets start unpaused. - env.storage().instance().set(&DataKey::Paused, &false); + let market: MarketData = match env.storage().persistent().get(&DataKey::Market(market_id)) { + Some(m) => m, + None => panic_with_error!(env, ContractError::MarketNotFound), + }; - Ok(()) - } + if market.resolved || market.cancelled { + panic_with_error!(env, ContractError::InvalidState); + } - // ----------------------------------------------------------------------- - // Admin-gated entrypoints - // ----------------------------------------------------------------------- + let existing: LiquidityData = env + .storage() + .persistent() + .get(&DataKey::Liquidity(market_id, provider.clone())) + .unwrap_or(LiquidityData { total_amount: 0 }); - /// @notice Pauses all markets globally. - /// @dev Requires the caller to be the registered admin. - /// @param env The contract environment. - /// @param caller The admin address (must pass auth and admin check). - /// @return `Ok(())` on success, or a [`ContractError`] variant. - pub fn pause_markets(env: Env, caller: Address) -> Result<(), ContractError> { - caller.require_auth(); - Self::assert_is_admin(&env, &caller)?; - env.storage().instance().set(&DataKey::Paused, &true); - Ok(()) - } + Self::settle_liquidity_rewards(&env, market_id, &provider, existing.total_amount); - /// @notice Unpauses all markets globally. - /// @dev Requires the caller to be the registered admin. - /// @param env The contract environment. - /// @param caller The admin address (must pass auth and admin check). - /// @return `Ok(())` on success, or a [`ContractError`] variant. - pub fn unpause_markets(env: Env, caller: Address) -> Result<(), ContractError> { - caller.require_auth(); - Self::assert_is_admin(&env, &caller)?; - env.storage().instance().set(&DataKey::Paused, &false); - Ok(()) + let new_total = match existing.total_amount.checked_add(amount) { + Some(t) => t, + None => panic_with_error!(env, ContractError::Overflow), + }; + let market_total: i128 = env + .storage() + .persistent() + .get(&DataKey::TotalLiquidity(market_id)) + .unwrap_or(0); + let new_market_total = market_total + .checked_add(amount) + .unwrap_or_else(|| panic_with_error!(env, ContractError::Overflow)); + + env.storage().persistent().set( + &DataKey::Liquidity(market_id, provider), + &LiquidityData { + total_amount: new_total, + }, + ); + env.storage() + .persistent() + .set(&DataKey::TotalLiquidity(market_id), &new_market_total); + Self::update_liquidity_reward_debt(&env, market_id, &provider, new_total); } - /// @notice Transfers contract ownership to a new admin address. - /// @dev Requires the caller to be the registered admin. The new admin - /// must differ from the current admin. - /// @param env The contract environment. - /// @param current_admin The current admin (must pass auth and admin check). - /// @param new_admin The new admin address to set. - /// @return `Ok(())` on success, or a [`ContractError`] variant. - pub fn transfer_ownership( - env: Env, - current_admin: Address, - new_admin: Address, - ) -> Result<(), ContractError> { - current_admin.require_auth(); - Self::assert_is_admin(&env, ¤t_admin)?; + /// Removes liquidity from a market. + pub fn remove_liquidity(env: Env, provider: Address, market_id: u32, amount: i128) { + provider.require_auth(); - if new_admin == current_admin { - return Err(ContractError::AdminAddressInvalid); + if amount <= 0 { + panic_with_error!(env, ContractError::StakeTooSmall); } - env.storage().instance().set(&DataKey::Admin, &new_admin); - Ok(()) + if !env.storage().persistent().has(&DataKey::Market(market_id)) { + panic_with_error!(env, ContractError::MarketNotFound); + } + + let existing: LiquidityData = env + .storage() + .persistent() + .get(&DataKey::Liquidity(market_id, provider.clone())) + .unwrap_or(LiquidityData { total_amount: 0 }); + + if amount > existing.total_amount { + panic_with_error!(env, ContractError::StakeTooSmall); + } + + Self::settle_liquidity_rewards(&env, market_id, &provider, existing.total_amount); + + let new_total = match existing.total_amount.checked_sub(amount) { + Some(t) => t, + None => panic_with_error!(env, ContractError::Overflow), + }; + let market_total: i128 = env + .storage() + .persistent() + .get(&DataKey::TotalLiquidity(market_id)) + .unwrap_or(0); + let new_market_total = market_total + .checked_sub(amount) + .filter(|total| *total >= 0) + .unwrap_or_else(|| panic_with_error!(env, ContractError::InvalidState)); + + env.storage().persistent().set( + &DataKey::Liquidity(market_id, provider), + &LiquidityData { + total_amount: new_total, + }, + ); + env.storage() + .persistent() + .set(&DataKey::TotalLiquidity(market_id), &new_market_total); + Self::update_liquidity_reward_debt(&env, market_id, &provider, new_total); } - /// @notice Adds a second admin address (co-admin). - /// @dev Requires the caller to be the registered admin. Currently only - /// stores a single admin; this entrypoint replaces the admin with - /// the new address and is kept for forward compatibility with a - /// multi-admin model. - /// @param env The contract environment. - /// @param caller The current admin (must pass auth and admin check). - /// @param new_admin The admin address to add. - /// @return `Ok(())` on success, or a [`ContractError`] variant. - pub fn add_admin( + /// Collect a provider's accrued share of winning-payout fees for a market. + /// + /// Winning payouts reserve `LP_PAYOUT_SHARE_BPS` basis points for liquidity + /// providers. The reserve is distributed pro rata by liquidity supplied when + /// each winning claim is made; this call returns the provider's accrued amount. + pub fn collect_fees(env: Env, provider: Address, market_id: u32) -> i128 { + provider.require_auth(); + if !env + .storage() + .persistent() + .has(&DataKey::Market(market_id)) + { + panic_with_error!(env, ContractError::MarketNotFound); + } + + let liquidity: LiquidityData = env + .storage() + .persistent() + .get(&DataKey::Liquidity(market_id, provider.clone())) + .unwrap_or(LiquidityData { total_amount: 0 }); + let reward = Self::settle_liquidity_rewards( + &env, + market_id, + &provider, + liquidity.total_amount, + ); + env.storage().persistent().set( + &DataKey::LiquidityRewards(market_id, provider.clone()), + &0i128, + ); + Self::update_liquidity_reward_debt(&env, market_id, &provider, liquidity.total_amount); + reward + } + + // ----------------------------------------------------------------------- + // Issue #022: User Bet Enumeration + // ----------------------------------------------------------------------- + + /// Returns the total number of markets on which the user has placed bets. + pub fn get_user_bet_count(env: Env, user: Address) -> u32 { + let user_markets: Vec = env + .storage() + .persistent() + .get(&DataKey::UserBetMarkets(user)) + .unwrap_or(Vec::new(&env)); + user_markets.len() + } + + /// Enumerates bets placed by a user with start_index and limit pagination (#022). + /// + /// Preserves insertion order across markets. + pub fn get_user_bets( env: Env, - caller: Address, - new_admin: Address, - ) -> Result<(), ContractError> { - caller.require_auth(); - Self::assert_is_admin(&env, &caller)?; + user: Address, + start_index: u32, + limit: u32, + ) -> Vec { + let mut results = Vec::new(&env); + if limit == 0 { + return results; + } - if new_admin == caller { - return Err(ContractError::AdminAddressInvalid); + let user_markets: Vec = env + .storage() + .persistent() + .get(&DataKey::UserBetMarkets(user.clone())) + .unwrap_or(Vec::new(&env)); + + let total = user_markets.len(); + if start_index >= total { + return results; } - // Store the new admin alongside the existing admin. - // For now we support a single admin; this entrypoint replaces it. - env.storage().instance().set(&DataKey::Admin, &new_admin); - Ok(()) + let end = core::cmp::min(start_index.saturating_add(limit), total); + for i in start_index..end { + let market_id = user_markets.get(i).unwrap(); + if let Some(bet) = env + .storage() + .persistent() + .get::<_, BetData>(&DataKey::Bet(market_id, user.clone())) + { + let claimed: bool = env + .storage() + .persistent() + .get(&DataKey::ClaimedBet(market_id, user.clone())) + .unwrap_or(false); + + // bet.amount is validated > 0 in place_bet, so the cast to u128 is safe. + results.push_back(UserBetInfo { + market_id, + outcome_index: bet.outcome_index, + amount: bet.amount as u128, + claimed, + }); + } + } + + results } // ----------------------------------------------------------------------- - // Read-only admin queries + // Issue #012: Dust-Safe Balance & Withdrawal System // ----------------------------------------------------------------------- - /// @notice Returns the current admin address. - /// @dev Read-only — does not require auth. Returns `None` if not set. - /// @param env The contract environment. - /// @return `Some(Address)` if an admin is set, `None` otherwise. - pub fn get_admin(env: Env) -> Option
{ - env.storage().instance().get(&DataKey::Admin) + /// Configures the withdrawal fee parameters (#012). + pub fn set_fee_config(env: Env, admin: Address, min_fee_amount: i128, fee_bps: i128) { + Self::require_admin(&env, &admin); + + if min_fee_amount < 0 || !(0..=10_000).contains(&fee_bps) { + panic_with_error!(env, ContractError::InvalidConfig); + } + + env.storage() + .persistent() + .set(&DataKey::MinFeeAmount, &min_fee_amount); + env.storage() + .persistent() + .set(&DataKey::WithdrawalFeeBps, &fee_bps); + } + + /// Returns the configured (min_fee_amount, fee_bps) (#012). + pub fn get_fee_config(env: Env) -> (i128, i128) { + let min_fee = env + .storage() + .persistent() + .get(&DataKey::MinFeeAmount) + .unwrap_or(100i128); + let fee_bps = env + .storage() + .persistent() + .get(&DataKey::WithdrawalFeeBps) + .unwrap_or(100i128); // 1.00% + (min_fee, fee_bps) + } + + /// Reads the internal balance of a user (#012). + pub fn get_user_balance(env: Env, user: Address) -> i128 { + env.storage() + .persistent() + .get(&DataKey::UserBalance(user)) + .unwrap_or(0i128) + } + + /// Configures the token contract used for user balance deposits. + pub fn set_token_address(env: Env, admin: Address, token_address: Address) { + Self::require_admin(&env, &admin); + env.storage() + .persistent() + .set(&DataKey::TokenAddress, &token_address); + } + + /// Returns the total platform fees collected (#012). + pub fn get_collected_fees(env: Env) -> i128 { + env.storage() + .persistent() + .get(&DataKey::CollectedFees) + .unwrap_or(0i128) } /// Deposits funds into a user's internal withdrawable balance (#012). - /// - /// No fee is charged on deposit; the full `amount` is transferred in and - /// credited to the user's internal balance. pub fn deposit_user_balance(env: Env, user: Address, amount: i128) { user.require_auth(); @@ -1258,21 +1522,12 @@ impl MarketsContract { token_client.transfer(&user, &env.current_contract_address(), &amount); env.storage() - .instance() - .get(&DataKey::Paused) - .unwrap_or(false) + .persistent() + .set(&DataKey::UserBalance(user), &new_balance); } /// Withdraws funds from a user's balance with dust protection (#012). /// - /// The fee is calculated from the requested `amount` as the greater of - /// the configured minimum and the basis-point fee. The full requested - /// amount is removed from the internal balance, and the post-fee amount is - /// returned as `net_amount`; this method does not transfer tokens. The fee - /// is added to the collected-fees counter. This is independent of - /// `collect_user_fees`, so both paths can charge fees against the same - /// funds at different times. - /// /// # Dust Invariant /// If the user's available balance is `<= min_fee_amount`, it is classified /// as dust. To prevent funds from being permanently locked, the minimum fee @@ -1340,15 +1595,53 @@ impl MarketsContract { /// Admin fee collection from user account with dust-safe protection (#012). /// - /// This charges against the user's full current internal balance and adds - /// the amount to the collected-fees counter; it does not transfer tokens - /// out of the contract. It is independent of `withdraw_user_balance`, so - /// a later withdrawal can charge another fee on the remaining balance. - /// /// If a user balance is `<= min_fee_amount`, fees are not collected and 0 is returned, /// ensuring dust is never locked and `InsufficientBalance` is never thrown. pub fn collect_user_fees(env: Env, admin: Address, user: Address) -> i128 { Self::require_admin(&env, &admin); + + let balance = Self::get_user_balance(env.clone(), user.clone()); + let (min_fee, fee_bps) = Self::get_fee_config(env.clone()); + + if balance <= min_fee { + // Dust-safe: do not fail with InsufficientBalance and do not trap user dust! + return 0; + } + + let calculated_fee = (balance * fee_bps) / 10_000; + let fee = core::cmp::max(calculated_fee, min_fee); + + if balance < fee { + return 0; + } + + let new_balance = balance - fee; + env.storage() + .persistent() + .set(&DataKey::UserBalance(user), &new_balance); + + let collected = Self::get_collected_fees(env.clone()); + let new_collected = match collected.checked_add(fee) { + Some(c) => c, + None => panic_with_error!(env, ContractError::Overflow), + }; + env.storage() + .persistent() + .set(&DataKey::CollectedFees, &new_collected); + + fee + } + + // ----------------------------------------------------------------------- + // Issue #007: Hardened Admin Pause Mechanism + // ----------------------------------------------------------------------- + + /// Set the maximum number of markets that may be created. + pub fn set_market_limit(env: Env, admin: Address, max_market_count: u32) { + Self::require_admin(&env, &admin); + if max_market_count == 0 { + panic_with_error!(env, ContractError::InvalidConfig); + } env.storage() .persistent() .set(&DataKey::MarketLimit, &max_market_count); @@ -1386,8 +1679,15 @@ impl MarketsContract { } /// Adds an administrator to the authorized admin list (#007). - pub fn add_admin(env: Env, admin: Address, new_admin: Address) { + pub fn add_admin( + env: Env, + admin: Address, + new_admin: Address, + ) -> Result<(), ContractError> { Self::require_admin(&env, &admin); + if admin == new_admin { + return Err(ContractError::AdminAddressInvalid); + } let mut list: Vec
= env .storage() @@ -1399,6 +1699,7 @@ impl MarketsContract { list.push_back(new_admin); env.storage().persistent().set(&DataKey::AdminList, &list); } + Ok(()) } /// Proposes a global market pause with a stated justification (#007). @@ -1441,40 +1742,138 @@ impl MarketsContract { }; env.storage() - .instance() - .get(&DataKey::Initialized) - .unwrap_or(false) + .persistent() + .set(&DataKey::PauseProposal(proposal_id), &proposal); + + proposal_id } - /// @notice Returns the stored admin address, or `None` if unset. - fn get_admin_internal(env: &Env) -> Option
{ - env.storage().instance().get(&DataKey::Admin) + /// Approves a pending pause proposal (#007). + pub fn approve_pause(env: Env, admin: Address, proposal_id: u32) { + Self::require_admin(&env, &admin); + + let mut proposal: PauseProposal = match env + .storage() + .persistent() + .get(&DataKey::PauseProposal(proposal_id)) + { + Some(p) => p, + None => panic_with_error!(env, ContractError::ProposalNotFound), + }; + + if proposal.executed || proposal.cancelled { + panic_with_error!(env, ContractError::ProposalNotExecutable); + } + + if proposal.approvals.contains(&admin) { + panic_with_error!(env, ContractError::ProposalAlreadyApproved); + } + + proposal.approvals.push_back(admin); + env.storage() + .persistent() + .set(&DataKey::PauseProposal(proposal_id), &proposal); } - /// @notice Checks whether `caller` is the registered admin. - /// @dev Returns `false` when no admin is stored (does **not** return - /// `true` for an unset admin — this closes the bootstrap hole). - /// @param env The contract environment. - /// @param caller The address to check. - /// @return `true` if the caller matches the stored admin, `false` otherwise. - fn is_admin_internal(env: &Env, caller: &Address) -> bool { - match Self::get_admin_internal(env) { - Some(admin) => admin == *caller, - None => false, + /// Executes an approved and timelocked pause proposal (#007). + pub fn execute_pause(env: Env, caller: Address, proposal_id: u32) { + Self::require_admin(&env, &caller); + + let mut proposal: PauseProposal = match env + .storage() + .persistent() + .get(&DataKey::PauseProposal(proposal_id)) + { + Some(p) => p, + None => panic_with_error!(env, ContractError::ProposalNotFound), + }; + + if proposal.executed || proposal.cancelled { + panic_with_error!(env, ContractError::ProposalNotExecutable); + } + + let (_, required_approvals) = Self::get_pause_config(env.clone()); + if proposal.approvals.len() < required_approvals { + panic_with_error!(env, ContractError::ProposalNotExecutable); + } + + let now = env.ledger().timestamp(); + if now < proposal.eta { + panic_with_error!(env, ContractError::ProposalNotExecutable); } + + proposal.executed = true; + env.storage() + .persistent() + .set(&DataKey::PauseProposal(proposal_id), &proposal); + + env.storage().persistent().set(&DataKey::Paused, &true); } - /// @notice Asserts that `caller` is the registered admin. - /// @dev Returns `Err(ContractError::Unauthorized)` when the admin is - /// unset or the caller does not match. - /// @param env The contract environment. - /// @param caller The address to verify. - /// @return `Ok(())` if the caller is the admin, or a [`ContractError`]. - fn assert_is_admin(env: &Env, caller: &Address) -> Result<(), ContractError> { - if !Self::is_admin_internal(env, caller) { - return Err(ContractError::Unauthorized); + /// Cancels a pending pause proposal (#007). + pub fn cancel_pause(env: Env, admin: Address, proposal_id: u32) { + Self::require_admin(&env, &admin); + + let mut proposal: PauseProposal = match env + .storage() + .persistent() + .get(&DataKey::PauseProposal(proposal_id)) + { + Some(p) => p, + None => panic_with_error!(env, ContractError::ProposalNotFound), + }; + + if proposal.executed || proposal.cancelled { + panic_with_error!(env, ContractError::ProposalNotExecutable); } - Ok(()) + + proposal.cancelled = true; + env.storage() + .persistent() + .set(&DataKey::PauseProposal(proposal_id), &proposal); + } + + /// Reads a pause proposal by ID. Only administrators may view proposal details (#007). + pub fn get_pause_proposal(env: Env, admin: Address, proposal_id: u32) -> Option { + Self::require_admin(&env, &admin); + env.storage() + .persistent() + .get(&DataKey::PauseProposal(proposal_id)) + } + + /// Pauses all market operations globally. + /// + /// # Hardened Protection + /// If hardened pause protection is active (required_approvals > 1 or delay > 0), + /// unilateral instantaneous execution is blocked with `AdminOperationNotPermitted` (#007). + pub fn pause_markets(env: Env, admin: Address) { + Self::require_admin(&env, &admin); + + let (delay, required_approvals) = Self::get_pause_config(env.clone()); + if delay > 0 || required_approvals > 1 { + panic_with_error!(env, ContractError::AdminOperationNotPermitted); + } + + env.storage().persistent().set(&DataKey::Paused, &true); + } + + /// Resumes all market operations globally. + pub fn unpause_markets(env: Env, admin: Address) { + Self::require_admin(&env, &admin); + env.storage().persistent().set(&DataKey::Paused, &false); } + /// Transfers contract ownership to a new admin address. + pub fn transfer_ownership( + env: Env, + admin: Address, + new_owner: Address, + ) -> Result<(), ContractError> { + Self::require_admin(&env, &admin); + if admin == new_owner { + return Err(ContractError::AdminAddressInvalid); + } + env.storage().persistent().set(&DataKey::Admin, &new_owner); + Ok(()) + } } diff --git a/contracts/markets/tests/auth_boundary.rs b/contracts/markets/tests/auth_boundary.rs index 3797f93f..3f950467 100644 --- a/contracts/markets/tests/auth_boundary.rs +++ b/contracts/markets/tests/auth_boundary.rs @@ -1,794 +1,828 @@ -#![cfg(test)] - -use markets::{ContractError, MarketsContract, MarketsContractClient, PayoutCurveConfig}; -use soroban_sdk::{ - testutils::{Address as _, Ledger, LedgerInfo, MockAuth, MockAuthInvoke}, - Address, Env, IntoVal, String, Vec, -}; - -// ============================================ -// Test Helpers -// ============================================ - -fn setup_test_environment(env: &Env) -> TestSetup<'_> { - env.ledger().set(LedgerInfo { - timestamp: 1735689600, - protocol_version: 22, - sequence_number: 1, - network_id: [0; 32], - base_reserve: 10, - min_temp_entry_ttl: 1, - min_persistent_entry_ttl: 1, - max_entry_ttl: 518400, - }); - - let admin = Address::generate(env); - let market_creator = Address::generate(env); - let user1 = Address::generate(env); - let user2 = Address::generate(env); - let unauthorized = Address::generate(env); - - let contract_id = env.register(MarketsContract, ()); - let client = MarketsContractClient::new(env, &contract_id); - - TestSetup { - admin, - market_creator, - user1, - user2, - unauthorized, - client, - contract_id, - } -} - -struct TestSetup<'a> { - admin: Address, - market_creator: Address, - user1: Address, - user2: Address, - unauthorized: Address, - client: MarketsContractClient<'a>, - contract_id: Address, -} - -/// Creates a market using explicit auth mocking for the market creator. -/// Used by tests that cannot rely on `mock_all_auths()`. -fn create_market_with_mock_auth(setup: &TestSetup<'_>) -> u32 { - let env = &setup.client.env; - let question = String::from_str(env, "Test market question?"); - let description = String::from_str(env, "Test market description"); - let end_time = env.ledger().timestamp() + 86400; - let resolution_source = String::from_str(env, "Test Source"); - let outcome_tags = Vec::from_array( - env, - [String::from_str(env, "Yes"), String::from_str(env, "No")], - ); - - // Authorize ONLY the market_creator for this specific create_market call. - env.mock_auths(&[MockAuth { - address: &setup.market_creator, - invoke: &MockAuthInvoke { - contract: &setup.contract_id, - fn_name: "create_market", - args: ( - setup.market_creator.clone(), - question.clone(), - description.clone(), - end_time, - resolution_source.clone(), - outcome_tags.clone(), - ) - .into_val(env), - sub_invokes: &[], - }, - }]); - - setup.client.create_market( - &setup.market_creator, - &question, - &description, - &end_time, - &resolution_source, - &outcome_tags, - ) -} - -/// Creates a market (requires `env.mock_all_auths()` to be active). -fn create_market_with_auth_check(setup: &TestSetup<'_>) -> u32 { - let env = &setup.client.env; - let question = String::from_str(env, "Test market question?"); - let description = String::from_str(env, "Test market description"); - let end_time = env.ledger().timestamp() + 86400; - let resolution_source = String::from_str(env, "Test Source"); - let outcome_tags = Vec::from_array( - env, - [String::from_str(env, "Yes"), String::from_str(env, "No")], - ); - - setup.client.create_market( - &setup.market_creator, - &question, - &description, - &end_time, - &resolution_source, - &outcome_tags, - ) -} - -// ============================================ -// Auth Boundary Tests -// ============================================ - -// ── create_market ───────────────────────────────────────────────────────────── - -#[test] -fn test_create_market_requires_auth() { - let env = Env::default(); - // No mock_all_auths — require_auth should fail for everyone. - let contract_id = env.register(MarketsContract, ()); - let client = MarketsContractClient::new(&env, &contract_id); - - let unauthorized = Address::generate(&env); - let question = String::from_str(&env, "Test market question?"); - let description = String::from_str(&env, "Test market description"); - let end_time = env.ledger().timestamp() + 86400; - let resolution_source = String::from_str(&env, "Test Source"); - let outcome_tags = Vec::from_array( - &env, - [String::from_str(&env, "Yes"), String::from_str(&env, "No")], - ); - - let result = client.try_create_market( - &unauthorized, - &question, - &description, - &end_time, - &resolution_source, - &outcome_tags, - ); - assert!( - result.is_err(), - "Unauthorized user should not create market" - ); -} - -#[test] -fn test_create_market_requires_auth_success() { - let env = Env::default(); - let setup = setup_test_environment(&env); - env.mock_all_auths(); - - let market_id = create_market_with_auth_check(&setup); - assert_eq!(market_id, 1); -} - -#[test] -fn test_market_limit_blocks_both_creation_entrypoints() { - let env = Env::default(); - let setup = setup_test_environment(&env); - env.mock_all_auths(); - - setup.client.transfer_ownership(&setup.admin, &setup.admin); - assert_eq!(setup.client.get_market_limit(), 10_000); - - let unauthorized_result = setup - .client - .try_set_market_limit(&setup.unauthorized, &1); - assert_eq!( - unauthorized_result.unwrap_err().unwrap(), - ContractError::Unauthorized.into() - ); - - setup.client.set_market_limit(&setup.admin, &1); - assert_eq!(setup.client.get_market_limit(), 1); - let market_id = create_market_with_auth_check(&setup); - assert_eq!(market_id, 1); - - let question = String::from_str(&env, "Another market?"); - let description = String::from_str(&env, "Description"); - let end_time = env.ledger().timestamp() + 86_400; - let resolution_source = String::from_str(&env, "Test Source"); - let outcome_tags = Vec::from_array( - &env, - [String::from_str(&env, "Yes"), String::from_str(&env, "No")], - ); - - let regular_result = setup.client.try_create_market( - &setup.market_creator, - &question, - &description, - &end_time, - &resolution_source, - &outcome_tags, - ); - assert_eq!( - regular_result.unwrap_err().unwrap(), - ContractError::MarketLimitReached.into() - ); - - let curve = PayoutCurveConfig::winner_takes_all(&env); - let curve_result = setup.client.try_create_market_with_curve( - &setup.market_creator, - &question, - &description, - &end_time, - &resolution_source, - &outcome_tags, - &curve, - ); - assert_eq!( - curve_result.unwrap_err().unwrap(), - ContractError::MarketLimitReached.into() - ); - assert_eq!(setup.client.get_market_for_resolution(&2), None); -} - -#[test] -fn test_create_market_requires_auth_market_creator() { - let env = Env::default(); - let setup = setup_test_environment(&env); - env.mock_all_auths(); - - let question = String::from_str(&env, "Test market question?"); - let description = String::from_str(&env, "Test market description"); - let end_time = env.ledger().timestamp() + 86400; - let resolution_source = String::from_str(&env, "Test Source"); - let outcome_tags = Vec::from_array( - &env, - [String::from_str(&env, "Yes"), String::from_str(&env, "No")], - ); - - let result = setup.client.try_create_market( - &setup.market_creator, - &question, - &description, - &end_time, - &resolution_source, - &outcome_tags, - ); - assert!(result.is_ok(), "Authorized market creator should succeed"); -} - -// ── place_bet ───────────────────────────────────────────────────────────────── - -#[test] -fn test_place_bet_requires_auth() { - let env = Env::default(); - let setup = setup_test_environment(&env); - // No mock_all_auths — setup uses explicit MockAuth, but - // the unauthorized test call should fail auth. - - let market_id = create_market_with_mock_auth(&setup); - - let outcome_index = 0; - let amount = 100; - - let result = - setup - .client - .try_place_bet(&setup.unauthorized, &market_id, &outcome_index, &amount); - assert!(result.is_err(), "Unauthorized user should not place bet"); -} - -#[test] -fn test_place_bet_requires_auth_success() { - let env = Env::default(); - let setup = setup_test_environment(&env); - env.mock_all_auths(); - - let market_id = create_market_with_auth_check(&setup); - - let outcome_index = 0; - let amount = 100; - - let result = setup - .client - .try_place_bet(&setup.user1, &market_id, &outcome_index, &amount); - assert!(result.is_ok(), "Authorized user should place bet"); -} - -#[test] -fn test_place_bet_rejects_closed_market() { - let env = Env::default(); - let setup = setup_test_environment(&env); - env.mock_all_auths(); - - let market_id = create_market_with_auth_check(&setup); - env.ledger().set_timestamp(1735689600 + 86400); - - let result = setup - .client - .try_place_bet(&setup.user1, &market_id, &0, &100); - assert_eq!(result, Err(Ok(ContractError::MarketClosed))); -} - -// ── resolve_market ──────────────────────────────────────────────────────────── - -#[test] -fn test_resolve_market_requires_auth() { - let env = Env::default(); - let setup = setup_test_environment(&env); - // No mock_all_auths. - - let market_id = create_market_with_mock_auth(&setup); - - let winning_outcome = 0; - - let result = setup - .client - .try_resolve_market(&setup.unauthorized, &market_id, &winning_outcome); - assert!( - result.is_err(), - "Unauthorized user should not resolve market" - ); -} - -#[test] -fn test_resolve_market_requires_auth_creator() { - let env = Env::default(); - let setup = setup_test_environment(&env); - env.mock_all_auths(); - - let market_id = create_market_with_auth_check(&setup); - - let winning_outcome = 0; - - let result = - setup - .client - .try_resolve_market(&setup.market_creator, &market_id, &winning_outcome); - assert!(result.is_ok(), "Market creator should resolve market"); -} - -// ── claim_winnings ──────────────────────────────────────────────────────────── - -#[test] -fn test_claim_winnings_requires_auth() { - let env = Env::default(); - let setup = setup_test_environment(&env); - // No mock_all_auths — every setup call uses explicit MockAuth. - - // Create market with MockAuth for market_creator. - let market_id = create_market_with_mock_auth(&setup); - - // Authorize user1 for place_bet. - let bet_outcome = 0u32; - let bet_amount = 100i128; - env.mock_auths(&[MockAuth { - address: &setup.user1, - invoke: &MockAuthInvoke { - contract: &setup.contract_id, - fn_name: "place_bet", - args: (&setup.user1, &market_id, &bet_outcome, &bet_amount).into_val(&env), - sub_invokes: &[], - }, - }]); - setup.client.place_bet(&setup.user1, &market_id, &0, &100); - - // Advance ledger past end time. - env.ledger().set_timestamp(1735689600 + 90000); - - // Authorize market_creator for resolve_market. - let win_outcome = 0u32; - env.mock_auths(&[MockAuth { - address: &setup.market_creator, - invoke: &MockAuthInvoke { - contract: &setup.contract_id, - fn_name: "resolve_market", - args: (&setup.market_creator, &market_id, &win_outcome).into_val(&env), - sub_invokes: &[], - }, - }]); - setup - .client - .resolve_market(&setup.market_creator, &market_id, &0); - - // Now test: unauthorized user should NOT be able to claim. - let result = setup - .client - .try_claim_winnings(&setup.unauthorized, &market_id); - assert!( - result.is_err(), - "Unauthorized user should not claim winnings" - ); -} - -#[test] -fn test_claim_winnings_requires_auth_success() { - let env = Env::default(); - let setup = setup_test_environment(&env); - env.mock_all_auths(); - - let market_id = create_market_with_auth_check(&setup); - - setup.client.place_bet(&setup.user1, &market_id, &0, &100); - - env.ledger().set_timestamp(1735689600 + 90000); - - setup - .client - .resolve_market(&setup.market_creator, &market_id, &0); - - let result = setup.client.try_claim_winnings(&setup.user1, &market_id); - assert!(result.is_ok(), "Winner should claim winnings"); -} - -// ── Reentrancy / double-claim guard (issue #21) ─────────────────────────────── - -/// Regression test for issue #21. -/// -/// Verifies that a second `claim_winnings` call for the same (market, user) -/// pair is rejected with `AlreadyClaimed`, even when the market is resolved -/// and the original bet was on the winning outcome. The `AlreadyClaimed` flag -/// is written to persistent storage **before** any token transfer, so this -/// check covers the reentrancy window described in the issue. -#[test] -fn test_claim_winnings_rejects_double_claim() { - let env = Env::default(); - let setup = setup_test_environment(&env); - env.mock_all_auths(); - - let market_id = create_market_with_auth_check(&setup); - - // user1 places a winning bet on outcome 0. - setup.client.place_bet(&setup.user1, &market_id, &0, &100); - - env.ledger().set_timestamp(1735689600 + 90000); - - setup - .client - .resolve_market(&setup.market_creator, &market_id, &0); - - // First claim: must succeed. - let first = setup.client.try_claim_winnings(&setup.user1, &market_id); - assert!(first.is_ok(), "First claim should succeed"); - - // Second claim: must be rejected as AlreadyClaimed. - let second = setup.client.try_claim_winnings(&setup.user1, &market_id); - assert!( - second.is_err(), - "Second claim must be rejected (double-claim / reentrancy guard)" - ); - assert_eq!( - second.unwrap_err().unwrap(), - markets::ContractError::AlreadyClaimed.into(), - "Error must be AlreadyClaimed (code 15)" - ); -} - -#[test] -fn test_cancel_market_requires_auth() { - let env = Env::default(); - let setup = setup_test_environment(&env); - // No mock_all_auths. - - let market_id = create_market_with_mock_auth(&setup); - - let result = setup - .client - .try_cancel_market(&setup.unauthorized, &market_id); - assert!( - result.is_err(), - "Unauthorized user should not cancel market" - ); -} - -#[test] -fn test_cancel_market_requires_auth_creator() { - let env = Env::default(); - let setup = setup_test_environment(&env); - env.mock_all_auths(); - - let market_id = create_market_with_auth_check(&setup); - - let result = setup - .client - .try_cancel_market(&setup.market_creator, &market_id); - assert!(result.is_ok(), "Market creator should cancel market"); -} - -// ── withdraw_funds ──────────────────────────────────────────────────────────── - -#[test] -fn test_withdraw_funds_requires_auth() { - let env = Env::default(); - let setup = setup_test_environment(&env); - // No mock_all_auths. - - let market_id = create_market_with_mock_auth(&setup); - - let amount = 50; - - let result = setup - .client - .try_withdraw_funds(&setup.unauthorized, &market_id, &amount); - assert!( - result.is_err(), - "Unauthorized user should not withdraw funds" - ); -} - -#[test] -fn test_withdraw_funds_requires_auth_creator() { - let env = Env::default(); - let setup = setup_test_environment(&env); - env.mock_all_auths(); - - let market_id = create_market_with_auth_check(&setup); - - let amount = 50; - - let result = setup - .client - .try_withdraw_funds(&setup.market_creator, &market_id, &amount); - match result { - Ok(_) => assert!(true, "Auth passed"), - Err(e) => { - let error_str = format!("{:?}", e); - assert!(!error_str.contains("auth"), "Auth should not fail"); - } - } -} - -// ── update_market_params ───────────────────────────────────────────────────── - -#[test] -fn test_update_market_params_requires_auth() { - let env = Env::default(); - let setup = setup_test_environment(&env); - // No mock_all_auths. - - let market_id = create_market_with_mock_auth(&setup); - - let new_end_time = env.ledger().timestamp() + 172800; - - let result = - setup - .client - .try_update_market_params(&setup.unauthorized, &market_id, &new_end_time); - assert!( - result.is_err(), - "Unauthorized user should not update market params" - ); -} - -#[test] -fn test_update_market_params_requires_auth_creator() { - let env = Env::default(); - let setup = setup_test_environment(&env); - env.mock_all_auths(); - - let market_id = create_market_with_auth_check(&setup); - - let new_end_time = env.ledger().timestamp() + 172800; - - let result = - setup - .client - .try_update_market_params(&setup.market_creator, &market_id, &new_end_time); - match result { - Ok(_) => assert!(true, "Auth passed"), - Err(e) => { - let error_str = format!("{:?}", e); - assert!(!error_str.contains("auth"), "Auth should not fail"); - } - } -} - -// ── add_liquidity ───────────────────────────────────────────────────────────── - -#[test] -fn test_add_liquidity_requires_auth() { - let env = Env::default(); - let setup = setup_test_environment(&env); - // No mock_all_auths. - - let market_id = create_market_with_mock_auth(&setup); - - let amount = 1000; - - let result = setup - .client - .try_add_liquidity(&setup.unauthorized, &market_id, &amount); - assert!( - result.is_err(), - "Unauthorized user should not add liquidity" - ); -} - -#[test] -fn test_add_liquidity_requires_auth_success() { - let env = Env::default(); - let setup = setup_test_environment(&env); - env.mock_all_auths(); - - let market_id = create_market_with_auth_check(&setup); - - let amount = 1000; - - let result = setup - .client - .try_add_liquidity(&setup.user1, &market_id, &amount); - match result { - Ok(_) => assert!(true, "Auth passed"), - Err(e) => { - let error_str = format!("{:?}", e); - assert!(!error_str.contains("auth"), "Auth should not fail"); - } - } -} - -// ── remove_liquidity ───────────────────────────────────────────────────────── - -#[test] -fn test_remove_liquidity_requires_auth() { - let env = Env::default(); - let setup = setup_test_environment(&env); - // No mock_all_auths — every setup call uses explicit MockAuth. - - // Create market with MockAuth for market_creator. - let market_id = create_market_with_mock_auth(&setup); - - // Authorize user1 for add_liquidity. - let add_amount = 1000i128; - env.mock_auths(&[MockAuth { - address: &setup.user1, - invoke: &MockAuthInvoke { - contract: &setup.contract_id, - fn_name: "add_liquidity", - args: (&setup.user1, &market_id, &add_amount).into_val(&env), - sub_invokes: &[], - }, - }]); - let _ = setup - .client - .try_add_liquidity(&setup.user1, &market_id, &1000); - - // Now test: unauthorized user should NOT be able to remove liquidity. - let amount = 100; - let result = setup - .client - .try_remove_liquidity(&setup.unauthorized, &market_id, &amount); - assert!( - result.is_err(), - "Unauthorized user should not remove liquidity" - ); -} - -#[test] -fn test_remove_liquidity_requires_auth_liquidity_provider() { - let env = Env::default(); - let setup = setup_test_environment(&env); - env.mock_all_auths(); - - let market_id = create_market_with_auth_check(&setup); - - let _ = setup - .client - .try_add_liquidity(&setup.user1, &market_id, &1000); - - let amount = 100; - - let result = setup - .client - .try_remove_liquidity(&setup.user1, &market_id, &amount); - match result { - Ok(_) => assert!(true, "Auth passed"), - Err(e) => { - let error_str = format!("{:?}", e); - assert!(!error_str.contains("auth"), "Auth should not fail"); - } - } -} - -// ── admin functions ────────────────────────────────────────────────────────── - -#[test] -fn test_admin_requires_auth() { - let env = Env::default(); - // No mock_all_auths — require_auth should fail for everyone. - let contract_id = env.register(MarketsContract, ()); - let client = MarketsContractClient::new(&env, &contract_id); - - let unauthorized = Address::generate(&env); - - let result = client.try_pause_markets(&unauthorized); - assert!( - result.is_err(), - "Unauthorized user should not pause markets" - ); - - let result = client.try_unpause_markets(&unauthorized); - assert!( - result.is_err(), - "Unauthorized user should not unpause markets" - ); -} - -#[test] -fn test_admin_requires_auth_admin() { - let env = Env::default(); - let setup = setup_test_environment(&env); - env.mock_all_auths(); - - let result = setup.client.try_pause_markets(&setup.admin); - assert!(result.is_ok(), "Admin should pause markets"); - - let result = setup.client.try_unpause_markets(&setup.admin); - assert!(result.is_ok(), "Admin should unpause markets"); -} - -// ── transfer_ownership ──────────────────────────────────────────────────────── - -#[test] -fn test_transfer_ownership_requires_auth() { - let env = Env::default(); - // No mock_all_auths — require_auth should fail. - let contract_id = env.register(MarketsContract, ()); - let client = MarketsContractClient::new(&env, &contract_id); - - let unauthorized = Address::generate(&env); - let new_owner = Address::generate(&env); - - let result = client.try_transfer_ownership(&unauthorized, &new_owner); - assert!( - result.is_err(), - "Unauthorized user should not transfer ownership" - ); -} - -#[test] -fn test_transfer_ownership_requires_auth_admin() { - let env = Env::default(); - let setup = setup_test_environment(&env); - env.mock_all_auths(); - - let new_owner = Address::generate(&env); - - let result = setup - .client - .try_transfer_ownership(&setup.admin, &new_owner); - match result { - Ok(_) => assert!(true, "Auth passed"), - Err(e) => { - let error_str = format!("{:?}", e); - assert!(!error_str.contains("auth"), "Auth should not fail"); - } - } -} - -// ── test coverage for all entrypoints ──────────────────────────────────────── - -#[test] -fn test_all_entrypoints_have_auth_checks() { - let env = Env::default(); - let setup = setup_test_environment(&env); - env.mock_all_auths(); - - let _market_id = create_market_with_auth_check(&setup); - - let auth_functions = vec![ - "create_market", - "place_bet", - "resolve_market", - "claim_winnings", - "cancel_market", - "withdraw_funds", - "update_market_params", - "add_liquidity", - "remove_liquidity", - "pause_markets", - "unpause_markets", - "transfer_ownership", - ]; - - for func in &auth_functions { - println!("✅ {} has auth checks", func); - } - - assert!( - auth_functions.len() >= 12, - "All entrypoints should be tested" - ); -} +#![cfg(test)] + +use markets::{ContractError, MarketsContract, MarketsContractClient, PayoutCurveConfig}; +use soroban_sdk::{ + testutils::{Address as _, Ledger, LedgerInfo, MockAuth, MockAuthInvoke}, + Address, Env, IntoVal, String, Vec, +}; + +// ============================================ +// Test Helpers +// ============================================ + +fn setup_test_environment(env: &Env) -> TestSetup<'_> { + env.ledger().set(LedgerInfo { + timestamp: 1735689600, + protocol_version: 22, + sequence_number: 1, + network_id: [0; 32], + base_reserve: 10, + min_temp_entry_ttl: 1, + min_persistent_entry_ttl: 1, + max_entry_ttl: 518400, + }); + + let admin = Address::generate(env); + let market_creator = Address::generate(env); + let user1 = Address::generate(env); + let user2 = Address::generate(env); + let unauthorized = Address::generate(env); + + let contract_id = env.register(MarketsContract, ()); + let client = MarketsContractClient::new(env, &contract_id); + + let init_args = (admin.clone(),).into_val(env); + env.mock_auths(&[MockAuth { + address: &admin, + invoke: &MockAuthInvoke { + contract: &contract_id, + fn_name: "initialize", + args: init_args, + sub_invokes: &[], + }, + }]); + client.initialize(&admin); + let _ = env.auths(); + + TestSetup { + admin, + market_creator, + user1, + user2, + unauthorized, + client, + contract_id, + } +} + +struct TestSetup<'a> { + admin: Address, + market_creator: Address, + user1: Address, + user2: Address, + unauthorized: Address, + client: MarketsContractClient<'a>, + contract_id: Address, +} + +/// Creates a market using explicit auth mocking for the market creator. +/// Used by tests that cannot rely on `mock_all_auths()`. +fn create_market_with_mock_auth(setup: &TestSetup<'_>) -> u32 { + let env = &setup.client.env; + let question = String::from_str(env, "Test market question?"); + let description = String::from_str(env, "Test market description"); + let end_time = env.ledger().timestamp() + 86400; + let resolution_source = String::from_str(env, "Test Source"); + let outcome_tags = Vec::from_array( + env, + [String::from_str(env, "Yes"), String::from_str(env, "No")], + ); + + // Authorize ONLY the market_creator for this specific create_market call. + env.mock_auths(&[MockAuth { + address: &setup.market_creator, + invoke: &MockAuthInvoke { + contract: &setup.contract_id, + fn_name: "create_market", + args: ( + setup.market_creator.clone(), + question.clone(), + description.clone(), + end_time, + resolution_source.clone(), + outcome_tags.clone(), + ) + .into_val(env), + sub_invokes: &[], + }, + }]); + + setup.client.create_market( + &setup.market_creator, + &question, + &description, + &end_time, + &resolution_source, + &outcome_tags, + ) +} + +/// Creates a market (requires `env.mock_all_auths()` to be active). +fn create_market_with_auth_check(setup: &TestSetup<'_>) -> u32 { + let env = &setup.client.env; + let question = String::from_str(env, "Test market question?"); + let description = String::from_str(env, "Test market description"); + let end_time = env.ledger().timestamp() + 86400; + let resolution_source = String::from_str(env, "Test Source"); + let outcome_tags = Vec::from_array( + env, + [String::from_str(env, "Yes"), String::from_str(env, "No")], + ); + + setup.client.create_market( + &setup.market_creator, + &question, + &description, + &end_time, + &resolution_source, + &outcome_tags, + ) +} + +// ============================================ +// Auth Boundary Tests +// ============================================ + +// ── create_market ───────────────────────────────────────────────────────────── + +#[test] +fn test_create_market_requires_auth() { + let env = Env::default(); + // No mock_all_auths — require_auth should fail for everyone. + let contract_id = env.register(MarketsContract, ()); + let client = MarketsContractClient::new(&env, &contract_id); + + let unauthorized = Address::generate(&env); + let question = String::from_str(&env, "Test market question?"); + let description = String::from_str(&env, "Test market description"); + let end_time = env.ledger().timestamp() + 86400; + let resolution_source = String::from_str(&env, "Test Source"); + let outcome_tags = Vec::from_array( + &env, + [String::from_str(&env, "Yes"), String::from_str(&env, "No")], + ); + + let result = client.try_create_market( + &unauthorized, + &question, + &description, + &end_time, + &resolution_source, + &outcome_tags, + ); + assert!( + result.is_err(), + "Unauthorized user should not create market" + ); +} + +#[test] +fn test_create_market_requires_auth_success() { + let env = Env::default(); + let setup = setup_test_environment(&env); + env.mock_all_auths(); + + let market_id = create_market_with_auth_check(&setup); + assert_eq!(market_id, 1); +} + +#[test] +fn test_market_limit_blocks_both_creation_entrypoints() { + let env = Env::default(); + let setup = setup_test_environment(&env); + env.mock_all_auths(); + + assert_eq!(setup.client.get_market_limit(), 10_000); + + let unauthorized_result = setup + .client + .try_set_market_limit(&setup.unauthorized, &1); + assert_eq!( + unauthorized_result.unwrap_err().unwrap(), + ContractError::Unauthorized.into() + ); + + setup.client.set_market_limit(&setup.admin, &1); + assert_eq!(setup.client.get_market_limit(), 1); + let market_id = create_market_with_auth_check(&setup); + assert_eq!(market_id, 1); + + let question = String::from_str(&env, "Another market?"); + let description = String::from_str(&env, "Description"); + let end_time = env.ledger().timestamp() + 86_400; + let resolution_source = String::from_str(&env, "Test Source"); + let outcome_tags = Vec::from_array( + &env, + [String::from_str(&env, "Yes"), String::from_str(&env, "No")], + ); + + let regular_result = setup.client.try_create_market( + &setup.market_creator, + &question, + &description, + &end_time, + &resolution_source, + &outcome_tags, + ); + assert_eq!( + regular_result.unwrap_err().unwrap(), + ContractError::MarketLimitReached.into() + ); + + let curve = PayoutCurveConfig::winner_takes_all(&env); + let curve_result = setup.client.try_create_market_with_curve( + &setup.market_creator, + &question, + &description, + &end_time, + &resolution_source, + &outcome_tags, + &curve, + ); + assert_eq!( + curve_result.unwrap_err().unwrap(), + ContractError::MarketLimitReached.into() + ); + assert_eq!(setup.client.get_market_for_resolution(&2), None); +} + +#[test] +fn test_uninitialized_contract_rejects_admin_actions() { + let env = Env::default(); + env.mock_all_auths(); + let attacker = Address::generate(&env); + let contract_id = env.register(MarketsContract, ()); + let client = MarketsContractClient::new(&env, &contract_id); + + assert_eq!(client.get_admin(), None); + assert_eq!( + client.try_pause_markets(&attacker).unwrap_err().unwrap(), + ContractError::Unauthorized.into() + ); + assert_eq!( + client + .try_transfer_ownership(&attacker, &Address::generate(&env)) + .unwrap_err() + .unwrap(), + ContractError::Unauthorized.into() + ); +} + +#[test] +fn test_create_market_requires_auth_market_creator() { + let env = Env::default(); + let setup = setup_test_environment(&env); + env.mock_all_auths(); + + let question = String::from_str(&env, "Test market question?"); + let description = String::from_str(&env, "Test market description"); + let end_time = env.ledger().timestamp() + 86400; + let resolution_source = String::from_str(&env, "Test Source"); + let outcome_tags = Vec::from_array( + &env, + [String::from_str(&env, "Yes"), String::from_str(&env, "No")], + ); + + let result = setup.client.try_create_market( + &setup.market_creator, + &question, + &description, + &end_time, + &resolution_source, + &outcome_tags, + ); + assert!(result.is_ok(), "Authorized market creator should succeed"); +} + +// ── place_bet ───────────────────────────────────────────────────────────────── + +#[test] +fn test_place_bet_requires_auth() { + let env = Env::default(); + let setup = setup_test_environment(&env); + // No mock_all_auths — setup uses explicit MockAuth, but + // the unauthorized test call should fail auth. + + let market_id = create_market_with_mock_auth(&setup); + + let outcome_index = 0; + let amount = 100; + + let result = + setup + .client + .try_place_bet(&setup.unauthorized, &market_id, &outcome_index, &amount); + assert!(result.is_err(), "Unauthorized user should not place bet"); +} + +#[test] +fn test_place_bet_requires_auth_success() { + let env = Env::default(); + let setup = setup_test_environment(&env); + env.mock_all_auths(); + + let market_id = create_market_with_auth_check(&setup); + + let outcome_index = 0; + let amount = 100; + + let result = setup + .client + .try_place_bet(&setup.user1, &market_id, &outcome_index, &amount); + assert!(result.is_ok(), "Authorized user should place bet"); +} + +#[test] +fn test_place_bet_rejects_closed_market() { + let env = Env::default(); + let setup = setup_test_environment(&env); + env.mock_all_auths(); + + let market_id = create_market_with_auth_check(&setup); + env.ledger().set_timestamp(1735689600 + 86400); + + let result = setup + .client + .try_place_bet(&setup.user1, &market_id, &0, &100); + assert_eq!(result, Err(Ok(ContractError::MarketClosed))); +} + +// ── resolve_market ──────────────────────────────────────────────────────────── + +#[test] +fn test_resolve_market_requires_auth() { + let env = Env::default(); + let setup = setup_test_environment(&env); + // No mock_all_auths. + + let market_id = create_market_with_mock_auth(&setup); + + let winning_outcome = 0; + + let result = setup + .client + .try_resolve_market(&setup.unauthorized, &market_id, &winning_outcome); + assert!( + result.is_err(), + "Unauthorized user should not resolve market" + ); +} + +#[test] +fn test_resolve_market_requires_auth_creator() { + let env = Env::default(); + let setup = setup_test_environment(&env); + env.mock_all_auths(); + + let market_id = create_market_with_auth_check(&setup); + + let winning_outcome = 0; + + let result = + setup + .client + .try_resolve_market(&setup.market_creator, &market_id, &winning_outcome); + assert!(result.is_ok(), "Market creator should resolve market"); +} + +// ── claim_winnings ──────────────────────────────────────────────────────────── + +#[test] +fn test_claim_winnings_requires_auth() { + let env = Env::default(); + let setup = setup_test_environment(&env); + // No mock_all_auths — every setup call uses explicit MockAuth. + + // Create market with MockAuth for market_creator. + let market_id = create_market_with_mock_auth(&setup); + + // Authorize user1 for place_bet. + let bet_outcome = 0u32; + let bet_amount = 100i128; + env.mock_auths(&[MockAuth { + address: &setup.user1, + invoke: &MockAuthInvoke { + contract: &setup.contract_id, + fn_name: "place_bet", + args: (&setup.user1, &market_id, &bet_outcome, &bet_amount).into_val(&env), + sub_invokes: &[], + }, + }]); + setup.client.place_bet(&setup.user1, &market_id, &0, &100); + + // Advance ledger past end time. + env.ledger().set_timestamp(1735689600 + 90000); + + // Authorize market_creator for resolve_market. + let win_outcome = 0u32; + env.mock_auths(&[MockAuth { + address: &setup.market_creator, + invoke: &MockAuthInvoke { + contract: &setup.contract_id, + fn_name: "resolve_market", + args: (&setup.market_creator, &market_id, &win_outcome).into_val(&env), + sub_invokes: &[], + }, + }]); + setup + .client + .resolve_market(&setup.market_creator, &market_id, &0); + + // Now test: unauthorized user should NOT be able to claim. + let result = setup + .client + .try_claim_winnings(&setup.unauthorized, &market_id); + assert!( + result.is_err(), + "Unauthorized user should not claim winnings" + ); +} + +#[test] +fn test_claim_winnings_requires_auth_success() { + let env = Env::default(); + let setup = setup_test_environment(&env); + env.mock_all_auths(); + + let market_id = create_market_with_auth_check(&setup); + + setup.client.place_bet(&setup.user1, &market_id, &0, &100); + + env.ledger().set_timestamp(1735689600 + 90000); + + setup + .client + .resolve_market(&setup.market_creator, &market_id, &0); + + let result = setup.client.try_claim_winnings(&setup.user1, &market_id); + assert!(result.is_ok(), "Winner should claim winnings"); +} + +// ── Reentrancy / double-claim guard (issue #21) ─────────────────────────────── + +/// Regression test for issue #21. +/// +/// Verifies that a second `claim_winnings` call for the same (market, user) +/// pair is rejected with `AlreadyClaimed`, even when the market is resolved +/// and the original bet was on the winning outcome. The `AlreadyClaimed` flag +/// is written to persistent storage **before** any token transfer, so this +/// check covers the reentrancy window described in the issue. +#[test] +fn test_claim_winnings_rejects_double_claim() { + let env = Env::default(); + let setup = setup_test_environment(&env); + env.mock_all_auths(); + + let market_id = create_market_with_auth_check(&setup); + + // user1 places a winning bet on outcome 0. + setup.client.place_bet(&setup.user1, &market_id, &0, &100); + + env.ledger().set_timestamp(1735689600 + 90000); + + setup + .client + .resolve_market(&setup.market_creator, &market_id, &0); + + // First claim: must succeed. + let first = setup.client.try_claim_winnings(&setup.user1, &market_id); + assert!(first.is_ok(), "First claim should succeed"); + + // Second claim: must be rejected as AlreadyClaimed. + let second = setup.client.try_claim_winnings(&setup.user1, &market_id); + assert!( + second.is_err(), + "Second claim must be rejected (double-claim / reentrancy guard)" + ); + assert_eq!( + second.unwrap_err().unwrap(), + markets::ContractError::AlreadyClaimed.into(), + "Error must be AlreadyClaimed (code 15)" + ); +} + +#[test] +fn test_cancel_market_requires_auth() { + let env = Env::default(); + let setup = setup_test_environment(&env); + // No mock_all_auths. + + let market_id = create_market_with_mock_auth(&setup); + + let result = setup + .client + .try_cancel_market(&setup.unauthorized, &market_id); + assert!( + result.is_err(), + "Unauthorized user should not cancel market" + ); +} + +#[test] +fn test_cancel_market_requires_auth_creator() { + let env = Env::default(); + let setup = setup_test_environment(&env); + env.mock_all_auths(); + + let market_id = create_market_with_auth_check(&setup); + + let result = setup + .client + .try_cancel_market(&setup.market_creator, &market_id); + assert!(result.is_ok(), "Market creator should cancel market"); +} + +// ── withdraw_funds ──────────────────────────────────────────────────────────── + +#[test] +fn test_withdraw_funds_requires_auth() { + let env = Env::default(); + let setup = setup_test_environment(&env); + // No mock_all_auths. + + let market_id = create_market_with_mock_auth(&setup); + + let amount = 50; + + let result = setup + .client + .try_withdraw_funds(&setup.unauthorized, &market_id, &amount); + assert!( + result.is_err(), + "Unauthorized user should not withdraw funds" + ); +} + +#[test] +fn test_withdraw_funds_requires_auth_creator() { + let env = Env::default(); + let setup = setup_test_environment(&env); + env.mock_all_auths(); + + let market_id = create_market_with_auth_check(&setup); + + let amount = 50; + + let result = setup + .client + .try_withdraw_funds(&setup.market_creator, &market_id, &amount); + match result { + Ok(_) => assert!(true, "Auth passed"), + Err(e) => { + let error_str = format!("{:?}", e); + assert!(!error_str.contains("auth"), "Auth should not fail"); + } + } +} + +// ── update_market_params ───────────────────────────────────────────────────── + +#[test] +fn test_update_market_params_requires_auth() { + let env = Env::default(); + let setup = setup_test_environment(&env); + // No mock_all_auths. + + let market_id = create_market_with_mock_auth(&setup); + + let new_end_time = env.ledger().timestamp() + 172800; + + let result = + setup + .client + .try_update_market_params(&setup.unauthorized, &market_id, &new_end_time); + assert!( + result.is_err(), + "Unauthorized user should not update market params" + ); +} + +#[test] +fn test_update_market_params_requires_auth_creator() { + let env = Env::default(); + let setup = setup_test_environment(&env); + env.mock_all_auths(); + + let market_id = create_market_with_auth_check(&setup); + + let new_end_time = env.ledger().timestamp() + 172800; + + let result = + setup + .client + .try_update_market_params(&setup.market_creator, &market_id, &new_end_time); + match result { + Ok(_) => assert!(true, "Auth passed"), + Err(e) => { + let error_str = format!("{:?}", e); + assert!(!error_str.contains("auth"), "Auth should not fail"); + } + } +} + +// ── add_liquidity ───────────────────────────────────────────────────────────── + +#[test] +fn test_add_liquidity_requires_auth() { + let env = Env::default(); + let setup = setup_test_environment(&env); + // No mock_all_auths. + + let market_id = create_market_with_mock_auth(&setup); + + let amount = 1000; + + let result = setup + .client + .try_add_liquidity(&setup.unauthorized, &market_id, &amount); + assert!( + result.is_err(), + "Unauthorized user should not add liquidity" + ); +} + +#[test] +fn test_add_liquidity_requires_auth_success() { + let env = Env::default(); + let setup = setup_test_environment(&env); + env.mock_all_auths(); + + let market_id = create_market_with_auth_check(&setup); + + let amount = 1000; + + let result = setup + .client + .try_add_liquidity(&setup.user1, &market_id, &amount); + match result { + Ok(_) => assert!(true, "Auth passed"), + Err(e) => { + let error_str = format!("{:?}", e); + assert!(!error_str.contains("auth"), "Auth should not fail"); + } + } +} + +// ── remove_liquidity ───────────────────────────────────────────────────────── + +#[test] +fn test_remove_liquidity_requires_auth() { + let env = Env::default(); + let setup = setup_test_environment(&env); + // No mock_all_auths — every setup call uses explicit MockAuth. + + // Create market with MockAuth for market_creator. + let market_id = create_market_with_mock_auth(&setup); + + // Authorize user1 for add_liquidity. + let add_amount = 1000i128; + env.mock_auths(&[MockAuth { + address: &setup.user1, + invoke: &MockAuthInvoke { + contract: &setup.contract_id, + fn_name: "add_liquidity", + args: (&setup.user1, &market_id, &add_amount).into_val(&env), + sub_invokes: &[], + }, + }]); + let _ = setup + .client + .try_add_liquidity(&setup.user1, &market_id, &1000); + + // Now test: unauthorized user should NOT be able to remove liquidity. + let amount = 100; + let result = setup + .client + .try_remove_liquidity(&setup.unauthorized, &market_id, &amount); + assert!( + result.is_err(), + "Unauthorized user should not remove liquidity" + ); +} + +#[test] +fn test_remove_liquidity_requires_auth_liquidity_provider() { + let env = Env::default(); + let setup = setup_test_environment(&env); + env.mock_all_auths(); + + let market_id = create_market_with_auth_check(&setup); + + let _ = setup + .client + .try_add_liquidity(&setup.user1, &market_id, &1000); + + let amount = 100; + + let result = setup + .client + .try_remove_liquidity(&setup.user1, &market_id, &amount); + match result { + Ok(_) => assert!(true, "Auth passed"), + Err(e) => { + let error_str = format!("{:?}", e); + assert!(!error_str.contains("auth"), "Auth should not fail"); + } + } +} + +// ── admin functions ────────────────────────────────────────────────────────── + +#[test] +fn test_admin_requires_auth() { + let env = Env::default(); + // No mock_all_auths — require_auth should fail for everyone. + let contract_id = env.register(MarketsContract, ()); + let client = MarketsContractClient::new(&env, &contract_id); + + let unauthorized = Address::generate(&env); + + let result = client.try_pause_markets(&unauthorized); + assert!( + result.is_err(), + "Unauthorized user should not pause markets" + ); + + let result = client.try_unpause_markets(&unauthorized); + assert!( + result.is_err(), + "Unauthorized user should not unpause markets" + ); +} + +#[test] +fn test_admin_requires_auth_admin() { + let env = Env::default(); + let setup = setup_test_environment(&env); + env.mock_all_auths(); + + let result = setup.client.try_pause_markets(&setup.admin); + assert!(result.is_ok(), "Admin should pause markets"); + + let result = setup.client.try_unpause_markets(&setup.admin); + assert!(result.is_ok(), "Admin should unpause markets"); +} + +// ── transfer_ownership ──────────────────────────────────────────────────────── + +#[test] +fn test_transfer_ownership_requires_auth() { + let env = Env::default(); + // No mock_all_auths — require_auth should fail. + let contract_id = env.register(MarketsContract, ()); + let client = MarketsContractClient::new(&env, &contract_id); + + let unauthorized = Address::generate(&env); + let new_owner = Address::generate(&env); + + let result = client.try_transfer_ownership(&unauthorized, &new_owner); + assert!( + result.is_err(), + "Unauthorized user should not transfer ownership" + ); +} + +#[test] +fn test_transfer_ownership_requires_auth_admin() { + let env = Env::default(); + let setup = setup_test_environment(&env); + env.mock_all_auths(); + + let new_owner = Address::generate(&env); + + let result = setup + .client + .try_transfer_ownership(&setup.admin, &new_owner); + match result { + Ok(_) => assert!(true, "Auth passed"), + Err(e) => { + let error_str = format!("{:?}", e); + assert!(!error_str.contains("auth"), "Auth should not fail"); + } + } +} + +// ── test coverage for all entrypoints ──────────────────────────────────────── + +#[test] +fn test_all_entrypoints_have_auth_checks() { + let env = Env::default(); + let setup = setup_test_environment(&env); + env.mock_all_auths(); + + let _market_id = create_market_with_auth_check(&setup); + + let auth_functions = vec![ + "create_market", + "place_bet", + "resolve_market", + "claim_winnings", + "cancel_market", + "withdraw_funds", + "update_market_params", + "add_liquidity", + "remove_liquidity", + "pause_markets", + "unpause_markets", + "transfer_ownership", + ]; + + for func in &auth_functions { + println!("✅ {} has auth checks", func); + } + + assert!( + auth_functions.len() >= 12, + "All entrypoints should be tested" + ); +} diff --git a/contracts/markets/tests/issues_007_012_014_022.rs b/contracts/markets/tests/issues_007_012_014_022.rs index 6319ca95..23f5d4ee 100644 --- a/contracts/markets/tests/issues_007_012_014_022.rs +++ b/contracts/markets/tests/issues_007_012_014_022.rs @@ -34,7 +34,7 @@ fn setup_test_env() -> TestEnv<'static> { let client = MarketsContractClient::new(&env, &contract_id); // Initialize admin - client.transfer_ownership(&admin1, &admin1); + client.initialize(&admin1); let token_contract = env.register_stellar_asset_contract_v2(admin1.clone()); let token_address = token_contract.address(); @@ -329,6 +329,44 @@ fn test_default_winner_takes_all_payout() { ); } +#[test] +fn test_liquidity_providers_receive_pro_rata_winning_payout_share() { + let te = setup_test_env(); + let market_id = create_standard_market(&te, 2); + + te.client.add_liquidity(&te.admin1, &market_id, &100); + te.client.add_liquidity(&te.admin2, &market_id, &300); + te.client.place_bet(&te.bettor1, &market_id, &0, &100); + te.client.place_bet(&te.bettor2, &market_id, &1, &900); + te.client.resolve_market(&te.creator, &market_id, &0); + + // Reserve 10% of the winner's gross payout and split it 1:3 by liquidity. + assert_eq!(te.client.calculate_payout(&market_id, &te.bettor1), 900); + assert_eq!(te.client.claim_winnings(&te.bettor1, &market_id), 900); + assert_eq!(te.client.collect_fees(&te.admin1, &market_id), 25); + assert_eq!(te.client.collect_fees(&te.admin2, &market_id), 75); + assert_eq!(te.client.collect_fees(&te.admin1, &market_id), 0); +} + +#[test] +fn test_removing_liquidity_preserves_accrued_rewards() { + let te = setup_test_env(); + let market_id = create_standard_market(&te, 2); + + te.client.add_liquidity(&te.admin1, &market_id, &100); + te.client.add_liquidity(&te.admin2, &market_id, &100); + te.client.place_bet(&te.bettor1, &market_id, &0, &100); + te.client.place_bet(&te.bettor2, &market_id, &1, &900); + te.client.resolve_market(&te.creator, &market_id, &0); + + te.client.claim_winnings(&te.bettor1, &market_id); + te.client.remove_liquidity(&te.admin1, &market_id, &100); + + // Removing principal does not forfeit rewards earned before removal. + assert_eq!(te.client.collect_fees(&te.admin1, &market_id), 50); + assert_eq!(te.client.collect_fees(&te.admin2, &market_id), 50); +} + #[test] fn test_winner_takes_all_carries_fractional_payout_remainders() { let te = setup_test_env(); diff --git a/contracts/predictify-hybrid/src/admin/fees.rs b/contracts/predictify-hybrid/src/admin/fees.rs new file mode 100644 index 00000000..238bc4b8 --- /dev/null +++ b/contracts/predictify-hybrid/src/admin/fees.rs @@ -0,0 +1,126 @@ +#![allow(unused_imports)] + +use super::*; + +impl AdminFunctions { + /// Updates the platform fee configuration (admin only). + /// + /// This function allows authorized admins to modify the fee structure + /// used throughout the platform, including platform fees, creation fees, + /// and other fee-related parameters. Changes take effect immediately. + /// + /// # Parameters + /// + /// * `env` - The Soroban environment for blockchain operations + /// * `admin` - The admin address performing the update (must have UpdateFees permission) + /// * `new_config` - The new fee configuration to apply + /// + /// # Returns + /// + /// Returns `Result` where: + /// - `Ok(FeeConfig)` - Updated fee configuration + /// - `Err(Error)` - Update failed due to permissions or validation + /// + /// # Errors + /// + /// This function returns specific errors: + /// - `Error::Unauthorized` - Admin lacks UpdateFees permission + /// - `Error::InvalidInput` - Fee configuration contains invalid values + /// - Fee validation errors from FeeManager + /// - Storage operation errors + /// + /// # Example + /// + /// ```rust + /// # use soroban_sdk::{Env, Address}; + /// # use predictify_hybrid::admin::AdminFunctions; + /// # use predictify_hybrid::fees::FeeConfig; + /// # let env = Env::default(); + /// # let admin = Address::generate(&env); + /// # let new_config = FeeConfig { + /// # platform_fee_percentage: 250, // 2.5% + /// # creation_fee: 1000000, // 1 XLM + /// # min_stake: 100000, // 0.1 XLM + /// # }; + /// + /// // Update platform fees + /// match AdminFunctions::update_fee_config(&env, &admin, &new_config) { + /// Ok(updated_config) => { + /// println!("Fees updated successfully"); + /// println!("New platform fee: {}%", updated_config.platform_fee_percentage / 100); + /// }, + /// Err(e) => { + /// println!("Failed to update fees: {:?}", e); + /// } + /// } + /// ``` + /// + /// # Fee Configuration Parameters + /// + /// The FeeConfig struct typically includes: + /// - **Platform Fee Percentage**: Fee taken from winning payouts (basis points) + /// - **Creation Fee**: Fee required to create new markets + /// - **Minimum Stake**: Minimum amount required for voting + /// - **Maximum Fee Cap**: Upper limit on total fees + /// + /// # Update Process + /// + /// The update process: + /// 1. **Permission Validation**: Ensures admin has UpdateFees permission + /// 2. **Configuration Validation**: Validates new fee parameters + /// 3. **Fee Update**: Uses FeeManager to apply new configuration + /// 4. **Action Logging**: Records fee update for audit trail + /// + /// # Impact and Considerations + /// + /// Fee updates have immediate platform-wide effects: + /// - New markets use updated creation fees + /// - Existing market resolutions use updated platform fees + /// - User interfaces should reflect new fee structure + /// - Consider gradual rollout for major fee changes + /// + /// # Best Practices + /// + /// - Announce fee changes to the community in advance + /// - Test fee changes on testnet before mainnet deployment + /// - Monitor platform activity after fee changes + /// - Keep fees competitive with similar platforms + /// - Document rationale for fee changes + pub fn update_fee_config( + env: &Env, + admin: &Address, + new_config: &FeeConfig, + ) -> Result<(), Error> { + // Validate admin permissions + AdminAccessControl::validate_admin_for_action(env, admin, "update_fees")?; + + // Queue fee configuration with governance time-lock + FeeManager::update_fee_config(env, admin.clone(), new_config.clone())?; + + // Log admin action + let mut params = Map::new(env); + params.set( + String::from_str(env, "platform_fee"), + String::from_str(env, "platform_fee"), + ); + params.set( + String::from_str(env, "creation_fee"), + String::from_str(env, "creation_fee"), + ); + AdminActionLogger::log_action(env, admin, "update_fees", None, params, true, None)?; + + Ok(()) + } + + /// Apply a previously queued fee configuration update. + /// + /// Succeeds only when the ETA has been reached. Can be called by anyone + /// once the timelock expires. + pub fn apply_fee_update(env: &Env, admin: &Address) -> Result<(), Error> { + FeeManager::apply_fee_update(env, admin.clone())?; + Ok(()) + } + + + +} diff --git a/contracts/predictify-hybrid/src/admin.rs b/contracts/predictify-hybrid/src/admin/governance.rs similarity index 50% rename from contracts/predictify-hybrid/src/admin.rs rename to contracts/predictify-hybrid/src/admin/governance.rs index 0102f36c..bba7205f 100644 --- a/contracts/predictify-hybrid/src/admin.rs +++ b/contracts/predictify-hybrid/src/admin/governance.rs @@ -1,4646 +1,2316 @@ -extern crate alloc; -use alloc::format; -use soroban_sdk::{contracttype, Address, Env, Map, String, Symbol, Vec, panic_with_error}; -// use alloc::string::ToString; // Unused import - -use crate::config::{ConfigManager, ConfigUtils, ContractConfig, Environment}; -use crate::err::Error; -use crate::events::EventEmitter; -use crate::extensions::ExtensionManager; -use crate::fees::{FeeConfig, FeeManager}; -use crate::markets::MarketStateManager; -// use crate::resolution::MarketResolutionManager; -use crate::audit_trail::{AuditAction, AuditTrailManager}; -use alloc::string::ToString; - - -/// Admin management system for Predictify Hybrid contract -/// -/// This module provides a comprehensive admin system with: -/// - Admin initialization and setup functions -/// - Access control and permission validation -/// - Admin role management and hierarchy -/// - Admin action logging and tracking -/// - Admin helper utilities and testing functions -/// - Admin event emission and monitoring - -// ===== ADMIN TYPES ===== - -/// Admin role enumeration -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -#[contracttype] -pub enum AdminRole { - /// Super admin with all permissions - SuperAdmin, - /// Market admin with market management permissions - MarketAdmin, - /// Config admin with configuration permissions - ConfigAdmin, - /// Fee admin with fee management permissions - FeeAdmin, - /// Read-only admin with view permissions only - ReadOnlyAdmin, -} - -#[contracterror] -#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] -pub enum AdminError { - CooldownActive = 101, // Error code for rapid abuse prevention -} - -/// Severity level for admin broadcasts -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -#[contracttype] -pub enum Severity { - Info, - Warning, - Critical, -} +#![allow(unused_imports)] -// 1. Define the storage key for the last action timestamp -pub const LAST_ADMIN_ACTION: &str = "LAST_ADMIN_ACT"; - -// 2. Define the cooldown duration (24 hours = 86,400 seconds) -// For testing purposes, you might want to make this configurable, -// but b#009 usually implies a strict window. -pub const COOLDOWN_PERIOD: u64 = 86_400; - -/// Internal helper to enforce a wait period between critical admin actions. -/// Call this inside any function that modifies sensitive contract state. -pub fn check_and_update_cooldown(env: &Env) { - let now = env.ledger().timestamp(); - - // Get the timestamp of the last action (default to 0 if never called) - let last_action: u64 = env.storage().instance().get(&LAST_ADMIN_ACTION).unwrap_or(0); - - if last_action > 0 { - // Ensure (now - last_action) >= COOLDOWN_PERIOD - if now < last_action.checked_add(COOLDOWN_PERIOD).expect("Overflow") { - panic_with_error!(env, AdminError::CooldownActive); - } - } - - // Update the timestamp to the current time for the next action - env.storage().instance().set(&LAST_ADMIN_ACTION, &now); -} +use super::*; -// --- Critical Admin Functions --- - -pub fn set_voting_parameters(env: Env, admin: Address, /* other params */) { - admin.require_auth(); - - // 3. ENFORCE COOLDOWN (The fix for #1099) - check_and_update_cooldown(&env); - - // ... rest of the logic to update parameters ... -} +// ===== MULTISIG MANAGER ===== -/// Admin permission enumeration -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -#[contracttype] -pub enum AdminPermission { - /// Initialize contract - Initialize, - /// Create markets - CreateMarket, - /// Close markets - CloseMarket, - /// Finalize markets - FinalizeMarket, - /// Extend market duration - ExtendMarket, - /// Update fee configuration - UpdateFees, - /// Update contract configuration - UpdateConfig, - /// Reset configuration - ResetConfig, - /// Collect fees - CollectFees, - /// Manage disputes - ManageDispute, - /// View analytics - ViewAnalytic, - /// Emergency actions - Emergency, - /// Configuration admin actions (set cooldowns, update oracle admin config) - ConfigAdmin, -} +/// Manages multisig/threshold approval for sensitive admin operations +pub const THRESHOLD_ROTATION_DELAY: u64 = 86400; // 24 hours -/// Admin action record +/// Pending threshold update for two-phase commit #[derive(Clone, Debug)] #[contracttype] -pub struct AdminAction { - pub admin: Address, - pub action: String, - pub target: Option, - pub parameters: Map, - pub timestamp: u64, - pub success: bool, - pub error_message: Option, +pub struct PendingThresholdUpdate { + pub new_threshold: u32, + pub proposed_at: u64, + pub proposed_by: Address, } -/// Admin role assignment -#[derive(Clone, Debug)] +/// State for multisig signer rotation cooldowns +#[derive(Clone, Debug, Eq, PartialEq)] #[contracttype] -pub struct AdminRoleAssignment { - pub admin: Address, - pub role: AdminRole, - pub assigned_by: Address, - pub assigned_at: u64, - pub permissions: Vec, - pub is_active: bool, +pub struct MultisigRotationState { + pub cooldown_seconds: u64, + pub last_rotation_timestamp: u64, } -/// Admin analytics -#[derive(Clone, Debug)] -#[contracttype] -pub struct AdminAnalytics { - pub total_admins: u32, - pub active_admins: u32, - pub total_actions: u32, - pub successful_actions: u32, - pub failed_actions: u32, - pub action_distribution: Map, - pub role_distribution: Map, - pub recent_actions: Vec, -} +pub struct MultisigManager; -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -#[contracttype] -pub enum AdminActionType { - Added, - Removed, - RoleUpdated, - Activated, - Deactivated, -} +impl MultisigManager { + /// Propose a new multisig threshold (M-of-N) + pub fn propose_threshold(env: &Env, admin: &Address, new_threshold: u32) -> Result<(), Error> { + AdminAccessControl::validate_permission(env, admin, &AdminPermission::Emergency)?; -/// Admin analytics result for multi-admin system -#[derive(Clone, Debug)] -#[contracttype] -pub struct AdminAnalyticsResult { - pub total_admins: u32, - pub active_admins: u32, - pub role_distribution: Map, - pub last_updated: u64, -} + let total_admins = Self::count_active_admins(env); + if new_threshold == 0 || new_threshold > total_admins { + return Err(Error::InvalidInput); + } -/// Multisig configuration for admin operations -#[derive(Clone, Debug)] -#[contracttype] -pub struct MultisigConfig { - pub threshold: u32, - pub total_admins: u32, - pub enabled: bool, -} + let pending = PendingThresholdUpdate { + new_threshold, + proposed_at: env.ledger().timestamp(), + proposed_by: admin.clone(), + }; -/// Pending admin action requiring multisig approval -#[derive(Clone, Debug)] -#[contracttype] -pub struct PendingAdminAction { - pub action_id: u64, - pub action_type: String, - pub target: Address, - pub initiator: Address, - pub approvals: Vec
, - pub created_at: u64, - pub expires_at: u64, - pub executed: bool, - pub data: Map, -} + env.storage() + .persistent() + .set(&Symbol::new(env, "PendingThreshold"), &pending); -// ===== ADMIN INITIALIZATION ===== + let config = Self::get_config(env); + EventEmitter::emit_threshold_proposed( + env, + admin, + config.threshold, + new_threshold, + env.ledger().timestamp() + THRESHOLD_ROTATION_DELAY, + ); -/// Admin initialization management -pub struct AdminInitializer; + Ok(()) + } -impl AdminInitializer { - /// Initializes the Predictify Hybrid contract with a primary administrator. - /// - /// This function sets up the foundational admin structure for the contract, - /// establishing the primary admin with SuperAdmin privileges and initializing - /// the admin management system. It must be called once after contract deployment. - /// - /// # Parameters - /// - /// * `env` - The Soroban environment for blockchain operations - /// * `admin` - The address to be granted SuperAdmin privileges - /// - /// # Returns - /// - /// Returns `Result<(), Error>` where: - /// - `Ok(())` - Admin initialization completed successfully - /// - `Err(Error)` - Specific error if initialization fails - /// - /// # Errors - /// - /// This function returns specific errors: - /// - `Error::InvalidAddress` - Admin address is invalid or zero - /// - `Error::AlreadyInitialized` - Contract has already been initialized - /// - `Error::StorageError` - Failed to store admin data - /// - Role assignment errors from AdminRoleManager - /// - /// # Example - /// - /// ```rust - /// # use soroban_sdk::{Env, Address}; - /// # use predictify_hybrid::admin::AdminInitializer; - /// # let env = Env::default(); - /// # let admin_address = Address::generate(&env); - /// - /// match AdminInitializer::initialize(&env, &admin_address) { - /// Ok(()) => { - /// println!("Contract initialized successfully"); - /// }, - /// Err(e) => { - /// println!("Initialization failed: {:?}", e); - /// } - /// } - /// ``` - /// - /// # Initialization Process - /// - /// The initialization performs these steps: - /// 1. **Address Validation**: Ensures admin address is valid - /// 2. **Storage Setup**: Stores admin address in persistent storage - /// 3. **Role Assignment**: Grants SuperAdmin role to the admin - /// 4. **Event Emission**: Emits admin initialization event - /// 5. **Action Logging**: Records the initialization action - /// - /// # Post-Initialization State - /// - /// After successful initialization: - /// - Admin has full SuperAdmin privileges - /// - All admin permissions are available to the admin - /// - Admin management system is fully operational - /// - Contract is ready for market creation and management - /// - /// # Security - /// - /// The admin address should be carefully chosen as it will have complete - /// control over the contract. Consider using a multi-signature wallet - /// or governance contract for production deployments. - pub fn initialize(env: &Env, admin: &Address) -> Result<(), Error> { - // Check for re-initialization attempt (critical security check) - AdminValidator::validate_contract_not_initialized(env)?; + /// Confirm a pending threshold update after the delay + pub fn confirm_threshold(env: &Env) -> Result<(), Error> { + let pending: PendingThresholdUpdate = env + .storage() + .persistent() + .get(&Symbol::new(env, "PendingThreshold")) + .ok_or(Error::InvalidState)?; - // Validate admin address - AdminValidator::validate_admin_address(env, admin)?; + if env.ledger().timestamp() < pending.proposed_at + THRESHOLD_ROTATION_DELAY { + return Err(Error::InvalidState); + } - // Store admin in persistent storage - env.storage() - .persistent() - .set(&Symbol::new(env, "Admin"), admin); + let total_admins = Self::count_active_admins(env); + if pending.new_threshold == 0 || pending.new_threshold > total_admins { + env.storage().persistent().remove(&Symbol::new(env, "PendingThreshold")); + return Err(Error::InvalidInput); + } - // Set default admin role - AdminRoleManager::assign_role(env, admin, AdminRole::SuperAdmin, admin)?; + let mut config = Self::get_config(env); + let old_threshold = config.threshold; + config.threshold = pending.new_threshold; + config.enabled = pending.new_threshold > 1; + config.total_admins = total_admins; - // Emit admin initialization event - EventEmitter::emit_admin_initialized(env, admin); + env.storage() + .persistent() + .set(&Symbol::new(env, "MultisigConfig"), &config); - // Log admin action - AdminActionLogger::log_action(env, admin, "initialize", None, Map::new(env), true, None)?; + env.storage().persistent().remove(&Symbol::new(env, "PendingThreshold")); - AuditTrailManager::append_record( + EventEmitter::emit_threshold_confirmed( env, - AuditAction::ContractInitialized, - admin.clone(), - Map::new(env), - None, + &pending.proposed_by, + old_threshold, + pending.new_threshold, ); Ok(()) } - /// Initializes the contract with admin and environment-specific configuration. - /// - /// This advanced initialization function sets up both admin privileges and - /// applies environment-specific configurations (development, testnet, mainnet). - /// It's ideal for deployment scenarios where specific configurations are needed. - /// - /// # Parameters - /// - /// * `env` - The Soroban environment for blockchain operations - /// * `admin` - The address to be granted SuperAdmin privileges - /// * `environment` - The target environment configuration to apply - /// - /// # Returns - /// - /// Returns `Result<(), Error>` where: - /// - `Ok(())` - Admin and configuration initialization completed successfully - /// - `Err(Error)` - Specific error if initialization fails - /// - /// # Errors - /// - /// This function returns errors from: - /// - `AdminInitializer::initialize()` - Basic admin initialization errors - /// - `ConfigManager::store_config()` - Configuration storage errors - /// - Event emission errors - /// - /// # Example - /// - /// ```rust - /// # use soroban_sdk::{Env, Address}; - /// # use predictify_hybrid::admin::AdminInitializer; - /// # use predictify_hybrid::config::Environment; - /// # let env = Env::default(); - /// # let admin_address = Address::generate(&env); - /// - /// // Initialize for mainnet deployment - /// match AdminInitializer::initialize_with_config( - /// &env, - /// &admin_address, - /// &Environment::Mainnet - /// ) { - /// Ok(()) => { - /// println!("Contract initialized with mainnet config"); - /// }, - /// Err(e) => { - /// println!("Initialization failed: {:?}", e); - /// } - /// } - /// ``` - /// - /// # Environment Configurations - /// - /// - **Development**: Relaxed validation, debug features enabled - /// - **Testnet**: Production-like settings with test-friendly parameters - /// - **Mainnet**: Full production settings with strict validation - /// - **Custom**: Defaults to development configuration - /// - /// # Configuration Applied - /// - /// Environment-specific settings include: - /// - Fee structures and percentages - /// - Market duration limits - /// - Validation thresholds - /// - Oracle timeout settings - /// - Dispute resolution parameters - /// - /// # Use Cases - /// - /// - **Production Deployment**: Use with `Environment::Mainnet` - /// - **Testing**: Use with `Environment::Testnet` or `Environment::Development` - /// - **CI/CD Pipelines**: Automated deployment with appropriate environment - /// - **Multi-Environment Contracts**: Same contract code, different configs - pub fn initialize_with_config( - env: &Env, - admin: &Address, - environment: &Environment, - ) -> Result<(), Error> { - // Initialize basic admin setup - AdminInitializer::initialize(env, admin)?; - - let config = match environment { - Environment::Development => ConfigManager::get_development_config(env), - Environment::Testnet => ConfigManager::get_testnet_config(env), - Environment::Mainnet => ConfigManager::get_mainnet_config(env), - Environment::Custom => ConfigManager::get_development_config(env), - }; - ConfigManager::store_config(env, &config)?; - - // Emit configuration initialization event - EventEmitter::emit_config_initialized(env, admin, environment); + /// Cancel a pending threshold proposal + pub fn cancel_threshold_proposal(env: &Env, admin: &Address) -> Result<(), Error> { + AdminAccessControl::validate_permission(env, admin, &AdminPermission::Emergency)?; - Ok(()) + if env.storage().persistent().has(&Symbol::new(env, "PendingThreshold")) { + env.storage().persistent().remove(&Symbol::new(env, "PendingThreshold")); + Ok(()) + } else { + Err(Error::InvalidState) + } } - /// Validates parameters before contract initialization. - /// - /// This function performs pre-initialization validation to ensure the contract - /// can be safely initialized with the provided parameters. It's useful for - /// checking initialization requirements before committing to the initialization. - /// - /// # Parameters - /// - /// * `env` - The Soroban environment for blockchain operations - /// * `admin` - The proposed admin address to validate - /// - /// # Returns - /// - /// Returns `Result<(), Error>` where: - /// - `Ok(())` - All parameters are valid for initialization - /// - `Err(Error)` - Specific validation error - /// - /// # Errors - /// - /// This function returns specific validation errors: - /// - `Error::InvalidAddress` - Admin address is invalid, zero, or malformed - /// - `Error::AlreadyInitialized` - Contract has already been initialized - /// - Address format validation errors - /// - /// # Example - /// - /// ```rust - /// # use soroban_sdk::{Env, Address}; - /// # use predictify_hybrid::admin::AdminInitializer; - /// # let env = Env::default(); - /// # let proposed_admin = Address::generate(&env); - /// - /// // Validate before initialization - /// match AdminInitializer::validate_initialization_params(&env, &proposed_admin) { - /// Ok(()) => { - /// // Parameters are valid, proceed with initialization - /// AdminInitializer::initialize(&env, &proposed_admin).unwrap(); - /// }, - /// Err(e) => { - /// println!("Invalid parameters: {:?}", e); - /// } - /// } - /// ``` - /// - /// # Validation Checks - /// - /// The function performs these validations: - /// 1. **Admin Address**: Ensures address is valid and not zero - /// 2. **Contract State**: Verifies contract hasn't been initialized - /// 3. **Address Format**: Validates Stellar address format - /// 4. **Storage Access**: Ensures storage operations will succeed - /// - /// # Use Cases - /// - /// - **Pre-flight Checks**: Validate before expensive initialization - /// - **UI Validation**: Check parameters in user interfaces - /// - **Deployment Scripts**: Ensure deployment will succeed - /// - **Testing**: Validate test parameters before test execution - /// - **Error Prevention**: Catch issues before state changes - /// - /// # Best Practices - /// - /// Always call this function before `initialize()` or `initialize_with_config()` - /// to prevent failed initialization attempts that could leave the contract - /// in an inconsistent state. - pub fn validate_initialization_params(env: &Env, admin: &Address) -> Result<(), Error> { - AdminValidator::validate_admin_address(env, admin)?; - AdminValidator::validate_contract_not_initialized(env)?; - Ok(()) + /// Get current multisig configuration + pub fn get_config(env: &Env) -> MultisigConfig { + env.storage() + .persistent() + .get(&Symbol::new(env, "MultisigConfig")) + .unwrap_or(MultisigConfig { + threshold: 1, + total_admins: 1, + enabled: false, + }) } -} -// ===== ADMIN ACCESS CONTROL ===== + /// Create a pending action requiring approval + pub fn create_pending_action( + env: &Env, + initiator: &Address, + action_type: String, + target: Address, + data: Map, + ) -> Result { + AdminAccessControl::validate_permission(env, initiator, &AdminPermission::Emergency)?; -/// Admin access control management -pub struct AdminAccessControl; + let action_id = Self::get_next_action_id(env); + let mut approvals = Vec::new(env); + approvals.push_back(initiator.clone()); -impl AdminAccessControl { - /// Validates that an admin has the required permission for a specific action. - /// - /// This function checks if the given admin address has the necessary permission - /// to perform a specific action based on their assigned role. It's the core - /// authorization mechanism for all admin operations in the contract. - /// - /// # Parameters - /// - /// * `env` - The Soroban environment for blockchain operations - /// * `admin` - The admin address to validate permissions for - /// * `permission` - The specific permission required for the action - /// - /// # Returns - /// - /// Returns `Result<(), Error>` where: - /// - `Ok(())` - Admin has the required permission - /// - `Err(Error)` - Admin lacks permission or validation failed - /// - /// # Errors - /// - /// This function returns specific errors: - /// - `Error::Unauthorized` - Admin doesn't have the required permission - /// - `Error::Unauthorized` - Admin role not found or inactive - /// - Role retrieval errors from AdminRoleManager - /// - /// # Example - /// - /// ```rust - /// # use soroban_sdk::{Env, Address}; - /// # use predictify_hybrid::admin::{AdminAccessControl, AdminPermission}; - /// # let env = Env::default(); - /// # let admin = Address::generate(&env); - /// - /// // Check if admin can create markets - /// match AdminAccessControl::validate_permission( - /// &env, - /// &admin, - /// &AdminPermission::CreateMarket - /// ) { - /// Ok(()) => { - /// // Admin has permission, proceed with market creation - /// println!("Admin authorized for market creation"); - /// }, - /// Err(e) => { - /// println!("Permission denied: {:?}", e); - /// } - /// } - /// ``` - /// - /// # Permission Hierarchy - /// - /// Different admin roles have different permission sets: - /// - **SuperAdmin**: All permissions - /// - **MarketAdmin**: Market-related permissions - /// - **ConfigAdmin**: Configuration permissions - /// - **FeeAdmin**: Fee management permissions - /// - **ReadOnlyAdmin**: View-only permissions - /// - /// # Use Cases - /// - /// - **Function Guards**: Validate permissions before executing admin functions - /// - **UI Authorization**: Show/hide UI elements based on permissions - /// - **API Endpoints**: Authorize admin API calls - /// - **Batch Operations**: Validate permissions for multiple operations - /// - **Audit Trails**: Log permission checks for security auditing - pub fn validate_permission( - env: &Env, - admin: &Address, - permission: &AdminPermission, - ) -> Result<(), Error> { - // Try new multi-admin system first if migrated - if AdminSystemIntegration::is_migrated(env) { - return AdminManager::validate_admin_permission(env, admin, *permission); - } - - // Fall back to existing logic - let role = AdminRoleManager::get_admin_role(env, admin)?; + let action = PendingAdminAction { + action_id, + action_type, + target, + initiator: initiator.clone(), + approvals, + created_at: env.ledger().timestamp(), + expires_at: env.ledger().timestamp() + 86400, // 24 hours + executed: false, + data, + }; - // Check if admin has the required permission - if !AdminRoleManager::has_permission(env, &role, permission)? { - return Err(Error::Unauthorized); - } + let key = Self::get_action_key(env, action_id); + env.storage().persistent().set(&key, &action); - Ok(()) + Ok(action_id) } - /// Requires admin authentication and validates admin status. - /// - /// This function performs comprehensive admin authentication by verifying - /// the caller's signature and confirming they are a registered admin. - /// It's the fundamental authentication check for all admin operations. - /// - /// # Parameters - /// - /// * `env` - The Soroban environment for blockchain operations - /// * `admin` - The admin address to authenticate - /// - /// # Returns - /// - /// Returns `Result<(), Error>` where: - /// - `Ok(())` - Admin is authenticated and authorized - /// - `Err(Error)` - Authentication or authorization failed - /// - /// # Errors - /// - /// This function returns specific errors: - /// - `Error::AdminNotSet` - No admin has been configured for the contract - /// - `Error::Unauthorized` - Caller is not the registered admin - /// - Authentication errors from Soroban's `require_auth()` - /// - /// # Example - /// - /// ```rust - /// # use soroban_sdk::{Env, Address}; - /// # use predictify_hybrid::admin::AdminAccessControl; - /// # let env = Env::default(); - /// # let admin = Address::generate(&env); - /// - /// // Authenticate admin before sensitive operation - /// match AdminAccessControl::require_admin_auth(&env, &admin) { - /// Ok(()) => { - /// // Admin is authenticated, proceed with operation - /// println!("Admin authenticated successfully"); - /// }, - /// Err(e) => { - /// println!("Authentication failed: {:?}", e); - /// return; - /// } - /// } - /// ``` - /// - /// # Authentication Process - /// - /// The authentication performs these checks: - /// 1. **Signature Verification**: Validates the caller's cryptographic signature - /// 2. **Admin Lookup**: Retrieves the stored admin address from contract storage - /// 3. **Address Comparison**: Ensures the caller matches the stored admin - /// 4. **Status Validation**: Confirms admin status is active - /// - /// # Security Considerations - /// - /// - Uses Soroban's built-in signature verification - /// - Prevents unauthorized access to admin functions - /// - Should be called before any admin-only operations - /// - Protects against address spoofing attacks - /// - /// # Use Cases - /// - /// - **Function Entry Points**: First check in admin functions - /// - **Batch Operations**: Authenticate once for multiple operations - /// - **API Gateways**: Validate admin API requests - /// - **Emergency Functions**: Ensure only authorized emergency actions - pub fn require_admin_auth(env: &Env, admin: &Address) -> Result<(), Error> { - // Verify admin authentication - admin.require_auth(); + /// Approve a pending action + pub fn approve_action(env: &Env, admin: &Address, action_id: u64) -> Result { + AdminAccessControl::validate_permission(env, admin, &AdminPermission::Emergency)?; - // Validate admin exists - let stored_admin: Address = env + let key = Self::get_action_key(env, action_id); + let mut action: PendingAdminAction = env .storage() .persistent() - .get(&Symbol::new(env, "Admin")) - .ok_or(Error::AdminNotSet)?; + .get(&key) + .ok_or(Error::ConfigNotFound)?; - if admin != &stored_admin { - return Err(Error::Unauthorized); + if action.executed { + return Err(Error::InvalidState); } - Ok(()) + if env.ledger().timestamp() > action.expires_at { + return Err(Error::DisputeError); + } + + if action.approvals.contains(admin) { + return Err(Error::InvalidState); + } + + action.approvals.push_back(admin.clone()); + env.storage().persistent().set(&key, &action); + + let config = Self::get_config(env); + Ok(action.approvals.len() >= config.threshold) } - /// Gets the primary admin address from persistent storage. - pub fn get_admin(env: &Env) -> Result { - env.storage() + /// Execute a pending action if threshold is met + pub fn execute_action(env: &Env, action_id: u64) -> Result<(), Error> { + let key = Self::get_action_key(env, action_id); + let mut action: PendingAdminAction = env + .storage() .persistent() - .get(&Symbol::new(env, "Admin")) - .ok_or(Error::AdminNotSet) - } -} + .get(&key) + .ok_or(Error::ConfigNotFound)?; + + if action.executed { + return Err(Error::InvalidState); + } -// ===== CONTRACT PAUSE AND ADMIN TRANSFER ===== + let config = Self::get_config(env); + if action.approvals.len() < config.threshold { + return Err(Error::Unauthorized); + } -const CONTRACT_PAUSED_KEY: &str = "ContractPaused"; + action.executed = true; + env.storage().persistent().set(&key, &action); -/// Storage key holding the address staged by [`ContractPauseManager::transfer_admin`] -/// until it calls [`ContractPauseManager::accept_admin`]. -const PENDING_ADMIN_KEY: &str = "PendingAdmin"; + Ok(()) + } -/// Contract-level pause and primary admin transfer. -pub struct ContractPauseManager; + /// Get pending action details + pub fn get_pending_action(env: &Env, action_id: u64) -> Option { + let key = Self::get_action_key(env, action_id); + env.storage().persistent().get(&key) + } -impl ContractPauseManager { - /// Returns true if the contract is currently paused. - pub fn is_contract_paused(env: &Env) -> bool { - env.storage() - .persistent() - .get(&Symbol::new(env, CONTRACT_PAUSED_KEY)) - .unwrap_or(false) + /// Check if action requires multisig approval + pub fn requires_multisig(env: &Env) -> bool { + let config = Self::get_config(env); + config.enabled && config.threshold > 1 } - /// Pause contract operations. Caller must be the current primary admin. - pub fn pause(env: &Env, admin: &Address) -> Result<(), Error> { - AdminAccessControl::require_admin_auth(env, admin)?; - env.storage() - .persistent() - .set(&Symbol::new(env, CONTRACT_PAUSED_KEY), &true); - EventEmitter::emit_contract_paused(env, admin); - AuditTrailManager::append_record( - env, - AuditAction::ContractPaused, - admin.clone(), - Map::new(env), - None, - ); - Ok(()) + // Helper methods + fn get_action_key(env: &Env, action_id: u64) -> Symbol { + let key_str = alloc::format!("PendingAction_{}", action_id); + Symbol::new(env, &key_str) } - /// Unpause contract operations. Caller must be the current primary admin. - pub fn unpause(env: &Env, admin: &Address) -> Result<(), Error> { - AdminAccessControl::require_admin_auth(env, admin)?; - env.storage() - .persistent() - .set(&Symbol::new(env, CONTRACT_PAUSED_KEY), &false); - EventEmitter::emit_contract_unpaused(env, admin); - AuditTrailManager::append_record( - env, - AuditAction::ContractUnpaused, - admin.clone(), - Map::new(env), - None, - ); - Ok(()) + fn get_next_action_id(env: &Env) -> u64 { + let key = Symbol::new(env, "NextActionId"); + let current: u64 = env.storage().persistent().get(&key).unwrap_or(1); + env.storage().persistent().set(&key, &(current + 1)); + current } - /// Require that the contract is not paused; return Error::InvalidState otherwise. - pub fn require_not_paused(env: &Env) -> Result<(), Error> { - if Self::is_contract_paused(env) { - return Err(Error::InvalidState); - } - Ok(()) + fn count_active_admins(env: &Env) -> u32 { + let count_key = Symbol::new(env, "AdminCount"); + env.storage().persistent().get(&count_key).unwrap_or(1) } - /// Step 1 of 2: stage a new primary admin. - /// - /// Caller must be the current primary admin and `new_admin` must pass - /// [`AdminValidator::validate_admin_address`]. The role is **not** moved - /// here: the address is only recorded as pending, and the current admin - /// keeps full control until the pending address calls - /// [`Self::accept_admin`]. This means a typo (or an arbitrary attacker - /// address) can no longer permanently orphan the contract — the transfer - /// can be corrected by calling this function again or aborted with - /// [`Self::cancel_admin_transfer`]. - pub fn transfer_admin( - env: &Env, - current_admin: &Address, - new_admin: &Address, - ) -> Result<(), Error> { - AdminAccessControl::require_admin_auth(env, current_admin)?; - if new_admin == current_admin { - return Err(Error::InvalidInput); - } - AdminValidator::validate_admin_address(env, new_admin)?; + /// Get current rotation state + pub fn get_rotation_state(env: &Env) -> MultisigRotationState { env.storage() .persistent() - .set(&Symbol::new(env, PENDING_ADMIN_KEY), new_admin); - EventEmitter::emit_admin_transfer_started(env, current_admin, new_admin); + .get(&crate::storage::DataKey::MultisigRotationState) + .unwrap_or(MultisigRotationState { + cooldown_seconds: 3600, // 1 hour default + last_rotation_timestamp: 0, + }) + } + + /// Set rotation cooldown (Emergency permission required) + pub fn set_rotation_cooldown(env: &Env, admin: &Address, cooldown_seconds: u64) -> Result<(), Error> { + admin.require_auth(); + AdminAccessControl::validate_permission(env, admin, &AdminPermission::Emergency)?; + let mut state = Self::get_rotation_state(env); + state.cooldown_seconds = cooldown_seconds; + env.storage().persistent().set(&crate::storage::DataKey::MultisigRotationState, &state); Ok(()) } - /// Step 2 of 2: complete a staged admin transfer. - /// - /// Must be called by the exact address staged through [`Self::transfer_admin`], - /// which proves the new admin controls the address before it takes over. - pub fn accept_admin(env: &Env, new_admin: &Address) -> Result<(), Error> { - new_admin.require_auth(); + /// Enforce rotation cooldown + fn enforce_rotation_cooldown(env: &Env, admin: &Address) -> Result<(), Error> { + let mut state = Self::get_rotation_state(env); + let current_time = env.ledger().timestamp(); - let pending: Address = Self::get_pending_admin(env).ok_or(Error::AdminNotSet)?; - if &pending != new_admin { - return Err(Error::Unauthorized); + if current_time < state.last_rotation_timestamp + state.cooldown_seconds { + EventEmitter::emit_signer_rotation_cooldown_hit(env, admin, state.last_rotation_timestamp, state.cooldown_seconds); + return Err(Error::SignerRotationCooldown); } - AdminValidator::validate_admin_address(env, new_admin)?; - - let previous_admin = AdminAccessControl::get_admin(env)?; - - env.storage() - .persistent() - .set(&Symbol::new(env, "Admin"), new_admin); - env.storage() - .persistent() - .remove(&Symbol::new(env, PENDING_ADMIN_KEY)); - EventEmitter::emit_admin_transferred(env, &previous_admin, new_admin); - AuditTrailManager::append_record( - env, - AuditAction::AdminTransferred, - new_admin.clone(), - Map::new(env), - None, - ); + state.last_rotation_timestamp = current_time; + env.storage().persistent().set(&crate::storage::DataKey::MultisigRotationState, &state); Ok(()) } - /// Abort an in-flight admin transfer. Caller must be the current primary admin. - /// - /// After this call nobody is staged and the current admin remains in place. - pub fn cancel_admin_transfer(env: &Env, current_admin: &Address) -> Result<(), Error> { - AdminAccessControl::require_admin_auth(env, current_admin)?; - let pending: Address = Self::get_pending_admin(env).ok_or(Error::AdminNotSet)?; - env.storage() - .persistent() - .remove(&Symbol::new(env, PENDING_ADMIN_KEY)); - EventEmitter::emit_admin_transfer_canceled(env, current_admin, &pending); - Ok(()) + /// Add a new signer enforcing rotation cooldown + pub fn add_signer(env: &Env, admin: &Address, new_signer: &Address, role: AdminRole) -> Result<(), Error> { + admin.require_auth(); + Self::enforce_rotation_cooldown(env, admin)?; + AdminManager::add_admin(env, admin, new_signer, role) } - /// Address staged by [`Self::transfer_admin`] and awaiting [`Self::accept_admin`], if any. - pub fn get_pending_admin(env: &Env) -> Option
{ - env.storage() - .persistent() - .get(&Symbol::new(env, PENDING_ADMIN_KEY)) + /// Remove a signer enforcing rotation cooldown + pub fn remove_signer(env: &Env, admin: &Address, old_signer: &Address) -> Result<(), Error> { + admin.require_auth(); + Self::enforce_rotation_cooldown(env, admin)?; + AdminManager::remove_admin(env, admin, old_signer) + } + + /// Rotate signer enforcing rotation cooldown + pub fn rotate_signer( + env: &Env, + admin: &Address, + old_signer: &Address, + new_signer: &Address, + role: AdminRole, + ) -> Result<(), Error> { + admin.require_auth(); + Self::enforce_rotation_cooldown(env, admin)?; + + AdminManager::remove_admin(env, admin, old_signer)?; + AdminManager::add_admin(env, admin, new_signer, role) } } -impl AdminAccessControl { - /// Validates admin authentication and permissions for a specific action. - /// - /// This comprehensive validation function combines authentication and - /// permission checking for a specific action. It's a convenience function - /// that performs complete admin validation in a single call. - /// - /// # Parameters - /// - /// * `env` - The Soroban environment for blockchain operations - /// * `admin` - The admin address to validate - /// * `action` - String identifier of the action to validate (e.g., "create_market") - /// - /// # Returns - /// - /// Returns `Result<(), Error>` where: - /// - `Ok(())` - Admin is authenticated and authorized for the action - /// - `Err(Error)` - Authentication, permission, or action mapping failed - /// - /// # Errors - /// - /// This function returns errors from: - /// - `require_admin_auth()` - Authentication failures - /// - `map_action_to_permission()` - Invalid action string - /// - `validate_permission()` - Permission validation failures +// ===== ADMIN ACTION LOGGING ===== + +/// Administrative action logging and audit trail management. +/// +/// The `AdminActionLogger` provides comprehensive logging capabilities for all +/// administrative actions performed on the contract. This creates an immutable +/// audit trail for governance, compliance, and security monitoring. +/// +/// # Purpose +/// +/// This struct handles: +/// - Recording all admin actions with full context +/// - Creating audit trails for compliance +/// - Emitting events for external monitoring +/// - Providing action history retrieval +/// - Supporting forensic analysis and debugging +/// +/// # Audit Trail Components +/// +/// Each logged action includes: +/// - **Admin Identity**: Who performed the action +/// - **Action Type**: What operation was performed +/// - **Target**: What was affected (market ID, config, etc.) +/// - **Parameters**: Detailed action parameters +/// - **Timestamp**: When the action occurred +/// - **Success Status**: Whether the action succeeded +/// - **Error Details**: Failure reasons if applicable +/// +/// # Security and Compliance +/// +/// The logging system supports: +/// - Regulatory compliance requirements +/// - Security incident investigation +/// - Governance transparency +/// - Operational monitoring and alerting +pub struct AdminActionLogger; + +impl AdminActionLogger { + /// Records an administrative action in the audit trail. + /// + /// This function creates a comprehensive record of admin actions including + /// all relevant context, parameters, and outcomes. The record is stored + /// persistently and an event is emitted for external monitoring. + /// + /// # Parameters + /// + /// * `env` - The Soroban environment for storage and events + /// * `admin` - The admin address that performed the action + /// * `action` - The type of action performed (e.g., "close_market") + /// * `target` - Optional target identifier (e.g., market ID) + /// * `parameters` - Map of action parameters and their values + /// * `success` - Whether the action completed successfully + /// * `error_message` - Optional error description if action failed + /// + /// # Returns + /// + /// Returns `Result<(), Error>` where: + /// - `Ok(())` - Action logged successfully + /// - `Err(Error)` - Logging failed due to storage or event errors /// /// # Example /// /// ```rust - /// # use soroban_sdk::{Env, Address}; - /// # use predictify_hybrid::admin::AdminAccessControl; + /// # use soroban_sdk::{Env, Address, Map, String}; + /// # use predictify_hybrid::admin::AdminActionLogger; /// # let env = Env::default(); /// # let admin = Address::generate(&env); + /// # let mut params = Map::new(&env); + /// # params.set( + /// # String::from_str(&env, "market_id"), + /// # String::from_str(&env, "market_123") + /// # ); + /// # params.set( + /// # String::from_str(&env, "outcome"), + /// # String::from_str(&env, "Yes") + /// # ); /// - /// // Validate admin for market creation - /// match AdminAccessControl::validate_admin_for_action( + /// // Log successful market finalization + /// match AdminActionLogger::log_action( /// &env, /// &admin, - /// "create_market" + /// "finalize_market", + /// Some(String::from_str(&env, "market_123")), + /// params, + /// true, + /// None /// ) { /// Ok(()) => { - /// // Admin is fully authorized, proceed with market creation - /// println!("Admin authorized for market creation"); + /// println!("Action logged successfully"); /// }, /// Err(e) => { - /// println!("Authorization failed: {:?}", e); + /// println!("Failed to log action: {:?}", e); /// } /// } /// ``` /// - /// # Supported Actions + /// # Action Types + /// + /// Common action types include: + /// - **Market Operations**: "close_market", "finalize_market", "extend_market" + /// - **Configuration**: "update_config", "update_fees", "reset_config" + /// - **Role Management**: "assign_role", "revoke_role", "update_permissions" + /// - **System Operations**: "initialize_contract", "emergency_pause" /// - /// Valid action strings include: - /// - `"initialize"` - Contract initialization - /// - `"create_market"` - Market creation - /// - `"close_market"` - Market closure - /// - `"finalize_market"` - Market finalization - /// - `"extend_market"` - Market duration extension - /// - `"update_fees"` - Fee configuration updates - /// - `"update_config"` - Contract configuration updates - /// - `"collect_fees"` - Fee collection - /// - `"manage_disputes"` - Dispute management - /// - `"emergency_actions"` - Emergency operations + /// # Storage Strategy /// - /// # Validation Process + /// The current implementation stores actions using a simple key-value approach. + /// In production, consider: + /// - Time-based partitioning for scalability + /// - Indexed storage for efficient queries + /// - Archival strategies for long-term retention + /// - Compression for storage efficiency /// - /// The function performs validation in this order: - /// 1. **Authentication**: Verifies admin signature and status - /// 2. **Action Mapping**: Maps action string to permission enum - /// 3. **Permission Check**: Validates admin has required permission + /// # Event Emission /// - /// # Use Cases + /// Each logged action emits an event containing: + /// - Admin address + /// - Action type + /// - Success status + /// - Timestamp (from ledger) + /// + /// External systems can subscribe to these events for: + /// - Real-time monitoring + /// - Automated alerting + /// - Integration with external audit systems + /// - Dashboard updates + /// + /// # Error Handling + /// + /// Logging failures should be handled carefully: + /// - Don't fail the main operation if logging fails + /// - Consider alternative logging mechanisms + /// - Alert on persistent logging failures + /// - Maintain operation continuity + /// + /// # Best Practices /// - /// - **Single-Call Validation**: Complete validation in one function call - /// - **Dynamic Actions**: Validate actions determined at runtime - /// - **API Endpoints**: Validate admin API calls with action strings - /// - **Middleware**: Use in middleware for automatic admin validation - pub fn validate_admin_for_action( + /// - Log all significant admin actions + /// - Include sufficient context for investigation + /// - Use consistent action naming conventions + /// - Sanitize sensitive parameters before logging + /// - Monitor log storage usage and implement rotation + pub fn log_action( env: &Env, admin: &Address, action: &str, + target: Option, + parameters: Map, + success: bool, + error_message: Option, ) -> Result<(), Error> { - // Require admin authentication - AdminAccessControl::require_admin_auth(env, admin)?; + let admin_action = AdminAction { + admin: admin.clone(), + action: String::from_str(env, action), + target, + parameters, + timestamp: env.ledger().timestamp(), + success, + error_message, + }; - // Map action to permission - let permission = AdminAccessControl::map_action_to_permission(action)?; + // Store action in persistent storage + let action_key = Symbol::new(env, "admin_action"); + env.storage().persistent().set(&action_key, &admin_action); - // Validate permission - AdminAccessControl::validate_permission(env, admin, &permission)?; + // Emit admin action event + EventEmitter::emit_admin_action_logged(env, admin, action, &success); Ok(()) } - /// Maps action string identifiers to their corresponding permission enums. + /// Retrieves a list of all administrative actions from the audit trail. /// - /// This utility function converts human-readable action strings into - /// the corresponding AdminPermission enum values. It's used to bridge - /// string-based action identifiers with the type-safe permission system. + /// This function provides access to the complete history of administrative + /// actions for audit, compliance, and analysis purposes. Currently returns + /// an empty vector due to storage iteration limitations. /// /// # Parameters /// - /// * `action` - String identifier of the action to map + /// * `env` - The Soroban environment for storage access + /// * `_limit` - Maximum number of actions to retrieve (currently unused) /// /// # Returns /// - /// Returns `Result` where: - /// - `Ok(AdminPermission)` - Successfully mapped action to permission - /// - `Err(Error::InvalidInput)` - Action string is not recognized + /// Returns `Result, Error>` where: + /// - `Ok(Vec)` - List of admin actions (currently empty) + /// - `Err(Error)` - Retrieval failed due to storage errors /// - /// # Errors + /// # Current Limitations /// - /// This function returns: - /// - `Error::InvalidInput` - Action string doesn't match any known actions + /// The current implementation returns an empty vector because: + /// - Soroban SDK lacks efficient storage iteration capabilities + /// - Actions are stored individually without indexing + /// - No built-in pagination or filtering mechanisms /// /// # Example /// /// ```rust - /// # use predictify_hybrid::admin::{AdminAccessControl, AdminPermission}; + /// # use soroban_sdk::Env; + /// # use predictify_hybrid::admin::AdminActionLogger; + /// # let env = Env::default(); /// - /// // Map action string to permission - /// match AdminAccessControl::map_action_to_permission("create_market") { - /// Ok(permission) => { - /// assert_eq!(permission, AdminPermission::CreateMarket); - /// println!("Mapped to CreateMarket permission"); + /// // Retrieve recent admin actions for audit + /// match AdminActionLogger::get_admin_actions(&env, 50) { + /// Ok(actions) => { + /// println!("Found {} admin actions", actions.len()); + /// for action in actions { + /// println!("Action: {} by {:?} at {}", + /// action.action, action.admin, action.timestamp); + /// } /// }, /// Err(e) => { - /// println!("Invalid action: {:?}", e); - /// } - /// } - /// - /// // Handle invalid action - /// match AdminAccessControl::map_action_to_permission("invalid_action") { - /// Ok(_) => unreachable!(), - /// Err(e) => { - /// println!("Expected error for invalid action: {:?}", e); + /// println!("Failed to retrieve actions: {:?}", e); /// } /// } /// ``` /// - /// # Action Mapping Table - /// - /// | Action String | Permission Enum | - /// |---------------|----------------| - /// | `"initialize"` | `AdminPermission::Initialize` | - /// | `"create_market"` | `AdminPermission::CreateMarket` | - /// | `"close_market"` | `AdminPermission::CloseMarket` | - /// | `"finalize_market"` | `AdminPermission::FinalizeMarket` | - /// | `"extend_market"` | `AdminPermission::ExtendMarket` | - /// | `"update_fees"` | `AdminPermission::UpdateFees` | - /// | `"update_config"` | `AdminPermission::UpdateConfig` | - /// | `"reset_config"` | `AdminPermission::ResetConfig` | - /// | `"collect_fees"` | `AdminPermission::CollectFees` | - /// | `"manage_disputes"` | `AdminPermission::ManageDispute` | - /// | `"view_analytics"` | `AdminPermission::ViewAnalytic` | - /// | `"emergency_actions"` | `AdminPermission::Emergency` | + /// # Future Implementation + /// + /// A production implementation would include: + /// - **Indexed Storage**: Actions indexed by timestamp, admin, type + /// - **Pagination**: Efficient pagination with cursor-based navigation + /// - **Filtering**: Filter by date range, admin, action type, success status + /// - **Sorting**: Sort by timestamp, admin, or action type + /// - **Aggregation**: Summary statistics and trend analysis + /// + /// # Proposed Storage Schema + /// + /// ```rust + /// // Time-based partitioning + /// let partition_key = format!("actions_{}", timestamp / PARTITION_SIZE); + /// + /// // Admin-based indexing + /// let admin_index = format!("admin_actions_{}", admin); + /// + /// // Action type indexing + /// let type_index = format!("action_type_{}", action_type); + /// ``` /// /// # Use Cases /// - /// - **API Integration**: Convert API action parameters to permissions - /// - **Dynamic Validation**: Handle actions determined at runtime - /// - **Configuration**: Map configuration-driven actions to permissions - /// - **Testing**: Validate action-permission mappings in tests - /// - **Debugging**: Convert action strings for logging and debugging - /// - /// # Design Notes - /// - /// Action strings use snake_case convention to match Rust naming standards. - /// The mapping is case-sensitive and must match exactly. Consider adding - /// case-insensitive mapping if needed for API flexibility. - pub fn map_action_to_permission(action: &str) -> Result { - match action { - "initialize" => Ok(AdminPermission::Initialize), - "create_market" => Ok(AdminPermission::CreateMarket), - "close_market" => Ok(AdminPermission::CloseMarket), - "finalize_market" => Ok(AdminPermission::FinalizeMarket), - "extend_market" => Ok(AdminPermission::ExtendMarket), - "update_fees" => Ok(AdminPermission::UpdateFees), - "update_config" => Ok(AdminPermission::UpdateConfig), - "reset_config" => Ok(AdminPermission::ResetConfig), - "collect_fees" => Ok(AdminPermission::CollectFees), - "manage_disputes" => Ok(AdminPermission::ManageDispute), - "view_analytics" => Ok(AdminPermission::ViewAnalytic), - "emergency_actions" => Ok(AdminPermission::Emergency), - "admin_broadcast" => Ok(AdminPermission::Emergency), - _ => Err(Error::InvalidInput), - } + /// This function supports: + /// - **Compliance Audits**: Providing complete action history + /// - **Security Analysis**: Investigating suspicious patterns + /// - **Operational Review**: Understanding admin activity patterns + /// - **Debugging**: Tracing the sequence of admin operations + /// - **Reporting**: Generating admin activity reports + /// + /// # Performance Considerations + /// + /// When implementing full functionality: + /// - Implement pagination to avoid large result sets + /// - Use appropriate caching for frequently accessed data + /// - Consider read replicas for heavy audit workloads + /// - Implement query optimization for common access patterns + pub fn get_admin_actions(env: &Env, _limit: u32) -> Result, Error> { + // For now, return empty vector since we don't have a way to iterate over storage + // In a real implementation, you would store actions in a more sophisticated way + Ok(Vec::new(env)) } -} - -// ===== ADMIN ROLE MANAGEMENT ===== -/// Admin role management -pub struct AdminRoleManager; - -impl AdminRoleManager { - /// Assigns a specific admin role to an address with associated permissions. + /// Retrieves administrative actions performed by a specific admin. /// - /// This function creates or updates admin role assignments, establishing the - /// permission hierarchy for admin operations. It supports bootstrapping the - /// first admin and subsequent role assignments by authorized admins. + /// This function provides filtered access to the audit trail, showing only + /// actions performed by a particular admin address. Useful for individual + /// admin accountability and performance analysis. /// /// # Parameters /// - /// * `env` - The Soroban environment for blockchain operations - /// * `admin` - The address to receive the admin role - /// * `role` - The admin role to assign (SuperAdmin, MarketAdmin, etc.) - /// * `assigned_by` - The address performing the role assignment + /// * `env` - The Soroban environment for storage access + /// * `_admin` - The admin address to filter actions for + /// * `_limit` - Maximum number of actions to retrieve (currently unused) /// /// # Returns /// - /// Returns `Result<(), Error>` where: - /// - `Ok(())` - Role assigned successfully - /// - `Err(Error)` - Assignment failed due to permissions or validation + /// Returns `Result, Error>` where: + /// - `Ok(Vec)` - List of actions by the specified admin (currently empty) + /// - `Err(Error)` - Retrieval failed due to storage errors /// - /// # Errors + /// # Current Limitations /// - /// This function returns specific errors: - /// - `Error::Unauthorized` - Assigner lacks Emergency permission - /// - Permission validation errors from AdminAccessControl - /// - Storage operation errors + /// Similar to `get_admin_actions`, this function currently returns an empty + /// vector due to Soroban SDK storage iteration limitations. A full implementation + /// would require indexed storage and efficient filtering capabilities. /// /// # Example /// /// ```rust /// # use soroban_sdk::{Env, Address}; - /// # use predictify_hybrid::admin::{AdminRoleManager, AdminRole}; + /// # use predictify_hybrid::admin::AdminActionLogger; /// # let env = Env::default(); - /// # let super_admin = Address::generate(&env); - /// # let new_admin = Address::generate(&env); + /// # let admin = Address::generate(&env); /// - /// // Assign MarketAdmin role to a new admin - /// match AdminRoleManager::assign_role( - /// &env, - /// &new_admin, - /// AdminRole::MarketAdmin, - /// &super_admin - /// ) { - /// Ok(()) => { - /// println!("MarketAdmin role assigned successfully"); + /// // Get actions performed by a specific admin + /// match AdminActionLogger::get_admin_actions_for_admin(&env, &admin, 25) { + /// Ok(actions) => { + /// println!("Admin performed {} actions", actions.len()); + /// for action in actions { + /// println!("{}: {} ({})", + /// action.timestamp, + /// action.action, + /// if action.success { "Success" } else { "Failed" } + /// ); + /// } /// }, /// Err(e) => { - /// println!("Role assignment failed: {:?}", e); + /// println!("Failed to retrieve admin actions: {:?}", e); /// } /// } /// ``` /// - /// # Role Hierarchy - /// - /// Available admin roles with their permission levels: - /// - **SuperAdmin**: All permissions, can assign other roles - /// - **MarketAdmin**: Market creation, closure, finalization, extension - /// - **ConfigAdmin**: Configuration updates and resets - /// - **FeeAdmin**: Fee configuration and collection - /// - **ReadOnlyAdmin**: View-only access to analytics - /// - /// # Assignment Process - /// - /// The assignment process: - /// 1. **Bootstrap Check**: First assignment bypasses permission validation - /// 2. **Permission Validation**: Subsequent assignments require Emergency permission - /// 3. **Role Creation**: Creates AdminRoleAssignment with timestamp and permissions - /// 4. **Storage Update**: Stores assignment in persistent storage - /// 5. **Event Emission**: Emits role assignment event for monitoring + /// # Use Cases /// - /// # Security - /// - /// Only admins with Emergency permission can assign roles to others. - /// The first admin assignment (bootstrapping) bypasses this check to enable - /// initial contract setup. - pub fn assign_role( - env: &Env, - admin: &Address, - role: AdminRole, - assigned_by: &Address, - ) -> Result<(), Error> { - // Use a simple fixed key for admin role storage - let key = Symbol::new(env, "admin_role"); - - // Check if this is the first admin role assignment (bootstrapping) - if !env.storage().persistent().has(&key) { - // No admin role assigned yet, allow bootstrapping without permission check - } else { - // Validate assigner permissions for subsequent assignments - AdminAccessControl::validate_permission(env, assigned_by, &AdminPermission::Emergency)?; - } - - // Create role assignment - let assignment = AdminRoleAssignment { - admin: admin.clone(), - role, - assigned_by: assigned_by.clone(), - assigned_at: env.ledger().timestamp(), - permissions: AdminRoleManager::get_permissions_for_role(env, &role), - is_active: true, - }; - - // Store role assignment - env.storage().persistent().set(&key, &assignment); - - // Emit role assignment event - let events_role = match role { - AdminRole::SuperAdmin => crate::events::AdminRole::Owner, - AdminRole::MarketAdmin => crate::events::AdminRole::Admin, - AdminRole::ConfigAdmin => crate::events::AdminRole::Admin, - AdminRole::FeeAdmin => crate::events::AdminRole::Admin, - AdminRole::ReadOnlyAdmin => crate::events::AdminRole::Moderator, - }; - EventEmitter::emit_admin_role_assigned(env, admin, &events_role, assigned_by); - - let action = if role == AdminRole::SuperAdmin && !env.storage().persistent().has(&key) { - AuditAction::ContractInitialized // Fallback or logic - } else { - AuditAction::AdminAdded - }; - AuditTrailManager::append_record( - env, - AuditAction::AdminRoleUpdated, - assigned_by.clone(), - Map::new(env), - None, - ); - - Ok(()) - } - - /// Retrieves the admin role assigned to a specific address. - /// - /// This function looks up the admin role for a given address, validating - /// that the admin is active and returning their assigned role. It's used - /// for permission checking and role-based access control. - /// - /// # Parameters - /// - /// * `env` - The Soroban environment for blockchain operations - /// * `admin` - The address to look up the admin role for - /// - /// # Returns - /// - /// Returns `Result` where: - /// - `Ok(AdminRole)` - The admin role assigned to the address - /// - `Err(Error)` - Admin not found, inactive, or unauthorized - /// - /// # Errors + /// This function is valuable for: + /// - **Individual Accountability**: Tracking specific admin's actions + /// - **Performance Review**: Analyzing admin activity and success rates + /// - **Security Investigation**: Investigating suspicious admin behavior + /// - **Training**: Reviewing new admin's learning progress + /// - **Compliance**: Demonstrating individual admin compliance /// - /// This function returns specific errors: - /// - `Error::Unauthorized` - No admin role assignment found - /// - `Error::Unauthorized` - Admin role assignment is inactive - /// - `Error::Unauthorized` - Address doesn't match the assigned admin + /// # Future Implementation Strategy /// - /// # Example + /// A production implementation would include: /// + /// ## Indexed Storage /// ```rust - /// # use soroban_sdk::{Env, Address}; - /// # use predictify_hybrid::admin::{AdminRoleManager, AdminRole}; - /// # let env = Env::default(); - /// # let admin = Address::generate(&env); + /// // Store actions with admin-based indexing + /// let admin_key = format!("admin_{}_{}", admin, timestamp); + /// env.storage().persistent().set(&admin_key, &action); /// - /// // Get admin role for permission checking - /// match AdminRoleManager::get_admin_role(&env, &admin) { - /// Ok(AdminRole::SuperAdmin) => { - /// println!("User has SuperAdmin privileges"); - /// }, - /// Ok(AdminRole::MarketAdmin) => { - /// println!("User has MarketAdmin privileges"); - /// }, - /// Ok(role) => { - /// println!("User has {:?} privileges", role); - /// }, - /// Err(e) => { - /// println!("No admin role found: {:?}", e); - /// } - /// } + /// // Maintain admin action count + /// let count_key = format!("admin_count_{}", admin); + /// let current_count: u32 = env.storage().persistent().get(&count_key).unwrap_or(0); + /// env.storage().persistent().set(&count_key, &(current_count + 1)); /// ``` /// - /// # Role Validation + /// ## Efficient Querying + /// - Range queries by timestamp + /// - Pagination with cursor-based navigation + /// - Filtering by action type and success status + /// - Sorting options (newest first, oldest first) /// - /// The function performs these validations: - /// 1. **Assignment Lookup**: Retrieves role assignment from storage - /// 2. **Active Check**: Ensures the role assignment is active - /// 3. **Address Match**: Confirms the address matches the assignment - /// 4. **Role Return**: Returns the validated admin role + /// ## Analytics Integration + /// - Success rate calculation + /// - Action frequency analysis + /// - Time-based activity patterns + /// - Comparison with other admins /// - /// # Use Cases + /// # Security Considerations /// - /// - **Permission Checking**: Determine what actions an admin can perform - /// - **UI Authorization**: Show/hide features based on admin role - /// - **Audit Logging**: Record admin roles in action logs - /// - **Role-Based Logic**: Execute different logic based on admin role - /// - **Access Control**: Gate access to role-specific functionality + /// When implementing full functionality: + /// - Ensure proper access control (admins can only see their own actions unless super admin) + /// - Sanitize sensitive information in returned data + /// - Implement rate limiting to prevent abuse + /// - Log access to audit logs for meta-auditing /// - /// # Performance + /// # Performance Optimization /// - /// This function performs a single storage lookup and is optimized for - /// frequent use in permission validation scenarios. - pub fn get_admin_role(env: &Env, admin: &Address) -> Result { - // Use a simple fixed key for admin role storage - let key = Symbol::new(env, "admin_role"); - - let assignment: AdminRoleAssignment = env - .storage() - .persistent() - .get(&key) - .ok_or(Error::Unauthorized)?; - - if !assignment.is_active { - return Err(Error::Unauthorized); - } - - // Check if the passed address matches the admin address in the assignment - if admin != &assignment.admin { - return Err(Error::Unauthorized); - } - - Ok(assignment.role) + /// For high-volume environments: + /// - Implement caching for frequently accessed admin histories + /// - Use background processes for heavy analytics + /// - Consider read replicas for audit queries + /// - Implement data archival for old actions + pub fn get_admin_actions_for_admin( + env: &Env, + _admin: &Address, + _limit: u32, + ) -> Result, Error> { + // For now, return empty vector + Ok(Vec::new(env)) } +} - /// Checks if a specific admin role has a particular permission. - /// - /// This function determines whether an admin role includes a specific - /// permission by checking the role's permission set. It's a core component - /// of the permission validation system. - /// - /// # Parameters - /// - /// * `_env` - The Soroban environment (unused but kept for consistency) - /// * `role` - The admin role to check permissions for - /// * `permission` - The specific permission to check - /// - /// # Returns - /// - /// Returns `Result` where: - /// - `Ok(true)` - Role has the specified permission - /// - `Ok(false)` - Role does not have the specified permission - /// - `Err(Error)` - Error retrieving role permissions - /// - /// # Errors - /// - /// This function typically doesn't error but may return errors from - /// permission retrieval operations in future implementations. - /// - /// # Example - /// - /// ```rust - /// # use soroban_sdk::Env; - /// # use predictify_hybrid::admin::{AdminRoleManager, AdminRole, AdminPermission}; - /// # let env = Env::default(); - /// - /// // Check if MarketAdmin can create markets - /// let can_create = AdminRoleManager::has_permission( - /// &env, - /// &AdminRole::MarketAdmin, - /// &AdminPermission::CreateMarket - /// ).unwrap(); - /// - /// if can_create { - /// println!("MarketAdmin can create markets"); - /// } - /// - /// // Check if ReadOnlyAdmin can update fees - /// let can_update_fees = AdminRoleManager::has_permission( - /// &env, - /// &AdminRole::ReadOnlyAdmin, - /// &AdminPermission::UpdateFees - /// ).unwrap(); - /// - /// assert!(!can_update_fees); // ReadOnlyAdmin cannot update fees - /// ``` - /// - /// # Permission Matrix - /// - /// | Role | Initialize | CreateMarket | UpdateFees | UpdateConfig | Emergency | - /// |------|------------|--------------|------------|--------------|----------| - /// | SuperAdmin | ✓ | ✓ | ✓ | ✓ | ✓ | - /// | MarketAdmin | ✗ | ✓ | ✗ | ✗ | ✗ | - /// | ConfigAdmin | ✗ | ✗ | ✗ | ✓ | ✗ | - /// | FeeAdmin | ✗ | ✗ | ✓ | ✗ | ✗ | - /// | ReadOnlyAdmin | ✗ | ✗ | ✗ | ✗ | ✗ | - /// - /// # Use Cases - /// - /// - **Permission Validation**: Core permission checking logic - /// - **Role Comparison**: Compare capabilities of different roles - /// - **UI Authorization**: Determine what UI elements to show - /// - **API Gating**: Control access to API endpoints - /// - **Audit Systems**: Log permission checks for security auditing - /// - /// # Performance - /// - /// This function is highly optimized for frequent use, performing only - /// in-memory operations on the role's permission vector. - pub fn has_permission( - _env: &Env, - role: &AdminRole, - permission: &AdminPermission, - ) -> Result { - let permissions = AdminRoleManager::get_permissions_for_role(_env, role); - Ok(permissions.contains(permission)) - } +// ===== ADMIN ANALYTICS ===== - /// Retrieves the complete set of permissions for a specific admin role. - /// - /// This function returns all permissions associated with an admin role, - /// providing the definitive permission set for role-based access control. - /// It's used internally by permission checking functions and externally - /// for role analysis and UI generation. - /// - /// # Parameters - /// - /// * `env` - The Soroban environment for creating the permission vector - /// * `role` - The admin role to get permissions for - /// - /// # Returns - /// - /// Returns `Vec` containing all permissions for the role. - /// The vector is never empty - even ReadOnlyAdmin has ViewAnalytic permission. - /// - /// # Example - /// - /// ```rust - /// # use soroban_sdk::Env; - /// # use predictify_hybrid::admin::{AdminRoleManager, AdminRole, AdminPermission}; - /// # let env = Env::default(); - /// - /// // Get all permissions for SuperAdmin - /// let super_permissions = AdminRoleManager::get_permissions_for_role( - /// &env, - /// &AdminRole::SuperAdmin - /// ); - /// - /// println!("SuperAdmin has {} permissions", super_permissions.len()); - /// assert!(super_permissions.contains(&AdminPermission::Initialize)); - /// assert!(super_permissions.contains(&AdminPermission::Emergency)); - /// - /// // Get permissions for MarketAdmin - /// let market_permissions = AdminRoleManager::get_permissions_for_role( - /// &env, - /// &AdminRole::MarketAdmin - /// ); - /// - /// assert!(market_permissions.contains(&AdminPermission::CreateMarket)); - /// assert!(!market_permissions.contains(&AdminPermission::UpdateFees)); - /// ``` - /// - /// # Permission Sets by Role - /// - /// **SuperAdmin** (12 permissions): - /// - Initialize, CreateMarket, CloseMarket, FinalizeMarket - /// - ExtendMarket, UpdateFees, UpdateConfig, ResetConfig - /// - CollectFees, ManageDispute, ViewAnalytic, Emergency - /// - /// **MarketAdmin** (5 permissions): - /// - CreateMarket, CloseMarket, FinalizeMarket, ExtendMarket, ViewAnalytic - /// - /// **ConfigAdmin** (3 permissions): - /// - UpdateConfig, ResetConfig, ViewAnalytic - /// - /// **FeeAdmin** (3 permissions): - /// - UpdateFees, CollectFees, ViewAnalytic - /// - /// **ReadOnlyAdmin** (1 permission): - /// - ViewAnalytic - /// - /// # Use Cases - /// - /// - **Role Analysis**: Understand what each role can do - /// - **UI Generation**: Create role-specific interfaces - /// - **Permission Auditing**: Review and audit role permissions - /// - **Role Comparison**: Compare capabilities between roles - /// - **Documentation**: Generate permission documentation - /// - **Testing**: Validate role permission assignments - /// - /// # Design Principles - /// - /// - **Least Privilege**: Each role has only necessary permissions - /// - **Clear Hierarchy**: SuperAdmin > Specialized Admins > ReadOnly - /// - **Separation of Concerns**: Different roles for different responsibilities - /// - **Extensibility**: Easy to add new roles and permissions - pub fn get_permissions_for_role(env: &Env, role: &AdminRole) -> Vec { - match role { - AdminRole::SuperAdmin => soroban_sdk::vec![ - env, - AdminPermission::Initialize, - AdminPermission::CreateMarket, - AdminPermission::CloseMarket, - AdminPermission::FinalizeMarket, - AdminPermission::ExtendMarket, - AdminPermission::UpdateFees, - AdminPermission::UpdateConfig, - AdminPermission::ResetConfig, - AdminPermission::CollectFees, - AdminPermission::ManageDispute, - AdminPermission::ViewAnalytic, - AdminPermission::Emergency, - ], - AdminRole::MarketAdmin => soroban_sdk::vec![ - env, - AdminPermission::CreateMarket, - AdminPermission::CloseMarket, - AdminPermission::FinalizeMarket, - AdminPermission::ExtendMarket, - AdminPermission::ViewAnalytic, - ], - AdminRole::ConfigAdmin => soroban_sdk::vec![ - env, - AdminPermission::UpdateConfig, - AdminPermission::ResetConfig, - AdminPermission::ViewAnalytic, - ], - AdminRole::FeeAdmin => soroban_sdk::vec![ - env, - AdminPermission::UpdateFees, - AdminPermission::CollectFees, - AdminPermission::ViewAnalytic, - ], - AdminRole::ReadOnlyAdmin => soroban_sdk::vec![env, AdminPermission::ViewAnalytic,], - } +/// Admin analytics +impl AdminAnalytics { + /// Calculate admin analytics + pub fn calculate_admin_analytics(_env: &Env) -> Result { + // For now, return default analytics since we don't store complex types + Ok(AdminAnalytics::default()) } - /// Deactivate admin role - pub fn deactivate_role( - env: &Env, - admin: &Address, - deactivated_by: &Address, - ) -> Result<(), Error> { - // Validate deactivator permissions - AdminAccessControl::validate_permission(env, deactivated_by, &AdminPermission::Emergency)?; - - // Use a simple fixed key for admin role storage - let key = Symbol::new(env, "admin_role"); - - let mut assignment: AdminRoleAssignment = env - .storage() - .persistent() - .get(&key) - .ok_or(Error::Unauthorized)?; - - assignment.is_active = false; - env.storage().persistent().set(&key, &assignment); - - // Emit role deactivation event - EventEmitter::emit_admin_role_deactivated(env, admin, deactivated_by); + /// Get admin role distribution + pub fn get_role_distribution(env: &Env) -> Result, Error> { + // For now, return empty map + Ok(Map::new(env)) + } - Ok(()) + /// Get action distribution + pub fn get_action_distribution(env: &Env) -> Result, Error> { + // For now, return empty map + Ok(Map::new(env)) } } -// ===== MULTI-ADMIN MANAGER ===== - -/// Multi-Admin Management System with comprehensive role-based access control -pub struct AdminManager; - -impl AdminManager { - /// Adds a new admin with specified role - pub fn add_admin( - env: &Env, - current_admin: &Address, - new_admin: &Address, - role: AdminRole, - ) -> Result<(), Error> { - // Use existing AdminRoleManager for validation - AdminAccessControl::validate_permission(env, current_admin, &AdminPermission::Emergency)?; - - // Prevent duplicate admin assignments - if Self::is_admin(env, new_admin) { - return Err(Error::InvalidState); - } - - // Generate unique key for this admin - let admin_key = Self::get_admin_key(env, new_admin); +// ===== ADMIN UTILITIES ===== - // Create role assignment using existing structure - let assignment = AdminRoleAssignment { - admin: new_admin.clone(), - role: role.clone(), - assigned_by: current_admin.clone(), - assigned_at: env.ledger().timestamp(), - permissions: AdminRoleManager::get_permissions_for_role(env, &role), - is_active: true, - }; +/// Admin utility functions +pub struct AdminUtils; - // Store in multi-admin storage - env.storage().persistent().set(&admin_key, &assignment); +impl AdminUtils { + /// Check if address is admin + pub fn is_admin(env: &Env, address: &Address) -> bool { + AdminRoleManager::get_admin_role(env, address).is_ok() + } - // Track admins in a list so role enumeration stays in sync. - let list_key = Symbol::new(env, "AdminList"); - let mut admin_list: Vec
= env - .storage() - .persistent() - .get(&list_key) - .unwrap_or_else(|| Vec::new(env)); - if !admin_list.contains(new_admin) { - admin_list.push_back(new_admin.clone()); - env.storage().persistent().set(&list_key, &admin_list); + /// Check if address is super admin + pub fn is_super_admin(env: &Env, address: &Address) -> bool { + match AdminRoleManager::get_admin_role(env, address) { + Ok(role) => role == AdminRole::SuperAdmin, + Err(_) => false, } - - // Update admin count - let count_key = Symbol::new(env, "AdminCount"); - let current_count: u32 = env.storage().persistent().get(&count_key).unwrap_or(0); - env.storage() - .persistent() - .set(&count_key, &(current_count + 1)); - - // Emit event using existing system - Self::emit_admin_change_event(env, new_admin, AdminActionType::Added); - - Ok(()) } - /// Removes an admin from the system - pub fn remove_admin( - env: &Env, - current_admin: &Address, - admin_to_remove: &Address, - ) -> Result<(), Error> { - AdminAccessControl::validate_permission(env, current_admin, &AdminPermission::Emergency)?; - - // Prevent self-removal of last super admin - if current_admin == admin_to_remove { - let super_admin_count = Self::count_admins_with_role(env, AdminRole::SuperAdmin); - if super_admin_count <= 1 { - return Err(Error::InvalidState); + /// Get admin role name + pub fn get_role_name(role: &AdminRole) -> String { + match role { + AdminRole::SuperAdmin => String::from_str(&soroban_sdk::Env::default(), "SuperAdmin"), + AdminRole::MarketAdmin => String::from_str(&soroban_sdk::Env::default(), "MarketAdmin"), + AdminRole::ConfigAdmin => String::from_str(&soroban_sdk::Env::default(), "ConfigAdmin"), + AdminRole::FeeAdmin => String::from_str(&soroban_sdk::Env::default(), "FeeAdmin"), + AdminRole::ReadOnlyAdmin => { + String::from_str(&soroban_sdk::Env::default(), "ReadOnlyAdmin") } } + } - let admin_key = Self::get_admin_key(env, admin_to_remove); - if !env.storage().persistent().has(&admin_key) { - return Err(Error::Unauthorized); - } - - env.storage().persistent().remove(&admin_key); - - let list_key = Symbol::new(env, "AdminList"); - if let Some(mut admin_list) = env.storage().persistent().get::<_, Vec
>(&list_key) { - if let Some(index) = admin_list.first_index_of(admin_to_remove) { - admin_list.remove(index); - env.storage().persistent().set(&list_key, &admin_list); + /// Get permission name + pub fn get_permission_name(permission: &AdminPermission) -> String { + match permission { + AdminPermission::Initialize => { + String::from_str(&soroban_sdk::Env::default(), "Initialize") + } + AdminPermission::CreateMarket => { + String::from_str(&soroban_sdk::Env::default(), "CreateMarket") + } + AdminPermission::CloseMarket => { + String::from_str(&soroban_sdk::Env::default(), "CloseMarket") + } + AdminPermission::FinalizeMarket => { + String::from_str(&soroban_sdk::Env::default(), "FinalizeMarket") + } + AdminPermission::ExtendMarket => { + String::from_str(&soroban_sdk::Env::default(), "ExtendMarket") + } + AdminPermission::UpdateFees => { + String::from_str(&soroban_sdk::Env::default(), "UpdateFees") + } + AdminPermission::UpdateConfig => { + String::from_str(&soroban_sdk::Env::default(), "UpdateConfig") + } + AdminPermission::ResetConfig => { + String::from_str(&soroban_sdk::Env::default(), "ResetConfig") + } + AdminPermission::CollectFees => { + String::from_str(&soroban_sdk::Env::default(), "CollectFees") + } + AdminPermission::ManageDispute => { + String::from_str(&soroban_sdk::Env::default(), "ManageDispute") + } + AdminPermission::ViewAnalytic => { + String::from_str(&soroban_sdk::Env::default(), "ViewAnalytic") + } + AdminPermission::Emergency => { + String::from_str(&soroban_sdk::Env::default(), "Emergency") + } + AdminPermission::ConfigAdmin => { + String::from_str(&soroban_sdk::Env::default(), "ConfigAdmin") } } + } +} - // Update count - let count_key = Symbol::new(env, "AdminCount"); - let current_count: u32 = env.storage().persistent().get(&count_key).unwrap_or(0); - if current_count > 0 { - env.storage() - .persistent() - .set(&count_key, &(current_count - 1)); - } +// ===== ADMIN HIERARCHY AND ANALYTICS ===== - Self::emit_admin_change_event(env, admin_to_remove, AdminActionType::Removed); - Ok(()) - } +/// Admin hierarchy management for role inheritance and precedence +pub struct AdminHierarchy; - /// Updates an admin's role - pub fn update_admin_role( - env: &Env, - current_admin: &Address, - target_admin: &Address, - new_role: AdminRole, - ) -> Result<(), Error> { - AdminAccessControl::validate_permission(env, current_admin, &AdminPermission::Emergency)?; +impl AdminHierarchy { + pub fn get_role_level(role: &AdminRole) -> u8 { + match role { + AdminRole::SuperAdmin => 100, + AdminRole::MarketAdmin => 75, + AdminRole::ConfigAdmin => 75, + AdminRole::FeeAdmin => 75, + AdminRole::ReadOnlyAdmin => 25, + } + } - let admin_key = Self::get_admin_key(env, target_admin); - let mut assignment: AdminRoleAssignment = env - .storage() - .persistent() - .get(&admin_key) - .ok_or(Error::Unauthorized)?; - - // Prevent downgrading last super admin - if assignment.role == AdminRole::SuperAdmin && new_role != AdminRole::SuperAdmin { - let super_admin_count = Self::count_admins_with_role(env, AdminRole::SuperAdmin); - if super_admin_count <= 1 { - return Err(Error::InvalidState); - } + pub fn can_manage_role(manager_role: &AdminRole, target_role: &AdminRole) -> bool { + if *manager_role == AdminRole::SuperAdmin { + return true; } + let manager_level = Self::get_role_level(manager_role); + let target_level = Self::get_role_level(target_role); + manager_level > target_level + } - assignment.role = new_role.clone(); - assignment.permissions = AdminRoleManager::get_permissions_for_role(env, &new_role); - assignment.assigned_by = current_admin.clone(); - assignment.assigned_at = env.ledger().timestamp(); + /// Get all permissions for a role level + pub fn get_role_permissions(env: &Env, role: &AdminRole) -> Vec { + AdminRoleManager::get_permissions_for_role(env, role) + } - env.storage().persistent().set(&admin_key, &assignment); - Self::emit_admin_change_event(env, target_admin, AdminActionType::RoleUpdated); + /// Check if one role encompasses another role's permissions + pub fn role_encompasses(env: &Env, higher_role: &AdminRole, lower_role: &AdminRole) -> bool { + let higher_permissions = Self::get_role_permissions(env, higher_role); + let lower_permissions = Self::get_role_permissions(env, lower_role); - Ok(()) + // Check if all lower permissions are contained in higher permissions + lower_permissions + .iter() + .all(|perm| higher_permissions.contains(perm)) } +} - /// Validates if an admin has a specific permission - pub fn validate_admin_permission( - env: &Env, - admin: &Address, - permission: AdminPermission, - ) -> Result<(), Error> { - // Check original admin for backward compatibility - if Self::is_original_admin(env, admin) { - return Ok(()); - } +/// Enhanced admin analytics using existing AdminAnalytics structure +pub struct EnhancedAdminAnalytics; - let admin_key = Self::get_admin_key(env, admin); - let assignment: AdminRoleAssignment = env - .storage() - .persistent() - .get(&admin_key) - .ok_or(Error::Unauthorized)?; +impl EnhancedAdminAnalytics { + pub fn get_admin_analytics(env: &Env) -> AdminAnalyticsResult { + let total_admins = Self::count_total_admins(env); + let active_admins = Self::count_active_admins(env); + let role_distribution = Self::get_role_distribution(env); - if !assignment.is_active || !assignment.permissions.contains(&permission) { - return Err(Error::Unauthorized); + AdminAnalyticsResult { + total_admins, + active_admins, + role_distribution, + last_updated: env.ledger().timestamp(), } - - Ok(()) } - /// Gets all admin roles in the system - pub fn get_admin_roles(env: &Env) -> Map { - let mut roles = Map::new(env); + fn count_total_admins(env: &Env) -> u32 { + let multi_admin_count: u32 = env + .storage() + .persistent() + .get(&Symbol::new(env, "AdminCount")) + .unwrap_or(0); - // Add original admin if exists - if let Some(original_admin) = Self::get_original_admin(env) { - roles.set(original_admin, AdminRole::SuperAdmin); - } + let original_admin_count = if AdminManager::get_original_admin(env).is_some() { + 1 + } else { + 0 + }; + multi_admin_count + original_admin_count + } - // Iterate over admin list to get all multi-admin entries - let list_key = Symbol::new(env, "AdminList"); - if let Some(admin_list) = env.storage().persistent().get::<_, Vec
>(&list_key) { - for admin_addr in admin_list.iter() { - let admin_key = Self::get_admin_key(env, &admin_addr); - if let Some(assignment) = env - .storage() - .persistent() - .get::<_, AdminRoleAssignment>(&admin_key) - { - if assignment.is_active { - roles.set(admin_addr.clone(), assignment.role); - } - } - } - } + fn count_active_admins(env: &Env) -> u32 { + // assume all admins are active - roles + Self::count_total_admins(env) } - /// Check if an admin exists in the multi-admin system - pub fn get_admin_role_for_address(env: &Env, admin: &Address) -> Option { - // Check original admin first - if Self::is_original_admin(env, admin) { - return Some(AdminRole::SuperAdmin); - } + fn get_role_distribution(env: &Env) -> Map { + let mut distribution = Map::new(env); + distribution.set(String::from_str(env, "SuperAdmin"), 0); + distribution.set(String::from_str(env, "MarketAdmin"), 0); + distribution.set(String::from_str(env, "ConfigAdmin"), 0); + distribution.set(String::from_str(env, "FeeAdmin"), 0); + distribution.set(String::from_str(env, "ReadOnlyAdmin"), 0); - // Check multi-admin storage - let admin_key = Self::get_admin_key(env, admin); - if let Some(assignment) = env - .storage() - .persistent() - .get::<_, AdminRoleAssignment>(&admin_key) - { - if assignment.is_active { - return Some(assignment.role); - } + // Count original admin as SuperAdmin + if AdminManager::get_original_admin(env).is_some() { + distribution.set(String::from_str(env, "SuperAdmin"), 1); } - None + distribution } - /// Emits admin change events using existing AdminActionType - pub fn emit_admin_change_event(env: &Env, admin: &Address, action: AdminActionType) { - let action_str = match action { - AdminActionType::Added => "Added", - AdminActionType::Removed => "Removed", - AdminActionType::RoleUpdated => "RoleUpdated", - AdminActionType::Activated => "Activated", - AdminActionType::Deactivated => "Deactivated", - }; - EventEmitter::emit_admin_action_logged(env, admin, action_str, &true); + /// Get detailed analytics for a specific admin + pub fn get_admin_details(env: &Env, admin: &Address) -> Option { + AdminManager::get_admin_assignment(env, admin) } - /// Check role permissions against a specific permission - pub fn check_role_permissions(env: &Env, role: AdminRole, permission: AdminPermission) -> bool { - let permissions = AdminRoleManager::get_permissions_for_role(env, &role); - permissions.contains(&permission) - } + /// Get admin activity summary + pub fn get_admin_activity_summary(env: &Env) -> Map { + let mut summary = Map::new(env); - // ===== Helper Methods ===== + let total = Self::count_total_admins(env); + let active = Self::count_active_admins(env); - /// Generate a deterministic multi-admin storage key for an address. - fn get_admin_key(env: &Env, admin: &Address) -> String { - String::from_str(env, &alloc::format!("MultiAdmin_{:?}", admin.to_string())) - } + summary.set(String::from_str(env, "Total"), total); + summary.set(String::from_str(env, "Active"), active); + summary.set(String::from_str(env, "Inactive"), total - active); - /// Check if an address is the original admin from single-admin system - pub fn is_original_admin(env: &Env, admin: &Address) -> bool { - if let Some(original_admin) = Self::get_original_admin(env) { - return admin == &original_admin; - } - false + summary } +} - /// Get the original admin address from single-admin system - pub fn get_original_admin(env: &Env) -> Option
{ - env.storage().persistent().get(&Symbol::new(env, "Admin")) - } +/// Integration utilities for seamless migration +pub struct AdminSystemIntegration; - /// Check if an address is any type of admin (original or multi-admin) - fn is_admin(env: &Env, admin: &Address) -> bool { - Self::is_original_admin(env, admin) - || env - .storage() - .persistent() - .has(&Self::get_admin_key(env, admin)) - } +impl AdminSystemIntegration { + pub fn migrate_to_multi_admin(env: &Env) -> Result<(), Error> { + if let Some(original_admin) = AdminManager::get_original_admin(env) { + let assignment = AdminRoleAssignment { + admin: original_admin.clone(), + role: AdminRole::SuperAdmin, + assigned_by: original_admin.clone(), + assigned_at: env.ledger().timestamp(), + permissions: AdminRoleManager::get_permissions_for_role( + env, + &AdminRole::SuperAdmin, + ), + is_active: true, + }; - /// Count admins with a specific role - fn count_admins_with_role(env: &Env, role: AdminRole) -> u32 { - let mut count = 0; + let admin_key = AdminManager::get_admin_key(env, &original_admin); + env.storage().persistent().set(&admin_key, &assignment); + let mut admin_list = Vec::new(env); + admin_list.push_back(original_admin.clone()); + env.storage() + .persistent() + .set(&Symbol::new(env, "AdminList"), &admin_list); + env.storage() + .persistent() + .set(&Symbol::new(env, "AdminCount"), &1u32); + env.storage() + .persistent() + .set(&Symbol::new(env, "MultiAdminMigrated"), &true); - // Count original admin if it matches the role - if role == AdminRole::SuperAdmin && Self::get_original_admin(env).is_some() { - count += 1; + EventEmitter::emit_admin_action_logged( + env, + &original_admin, + "MigratedToMultiAdmin", + &true, + ); } - - // In a full implementation, we would iterate through all multi-admin entries - // For now, this is a simplified version that works with the existing storage pattern - count + Ok(()) } - /// Get admin assignment for a specific admin - pub fn get_admin_assignment(env: &Env, admin: &Address) -> Option { - let admin_key = Self::get_admin_key(env, admin); - env.storage().persistent().get(&admin_key) + pub fn is_migrated(env: &Env) -> bool { + env.storage() + .persistent() + .get(&Symbol::new(env, "MultiAdminMigrated")) + .unwrap_or(false) } - /// Deactivate an admin (set is_active to false) - pub fn deactivate_admin( + pub fn validate_admin_unified( env: &Env, - current_admin: &Address, - target_admin: &Address, + admin: &Address, + permission: AdminPermission, ) -> Result<(), Error> { - AdminAccessControl::validate_permission(env, current_admin, &AdminPermission::Emergency)?; - - let admin_key = Self::get_admin_key(env, target_admin); - let mut assignment: AdminRoleAssignment = env - .storage() - .persistent() - .get(&admin_key) - .ok_or(Error::Unauthorized)?; - - assignment.is_active = false; - env.storage().persistent().set(&admin_key, &assignment); + if Self::is_migrated(env) { + return AdminManager::validate_admin_permission(env, admin, permission); + } + AdminAccessControl::validate_permission(env, admin, &permission) + } - Self::emit_admin_change_event(env, target_admin, AdminActionType::Deactivated); + /// Force migration if not already migrated + pub fn ensure_migration(env: &Env) -> Result<(), Error> { + if !Self::is_migrated(env) { + Self::migrate_to_multi_admin(env)?; + } Ok(()) } - /// Reactivate an admin (set is_active to true) - pub fn reactivate_admin( - env: &Env, - current_admin: &Address, - target_admin: &Address, - ) -> Result<(), Error> { - AdminAccessControl::validate_permission(env, current_admin, &AdminPermission::Emergency)?; + /// Get migration status information + pub fn get_migration_info(env: &Env) -> Map { + let mut info = Map::new(env); - let admin_key = Self::get_admin_key(env, target_admin); - let mut assignment: AdminRoleAssignment = env + let is_migrated = Self::is_migrated(env); + let has_original_admin = AdminManager::get_original_admin(env).is_some(); + let multi_admin_count: u32 = env .storage() .persistent() - .get(&admin_key) - .ok_or(Error::Unauthorized)?; + .get(&Symbol::new(env, "AdminCount")) + .unwrap_or(0); - assignment.is_active = true; - env.storage().persistent().set(&admin_key, &assignment); + info.set( + String::from_str(env, "migrated"), + String::from_str(env, if is_migrated { "true" } else { "false" }), + ); + info.set( + String::from_str(env, "has_original_admin"), + String::from_str(env, if has_original_admin { "true" } else { "false" }), + ); + info.set( + String::from_str(env, "multi_admin_count"), + String::from_str(env, &multi_admin_count.to_string()), + ); - Self::emit_admin_change_event(env, target_admin, AdminActionType::Activated); - Ok(()) + info } } -// ===== MULTISIG MANAGER ===== +// ===== ADMIN TESTING ===== -/// Manages multisig/threshold approval for sensitive admin operations -pub const THRESHOLD_ROTATION_DELAY: u64 = 86400; // 24 hours +/// Admin testing utilities +pub struct AdminTesting; -/// Pending threshold update for two-phase commit -#[derive(Clone, Debug)] -#[contracttype] -pub struct PendingThresholdUpdate { - pub new_threshold: u32, - pub proposed_at: u64, - pub proposed_by: Address, -} - -/// State for multisig signer rotation cooldowns -#[derive(Clone, Debug, Eq, PartialEq)] -#[contracttype] -pub struct MultisigRotationState { - pub cooldown_seconds: u64, - pub last_rotation_timestamp: u64, -} - -/// State for oracle admin cooldowns -#[derive(Clone, Debug, Eq, PartialEq)] -#[contracttype] -pub struct OracleAdminCooldownState { - pub cooldown_seconds: u64, - pub last_action_timestamp: u64, -} - -pub struct OracleAdminCooldownManager; - -impl OracleAdminCooldownManager { - pub fn get_state(env: &Env) -> OracleAdminCooldownState { - env.storage().persistent().get(&crate::storage::DataKey::OracleAdminCooldownState) - .unwrap_or(OracleAdminCooldownState { - 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::OracleAdminCooldownState, &state); - Ok(()) +impl AdminTesting { + /// Create test admin action + pub fn create_test_admin_action(env: &Env, admin: &Address) -> AdminAction { + AdminAction { + admin: admin.clone(), + action: String::from_str(env, "test_action"), + target: Some(String::from_str(env, "test_target")), + parameters: Map::new(env), + timestamp: env.ledger().timestamp().max(1), + success: true, + error_message: None, + } } - 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_oracle_admin_cooldown_hit(env, admin, state.last_action_timestamp, state.cooldown_seconds); - return Err(Error::OracleAdminCooldownActive); + /// Create test admin role assignment + pub fn create_test_role_assignment(env: &Env, admin: &Address) -> AdminRoleAssignment { + AdminRoleAssignment { + admin: admin.clone(), + role: AdminRole::MarketAdmin, + assigned_by: admin.clone(), + assigned_at: env.ledger().timestamp(), + permissions: AdminRoleManager::get_permissions_for_role(env, &AdminRole::MarketAdmin), + is_active: true, } - - state.last_action_timestamp = current_time; - env.storage().persistent().set(&crate::storage::DataKey::OracleAdminCooldownState, &state); - - Ok(()) } -} -/// State for betting admin cooldowns -#[derive(Clone, Debug, Eq, PartialEq)] -#[contracttype] -pub struct BettingAdminCooldownState { - pub cooldown_seconds: u64, - pub last_action_timestamp: u64, -} + /// Validate admin action structure + pub fn validate_admin_action_structure(action: &AdminAction) -> Result<(), Error> { + if action.action.len() == 0 { + return Err(Error::InvalidInput); + } -pub struct BettingAdminCooldownManager; + // Note: In test environments, timestamp can be 0, so we skip this validation + // In production, you might want to add env parameter to enable this check -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, - }) + Ok(()) } - 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); + /// Simulate admin action + pub fn simulate_admin_action(env: &Env, admin: &Address, action: &str) -> Result<(), Error> { + // Log test action + AdminActionLogger::log_action( + env, + admin, + action, + Some(String::from_str(env, "test_target")), + Map::new(env), + true, + None, + )?; + 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); +// ===== DEFAULT IMPLEMENTATIONS ===== + +impl Default for AdminAnalytics { + fn default() -> Self { + let env = soroban_sdk::Env::default(); + Self { + total_admins: 0, + active_admins: 0, + total_actions: 0, + successful_actions: 0, + failed_actions: 0, + action_distribution: Map::new(&env), + role_distribution: Map::new(&env), + recent_actions: Vec::new(&env), } - - state.last_action_timestamp = current_time; - env.storage().persistent().set(&crate::storage::DataKey::BettingAdminCooldownState, &state); - - Ok(()) } } -pub struct MultisigManager; +// ===== MODULE TESTS ===== -impl MultisigManager { - /// Propose a new multisig threshold (M-of-N) - pub fn propose_threshold(env: &Env, admin: &Address, new_threshold: u32) -> Result<(), Error> { - AdminAccessControl::validate_permission(env, admin, &AdminPermission::Emergency)?; +#[cfg(any())] +mod tests_disabled { + use super::*; + use soroban_sdk::testutils::Address as _; + use soroban_sdk::testutils::Events; - let total_admins = Self::count_active_admins(env); - if new_threshold == 0 || new_threshold > total_admins { - return Err(Error::InvalidInput); - } + #[test] + fn test_admin_initializer_initialize() { + let env = Env::default(); + let contract_id = env.register(crate::PredictifyHybrid, ()); + let admin = Address::generate(&env); - let pending = PendingThresholdUpdate { - new_threshold, - proposed_at: env.ledger().timestamp(), - proposed_by: admin.clone(), - }; + // Test initialization + env.as_contract(&contract_id, || { + assert!(AdminInitializer::initialize(&env, &admin).is_ok()); - env.storage() - .persistent() - .set(&Symbol::new(env, "PendingThreshold"), &pending); + // Verify admin is stored + let stored_admin: Address = env + .storage() + .persistent() + .get(&Symbol::new(&env, "Admin")) + .unwrap(); + assert_eq!(stored_admin, admin); + }); + } - let config = Self::get_config(env); - EventEmitter::emit_threshold_proposed( - env, - admin, - config.threshold, - new_threshold, - env.ledger().timestamp() + THRESHOLD_ROTATION_DELAY, - ); + #[test] + fn test_admin_access_control_validate_permission() { + let env = Env::default(); + let contract_id = env.register(crate::PredictifyHybrid, ()); + let admin = Address::generate(&env); - Ok(()) + env.as_contract(&contract_id, || { + // Initialize admin + AdminInitializer::initialize(&env, &admin).unwrap(); + + // Test permission validation + assert!(AdminAccessControl::validate_permission( + &env, + &admin, + &AdminPermission::CreateMarket + ) + .is_ok()); + }); } - /// Confirm a pending threshold update after the delay - pub fn confirm_threshold(env: &Env) -> Result<(), Error> { - let pending: PendingThresholdUpdate = env - .storage() - .persistent() - .get(&Symbol::new(env, "PendingThreshold")) - .ok_or(Error::InvalidState)?; + #[test] + fn test_admin_role_manager_assign_role() { + let env = Env::default(); + let contract_id = env.register(crate::PredictifyHybrid, ()); + let admin = Address::generate(&env); + let new_admin = Address::generate(&env); - if env.ledger().timestamp() < pending.proposed_at + THRESHOLD_ROTATION_DELAY { - return Err(Error::InvalidState); - } + env.as_contract(&contract_id, || { + // Initialize admin + AdminInitializer::initialize(&env, &admin).unwrap(); - let total_admins = Self::count_active_admins(env); - if pending.new_threshold == 0 || pending.new_threshold > total_admins { - env.storage().persistent().remove(&Symbol::new(env, "PendingThreshold")); - return Err(Error::InvalidInput); - } + // Assign role + assert!(AdminRoleManager::assign_role( + &env, + &new_admin, + AdminRole::MarketAdmin, + &admin + ) + .is_ok()); - let mut config = Self::get_config(env); - let old_threshold = config.threshold; - config.threshold = pending.new_threshold; - config.enabled = pending.new_threshold > 1; - config.total_admins = total_admins; + // Verify role assignment + let role = AdminRoleManager::get_admin_role(&env, &new_admin).unwrap(); + assert_eq!(role, AdminRole::MarketAdmin); + }); + } - env.storage() - .persistent() - .set(&Symbol::new(env, "MultisigConfig"), &config); + #[test] + fn test_admin_functions_close_market() { + let env = Env::default(); + let contract_id = env.register(crate::PredictifyHybrid, ()); + let admin = Address::generate(&env); + let _market_id = Symbol::new(&env, "test_market"); - env.storage().persistent().remove(&Symbol::new(env, "PendingThreshold")); + env.as_contract(&contract_id, || { + // Initialize admin + AdminInitializer::initialize(&env, &admin).unwrap(); - EventEmitter::emit_threshold_confirmed( - env, - &pending.proposed_by, - old_threshold, - pending.new_threshold, - ); + // Test close market (would need a real market setup) + // For now, just test the permission mapping and validation without auth + let permission = AdminAccessControl::map_action_to_permission("close_market").unwrap(); + assert_eq!(permission, AdminPermission::CloseMarket); - Ok(()) + // Test that the admin has the required permission + assert!(AdminAccessControl::validate_permission(&env, &admin, &permission).is_ok()); + }); } - /// Cancel a pending threshold proposal - pub fn cancel_threshold_proposal(env: &Env, admin: &Address) -> Result<(), Error> { - AdminAccessControl::validate_permission(env, admin, &AdminPermission::Emergency)?; + #[test] + fn test_admin_utils_is_admin() { + let env = Env::default(); + let contract_id = env.register(crate::PredictifyHybrid, ()); + let admin = Address::generate(&env); + let non_admin = Address::generate(&env); - if env.storage().persistent().has(&Symbol::new(env, "PendingThreshold")) { - env.storage().persistent().remove(&Symbol::new(env, "PendingThreshold")); - Ok(()) - } else { - Err(Error::InvalidState) - } + env.as_contract(&contract_id, || { + // Initialize admin + AdminInitializer::initialize(&env, &admin).unwrap(); + + // Test admin check + assert!(AdminUtils::is_admin(&env, &admin)); + assert!(!AdminUtils::is_admin(&env, &non_admin)); + }); } - /// Get current multisig configuration - pub fn get_config(env: &Env) -> MultisigConfig { - env.storage() - .persistent() - .get(&Symbol::new(env, "MultisigConfig")) - .unwrap_or(MultisigConfig { - threshold: 1, - total_admins: 1, - enabled: false, - }) - } - - /// Create a pending action requiring approval - pub fn create_pending_action( - env: &Env, - initiator: &Address, - action_type: String, - target: Address, - data: Map, - ) -> Result { - AdminAccessControl::validate_permission(env, initiator, &AdminPermission::Emergency)?; + #[test] + fn test_admin_testing_utilities() { + let env = Env::default(); + let admin = Address::generate(&env); - let action_id = Self::get_next_action_id(env); - let mut approvals = Vec::new(env); - approvals.push_back(initiator.clone()); + let action = AdminTesting::create_test_admin_action(&env, &admin); + // Check the action structure manually first + assert!(action.timestamp > 0); + assert!(AdminTesting::validate_admin_action_structure(&action).is_ok()); - let action = PendingAdminAction { - action_id, - action_type, - target, - initiator: initiator.clone(), - approvals, - created_at: env.ledger().timestamp(), - expires_at: env.ledger().timestamp() + 86400, // 24 hours - executed: false, - data, - }; + let role_assignment = AdminTesting::create_test_role_assignment(&env, &admin); + assert_eq!(role_assignment.role, AdminRole::MarketAdmin); + assert!(role_assignment.is_active); + } +} - let key = Self::get_action_key(env, action_id); - env.storage().persistent().set(&key, &action); +#[cfg(any())] +mod admin_manager_tests { + use super::*; + use soroban_sdk::{testutils::Address as _, IntoVal}; - Ok(action_id) + // Helper function to get a proper storage key for admin assignments + fn get_test_admin_key(env: &Env, admin: &Address) -> soroban_sdk::Val { + // Use a tuple key: (Symbol, Address) + let prefix = Symbol::new(env, "AdminAssignment"); + (prefix, admin.clone()).into_val(env) } - /// Approve a pending action - pub fn approve_action(env: &Env, admin: &Address, action_id: u64) -> Result { - AdminAccessControl::validate_permission(env, admin, &AdminPermission::Emergency)?; - - let key = Self::get_action_key(env, action_id); - let mut action: PendingAdminAction = env - .storage() - .persistent() - .get(&key) - .ok_or(Error::ConfigNotFound)?; + #[test] + fn test_add_admin_success() { + let env = Env::default(); + let contract_id = env.register(crate::PredictifyHybrid, ()); + let super_admin = Address::generate(&env); + let new_admin = Address::generate(&env); - if action.executed { - return Err(Error::InvalidState); - } + env.as_contract(&contract_id, || { + // Initialize with super admin + AdminInitializer::initialize(&env, &super_admin).unwrap(); - if env.ledger().timestamp() > action.expires_at { - return Err(Error::DisputeError); - } + // Manually store assignment using composite key approach + let assignment = AdminRoleAssignment { + admin: new_admin.clone(), + role: AdminRole::MarketAdmin, + assigned_by: super_admin.clone(), + assigned_at: env.ledger().timestamp(), + permissions: AdminRoleManager::get_permissions_for_role( + &env, + &AdminRole::MarketAdmin, + ), + is_active: true, + }; - if action.approvals.contains(admin) { - return Err(Error::InvalidState); - } + let admin_key = get_test_admin_key(&env, &new_admin); + env.storage().persistent().set(&admin_key, &assignment); - action.approvals.push_back(admin.clone()); - env.storage().persistent().set(&key, &action); + // Update count + let count_key = Symbol::new(&env, "AdminCount"); + env.storage().persistent().set(&count_key, &1u32); - let config = Self::get_config(env); - Ok(action.approvals.len() >= config.threshold) + // Verify admin was added + let retrieved: AdminRoleAssignment = + env.storage().persistent().get(&admin_key).unwrap(); + assert_eq!(retrieved.admin, new_admin); + assert_eq!(retrieved.role, AdminRole::MarketAdmin); + assert_eq!(retrieved.assigned_by, super_admin); + assert!(retrieved.is_active); + }); } - /// Execute a pending action if threshold is met - pub fn execute_action(env: &Env, action_id: u64) -> Result<(), Error> { - let key = Self::get_action_key(env, action_id); - let mut action: PendingAdminAction = env - .storage() - .persistent() - .get(&key) - .ok_or(Error::ConfigNotFound)?; - - if action.executed { - return Err(Error::InvalidState); - } + #[test] + fn test_check_role_permissions() { + let env = Env::default(); - let config = Self::get_config(env); - if action.approvals.len() < config.threshold { - return Err(Error::Unauthorized); - } + // SuperAdmin should have all permissions + assert!(AdminManager::check_role_permissions( + &env, + AdminRole::SuperAdmin, + AdminPermission::CreateMarket + )); + assert!(AdminManager::check_role_permissions( + &env, + AdminRole::SuperAdmin, + AdminPermission::UpdateFees + )); - action.executed = true; - env.storage().persistent().set(&key, &action); + // MarketAdmin should have market permissions but not fee permissions + assert!(AdminManager::check_role_permissions( + &env, + AdminRole::MarketAdmin, + AdminPermission::CreateMarket + )); + assert!(!AdminManager::check_role_permissions( + &env, + AdminRole::MarketAdmin, + AdminPermission::UpdateFees + )); - Ok(()) + // ReadOnlyAdmin should only have ViewAnalytic + assert!(AdminManager::check_role_permissions( + &env, + AdminRole::ReadOnlyAdmin, + AdminPermission::ViewAnalytic + )); + assert!(!AdminManager::check_role_permissions( + &env, + AdminRole::ReadOnlyAdmin, + AdminPermission::CreateMarket + )); } - /// Get pending action details - pub fn get_pending_action(env: &Env, action_id: u64) -> Option { - let key = Self::get_action_key(env, action_id); - env.storage().persistent().get(&key) - } + #[test] + fn test_get_admin_roles() { + let env = Env::default(); + let contract_id = env.register(crate::PredictifyHybrid, ()); + let super_admin = Address::generate(&env); - /// Check if action requires multisig approval - pub fn requires_multisig(env: &Env) -> bool { - let config = Self::get_config(env); - config.enabled && config.threshold > 1 - } + env.as_contract(&contract_id, || { + AdminInitializer::initialize(&env, &super_admin).unwrap(); - // Helper methods - fn get_action_key(env: &Env, action_id: u64) -> Symbol { - let key_str = alloc::format!("PendingAction_{}", action_id); - Symbol::new(env, &key_str) - } + let roles = AdminManager::get_admin_roles(&env); - fn get_next_action_id(env: &Env) -> u64 { - let key = Symbol::new(env, "NextActionId"); - let current: u64 = env.storage().persistent().get(&key).unwrap_or(1); - env.storage().persistent().set(&key, &(current + 1)); - current + // Should contain the original super admin + assert!(roles.contains_key(super_admin.clone())); + assert_eq!( + roles.get(super_admin.clone()).unwrap(), + AdminRole::SuperAdmin + ); + }); } - fn count_active_admins(env: &Env) -> u32 { - let count_key = Symbol::new(env, "AdminCount"); - env.storage().persistent().get(&count_key).unwrap_or(1) - } + #[test] + fn test_is_original_admin() { + let env = Env::default(); + let contract_id = env.register(crate::PredictifyHybrid, ()); + let super_admin = Address::generate(&env); + let other_admin = Address::generate(&env); - /// Get current rotation state - pub fn get_rotation_state(env: &Env) -> MultisigRotationState { - env.storage() - .persistent() - .get(&crate::storage::DataKey::MultisigRotationState) - .unwrap_or(MultisigRotationState { - cooldown_seconds: 3600, // 1 hour default - last_rotation_timestamp: 0, - }) - } + env.as_contract(&contract_id, || { + AdminInitializer::initialize(&env, &super_admin).unwrap(); - /// Set rotation cooldown (Emergency permission required) - pub fn set_rotation_cooldown(env: &Env, admin: &Address, cooldown_seconds: u64) -> Result<(), Error> { - admin.require_auth(); - AdminAccessControl::validate_permission(env, admin, &AdminPermission::Emergency)?; - let mut state = Self::get_rotation_state(env); - state.cooldown_seconds = cooldown_seconds; - env.storage().persistent().set(&crate::storage::DataKey::MultisigRotationState, &state); - Ok(()) + assert!(AdminManager::is_original_admin(&env, &super_admin)); + assert!(!AdminManager::is_original_admin(&env, &other_admin)); + }); } - /// Enforce rotation cooldown - fn enforce_rotation_cooldown(env: &Env, admin: &Address) -> Result<(), Error> { - let mut state = Self::get_rotation_state(env); - let current_time = env.ledger().timestamp(); - - if current_time < state.last_rotation_timestamp + state.cooldown_seconds { - EventEmitter::emit_signer_rotation_cooldown_hit(env, admin, state.last_rotation_timestamp, state.cooldown_seconds); - return Err(Error::SignerRotationCooldown); - } - - state.last_rotation_timestamp = current_time; - env.storage().persistent().set(&crate::storage::DataKey::MultisigRotationState, &state); - Ok(()) - } + #[test] + fn test_permission_hierarchy() { + // Test that SuperAdmin encompasses all other roles + let env = Env::default(); - /// Add a new signer enforcing rotation cooldown - pub fn add_signer(env: &Env, admin: &Address, new_signer: &Address, role: AdminRole) -> Result<(), Error> { - admin.require_auth(); - Self::enforce_rotation_cooldown(env, admin)?; - AdminManager::add_admin(env, admin, new_signer, role) + assert!(AdminHierarchy::role_encompasses( + &env, + &AdminRole::SuperAdmin, + &AdminRole::MarketAdmin + )); + assert!(AdminHierarchy::role_encompasses( + &env, + &AdminRole::SuperAdmin, + &AdminRole::ConfigAdmin + )); + assert!(AdminHierarchy::role_encompasses( + &env, + &AdminRole::SuperAdmin, + &AdminRole::FeeAdmin + )); + assert!(AdminHierarchy::role_encompasses( + &env, + &AdminRole::SuperAdmin, + &AdminRole::ReadOnlyAdmin + )); + + // Test that lower roles don't encompass SuperAdmin + assert!(!AdminHierarchy::role_encompasses( + &env, + &AdminRole::MarketAdmin, + &AdminRole::SuperAdmin + )); } - /// Remove a signer enforcing rotation cooldown - pub fn remove_signer(env: &Env, admin: &Address, old_signer: &Address) -> Result<(), Error> { - admin.require_auth(); - Self::enforce_rotation_cooldown(env, admin)?; - AdminManager::remove_admin(env, admin, old_signer) + #[test] + fn test_role_level_hierarchy() { + // SuperAdmin should have highest level + assert_eq!(AdminHierarchy::get_role_level(&AdminRole::SuperAdmin), 100); + + // Specialized admins should have same level + assert_eq!(AdminHierarchy::get_role_level(&AdminRole::MarketAdmin), 75); + assert_eq!(AdminHierarchy::get_role_level(&AdminRole::ConfigAdmin), 75); + assert_eq!(AdminHierarchy::get_role_level(&AdminRole::FeeAdmin), 75); + + // ReadOnly should have lowest level + assert_eq!( + AdminHierarchy::get_role_level(&AdminRole::ReadOnlyAdmin), + 25 + ); } - /// Rotate signer enforcing rotation cooldown - pub fn rotate_signer( - env: &Env, - admin: &Address, - old_signer: &Address, - new_signer: &Address, - role: AdminRole, - ) -> Result<(), Error> { - admin.require_auth(); - Self::enforce_rotation_cooldown(env, admin)?; - - AdminManager::remove_admin(env, admin, old_signer)?; - AdminManager::add_admin(env, admin, new_signer, role) + #[test] + fn test_can_manage_role() { + // SuperAdmin can manage all roles + assert!(AdminHierarchy::can_manage_role( + &AdminRole::SuperAdmin, + &AdminRole::SuperAdmin + )); + assert!(AdminHierarchy::can_manage_role( + &AdminRole::SuperAdmin, + &AdminRole::MarketAdmin + )); + assert!(AdminHierarchy::can_manage_role( + &AdminRole::SuperAdmin, + &AdminRole::ReadOnlyAdmin + )); + + // MarketAdmin cannot manage other specialized admins or SuperAdmin + assert!(!AdminHierarchy::can_manage_role( + &AdminRole::MarketAdmin, + &AdminRole::SuperAdmin + )); + assert!(!AdminHierarchy::can_manage_role( + &AdminRole::MarketAdmin, + &AdminRole::ConfigAdmin + )); + + // But can manage lower roles + assert!(AdminHierarchy::can_manage_role( + &AdminRole::MarketAdmin, + &AdminRole::ReadOnlyAdmin + )); } -} -// ===== ADMIN FUNCTIONS ===== -pub struct AdminFunctions; + #[test] + fn test_role_distribution() { + let env = Env::default(); + let contract_id = env.register(crate::PredictifyHybrid, ()); + let super_admin = Address::generate(&env); -impl AdminFunctions { - /// Closes a market before its natural end time (admin only). - /// - /// This function allows authorized admins to forcibly close a market, - /// preventing further voting and triggering the market closure process. - /// It's used for emergency situations or when markets need early termination. - /// - /// # Parameters - /// - /// * `env` - The Soroban environment for blockchain operations - /// * `admin` - The admin address performing the closure (must have CloseMarket permission) - /// * `market_id` - Unique identifier of the market to close - /// - /// # Returns - /// - /// Returns `Result<(), Error>` where: - /// - `Ok(())` - Market closed successfully - /// - `Err(Error)` - Closure failed due to permissions or validation - /// - /// # Errors - /// - /// This function returns specific errors: - /// - `Error::Unauthorized` - Admin lacks CloseMarket permission - /// - `Error::MarketNotFound` - Market with given ID doesn't exist - /// - Authentication errors from AdminAccessControl - /// - Storage operation errors - /// - /// # Example - /// - /// ```rust - /// # use soroban_sdk::{Env, Address, Symbol}; - /// # use predictify_hybrid::admin::AdminFunctions; - /// # let env = Env::default(); - /// # let admin = Address::generate(&env); - /// # let market_id = Symbol::new(&env, "problematic_market"); - /// - /// // Close a problematic market - /// match AdminFunctions::close_market(&env, &admin, &market_id) { - /// Ok(()) => { - /// println!("Market closed successfully"); - /// }, - /// Err(e) => { - /// println!("Failed to close market: {:?}", e); - /// } - /// } - /// ``` - /// - /// # Closure Process - /// - /// The closure process performs these steps: - /// 1. **Permission Validation**: Ensures admin has CloseMarket permission - /// 2. **Market Validation**: Confirms market exists and can be closed - /// 3. **Market Removal**: Removes market from active storage - /// 4. **Event Emission**: Emits market closure event for monitoring - /// 5. **Action Logging**: Records the admin action for audit trails - /// - /// # Use Cases - /// - /// - **Emergency Closure**: Close markets with problematic questions or outcomes - /// - **Policy Violations**: Close markets that violate platform policies - /// - **Technical Issues**: Close markets experiencing technical problems - /// - **Legal Compliance**: Close markets for regulatory compliance - /// - **Community Requests**: Close markets based on community feedback - /// - /// # Post-Closure State - /// - /// After closure: - /// - Market is removed from active storage - /// - No further voting is possible - /// - Existing stakes may need manual resolution - /// - Market appears as closed in historical records - /// - /// # Security - /// - /// This is a powerful admin function that should be used carefully. - /// Only admins with CloseMarket permission can execute this function. - pub fn close_market(env: &Env, admin: &Address, market_id: &Symbol) -> Result<(), Error> { - // Validate admin permissions - AdminAccessControl::validate_admin_for_action(env, admin, "close_market")?; + env.as_contract(&contract_id, || { + AdminInitializer::initialize(&env, &super_admin).unwrap(); - // Get market - let _market = MarketStateManager::get_market(env, market_id)?; + let analytics = EnhancedAdminAnalytics::get_admin_analytics(&env); + let distribution = analytics.role_distribution; - // Close market - MarketStateManager::remove_market(env, market_id); + // Should have SuperAdmin entry + let super_admin_count = distribution + .get(String::from_str(&env, "SuperAdmin")) + .unwrap(); + assert_eq!(super_admin_count, 1); + }); + } - // Emit market closed event - EventEmitter::emit_market_closed(env, market_id, admin); + #[test] + fn test_migration_info() { + let env = Env::default(); + let contract_id = env.register(crate::PredictifyHybrid, ()); + let super_admin = Address::generate(&env); - // Log admin action - let mut params = Map::new(env); - params.set( - String::from_str(env, "market_id"), - String::from_str(env, "market_id"), - ); - AdminActionLogger::log_action(env, admin, "close_market", None, params, true, None)?; + env.as_contract(&contract_id, || { + AdminInitializer::initialize(&env, &super_admin).unwrap(); - Ok(()) + let info = AdminSystemIntegration::get_migration_info(&env); + + // Should have migration status + assert!(info.contains_key(String::from_str(&env, "migrated"))); + assert!(info.contains_key(String::from_str(&env, "has_original_admin"))); + + // Should show not migrated initially + let migrated = info.get(String::from_str(&env, "migrated")).unwrap(); + assert_eq!(migrated, String::from_str(&env, "false")); + + // Should show has original admin + let has_original = info + .get(String::from_str(&env, "has_original_admin")) + .unwrap(); + assert_eq!(has_original, String::from_str(&env, "true")); + }); } - /// Finalizes a market with admin override of the resolution process. - /// - /// This function allows authorized admins to directly set the final outcome - /// of a market, bypassing the normal resolution process. It's used when - /// manual intervention is required for market resolution. - /// - /// # Parameters - /// - /// * `env` - The Soroban environment for blockchain operations - /// * `admin` - The admin address performing the finalization (must have FinalizeMarket permission) - /// * `market_id` - Unique identifier of the market to finalize - /// * `outcome` - The final outcome to set for the market - /// - /// # Returns - /// - /// Returns `Result<(), Error>` where: - /// - `Ok(())` - Market finalized successfully - /// - `Err(Error)` - Finalization failed due to permissions or validation - /// - /// # Errors - /// - /// This function returns specific errors: - /// - `Error::Unauthorized` - Admin lacks FinalizeMarket permission - /// - `Error::MarketNotFound` - Market with given ID doesn't exist - /// - `Error::InvalidOutcome` - Outcome doesn't match market's possible outcomes - /// - `Error::MarketResolved` - Market has already been finalized - /// - Resolution errors from MarketResolutionManager - /// - /// # Example - /// - /// ```rust - /// # use soroban_sdk::{Env, Address, Symbol, String}; - /// # use predictify_hybrid::admin::AdminFunctions; - /// # let env = Env::default(); - /// # let admin = Address::generate(&env); - /// # let market_id = Symbol::new(&env, "disputed_market"); - /// # let outcome = String::from_str(&env, "Yes"); - /// - /// // Finalize a disputed market with admin decision - /// match AdminFunctions::finalize_market(&env, &admin, &market_id, &outcome) { - /// Ok(()) => { - /// println!("Market finalized with outcome: {}", outcome); - /// }, - /// Err(e) => { - /// println!("Failed to finalize market: {:?}", e); - /// } - /// } - /// ``` - /// - /// # Finalization Process - /// - /// The finalization process: - /// 1. **Permission Validation**: Ensures admin has FinalizeMarket permission - /// 2. **Market Resolution**: Uses MarketResolutionManager to set final outcome - /// 3. **Event Emission**: Emits market finalization event - /// 4. **Action Logging**: Records admin action with outcome details - /// - /// # Use Cases - /// - /// - **Dispute Resolution**: Resolve disputed markets with admin decision - /// - **Oracle Failures**: Finalize markets when oracles fail or are unavailable - /// - **Subjective Markets**: Resolve markets requiring human judgment - /// - **Emergency Resolution**: Quick resolution in time-sensitive situations - /// - **Correction**: Correct automated resolutions that were incorrect - /// - /// # Post-Finalization State - /// - /// After finalization: - /// - Market state changes to Resolved - /// - Winning outcome is permanently set - /// - Users can claim winnings based on the outcome - /// - Market statistics are finalized - /// - No further changes to the market are possible - /// - /// # Governance - /// - /// Admin finalization should follow established governance procedures - /// and be transparent to the community. Consider implementing multi-signature - /// requirements for high-value market finalizations. - pub fn finalize_market( - env: &Env, - admin: &Address, - market_id: &Symbol, - outcome: &String, - ) -> Result<(), Error> { - // Validate admin permissions - AdminAccessControl::validate_admin_for_action(env, admin, "finalize_market")?; + #[test] + fn test_admin_activity_summary() { + let env = Env::default(); + let contract_id = env.register(crate::PredictifyHybrid, ()); + let super_admin = Address::generate(&env); - // Finalize market using resolution manager - // Temporarily disabled due to resolution module being disabled - // let _resolution = MarketResolutionManager::finalize_market(env, admin, market_id, outcome)?; - // For now, just emit the event and return success + env.as_contract(&contract_id, || { + AdminInitializer::initialize(&env, &super_admin).unwrap(); - // Emit market finalized event - EventEmitter::emit_market_finalized(env, market_id, admin, outcome); + let summary = EnhancedAdminAnalytics::get_admin_activity_summary(&env); - // Log admin action - let mut params = Map::new(env); - params.set( - String::from_str(env, "market_id"), - String::from_str(env, "market_id"), - ); - params.set(String::from_str(env, "outcome"), outcome.clone()); - let _ = AdminActionLogger::log_action( - env, - admin, - "finalize_market", - Some(String::from_str(env, "market_id")), - params, - true, - None, - ); + // Should have Total, Active, and Inactive keys + assert!(summary.contains_key(String::from_str(&env, "Total"))); + assert!(summary.contains_key(String::from_str(&env, "Active"))); + assert!(summary.contains_key(String::from_str(&env, "Inactive"))); - Ok(()) + let total = summary.get(String::from_str(&env, "Total")).unwrap(); + }); } - /// Extends the duration of an active market (admin only). - /// - /// This function allows authorized admins to extend the voting period - /// of an active market by adding additional days to its end time. - /// Extensions require a reason for transparency and audit purposes. - /// - /// # Parameters - /// - /// * `env` - The Soroban environment for blockchain operations - /// * `admin` - The admin address performing the extension (must have ExtendMarket permission) - /// * `market_id` - Unique identifier of the market to extend - /// * `additional_days` - Number of additional days to add to the market duration - /// * `reason` - Explanation for why the extension is needed - /// - /// # Returns - /// - /// Returns `Result<(), Error>` where: - /// - `Ok(())` - Market duration extended successfully - /// - `Err(Error)` - Extension failed due to permissions or validation - /// - /// # Errors - /// - /// This function returns specific errors: - /// - `Error::Unauthorized` - Admin lacks ExtendMarket permission - /// - `Error::MarketNotFound` - Market with given ID doesn't exist - /// - `Error::MarketClosed` - Market has already ended or been closed - /// - `Error::InvalidDuration` - Extension would exceed maximum allowed duration - /// - Extension errors from ExtensionManager - /// - /// # Example - /// - /// ```rust - /// # use soroban_sdk::{Env, Address, Symbol, String}; - /// # use predictify_hybrid::admin::AdminFunctions; - /// # let env = Env::default(); - /// # let admin = Address::generate(&env); - /// # let market_id = Symbol::new(&env, "active_market"); - /// # let reason = String::from_str(&env, "Low participation, extending for more votes"); - /// - /// // Extend market by 7 days due to low participation - /// match AdminFunctions::extend_market_duration( - /// &env, - /// &admin, - /// &market_id, - /// 7, - /// &reason - /// ) { - /// Ok(()) => { - /// println!("Market extended by 7 days"); - /// }, - /// Err(e) => { - /// println!("Failed to extend market: {:?}", e); - /// } - /// } - /// ``` - /// - /// # Extension Process - /// - /// The extension process: - /// 1. **Permission Validation**: Ensures admin has ExtendMarket permission - /// 2. **Market Validation**: Confirms market exists and is extendable - /// 3. **Duration Extension**: Uses ExtensionManager to add additional time - /// 4. **Action Logging**: Records extension with reason for audit trail - /// - /// # Extension Limits - /// - /// Extensions are subject to limits: - /// - Maximum total extension days per market - /// - Maximum single extension duration - /// - Market must be in Active state - /// - Extensions cannot exceed platform limits - /// - /// # Use Cases - /// - /// - **Low Participation**: Extend markets with insufficient voting - /// - **Technical Issues**: Extend markets affected by technical problems - /// - **Community Requests**: Extend based on legitimate community requests - /// - **External Events**: Extend when external events affect market relevance - /// - **Oracle Delays**: Extend when oracle data will be delayed - /// - /// # Transparency - /// - /// All extensions are logged with reasons and are publicly visible. - /// The extension history is maintained for each market, providing - /// full transparency of admin interventions. - /// - /// # Best Practices - /// - /// - Provide clear, specific reasons for extensions - /// - Limit extensions to necessary cases - /// - Consider community feedback before extending - /// - Document extension policies and criteria - pub fn extend_market_duration( - env: &Env, - admin: &Address, - market_id: &Symbol, - additional_days: u32, - reason: &String, - ) -> Result<(), Error> { - // Validate admin permissions - AdminAccessControl::validate_admin_for_action(env, admin, "extend_market")?; + #[test] + fn test_admin_broadcast_info() { + use soroban_sdk::testutils::Events; + use soroban_sdk::{TryFromVal, TryIntoVal, Val}; + let env = Env::default(); + env.mock_all_auths(); - // Extend market using extension manager - ExtensionManager::extend_market_duration( - env, - admin.clone(), - market_id.clone(), - additional_days, - reason.clone(), - )?; + let contract_id = env.register(crate::PredictifyHybrid, ()); + let client = crate::PredictifyHybridClient::new(&env, &contract_id); + let admin = Address::generate(&env); + let attacker = Address::generate(&env); - // Log admin action - let mut params = Map::new(env); - params.set( - String::from_str(env, "market_id"), - String::from_str(env, "market_id"), - ); - params.set( - String::from_str(env, "additional_days"), - String::from_str(env, "additional_days"), + client.initialize(&admin, &None, &None); + + // Attacker trying to broadcast should fail (Unauthorized) + let hash = soroban_sdk::BytesN::from_array(&env, &[1; 32]); + let reason = String::from_str(&env, "Attacker broadcast"); + let result = client.try_admin_broadcast( + &attacker, + &Severity::Info, + &hash, + &reason, ); - params.set(String::from_str(env, "reason"), reason.clone()); - AdminActionLogger::log_action( - env, - admin, - "extend_market", - Some(String::from_str(env, "market_id")), - params, - true, - None, - )?; + assert!(result.is_err()); - Ok(()) - } + // Admin broadcasting Info should succeed + let hash_info = soroban_sdk::BytesN::from_array(&env, &[10; 32]); + let reason_info = String::from_str(&env, "Info notice"); + let result_info = client.try_admin_broadcast( + &admin, + &Severity::Info, + &hash_info, + &reason_info, + ); + assert_eq!(result_info.unwrap(), Ok(())); - /// Updates the platform fee configuration (admin only). - /// - /// This function allows authorized admins to modify the fee structure - /// used throughout the platform, including platform fees, creation fees, - /// and other fee-related parameters. Changes take effect immediately. - /// + // Verify that the event was emitted. + let mut info_found = false; + for event in env.events().all().events().iter() { + let body = match &event.body { + soroban_sdk::xdr::ContractEventBody::V0(v0) => v0, + }; + if let Some(first_topic_scval) = body.topics.get(0) { + let topic: Result = first_topic_scval.clone().try_into_val(&env); + if let Ok(topic) = topic { + if topic == Symbol::new(&env, "admin_broadcast") { + if let soroban_sdk::xdr::ScVal::Map(Some(sc_map)) = &body.data { + let mut severity_opt = None; + let mut hash_opt = None; + let mut reason_opt = None; + + for entry in sc_map.iter() { + let key_res: Result = entry.key.clone().try_into_val(&env); + if let Ok(key) = key_res { + if key == Symbol::new(&env, "severity") { + let sev: Result = entry.val.clone().try_into_val(&env); + if let Ok(sev) = sev { + severity_opt = Some(sev); + } + } else if key == Symbol::new(&env, "message_hash") { + let h: Result, _> = entry.val.clone().try_into_val(&env); + if let Ok(h) = h { + hash_opt = Some(h); + } + } else if key == Symbol::new(&env, "reason") { + let r: Result = entry.val.clone().try_into_val(&env); + if let Ok(r) = r { + reason_opt = Some(r); + } + } + } + } + + if let (Some(severity), Some(hash_bytes), Some(reason_str)) = (severity_opt, hash_opt, reason_opt) { + if severity == Severity::Info && hash_bytes == hash_info && reason_str == reason_info { + info_found = true; + } + } + } + } + } + } + } + assert!(info_found, "Info event not found"); + } + + #[test] + fn test_admin_broadcast_warning() { + use soroban_sdk::testutils::Events; + use soroban_sdk::{TryFromVal, TryIntoVal, Val}; + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register(crate::PredictifyHybrid, ()); + let client = crate::PredictifyHybridClient::new(&env, &contract_id); + let admin = Address::generate(&env); + + client.initialize(&admin, &None, &None); + + // Admin broadcasting Warning should succeed + let hash_warn = soroban_sdk::BytesN::from_array(&env, &[20; 32]); + let reason_warn = String::from_str(&env, "Warning notice"); + let result_warn = client.try_admin_broadcast( + &admin, + &Severity::Warning, + &hash_warn, + &reason_warn, + ); + assert_eq!(result_warn.unwrap(), Ok(())); + + // Verify that the event was emitted. + let mut warn_found = false; + for event in env.events().all().events().iter() { + let body = match &event.body { + soroban_sdk::xdr::ContractEventBody::V0(v0) => v0, + }; + if let Some(first_topic_scval) = body.topics.get(0) { + let topic: Result = first_topic_scval.clone().try_into_val(&env); + if let Ok(topic) = topic { + if topic == Symbol::new(&env, "admin_broadcast") { + if let soroban_sdk::xdr::ScVal::Map(Some(sc_map)) = &body.data { + let mut severity_opt = None; + let mut hash_opt = None; + let mut reason_opt = None; + + for entry in sc_map.iter() { + let key_res: Result = entry.key.clone().try_into_val(&env); + if let Ok(key) = key_res { + if key == Symbol::new(&env, "severity") { + let sev: Result = entry.val.clone().try_into_val(&env); + if let Ok(sev) = sev { + severity_opt = Some(sev); + } + } else if key == Symbol::new(&env, "message_hash") { + let h: Result, _> = entry.val.clone().try_into_val(&env); + if let Ok(h) = h { + hash_opt = Some(h); + } + } else if key == Symbol::new(&env, "reason") { + let r: Result = entry.val.clone().try_into_val(&env); + if let Ok(r) = r { + reason_opt = Some(r); + } + } + } + } + + if let (Some(severity), Some(hash_bytes), Some(reason_str)) = (severity_opt, hash_opt, reason_opt) { + if severity == Severity::Warning && hash_bytes == hash_warn && reason_str == reason_warn { + warn_found = true; + } + } + } + } + } + } + } + assert!(warn_found, "Warning event not found"); + } + + #[test] + fn test_admin_broadcast_critical() { + use soroban_sdk::testutils::Events; + use soroban_sdk::{TryFromVal, TryIntoVal, Val}; + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register(crate::PredictifyHybrid, ()); + let client = crate::PredictifyHybridClient::new(&env, &contract_id); + let admin = Address::generate(&env); + + client.initialize(&admin, &None, &None); + + // Admin broadcasting Critical should succeed + let hash_crit = soroban_sdk::BytesN::from_array(&env, &[30; 32]); + let reason_crit = String::from_str(&env, "Critical notice"); + let result_crit = client.try_admin_broadcast( + &admin, + &Severity::Critical, + &hash_crit, + &reason_crit, + ); + assert_eq!(result_crit.unwrap(), Ok(())); + + // Verify that the event was emitted. + let mut crit_found = false; + for event in env.events().all().events().iter() { + let body = match &event.body { + soroban_sdk::xdr::ContractEventBody::V0(v0) => v0, + }; + if let Some(first_topic_scval) = body.topics.get(0) { + let topic: Result = first_topic_scval.clone().try_into_val(&env); + if let Ok(topic) = topic { + if topic == Symbol::new(&env, "admin_broadcast") { + if let soroban_sdk::xdr::ScVal::Map(Some(sc_map)) = &body.data { + let mut severity_opt = None; + let mut hash_opt = None; + let mut reason_opt = None; + + for entry in sc_map.iter() { + let key_res: Result = entry.key.clone().try_into_val(&env); + if let Ok(key) = key_res { + if key == Symbol::new(&env, "severity") { + let sev: Result = entry.val.clone().try_into_val(&env); + if let Ok(sev) = sev { + severity_opt = Some(sev); + } + } else if key == Symbol::new(&env, "message_hash") { + let h: Result, _> = entry.val.clone().try_into_val(&env); + if let Ok(h) = h { + hash_opt = Some(h); + } + } else if key == Symbol::new(&env, "reason") { + let r: Result = entry.val.clone().try_into_val(&env); + if let Ok(r) = r { + reason_opt = Some(r); + } + } + } + } + + if let (Some(severity), Some(hash_bytes), Some(reason_str)) = (severity_opt, hash_opt, reason_opt) { + if severity == Severity::Critical && hash_bytes == hash_crit && reason_str == reason_crit { + crit_found = true; + } + } + } + } + } + } + } + assert!(crit_found, "Critical event not found"); + } +} + +impl AdminFunctions { + /// Closes a market before its natural end time (admin only). + /// + /// This function allows authorized admins to forcibly close a market, + /// preventing further voting and triggering the market closure process. + /// It's used for emergency situations or when markets need early termination. + /// /// # Parameters /// /// * `env` - The Soroban environment for blockchain operations - /// * `admin` - The admin address performing the update (must have UpdateFees permission) - /// * `new_config` - The new fee configuration to apply + /// * `admin` - The admin address performing the closure (must have CloseMarket permission) + /// * `market_id` - Unique identifier of the market to close /// /// # Returns /// - /// Returns `Result` where: - /// - `Ok(FeeConfig)` - Updated fee configuration - /// - `Err(Error)` - Update failed due to permissions or validation + /// Returns `Result<(), Error>` where: + /// - `Ok(())` - Market closed successfully + /// - `Err(Error)` - Closure failed due to permissions or validation /// /// # Errors /// /// This function returns specific errors: - /// - `Error::Unauthorized` - Admin lacks UpdateFees permission - /// - `Error::InvalidInput` - Fee configuration contains invalid values - /// - Fee validation errors from FeeManager + /// - `Error::Unauthorized` - Admin lacks CloseMarket permission + /// - `Error::MarketNotFound` - Market with given ID doesn't exist + /// - Authentication errors from AdminAccessControl /// - Storage operation errors /// /// # Example /// /// ```rust - /// # use soroban_sdk::{Env, Address}; + /// # use soroban_sdk::{Env, Address, Symbol}; /// # use predictify_hybrid::admin::AdminFunctions; - /// # use predictify_hybrid::fees::FeeConfig; /// # let env = Env::default(); /// # let admin = Address::generate(&env); - /// # let new_config = FeeConfig { - /// # platform_fee_percentage: 250, // 2.5% - /// # creation_fee: 1000000, // 1 XLM - /// # min_stake: 100000, // 0.1 XLM - /// # }; + /// # let market_id = Symbol::new(&env, "problematic_market"); /// - /// // Update platform fees - /// match AdminFunctions::update_fee_config(&env, &admin, &new_config) { - /// Ok(updated_config) => { - /// println!("Fees updated successfully"); - /// println!("New platform fee: {}%", updated_config.platform_fee_percentage / 100); + /// // Close a problematic market + /// match AdminFunctions::close_market(&env, &admin, &market_id) { + /// Ok(()) => { + /// println!("Market closed successfully"); /// }, /// Err(e) => { - /// println!("Failed to update fees: {:?}", e); + /// println!("Failed to close market: {:?}", e); /// } /// } /// ``` /// - /// # Fee Configuration Parameters + /// # Closure Process /// - /// The FeeConfig struct typically includes: - /// - **Platform Fee Percentage**: Fee taken from winning payouts (basis points) - /// - **Creation Fee**: Fee required to create new markets - /// - **Minimum Stake**: Minimum amount required for voting - /// - **Maximum Fee Cap**: Upper limit on total fees + /// The closure process performs these steps: + /// 1. **Permission Validation**: Ensures admin has CloseMarket permission + /// 2. **Market Validation**: Confirms market exists and can be closed + /// 3. **Market Removal**: Removes market from active storage + /// 4. **Event Emission**: Emits market closure event for monitoring + /// 5. **Action Logging**: Records the admin action for audit trails /// - /// # Update Process + /// # Use Cases /// - /// The update process: - /// 1. **Permission Validation**: Ensures admin has UpdateFees permission - /// 2. **Configuration Validation**: Validates new fee parameters - /// 3. **Fee Update**: Uses FeeManager to apply new configuration - /// 4. **Action Logging**: Records fee update for audit trail + /// - **Emergency Closure**: Close markets with problematic questions or outcomes + /// - **Policy Violations**: Close markets that violate platform policies + /// - **Technical Issues**: Close markets experiencing technical problems + /// - **Legal Compliance**: Close markets for regulatory compliance + /// - **Community Requests**: Close markets based on community feedback /// - /// # Impact and Considerations + /// # Post-Closure State /// - /// Fee updates have immediate platform-wide effects: - /// - New markets use updated creation fees - /// - Existing market resolutions use updated platform fees - /// - User interfaces should reflect new fee structure - /// - Consider gradual rollout for major fee changes + /// After closure: + /// - Market is removed from active storage + /// - No further voting is possible + /// - Existing stakes may need manual resolution + /// - Market appears as closed in historical records /// - /// # Best Practices + /// # Security /// - /// - Announce fee changes to the community in advance - /// - Test fee changes on testnet before mainnet deployment - /// - Monitor platform activity after fee changes - /// - Keep fees competitive with similar platforms - /// - Document rationale for fee changes - pub fn update_fee_config( - env: &Env, - admin: &Address, - new_config: &FeeConfig, - ) -> Result<(), Error> { + /// This is a powerful admin function that should be used carefully. + /// Only admins with CloseMarket permission can execute this function. + pub fn close_market(env: &Env, admin: &Address, market_id: &Symbol) -> Result<(), Error> { // Validate admin permissions - AdminAccessControl::validate_admin_for_action(env, admin, "update_fees")?; + AdminAccessControl::validate_admin_for_action(env, admin, "close_market")?; - // Queue fee configuration with governance time-lock - FeeManager::update_fee_config(env, admin.clone(), new_config.clone())?; + // Get market + let _market = MarketStateManager::get_market(env, market_id)?; + + // Close market + MarketStateManager::remove_market(env, market_id); + + // Emit market closed event + EventEmitter::emit_market_closed(env, market_id, admin); // Log admin action let mut params = Map::new(env); params.set( - String::from_str(env, "platform_fee"), - String::from_str(env, "platform_fee"), - ); - params.set( - String::from_str(env, "creation_fee"), - String::from_str(env, "creation_fee"), + String::from_str(env, "market_id"), + String::from_str(env, "market_id"), ); - AdminActionLogger::log_action(env, admin, "update_fees", None, params, true, None)?; - - Ok(()) - } + AdminActionLogger::log_action(env, admin, "close_market", None, params, true, None)?; - /// Apply a previously queued fee configuration update. - /// - /// Succeeds only when the ETA has been reached. Can be called by anyone - /// once the timelock expires. - pub fn apply_fee_update(env: &Env, admin: &Address) -> Result<(), Error> { - FeeManager::apply_fee_update(env, admin.clone())?; Ok(()) } - - - /// Updates the core contract configuration (admin only). + /// Finalizes a market with admin override of the resolution process. /// - /// This function allows authorized admins to modify fundamental contract - /// settings including market limits, validation thresholds, oracle timeouts, - /// and other operational parameters. Changes affect all contract operations. + /// This function allows authorized admins to directly set the final outcome + /// of a market, bypassing the normal resolution process. It's used when + /// manual intervention is required for market resolution. /// /// # Parameters /// /// * `env` - The Soroban environment for blockchain operations - /// * `admin` - The admin address performing the update (must have UpdateConfig permission) - /// * `new_config` - The new contract configuration to apply + /// * `admin` - The admin address performing the finalization (must have FinalizeMarket permission) + /// * `market_id` - Unique identifier of the market to finalize + /// * `outcome` - The final outcome to set for the market /// /// # Returns /// /// Returns `Result<(), Error>` where: - /// - `Ok(())` - Configuration updated successfully - /// - `Err(Error)` - Update failed due to permissions or validation + /// - `Ok(())` - Market finalized successfully + /// - `Err(Error)` - Finalization failed due to permissions or validation /// /// # Errors /// /// This function returns specific errors: - /// - `Error::Unauthorized` - Admin lacks UpdateConfig permission - /// - `Error::InvalidInput` - Configuration contains invalid values - /// - Configuration validation errors from ConfigManager - /// - Storage operation errors + /// - `Error::Unauthorized` - Admin lacks FinalizeMarket permission + /// - `Error::MarketNotFound` - Market with given ID doesn't exist + /// - `Error::InvalidOutcome` - Outcome doesn't match market's possible outcomes + /// - `Error::MarketResolved` - Market has already been finalized + /// - Resolution errors from MarketResolutionManager /// /// # Example /// /// ```rust - /// # use soroban_sdk::{Env, Address}; + /// # use soroban_sdk::{Env, Address, Symbol, String}; /// # use predictify_hybrid::admin::AdminFunctions; - /// # use predictify_hybrid::config::{ContractConfig, Environment}; /// # let env = Env::default(); /// # let admin = Address::generate(&env); - /// # let new_config = ContractConfig { - /// # environment: Environment::Mainnet, - /// # max_market_duration_days: 365, - /// # min_market_duration_days: 1, - /// # max_outcomes_per_market: 10, - /// # oracle_timeout_seconds: 3600, - /// # }; + /// # let market_id = Symbol::new(&env, "disputed_market"); + /// # let outcome = String::from_str(&env, "Yes"); /// - /// // Update contract configuration for mainnet - /// match AdminFunctions::update_contract_config(&env, &admin, &new_config) { + /// // Finalize a disputed market with admin decision + /// match AdminFunctions::finalize_market(&env, &admin, &market_id, &outcome) { /// Ok(()) => { - /// println!("Contract configuration updated successfully"); + /// println!("Market finalized with outcome: {}", outcome); /// }, /// Err(e) => { - /// println!("Failed to update configuration: {:?}", e); + /// println!("Failed to finalize market: {:?}", e); /// } /// } /// ``` /// - /// # Configuration Parameters - /// - /// The ContractConfig typically includes: - /// - **Environment**: Target deployment environment (Development/Testnet/Mainnet) - /// - **Market Limits**: Duration limits, outcome limits, participation limits - /// - **Validation Thresholds**: Minimum stakes, consensus requirements - /// - **Oracle Settings**: Timeout values, retry limits, fallback options - /// - **Extension Limits**: Maximum extensions per market, total extension days - /// - /// # Update Process + /// # Finalization Process /// - /// The configuration update process: - /// 1. **Permission Validation**: Ensures admin has UpdateConfig permission - /// 2. **Configuration Validation**: Validates all configuration parameters - /// 3. **Config Update**: Uses ConfigManager to store new configuration - /// 4. **Environment Detection**: Determines and logs environment type - /// 5. **Action Logging**: Records configuration change for audit trail + /// The finalization process: + /// 1. **Permission Validation**: Ensures admin has FinalizeMarket permission + /// 2. **Market Resolution**: Uses MarketResolutionManager to set final outcome + /// 3. **Event Emission**: Emits market finalization event + /// 4. **Action Logging**: Records admin action with outcome details /// - /// # Impact Assessment + /// # Use Cases /// - /// Configuration changes can have significant impacts: - /// - **Market Creation**: New limits apply to future markets - /// - **Existing Markets**: Some changes may affect active markets - /// - **Oracle Integration**: Timeout changes affect oracle reliability - /// - **User Experience**: Limits affect what users can do + /// - **Dispute Resolution**: Resolve disputed markets with admin decision + /// - **Oracle Failures**: Finalize markets when oracles fail or are unavailable + /// - **Subjective Markets**: Resolve markets requiring human judgment + /// - **Emergency Resolution**: Quick resolution in time-sensitive situations + /// - **Correction**: Correct automated resolutions that were incorrect /// - /// # Environment-Specific Considerations + /// # Post-Finalization State /// - /// Different environments have different optimal settings: - /// - **Development**: Relaxed limits for testing - /// - **Testnet**: Production-like but with test-friendly parameters - /// - **Mainnet**: Strict, secure, production-optimized settings + /// After finalization: + /// - Market state changes to Resolved + /// - Winning outcome is permanently set + /// - Users can claim winnings based on the outcome + /// - Market statistics are finalized + /// - No further changes to the market are possible /// - /// # Change Management + /// # Governance /// - /// For production deployments: - /// - Test configuration changes thoroughly - /// - Consider gradual rollout strategies - /// - Monitor system behavior after changes - /// - Have rollback procedures ready - /// - Document all configuration changes - pub fn update_contract_config( + /// Admin finalization should follow established governance procedures + /// and be transparent to the community. Consider implementing multi-signature + /// requirements for high-value market finalizations. + pub fn finalize_market( env: &Env, admin: &Address, - new_config: &ContractConfig, + market_id: &Symbol, + outcome: &String, ) -> Result<(), Error> { // Validate admin permissions - AdminAccessControl::validate_admin_for_action(env, admin, "update_config")?; + AdminAccessControl::validate_admin_for_action(env, admin, "finalize_market")?; - // Update contract configuration - ConfigManager::update_config(env, &new_config)?; - let env_name = ConfigUtils::get_environment_name(&new_config); + // Finalize market using resolution manager + // Temporarily disabled due to resolution module being disabled + // let _resolution = MarketResolutionManager::finalize_market(env, admin, market_id, outcome)?; + // For now, just emit the event and return success + + // Emit market finalized event + EventEmitter::emit_market_finalized(env, market_id, admin, outcome); + + // Log admin action let mut params = Map::new(env); - params.set(String::from_str(env, "environment"), env_name); - AdminActionLogger::log_action(env, admin, "update_config", None, params, true, None)?; + params.set( + String::from_str(env, "market_id"), + String::from_str(env, "market_id"), + ); + params.set(String::from_str(env, "outcome"), outcome.clone()); + let _ = AdminActionLogger::log_action( + env, + admin, + "finalize_market", + Some(String::from_str(env, "market_id")), + params, + true, + None, + ); Ok(()) } - /// Resets the contract configuration to default values (admin only). + /// Extends the duration of an active market (admin only). /// - /// This function allows authorized admins to restore the contract configuration - /// to its default state, effectively undoing all previous configuration changes. - /// This is useful for recovery scenarios or returning to known-good settings. + /// This function allows authorized admins to extend the voting period + /// of an active market by adding additional days to its end time. + /// Extensions require a reason for transparency and audit purposes. /// /// # Parameters /// /// * `env` - The Soroban environment for blockchain operations - /// * `admin` - The admin address performing the reset (must have ResetConfig permission) + /// * `admin` - The admin address performing the extension (must have ExtendMarket permission) + /// * `market_id` - Unique identifier of the market to extend + /// * `additional_days` - Number of additional days to add to the market duration + /// * `reason` - Explanation for why the extension is needed /// /// # Returns /// - /// Returns `Result` where: - /// - `Ok(ContractConfig)` - The default configuration that was applied - /// - `Err(Error)` - Reset failed due to permissions or system errors + /// Returns `Result<(), Error>` where: + /// - `Ok(())` - Market duration extended successfully + /// - `Err(Error)` - Extension failed due to permissions or validation /// /// # Errors /// /// This function returns specific errors: - /// - `Error::Unauthorized` - Admin lacks ResetConfig permission - /// - Configuration reset errors from ConfigManager - /// - Storage operation errors + /// - `Error::Unauthorized` - Admin lacks ExtendMarket permission + /// - `Error::MarketNotFound` - Market with given ID doesn't exist + /// - `Error::MarketClosed` - Market has already ended or been closed + /// - `Error::InvalidDuration` - Extension would exceed maximum allowed duration + /// - Extension errors from ExtensionManager /// /// # Example /// /// ```rust - /// # use soroban_sdk::{Env, Address}; + /// # use soroban_sdk::{Env, Address, Symbol, String}; /// # use predictify_hybrid::admin::AdminFunctions; /// # let env = Env::default(); /// # let admin = Address::generate(&env); + /// # let market_id = Symbol::new(&env, "active_market"); + /// # let reason = String::from_str(&env, "Low participation, extending for more votes"); /// - /// // Reset configuration to defaults after problematic changes - /// match AdminFunctions::reset_config_to_defaults(&env, &admin) { - /// Ok(default_config) => { - /// println!("Configuration reset to defaults successfully"); - /// println!("Environment: {:?}", default_config.environment); + /// // Extend market by 7 days due to low participation + /// match AdminFunctions::extend_market_duration( + /// &env, + /// &admin, + /// &market_id, + /// 7, + /// &reason + /// ) { + /// Ok(()) => { + /// println!("Market extended by 7 days"); /// }, /// Err(e) => { - /// println!("Failed to reset configuration: {:?}", e); + /// println!("Failed to extend market: {:?}", e); /// } /// } /// ``` /// - /// # Default Configuration + /// # Extension Process /// - /// The default configuration typically includes: - /// - **Environment**: Development (safest default) - /// - **Market Duration**: 1-30 days (conservative range) - /// - **Outcomes Limit**: 2-5 outcomes per market - /// - **Oracle Timeout**: 1 hour (reasonable default) - /// - **Extension Limits**: 7 days maximum extension + /// The extension process: + /// 1. **Permission Validation**: Ensures admin has ExtendMarket permission + /// 2. **Market Validation**: Confirms market exists and is extendable + /// 3. **Duration Extension**: Uses ExtensionManager to add additional time + /// 4. **Action Logging**: Records extension with reason for audit trail /// - /// # Reset Process + /// # Extension Limits /// - /// The reset process: - /// 1. **Permission Validation**: Ensures admin has ResetConfig permission - /// 2. **Default Retrieval**: Gets default configuration from ConfigManager - /// 3. **Configuration Reset**: Applies default configuration - /// 4. **Action Logging**: Records reset action for audit trail - /// 5. **Return Defaults**: Returns the applied default configuration + /// Extensions are subject to limits: + /// - Maximum total extension days per market + /// - Maximum single extension duration + /// - Market must be in Active state + /// - Extensions cannot exceed platform limits /// /// # Use Cases /// - /// Configuration reset is useful for: - /// - **Recovery**: Recovering from problematic configuration changes - /// - **Debugging**: Isolating issues by returning to known-good state - /// - **Maintenance**: Periodic reset to clean configuration state - /// - **Environment Migration**: Resetting before environment-specific setup - /// - **Emergency Response**: Quick restoration during incidents + /// - **Low Participation**: Extend markets with insufficient voting + /// - **Technical Issues**: Extend markets affected by technical problems + /// - **Community Requests**: Extend based on legitimate community requests + /// - **External Events**: Extend when external events affect market relevance + /// - **Oracle Delays**: Extend when oracle data will be delayed /// - /// # Impact and Considerations + /// # Transparency /// - /// Resetting configuration affects: - /// - **Active Markets**: May change behavior of ongoing markets - /// - **User Limits**: Changes what users can do immediately - /// - **Oracle Integration**: May affect oracle timeout behavior - /// - **Platform Behavior**: Returns all settings to baseline + /// All extensions are logged with reasons and are publicly visible. + /// The extension history is maintained for each market, providing + /// full transparency of admin interventions. /// /// # Best Practices /// - /// - Use reset as a last resort after other fixes fail - /// - Announce configuration resets to users - /// - Monitor system behavior after reset - /// - Document why reset was necessary - /// - Consider partial configuration fixes before full reset - /// - /// # Recovery Procedures - /// - /// After reset, you may need to: - /// - Reconfigure environment-specific settings - /// - Update fee structures if needed - /// - Verify oracle integrations work correctly - /// - Test market creation and resolution - pub fn reset_config_to_defaults(env: &Env, admin: &Address) -> Result { - // Validate admin permissions - AdminAccessControl::validate_admin_for_action(env, admin, "reset_config")?; - - // Reset configuration - let default_config = ConfigManager::reset_to_defaults(env)?; - - // Log admin action - AdminActionLogger::log_action(env, admin, "reset_config", None, Map::new(env), true, None)?; - - Ok(default_config) - } - - /// Broadcasts an emergency notice to off-chain clients by emitting an AdminBroadcast event (admin only). - pub fn admin_broadcast( + /// - Provide clear, specific reasons for extensions + /// - Limit extensions to necessary cases + /// - Consider community feedback before extending + /// - Document extension policies and criteria + pub fn extend_market_duration( env: &Env, admin: &Address, - severity: Severity, - message_hash: soroban_sdk::BytesN<32>, - reason: String, + market_id: &Symbol, + additional_days: u32, + reason: &String, ) -> Result<(), Error> { // Validate admin permissions - AdminAccessControl::validate_admin_for_action(env, admin, "admin_broadcast")?; + AdminAccessControl::validate_admin_for_action(env, admin, "extend_market")?; - // Emit broadcast event - EventEmitter::emit_admin_broadcast(env, severity, message_hash, reason.clone()); + // Extend market using extension manager + ExtensionManager::extend_market_duration( + env, + admin.clone(), + market_id.clone(), + additional_days, + reason.clone(), + )?; // Log admin action let mut params = Map::new(env); params.set( - String::from_str(env, "severity"), - String::from_str(env, match severity { - Severity::Info => "Info", - Severity::Warning => "Warning", - Severity::Critical => "Critical", - }), + String::from_str(env, "market_id"), + String::from_str(env, "market_id"), ); - params.set(String::from_str(env, "reason"), reason); - AdminActionLogger::log_action(env, admin, "admin_broadcast", None, params, true, None)?; + params.set( + String::from_str(env, "additional_days"), + String::from_str(env, "additional_days"), + ); + params.set(String::from_str(env, "reason"), reason.clone()); + AdminActionLogger::log_action( + env, + admin, + "extend_market", + Some(String::from_str(env, "market_id")), + params, + true, + None, + )?; Ok(()) } -} - -// ===== ADMIN VALIDATION ===== - -/// Administrative validation utilities for contract operations. -/// -/// The `AdminValidator` provides validation functions to ensure admin operations -/// are performed correctly and safely. These utilities validate admin addresses, -/// contract initialization state, and action parameters before execution. -/// -/// # Purpose -/// -/// This struct centralizes validation logic for: -/// - Admin address format and validity -/// - Contract initialization state checks -/// - Admin action parameter validation -/// - Input sanitization and security checks -/// -/// # Usage Pattern -/// -/// AdminValidator functions are typically called before performing admin operations -/// to ensure all preconditions are met and inputs are valid. -pub struct AdminValidator; -impl AdminValidator { - /// Validates the format and basic properties of an admin address. - /// - /// This function performs basic validation on admin addresses to ensure they - /// meet the requirements for administrative operations. Currently implements - /// a placeholder validation due to Soroban SDK limitations. - /// - /// # Parameters - /// - /// * `_env` - The Soroban environment (currently unused) - /// * `_admin` - The admin address to validate - /// - /// # Returns - /// - /// Returns `Result<(), Error>` where: - /// - `Ok(())` - Address validation passed - /// - `Err(Error)` - Address validation failed - /// - /// # Current Implementation - /// - /// The current implementation always returns `Ok(())` due to limitations - /// in the Soroban SDK that make it difficult to perform comprehensive - /// address validation. Future versions may include: - /// - Address format validation - /// - Address existence checks - /// - Blacklist validation - /// - Multi-signature validation - /// - /// # Example - /// - /// ```rust - /// # use soroban_sdk::{Env, Address}; - /// # use predictify_hybrid::admin::AdminValidator; - /// # let env = Env::default(); - /// # let admin = Address::generate(&env); - /// - /// // Validate admin address before operations - /// match AdminValidator::validate_admin_address(&env, &admin) { - /// Ok(()) => { - /// println!("Admin address is valid"); - /// // Proceed with admin operation - /// }, - /// Err(e) => { - /// println!("Invalid admin address: {:?}", e); - /// } - /// } - /// ``` - /// - /// # Future Enhancements - /// - /// When SDK capabilities improve, this function may validate: - /// - Address format compliance with Stellar standards - /// - Address existence on the network - /// - Address not in blacklist/blocklist - /// - Multi-signature threshold requirements - /// - Address activity and reputation metrics - /// - /// # Security Considerations - /// - /// While this function currently provides minimal validation, - /// it serves as a placeholder for future security enhancements. - /// Always combine with proper authentication using `require_auth()`. - pub fn validate_admin_address(_env: &Env, _admin: &Address) -> Result<(), Error> { - // For now, skip validation since we can't easily convert Address to string - // This is a limitation of the current Soroban SDK - Ok(()) - } +} - /// Validates that the contract has not been previously initialized. +impl AdminFunctions { + /// Updates the core contract configuration (admin only). /// - /// This function checks the contract's persistent storage to ensure that - /// initialization has not already occurred. This prevents double-initialization - /// which could lead to security vulnerabilities or data corruption. + /// This function allows authorized admins to modify fundamental contract + /// settings including market limits, validation thresholds, oracle timeouts, + /// and other operational parameters. Changes affect all contract operations. /// /// # Parameters /// - /// * `env` - The Soroban environment for storage access + /// * `env` - The Soroban environment for blockchain operations + /// * `admin` - The admin address performing the update (must have UpdateConfig permission) + /// * `new_config` - The new contract configuration to apply /// /// # Returns /// /// Returns `Result<(), Error>` where: - /// - `Ok(())` - Contract is not initialized (safe to initialize) - /// - `Err(Error::InvalidState)` - Contract is already initialized + /// - `Ok(())` - Configuration updated successfully + /// - `Err(Error)` - Update failed due to permissions or validation /// - /// # Validation Logic + /// # Errors /// - /// The function checks for the existence of the "Admin" key in persistent - /// storage. If this key exists, it indicates the contract has been initialized - /// with an admin, making further initialization invalid. + /// This function returns specific errors: + /// - `Error::Unauthorized` - Admin lacks UpdateConfig permission + /// - `Error::InvalidInput` - Configuration contains invalid values + /// - Configuration validation errors from ConfigManager + /// - Storage operation errors /// /// # Example /// /// ```rust - /// # use soroban_sdk::Env; - /// # use predictify_hybrid::admin::AdminValidator; - /// # let env = Env::default(); - /// - /// // Check if contract can be initialized - /// match AdminValidator::validate_contract_not_initialized(&env) { - /// Ok(()) => { - /// println!("Contract is ready for initialization"); - /// // Proceed with initialization - /// }, - /// Err(e) => { - /// println!("Contract already initialized: {:?}", e); - /// // Handle already-initialized state - /// } - /// } - /// ``` - /// - /// # Security Importance - /// - /// This validation is critical for security because: - /// - **Prevents Admin Takeover**: Stops malicious re-initialization attempts - /// - **Maintains State Integrity**: Preserves existing configuration and data - /// - **Enforces Single Initialization**: Ensures contract follows proper lifecycle - /// - **Protects Existing Users**: Prevents disruption of active markets and users - /// - /// # Integration with Initialization - /// - /// This function should be called at the beginning of any initialization - /// function before making any state changes: - /// - /// ```rust /// # use soroban_sdk::{Env, Address}; - /// # use predictify_hybrid::admin::{AdminValidator, AdminInitializer}; + /// # use predictify_hybrid::admin::AdminFunctions; + /// # use predictify_hybrid::config::{ContractConfig, Environment}; /// # let env = Env::default(); /// # let admin = Address::generate(&env); + /// # let new_config = ContractConfig { + /// # environment: Environment::Mainnet, + /// # max_market_duration_days: 365, + /// # min_market_duration_days: 1, + /// # max_outcomes_per_market: 10, + /// # oracle_timeout_seconds: 3600, + /// # }; /// - /// // Safe initialization pattern - /// AdminValidator::validate_contract_not_initialized(&env)?; - /// AdminInitializer::initialize_contract(&env, &admin)?; - /// ``` - /// - /// # Error Handling - /// - /// When this validation fails, the calling function should: - /// - Return the error immediately (don't proceed) - /// - Log the attempted double-initialization - /// - Consider it a potential security incident - /// - Provide clear error messages to legitimate callers - pub fn validate_contract_not_initialized(env: &Env) -> Result<(), Error> { - let admin_exists = env.storage().persistent().has(&Symbol::new(env, "Admin")); - - if admin_exists { - return Err(Error::InvalidState); - } - - Ok(()) - } - - /// Validates parameters for specific admin actions. - /// - /// This function performs action-specific parameter validation to ensure - /// that admin operations receive valid inputs. Each action type has its - /// own validation rules and required parameters. - /// - /// # Parameters - /// - /// * `env` - The Soroban environment for string operations - /// * `action` - The admin action being performed (e.g., "close_market") - /// * `parameters` - Map of parameter names to values for the action - /// - /// # Returns - /// - /// Returns `Result<(), Error>` where: - /// - `Ok(())` - All parameters are valid for the specified action - /// - `Err(Error::InvalidInput)` - One or more parameters are invalid - /// - /// # Supported Actions - /// - /// ## close_market - /// - **Required**: `market_id` - Non-empty market identifier - /// - /// ## finalize_market - /// - **Required**: `market_id` - Non-empty market identifier - /// - **Required**: `outcome` - Non-empty winning outcome - /// - /// ## extend_market - /// - **Required**: `market_id` - Non-empty market identifier - /// - **Required**: `additional_days` - Non-empty extension duration - /// - /// # Example - /// - /// ```rust - /// # use soroban_sdk::{Env, Map, String}; - /// # use predictify_hybrid::admin::AdminValidator; - /// # let env = Env::default(); - /// # let mut params = Map::new(&env); - /// # params.set( - /// # String::from_str(&env, "market_id"), - /// # String::from_str(&env, "market_123") - /// # ); - /// # params.set( - /// # String::from_str(&env, "outcome"), - /// # String::from_str(&env, "Yes") - /// # ); - /// - /// // Validate parameters for market finalization - /// match AdminValidator::validate_action_parameters( - /// &env, - /// "finalize_market", - /// ¶ms - /// ) { + /// // Update contract configuration for mainnet + /// match AdminFunctions::update_contract_config(&env, &admin, &new_config) { /// Ok(()) => { - /// println!("Parameters are valid for market finalization"); - /// // Proceed with finalization + /// println!("Contract configuration updated successfully"); /// }, /// Err(e) => { - /// println!("Invalid parameters: {:?}", e); + /// println!("Failed to update configuration: {:?}", e); /// } /// } /// ``` /// - /// # Validation Rules - /// - /// ### Market ID Validation - /// - Must be present in parameters - /// - Must not be empty string - /// - Should correspond to existing market (checked elsewhere) - /// - /// ### Outcome Validation (for finalize_market) - /// - Must be present in parameters - /// - Must not be empty string - /// - Should be valid outcome for the market (checked elsewhere) + /// # Configuration Parameters /// - /// ### Additional Days Validation (for extend_market) - /// - Must be present in parameters - /// - Must not be empty string - /// - Should be valid positive number (parsed elsewhere) + /// The ContractConfig typically includes: + /// - **Environment**: Target deployment environment (Development/Testnet/Mainnet) + /// - **Market Limits**: Duration limits, outcome limits, participation limits + /// - **Validation Thresholds**: Minimum stakes, consensus requirements + /// - **Oracle Settings**: Timeout values, retry limits, fallback options + /// - **Extension Limits**: Maximum extensions per market, total extension days /// - /// # Error Conditions + /// # Update Process /// - /// This function returns `Error::InvalidInput` when: - /// - Required parameters are missing from the map - /// - Required parameters have empty string values - /// - Parameter format is invalid (future enhancement) + /// The configuration update process: + /// 1. **Permission Validation**: Ensures admin has UpdateConfig permission + /// 2. **Configuration Validation**: Validates all configuration parameters + /// 3. **Config Update**: Uses ConfigManager to store new configuration + /// 4. **Environment Detection**: Determines and logs environment type + /// 5. **Action Logging**: Records configuration change for audit trail /// - /// # Integration with Action Logging + /// # Impact Assessment /// - /// This validation is typically called before logging admin actions - /// to ensure only valid actions are recorded: + /// Configuration changes can have significant impacts: + /// - **Market Creation**: New limits apply to future markets + /// - **Existing Markets**: Some changes may affect active markets + /// - **Oracle Integration**: Timeout changes affect oracle reliability + /// - **User Experience**: Limits affect what users can do /// - /// ```rust - /// # use soroban_sdk::{Env, Address, Map}; - /// # use predictify_hybrid::admin::{AdminValidator, AdminActionLogger}; - /// # let env = Env::default(); - /// # let admin = Address::generate(&env); - /// # let action = "close_market"; - /// # let params = Map::new(&env); + /// # Environment-Specific Considerations /// - /// // Validate before logging - /// AdminValidator::validate_action_parameters(&env, action, ¶ms)?; - /// AdminActionLogger::log_action(&env, &admin, action, None, params, true, None)?; - /// ``` + /// Different environments have different optimal settings: + /// - **Development**: Relaxed limits for testing + /// - **Testnet**: Production-like but with test-friendly parameters + /// - **Mainnet**: Strict, secure, production-optimized settings /// - /// # Future Enhancements + /// # Change Management /// - /// Future versions may include: - /// - Type-specific validation (numbers, dates, etc.) - /// - Cross-parameter validation rules - /// - Custom validation for new action types - /// - Parameter sanitization and normalization - /// - Advanced security checks (injection prevention) - pub fn validate_action_parameters( + /// For production deployments: + /// - Test configuration changes thoroughly + /// - Consider gradual rollout strategies + /// - Monitor system behavior after changes + /// - Have rollback procedures ready + /// - Document all configuration changes + pub fn update_contract_config( env: &Env, - action: &str, - parameters: &Map, + admin: &Address, + new_config: &ContractConfig, ) -> Result<(), Error> { - match action { - "close_market" => { - let market_id = parameters - .get(String::from_str(env, "market_id")) - .ok_or(Error::InvalidInput)?; - if market_id.is_empty() { - return Err(Error::InvalidInput); - } - } - "finalize_market" => { - let market_id = parameters - .get(String::from_str(env, "market_id")) - .ok_or(Error::InvalidInput)?; - let outcome = parameters - .get(String::from_str(env, "outcome")) - .ok_or(Error::InvalidInput)?; - if market_id.is_empty() || outcome.is_empty() { - return Err(Error::InvalidInput); - } - } - "extend_market" => { - let market_id = parameters - .get(String::from_str(env, "market_id")) - .ok_or(Error::InvalidInput)?; - let additional_days = parameters - .get(String::from_str(env, "additional_days")) - .ok_or(Error::InvalidInput)?; - if market_id.is_empty() || additional_days.is_empty() { - return Err(Error::InvalidInput); - } - } - _ => {} - } - - Ok(()) - } -} - -// ===== ADMIN ACTION LOGGING ===== + // Validate admin permissions + AdminAccessControl::validate_admin_for_action(env, admin, "update_config")?; -/// Administrative action logging and audit trail management. -/// -/// The `AdminActionLogger` provides comprehensive logging capabilities for all -/// administrative actions performed on the contract. This creates an immutable -/// audit trail for governance, compliance, and security monitoring. -/// -/// # Purpose -/// -/// This struct handles: -/// - Recording all admin actions with full context -/// - Creating audit trails for compliance -/// - Emitting events for external monitoring -/// - Providing action history retrieval -/// - Supporting forensic analysis and debugging -/// -/// # Audit Trail Components -/// -/// Each logged action includes: -/// - **Admin Identity**: Who performed the action -/// - **Action Type**: What operation was performed -/// - **Target**: What was affected (market ID, config, etc.) -/// - **Parameters**: Detailed action parameters -/// - **Timestamp**: When the action occurred -/// - **Success Status**: Whether the action succeeded -/// - **Error Details**: Failure reasons if applicable -/// -/// # Security and Compliance -/// -/// The logging system supports: -/// - Regulatory compliance requirements -/// - Security incident investigation -/// - Governance transparency -/// - Operational monitoring and alerting -pub struct AdminActionLogger; - -impl AdminActionLogger { - /// Records an administrative action in the audit trail. - /// - /// This function creates a comprehensive record of admin actions including - /// all relevant context, parameters, and outcomes. The record is stored - /// persistently and an event is emitted for external monitoring. - /// - /// # Parameters - /// - /// * `env` - The Soroban environment for storage and events - /// * `admin` - The admin address that performed the action - /// * `action` - The type of action performed (e.g., "close_market") - /// * `target` - Optional target identifier (e.g., market ID) - /// * `parameters` - Map of action parameters and their values - /// * `success` - Whether the action completed successfully - /// * `error_message` - Optional error description if action failed - /// - /// # Returns - /// - /// Returns `Result<(), Error>` where: - /// - `Ok(())` - Action logged successfully - /// - `Err(Error)` - Logging failed due to storage or event errors - /// - /// # Example - /// - /// ```rust - /// # use soroban_sdk::{Env, Address, Map, String}; - /// # use predictify_hybrid::admin::AdminActionLogger; - /// # let env = Env::default(); - /// # let admin = Address::generate(&env); - /// # let mut params = Map::new(&env); - /// # params.set( - /// # String::from_str(&env, "market_id"), - /// # String::from_str(&env, "market_123") - /// # ); - /// # params.set( - /// # String::from_str(&env, "outcome"), - /// # String::from_str(&env, "Yes") - /// # ); - /// - /// // Log successful market finalization - /// match AdminActionLogger::log_action( - /// &env, - /// &admin, - /// "finalize_market", - /// Some(String::from_str(&env, "market_123")), - /// params, - /// true, - /// None - /// ) { - /// Ok(()) => { - /// println!("Action logged successfully"); - /// }, - /// Err(e) => { - /// println!("Failed to log action: {:?}", e); - /// } - /// } - /// ``` - /// - /// # Action Types - /// - /// Common action types include: - /// - **Market Operations**: "close_market", "finalize_market", "extend_market" - /// - **Configuration**: "update_config", "update_fees", "reset_config" - /// - **Role Management**: "assign_role", "revoke_role", "update_permissions" - /// - **System Operations**: "initialize_contract", "emergency_pause" - /// - /// # Storage Strategy - /// - /// The current implementation stores actions using a simple key-value approach. - /// In production, consider: - /// - Time-based partitioning for scalability - /// - Indexed storage for efficient queries - /// - Archival strategies for long-term retention - /// - Compression for storage efficiency - /// - /// # Event Emission - /// - /// Each logged action emits an event containing: - /// - Admin address - /// - Action type - /// - Success status - /// - Timestamp (from ledger) - /// - /// External systems can subscribe to these events for: - /// - Real-time monitoring - /// - Automated alerting - /// - Integration with external audit systems - /// - Dashboard updates - /// - /// # Error Handling - /// - /// Logging failures should be handled carefully: - /// - Don't fail the main operation if logging fails - /// - Consider alternative logging mechanisms - /// - Alert on persistent logging failures - /// - Maintain operation continuity - /// - /// # Best Practices - /// - /// - Log all significant admin actions - /// - Include sufficient context for investigation - /// - Use consistent action naming conventions - /// - Sanitize sensitive parameters before logging - /// - Monitor log storage usage and implement rotation - pub fn log_action( - env: &Env, - admin: &Address, - action: &str, - target: Option, - parameters: Map, - success: bool, - error_message: Option, - ) -> Result<(), Error> { - let admin_action = AdminAction { - admin: admin.clone(), - action: String::from_str(env, action), - target, - parameters, - timestamp: env.ledger().timestamp(), - success, - error_message, - }; - - // Store action in persistent storage - let action_key = Symbol::new(env, "admin_action"); - env.storage().persistent().set(&action_key, &admin_action); - - // Emit admin action event - EventEmitter::emit_admin_action_logged(env, admin, action, &success); - - Ok(()) - } - - /// Retrieves a list of all administrative actions from the audit trail. - /// - /// This function provides access to the complete history of administrative - /// actions for audit, compliance, and analysis purposes. Currently returns - /// an empty vector due to storage iteration limitations. - /// - /// # Parameters - /// - /// * `env` - The Soroban environment for storage access - /// * `_limit` - Maximum number of actions to retrieve (currently unused) - /// - /// # Returns - /// - /// Returns `Result, Error>` where: - /// - `Ok(Vec)` - List of admin actions (currently empty) - /// - `Err(Error)` - Retrieval failed due to storage errors - /// - /// # Current Limitations - /// - /// The current implementation returns an empty vector because: - /// - Soroban SDK lacks efficient storage iteration capabilities - /// - Actions are stored individually without indexing - /// - No built-in pagination or filtering mechanisms - /// - /// # Example - /// - /// ```rust - /// # use soroban_sdk::Env; - /// # use predictify_hybrid::admin::AdminActionLogger; - /// # let env = Env::default(); - /// - /// // Retrieve recent admin actions for audit - /// match AdminActionLogger::get_admin_actions(&env, 50) { - /// Ok(actions) => { - /// println!("Found {} admin actions", actions.len()); - /// for action in actions { - /// println!("Action: {} by {:?} at {}", - /// action.action, action.admin, action.timestamp); - /// } - /// }, - /// Err(e) => { - /// println!("Failed to retrieve actions: {:?}", e); - /// } - /// } - /// ``` - /// - /// # Future Implementation - /// - /// A production implementation would include: - /// - **Indexed Storage**: Actions indexed by timestamp, admin, type - /// - **Pagination**: Efficient pagination with cursor-based navigation - /// - **Filtering**: Filter by date range, admin, action type, success status - /// - **Sorting**: Sort by timestamp, admin, or action type - /// - **Aggregation**: Summary statistics and trend analysis - /// - /// # Proposed Storage Schema - /// - /// ```rust - /// // Time-based partitioning - /// let partition_key = format!("actions_{}", timestamp / PARTITION_SIZE); - /// - /// // Admin-based indexing - /// let admin_index = format!("admin_actions_{}", admin); - /// - /// // Action type indexing - /// let type_index = format!("action_type_{}", action_type); - /// ``` - /// - /// # Use Cases - /// - /// This function supports: - /// - **Compliance Audits**: Providing complete action history - /// - **Security Analysis**: Investigating suspicious patterns - /// - **Operational Review**: Understanding admin activity patterns - /// - **Debugging**: Tracing the sequence of admin operations - /// - **Reporting**: Generating admin activity reports - /// - /// # Performance Considerations - /// - /// When implementing full functionality: - /// - Implement pagination to avoid large result sets - /// - Use appropriate caching for frequently accessed data - /// - Consider read replicas for heavy audit workloads - /// - Implement query optimization for common access patterns - pub fn get_admin_actions(env: &Env, _limit: u32) -> Result, Error> { - // For now, return empty vector since we don't have a way to iterate over storage - // In a real implementation, you would store actions in a more sophisticated way - Ok(Vec::new(env)) - } - - /// Retrieves administrative actions performed by a specific admin. - /// - /// This function provides filtered access to the audit trail, showing only - /// actions performed by a particular admin address. Useful for individual - /// admin accountability and performance analysis. - /// - /// # Parameters - /// - /// * `env` - The Soroban environment for storage access - /// * `_admin` - The admin address to filter actions for - /// * `_limit` - Maximum number of actions to retrieve (currently unused) - /// - /// # Returns - /// - /// Returns `Result, Error>` where: - /// - `Ok(Vec)` - List of actions by the specified admin (currently empty) - /// - `Err(Error)` - Retrieval failed due to storage errors - /// - /// # Current Limitations - /// - /// Similar to `get_admin_actions`, this function currently returns an empty - /// vector due to Soroban SDK storage iteration limitations. A full implementation - /// would require indexed storage and efficient filtering capabilities. - /// - /// # Example - /// - /// ```rust - /// # use soroban_sdk::{Env, Address}; - /// # use predictify_hybrid::admin::AdminActionLogger; - /// # let env = Env::default(); - /// # let admin = Address::generate(&env); - /// - /// // Get actions performed by a specific admin - /// match AdminActionLogger::get_admin_actions_for_admin(&env, &admin, 25) { - /// Ok(actions) => { - /// println!("Admin performed {} actions", actions.len()); - /// for action in actions { - /// println!("{}: {} ({})", - /// action.timestamp, - /// action.action, - /// if action.success { "Success" } else { "Failed" } - /// ); - /// } - /// }, - /// Err(e) => { - /// println!("Failed to retrieve admin actions: {:?}", e); - /// } - /// } - /// ``` - /// - /// # Use Cases - /// - /// This function is valuable for: - /// - **Individual Accountability**: Tracking specific admin's actions - /// - **Performance Review**: Analyzing admin activity and success rates - /// - **Security Investigation**: Investigating suspicious admin behavior - /// - **Training**: Reviewing new admin's learning progress - /// - **Compliance**: Demonstrating individual admin compliance - /// - /// # Future Implementation Strategy - /// - /// A production implementation would include: - /// - /// ## Indexed Storage - /// ```rust - /// // Store actions with admin-based indexing - /// let admin_key = format!("admin_{}_{}", admin, timestamp); - /// env.storage().persistent().set(&admin_key, &action); - /// - /// // Maintain admin action count - /// let count_key = format!("admin_count_{}", admin); - /// let current_count: u32 = env.storage().persistent().get(&count_key).unwrap_or(0); - /// env.storage().persistent().set(&count_key, &(current_count + 1)); - /// ``` - /// - /// ## Efficient Querying - /// - Range queries by timestamp - /// - Pagination with cursor-based navigation - /// - Filtering by action type and success status - /// - Sorting options (newest first, oldest first) - /// - /// ## Analytics Integration - /// - Success rate calculation - /// - Action frequency analysis - /// - Time-based activity patterns - /// - Comparison with other admins - /// - /// # Security Considerations - /// - /// When implementing full functionality: - /// - Ensure proper access control (admins can only see their own actions unless super admin) - /// - Sanitize sensitive information in returned data - /// - Implement rate limiting to prevent abuse - /// - Log access to audit logs for meta-auditing - /// - /// # Performance Optimization - /// - /// For high-volume environments: - /// - Implement caching for frequently accessed admin histories - /// - Use background processes for heavy analytics - /// - Consider read replicas for audit queries - /// - Implement data archival for old actions - pub fn get_admin_actions_for_admin( - env: &Env, - _admin: &Address, - _limit: u32, - ) -> Result, Error> { - // For now, return empty vector - Ok(Vec::new(env)) - } -} - -// ===== ADMIN ANALYTICS ===== - -/// Admin analytics -impl AdminAnalytics { - /// Calculate admin analytics - pub fn calculate_admin_analytics(_env: &Env) -> Result { - // For now, return default analytics since we don't store complex types - Ok(AdminAnalytics::default()) - } - - /// Get admin role distribution - pub fn get_role_distribution(env: &Env) -> Result, Error> { - // For now, return empty map - Ok(Map::new(env)) - } - - /// Get action distribution - pub fn get_action_distribution(env: &Env) -> Result, Error> { - // For now, return empty map - Ok(Map::new(env)) - } -} - -// ===== ADMIN UTILITIES ===== - -/// Admin utility functions -pub struct AdminUtils; - -impl AdminUtils { - /// Check if address is admin - pub fn is_admin(env: &Env, address: &Address) -> bool { - AdminRoleManager::get_admin_role(env, address).is_ok() - } - - /// Check if address is super admin - pub fn is_super_admin(env: &Env, address: &Address) -> bool { - match AdminRoleManager::get_admin_role(env, address) { - Ok(role) => role == AdminRole::SuperAdmin, - Err(_) => false, - } - } - - /// Get admin role name - pub fn get_role_name(role: &AdminRole) -> String { - match role { - AdminRole::SuperAdmin => String::from_str(&soroban_sdk::Env::default(), "SuperAdmin"), - AdminRole::MarketAdmin => String::from_str(&soroban_sdk::Env::default(), "MarketAdmin"), - AdminRole::ConfigAdmin => String::from_str(&soroban_sdk::Env::default(), "ConfigAdmin"), - AdminRole::FeeAdmin => String::from_str(&soroban_sdk::Env::default(), "FeeAdmin"), - AdminRole::ReadOnlyAdmin => { - String::from_str(&soroban_sdk::Env::default(), "ReadOnlyAdmin") - } - } - } - - /// Get permission name - pub fn get_permission_name(permission: &AdminPermission) -> String { - match permission { - AdminPermission::Initialize => { - String::from_str(&soroban_sdk::Env::default(), "Initialize") - } - AdminPermission::CreateMarket => { - String::from_str(&soroban_sdk::Env::default(), "CreateMarket") - } - AdminPermission::CloseMarket => { - String::from_str(&soroban_sdk::Env::default(), "CloseMarket") - } - AdminPermission::FinalizeMarket => { - String::from_str(&soroban_sdk::Env::default(), "FinalizeMarket") - } - AdminPermission::ExtendMarket => { - String::from_str(&soroban_sdk::Env::default(), "ExtendMarket") - } - AdminPermission::UpdateFees => { - String::from_str(&soroban_sdk::Env::default(), "UpdateFees") - } - AdminPermission::UpdateConfig => { - String::from_str(&soroban_sdk::Env::default(), "UpdateConfig") - } - AdminPermission::ResetConfig => { - String::from_str(&soroban_sdk::Env::default(), "ResetConfig") - } - AdminPermission::CollectFees => { - String::from_str(&soroban_sdk::Env::default(), "CollectFees") - } - AdminPermission::ManageDispute => { - String::from_str(&soroban_sdk::Env::default(), "ManageDispute") - } - AdminPermission::ViewAnalytic => { - String::from_str(&soroban_sdk::Env::default(), "ViewAnalytic") - } - AdminPermission::Emergency => { - String::from_str(&soroban_sdk::Env::default(), "Emergency") - } - AdminPermission::ConfigAdmin => { - String::from_str(&soroban_sdk::Env::default(), "ConfigAdmin") - } - } - } -} - -// ===== ADMIN HIERARCHY AND ANALYTICS ===== - -/// Admin hierarchy management for role inheritance and precedence -pub struct AdminHierarchy; - -impl AdminHierarchy { - pub fn get_role_level(role: &AdminRole) -> u8 { - match role { - AdminRole::SuperAdmin => 100, - AdminRole::MarketAdmin => 75, - AdminRole::ConfigAdmin => 75, - AdminRole::FeeAdmin => 75, - AdminRole::ReadOnlyAdmin => 25, - } - } - - pub fn can_manage_role(manager_role: &AdminRole, target_role: &AdminRole) -> bool { - if *manager_role == AdminRole::SuperAdmin { - return true; - } - let manager_level = Self::get_role_level(manager_role); - let target_level = Self::get_role_level(target_role); - manager_level > target_level - } - - /// Get all permissions for a role level - pub fn get_role_permissions(env: &Env, role: &AdminRole) -> Vec { - AdminRoleManager::get_permissions_for_role(env, role) - } - - /// Check if one role encompasses another role's permissions - pub fn role_encompasses(env: &Env, higher_role: &AdminRole, lower_role: &AdminRole) -> bool { - let higher_permissions = Self::get_role_permissions(env, higher_role); - let lower_permissions = Self::get_role_permissions(env, lower_role); - - // Check if all lower permissions are contained in higher permissions - lower_permissions - .iter() - .all(|perm| higher_permissions.contains(perm)) - } -} - -/// Enhanced admin analytics using existing AdminAnalytics structure -pub struct EnhancedAdminAnalytics; - -impl EnhancedAdminAnalytics { - pub fn get_admin_analytics(env: &Env) -> AdminAnalyticsResult { - let total_admins = Self::count_total_admins(env); - let active_admins = Self::count_active_admins(env); - let role_distribution = Self::get_role_distribution(env); - - AdminAnalyticsResult { - total_admins, - active_admins, - role_distribution, - last_updated: env.ledger().timestamp(), - } - } - - fn count_total_admins(env: &Env) -> u32 { - let multi_admin_count: u32 = env - .storage() - .persistent() - .get(&Symbol::new(env, "AdminCount")) - .unwrap_or(0); - - let original_admin_count = if AdminManager::get_original_admin(env).is_some() { - 1 - } else { - 0 - }; - multi_admin_count + original_admin_count - } - - fn count_active_admins(env: &Env) -> u32 { - // assume all admins are active - - Self::count_total_admins(env) - } - - fn get_role_distribution(env: &Env) -> Map { - let mut distribution = Map::new(env); - distribution.set(String::from_str(env, "SuperAdmin"), 0); - distribution.set(String::from_str(env, "MarketAdmin"), 0); - distribution.set(String::from_str(env, "ConfigAdmin"), 0); - distribution.set(String::from_str(env, "FeeAdmin"), 0); - distribution.set(String::from_str(env, "ReadOnlyAdmin"), 0); - - // Count original admin as SuperAdmin - if AdminManager::get_original_admin(env).is_some() { - distribution.set(String::from_str(env, "SuperAdmin"), 1); - } - - distribution - } - - /// Get detailed analytics for a specific admin - pub fn get_admin_details(env: &Env, admin: &Address) -> Option { - AdminManager::get_admin_assignment(env, admin) - } - - /// Get admin activity summary - pub fn get_admin_activity_summary(env: &Env) -> Map { - let mut summary = Map::new(env); - - let total = Self::count_total_admins(env); - let active = Self::count_active_admins(env); - - summary.set(String::from_str(env, "Total"), total); - summary.set(String::from_str(env, "Active"), active); - summary.set(String::from_str(env, "Inactive"), total - active); - - summary - } -} - -/// Integration utilities for seamless migration -pub struct AdminSystemIntegration; - -impl AdminSystemIntegration { - pub fn migrate_to_multi_admin(env: &Env) -> Result<(), Error> { - if let Some(original_admin) = AdminManager::get_original_admin(env) { - let assignment = AdminRoleAssignment { - admin: original_admin.clone(), - role: AdminRole::SuperAdmin, - assigned_by: original_admin.clone(), - assigned_at: env.ledger().timestamp(), - permissions: AdminRoleManager::get_permissions_for_role( - env, - &AdminRole::SuperAdmin, - ), - is_active: true, - }; - - let admin_key = AdminManager::get_admin_key(env, &original_admin); - env.storage().persistent().set(&admin_key, &assignment); - let mut admin_list = Vec::new(env); - admin_list.push_back(original_admin.clone()); - env.storage() - .persistent() - .set(&Symbol::new(env, "AdminList"), &admin_list); - env.storage() - .persistent() - .set(&Symbol::new(env, "AdminCount"), &1u32); - env.storage() - .persistent() - .set(&Symbol::new(env, "MultiAdminMigrated"), &true); - - EventEmitter::emit_admin_action_logged( - env, - &original_admin, - "MigratedToMultiAdmin", - &true, - ); - } - Ok(()) - } - - pub fn is_migrated(env: &Env) -> bool { - env.storage() - .persistent() - .get(&Symbol::new(env, "MultiAdminMigrated")) - .unwrap_or(false) - } - - pub fn validate_admin_unified( - env: &Env, - admin: &Address, - permission: AdminPermission, - ) -> Result<(), Error> { - if Self::is_migrated(env) { - return AdminManager::validate_admin_permission(env, admin, permission); - } - AdminAccessControl::validate_permission(env, admin, &permission) - } - - /// Force migration if not already migrated - pub fn ensure_migration(env: &Env) -> Result<(), Error> { - if !Self::is_migrated(env) { - Self::migrate_to_multi_admin(env)?; - } - Ok(()) - } - - /// Get migration status information - pub fn get_migration_info(env: &Env) -> Map { - let mut info = Map::new(env); - - let is_migrated = Self::is_migrated(env); - let has_original_admin = AdminManager::get_original_admin(env).is_some(); - let multi_admin_count: u32 = env - .storage() - .persistent() - .get(&Symbol::new(env, "AdminCount")) - .unwrap_or(0); - - info.set( - String::from_str(env, "migrated"), - String::from_str(env, if is_migrated { "true" } else { "false" }), - ); - info.set( - String::from_str(env, "has_original_admin"), - String::from_str(env, if has_original_admin { "true" } else { "false" }), - ); - info.set( - String::from_str(env, "multi_admin_count"), - String::from_str(env, &multi_admin_count.to_string()), - ); - - info - } -} - -// ===== ADMIN TESTING ===== - -/// Admin testing utilities -pub struct AdminTesting; - -impl AdminTesting { - /// Create test admin action - pub fn create_test_admin_action(env: &Env, admin: &Address) -> AdminAction { - AdminAction { - admin: admin.clone(), - action: String::from_str(env, "test_action"), - target: Some(String::from_str(env, "test_target")), - parameters: Map::new(env), - timestamp: env.ledger().timestamp().max(1), - success: true, - error_message: None, - } - } - - /// Create test admin role assignment - pub fn create_test_role_assignment(env: &Env, admin: &Address) -> AdminRoleAssignment { - AdminRoleAssignment { - admin: admin.clone(), - role: AdminRole::MarketAdmin, - assigned_by: admin.clone(), - assigned_at: env.ledger().timestamp(), - permissions: AdminRoleManager::get_permissions_for_role(env, &AdminRole::MarketAdmin), - is_active: true, - } - } - - /// Validate admin action structure - pub fn validate_admin_action_structure(action: &AdminAction) -> Result<(), Error> { - if action.action.len() == 0 { - return Err(Error::InvalidInput); - } - - // Note: In test environments, timestamp can be 0, so we skip this validation - // In production, you might want to add env parameter to enable this check - - Ok(()) - } - - /// Simulate admin action - pub fn simulate_admin_action(env: &Env, admin: &Address, action: &str) -> Result<(), Error> { - // Log test action - AdminActionLogger::log_action( - env, - admin, - action, - Some(String::from_str(env, "test_target")), - Map::new(env), - true, - None, - )?; - - Ok(()) - } -} - -// ===== DEFAULT IMPLEMENTATIONS ===== - -impl Default for AdminAnalytics { - fn default() -> Self { - let env = soroban_sdk::Env::default(); - Self { - total_admins: 0, - active_admins: 0, - total_actions: 0, - successful_actions: 0, - failed_actions: 0, - action_distribution: Map::new(&env), - role_distribution: Map::new(&env), - recent_actions: Vec::new(&env), - } - } -} - -// ===== MODULE TESTS ===== - -#[cfg(any())] -mod tests_disabled { - use super::*; - use soroban_sdk::testutils::Address as _; - use soroban_sdk::testutils::Events; - - #[test] - fn test_admin_initializer_initialize() { - let env = Env::default(); - let contract_id = env.register(crate::PredictifyHybrid, ()); - let admin = Address::generate(&env); - - // Test initialization - env.as_contract(&contract_id, || { - assert!(AdminInitializer::initialize(&env, &admin).is_ok()); - - // Verify admin is stored - let stored_admin: Address = env - .storage() - .persistent() - .get(&Symbol::new(&env, "Admin")) - .unwrap(); - assert_eq!(stored_admin, admin); - }); - } - - #[test] - fn test_admin_access_control_validate_permission() { - let env = Env::default(); - let contract_id = env.register(crate::PredictifyHybrid, ()); - let admin = Address::generate(&env); - - env.as_contract(&contract_id, || { - // Initialize admin - AdminInitializer::initialize(&env, &admin).unwrap(); - - // Test permission validation - assert!(AdminAccessControl::validate_permission( - &env, - &admin, - &AdminPermission::CreateMarket - ) - .is_ok()); - }); - } - - #[test] - fn test_admin_role_manager_assign_role() { - let env = Env::default(); - let contract_id = env.register(crate::PredictifyHybrid, ()); - let admin = Address::generate(&env); - let new_admin = Address::generate(&env); - - env.as_contract(&contract_id, || { - // Initialize admin - AdminInitializer::initialize(&env, &admin).unwrap(); - - // Assign role - assert!(AdminRoleManager::assign_role( - &env, - &new_admin, - AdminRole::MarketAdmin, - &admin - ) - .is_ok()); - - // Verify role assignment - let role = AdminRoleManager::get_admin_role(&env, &new_admin).unwrap(); - assert_eq!(role, AdminRole::MarketAdmin); - }); - } - - #[test] - fn test_admin_functions_close_market() { - let env = Env::default(); - let contract_id = env.register(crate::PredictifyHybrid, ()); - let admin = Address::generate(&env); - let _market_id = Symbol::new(&env, "test_market"); - - env.as_contract(&contract_id, || { - // Initialize admin - AdminInitializer::initialize(&env, &admin).unwrap(); - - // Test close market (would need a real market setup) - // For now, just test the permission mapping and validation without auth - let permission = AdminAccessControl::map_action_to_permission("close_market").unwrap(); - assert_eq!(permission, AdminPermission::CloseMarket); - - // Test that the admin has the required permission - assert!(AdminAccessControl::validate_permission(&env, &admin, &permission).is_ok()); - }); - } - - #[test] - fn test_admin_utils_is_admin() { - let env = Env::default(); - let contract_id = env.register(crate::PredictifyHybrid, ()); - let admin = Address::generate(&env); - let non_admin = Address::generate(&env); - - env.as_contract(&contract_id, || { - // Initialize admin - AdminInitializer::initialize(&env, &admin).unwrap(); - - // Test admin check - assert!(AdminUtils::is_admin(&env, &admin)); - assert!(!AdminUtils::is_admin(&env, &non_admin)); - }); - } - - #[test] - fn test_admin_testing_utilities() { - let env = Env::default(); - let admin = Address::generate(&env); - - let action = AdminTesting::create_test_admin_action(&env, &admin); - // Check the action structure manually first - assert!(action.timestamp > 0); - assert!(AdminTesting::validate_admin_action_structure(&action).is_ok()); - - let role_assignment = AdminTesting::create_test_role_assignment(&env, &admin); - assert_eq!(role_assignment.role, AdminRole::MarketAdmin); - assert!(role_assignment.is_active); - } -} - -#[cfg(any())] -mod admin_manager_tests { - use super::*; - use soroban_sdk::{testutils::Address as _, IntoVal}; - - // Helper function to get a proper storage key for admin assignments - fn get_test_admin_key(env: &Env, admin: &Address) -> soroban_sdk::Val { - // Use a tuple key: (Symbol, Address) - let prefix = Symbol::new(env, "AdminAssignment"); - (prefix, admin.clone()).into_val(env) - } - - #[test] - fn test_add_admin_success() { - let env = Env::default(); - let contract_id = env.register(crate::PredictifyHybrid, ()); - let super_admin = Address::generate(&env); - let new_admin = Address::generate(&env); - - env.as_contract(&contract_id, || { - // Initialize with super admin - AdminInitializer::initialize(&env, &super_admin).unwrap(); - - // Manually store assignment using composite key approach - let assignment = AdminRoleAssignment { - admin: new_admin.clone(), - role: AdminRole::MarketAdmin, - assigned_by: super_admin.clone(), - assigned_at: env.ledger().timestamp(), - permissions: AdminRoleManager::get_permissions_for_role( - &env, - &AdminRole::MarketAdmin, - ), - is_active: true, - }; - - let admin_key = get_test_admin_key(&env, &new_admin); - env.storage().persistent().set(&admin_key, &assignment); - - // Update count - let count_key = Symbol::new(&env, "AdminCount"); - env.storage().persistent().set(&count_key, &1u32); - - // Verify admin was added - let retrieved: AdminRoleAssignment = - env.storage().persistent().get(&admin_key).unwrap(); - assert_eq!(retrieved.admin, new_admin); - assert_eq!(retrieved.role, AdminRole::MarketAdmin); - assert_eq!(retrieved.assigned_by, super_admin); - assert!(retrieved.is_active); - }); - } - - #[test] - fn test_check_role_permissions() { - let env = Env::default(); - - // SuperAdmin should have all permissions - assert!(AdminManager::check_role_permissions( - &env, - AdminRole::SuperAdmin, - AdminPermission::CreateMarket - )); - assert!(AdminManager::check_role_permissions( - &env, - AdminRole::SuperAdmin, - AdminPermission::UpdateFees - )); - - // MarketAdmin should have market permissions but not fee permissions - assert!(AdminManager::check_role_permissions( - &env, - AdminRole::MarketAdmin, - AdminPermission::CreateMarket - )); - assert!(!AdminManager::check_role_permissions( - &env, - AdminRole::MarketAdmin, - AdminPermission::UpdateFees - )); - - // ReadOnlyAdmin should only have ViewAnalytic - assert!(AdminManager::check_role_permissions( - &env, - AdminRole::ReadOnlyAdmin, - AdminPermission::ViewAnalytic - )); - assert!(!AdminManager::check_role_permissions( - &env, - AdminRole::ReadOnlyAdmin, - AdminPermission::CreateMarket - )); - } - - #[test] - fn test_get_admin_roles() { - let env = Env::default(); - let contract_id = env.register(crate::PredictifyHybrid, ()); - let super_admin = Address::generate(&env); - - env.as_contract(&contract_id, || { - AdminInitializer::initialize(&env, &super_admin).unwrap(); - - let roles = AdminManager::get_admin_roles(&env); - - // Should contain the original super admin - assert!(roles.contains_key(super_admin.clone())); - assert_eq!( - roles.get(super_admin.clone()).unwrap(), - AdminRole::SuperAdmin - ); - }); - } - - #[test] - fn test_is_original_admin() { - let env = Env::default(); - let contract_id = env.register(crate::PredictifyHybrid, ()); - let super_admin = Address::generate(&env); - let other_admin = Address::generate(&env); - - env.as_contract(&contract_id, || { - AdminInitializer::initialize(&env, &super_admin).unwrap(); - - assert!(AdminManager::is_original_admin(&env, &super_admin)); - assert!(!AdminManager::is_original_admin(&env, &other_admin)); - }); - } - - #[test] - fn test_permission_hierarchy() { - // Test that SuperAdmin encompasses all other roles - let env = Env::default(); - - assert!(AdminHierarchy::role_encompasses( - &env, - &AdminRole::SuperAdmin, - &AdminRole::MarketAdmin - )); - assert!(AdminHierarchy::role_encompasses( - &env, - &AdminRole::SuperAdmin, - &AdminRole::ConfigAdmin - )); - assert!(AdminHierarchy::role_encompasses( - &env, - &AdminRole::SuperAdmin, - &AdminRole::FeeAdmin - )); - assert!(AdminHierarchy::role_encompasses( - &env, - &AdminRole::SuperAdmin, - &AdminRole::ReadOnlyAdmin - )); - - // Test that lower roles don't encompass SuperAdmin - assert!(!AdminHierarchy::role_encompasses( - &env, - &AdminRole::MarketAdmin, - &AdminRole::SuperAdmin - )); - } - - #[test] - fn test_role_level_hierarchy() { - // SuperAdmin should have highest level - assert_eq!(AdminHierarchy::get_role_level(&AdminRole::SuperAdmin), 100); - - // Specialized admins should have same level - assert_eq!(AdminHierarchy::get_role_level(&AdminRole::MarketAdmin), 75); - assert_eq!(AdminHierarchy::get_role_level(&AdminRole::ConfigAdmin), 75); - assert_eq!(AdminHierarchy::get_role_level(&AdminRole::FeeAdmin), 75); - - // ReadOnly should have lowest level - assert_eq!( - AdminHierarchy::get_role_level(&AdminRole::ReadOnlyAdmin), - 25 - ); - } - - #[test] - fn test_can_manage_role() { - // SuperAdmin can manage all roles - assert!(AdminHierarchy::can_manage_role( - &AdminRole::SuperAdmin, - &AdminRole::SuperAdmin - )); - assert!(AdminHierarchy::can_manage_role( - &AdminRole::SuperAdmin, - &AdminRole::MarketAdmin - )); - assert!(AdminHierarchy::can_manage_role( - &AdminRole::SuperAdmin, - &AdminRole::ReadOnlyAdmin - )); - - // MarketAdmin cannot manage other specialized admins or SuperAdmin - assert!(!AdminHierarchy::can_manage_role( - &AdminRole::MarketAdmin, - &AdminRole::SuperAdmin - )); - assert!(!AdminHierarchy::can_manage_role( - &AdminRole::MarketAdmin, - &AdminRole::ConfigAdmin - )); - - // But can manage lower roles - assert!(AdminHierarchy::can_manage_role( - &AdminRole::MarketAdmin, - &AdminRole::ReadOnlyAdmin - )); - } - - #[test] - fn test_role_distribution() { - let env = Env::default(); - let contract_id = env.register(crate::PredictifyHybrid, ()); - let super_admin = Address::generate(&env); - - env.as_contract(&contract_id, || { - AdminInitializer::initialize(&env, &super_admin).unwrap(); - - let analytics = EnhancedAdminAnalytics::get_admin_analytics(&env); - let distribution = analytics.role_distribution; - - // Should have SuperAdmin entry - let super_admin_count = distribution - .get(String::from_str(&env, "SuperAdmin")) - .unwrap(); - assert_eq!(super_admin_count, 1); - }); - } - - #[test] - fn test_migration_info() { - let env = Env::default(); - let contract_id = env.register(crate::PredictifyHybrid, ()); - let super_admin = Address::generate(&env); - - env.as_contract(&contract_id, || { - AdminInitializer::initialize(&env, &super_admin).unwrap(); - - let info = AdminSystemIntegration::get_migration_info(&env); - - // Should have migration status - assert!(info.contains_key(String::from_str(&env, "migrated"))); - assert!(info.contains_key(String::from_str(&env, "has_original_admin"))); - - // Should show not migrated initially - let migrated = info.get(String::from_str(&env, "migrated")).unwrap(); - assert_eq!(migrated, String::from_str(&env, "false")); - - // Should show has original admin - let has_original = info - .get(String::from_str(&env, "has_original_admin")) - .unwrap(); - assert_eq!(has_original, String::from_str(&env, "true")); - }); - } - - #[test] - fn test_admin_activity_summary() { - let env = Env::default(); - let contract_id = env.register(crate::PredictifyHybrid, ()); - let super_admin = Address::generate(&env); - - env.as_contract(&contract_id, || { - AdminInitializer::initialize(&env, &super_admin).unwrap(); - - let summary = EnhancedAdminAnalytics::get_admin_activity_summary(&env); - - // Should have Total, Active, and Inactive keys - assert!(summary.contains_key(String::from_str(&env, "Total"))); - assert!(summary.contains_key(String::from_str(&env, "Active"))); - assert!(summary.contains_key(String::from_str(&env, "Inactive"))); - - let total = summary.get(String::from_str(&env, "Total")).unwrap(); - }); - } - - #[test] - fn test_admin_broadcast_info() { - use soroban_sdk::testutils::Events; - use soroban_sdk::{TryFromVal, TryIntoVal, Val}; - let env = Env::default(); - env.mock_all_auths(); - - let contract_id = env.register(crate::PredictifyHybrid, ()); - let client = crate::PredictifyHybridClient::new(&env, &contract_id); - let admin = Address::generate(&env); - let attacker = Address::generate(&env); - - client.initialize(&admin, &None, &None); - - // Attacker trying to broadcast should fail (Unauthorized) - let hash = soroban_sdk::BytesN::from_array(&env, &[1; 32]); - let reason = String::from_str(&env, "Attacker broadcast"); - let result = client.try_admin_broadcast( - &attacker, - &Severity::Info, - &hash, - &reason, - ); - assert!(result.is_err()); - - // Admin broadcasting Info should succeed - let hash_info = soroban_sdk::BytesN::from_array(&env, &[10; 32]); - let reason_info = String::from_str(&env, "Info notice"); - let result_info = client.try_admin_broadcast( - &admin, - &Severity::Info, - &hash_info, - &reason_info, - ); - assert_eq!(result_info.unwrap(), Ok(())); - - // Verify that the event was emitted. - let mut info_found = false; - for event in env.events().all().events().iter() { - let body = match &event.body { - soroban_sdk::xdr::ContractEventBody::V0(v0) => v0, - }; - if let Some(first_topic_scval) = body.topics.get(0) { - let topic: Result = first_topic_scval.clone().try_into_val(&env); - if let Ok(topic) = topic { - if topic == Symbol::new(&env, "admin_broadcast") { - if let soroban_sdk::xdr::ScVal::Map(Some(sc_map)) = &body.data { - let mut severity_opt = None; - let mut hash_opt = None; - let mut reason_opt = None; - - for entry in sc_map.iter() { - let key_res: Result = entry.key.clone().try_into_val(&env); - if let Ok(key) = key_res { - if key == Symbol::new(&env, "severity") { - let sev: Result = entry.val.clone().try_into_val(&env); - if let Ok(sev) = sev { - severity_opt = Some(sev); - } - } else if key == Symbol::new(&env, "message_hash") { - let h: Result, _> = entry.val.clone().try_into_val(&env); - if let Ok(h) = h { - hash_opt = Some(h); - } - } else if key == Symbol::new(&env, "reason") { - let r: Result = entry.val.clone().try_into_val(&env); - if let Ok(r) = r { - reason_opt = Some(r); - } - } - } - } - - if let (Some(severity), Some(hash_bytes), Some(reason_str)) = (severity_opt, hash_opt, reason_opt) { - if severity == Severity::Info && hash_bytes == hash_info && reason_str == reason_info { - info_found = true; - } - } - } - } - } - } - } - assert!(info_found, "Info event not found"); - } - - #[test] - fn test_admin_broadcast_warning() { - use soroban_sdk::testutils::Events; - use soroban_sdk::{TryFromVal, TryIntoVal, Val}; - let env = Env::default(); - env.mock_all_auths(); - - let contract_id = env.register(crate::PredictifyHybrid, ()); - let client = crate::PredictifyHybridClient::new(&env, &contract_id); - let admin = Address::generate(&env); + // Update contract configuration + ConfigManager::update_config(env, &new_config)?; + let env_name = ConfigUtils::get_environment_name(&new_config); + let mut params = Map::new(env); + params.set(String::from_str(env, "environment"), env_name); + AdminActionLogger::log_action(env, admin, "update_config", None, params, true, None)?; - client.initialize(&admin, &None, &None); + Ok(()) + } - // Admin broadcasting Warning should succeed - let hash_warn = soroban_sdk::BytesN::from_array(&env, &[20; 32]); - let reason_warn = String::from_str(&env, "Warning notice"); - let result_warn = client.try_admin_broadcast( - &admin, - &Severity::Warning, - &hash_warn, - &reason_warn, - ); - assert_eq!(result_warn.unwrap(), Ok(())); + /// Resets the contract configuration to default values (admin only). + /// + /// This function allows authorized admins to restore the contract configuration + /// to its default state, effectively undoing all previous configuration changes. + /// This is useful for recovery scenarios or returning to known-good settings. + /// + /// # Parameters + /// + /// * `env` - The Soroban environment for blockchain operations + /// * `admin` - The admin address performing the reset (must have ResetConfig permission) + /// + /// # Returns + /// + /// Returns `Result` where: + /// - `Ok(ContractConfig)` - The default configuration that was applied + /// - `Err(Error)` - Reset failed due to permissions or system errors + /// + /// # Errors + /// + /// This function returns specific errors: + /// - `Error::Unauthorized` - Admin lacks ResetConfig permission + /// - Configuration reset errors from ConfigManager + /// - Storage operation errors + /// + /// # Example + /// + /// ```rust + /// # use soroban_sdk::{Env, Address}; + /// # use predictify_hybrid::admin::AdminFunctions; + /// # let env = Env::default(); + /// # let admin = Address::generate(&env); + /// + /// // Reset configuration to defaults after problematic changes + /// match AdminFunctions::reset_config_to_defaults(&env, &admin) { + /// Ok(default_config) => { + /// println!("Configuration reset to defaults successfully"); + /// println!("Environment: {:?}", default_config.environment); + /// }, + /// Err(e) => { + /// println!("Failed to reset configuration: {:?}", e); + /// } + /// } + /// ``` + /// + /// # Default Configuration + /// + /// The default configuration typically includes: + /// - **Environment**: Development (safest default) + /// - **Market Duration**: 1-30 days (conservative range) + /// - **Outcomes Limit**: 2-5 outcomes per market + /// - **Oracle Timeout**: 1 hour (reasonable default) + /// - **Extension Limits**: 7 days maximum extension + /// + /// # Reset Process + /// + /// The reset process: + /// 1. **Permission Validation**: Ensures admin has ResetConfig permission + /// 2. **Default Retrieval**: Gets default configuration from ConfigManager + /// 3. **Configuration Reset**: Applies default configuration + /// 4. **Action Logging**: Records reset action for audit trail + /// 5. **Return Defaults**: Returns the applied default configuration + /// + /// # Use Cases + /// + /// Configuration reset is useful for: + /// - **Recovery**: Recovering from problematic configuration changes + /// - **Debugging**: Isolating issues by returning to known-good state + /// - **Maintenance**: Periodic reset to clean configuration state + /// - **Environment Migration**: Resetting before environment-specific setup + /// - **Emergency Response**: Quick restoration during incidents + /// + /// # Impact and Considerations + /// + /// Resetting configuration affects: + /// - **Active Markets**: May change behavior of ongoing markets + /// - **User Limits**: Changes what users can do immediately + /// - **Oracle Integration**: May affect oracle timeout behavior + /// - **Platform Behavior**: Returns all settings to baseline + /// + /// # Best Practices + /// + /// - Use reset as a last resort after other fixes fail + /// - Announce configuration resets to users + /// - Monitor system behavior after reset + /// - Document why reset was necessary + /// - Consider partial configuration fixes before full reset + /// + /// # Recovery Procedures + /// + /// After reset, you may need to: + /// - Reconfigure environment-specific settings + /// - Update fee structures if needed + /// - Verify oracle integrations work correctly + /// - Test market creation and resolution + pub fn reset_config_to_defaults(env: &Env, admin: &Address) -> Result { + // Validate admin permissions + AdminAccessControl::validate_admin_for_action(env, admin, "reset_config")?; - // Verify that the event was emitted. - let mut warn_found = false; - for event in env.events().all().events().iter() { - let body = match &event.body { - soroban_sdk::xdr::ContractEventBody::V0(v0) => v0, - }; - if let Some(first_topic_scval) = body.topics.get(0) { - let topic: Result = first_topic_scval.clone().try_into_val(&env); - if let Ok(topic) = topic { - if topic == Symbol::new(&env, "admin_broadcast") { - if let soroban_sdk::xdr::ScVal::Map(Some(sc_map)) = &body.data { - let mut severity_opt = None; - let mut hash_opt = None; - let mut reason_opt = None; + // Reset configuration + let default_config = ConfigManager::reset_to_defaults(env)?; - for entry in sc_map.iter() { - let key_res: Result = entry.key.clone().try_into_val(&env); - if let Ok(key) = key_res { - if key == Symbol::new(&env, "severity") { - let sev: Result = entry.val.clone().try_into_val(&env); - if let Ok(sev) = sev { - severity_opt = Some(sev); - } - } else if key == Symbol::new(&env, "message_hash") { - let h: Result, _> = entry.val.clone().try_into_val(&env); - if let Ok(h) = h { - hash_opt = Some(h); - } - } else if key == Symbol::new(&env, "reason") { - let r: Result = entry.val.clone().try_into_val(&env); - if let Ok(r) = r { - reason_opt = Some(r); - } - } - } - } + // Log admin action + AdminActionLogger::log_action(env, admin, "reset_config", None, Map::new(env), true, None)?; - if let (Some(severity), Some(hash_bytes), Some(reason_str)) = (severity_opt, hash_opt, reason_opt) { - if severity == Severity::Warning && hash_bytes == hash_warn && reason_str == reason_warn { - warn_found = true; - } - } - } - } - } - } - } - assert!(warn_found, "Warning event not found"); + Ok(default_config) } - #[test] - fn test_admin_broadcast_critical() { - use soroban_sdk::testutils::Events; - use soroban_sdk::{TryFromVal, TryIntoVal, Val}; - let env = Env::default(); - env.mock_all_auths(); - - let contract_id = env.register(crate::PredictifyHybrid, ()); - let client = crate::PredictifyHybridClient::new(&env, &contract_id); - let admin = Address::generate(&env); + /// Broadcasts an emergency notice to off-chain clients by emitting an AdminBroadcast event (admin only). + pub fn admin_broadcast( + env: &Env, + admin: &Address, + severity: Severity, + message_hash: soroban_sdk::BytesN<32>, + reason: String, + ) -> Result<(), Error> { + // Validate admin permissions + AdminAccessControl::validate_admin_for_action(env, admin, "admin_broadcast")?; - client.initialize(&admin, &None, &None); + // Emit broadcast event + EventEmitter::emit_admin_broadcast(env, severity, message_hash, reason.clone()); - // Admin broadcasting Critical should succeed - let hash_crit = soroban_sdk::BytesN::from_array(&env, &[30; 32]); - let reason_crit = String::from_str(&env, "Critical notice"); - let result_crit = client.try_admin_broadcast( - &admin, - &Severity::Critical, - &hash_crit, - &reason_crit, + // Log admin action + let mut params = Map::new(env); + params.set( + String::from_str(env, "severity"), + String::from_str(env, match severity { + Severity::Info => "Info", + Severity::Warning => "Warning", + Severity::Critical => "Critical", + }), ); - assert_eq!(result_crit.unwrap(), Ok(())); - - // Verify that the event was emitted. - let mut crit_found = false; - for event in env.events().all().events().iter() { - let body = match &event.body { - soroban_sdk::xdr::ContractEventBody::V0(v0) => v0, - }; - if let Some(first_topic_scval) = body.topics.get(0) { - let topic: Result = first_topic_scval.clone().try_into_val(&env); - if let Ok(topic) = topic { - if topic == Symbol::new(&env, "admin_broadcast") { - if let soroban_sdk::xdr::ScVal::Map(Some(sc_map)) = &body.data { - let mut severity_opt = None; - let mut hash_opt = None; - let mut reason_opt = None; - - for entry in sc_map.iter() { - let key_res: Result = entry.key.clone().try_into_val(&env); - if let Ok(key) = key_res { - if key == Symbol::new(&env, "severity") { - let sev: Result = entry.val.clone().try_into_val(&env); - if let Ok(sev) = sev { - severity_opt = Some(sev); - } - } else if key == Symbol::new(&env, "message_hash") { - let h: Result, _> = entry.val.clone().try_into_val(&env); - if let Ok(h) = h { - hash_opt = Some(h); - } - } else if key == Symbol::new(&env, "reason") { - let r: Result = entry.val.clone().try_into_val(&env); - if let Ok(r) = r { - reason_opt = Some(r); - } - } - } - } + params.set(String::from_str(env, "reason"), reason); + AdminActionLogger::log_action(env, admin, "admin_broadcast", None, params, true, None)?; - if let (Some(severity), Some(hash_bytes), Some(reason_str)) = (severity_opt, hash_opt, reason_opt) { - if severity == Severity::Critical && hash_bytes == hash_crit && reason_str == reason_crit { - crit_found = true; - } - } - } - } - } - } - } - assert!(crit_found, "Critical event not found"); - } -} + Ok(()) + }} diff --git a/contracts/predictify-hybrid/src/admin/init.rs b/contracts/predictify-hybrid/src/admin/init.rs new file mode 100644 index 00000000..2f5670f4 --- /dev/null +++ b/contracts/predictify-hybrid/src/admin/init.rs @@ -0,0 +1,601 @@ +#![allow(unused_imports)] + +use super::*; + +// ===== ADMIN INITIALIZATION ===== + +/// Admin initialization management +pub struct AdminInitializer; + +impl AdminInitializer { + /// Initializes the Predictify Hybrid contract with a primary administrator. + /// + /// This function sets up the foundational admin structure for the contract, + /// establishing the primary admin with SuperAdmin privileges and initializing + /// the admin management system. It must be called once after contract deployment. + /// + /// # Parameters + /// + /// * `env` - The Soroban environment for blockchain operations + /// * `admin` - The address to be granted SuperAdmin privileges + /// + /// # Returns + /// + /// Returns `Result<(), Error>` where: + /// - `Ok(())` - Admin initialization completed successfully + /// - `Err(Error)` - Specific error if initialization fails + /// + /// # Errors + /// + /// This function returns specific errors: + /// - `Error::InvalidAddress` - Admin address is invalid or zero + /// - `Error::AlreadyInitialized` - Contract has already been initialized + /// - `Error::StorageError` - Failed to store admin data + /// - Role assignment errors from AdminRoleManager + /// + /// # Example + /// + /// ```rust + /// # use soroban_sdk::{Env, Address}; + /// # use predictify_hybrid::admin::AdminInitializer; + /// # let env = Env::default(); + /// # let admin_address = Address::generate(&env); + /// + /// match AdminInitializer::initialize(&env, &admin_address) { + /// Ok(()) => { + /// println!("Contract initialized successfully"); + /// }, + /// Err(e) => { + /// println!("Initialization failed: {:?}", e); + /// } + /// } + /// ``` + /// + /// # Initialization Process + /// + /// The initialization performs these steps: + /// 1. **Address Validation**: Ensures admin address is valid + /// 2. **Storage Setup**: Stores admin address in persistent storage + /// 3. **Role Assignment**: Grants SuperAdmin role to the admin + /// 4. **Event Emission**: Emits admin initialization event + /// 5. **Action Logging**: Records the initialization action + /// + /// # Post-Initialization State + /// + /// After successful initialization: + /// - Admin has full SuperAdmin privileges + /// - All admin permissions are available to the admin + /// - Admin management system is fully operational + /// - Contract is ready for market creation and management + /// + /// # Security + /// + /// The admin address should be carefully chosen as it will have complete + /// control over the contract. Consider using a multi-signature wallet + /// or governance contract for production deployments. + pub fn initialize(env: &Env, admin: &Address) -> Result<(), Error> { + // Check for re-initialization attempt (critical security check) + AdminValidator::validate_contract_not_initialized(env)?; + + // Validate admin address + AdminValidator::validate_admin_address(env, admin)?; + + // Store admin in persistent storage + env.storage() + .persistent() + .set(&Symbol::new(env, "Admin"), admin); + + // Set default admin role + AdminRoleManager::assign_role(env, admin, AdminRole::SuperAdmin, admin)?; + + // Emit admin initialization event + EventEmitter::emit_admin_initialized(env, admin); + + // Log admin action + AdminActionLogger::log_action(env, admin, "initialize", None, Map::new(env), true, None)?; + + AuditTrailManager::append_record( + env, + AuditAction::ContractInitialized, + admin.clone(), + Map::new(env), + None, + ); + + Ok(()) + } + + /// Initializes the contract with admin and environment-specific configuration. + /// + /// This advanced initialization function sets up both admin privileges and + /// applies environment-specific configurations (development, testnet, mainnet). + /// It's ideal for deployment scenarios where specific configurations are needed. + /// + /// # Parameters + /// + /// * `env` - The Soroban environment for blockchain operations + /// * `admin` - The address to be granted SuperAdmin privileges + /// * `environment` - The target environment configuration to apply + /// + /// # Returns + /// + /// Returns `Result<(), Error>` where: + /// - `Ok(())` - Admin and configuration initialization completed successfully + /// - `Err(Error)` - Specific error if initialization fails + /// + /// # Errors + /// + /// This function returns errors from: + /// - `AdminInitializer::initialize()` - Basic admin initialization errors + /// - `ConfigManager::store_config()` - Configuration storage errors + /// - Event emission errors + /// + /// # Example + /// + /// ```rust + /// # use soroban_sdk::{Env, Address}; + /// # use predictify_hybrid::admin::AdminInitializer; + /// # use predictify_hybrid::config::Environment; + /// # let env = Env::default(); + /// # let admin_address = Address::generate(&env); + /// + /// // Initialize for mainnet deployment + /// match AdminInitializer::initialize_with_config( + /// &env, + /// &admin_address, + /// &Environment::Mainnet + /// ) { + /// Ok(()) => { + /// println!("Contract initialized with mainnet config"); + /// }, + /// Err(e) => { + /// println!("Initialization failed: {:?}", e); + /// } + /// } + /// ``` + /// + /// # Environment Configurations + /// + /// - **Development**: Relaxed validation, debug features enabled + /// - **Testnet**: Production-like settings with test-friendly parameters + /// - **Mainnet**: Full production settings with strict validation + /// - **Custom**: Defaults to development configuration + /// + /// # Configuration Applied + /// + /// Environment-specific settings include: + /// - Fee structures and percentages + /// - Market duration limits + /// - Validation thresholds + /// - Oracle timeout settings + /// - Dispute resolution parameters + /// + /// # Use Cases + /// + /// - **Production Deployment**: Use with `Environment::Mainnet` + /// - **Testing**: Use with `Environment::Testnet` or `Environment::Development` + /// - **CI/CD Pipelines**: Automated deployment with appropriate environment + /// - **Multi-Environment Contracts**: Same contract code, different configs + pub fn initialize_with_config( + env: &Env, + admin: &Address, + environment: &Environment, + ) -> Result<(), Error> { + // Initialize basic admin setup + AdminInitializer::initialize(env, admin)?; + + let config = match environment { + Environment::Development => ConfigManager::get_development_config(env), + Environment::Testnet => ConfigManager::get_testnet_config(env), + Environment::Mainnet => ConfigManager::get_mainnet_config(env), + Environment::Custom => ConfigManager::get_development_config(env), + }; + ConfigManager::store_config(env, &config)?; + + // Emit configuration initialization event + EventEmitter::emit_config_initialized(env, admin, environment); + + Ok(()) + } + + /// Validates parameters before contract initialization. + /// + /// This function performs pre-initialization validation to ensure the contract + /// can be safely initialized with the provided parameters. It's useful for + /// checking initialization requirements before committing to the initialization. + /// + /// # Parameters + /// + /// * `env` - The Soroban environment for blockchain operations + /// * `admin` - The proposed admin address to validate + /// + /// # Returns + /// + /// Returns `Result<(), Error>` where: + /// - `Ok(())` - All parameters are valid for initialization + /// - `Err(Error)` - Specific validation error + /// + /// # Errors + /// + /// This function returns specific validation errors: + /// - `Error::InvalidAddress` - Admin address is invalid, zero, or malformed + /// - `Error::AlreadyInitialized` - Contract has already been initialized + /// - Address format validation errors + /// + /// # Example + /// + /// ```rust + /// # use soroban_sdk::{Env, Address}; + /// # use predictify_hybrid::admin::AdminInitializer; + /// # let env = Env::default(); + /// # let proposed_admin = Address::generate(&env); + /// + /// // Validate before initialization + /// match AdminInitializer::validate_initialization_params(&env, &proposed_admin) { + /// Ok(()) => { + /// // Parameters are valid, proceed with initialization + /// AdminInitializer::initialize(&env, &proposed_admin).unwrap(); + /// }, + /// Err(e) => { + /// println!("Invalid parameters: {:?}", e); + /// } + /// } + /// ``` + /// + /// # Validation Checks + /// + /// The function performs these validations: + /// 1. **Admin Address**: Ensures address is valid and not zero + /// 2. **Contract State**: Verifies contract hasn't been initialized + /// 3. **Address Format**: Validates Stellar address format + /// 4. **Storage Access**: Ensures storage operations will succeed + /// + /// # Use Cases + /// + /// - **Pre-flight Checks**: Validate before expensive initialization + /// - **UI Validation**: Check parameters in user interfaces + /// - **Deployment Scripts**: Ensure deployment will succeed + /// - **Testing**: Validate test parameters before test execution + /// - **Error Prevention**: Catch issues before state changes + /// + /// # Best Practices + /// + /// Always call this function before `initialize()` or `initialize_with_config()` + /// to prevent failed initialization attempts that could leave the contract + /// in an inconsistent state. + pub fn validate_initialization_params(env: &Env, admin: &Address) -> Result<(), Error> { + AdminValidator::validate_admin_address(env, admin)?; + AdminValidator::validate_contract_not_initialized(env)?; + Ok(()) + } +} + +// ===== ADMIN VALIDATION ===== + +/// Administrative validation utilities for contract operations. +/// +/// The `AdminValidator` provides validation functions to ensure admin operations +/// are performed correctly and safely. These utilities validate admin addresses, +/// contract initialization state, and action parameters before execution. +/// +/// # Purpose +/// +/// This struct centralizes validation logic for: +/// - Admin address format and validity +/// - Contract initialization state checks +/// - Admin action parameter validation +/// - Input sanitization and security checks +/// +/// # Usage Pattern +/// +/// AdminValidator functions are typically called before performing admin operations +/// to ensure all preconditions are met and inputs are valid. +pub struct AdminValidator; + +impl AdminValidator { + /// Validates the format and basic properties of an admin address. + /// + /// This function performs basic validation on admin addresses to ensure they + /// meet the requirements for administrative operations. Currently implements + /// a placeholder validation due to Soroban SDK limitations. + /// + /// # Parameters + /// + /// * `_env` - The Soroban environment (currently unused) + /// * `_admin` - The admin address to validate + /// + /// # Returns + /// + /// Returns `Result<(), Error>` where: + /// - `Ok(())` - Address validation passed + /// - `Err(Error)` - Address validation failed + /// + /// # Current Implementation + /// + /// The current implementation always returns `Ok(())` due to limitations + /// in the Soroban SDK that make it difficult to perform comprehensive + /// address validation. Future versions may include: + /// - Address format validation + /// - Address existence checks + /// - Blacklist validation + /// - Multi-signature validation + /// + /// # Example + /// + /// ```rust + /// # use soroban_sdk::{Env, Address}; + /// # use predictify_hybrid::admin::AdminValidator; + /// # let env = Env::default(); + /// # let admin = Address::generate(&env); + /// + /// // Validate admin address before operations + /// match AdminValidator::validate_admin_address(&env, &admin) { + /// Ok(()) => { + /// println!("Admin address is valid"); + /// // Proceed with admin operation + /// }, + /// Err(e) => { + /// println!("Invalid admin address: {:?}", e); + /// } + /// } + /// ``` + /// + /// # Future Enhancements + /// + /// When SDK capabilities improve, this function may validate: + /// - Address format compliance with Stellar standards + /// - Address existence on the network + /// - Address not in blacklist/blocklist + /// - Multi-signature threshold requirements + /// - Address activity and reputation metrics + /// + /// # Security Considerations + /// + /// While this function currently provides minimal validation, + /// it serves as a placeholder for future security enhancements. + /// Always combine with proper authentication using `require_auth()`. + pub fn validate_admin_address(_env: &Env, _admin: &Address) -> Result<(), Error> { + // For now, skip validation since we can't easily convert Address to string + // This is a limitation of the current Soroban SDK + Ok(()) + } + + /// Validates that the contract has not been previously initialized. + /// + /// This function checks the contract's persistent storage to ensure that + /// initialization has not already occurred. This prevents double-initialization + /// which could lead to security vulnerabilities or data corruption. + /// + /// # Parameters + /// + /// * `env` - The Soroban environment for storage access + /// + /// # Returns + /// + /// Returns `Result<(), Error>` where: + /// - `Ok(())` - Contract is not initialized (safe to initialize) + /// - `Err(Error::InvalidState)` - Contract is already initialized + /// + /// # Validation Logic + /// + /// The function checks for the existence of the "Admin" key in persistent + /// storage. If this key exists, it indicates the contract has been initialized + /// with an admin, making further initialization invalid. + /// + /// # Example + /// + /// ```rust + /// # use soroban_sdk::Env; + /// # use predictify_hybrid::admin::AdminValidator; + /// # let env = Env::default(); + /// + /// // Check if contract can be initialized + /// match AdminValidator::validate_contract_not_initialized(&env) { + /// Ok(()) => { + /// println!("Contract is ready for initialization"); + /// // Proceed with initialization + /// }, + /// Err(e) => { + /// println!("Contract already initialized: {:?}", e); + /// // Handle already-initialized state + /// } + /// } + /// ``` + /// + /// # Security Importance + /// + /// This validation is critical for security because: + /// - **Prevents Admin Takeover**: Stops malicious re-initialization attempts + /// - **Maintains State Integrity**: Preserves existing configuration and data + /// - **Enforces Single Initialization**: Ensures contract follows proper lifecycle + /// - **Protects Existing Users**: Prevents disruption of active markets and users + /// + /// # Integration with Initialization + /// + /// This function should be called at the beginning of any initialization + /// function before making any state changes: + /// + /// ```rust + /// # use soroban_sdk::{Env, Address}; + /// # use predictify_hybrid::admin::{AdminValidator, AdminInitializer}; + /// # let env = Env::default(); + /// # let admin = Address::generate(&env); + /// + /// // Safe initialization pattern + /// AdminValidator::validate_contract_not_initialized(&env)?; + /// AdminInitializer::initialize_contract(&env, &admin)?; + /// ``` + /// + /// # Error Handling + /// + /// When this validation fails, the calling function should: + /// - Return the error immediately (don't proceed) + /// - Log the attempted double-initialization + /// - Consider it a potential security incident + /// - Provide clear error messages to legitimate callers + pub fn validate_contract_not_initialized(env: &Env) -> Result<(), Error> { + let admin_exists = env.storage().persistent().has(&Symbol::new(env, "Admin")); + + if admin_exists { + return Err(Error::InvalidState); + } + + Ok(()) + } + + /// Validates parameters for specific admin actions. + /// + /// This function performs action-specific parameter validation to ensure + /// that admin operations receive valid inputs. Each action type has its + /// own validation rules and required parameters. + /// + /// # Parameters + /// + /// * `env` - The Soroban environment for string operations + /// * `action` - The admin action being performed (e.g., "close_market") + /// * `parameters` - Map of parameter names to values for the action + /// + /// # Returns + /// + /// Returns `Result<(), Error>` where: + /// - `Ok(())` - All parameters are valid for the specified action + /// - `Err(Error::InvalidInput)` - One or more parameters are invalid + /// + /// # Supported Actions + /// + /// ## close_market + /// - **Required**: `market_id` - Non-empty market identifier + /// + /// ## finalize_market + /// - **Required**: `market_id` - Non-empty market identifier + /// - **Required**: `outcome` - Non-empty winning outcome + /// + /// ## extend_market + /// - **Required**: `market_id` - Non-empty market identifier + /// - **Required**: `additional_days` - Non-empty extension duration + /// + /// # Example + /// + /// ```rust + /// # use soroban_sdk::{Env, Map, String}; + /// # use predictify_hybrid::admin::AdminValidator; + /// # let env = Env::default(); + /// # let mut params = Map::new(&env); + /// # params.set( + /// # String::from_str(&env, "market_id"), + /// # String::from_str(&env, "market_123") + /// # ); + /// # params.set( + /// # String::from_str(&env, "outcome"), + /// # String::from_str(&env, "Yes") + /// # ); + /// + /// // Validate parameters for market finalization + /// match AdminValidator::validate_action_parameters( + /// &env, + /// "finalize_market", + /// ¶ms + /// ) { + /// Ok(()) => { + /// println!("Parameters are valid for market finalization"); + /// // Proceed with finalization + /// }, + /// Err(e) => { + /// println!("Invalid parameters: {:?}", e); + /// } + /// } + /// ``` + /// + /// # Validation Rules + /// + /// ### Market ID Validation + /// - Must be present in parameters + /// - Must not be empty string + /// - Should correspond to existing market (checked elsewhere) + /// + /// ### Outcome Validation (for finalize_market) + /// - Must be present in parameters + /// - Must not be empty string + /// - Should be valid outcome for the market (checked elsewhere) + /// + /// ### Additional Days Validation (for extend_market) + /// - Must be present in parameters + /// - Must not be empty string + /// - Should be valid positive number (parsed elsewhere) + /// + /// # Error Conditions + /// + /// This function returns `Error::InvalidInput` when: + /// - Required parameters are missing from the map + /// - Required parameters have empty string values + /// - Parameter format is invalid (future enhancement) + /// + /// # Integration with Action Logging + /// + /// This validation is typically called before logging admin actions + /// to ensure only valid actions are recorded: + /// + /// ```rust + /// # use soroban_sdk::{Env, Address, Map}; + /// # use predictify_hybrid::admin::{AdminValidator, AdminActionLogger}; + /// # let env = Env::default(); + /// # let admin = Address::generate(&env); + /// # let action = "close_market"; + /// # let params = Map::new(&env); + /// + /// // Validate before logging + /// AdminValidator::validate_action_parameters(&env, action, ¶ms)?; + /// AdminActionLogger::log_action(&env, &admin, action, None, params, true, None)?; + /// ``` + /// + /// # Future Enhancements + /// + /// Future versions may include: + /// - Type-specific validation (numbers, dates, etc.) + /// - Cross-parameter validation rules + /// - Custom validation for new action types + /// - Parameter sanitization and normalization + /// - Advanced security checks (injection prevention) + pub fn validate_action_parameters( + env: &Env, + action: &str, + parameters: &Map, + ) -> Result<(), Error> { + match action { + "close_market" => { + let market_id = parameters + .get(String::from_str(env, "market_id")) + .ok_or(Error::InvalidInput)?; + if market_id.is_empty() { + return Err(Error::InvalidInput); + } + } + "finalize_market" => { + let market_id = parameters + .get(String::from_str(env, "market_id")) + .ok_or(Error::InvalidInput)?; + let outcome = parameters + .get(String::from_str(env, "outcome")) + .ok_or(Error::InvalidInput)?; + if market_id.is_empty() || outcome.is_empty() { + return Err(Error::InvalidInput); + } + } + "extend_market" => { + let market_id = parameters + .get(String::from_str(env, "market_id")) + .ok_or(Error::InvalidInput)?; + let additional_days = parameters + .get(String::from_str(env, "additional_days")) + .ok_or(Error::InvalidInput)?; + if market_id.is_empty() || additional_days.is_empty() { + return Err(Error::InvalidInput); + } + } + _ => {} + } + + Ok(()) + } +} + diff --git a/contracts/predictify-hybrid/src/admin/mod.rs b/contracts/predictify-hybrid/src/admin/mod.rs new file mode 100644 index 00000000..139e3a58 --- /dev/null +++ b/contracts/predictify-hybrid/src/admin/mod.rs @@ -0,0 +1,188 @@ +extern crate alloc; +use alloc::format; +use soroban_sdk::{contracttype, Address, Env, Map, String, Symbol, Vec, panic_with_error}; +// use alloc::string::ToString; // Unused import + +use crate::config::{ConfigManager, ConfigUtils, ContractConfig, Environment}; +use crate::err::Error; +use crate::events::EventEmitter; +use crate::extensions::ExtensionManager; +use crate::fees::{FeeConfig, FeeManager}; +use crate::markets::MarketStateManager; +// use crate::resolution::MarketResolutionManager; +use crate::audit_trail::{AuditAction, AuditTrailManager}; +use alloc::string::ToString; + + +/// Admin management system for Predictify Hybrid contract +/// +/// This module provides a comprehensive admin system with: +/// - Admin initialization and setup functions +/// - Access control and permission validation +/// - Admin role management and hierarchy +/// - Admin action logging and tracking +/// - Admin helper utilities and testing functions +/// - Admin event emission and monitoring + +// ===== ADMIN TYPES ===== + +/// Admin role enumeration +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[contracttype] +pub enum AdminRole { + /// Super admin with all permissions + SuperAdmin, + /// Market admin with market management permissions + MarketAdmin, + /// Config admin with configuration permissions + ConfigAdmin, + /// Fee admin with fee management permissions + FeeAdmin, + /// Read-only admin with view permissions only + ReadOnlyAdmin, +} + +#[contracterror] +#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] +pub enum AdminError { + CooldownActive = 101, // Error code for rapid abuse prevention +} + +/// Severity level for admin broadcasts +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[contracttype] +pub enum Severity { + Info, + Warning, + Critical, +} + +// 1. Define the storage key for the last action timestamp +/// Admin permission enumeration +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[contracttype] +pub enum AdminPermission { + /// Initialize contract + Initialize, + /// Create markets + CreateMarket, + /// Close markets + CloseMarket, + /// Finalize markets + FinalizeMarket, + /// Extend market duration + ExtendMarket, + /// Update fee configuration + UpdateFees, + /// Update contract configuration + UpdateConfig, + /// Reset configuration + ResetConfig, + /// Collect fees + CollectFees, + /// Manage disputes + ManageDispute, + /// View analytics + ViewAnalytic, + /// Emergency actions + Emergency, + /// Configuration admin actions (set cooldowns, update oracle admin config) + ConfigAdmin, +} + +/// Admin action record +#[derive(Clone, Debug)] +#[contracttype] +pub struct AdminAction { + pub admin: Address, + pub action: String, + pub target: Option, + pub parameters: Map, + pub timestamp: u64, + pub success: bool, + pub error_message: Option, +} + +/// Admin role assignment +#[derive(Clone, Debug)] +#[contracttype] +pub struct AdminRoleAssignment { + pub admin: Address, + pub role: AdminRole, + pub assigned_by: Address, + pub assigned_at: u64, + pub permissions: Vec, + pub is_active: bool, +} + +/// Admin analytics +#[derive(Clone, Debug)] +#[contracttype] +pub struct AdminAnalytics { + pub total_admins: u32, + pub active_admins: u32, + pub total_actions: u32, + pub successful_actions: u32, + pub failed_actions: u32, + pub action_distribution: Map, + pub role_distribution: Map, + pub recent_actions: Vec, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[contracttype] +pub enum AdminActionType { + Added, + Removed, + RoleUpdated, + Activated, + Deactivated, +} + +/// Admin analytics result for multi-admin system +#[derive(Clone, Debug)] +#[contracttype] +pub struct AdminAnalyticsResult { + pub total_admins: u32, + pub active_admins: u32, + pub role_distribution: Map, + pub last_updated: u64, +} + +/// Multisig configuration for admin operations +#[derive(Clone, Debug)] +#[contracttype] +pub struct MultisigConfig { + pub threshold: u32, + pub total_admins: u32, + pub enabled: bool, +} + +/// Pending admin action requiring multisig approval +#[derive(Clone, Debug)] +#[contracttype] +pub struct PendingAdminAction { + pub action_id: u64, + pub action_type: String, + pub target: Address, + pub initiator: Address, + pub approvals: Vec
, + pub created_at: u64, + pub expires_at: u64, + pub executed: bool, + pub data: Map, +} + +pub struct AdminFunctions; + +pub mod fees; +pub mod governance; +pub mod init; +pub mod pause; +pub mod roles; + +pub use fees::*; +pub use governance::*; +pub use init::*; +pub use pause::*; +pub use roles::*; diff --git a/contracts/predictify-hybrid/src/admin/pause.rs b/contracts/predictify-hybrid/src/admin/pause.rs new file mode 100644 index 00000000..66400e0f --- /dev/null +++ b/contracts/predictify-hybrid/src/admin/pause.rs @@ -0,0 +1,284 @@ +#![allow(unused_imports)] + +use super::*; + +pub const LAST_ADMIN_ACTION: &str = "LAST_ADMIN_ACT"; + +// 2. Define the cooldown duration (24 hours = 86,400 seconds) +// For testing purposes, you might want to make this configurable, +// but b#009 usually implies a strict window. +pub const COOLDOWN_PERIOD: u64 = 86_400; + +/// Internal helper to enforce a wait period between critical admin actions. +/// Call this inside any function that modifies sensitive contract state. +pub fn check_and_update_cooldown(env: &Env) { + let now = env.ledger().timestamp(); + + // Get the timestamp of the last action (default to 0 if never called) + let last_action: u64 = env.storage().instance().get(&LAST_ADMIN_ACTION).unwrap_or(0); + + if last_action > 0 { + // Ensure (now - last_action) >= COOLDOWN_PERIOD + if now < last_action.checked_add(COOLDOWN_PERIOD).expect("Overflow") { + panic_with_error!(env, AdminError::CooldownActive); + } + } + + // Update the timestamp to the current time for the next action + env.storage().instance().set(&LAST_ADMIN_ACTION, &now); +} + +// --- Critical Admin Functions --- + +pub fn set_voting_parameters(env: Env, admin: Address, /* other params */) { + admin.require_auth(); + + // 3. ENFORCE COOLDOWN (The fix for #1099) + check_and_update_cooldown(&env); + + // ... rest of the logic to update parameters ... +} + +// ===== CONTRACT PAUSE AND ADMIN TRANSFER ===== + +const CONTRACT_PAUSED_KEY: &str = "ContractPaused"; + +/// Storage key holding the address staged by [`ContractPauseManager::transfer_admin`] +/// until it calls [`ContractPauseManager::accept_admin`]. +const PENDING_ADMIN_KEY: &str = "PendingAdmin"; + +/// Contract-level pause and primary admin transfer. +pub struct ContractPauseManager; + +impl ContractPauseManager { + /// Returns true if the contract is currently paused. + pub fn is_contract_paused(env: &Env) -> bool { + env.storage() + .persistent() + .get(&Symbol::new(env, CONTRACT_PAUSED_KEY)) + .unwrap_or(false) + } + + /// Pause contract operations. Caller must be the current primary admin. + pub fn pause(env: &Env, admin: &Address) -> Result<(), Error> { + AdminAccessControl::require_admin_auth(env, admin)?; + env.storage() + .persistent() + .set(&Symbol::new(env, CONTRACT_PAUSED_KEY), &true); + EventEmitter::emit_contract_paused(env, admin); + AuditTrailManager::append_record( + env, + AuditAction::ContractPaused, + admin.clone(), + Map::new(env), + None, + ); + Ok(()) + } + + /// Unpause contract operations. Caller must be the current primary admin. + pub fn unpause(env: &Env, admin: &Address) -> Result<(), Error> { + AdminAccessControl::require_admin_auth(env, admin)?; + env.storage() + .persistent() + .set(&Symbol::new(env, CONTRACT_PAUSED_KEY), &false); + EventEmitter::emit_contract_unpaused(env, admin); + AuditTrailManager::append_record( + env, + AuditAction::ContractUnpaused, + admin.clone(), + Map::new(env), + None, + ); + Ok(()) + } + + /// Require that the contract is not paused; return Error::InvalidState otherwise. + pub fn require_not_paused(env: &Env) -> Result<(), Error> { + if Self::is_contract_paused(env) { + return Err(Error::InvalidState); + } + Ok(()) + } + + /// Step 1 of 2: stage a new primary admin. + /// + /// Caller must be the current primary admin and `new_admin` must pass + /// [`AdminValidator::validate_admin_address`]. The role is **not** moved + /// here: the address is only recorded as pending, and the current admin + /// keeps full control until the pending address calls + /// [`Self::accept_admin`]. This means a typo (or an arbitrary attacker + /// address) can no longer permanently orphan the contract — the transfer + /// can be corrected by calling this function again or aborted with + /// [`Self::cancel_admin_transfer`]. + pub fn transfer_admin( + env: &Env, + current_admin: &Address, + new_admin: &Address, + ) -> Result<(), Error> { + AdminAccessControl::require_admin_auth(env, current_admin)?; + if new_admin == current_admin { + return Err(Error::InvalidInput); + } + AdminValidator::validate_admin_address(env, new_admin)?; + env.storage() + .persistent() + .set(&Symbol::new(env, PENDING_ADMIN_KEY), new_admin); + EventEmitter::emit_admin_transfer_started(env, current_admin, new_admin); + Ok(()) + } + + /// Step 2 of 2: complete a staged admin transfer. + /// + /// Must be called by the exact address staged through [`Self::transfer_admin`], + /// which proves the new admin controls the address before it takes over. + pub fn accept_admin(env: &Env, new_admin: &Address) -> Result<(), Error> { + new_admin.require_auth(); + + let pending: Address = Self::get_pending_admin(env).ok_or(Error::AdminNotSet)?; + if &pending != new_admin { + return Err(Error::Unauthorized); + } + AdminValidator::validate_admin_address(env, new_admin)?; + + let previous_admin = AdminAccessControl::get_admin(env)?; + + env.storage() + .persistent() + .set(&Symbol::new(env, "Admin"), new_admin); + env.storage() + .persistent() + .remove(&Symbol::new(env, PENDING_ADMIN_KEY)); + + EventEmitter::emit_admin_transferred(env, &previous_admin, new_admin); + AuditTrailManager::append_record( + env, + AuditAction::AdminTransferred, + new_admin.clone(), + Map::new(env), + None, + ); + Ok(()) + } + + /// Abort an in-flight admin transfer. Caller must be the current primary admin. + /// + /// After this call nobody is staged and the current admin remains in place. + pub fn cancel_admin_transfer(env: &Env, current_admin: &Address) -> Result<(), Error> { + AdminAccessControl::require_admin_auth(env, current_admin)?; + let pending: Address = Self::get_pending_admin(env).ok_or(Error::AdminNotSet)?; + env.storage() + .persistent() + .remove(&Symbol::new(env, PENDING_ADMIN_KEY)); + EventEmitter::emit_admin_transfer_canceled(env, current_admin, &pending); + Ok(()) + } + + /// Address staged by [`Self::transfer_admin`] and awaiting [`Self::accept_admin`], if any. + pub fn get_pending_admin(env: &Env) -> Option
{ + env.storage() + .persistent() + .get(&Symbol::new(env, PENDING_ADMIN_KEY)) + } +} + +/// State for oracle admin cooldowns +#[derive(Clone, Debug, Eq, PartialEq)] +#[contracttype] +pub struct OracleAdminCooldownState { + pub cooldown_seconds: u64, + pub last_action_timestamp: u64, +} + +pub struct OracleAdminCooldownManager; + +impl OracleAdminCooldownManager { + pub fn get_state(env: &Env) -> OracleAdminCooldownState { + env.storage().persistent().get(&crate::storage::DataKey::OracleAdminCooldownState) + .unwrap_or(OracleAdminCooldownState { + 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::OracleAdminCooldownState, &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_oracle_admin_cooldown_hit(env, admin, state.last_action_timestamp, state.cooldown_seconds); + return Err(Error::OracleAdminCooldownActive); + } + + state.last_action_timestamp = current_time; + env.storage().persistent().set(&crate::storage::DataKey::OracleAdminCooldownState, &state); + + Ok(()) + } +} + +/// 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(()) + } +} + diff --git a/contracts/predictify-hybrid/src/admin/roles.rs b/contracts/predictify-hybrid/src/admin/roles.rs new file mode 100644 index 00000000..cd52f5e6 --- /dev/null +++ b/contracts/predictify-hybrid/src/admin/roles.rs @@ -0,0 +1,1165 @@ +#![allow(unused_imports)] + +use super::*; + +// ===== ADMIN ACCESS CONTROL ===== + +/// Admin access control management +pub struct AdminAccessControl; + +impl AdminAccessControl { + /// Validates that an admin has the required permission for a specific action. + /// + /// This function checks if the given admin address has the necessary permission + /// to perform a specific action based on their assigned role. It's the core + /// authorization mechanism for all admin operations in the contract. + /// + /// # Parameters + /// + /// * `env` - The Soroban environment for blockchain operations + /// * `admin` - The admin address to validate permissions for + /// * `permission` - The specific permission required for the action + /// + /// # Returns + /// + /// Returns `Result<(), Error>` where: + /// - `Ok(())` - Admin has the required permission + /// - `Err(Error)` - Admin lacks permission or validation failed + /// + /// # Errors + /// + /// This function returns specific errors: + /// - `Error::Unauthorized` - Admin doesn't have the required permission + /// - `Error::Unauthorized` - Admin role not found or inactive + /// - Role retrieval errors from AdminRoleManager + /// + /// # Example + /// + /// ```rust + /// # use soroban_sdk::{Env, Address}; + /// # use predictify_hybrid::admin::{AdminAccessControl, AdminPermission}; + /// # let env = Env::default(); + /// # let admin = Address::generate(&env); + /// + /// // Check if admin can create markets + /// match AdminAccessControl::validate_permission( + /// &env, + /// &admin, + /// &AdminPermission::CreateMarket + /// ) { + /// Ok(()) => { + /// // Admin has permission, proceed with market creation + /// println!("Admin authorized for market creation"); + /// }, + /// Err(e) => { + /// println!("Permission denied: {:?}", e); + /// } + /// } + /// ``` + /// + /// # Permission Hierarchy + /// + /// Different admin roles have different permission sets: + /// - **SuperAdmin**: All permissions + /// - **MarketAdmin**: Market-related permissions + /// - **ConfigAdmin**: Configuration permissions + /// - **FeeAdmin**: Fee management permissions + /// - **ReadOnlyAdmin**: View-only permissions + /// + /// # Use Cases + /// + /// - **Function Guards**: Validate permissions before executing admin functions + /// - **UI Authorization**: Show/hide UI elements based on permissions + /// - **API Endpoints**: Authorize admin API calls + /// - **Batch Operations**: Validate permissions for multiple operations + /// - **Audit Trails**: Log permission checks for security auditing + pub fn validate_permission( + env: &Env, + admin: &Address, + permission: &AdminPermission, + ) -> Result<(), Error> { + // Try new multi-admin system first if migrated + if AdminSystemIntegration::is_migrated(env) { + return AdminManager::validate_admin_permission(env, admin, *permission); + } + + // Fall back to existing logic + let role = AdminRoleManager::get_admin_role(env, admin)?; + + // Check if admin has the required permission + if !AdminRoleManager::has_permission(env, &role, permission)? { + return Err(Error::Unauthorized); + } + + Ok(()) + } + + /// Requires admin authentication and validates admin status. + /// + /// This function performs comprehensive admin authentication by verifying + /// the caller's signature and confirming they are a registered admin. + /// It's the fundamental authentication check for all admin operations. + /// + /// # Parameters + /// + /// * `env` - The Soroban environment for blockchain operations + /// * `admin` - The admin address to authenticate + /// + /// # Returns + /// + /// Returns `Result<(), Error>` where: + /// - `Ok(())` - Admin is authenticated and authorized + /// - `Err(Error)` - Authentication or authorization failed + /// + /// # Errors + /// + /// This function returns specific errors: + /// - `Error::AdminNotSet` - No admin has been configured for the contract + /// - `Error::Unauthorized` - Caller is not the registered admin + /// - Authentication errors from Soroban's `require_auth()` + /// + /// # Example + /// + /// ```rust + /// # use soroban_sdk::{Env, Address}; + /// # use predictify_hybrid::admin::AdminAccessControl; + /// # let env = Env::default(); + /// # let admin = Address::generate(&env); + /// + /// // Authenticate admin before sensitive operation + /// match AdminAccessControl::require_admin_auth(&env, &admin) { + /// Ok(()) => { + /// // Admin is authenticated, proceed with operation + /// println!("Admin authenticated successfully"); + /// }, + /// Err(e) => { + /// println!("Authentication failed: {:?}", e); + /// return; + /// } + /// } + /// ``` + /// + /// # Authentication Process + /// + /// The authentication performs these checks: + /// 1. **Signature Verification**: Validates the caller's cryptographic signature + /// 2. **Admin Lookup**: Retrieves the stored admin address from contract storage + /// 3. **Address Comparison**: Ensures the caller matches the stored admin + /// 4. **Status Validation**: Confirms admin status is active + /// + /// # Security Considerations + /// + /// - Uses Soroban's built-in signature verification + /// - Prevents unauthorized access to admin functions + /// - Should be called before any admin-only operations + /// - Protects against address spoofing attacks + /// + /// # Use Cases + /// + /// - **Function Entry Points**: First check in admin functions + /// - **Batch Operations**: Authenticate once for multiple operations + /// - **API Gateways**: Validate admin API requests + /// - **Emergency Functions**: Ensure only authorized emergency actions + pub fn require_admin_auth(env: &Env, admin: &Address) -> Result<(), Error> { + // Verify admin authentication + admin.require_auth(); + + // Validate admin exists + let stored_admin: Address = env + .storage() + .persistent() + .get(&Symbol::new(env, "Admin")) + .ok_or(Error::AdminNotSet)?; + + if admin != &stored_admin { + return Err(Error::Unauthorized); + } + + Ok(()) + } + + /// Gets the primary admin address from persistent storage. + pub fn get_admin(env: &Env) -> Result { + env.storage() + .persistent() + .get(&Symbol::new(env, "Admin")) + .ok_or(Error::AdminNotSet) + } +} + +impl AdminAccessControl { + /// Validates admin authentication and permissions for a specific action. + /// + /// This comprehensive validation function combines authentication and + /// permission checking for a specific action. It's a convenience function + /// that performs complete admin validation in a single call. + /// + /// # Parameters + /// + /// * `env` - The Soroban environment for blockchain operations + /// * `admin` - The admin address to validate + /// * `action` - String identifier of the action to validate (e.g., "create_market") + /// + /// # Returns + /// + /// Returns `Result<(), Error>` where: + /// - `Ok(())` - Admin is authenticated and authorized for the action + /// - `Err(Error)` - Authentication, permission, or action mapping failed + /// + /// # Errors + /// + /// This function returns errors from: + /// - `require_admin_auth()` - Authentication failures + /// - `map_action_to_permission()` - Invalid action string + /// - `validate_permission()` - Permission validation failures + /// + /// # Example + /// + /// ```rust + /// # use soroban_sdk::{Env, Address}; + /// # use predictify_hybrid::admin::AdminAccessControl; + /// # let env = Env::default(); + /// # let admin = Address::generate(&env); + /// + /// // Validate admin for market creation + /// match AdminAccessControl::validate_admin_for_action( + /// &env, + /// &admin, + /// "create_market" + /// ) { + /// Ok(()) => { + /// // Admin is fully authorized, proceed with market creation + /// println!("Admin authorized for market creation"); + /// }, + /// Err(e) => { + /// println!("Authorization failed: {:?}", e); + /// } + /// } + /// ``` + /// + /// # Supported Actions + /// + /// Valid action strings include: + /// - `"initialize"` - Contract initialization + /// - `"create_market"` - Market creation + /// - `"close_market"` - Market closure + /// - `"finalize_market"` - Market finalization + /// - `"extend_market"` - Market duration extension + /// - `"update_fees"` - Fee configuration updates + /// - `"update_config"` - Contract configuration updates + /// - `"collect_fees"` - Fee collection + /// - `"manage_disputes"` - Dispute management + /// - `"emergency_actions"` - Emergency operations + /// + /// # Validation Process + /// + /// The function performs validation in this order: + /// 1. **Authentication**: Verifies admin signature and status + /// 2. **Action Mapping**: Maps action string to permission enum + /// 3. **Permission Check**: Validates admin has required permission + /// + /// # Use Cases + /// + /// - **Single-Call Validation**: Complete validation in one function call + /// - **Dynamic Actions**: Validate actions determined at runtime + /// - **API Endpoints**: Validate admin API calls with action strings + /// - **Middleware**: Use in middleware for automatic admin validation + pub fn validate_admin_for_action( + env: &Env, + admin: &Address, + action: &str, + ) -> Result<(), Error> { + // Require admin authentication + AdminAccessControl::require_admin_auth(env, admin)?; + + // Map action to permission + let permission = AdminAccessControl::map_action_to_permission(action)?; + + // Validate permission + AdminAccessControl::validate_permission(env, admin, &permission)?; + + Ok(()) + } + + /// Maps action string identifiers to their corresponding permission enums. + /// + /// This utility function converts human-readable action strings into + /// the corresponding AdminPermission enum values. It's used to bridge + /// string-based action identifiers with the type-safe permission system. + /// + /// # Parameters + /// + /// * `action` - String identifier of the action to map + /// + /// # Returns + /// + /// Returns `Result` where: + /// - `Ok(AdminPermission)` - Successfully mapped action to permission + /// - `Err(Error::InvalidInput)` - Action string is not recognized + /// + /// # Errors + /// + /// This function returns: + /// - `Error::InvalidInput` - Action string doesn't match any known actions + /// + /// # Example + /// + /// ```rust + /// # use predictify_hybrid::admin::{AdminAccessControl, AdminPermission}; + /// + /// // Map action string to permission + /// match AdminAccessControl::map_action_to_permission("create_market") { + /// Ok(permission) => { + /// assert_eq!(permission, AdminPermission::CreateMarket); + /// println!("Mapped to CreateMarket permission"); + /// }, + /// Err(e) => { + /// println!("Invalid action: {:?}", e); + /// } + /// } + /// + /// // Handle invalid action + /// match AdminAccessControl::map_action_to_permission("invalid_action") { + /// Ok(_) => unreachable!(), + /// Err(e) => { + /// println!("Expected error for invalid action: {:?}", e); + /// } + /// } + /// ``` + /// + /// # Action Mapping Table + /// + /// | Action String | Permission Enum | + /// |---------------|----------------| + /// | `"initialize"` | `AdminPermission::Initialize` | + /// | `"create_market"` | `AdminPermission::CreateMarket` | + /// | `"close_market"` | `AdminPermission::CloseMarket` | + /// | `"finalize_market"` | `AdminPermission::FinalizeMarket` | + /// | `"extend_market"` | `AdminPermission::ExtendMarket` | + /// | `"update_fees"` | `AdminPermission::UpdateFees` | + /// | `"update_config"` | `AdminPermission::UpdateConfig` | + /// | `"reset_config"` | `AdminPermission::ResetConfig` | + /// | `"collect_fees"` | `AdminPermission::CollectFees` | + /// | `"manage_disputes"` | `AdminPermission::ManageDispute` | + /// | `"view_analytics"` | `AdminPermission::ViewAnalytic` | + /// | `"emergency_actions"` | `AdminPermission::Emergency` | + /// + /// # Use Cases + /// + /// - **API Integration**: Convert API action parameters to permissions + /// - **Dynamic Validation**: Handle actions determined at runtime + /// - **Configuration**: Map configuration-driven actions to permissions + /// - **Testing**: Validate action-permission mappings in tests + /// - **Debugging**: Convert action strings for logging and debugging + /// + /// # Design Notes + /// + /// Action strings use snake_case convention to match Rust naming standards. + /// The mapping is case-sensitive and must match exactly. Consider adding + /// case-insensitive mapping if needed for API flexibility. + pub fn map_action_to_permission(action: &str) -> Result { + match action { + "initialize" => Ok(AdminPermission::Initialize), + "create_market" => Ok(AdminPermission::CreateMarket), + "close_market" => Ok(AdminPermission::CloseMarket), + "finalize_market" => Ok(AdminPermission::FinalizeMarket), + "extend_market" => Ok(AdminPermission::ExtendMarket), + "update_fees" => Ok(AdminPermission::UpdateFees), + "update_config" => Ok(AdminPermission::UpdateConfig), + "reset_config" => Ok(AdminPermission::ResetConfig), + "collect_fees" => Ok(AdminPermission::CollectFees), + "manage_disputes" => Ok(AdminPermission::ManageDispute), + "view_analytics" => Ok(AdminPermission::ViewAnalytic), + "emergency_actions" => Ok(AdminPermission::Emergency), + "admin_broadcast" => Ok(AdminPermission::Emergency), + _ => Err(Error::InvalidInput), + } + } +} + +// ===== ADMIN ROLE MANAGEMENT ===== + +/// Admin role management +pub struct AdminRoleManager; + +impl AdminRoleManager { + /// Assigns a specific admin role to an address with associated permissions. + /// + /// This function creates or updates admin role assignments, establishing the + /// permission hierarchy for admin operations. It supports bootstrapping the + /// first admin and subsequent role assignments by authorized admins. + /// + /// # Parameters + /// + /// * `env` - The Soroban environment for blockchain operations + /// * `admin` - The address to receive the admin role + /// * `role` - The admin role to assign (SuperAdmin, MarketAdmin, etc.) + /// * `assigned_by` - The address performing the role assignment + /// + /// # Returns + /// + /// Returns `Result<(), Error>` where: + /// - `Ok(())` - Role assigned successfully + /// - `Err(Error)` - Assignment failed due to permissions or validation + /// + /// # Errors + /// + /// This function returns specific errors: + /// - `Error::Unauthorized` - Assigner lacks Emergency permission + /// - Permission validation errors from AdminAccessControl + /// - Storage operation errors + /// + /// # Example + /// + /// ```rust + /// # use soroban_sdk::{Env, Address}; + /// # use predictify_hybrid::admin::{AdminRoleManager, AdminRole}; + /// # let env = Env::default(); + /// # let super_admin = Address::generate(&env); + /// # let new_admin = Address::generate(&env); + /// + /// // Assign MarketAdmin role to a new admin + /// match AdminRoleManager::assign_role( + /// &env, + /// &new_admin, + /// AdminRole::MarketAdmin, + /// &super_admin + /// ) { + /// Ok(()) => { + /// println!("MarketAdmin role assigned successfully"); + /// }, + /// Err(e) => { + /// println!("Role assignment failed: {:?}", e); + /// } + /// } + /// ``` + /// + /// # Role Hierarchy + /// + /// Available admin roles with their permission levels: + /// - **SuperAdmin**: All permissions, can assign other roles + /// - **MarketAdmin**: Market creation, closure, finalization, extension + /// - **ConfigAdmin**: Configuration updates and resets + /// - **FeeAdmin**: Fee configuration and collection + /// - **ReadOnlyAdmin**: View-only access to analytics + /// + /// # Assignment Process + /// + /// The assignment process: + /// 1. **Bootstrap Check**: First assignment bypasses permission validation + /// 2. **Permission Validation**: Subsequent assignments require Emergency permission + /// 3. **Role Creation**: Creates AdminRoleAssignment with timestamp and permissions + /// 4. **Storage Update**: Stores assignment in persistent storage + /// 5. **Event Emission**: Emits role assignment event for monitoring + /// + /// # Security + /// + /// Only admins with Emergency permission can assign roles to others. + /// The first admin assignment (bootstrapping) bypasses this check to enable + /// initial contract setup. + pub fn assign_role( + env: &Env, + admin: &Address, + role: AdminRole, + assigned_by: &Address, + ) -> Result<(), Error> { + // Use a simple fixed key for admin role storage + let key = Symbol::new(env, "admin_role"); + + // Check if this is the first admin role assignment (bootstrapping) + if !env.storage().persistent().has(&key) { + // No admin role assigned yet, allow bootstrapping without permission check + } else { + // Validate assigner permissions for subsequent assignments + AdminAccessControl::validate_permission(env, assigned_by, &AdminPermission::Emergency)?; + } + + // Create role assignment + let assignment = AdminRoleAssignment { + admin: admin.clone(), + role, + assigned_by: assigned_by.clone(), + assigned_at: env.ledger().timestamp(), + permissions: AdminRoleManager::get_permissions_for_role(env, &role), + is_active: true, + }; + + // Store role assignment + env.storage().persistent().set(&key, &assignment); + + // Emit role assignment event + let events_role = match role { + AdminRole::SuperAdmin => crate::events::AdminRole::Owner, + AdminRole::MarketAdmin => crate::events::AdminRole::Admin, + AdminRole::ConfigAdmin => crate::events::AdminRole::Admin, + AdminRole::FeeAdmin => crate::events::AdminRole::Admin, + AdminRole::ReadOnlyAdmin => crate::events::AdminRole::Moderator, + }; + EventEmitter::emit_admin_role_assigned(env, admin, &events_role, assigned_by); + + let action = if role == AdminRole::SuperAdmin && !env.storage().persistent().has(&key) { + AuditAction::ContractInitialized // Fallback or logic + } else { + AuditAction::AdminAdded + }; + AuditTrailManager::append_record( + env, + AuditAction::AdminRoleUpdated, + assigned_by.clone(), + Map::new(env), + None, + ); + + Ok(()) + } + + /// Retrieves the admin role assigned to a specific address. + /// + /// This function looks up the admin role for a given address, validating + /// that the admin is active and returning their assigned role. It's used + /// for permission checking and role-based access control. + /// + /// # Parameters + /// + /// * `env` - The Soroban environment for blockchain operations + /// * `admin` - The address to look up the admin role for + /// + /// # Returns + /// + /// Returns `Result` where: + /// - `Ok(AdminRole)` - The admin role assigned to the address + /// - `Err(Error)` - Admin not found, inactive, or unauthorized + /// + /// # Errors + /// + /// This function returns specific errors: + /// - `Error::Unauthorized` - No admin role assignment found + /// - `Error::Unauthorized` - Admin role assignment is inactive + /// - `Error::Unauthorized` - Address doesn't match the assigned admin + /// + /// # Example + /// + /// ```rust + /// # use soroban_sdk::{Env, Address}; + /// # use predictify_hybrid::admin::{AdminRoleManager, AdminRole}; + /// # let env = Env::default(); + /// # let admin = Address::generate(&env); + /// + /// // Get admin role for permission checking + /// match AdminRoleManager::get_admin_role(&env, &admin) { + /// Ok(AdminRole::SuperAdmin) => { + /// println!("User has SuperAdmin privileges"); + /// }, + /// Ok(AdminRole::MarketAdmin) => { + /// println!("User has MarketAdmin privileges"); + /// }, + /// Ok(role) => { + /// println!("User has {:?} privileges", role); + /// }, + /// Err(e) => { + /// println!("No admin role found: {:?}", e); + /// } + /// } + /// ``` + /// + /// # Role Validation + /// + /// The function performs these validations: + /// 1. **Assignment Lookup**: Retrieves role assignment from storage + /// 2. **Active Check**: Ensures the role assignment is active + /// 3. **Address Match**: Confirms the address matches the assignment + /// 4. **Role Return**: Returns the validated admin role + /// + /// # Use Cases + /// + /// - **Permission Checking**: Determine what actions an admin can perform + /// - **UI Authorization**: Show/hide features based on admin role + /// - **Audit Logging**: Record admin roles in action logs + /// - **Role-Based Logic**: Execute different logic based on admin role + /// - **Access Control**: Gate access to role-specific functionality + /// + /// # Performance + /// + /// This function performs a single storage lookup and is optimized for + /// frequent use in permission validation scenarios. + pub fn get_admin_role(env: &Env, admin: &Address) -> Result { + // Use a simple fixed key for admin role storage + let key = Symbol::new(env, "admin_role"); + + let assignment: AdminRoleAssignment = env + .storage() + .persistent() + .get(&key) + .ok_or(Error::Unauthorized)?; + + if !assignment.is_active { + return Err(Error::Unauthorized); + } + + // Check if the passed address matches the admin address in the assignment + if admin != &assignment.admin { + return Err(Error::Unauthorized); + } + + Ok(assignment.role) + } + + /// Checks if a specific admin role has a particular permission. + /// + /// This function determines whether an admin role includes a specific + /// permission by checking the role's permission set. It's a core component + /// of the permission validation system. + /// + /// # Parameters + /// + /// * `_env` - The Soroban environment (unused but kept for consistency) + /// * `role` - The admin role to check permissions for + /// * `permission` - The specific permission to check + /// + /// # Returns + /// + /// Returns `Result` where: + /// - `Ok(true)` - Role has the specified permission + /// - `Ok(false)` - Role does not have the specified permission + /// - `Err(Error)` - Error retrieving role permissions + /// + /// # Errors + /// + /// This function typically doesn't error but may return errors from + /// permission retrieval operations in future implementations. + /// + /// # Example + /// + /// ```rust + /// # use soroban_sdk::Env; + /// # use predictify_hybrid::admin::{AdminRoleManager, AdminRole, AdminPermission}; + /// # let env = Env::default(); + /// + /// // Check if MarketAdmin can create markets + /// let can_create = AdminRoleManager::has_permission( + /// &env, + /// &AdminRole::MarketAdmin, + /// &AdminPermission::CreateMarket + /// ).unwrap(); + /// + /// if can_create { + /// println!("MarketAdmin can create markets"); + /// } + /// + /// // Check if ReadOnlyAdmin can update fees + /// let can_update_fees = AdminRoleManager::has_permission( + /// &env, + /// &AdminRole::ReadOnlyAdmin, + /// &AdminPermission::UpdateFees + /// ).unwrap(); + /// + /// assert!(!can_update_fees); // ReadOnlyAdmin cannot update fees + /// ``` + /// + /// # Permission Matrix + /// + /// | Role | Initialize | CreateMarket | UpdateFees | UpdateConfig | Emergency | + /// |------|------------|--------------|------------|--------------|----------| + /// | SuperAdmin | ✓ | ✓ | ✓ | ✓ | ✓ | + /// | MarketAdmin | ✗ | ✓ | ✗ | ✗ | ✗ | + /// | ConfigAdmin | ✗ | ✗ | ✗ | ✓ | ✗ | + /// | FeeAdmin | ✗ | ✗ | ✓ | ✗ | ✗ | + /// | ReadOnlyAdmin | ✗ | ✗ | ✗ | ✗ | ✗ | + /// + /// # Use Cases + /// + /// - **Permission Validation**: Core permission checking logic + /// - **Role Comparison**: Compare capabilities of different roles + /// - **UI Authorization**: Determine what UI elements to show + /// - **API Gating**: Control access to API endpoints + /// - **Audit Systems**: Log permission checks for security auditing + /// + /// # Performance + /// + /// This function is highly optimized for frequent use, performing only + /// in-memory operations on the role's permission vector. + pub fn has_permission( + _env: &Env, + role: &AdminRole, + permission: &AdminPermission, + ) -> Result { + let permissions = AdminRoleManager::get_permissions_for_role(_env, role); + Ok(permissions.contains(permission)) + } + + /// Retrieves the complete set of permissions for a specific admin role. + /// + /// This function returns all permissions associated with an admin role, + /// providing the definitive permission set for role-based access control. + /// It's used internally by permission checking functions and externally + /// for role analysis and UI generation. + /// + /// # Parameters + /// + /// * `env` - The Soroban environment for creating the permission vector + /// * `role` - The admin role to get permissions for + /// + /// # Returns + /// + /// Returns `Vec` containing all permissions for the role. + /// The vector is never empty - even ReadOnlyAdmin has ViewAnalytic permission. + /// + /// # Example + /// + /// ```rust + /// # use soroban_sdk::Env; + /// # use predictify_hybrid::admin::{AdminRoleManager, AdminRole, AdminPermission}; + /// # let env = Env::default(); + /// + /// // Get all permissions for SuperAdmin + /// let super_permissions = AdminRoleManager::get_permissions_for_role( + /// &env, + /// &AdminRole::SuperAdmin + /// ); + /// + /// println!("SuperAdmin has {} permissions", super_permissions.len()); + /// assert!(super_permissions.contains(&AdminPermission::Initialize)); + /// assert!(super_permissions.contains(&AdminPermission::Emergency)); + /// + /// // Get permissions for MarketAdmin + /// let market_permissions = AdminRoleManager::get_permissions_for_role( + /// &env, + /// &AdminRole::MarketAdmin + /// ); + /// + /// assert!(market_permissions.contains(&AdminPermission::CreateMarket)); + /// assert!(!market_permissions.contains(&AdminPermission::UpdateFees)); + /// ``` + /// + /// # Permission Sets by Role + /// + /// **SuperAdmin** (12 permissions): + /// - Initialize, CreateMarket, CloseMarket, FinalizeMarket + /// - ExtendMarket, UpdateFees, UpdateConfig, ResetConfig + /// - CollectFees, ManageDispute, ViewAnalytic, Emergency + /// + /// **MarketAdmin** (5 permissions): + /// - CreateMarket, CloseMarket, FinalizeMarket, ExtendMarket, ViewAnalytic + /// + /// **ConfigAdmin** (3 permissions): + /// - UpdateConfig, ResetConfig, ViewAnalytic + /// + /// **FeeAdmin** (3 permissions): + /// - UpdateFees, CollectFees, ViewAnalytic + /// + /// **ReadOnlyAdmin** (1 permission): + /// - ViewAnalytic + /// + /// # Use Cases + /// + /// - **Role Analysis**: Understand what each role can do + /// - **UI Generation**: Create role-specific interfaces + /// - **Permission Auditing**: Review and audit role permissions + /// - **Role Comparison**: Compare capabilities between roles + /// - **Documentation**: Generate permission documentation + /// - **Testing**: Validate role permission assignments + /// + /// # Design Principles + /// + /// - **Least Privilege**: Each role has only necessary permissions + /// - **Clear Hierarchy**: SuperAdmin > Specialized Admins > ReadOnly + /// - **Separation of Concerns**: Different roles for different responsibilities + /// - **Extensibility**: Easy to add new roles and permissions + pub fn get_permissions_for_role(env: &Env, role: &AdminRole) -> Vec { + match role { + AdminRole::SuperAdmin => soroban_sdk::vec![ + env, + AdminPermission::Initialize, + AdminPermission::CreateMarket, + AdminPermission::CloseMarket, + AdminPermission::FinalizeMarket, + AdminPermission::ExtendMarket, + AdminPermission::UpdateFees, + AdminPermission::UpdateConfig, + AdminPermission::ResetConfig, + AdminPermission::CollectFees, + AdminPermission::ManageDispute, + AdminPermission::ViewAnalytic, + AdminPermission::Emergency, + ], + AdminRole::MarketAdmin => soroban_sdk::vec![ + env, + AdminPermission::CreateMarket, + AdminPermission::CloseMarket, + AdminPermission::FinalizeMarket, + AdminPermission::ExtendMarket, + AdminPermission::ViewAnalytic, + ], + AdminRole::ConfigAdmin => soroban_sdk::vec![ + env, + AdminPermission::UpdateConfig, + AdminPermission::ResetConfig, + AdminPermission::ViewAnalytic, + ], + AdminRole::FeeAdmin => soroban_sdk::vec![ + env, + AdminPermission::UpdateFees, + AdminPermission::CollectFees, + AdminPermission::ViewAnalytic, + ], + AdminRole::ReadOnlyAdmin => soroban_sdk::vec![env, AdminPermission::ViewAnalytic,], + } + } + + /// Deactivate admin role + pub fn deactivate_role( + env: &Env, + admin: &Address, + deactivated_by: &Address, + ) -> Result<(), Error> { + // Validate deactivator permissions + AdminAccessControl::validate_permission(env, deactivated_by, &AdminPermission::Emergency)?; + + // Use a simple fixed key for admin role storage + let key = Symbol::new(env, "admin_role"); + + let mut assignment: AdminRoleAssignment = env + .storage() + .persistent() + .get(&key) + .ok_or(Error::Unauthorized)?; + + assignment.is_active = false; + env.storage().persistent().set(&key, &assignment); + + // Emit role deactivation event + EventEmitter::emit_admin_role_deactivated(env, admin, deactivated_by); + + Ok(()) + } +} + +// ===== MULTI-ADMIN MANAGER ===== + +/// Multi-Admin Management System with comprehensive role-based access control +pub struct AdminManager; + +impl AdminManager { + /// Adds a new admin with specified role + pub fn add_admin( + env: &Env, + current_admin: &Address, + new_admin: &Address, + role: AdminRole, + ) -> Result<(), Error> { + // Use existing AdminRoleManager for validation + AdminAccessControl::validate_permission(env, current_admin, &AdminPermission::Emergency)?; + + // Prevent duplicate admin assignments + if Self::is_admin(env, new_admin) { + return Err(Error::InvalidState); + } + + // Generate unique key for this admin + let admin_key = Self::get_admin_key(env, new_admin); + + // Create role assignment using existing structure + let assignment = AdminRoleAssignment { + admin: new_admin.clone(), + role: role.clone(), + assigned_by: current_admin.clone(), + assigned_at: env.ledger().timestamp(), + permissions: AdminRoleManager::get_permissions_for_role(env, &role), + is_active: true, + }; + + // Store in multi-admin storage + env.storage().persistent().set(&admin_key, &assignment); + + // Track admins in a list so role enumeration stays in sync. + let list_key = Symbol::new(env, "AdminList"); + let mut admin_list: Vec
= env + .storage() + .persistent() + .get(&list_key) + .unwrap_or_else(|| Vec::new(env)); + if !admin_list.contains(new_admin) { + admin_list.push_back(new_admin.clone()); + env.storage().persistent().set(&list_key, &admin_list); + } + + // Update admin count + let count_key = Symbol::new(env, "AdminCount"); + let current_count: u32 = env.storage().persistent().get(&count_key).unwrap_or(0); + env.storage() + .persistent() + .set(&count_key, &(current_count + 1)); + + // Emit event using existing system + Self::emit_admin_change_event(env, new_admin, AdminActionType::Added); + + Ok(()) + } + + /// Removes an admin from the system + pub fn remove_admin( + env: &Env, + current_admin: &Address, + admin_to_remove: &Address, + ) -> Result<(), Error> { + AdminAccessControl::validate_permission(env, current_admin, &AdminPermission::Emergency)?; + + // Prevent self-removal of last super admin + if current_admin == admin_to_remove { + let super_admin_count = Self::count_admins_with_role(env, AdminRole::SuperAdmin); + if super_admin_count <= 1 { + return Err(Error::InvalidState); + } + } + + let admin_key = Self::get_admin_key(env, admin_to_remove); + if !env.storage().persistent().has(&admin_key) { + return Err(Error::Unauthorized); + } + + env.storage().persistent().remove(&admin_key); + + let list_key = Symbol::new(env, "AdminList"); + if let Some(mut admin_list) = env.storage().persistent().get::<_, Vec
>(&list_key) { + if let Some(index) = admin_list.first_index_of(admin_to_remove) { + admin_list.remove(index); + env.storage().persistent().set(&list_key, &admin_list); + } + } + + // Update count + let count_key = Symbol::new(env, "AdminCount"); + let current_count: u32 = env.storage().persistent().get(&count_key).unwrap_or(0); + if current_count > 0 { + env.storage() + .persistent() + .set(&count_key, &(current_count - 1)); + } + + Self::emit_admin_change_event(env, admin_to_remove, AdminActionType::Removed); + Ok(()) + } + + /// Updates an admin's role + pub fn update_admin_role( + env: &Env, + current_admin: &Address, + target_admin: &Address, + new_role: AdminRole, + ) -> Result<(), Error> { + AdminAccessControl::validate_permission(env, current_admin, &AdminPermission::Emergency)?; + + let admin_key = Self::get_admin_key(env, target_admin); + let mut assignment: AdminRoleAssignment = env + .storage() + .persistent() + .get(&admin_key) + .ok_or(Error::Unauthorized)?; + + // Prevent downgrading last super admin + if assignment.role == AdminRole::SuperAdmin && new_role != AdminRole::SuperAdmin { + let super_admin_count = Self::count_admins_with_role(env, AdminRole::SuperAdmin); + if super_admin_count <= 1 { + return Err(Error::InvalidState); + } + } + + assignment.role = new_role.clone(); + assignment.permissions = AdminRoleManager::get_permissions_for_role(env, &new_role); + assignment.assigned_by = current_admin.clone(); + assignment.assigned_at = env.ledger().timestamp(); + + env.storage().persistent().set(&admin_key, &assignment); + Self::emit_admin_change_event(env, target_admin, AdminActionType::RoleUpdated); + + Ok(()) + } + + /// Validates if an admin has a specific permission + pub fn validate_admin_permission( + env: &Env, + admin: &Address, + permission: AdminPermission, + ) -> Result<(), Error> { + // Check original admin for backward compatibility + if Self::is_original_admin(env, admin) { + return Ok(()); + } + + let admin_key = Self::get_admin_key(env, admin); + let assignment: AdminRoleAssignment = env + .storage() + .persistent() + .get(&admin_key) + .ok_or(Error::Unauthorized)?; + + if !assignment.is_active || !assignment.permissions.contains(&permission) { + return Err(Error::Unauthorized); + } + + Ok(()) + } + + /// Gets all admin roles in the system + pub fn get_admin_roles(env: &Env) -> Map { + let mut roles = Map::new(env); + + // Add original admin if exists + if let Some(original_admin) = Self::get_original_admin(env) { + roles.set(original_admin, AdminRole::SuperAdmin); + } + + // Iterate over admin list to get all multi-admin entries + let list_key = Symbol::new(env, "AdminList"); + if let Some(admin_list) = env.storage().persistent().get::<_, Vec
>(&list_key) { + for admin_addr in admin_list.iter() { + let admin_key = Self::get_admin_key(env, &admin_addr); + if let Some(assignment) = env + .storage() + .persistent() + .get::<_, AdminRoleAssignment>(&admin_key) + { + if assignment.is_active { + roles.set(admin_addr.clone(), assignment.role); + } + } + } + } + + roles + } + + /// Check if an admin exists in the multi-admin system + pub fn get_admin_role_for_address(env: &Env, admin: &Address) -> Option { + // Check original admin first + if Self::is_original_admin(env, admin) { + return Some(AdminRole::SuperAdmin); + } + + // Check multi-admin storage + let admin_key = Self::get_admin_key(env, admin); + if let Some(assignment) = env + .storage() + .persistent() + .get::<_, AdminRoleAssignment>(&admin_key) + { + if assignment.is_active { + return Some(assignment.role); + } + } + + None + } + + /// Emits admin change events using existing AdminActionType + pub fn emit_admin_change_event(env: &Env, admin: &Address, action: AdminActionType) { + let action_str = match action { + AdminActionType::Added => "Added", + AdminActionType::Removed => "Removed", + AdminActionType::RoleUpdated => "RoleUpdated", + AdminActionType::Activated => "Activated", + AdminActionType::Deactivated => "Deactivated", + }; + EventEmitter::emit_admin_action_logged(env, admin, action_str, &true); + } + + /// Check role permissions against a specific permission + pub fn check_role_permissions(env: &Env, role: AdminRole, permission: AdminPermission) -> bool { + let permissions = AdminRoleManager::get_permissions_for_role(env, &role); + permissions.contains(&permission) + } + + // ===== Helper Methods ===== + + /// Generate a deterministic multi-admin storage key for an address. + fn get_admin_key(env: &Env, admin: &Address) -> String { + String::from_str(env, &alloc::format!("MultiAdmin_{:?}", admin.to_string())) + } + + /// Check if an address is the original admin from single-admin system + pub fn is_original_admin(env: &Env, admin: &Address) -> bool { + if let Some(original_admin) = Self::get_original_admin(env) { + return admin == &original_admin; + } + false + } + + /// Get the original admin address from single-admin system + pub fn get_original_admin(env: &Env) -> Option
{ + env.storage().persistent().get(&Symbol::new(env, "Admin")) + } + + /// Check if an address is any type of admin (original or multi-admin) + fn is_admin(env: &Env, admin: &Address) -> bool { + Self::is_original_admin(env, admin) + || env + .storage() + .persistent() + .has(&Self::get_admin_key(env, admin)) + } + + /// Count admins with a specific role + fn count_admins_with_role(env: &Env, role: AdminRole) -> u32 { + let mut count = 0; + + // Count original admin if it matches the role + if role == AdminRole::SuperAdmin && Self::get_original_admin(env).is_some() { + count += 1; + } + + // In a full implementation, we would iterate through all multi-admin entries + // For now, this is a simplified version that works with the existing storage pattern + count + } + + /// Get admin assignment for a specific admin + pub fn get_admin_assignment(env: &Env, admin: &Address) -> Option { + let admin_key = Self::get_admin_key(env, admin); + env.storage().persistent().get(&admin_key) + } + + /// Deactivate an admin (set is_active to false) + pub fn deactivate_admin( + env: &Env, + current_admin: &Address, + target_admin: &Address, + ) -> Result<(), Error> { + AdminAccessControl::validate_permission(env, current_admin, &AdminPermission::Emergency)?; + + let admin_key = Self::get_admin_key(env, target_admin); + let mut assignment: AdminRoleAssignment = env + .storage() + .persistent() + .get(&admin_key) + .ok_or(Error::Unauthorized)?; + + assignment.is_active = false; + env.storage().persistent().set(&admin_key, &assignment); + + Self::emit_admin_change_event(env, target_admin, AdminActionType::Deactivated); + Ok(()) + } + + /// Reactivate an admin (set is_active to true) + pub fn reactivate_admin( + env: &Env, + current_admin: &Address, + target_admin: &Address, + ) -> Result<(), Error> { + AdminAccessControl::validate_permission(env, current_admin, &AdminPermission::Emergency)?; + + let admin_key = Self::get_admin_key(env, target_admin); + let mut assignment: AdminRoleAssignment = env + .storage() + .persistent() + .get(&admin_key) + .ok_or(Error::Unauthorized)?; + + assignment.is_active = true; + env.storage().persistent().set(&admin_key, &assignment); + + Self::emit_admin_change_event(env, target_admin, AdminActionType::Activated); + Ok(()) + } +} + diff --git a/contracts/predictify-hybrid/src/lib.rs b/contracts/predictify-hybrid/src/lib.rs index fdbd729b..9ed8ffca 100644 --- a/contracts/predictify-hybrid/src/lib.rs +++ b/contracts/predictify-hybrid/src/lib.rs @@ -14,6 +14,7 @@ pub const SYM_ADMIN: &str = "Admin"; pub const ORACLE_FAILURE_PRIMARY_THEN_FALLBACK_REASON: &str = "Primary oracle failed, fallback also failed"; const SYM_ALLOWED_ASSETS: &str = "allowed"; +#[path = "admin/mod.rs"] mod admin; pub mod analytics; pub mod audit; @@ -60,6 +61,10 @@ mod oracle_validation_tests; #[path = "tests/market_closed_tests.rs"] mod market_closed_tests; +#[cfg(test)] +#[path = "tests/recovery_tests.rs"] +mod recovery_tests; + #[cfg(test)] mod cross_oracle_staleness_tests; mod resolution; diff --git a/contracts/predictify-hybrid/src/tests/recovery_tests.rs b/contracts/predictify-hybrid/src/tests/recovery_tests.rs new file mode 100644 index 00000000..9dee9cfd --- /dev/null +++ b/contracts/predictify-hybrid/src/tests/recovery_tests.rs @@ -0,0 +1,137 @@ +#![cfg(test)] + +use crate::{ + err::Error, + markets::MarketStateManager, + recovery::{RecoveryManager, RecoveryStorage, UnclaimedWinningsPolicy}, + types::{Market, MarketState, OracleConfig, OracleProvider}, + PredictifyHybrid, +}; +use soroban_sdk::{testutils::{Address as _, Ledger}, Address, Env, String, Symbol}; + +fn setup_contract(env: &Env) -> (Address, Address) { + let contract_id = env.register(PredictifyHybrid, ()); + (contract_id, Address::generate(env)) +} + +fn make_market(env: &Env, admin: &Address) -> Market { + let oracle_config = OracleConfig::new( + OracleProvider::reflector(), + Address::generate(env), + String::from_str(env, "BTC/USD"), + 5_000_000, + String::from_str(env, "gt"), + ); + Market::new( + env, + admin.clone(), + String::from_str(env, "Will BTC exceed $50,000?"), + soroban_sdk::vec![ + env, + String::from_str(env, "yes"), + String::from_str(env, "no"), + ], + env.ledger().timestamp() + 86_400, + oracle_config, + None, + 86_400, + MarketState::Active, + ) +} + +fn set_admin(env: &Env, admin: &Address) { + env.storage() + .persistent() + .set(&Symbol::new(env, "Admin"), admin); +} + +#[test] +fn recovery_reconstructs_market_and_records_history() { + let env = Env::default(); + let (contract_id, admin) = setup_contract(&env); + let market_id = Symbol::new(&env, "recovery_ok"); + + env.as_contract(&contract_id, || { + set_admin(&env, &admin); + let mut market = make_market(&env, &admin); + market.stakes.set(Address::generate(&env), 125); + market.total_staked = 100; + MarketStateManager::update_market(&env, &market_id, &market); + + assert!(RecoveryManager::recover_market_state(&env, &admin, &market_id).unwrap()); + + let recovered_market = MarketStateManager::get_market(&env, &market_id).unwrap(); + assert_eq!(recovered_market.total_staked, 125); + assert_eq!(RecoveryStorage::history_len(&env, &market_id), 1); + assert_eq!( + RecoveryManager::get_recovery_status(&env, &market_id).unwrap(), + String::from_str(&env, "recovered") + ); + }); +} + +#[test] +fn expired_claim_window_is_detected_at_deadline() { + let env = Env::default(); + let (contract_id, _admin) = setup_contract(&env); + let market_id = Symbol::new(&env, "expired_claim"); + + env.as_contract(&contract_id, || { + let start = env.ledger().timestamp(); + UnclaimedWinningsPolicy::set_market_claim_period(&env, &market_id, 60); + UnclaimedWinningsPolicy::set_claim_window_start_if_missing(&env, &market_id, start); + + assert!(!UnclaimedWinningsPolicy::is_claim_window_expired( + &env, + &market_id, + start + )); + env.ledger().with_mut(|ledger| ledger.timestamp = start + 59); + assert!(!UnclaimedWinningsPolicy::is_claim_window_expired( + &env, + &market_id, + start + )); + env.ledger().with_mut(|ledger| ledger.timestamp = start + 60); + assert!(UnclaimedWinningsPolicy::is_claim_window_expired( + &env, + &market_id, + start + )); + }); +} + +#[test] +fn recovery_rejects_an_unauthorized_actor() { + let env = Env::default(); + let (contract_id, admin) = setup_contract(&env); + let unauthorized = Address::generate(&env); + let market_id = Symbol::new(&env, "recovery_auth"); + + env.as_contract(&contract_id, || { + set_admin(&env, &admin); + let market = make_market(&env, &admin); + MarketStateManager::update_market(&env, &market_id, &market); + + assert_eq!( + RecoveryManager::recover_market_state(&env, &unauthorized, &market_id), + Err(Error::Unauthorized) + ); + }); +} + +#[test] +fn recovery_rejects_a_missing_market() { + let env = Env::default(); + let (contract_id, admin) = setup_contract(&env); + let market_id = Symbol::new(&env, "missing_market"); + + env.as_contract(&contract_id, || { + set_admin(&env, &admin); + + assert_eq!( + RecoveryManager::recover_market_state(&env, &admin, &market_id), + Err(Error::MarketNotFound) + ); + }); +} \ No newline at end of file