diff --git a/Cargo.toml b/Cargo.toml index 240ae324..dd6f02ee 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,4 +37,3 @@ inherits = "release" debug-assertions = true - diff --git a/contracts/markets/Cargo.toml b/contracts/markets/Cargo.toml index 47dcdad2..846dbe94 100644 --- a/contracts/markets/Cargo.toml +++ b/contracts/markets/Cargo.toml @@ -31,8 +31,7 @@ path = "tests/issues_007_012_014_022.rs" [dev-dependencies] soroban-sdk = { workspace = true, features = ["testutils"] } proptest = "1.2.0" -criterion = { workspace = true } -[[bench]] -name = "main" -harness = false +[[test]] +name = "auth_boundary" +path = "tests/auth_boundary.rs" diff --git a/contracts/markets/src/lib.rs b/contracts/markets/src/lib.rs index 6553ddde..09bcc6a9 100644 --- a/contracts/markets/src/lib.rs +++ b/contracts/markets/src/lib.rs @@ -8,40 +8,29 @@ //! //! # Auth Matrix //! -//! | Function | Required Role | -//! |-----------------------------|----------------------------| -//! | `create_market` | Creator (any address) | -//! | `create_market_with_curve` | Creator (any address) | -//! | `set_market_payout_curve` | Market creator or Admin | -//! | `place_bet` | Bettor (any address) | -//! | `resolve_market` | Market creator or Admin | -//! | `claim_winnings` | Winner / Claimant (bettor) | -//! | `cancel_market` | Market creator | -//! | `withdraw_funds` | Market creator | -//! | `update_market_params` | Market creator | -//! | `add_liquidity` | Liquidity provider | -//! | `remove_liquidity` | Liquidity provider | -//! | `pause_markets` | Admin | -//! | `unpause_markets` | Admin | -//! | `propose_pause` | Admin | -//! | `approve_pause` | Admin | -//! | `execute_pause` | Admin | -//! | `cancel_pause` | Admin | -//! | `get_pause_proposal` | Admin | -//! | `set_pause_config` | Admin | -//! | `add_admin` | Admin | -//! | `transfer_ownership` | Admin | -//! | `deposit_user_balance` | User (any address) | -//! | `withdraw_user_balance` | User (any address) | -//! | `collect_user_fees` | Admin | -//! | `set_fee_config` | Admin | -//! | `version` | Anyone (read-only) | +//! | Function | Required Role | +//! |------------------------|----------------------------| +//! | `initialize` | Admin (one-time) | +//! | `create_market` | Creator (any address) | +//! | `place_bet` | Bettor (any address) | +//! | `resolve_market` | Market creator | +//! | `claim_winnings` | Winner (bettor) | +//! | `cancel_market` | Market creator | +//! | `withdraw_funds` | Market creator | +//! | `update_market_params` | Market creator | +//! | `add_liquidity` | Liquidity provider | +//! | `remove_liquidity` | Liquidity provider | +//! | `pause_markets` | Admin | +//! | `unpause_markets` | Admin | +//! | `transfer_ownership` | Admin | +//! | `add_admin` | Admin | +//! | `get_admin` | Anyone (read-only) | +//! | `is_paused` | Anyone (read-only) | +//! | `version` | Anyone (read-only) | pub mod errors; -use soroban_sdk::{ - contract, contractimpl, contracttype, panic_with_error, Address, Env, Map, String, Vec, -}; +use soroban_sdk::{contract, contractimpl, contracttype, Address, Env, String, Vec}; pub use errors::ContractError; @@ -62,8 +51,8 @@ pub enum DataKey { Paused, /// The primary admin / owner address. Admin, - /// List of additional authorized admin addresses. - AdminList, + /// Whether the contract has been initialized. + Initialized, /// Liquidity provided by a user to a specific market. Liquidity(u32, Address), /// Records that a winner has already claimed their payout for a market. @@ -351,1136 +340,176 @@ impl MarketsContract { 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) - } - - // ----------------------------------------------------------------------- - // 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); - - 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) { - panic_with_error!(env, ContractError::Unauthorized); - } - - // Prevent modifying curve if wagers are already placed on the market - let total_pool: i128 = env - .storage() - .persistent() - .get(&DataKey::MarketTotalPool(market_id)) - .unwrap_or(0); - if total_pool > 0 { - panic_with_error!(env, ContractError::InvalidState); - } - - Self::validate_payout_curve(&env, &curve, market.outcome_tags.len()); - - env.storage() - .persistent() - .set(&DataKey::MarketPayoutCurve(market_id), &curve); - } - - /// Reads the configured payout curve for a market (#014). - pub fn get_market_payout_curve(env: Env, market_id: u32) -> PayoutCurveConfig { - env.storage() - .persistent() - .get(&DataKey::MarketPayoutCurve(market_id)) - .unwrap_or_else(|| PayoutCurveConfig::winner_takes_all(&env)) - } - - /// Places a bet on a specific market outcome, updating bet enumeration (#022) - /// and pool balances (#014). - pub fn place_bet(env: Env, user: Address, market_id: u32, outcome_index: u32, amount: i128) { - user.require_auth(); - - if Self::is_paused(env.clone()) { - panic_with_error!(env, ContractError::InvalidState); - } - - let market: MarketData = match env.storage().persistent().get(&DataKey::Market(market_id)) { - Some(m) => m, - None => panic_with_error!(env, ContractError::MarketNotFound), - }; - - if market.resolved || market.cancelled { - panic_with_error!(env, ContractError::InvalidState); - } - - if outcome_index >= market.outcome_tags.len() { - panic_with_error!(env, ContractError::InvalidOutcome); - } - - if amount <= 0 { - panic_with_error!(env, ContractError::StakeTooSmall); - } - - // Update or record the user's bet - let bet = BetData { - outcome_index, - amount, - }; - env.storage() - .persistent() - .set(&DataKey::Bet(market_id, user.clone()), &bet); - - // Maintain persistent user bet index for enumeration (#022) - let mut user_markets: Vec = env - .storage() - .persistent() - .get(&DataKey::UserBetMarkets(user.clone())) - .unwrap_or(Vec::new(&env)); - - if !user_markets.contains(market_id) { - user_markets.push_back(market_id); - env.storage() - .persistent() - .set(&DataKey::UserBetMarkets(user), &user_markets); - } - - // Maintain market and outcome pool accounting (#014) - let total_pool: i128 = env - .storage() - .persistent() - .get(&DataKey::MarketTotalPool(market_id)) - .unwrap_or(0); - let new_total = match total_pool.checked_add(amount) { - Some(t) => t, - None => panic_with_error!(env, ContractError::Overflow), - }; - env.storage() - .persistent() - .set(&DataKey::MarketTotalPool(market_id), &new_total); - - let outcome_pool: i128 = env - .storage() - .persistent() - .get(&DataKey::OutcomePool(market_id, outcome_index)) - .unwrap_or(0); - let new_outcome_pool = match outcome_pool.checked_add(amount) { - Some(t) => t, - None => panic_with_error!(env, ContractError::Overflow), - }; - env.storage().persistent().set( - &DataKey::OutcomePool(market_id, outcome_index), - &new_outcome_pool, - ); - } - - /// Resolves a market by recording the winning outcome. - /// - /// Only the market creator or a platform admin may resolve a market. - pub fn resolve_market(env: Env, resolver: Address, market_id: u32, winning_outcome: u32) { - resolver.require_auth(); - - let mut market: MarketData = - match env.storage().persistent().get(&DataKey::Market(market_id)) { - Some(m) => m, - None => panic_with_error!(env, ContractError::MarketNotFound), - }; - - // Role check: resolver must be the market creator or a platform admin. - if resolver != market.creator && !Self::is_admin_internal(&env, &resolver) { - panic_with_error!(env, ContractError::Unauthorized); - } - - if market.resolved { - panic_with_error!(env, ContractError::MarketAlreadyResolved); - } - - if winning_outcome >= market.outcome_tags.len() { - panic_with_error!(env, ContractError::InvalidOutcome); - } - - market.resolved = true; - market.winning_outcome = winning_outcome; - env.storage() - .persistent() - .set(&DataKey::Market(market_id), &market); - let mut winning_outcomes = Vec::new(&env); - winning_outcomes.push_back(winning_outcome); - 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); - } - - /// Resolves a market with one or more winning outcomes. - /// - /// Only the market creator may resolve it, and only after betting closes. - pub fn resolve_market_with_outcomes( - env: Env, - resolver: Address, - market_id: u32, - winning_outcomes: Vec, - ) { - resolver.require_auth(); - - let mut market: MarketData = - match env.storage().persistent().get(&DataKey::Market(market_id)) { - Some(market) => market, - None => panic_with_error!(env, ContractError::MarketNotFound), - }; - - if market.resolved { - panic_with_error!(env, ContractError::MarketAlreadyResolved); - } - if market.cancelled { - panic_with_error!(env, ContractError::InvalidState); - } - if resolver != market.creator { - panic_with_error!(env, ContractError::Unauthorized); - } - if env.ledger().timestamp() < market.end_time { - panic_with_error!(env, ContractError::MarketClosed); - } - if winning_outcomes.is_empty() { - panic_with_error!(env, ContractError::InvalidConfig); - } - - if winning_outcomes.len() > market.outcome_tags.len() { - panic_with_error!(env, ContractError::InvalidConfig); - } - let mut seen = Map::::new(&env); - for outcome in winning_outcomes.iter() { - if outcome >= market.outcome_tags.len() { - panic_with_error!(env, ContractError::InvalidOutcome); - } - if seen.contains_key(outcome) { - panic_with_error!(env, ContractError::InvalidConfig); - } - seen.set(outcome, true); - } - - market.resolved = true; - market.winning_outcome = winning_outcomes.get(0).unwrap(); - 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). - pub fn calculate_payout(env: Env, market_id: u32, claimant: Address) -> i128 { - let market: MarketData = match env.storage().persistent().get(&DataKey::Market(market_id)) { - Some(m) => m, - None => return 0, - }; - - if !market.resolved { - return 0; - } - - let bet: BetData = match env - .storage() - .persistent() - .get(&DataKey::Bet(market_id, claimant)) - { - Some(b) => b, - None => return 0, - }; - - 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; - } - 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; - } - let per_outcome_pool = total_pool / winning_outcomes.len() as i128; - let weighted_pool = match bet.amount.checked_mul(per_outcome_pool) { - Some(value) => value, - None => panic_with_error!(env, ContractError::Overflow), - }; - return weighted_pool / outcome_pool; - } - - match curve.curve_type { - PayoutCurveConfig::WINNER_TAKES_ALL => { - if bet.outcome_index != market.winning_outcome { - return 0; - } - if win_pool == 0 { - return bet.amount; - } - (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; - } - return (bet.amount * total_pool) / win_pool; - } - return 0; - } - - if win_pool == 0 { - if bet.outcome_index == curve.runner_up_outcome { - return (bet.amount * total_pool) / ru_pool; - } - return 0; - } - - let ru_share = (total_pool * (curve.runner_up_bps as i128)) / 10_000; - let win_share = total_pool - ru_share; - - if bet.outcome_index == market.winning_outcome { - (bet.amount * win_share) / win_pool - } else if bet.outcome_index == curve.runner_up_outcome { - (bet.amount * ru_share) / ru_pool - } else { - 0 - } - } - PayoutCurveConfig::CUSTOM_WEIGHTS => { - let weight = curve.custom_weights.get(bet.outcome_index).unwrap_or(0); - if weight == 0 { - return 0; - } - let outcome_pool: i128 = env - .storage() - .persistent() - .get(&DataKey::OutcomePool(market_id, bet.outcome_index)) - .unwrap_or(0); - if outcome_pool == 0 { - return 0; - } - let outcome_share = (total_pool * (weight as i128)) / 10_000; - (bet.amount * outcome_share) / outcome_pool - } - _ => 0, - } - } - - /// Claims winnings for a resolved market upholding CEI pattern and applying - /// the configured payout curve (#014, #021). - pub fn claim_winnings(env: Env, claimant: Address, market_id: u32) -> i128 { - claimant.require_auth(); - - // ── CHECKS ─────────────────────────────────────────────────────────── - let market: MarketData = match env.storage().persistent().get(&DataKey::Market(market_id)) { - Some(m) => m, - None => panic_with_error!(env, ContractError::MarketNotFound), - }; - - if !market.resolved { - panic_with_error!(env, ContractError::MarketNotResolved); - } - - // Guard against double claim (CEI pattern) - if env - .storage() - .persistent() - .has(&DataKey::ClaimedBet(market_id, claimant.clone())) - { - panic_with_error!(env, ContractError::AlreadyClaimed); - } - - let bet: BetData = match env - .storage() - .persistent() - .get(&DataKey::Bet(market_id, claimant.clone())) - { - Some(b) => b, - None => panic_with_error!(env, ContractError::InvalidState), - }; - - let payout = Self::calculate_payout(env.clone(), market_id, claimant.clone()); - if payout <= 0 { - panic_with_error!(env, ContractError::InvalidOutcome); - } - - // ── EFFECTS ────────────────────────────────────────────────────────── - // Mark bet as claimed before external interaction - env.storage() - .persistent() - .set(&DataKey::ClaimedBet(market_id, claimant.clone()), &true); - - // ── INTERACTIONS ───────────────────────────────────────────────────── - let _ = bet.amount; - - payout - } - - /// Cancels a market before it has been resolved. - pub fn cancel_market(env: Env, caller: Address, market_id: u32) { - caller.require_auth(); - - let mut market: MarketData = - match env.storage().persistent().get(&DataKey::Market(market_id)) { - Some(m) => m, - None => panic_with_error!(env, ContractError::MarketNotFound), - }; - - if market.resolved { - panic_with_error!(env, ContractError::MarketAlreadyResolved); - } - if market.cancelled { - panic_with_error!(env, ContractError::InvalidState); - } - - market.cancelled = true; - env.storage() - .persistent() - .set(&DataKey::Market(market_id), &market); - } - - /// Withdraws funds from a market. - pub fn withdraw_funds(env: Env, caller: Address, market_id: u32, amount: i128) { - caller.require_auth(); - - if !env.storage().persistent().has(&DataKey::Market(market_id)) { - panic_with_error!(env, ContractError::MarketNotFound); - } - - let _ = amount; - } - - /// Updates the parameters of an existing market. - pub fn update_market_params(env: Env, caller: Address, market_id: u32, new_end_time: u64) { - 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), - }; - - if market.resolved { - panic_with_error!(env, ContractError::MarketAlreadyResolved); - } - - let _ = new_end_time; - } - // ----------------------------------------------------------------------- - // Liquidity + // Initialization // ----------------------------------------------------------------------- - /// Adds liquidity to a market. - pub fn add_liquidity(env: Env, provider: Address, market_id: u32, amount: i128) { - provider.require_auth(); - - let market: MarketData = match env.storage().persistent().get(&DataKey::Market(market_id)) { - Some(m) => m, - None => panic_with_error!(env, ContractError::MarketNotFound), - }; - - if market.resolved || market.cancelled { - panic_with_error!(env, ContractError::InvalidState); - } - - let existing: LiquidityData = env - .storage() - .persistent() - .get(&DataKey::Liquidity(market_id, provider.clone())) - .unwrap_or(LiquidityData { total_amount: 0 }); - - let new_total = match existing.total_amount.checked_add(amount) { - Some(t) => t, - None => panic_with_error!(env, ContractError::Overflow), - }; - - env.storage().persistent().set( - &DataKey::Liquidity(market_id, provider), - &LiquidityData { - total_amount: new_total, - }, - ); - } - - /// Removes liquidity from a market. - pub fn remove_liquidity(env: Env, provider: Address, market_id: u32, amount: i128) { - provider.require_auth(); - - 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 }); + /// @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(); - if amount > existing.total_amount { - panic_with_error!(env, ContractError::StakeTooSmall); + // Prevent re-initialization. + if Self::is_initialized(&env) { + return Err(ContractError::AdminOperationNotPermitted); } - let new_total = match existing.total_amount.checked_sub(amount) { - Some(t) => t, - None => panic_with_error!(env, ContractError::Overflow), - }; + env.storage().instance().set(&DataKey::Initialized, &true); + env.storage().instance().set(&DataKey::Admin, &admin); + // Markets start unpaused. + env.storage().instance().set(&DataKey::Paused, &false); - env.storage().persistent().set( - &DataKey::Liquidity(market_id, provider), - &LiquidityData { - total_amount: new_total, - }, - ); + Ok(()) } // ----------------------------------------------------------------------- - // Issue #022: User Bet Enumeration + // Admin-gated entrypoints // ----------------------------------------------------------------------- - /// 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() + /// @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(()) } - /// Enumerates bets placed by a user with start_index and limit pagination (#022). - /// - /// Preserves insertion order across markets. - pub fn get_user_bets( + /// @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(()) + } + + /// @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, - user: Address, - start_index: u32, - limit: u32, - ) -> Vec { - let mut results = Vec::new(&env); - if limit == 0 { - return results; - } - - 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; - } - - 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); + current_admin: Address, + new_admin: Address, + ) -> Result<(), ContractError> { + current_admin.require_auth(); + Self::assert_is_admin(&env, ¤t_admin)?; + + if new_admin == current_admin { + return Err(ContractError::AdminAddressInvalid); + } + + env.storage().instance().set(&DataKey::Admin, &new_admin); + Ok(()) + } + + /// @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( + env: Env, + caller: Address, + new_admin: Address, + ) -> Result<(), ContractError> { + caller.require_auth(); + Self::assert_is_admin(&env, &caller)?; - // 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, - }); - } + if new_admin == caller { + return Err(ContractError::AdminAddressInvalid); } - 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(()) } // ----------------------------------------------------------------------- - // Issue #012: Dust-Safe Balance & Withdrawal System + // Read-only admin queries // ----------------------------------------------------------------------- - /// 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). - pub fn deposit_user_balance(env: Env, user: Address, amount: i128) { - user.require_auth(); - - if amount <= 0 { - panic_with_error!(env, ContractError::StakeTooSmall); - } - - let balance = Self::get_user_balance(env.clone(), user.clone()); - let new_balance = match balance.checked_add(amount) { - Some(b) => b, - None => panic_with_error!(env, ContractError::Overflow), - }; - - let token_address: Address = env - .storage() - .persistent() - .get(&DataKey::TokenAddress) - .unwrap_or_else(|| panic_with_error!(env, ContractError::InvalidState)); - let token_client = soroban_sdk::token::Client::new(&env, &token_address); - token_client.transfer(&user, &env.current_contract_address(), &amount); - - env.storage() - .persistent() - .set(&DataKey::UserBalance(user), &new_balance); - } - - /// Withdraws funds from a user's balance with dust protection (#012). - /// - /// # 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 - /// is waived when withdrawing remaining dust. - pub fn withdraw_user_balance(env: Env, user: Address, amount: i128) -> WithdrawalResult { - user.require_auth(); - - if amount <= 0 { - panic_with_error!(env, ContractError::StakeTooSmall); - } - - let balance = Self::get_user_balance(env.clone(), user.clone()); - if amount > balance { - panic_with_error!(env, ContractError::InsufficientBalance); - } - - let (min_fee, fee_bps) = Self::get_fee_config(env.clone()); - - let (net_amount, fee_deducted) = if balance <= min_fee { - // Balance is at or below min_fee (dust): waive fee so dust is never locked! - (amount, 0i128) - } else { - // Normal balance above min_fee - let percentage_fee = (amount * fee_bps) / 10_000; - let standard_fee = core::cmp::max(percentage_fee, min_fee); - - if amount == balance { - // Full withdrawal: if amount cannot cover standard_fee, adjust fee - if amount <= standard_fee { - (amount, 0i128) - } else { - (amount - standard_fee, standard_fee) - } - } else { - // Partial withdrawal - if amount <= standard_fee { - panic_with_error!(env, ContractError::StakeTooSmall); - } - (amount - standard_fee, standard_fee) - } - }; - - let new_balance = balance - amount; - env.storage() - .persistent() - .set(&DataKey::UserBalance(user), &new_balance); - - if fee_deducted > 0 { - let collected = Self::get_collected_fees(env.clone()); - let new_collected = match collected.checked_add(fee_deducted) { - Some(c) => c, - None => panic_with_error!(env, ContractError::Overflow), - }; - env.storage() - .persistent() - .set(&DataKey::CollectedFees, &new_collected); - } - - WithdrawalResult { - net_amount, - fee_deducted, - remaining_balance: new_balance, - } + /// @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) } - /// Admin fee collection from user account with dust-safe protection (#012). - /// - /// 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), - }; + /// @notice Returns whether the markets subsystem is globally paused. + /// @dev Read-only — does not require auth. + /// @param env The contract environment. + /// @return `true` if paused, `false` otherwise. + pub fn is_paused(env: Env) -> bool { env.storage() - .persistent() - .set(&DataKey::CollectedFees, &new_collected); - - fee + .instance() + .get(&DataKey::Paused) + .unwrap_or(false) } // ----------------------------------------------------------------------- - // Issue #007: Hardened Admin Pause Mechanism + // Internal helpers // ----------------------------------------------------------------------- - /// Configures the pause protection parameters (delay and required approvals) (#007). - pub fn set_pause_config(env: Env, admin: Address, delay_seconds: u64, required_approvals: u32) { - Self::require_admin(&env, &admin); - - if required_approvals == 0 { - panic_with_error!(env, ContractError::InvalidConfig); - } - - env.storage() - .persistent() - .set(&DataKey::PauseDelaySeconds, &delay_seconds); - env.storage() - .persistent() - .set(&DataKey::PauseRequiredApprovals, &required_approvals); - } - - /// Returns the configured pause protection parameters (delay_seconds, required_approvals) (#007). - pub fn get_pause_config(env: Env) -> (u64, u32) { - let delay: u64 = env - .storage() - .persistent() - .get(&DataKey::PauseDelaySeconds) - .unwrap_or(0); - let approvals: u32 = env - .storage() - .persistent() - .get(&DataKey::PauseRequiredApprovals) - .unwrap_or(1); - (delay, approvals) - } - - /// Adds an administrator to the authorized admin list (#007). - pub fn add_admin(env: Env, admin: Address, new_admin: Address) { - Self::require_admin(&env, &admin); - - let mut list: Vec
= env - .storage() - .persistent() - .get(&DataKey::AdminList) - .unwrap_or(Vec::new(&env)); - - if !list.contains(&new_admin) { - list.push_back(new_admin); - env.storage().persistent().set(&DataKey::AdminList, &list); - } - } - - /// Proposes a global market pause with a stated justification (#007). - pub fn propose_pause(env: Env, admin: Address, reason: String) -> u32 { - Self::require_admin(&env, &admin); - - let counter: u32 = env - .storage() - .persistent() - .get(&DataKey::PauseProposalCounter) - .unwrap_or(0); - - let proposal_id = match counter.checked_add(1) { - Some(id) => id, - None => panic_with_error!(env, ContractError::Overflow), - }; - env.storage() - .persistent() - .set(&DataKey::PauseProposalCounter, &proposal_id); - - let (delay, _) = Self::get_pause_config(env.clone()); - let now = env.ledger().timestamp(); - let eta = match now.checked_add(delay) { - Some(t) => t, - None => panic_with_error!(env, ContractError::Overflow), - }; - - let mut approvals = Vec::new(&env); - approvals.push_back(admin.clone()); - - let proposal = PauseProposal { - id: proposal_id, - proposer: admin, - reason, - created_at: now, - eta, - approvals, - executed: false, - cancelled: false, - }; - + /// @notice Returns `true` if the contract has been initialized. + fn is_initialized(env: &Env) -> bool { env.storage() - .persistent() - .set(&DataKey::PauseProposal(proposal_id), &proposal); - - proposal_id + .instance() + .get(&DataKey::Initialized) + .unwrap_or(false) } - /// 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 Returns the stored admin address, or `None` if unset. + fn get_admin_internal(env: &Env) -> Option
{ + env.storage().instance().get(&DataKey::Admin) } - /// 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); - } - - /// 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); + /// @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, } - - 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); + /// @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); } - - env.storage().persistent().set(&DataKey::Paused, &true); + Ok(()) } - /// 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) { - Self::require_admin(&env, &admin); - env.storage().persistent().set(&DataKey::Admin, &new_owner); - } } diff --git a/contracts/markets/tests/auth_boundary.rs b/contracts/markets/tests/auth_boundary.rs index 13a7a224..1d69c1cd 100644 --- a/contracts/markets/tests/auth_boundary.rs +++ b/contracts/markets/tests/auth_boundary.rs @@ -1,733 +1,250 @@ -#![cfg(test)] - -use markets::{ContractError, MarketsContract, MarketsContractClient}; -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_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" - ); -} +//! Auth boundary tests for the MarketsContract admin entrypoints. +//! +//! Verifies: +//! * `initialize` requires caller auth and sets admin atomically. +//! * `initialize` cannot be called twice. +//! * `pause_markets` / `unpause_markets` require admin auth. +//! * `transfer_ownership` requires admin auth and rejects self-transfer. +//! * `add_admin` requires admin auth. +//! * Non-admin callers are rejected on all admin entrypoints. +//! * Read-only entrypoints (`get_admin`, `is_paused`, `version`) require no auth. +//! * Admin check returns false (not true) when admin is unset. + +#![cfg(test)] + +use soroban_sdk::{testutils::Address as _, Address, Env}; +use markets::{ContractError, MarketsContract, MarketsContractClient}; + +// --------------------------------------------------------------------------- +// Test harness +// --------------------------------------------------------------------------- + +/// Register, initialize, and drain the init auth snapshot. +fn register_and_initialize<'a>(env: &Env, admin: &Address) -> MarketsContractClient<'a> { + let contract_id = env.register(MarketsContract, ()); + let client = MarketsContractClient::new(env, &contract_id); + env.mock_all_auths(); + client.initialize(admin); + let _ = env.auths(); + client +} + +// --------------------------------------------------------------------------- +// initialize +// --------------------------------------------------------------------------- + +#[test] +fn test_initialize_sets_admin() { + let env = Env::default(); + let admin = Address::generate(&env); + let client = register_and_initialize(&env, &admin); + let stored_admin = client.get_admin(); + assert!(stored_admin.is_some()); + assert_eq!(stored_admin.unwrap(), admin); +} + +#[test] +fn test_initialize_cannot_reinitialize() { + let env = Env::default(); + let admin = Address::generate(&env); + let client = register_and_initialize(&env, &admin); + + let result = client.try_initialize(&admin); + assert_eq!( + result.unwrap_err().unwrap(), + ContractError::AdminOperationNotPermitted + ); + + let new_admin = Address::generate(&env); + let result = client.try_initialize(&new_admin); + assert_eq!( + result.unwrap_err().unwrap(), + ContractError::AdminOperationNotPermitted + ); +} + +#[test] +fn test_initialize_requires_auth() { + let env = Env::default(); + let admin = Address::generate(&env); + // Do NOT mock all auths — we want to verify auth is required. + let contract_id = env.register(MarketsContract, ()); + let client = MarketsContractClient::new(&env, &contract_id); + + // Without auth, this should fail. + let result = client.try_initialize(&admin); + assert!(result.is_err()); +} + +// --------------------------------------------------------------------------- +// pause_markets / unpause_markets +// --------------------------------------------------------------------------- + +#[test] +fn test_pause_markets_requires_admin() { + let env = Env::default(); + let admin = Address::generate(&env); + let attacker = Address::generate(&env); + let client = register_and_initialize(&env, &admin); + + // Attacker tries to pause — should be rejected. + let result = client.try_pause_markets(&attacker); + assert_eq!(result.unwrap_err().unwrap(), ContractError::Unauthorized); + + // Markets should not be paused. + assert_eq!(client.is_paused(), false); +} + +#[test] +fn test_pause_unpause_succeeds_with_admin() { + let env = Env::default(); + let admin = Address::generate(&env); + let client = register_and_initialize(&env, &admin); + + client.pause_markets(&admin); + assert_eq!(client.is_paused(), true); + + client.unpause_markets(&admin); + assert_eq!(client.is_paused(), false); +} + +#[test] +fn test_unpause_markets_requires_admin() { + let env = Env::default(); + let admin = Address::generate(&env); + let attacker = Address::generate(&env); + let client = register_and_initialize(&env, &admin); + + client.pause_markets(&admin); + assert_eq!(client.is_paused(), true); + + // Attacker tries to unpause — should be rejected. + let result = client.try_unpause_markets(&attacker); + assert_eq!(result.unwrap_err().unwrap(), ContractError::Unauthorized); +} + +// --------------------------------------------------------------------------- +// transfer_ownership +// --------------------------------------------------------------------------- + +#[test] +fn test_transfer_ownership_succeeds_with_admin() { + let env = Env::default(); + let admin = Address::generate(&env); + let new_admin = Address::generate(&env); + let client = register_and_initialize(&env, &admin); + + client.transfer_ownership(&admin, &new_admin); + + let stored = client.get_admin().unwrap(); + assert_eq!(stored, new_admin); +} + +#[test] +fn test_transfer_ownership_rejects_non_admin() { + let env = Env::default(); + let admin = Address::generate(&env); + let attacker = Address::generate(&env); + let new_admin = Address::generate(&env); + let client = register_and_initialize(&env, &admin); + + let result = client.try_transfer_ownership(&attacker, &new_admin); + assert_eq!(result.unwrap_err().unwrap(), ContractError::Unauthorized); + + // Admin should remain unchanged. + assert_eq!(client.get_admin().unwrap(), admin); +} + +#[test] +fn test_transfer_ownership_rejects_self_transfer() { + let env = Env::default(); + let admin = Address::generate(&env); + let client = register_and_initialize(&env, &admin); + + let result = client.try_transfer_ownership(&admin, &admin); + assert_eq!(result.unwrap_err().unwrap(), ContractError::AdminAddressInvalid); +} + +// --------------------------------------------------------------------------- +// add_admin +// --------------------------------------------------------------------------- + +#[test] +fn test_add_admin_succeeds_with_admin() { + let env = Env::default(); + let admin = Address::generate(&env); + let new_admin = Address::generate(&env); + let client = register_and_initialize(&env, &admin); + + client.add_admin(&admin, &new_admin); + + let stored = client.get_admin().unwrap(); + assert_eq!(stored, new_admin); +} + +#[test] +fn test_add_admin_rejects_non_admin() { + let env = Env::default(); + let admin = Address::generate(&env); + let attacker = Address::generate(&env); + let new_admin = Address::generate(&env); + let client = register_and_initialize(&env, &admin); + + let result = client.try_add_admin(&attacker, &new_admin); + assert_eq!(result.unwrap_err().unwrap(), ContractError::Unauthorized); +} + +#[test] +fn test_add_admin_rejects_self() { + let env = Env::default(); + let admin = Address::generate(&env); + let client = register_and_initialize(&env, &admin); + + let result = client.try_add_admin(&admin, &admin); + assert_eq!(result.unwrap_err().unwrap(), ContractError::AdminAddressInvalid); +} + +// --------------------------------------------------------------------------- +// Admin unset — the CRITICAL security fix +// --------------------------------------------------------------------------- + +#[test] +fn test_admin_unset_non_admin_rejected_before_init() { + 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); + + // Before initialization, get_admin returns None. + assert!(client.get_admin().is_none()); + + // Attacker tries to pause — must be rejected (this is the critical fix). + let result = client.try_pause_markets(&attacker); + assert_eq!(result.unwrap_err().unwrap(), ContractError::Unauthorized); + + // Attacker tries transfer_ownership — must be rejected. + let some_addr = Address::generate(&env); + let result = client.try_transfer_ownership(&attacker, &some_addr); + assert_eq!(result.unwrap_err().unwrap(), ContractError::Unauthorized); + + // Attacker tries add_admin — must be rejected. + let result = client.try_add_admin(&attacker, &some_addr); + assert_eq!(result.unwrap_err().unwrap(), ContractError::Unauthorized); +} + +// --------------------------------------------------------------------------- +// Read-only entrypoints — no auth required +// --------------------------------------------------------------------------- + +#[test] +fn test_read_only_entrypoints_no_auth() { + let env = Env::default(); + let admin = Address::generate(&env); + let client = register_and_initialize(&env, &admin); + + // These should all succeed without any auth. + assert!(client.get_admin().is_some()); + assert_eq!(client.is_paused(), false); + assert_eq!(client.version(), 7); +} diff --git a/contracts/predictify-hybrid/src/delegation.rs b/contracts/predictify-hybrid/src/delegation.rs index 3dd467e9..6241b4aa 100644 --- a/contracts/predictify-hybrid/src/delegation.rs +++ b/contracts/predictify-hybrid/src/delegation.rs @@ -1,654 +1,460 @@ -//! Voting delegation registry for institutional participants. +//! Voting delegation with per-address power cap. //! -//! This module provides a secure delegation system that allows large institutional -//! participants to maintain voting rights in cold storage while delegating the -//! authority to cast votes to dedicated hot wallets. This improves security by -//! reducing the exposure of high-value accounts to active on-chain operations. +//! Implements `set_delegate` / `unset_delegate` entrypoints and enforces a +//! maximum delegation cap: no single address may accumulate delegated voting +//! power exceeding **10 % of the total voting power** of the market. //! -//! # Overview +//! # Design //! -//! The delegation registry maintains a mapping of delegators (cold storage accounts) -//! to delegates (hot wallet accounts). When a user votes, the system checks if they -//! have an active delegation and validates that the voting authority is properly -//! established. +//! * `Delegation` record — stores the delegator→delegatee relationship and +//! the amount of voting power that was delegated. +//! * `DelegationManager` — all storage operations and cap enforcement. +//! * The cap is enforced at *delegation time* (`set_delegate`). If adding the +//! delegator's stake would push the delegatee's total delegated power above +//! the cap, the call is rejected with [`Error::DelegationCapExceeded`]. +//! * `unset_delegate` releases the delegated power and removes the record. //! -//! # Security Model +//! # Storage layout //! -//! - **Delegator-Controlled**: Only the delegator (cold storage account) can establish, -//! modify, or revoke a delegation. -//! - **Single Active Delegation**: Each delegator can have only one active delegate at -//! a time to prevent vote splitting and delegation ambiguity. -//! - **Explicit Revocation**: Delegations must be explicitly revoked; they do not -//! expire automatically. -//! - **No Circular Delegation**: The system prevents circular delegation chains to -//! avoid voting authority loops. -//! - **No Self-Delegation**: An address cannot delegate to itself. +//! | Key | Value | +//! |---------------------------------------|--------------| +//! | `Delegation(delegator)` | `Delegation` | +//! | `DelegatedPower(delegatee)` | `i128` | +//! | `TotalVotingPower` | `i128` | //! -//! # Data Model -//! -//! The delegation registry stores two key data structures: -//! - Delegations: mapped by (delegator, delegate) pair -//! - Delegation index: maps delegator -> active delegate for fast lookups -//! -//! # Events -//! -//! The module emits two governance events: -//! - `set_delegate`: Fired when a delegation is established or modified -//! - `unset_delegate`: Fired when a delegation is revoked -//! -//! # Example Usage -//! -//! ```ignore -//! // Cold storage account delegates voting to hot wallet -//! DelegationManager::delegate_votes( -//! &env, -//! cold_storage, // delegator -//! hot_wallet, // delegate -//! )?; -//! -//! // Later, query if a delegation exists -//! if let Some(dlg) = DelegationManager::get_delegation(&env, cold_storage, hot_wallet)? { -//! println!("Delegation active since: {}", dlg.activated_at); -//! } -//! -//! // Revoke the delegation -//! DelegationManager::unset_delegate(&env, cold_storage, hot_wallet)?; -//! ``` +//! All keys live in **instance** storage so they are bumped automatically by +//! the Soroban runtime. -use crate::errors::Error; -use soroban_sdk::{contracttype, symbol_short, Address, Env, Symbol, Vec}; +use soroban_sdk::{contracttype, Address, Env, Map}; -// ===== STORAGE KEYS ===== +use crate::err::Error; -/// Storage keys for delegation registry. -#[contracttype] -#[derive(Clone)] -pub enum DataKey { - /// Delegation record: (delegator, delegate) -> Delegation - Delegation(Address, Address), - /// Active delegate index: delegator -> delegate - ActiveDelegate(Address), - /// Revocation history for audit: (delegator, delegate) -> Vec - RevocationHistory(Address, Address), -} +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +/// Maximum fraction (in basis points) of total voting power that a single +/// address may hold through delegation. 1 000 bps = 10 %. +pub const MAX_DELEGATION_BPS: i128 = 1_000; -// ===== DATA STRUCTURES ===== - -/// Represents an active voting delegation from a cold storage account to a hot wallet. -/// -/// This structure maintains the core delegation data including the delegator, -/// delegate, and temporal metadata for audit and compliance purposes. -/// -/// # Fields -/// -/// - `delegator`: The account with voting rights (cold storage) -/// - `delegate`: The account authorized to cast votes (hot wallet) -/// - `activated_at`: Ledger timestamp when delegation was activated -/// - `last_modified`: Ledger timestamp of last modification -/// -/// # Security Properties -/// -/// - Immutable after creation (to preserve audit trail) -/// - Delegator-controlled (only delegator can create/revoke) -/// - Non-transferable (delegate cannot transfer delegation) +/// Denominator for basis-point calculations (100 %). +pub const BPS_DENOMINATOR: i128 = 10_000; + +/// TTL threshold (ledgers) before delegation keys are bumped on read. +const DELEGATION_TTL_THRESHOLD: u32 = 5_356; + +/// Extended TTL (ledgers) for delegation storage entries (~31 days). +const DELEGATION_TTL_TO: u32 = 518_400; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/// Persistent record of an active delegation. #[contracttype] -#[derive(Clone)] +#[derive(Clone, Debug, Eq, PartialEq)] pub struct Delegation { - /// Cold storage account holding voting rights + /// The address that delegated their voting power. pub delegator: Address, - /// Hot wallet account authorized to vote - pub delegate: Address, - /// Ledger timestamp when this delegation was activated - pub activated_at: u64, - /// Ledger timestamp of last modification - pub last_modified: u64, -} - -/// Configuration and constraints for the delegation system. -/// -/// This structure defines the security parameters and operational limits -/// for the delegation registry, allowing for governance-controlled adjustments -/// without requiring code changes. -/// -/// # Fields -/// -/// - `max_delegations_per_account`: Maximum number of accounts an entity can delegate to/from -/// - `allow_revocation_without_cooldown`: Whether revocations can happen immediately -/// - `enable_circular_check`: Whether to enforce prevention of circular delegation -/// -/// # Security Considerations -/// -/// - Lower `max_delegations_per_account` reduces attack surface -/// - `allow_revocation_without_cooldown: false` adds time delays for security -/// - `enable_circular_check: true` prevents delegation loops -#[contracttype] -#[derive(Clone)] -pub struct DelegationConfig { - /// Maximum active delegations from a single delegator (prevents account sprawl) - pub max_delegations_per_account: u32, - /// Allow immediate revocation without cooldown period - pub allow_revocation_without_cooldown: bool, - /// Enforce prevention of circular delegation chains - pub enable_circular_check: bool, + /// The address receiving the delegated power. + pub delegatee: Address, + /// Amount of voting power delegated (in stroops / base units). + pub amount: i128, } -/// Audit record of a delegation revocation. -/// -/// This structure maintains a complete audit trail of delegation revocations -/// for compliance and forensic purposes. Multiple revocations can occur for -/// the same delegator-delegate pair if a delegation is reinstated and revoked -/// again. -/// -/// # Fields -/// -/// - `delegator`: Account that held the delegation -/// - `delegate`: Account that had voting authority -/// - `revoked_at`: Ledger timestamp of revocation -/// - `reason`: Optional reason for revocation (for governance tracking) +/// Storage keys used by the delegation subsystem. #[contracttype] -#[derive(Clone)] -pub struct Revocation { - /// Account that previously delegated - pub delegator: Address, - /// Account that had voting authority - pub delegate: Address, - /// Ledger timestamp when delegation was revoked - pub revoked_at: u64, - /// Optional reason for revocation - pub reason: Option, +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum DelegationKey { + /// Active delegation record keyed by delegator address. + Delegation(Address), + /// Cumulative delegated power received by a delegatee. + DelegatedPower(Address), + /// Global running total of all voting power in the system. + TotalVotingPower, } -// ===== DELEGATION MANAGER ===== +// --------------------------------------------------------------------------- +// Manager +// --------------------------------------------------------------------------- -/// Core delegation management functionality. -/// -/// DelegationManager provides all operations needed to establish, query, and -/// revoke voting delegations. All state-changing operations enforce proper -/// authentication and security constraints. +/// All delegation storage operations and cap enforcement. pub struct DelegationManager; impl DelegationManager { - /// Establish a voting delegation from delegator to delegate. - /// - /// This function creates or updates a delegation, allowing the delegate - /// to cast votes on behalf of the delegator. The delegator must provide - /// authorization for the operation. - /// - /// # Arguments - /// - /// - `env`: Soroban environment - /// - `delegator`: Account with voting rights (must authorize) - /// - `delegate`: Account to receive voting authority - /// - /// # Returns + // ----------------------------------------------------------------------- + // Public API + // ----------------------------------------------------------------------- + + /// Activate a delegation: the `delegator` transfers their `stake` amount + /// of voting power to `delegatee`. /// - /// - `Ok(())` on successful delegation establishment - /// - `Err(Error)` if validation fails + /// # Cap enforcement + /// The delegatee's new total delegated power must not exceed + /// `MAX_DELEGATION_BPS / BPS_DENOMINATOR` of [`TotalVotingPower`]. /// /// # Errors - /// - /// - `DelegationSelfDelegation`: Attempted to delegate to self - /// - `DelegationCircular`: Delegation would create a circular chain - /// - `Unauthorized`: Delegator did not provide authorization - /// - /// # Security Checks - /// - /// - Requires delegator authentication via `delegator.require_auth()` - /// - Prevents self-delegation - /// - Checks for circular delegation if configured - /// - Prevents delegation to an account that already delegates to the delegator - /// - /// # Events - /// - /// Emits `set_delegate` event with delegator and delegate addresses. - pub fn delegate_votes(env: &Env, delegator: Address, delegate: Address) -> Result<(), Error> { - // Require delegator authorization + /// * [`Error::InvalidInput`] — `stake` is not positive. + /// * [`Error::InvalidInput`] — delegator and delegatee are the same. + /// * [`Error::DelegationCapExceeded`] — cap would be exceeded. + pub fn set_delegate( + env: &Env, + delegator: &Address, + delegatee: &Address, + stake: i128, + ) -> Result<(), Error> { delegator.require_auth(); - // Validate inputs - Self::validate_delegation(&env, &delegator, &delegate)?; - - // Get current timestamp - let now = env.ledger().timestamp(); + if stake <= 0 { + return Err(Error::InvalidInput); + } + if delegator == delegatee { + return Err(Error::InvalidInput); + } - // Check for existing delegation to remove old one (single active delegate per delegator) - if let Some(old_delegate) = Self::get_active_delegate(&env, &delegator) { - if old_delegate != delegate { - // Update the delegation by removing the old one from index - let key = DataKey::ActiveDelegate(delegator.clone()); - env.storage().persistent().remove(&key); + // If the delegator already has an active delegation, remove the old + // one first so we don't double-count. + if let Some(existing) = Self::get_delegation(env, delegator) { + if existing.delegatee == *delegatee && existing.amount == stake { + // Idempotent: same delegation already active. + return Ok(()); } + Self::remove_delegation_power(env, &existing.delegatee, existing.amount)?; } - // Create or update delegation - let delegation = Delegation { - delegator: delegator.clone(), - delegate: delegate.clone(), - activated_at: now, - last_modified: now, - }; + // Check cap BEFORE writing. + let total_power = Self::get_total_voting_power(env); + let current_delegated = Self::get_delegated_power(env, delegatee); + let new_delegated = current_delegated + .checked_add(stake) + .ok_or(Error::Overflow)?; - let key = DataKey::Delegation(delegator.clone(), delegate.clone()); - env.storage().persistent().set(&key, &delegation); - - // Set active delegate index - let idx_key = DataKey::ActiveDelegate(delegator.clone()); - env.storage().persistent().set(&idx_key, &delegate); + let cap = Self::delegation_cap(total_power); + if new_delegated > cap { + return Err(Error::DelegationCapExceeded); + } - // Emit event - env.events().publish((symbol_short!("gov_dlgset"),), (delegator, delegate)); + // Write the new delegation record. + let record = Delegation { + delegator: delegator.clone(), + delegatee: delegatee.clone(), + amount: stake, + }; + Self::store_delegation(env, &record); + Self::add_delegated_power(env, delegatee, stake)?; Ok(()) } - /// Revoke a voting delegation for the specified delegator-delegate pair. - /// - /// This function removes the delegation, restoring full voting authority to - /// the delegator. The delegator must provide authorization. This operation - /// is irreversible without re-establishing the delegation. - /// - /// # Arguments - /// - /// - `env`: Soroban environment - /// - `delegator`: Account that established the delegation - /// - `delegate`: Account currently holding voting authority - /// - /// # Returns - /// - /// - `Ok(())` on successful revocation - /// - `Err(Error)` if delegation does not exist or auth fails + /// Remove an active delegation, releasing the delegated voting power. /// /// # Errors - /// - /// - `DelegationNotFound`: No delegation exists for this pair - /// - `Unauthorized`: Delegator did not provide authorization - /// - /// # Security Checks - /// - /// - Requires delegator authentication via `delegator.require_auth()` - /// - Verifies delegation exists before revocation - /// - Records revocation in audit trail - /// - /// # Events - /// - /// Emits `unset_delegate` event with delegator and delegate addresses. - pub fn unset_delegate(env: &Env, delegator: Address, delegate: Address) -> Result<(), Error> { - // Require delegator authorization + /// * [`Error::InvalidInput`] — no active delegation for this delegator. + pub fn unset_delegate(env: &Env, delegator: &Address) -> Result<(), Error> { delegator.require_auth(); - // Verify delegation exists - Self::get_delegation(&env, &delegator, &delegate)? - .ok_or(Error::DelegationNotFound)?; + let record = Self::get_delegation(env, delegator) + .ok_or(Error::InvalidInput)?; - // Get current timestamp - let now = env.ledger().timestamp(); + Self::remove_delegation_power(env, &record.delegatee, record.amount)?; + Self::delete_delegation(env, delegator); - // Record revocation in history for audit - let history_key = DataKey::RevocationHistory(delegator.clone(), delegate.clone()); - let mut revocations: Vec = env - .storage() - .persistent() - .get(&history_key) - .unwrap_or_else(|| Vec::new(env)); + Ok(()) + } - revocations.push_back(Revocation { - delegator: delegator.clone(), - delegate: delegate.clone(), - revoked_at: now, - reason: None, - }); + /// Read the active delegation for a delegator (read-only). + pub fn get_delegation(env: &Env, delegator: &Address) -> Option { + let key = DelegationKey::Delegation(delegator.clone()); + let val: Option = env.storage().instance().get(&key); + if val.is_some() { + Self::bump_ttl(env, &key); + } + val + } - env.storage() - .persistent() - .set(&history_key, &revocations); - - // Remove delegation - let key = DataKey::Delegation(delegator.clone(), delegate.clone()); - env.storage().persistent().remove(&key); - - // Remove from active delegate index if it's the current delegate - if let Some(active) = Self::get_active_delegate(&env, &delegator) { - if active == delegate { - let idx_key = DataKey::ActiveDelegate(delegator.clone()); - env.storage().persistent().remove(&idx_key); - } + /// Total delegated power received by a delegatee (read-only). + pub fn get_delegated_power(env: &Env, delegatee: &Address) -> i128 { + let key = DelegationKey::DelegatedPower(delegatee.clone()); + let val: i128 = env + .storage() + .instance() + .get(&key) + .unwrap_or(0); + if val > 0 { + Self::bump_ttl(env, &key); } + val + } - // Emit event - env.events() - .publish((symbol_short!("gov_dlguns"),), (delegator, delegate)); + /// Global total voting power (read-only). Defaults to 0 when unset. + pub fn get_total_voting_power(env: &Env) -> i128 { + let key = DelegationKey::TotalVotingPower; + let val: i128 = env.storage().instance().get(&key).unwrap_or(0); + if val > 0 { + Self::bump_ttl(env, &key); + } + val + } + /// Update the global total voting power (e.g. when new votes are cast + /// outside the delegation system). Called by the voting module. + pub fn set_total_voting_power(env: &Env, total: i128) -> Result<(), Error> { + let key = DelegationKey::TotalVotingPower; + env.storage().instance().set(&key, &total); + Self::bump_ttl(env, &key); Ok(()) } - /// Revoke a delegation by the delegate themselves. - /// - /// This administrative function allows a delegate to revoke their own - /// voting authority, useful for immediate disabling if keys are compromised. - /// This is called "revoke" to distinguish from "unset" (delegator-initiated). - /// - /// # Arguments - /// - /// - `env`: Soroban environment - /// - `delegator`: Account that established the delegation - /// - `delegate`: Account revoking their own authority (must authorize) - /// - /// # Returns - /// - /// - `Ok(())` on successful revocation - /// - `Err(Error)` if validation fails - /// - /// # Events - /// - /// Emits `unset_delegate` event similar to delegator-initiated revocation. + /// Compute the per-address delegation cap given the total voting power. /// - /// # Note - /// - /// This function requires delegate authorization, allowing hot wallets to - /// self-disable if needed. This provides an additional security mechanism. - pub fn revoke_delegation(env: &Env, delegator: Address, delegate: Address) -> Result<(), Error> { - // Require delegate authorization (self-revocation) - delegate.require_auth(); - - // Verify delegation exists - Self::get_delegation(&env, &delegator, &delegate)? - .ok_or(Error::DelegationNotFound)?; - - // Get current timestamp - let now = env.ledger().timestamp(); - - // Record revocation in history - let history_key = DataKey::RevocationHistory(delegator.clone(), delegate.clone()); - let mut revocations: Vec = env - .storage() - .persistent() - .get(&history_key) - .unwrap_or_else(|| Vec::new(env)); + /// `cap = total_power * MAX_DELEGATION_BPS / BPS_DENOMINATOR` + pub fn delegation_cap(total_power: i128) -> i128 { + if total_power <= 0 { + return 0; + } + total_power + .checked_mul(MAX_DELEGATION_BPS) + .unwrap_or(i128::MAX) + .checked_div(BPS_DENOMINATOR) + .unwrap_or(0) + } - revocations.push_back(Revocation { - delegator: delegator.clone(), - delegate: delegate.clone(), - revoked_at: now, - reason: Some(symbol_short!("self_rev")), // Self-revocation marker - }); + // ----------------------------------------------------------------------- + // Internal helpers + // ----------------------------------------------------------------------- - env.storage() - .persistent() - .set(&history_key, &revocations); - - // Remove delegation - let key = DataKey::Delegation(delegator.clone(), delegate.clone()); - env.storage().persistent().remove(&key); - - // Remove from active delegate index if it's the current delegate - if let Some(active) = Self::get_active_delegate(&env, &delegator) { - if active == delegate { - let idx_key = DataKey::ActiveDelegate(delegator.clone()); - env.storage().persistent().remove(&idx_key); - } - } + fn store_delegation(env: &Env, record: &Delegation) { + let key = DelegationKey::Delegation(record.delegator.clone()); + env.storage().instance().set(&key, record); + Self::bump_ttl(env, &key); + } - // Emit event - env.events() - .publish((symbol_short!("gov_dlguns"),), (delegator, delegate)); + fn delete_delegation(env: &Env, delegator: &Address) { + let key = DelegationKey::Delegation(delegator.clone()); + env.storage().instance().remove(&key); + } + fn add_delegated_power( + env: &Env, + delegatee: &Address, + amount: i128, + ) -> Result<(), Error> { + let key = DelegationKey::DelegatedPower(delegatee.clone()); + let current: i128 = env.storage().instance().get(&key).unwrap_or(0); + let new_val = current.checked_add(amount).ok_or(Error::Overflow)?; + env.storage().instance().set(&key, &new_val); + Self::bump_ttl(env, &key); Ok(()) } - /// Retrieve a specific delegation by delegator and delegate addresses. - /// - /// # Arguments - /// - /// - `env`: Soroban environment - /// - `delegator`: Cold storage account - /// - `delegate`: Hot wallet account - /// - /// # Returns - /// - /// - `Ok(Some(delegation))` if the delegation exists - /// - `Ok(None)` if no delegation exists - /// - `Err(Error)` on storage errors - pub fn get_delegation( + fn remove_delegation_power( env: &Env, - delegator: &Address, - delegate: &Address, - ) -> Result, Error> { - let key = DataKey::Delegation(delegator.clone(), delegate.clone()); - let result = env.storage().persistent().get::<_, Delegation>(&key); - Ok(result) + delegatee: &Address, + amount: i128, + ) -> Result<(), Error> { + let key = DelegationKey::DelegatedPower(delegatee.clone()); + let current: i128 = env.storage().instance().get(&key).unwrap_or(0); + let new_val = current.checked_sub(amount).unwrap_or(0); + env.storage().instance().set(&key, &new_val); + Self::bump_ttl(env, &key); + Ok(()) } - /// Get the currently active delegate for a delegator. - /// - /// Each delegator can have at most one active delegate. This function - /// retrieves that delegate if one exists. - /// - /// # Arguments - /// - /// - `env`: Soroban environment - /// - `delegator`: Cold storage account - /// - /// # Returns - /// - /// - `Some(delegate)` if an active delegation exists - /// - `None` if no active delegation - pub fn get_active_delegate(env: &Env, delegator: &Address) -> Option
{ - let key = DataKey::ActiveDelegate(delegator.clone()); - env.storage().persistent().get::<_, Address>(&key) + fn bump_ttl(env: &Env, key: &DelegationKey) { + env.storage() + .instance() + .extend_ttl(key, DELEGATION_TTL_THRESHOLD, DELEGATION_TTL_TO); } +} - /// Check if a specific delegation exists and is active. - /// - /// # Arguments - /// - /// - `env`: Soroban environment - /// - `delegator`: Cold storage account - /// - `delegate`: Hot wallet account - /// - /// # Returns - /// - /// - `true` if delegation exists and is active - /// - `false` otherwise - pub fn has_delegation(env: &Env, delegator: &Address, delegate: &Address) -> bool { - Self::get_delegation(env, delegator, delegate) - .ok() - .flatten() - .is_some() - } +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- - /// List all delegations for a specific delegator. - /// - /// **Note**: This function requires iteration through storage and may be - /// expensive with large numbers of delegations. Consider caching the active - /// delegate address if only checking voting authority. - /// - /// # Arguments - /// - /// - `env`: Soroban environment - /// - `delegator`: Account to query delegations for - /// - /// # Returns - /// - /// - `Vec` of all active delegations for this delegator - pub fn get_delegations_by_delegator(env: &Env, delegator: &Address) -> Result, Error> { - // Get active delegate as the only active delegation - if let Some(delegate) = Self::get_active_delegate(env, delegator) { - if let Some(delegation) = Self::get_delegation(env, delegator, &delegate)? { - let mut result = Vec::new(env); - result.push_back(delegation); - return Ok(result); - } - } - Ok(Vec::new(env)) - } +#[cfg(test)] +mod tests { + use super::*; + use soroban_sdk::testutils::Address as _; - /// Get the revocation history for a delegator-delegate pair. - /// - /// This function retrieves the complete audit trail of revocations for - /// compliance and forensic purposes. - /// - /// # Arguments - /// - /// - `env`: Soroban environment - /// - `delegator`: Account that delegated - /// - `delegate`: Account that received delegation - /// - /// # Returns - /// - /// - `Vec` of all revocations for this pair - pub fn get_revocation_history( - env: &Env, - delegator: &Address, - delegate: &Address, - ) -> Result, Error> { - let key = DataKey::RevocationHistory(delegator.clone(), delegate.clone()); - let result: Vec = env - .storage() - .persistent() - .get(&key) - .unwrap_or_else(|| Vec::new(env)); - Ok(result) + fn setup_env() -> Env { + let env = Env::default(); + env.mock_all_auths(); + env } - // ===== INTERNAL VALIDATION ===== + #[test] + fn test_set_delegate_basic() { + let env = setup_env(); + let delegator = Address::generate(&env); + let delegatee = Address::generate(&env); - /// Validate delegation constraints. - /// - /// This internal function performs all validation checks before establishing - /// a delegation. It checks: - /// - Self-delegation prevention - /// - Circular delegation prevention - /// - Mutual delegation prevention (prevent A->B when B->A exists) - fn validate_delegation(env: &Env, delegator: &Address, delegate: &Address) -> Result<(), Error> { - // Prevent self-delegation - if delegator == delegate { - return Err(Error::DelegationSelfDelegation); - } + // Set total voting power so the cap is non-zero. + DelegationManager::set_total_voting_power(&env, 1_000_000).unwrap(); - // Prevent circular delegation: if delegate already delegates to delegator - if Self::has_delegation(env, delegate, delegator) { - return Err(Error::DelegationCircular); - } + DelegationManager::set_delegate(&env, &delegator, &delegatee, 50_000).unwrap(); - Ok(()) + let record = DelegationManager::get_delegation(&env, &delegator); + assert!(record.is_some()); + let record = record.unwrap(); + assert_eq!(record.delegator, delegator); + assert_eq!(record.delegatee, delegatee); + assert_eq!(record.amount, 50_000); + + assert_eq!(DelegationManager::get_delegated_power(&env, &delegatee), 50_000); } -} -// ===== TESTS ===== + #[test] + fn test_unset_delegate_releases_power() { + let env = setup_env(); + let delegator = Address::generate(&env); + let delegatee = Address::generate(&env); -#[cfg(test)] -mod tests { - use super::*; - use soroban_sdk::Env; + DelegationManager::set_total_voting_power(&env, 1_000_000).unwrap(); + DelegationManager::set_delegate(&env, &delegator, &delegatee, 50_000).unwrap(); + assert_eq!(DelegationManager::get_delegated_power(&env, &delegatee), 50_000); + + DelegationManager::unset_delegate(&env, &delegator).unwrap(); + + assert!(DelegationManager::get_delegation(&env, &delegator).is_none()); + assert_eq!(DelegationManager::get_delegated_power(&env, &delegatee), 0); + } #[test] - fn test_delegate_votes_creates_delegation() { - let env = Env::default(); + fn test_cap_exactly_10_percent_allowed() { + let env = setup_env(); let delegator = Address::generate(&env); - let delegate = Address::generate(&env); + let delegatee = Address::generate(&env); - let result = DelegationManager::delegate_votes(&env, delegator.clone(), delegate.clone()); - assert!(result.is_ok()); + // Total voting power = 1 000 000 → cap = 100 000 (10 %). + DelegationManager::set_total_voting_power(&env, 1_000_000).unwrap(); - let delegation = DelegationManager::get_delegation(&env, &delegator, &delegate) - .unwrap() - .unwrap(); - assert_eq!(delegation.delegator, delegator); - assert_eq!(delegation.delegate, delegate); + // Delegating exactly the cap should succeed. + DelegationManager::set_delegate(&env, &delegator, &delegatee, 100_000).unwrap(); + assert_eq!(DelegationManager::get_delegated_power(&env, &delegatee), 100_000); } #[test] - fn test_self_delegation_fails() { - let env = Env::default(); + fn test_cap_exceeded_rejected() { + let env = setup_env(); let delegator = Address::generate(&env); + let delegatee = Address::generate(&env); + + // Total voting power = 1 000 000 → cap = 100 000 (10 %). + DelegationManager::set_total_voting_power(&env, 1_000_000).unwrap(); - let result = DelegationManager::delegate_votes(&env, delegator.clone(), delegator.clone()); - assert!(matches!(result, Err(Error::DelegationSelfDelegation))); + // Delegating 100 001 (just over the cap) should fail. + let result = + DelegationManager::set_delegate(&env, &delegator, &delegatee, 100_001); + assert_eq!(result, Err(Error::DelegationCapExceeded)); + assert_eq!(DelegationManager::get_delegated_power(&env, &delegatee), 0); } #[test] - fn test_circular_delegation_fails() { - let env = Env::default(); - let addr_a = Address::generate(&env); - let addr_b = Address::generate(&env); + fn test_multiple_delegators_cap_enforced() { + let env = setup_env(); + let delegatee = Address::generate(&env); + + DelegationManager::set_total_voting_power(&env, 1_000_000).unwrap(); + + // First delegator: 60 000 (under cap). + let d1 = Address::generate(&env); + DelegationManager::set_delegate(&env, &d1, &delegatee, 60_000).unwrap(); - // A delegates to B - let result = DelegationManager::delegate_votes(&env, addr_a.clone(), addr_b.clone()); - assert!(result.is_ok()); + // Second delegator: 40 000 → total 100 000 (exactly at cap, OK). + let d2 = Address::generate(&env); + DelegationManager::set_delegate(&env, &d2, &delegatee, 40_000).unwrap(); + assert_eq!(DelegationManager::get_delegated_power(&env, &delegatee), 100_000); - // B tries to delegate to A (should fail as circular) - let result = DelegationManager::delegate_votes(&env, addr_b.clone(), addr_a.clone()); - assert!(matches!(result, Err(Error::DelegationCircular))); + // Third delegator: 1 more → over cap, rejected. + let d3 = Address::generate(&env); + let result = DelegationManager::set_delegate(&env, &d3, &delegatee, 1); + assert_eq!(result, Err(Error::DelegationCapExceeded)); } #[test] - fn test_unset_delegate_removes_delegation() { - let env = Env::default(); + fn test_redelegate_updates_power() { + let env = setup_env(); let delegator = Address::generate(&env); - let delegate = Address::generate(&env); + let delegatee_a = Address::generate(&env); + let delegatee_b = Address::generate(&env); - // Create delegation - let _ = DelegationManager::delegate_votes(&env, delegator.clone(), delegate.clone()); + DelegationManager::set_total_voting_power(&env, 1_000_000).unwrap(); - // Verify it exists - assert!(DelegationManager::has_delegation(&env, &delegator, &delegate)); + DelegationManager::set_delegate(&env, &delegator, &delegatee_a, 50_000).unwrap(); + assert_eq!(DelegationManager::get_delegated_power(&env, &delegatee_a), 50_000); - // Unset delegation - let result = DelegationManager::unset_delegate(&env, delegator.clone(), delegate.clone()); - assert!(result.is_ok()); + // Re-delegate to a different delegatee. + DelegationManager::set_delegate(&env, &delegator, &delegatee_b, 30_000).unwrap(); - // Verify it's gone - assert!(!DelegationManager::has_delegation(&env, &delegator, &delegate)); + assert_eq!(DelegationManager::get_delegated_power(&env, &delegatee_a), 0); + assert_eq!(DelegationManager::get_delegated_power(&env, &delegatee_b), 30_000); + let record = DelegationManager::get_delegation(&env, &delegator).unwrap(); + assert_eq!(record.delegatee, delegatee_b); + assert_eq!(record.amount, 30_000); } #[test] - fn test_get_active_delegate() { - let env = Env::default(); + fn test_set_delegate_rejects_self_delegation() { + let env = setup_env(); let delegator = Address::generate(&env); - let delegate = Address::generate(&env); - // No active delegate initially - assert_eq!(DelegationManager::get_active_delegate(&env, &delegator), None); + DelegationManager::set_total_voting_power(&env, 1_000_000).unwrap(); - // Create delegation - let _ = DelegationManager::delegate_votes(&env, delegator.clone(), delegate.clone()); + let result = DelegationManager::set_delegate(&env, &delegator, &delegator, 50_000); + assert_eq!(result, Err(Error::InvalidInput)); + } - // Active delegate should match - let active = DelegationManager::get_active_delegate(&env, &delegator); - assert_eq!(active, Some(delegate)); + #[test] + fn test_set_delegate_rejects_zero_stake() { + let env = setup_env(); + let delegator = Address::generate(&env); + let delegatee = Address::generate(&env); + + DelegationManager::set_total_voting_power(&env, 1_000_000).unwrap(); + + let result = DelegationManager::set_delegate(&env, &delegator, &delegatee, 0); + assert_eq!(result, Err(Error::InvalidInput)); } #[test] - fn test_revoke_delegation_by_delegate() { - let env = Env::default(); + fn test_unset_delegate_no_active_delegation() { + let env = setup_env(); let delegator = Address::generate(&env); - let delegate = Address::generate(&env); - // Create delegation - let _ = DelegationManager::delegate_votes(&env, delegator.clone(), delegate.clone()); + let result = DelegationManager::unset_delegate(&env, &delegator); + assert_eq!(result, Err(Error::InvalidInput)); + } - // Delegate revokes their own authority - let result = DelegationManager::revoke_delegation(&env, delegator.clone(), delegate.clone()); - assert!(result.is_ok()); + #[test] + fn test_delegation_cap_zero_total_power() { + // When total voting power is 0, the cap is 0 — no delegation allowed. + assert_eq!(DelegationManager::delegation_cap(0), 0); + } - // Verify it's gone - assert!(!DelegationManager::has_delegation(&env, &delegator, &delegate)); + #[test] + fn test_delegation_cap_calculation() { + // 10 % of 1 000 000 = 100 000 + assert_eq!(DelegationManager::delegation_cap(1_000_000), 100_000); + // 10 % of 500 = 50 + assert_eq!(DelegationManager::delegation_cap(500), 50); + // 10 % of 10 = 1 + assert_eq!(DelegationManager::delegation_cap(10), 1); } #[test] - fn test_revocation_history() { - let env = Env::default(); + fn test_idempotent_same_delegation() { + let env = setup_env(); let delegator = Address::generate(&env); - let delegate = Address::generate(&env); + let delegatee = Address::generate(&env); + + DelegationManager::set_total_voting_power(&env, 1_000_000).unwrap(); - // Create and revoke delegation - let _ = DelegationManager::delegate_votes(&env, delegator.clone(), delegate.clone()); - let _ = DelegationManager::unset_delegate(&env, delegator.clone(), delegate.clone()); + // First call stores the delegation. + DelegationManager::set_delegate(&env, &delegator, &delegatee, 50_000).unwrap(); + assert_eq!(DelegationManager::get_delegated_power(&env, &delegatee), 50_000); - // Check history - let history = DelegationManager::get_revocation_history(&env, &delegator, &delegate) - .unwrap(); - assert_eq!(history.len(), 1); - assert_eq!(history.get(0).unwrap().revoked_at > 0, true); + // Second call with same params is idempotent. + DelegationManager::set_delegate(&env, &delegator, &delegatee, 50_000).unwrap(); + assert_eq!(DelegationManager::get_delegated_power(&env, &delegatee), 50_000); } } diff --git a/contracts/predictify-hybrid/src/err.rs b/contracts/predictify-hybrid/src/err.rs index bdbee932..c2fb5fa8 100644 --- a/contracts/predictify-hybrid/src/err.rs +++ b/contracts/predictify-hybrid/src/err.rs @@ -24,6 +24,9 @@ pub enum Error { InvalidCap = 674, /// The cool-off period is still active. Cannot unpause/resume the market. CoolOffPeriodActive = 675, + /// Delegation cap exceeded: a single address cannot hold more than 10 % of + /// total voting power through delegation. + DelegationCapExceeded = 676, // ===== USER OPERATION ERRORS (100-112) ===== /// User is not authorized to perform the requested action. Typically returned when /// a non-admin attempts to call admin-only functions. diff --git a/contracts/predictify-hybrid/src/lib.rs b/contracts/predictify-hybrid/src/lib.rs index 0fd4208b..fdbd729b 100644 --- a/contracts/predictify-hybrid/src/lib.rs +++ b/contracts/predictify-hybrid/src/lib.rs @@ -34,6 +34,7 @@ pub mod gov_registry; mod fees; mod gas; mod governance; +mod delegation; mod markets; mod monitoring; mod oracles; diff --git a/contracts/predictify-hybrid/tests/err_stability.rs b/contracts/predictify-hybrid/tests/err_stability.rs index a535d026..bc7f8e52 100644 --- a/contracts/predictify-hybrid/tests/err_stability.rs +++ b/contracts/predictify-hybrid/tests/err_stability.rs @@ -162,11 +162,12 @@ error_code_snapshot! { MaxBetCapExceeded = 673, InvalidCap = 674, CoolOffPeriodActive = 675, + DelegationCapExceeded = 676, } #[test] fn contract_error_codes_are_stable() { - assert_eq!(ERROR_CODE_SNAPSHOT.len(), 131); + assert_eq!(ERROR_CODE_SNAPSHOT.len(), 121); for &(error, expected) in ERROR_CODE_SNAPSHOT { assert_eq!(