diff --git a/prediction_market/src/lib.rs b/prediction_market/src/lib.rs index 8145f0e..1355ee8 100644 --- a/prediction_market/src/lib.rs +++ b/prediction_market/src/lib.rs @@ -1,8 +1,8 @@ #![no_std] use soroban_sdk::{ - contract, contracterror, contractimpl, contracttype, token, vec, Address, BytesN, Env, IntoVal, - String, Symbol, Val, Vec, + contract, contracterror, contractimpl, contracttype, token, vec, Address, BytesN, Env, Executable, + IntoVal, String, Symbol, Val, Vec, }; // ── Event schema (issue #52) ──────────────────────────────────────────────── @@ -47,6 +47,8 @@ const LOSE_TOKENS: i128 = 2_0000000; // accumulator to an arbitrary address in one call. const WITHDRAW_DELAY_SECS: u64 = 86_400; // 24h timelock between request and payout const MAX_WITHDRAWAL_BPS: i128 = 2_000; // per-request cap: 20% of accumulated fees +const CONFIG_DELAY_SECS: u64 = 86_400; // issue #51: dispute window before Config is live +const MAX_GOVERNORS: u32 = 10; // TTL: ~1yr threshold, ~2yr extend (mainnet: ~1 ledger/5s) const TTL_BUMP: u32 = 3_153_600; @@ -96,6 +98,14 @@ pub enum MarketError { WithdrawalTooSoon = 25, ContractPaused = 26, InvalidDuration = 27, // issue #10: duration below the minimum + InvalidDependency = 28, // issue #51: address is not the expected executable kind + WasmHashMismatch = 29, // issue #51: live WASM hash != pinned / pending hash + ConfigChangeExists = 30, + NoConfigChange = 31, + ConfigChangeTooSoon = 32, + InsufficientApprovals = 33, + AlreadyApproved = 34, + InvalidThreshold = 35, /// A dependency (referral_registry or leaderboard) reported an /// interface_version this contract wasn't built against (issue #84). /// Note: a matching version number alone does not prove the callee's @@ -128,6 +138,12 @@ pub enum DataKey { Payout(u64, Address), // i128 — exact payout computed at resolve time // ── Timelocked withdrawal requests (issue #12) ─────────────────────── PendingWithdrawal(Address), // caller -> WithdrawalRequest + // ── Dependency governance (issue #51) ──────────────────────────────── + Governor(Address), + GovernorCount, + GovernorThreshold, + PendingConfig, + PinnedHashes, // ── Emergency circuit-breaker (issue #83) ───────────────────────────── Paused, } @@ -142,6 +158,26 @@ pub struct Config { pub xlm_sac: Address, } +/// WASM hashes (or the SAC sentinel) pinned for each Config role. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PinnedHashes { + pub token: BytesN<32>, + pub referral: BytesN<32>, + pub leaderboard: BytesN<32>, + pub xlm_sac: BytesN<32>, +} + +/// Timelocked, multi-sig Config mutation. Inactive until execute_set_config. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PendingConfigChange { + pub cfg: Config, + pub hashes: PinnedHashes, + pub requested_at: u64, + pub approvers: Vec
, +} + // ── BetEntry: Bet + Gross + BetCount in one slot ────────────────────────── #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] @@ -236,6 +272,28 @@ impl PredictionMarketContract { env.storage() .instance() .set(&DataKey::AccumulatedFees, &0_i128); + + // Bootstrap governance: the initializer is the first governor with + // a 1-of-1 threshold. Production deploys should add more governors + // and raise the threshold before relying on set_config. + env.storage() + .persistent() + .set(&DataKey::Governor(admin.clone()), &true); + env.storage().instance().set(&DataKey::GovernorCount, &1_u32); + env.storage() + .instance() + .set(&DataKey::GovernorThreshold, &1_u32); + if let Ok(hashes) = Self::fingerprint_config( + &env, + &token_contract, + &referral_contract, + &leaderboard_contract, + &xlm_sac, + ) { + env.storage() + .instance() + .set(&DataKey::PinnedHashes, &hashes); + } env.events().publish( (Symbol::new(&env, "initialized"), admin), (token_contract, referral_contract, leaderboard_contract, xlm_sac), @@ -256,16 +314,41 @@ impl PredictionMarketContract { Ok(()) } - /// Update the packed Config (token / referral / leaderboard / xlm_sac). Admin only. - /// Used to correct an address set at initialize time. + /// Propose a Config change. Does **not** take effect immediately. + /// + /// Issue #51: live WASM hashes are read on-chain (not caller-supplied), + /// the proposal is emitted for monitors, and it only becomes active after + /// `CONFIG_DELAY_SECS` **and** `GovernorThreshold` approvals via + /// `execute_set_config`. Any governor can `cancel_set_config` in between. pub fn set_config( env: Env, - admin: Address, + caller: Address, token_contract: Address, referral_contract: Address, leaderboard_contract: Address, xlm_sac: Address, ) -> Result<(), MarketError> { + caller.require_auth(); + Self::require_governor(&env, &caller)?; + if env.storage().instance().has(&DataKey::PendingConfig) { + return Err(MarketError::ConfigChangeExists); + } + + let hashes = Self::fingerprint_config( + &env, + &token_contract, + &referral_contract, + &leaderboard_contract, + &xlm_sac, + )?; + let mut approvers: Vec
= Vec::new(&env); + approvers.push_back(caller.clone()); + let pending = PendingConfigChange { + cfg: Config { + token: token_contract, + referral: referral_contract, + leaderboard: leaderboard_contract, + xlm_sac, Self::require_admin(&env, &admin)?; admin.require_auth(); env.storage().instance().set( @@ -276,6 +359,87 @@ impl PredictionMarketContract { leaderboard: leaderboard_contract.clone(), xlm_sac: xlm_sac.clone(), }, + hashes, + requested_at: env.ledger().timestamp(), + approvers, + }; + env.storage() + .instance() + .set(&DataKey::PendingConfig, &pending); + env.events().publish( + (Symbol::new(&env, "cfg_req"), caller), + pending, + ); + Ok(()) + } + + /// A governor attests a pending Config change during the dispute window. + pub fn approve_set_config(env: Env, caller: Address) -> Result { + caller.require_auth(); + Self::require_governor(&env, &caller)?; + let mut pending: PendingConfigChange = env + .storage() + .instance() + .get(&DataKey::PendingConfig) + .ok_or(MarketError::NoConfigChange)?; + if Self::approver_index(&pending.approvers, &caller).is_some() { + return Err(MarketError::AlreadyApproved); + } + pending.approvers.push_back(caller.clone()); + let count = pending.approvers.len(); + env.storage() + .instance() + .set(&DataKey::PendingConfig, &pending); + env.events().publish( + (Symbol::new(&env, "cfg_ok"), caller), + count, + ); + Ok(count) + } + + /// Activate a matured, sufficiently-approved Config change. Re-reads live + /// executables so a dependency cannot swap WASM during the delay. + pub fn execute_set_config(env: Env, caller: Address) -> Result<(), MarketError> { + caller.require_auth(); + Self::require_governor(&env, &caller)?; + let pending: PendingConfigChange = env + .storage() + .instance() + .get(&DataKey::PendingConfig) + .ok_or(MarketError::NoConfigChange)?; + + let now = env.ledger().timestamp(); + if now < pending.requested_at || now - pending.requested_at < CONFIG_DELAY_SECS { + return Err(MarketError::ConfigChangeTooSoon); + } + let threshold: u32 = env + .storage() + .instance() + .get(&DataKey::GovernorThreshold) + .unwrap_or(1); + if pending.approvers.len() < threshold { + return Err(MarketError::InsufficientApprovals); + } + + let live = Self::fingerprint_config( + &env, + &pending.cfg.token, + &pending.cfg.referral, + &pending.cfg.leaderboard, + &pending.cfg.xlm_sac, + )?; + if live != pending.hashes { + return Err(MarketError::WasmHashMismatch); + } + + env.storage().instance().set(&DataKey::Cfg, &pending.cfg); + env.storage() + .instance() + .set(&DataKey::PinnedHashes, &pending.hashes); + env.storage().instance().remove(&DataKey::PendingConfig); + env.events().publish( + (Symbol::new(&env, "cfg_act"), caller), + pending.cfg, ); env.events().publish( (Symbol::new(&env, "config_changed"), admin), @@ -284,11 +448,123 @@ impl PredictionMarketContract { Ok(()) } + /// Cancel a pending Config change during the dispute window. + pub fn cancel_set_config(env: Env, caller: Address) -> Result<(), MarketError> { + caller.require_auth(); + Self::require_governor(&env, &caller)?; + if !env.storage().instance().has(&DataKey::PendingConfig) { + return Err(MarketError::NoConfigChange); + } + env.storage().instance().remove(&DataKey::PendingConfig); + env.events().publish( + (Symbol::new(&env, "cfg_can"), caller), + 1_u32, + ); + Ok(()) + } + + pub fn add_governor(env: Env, admin: Address, governor: Address) -> Result<(), MarketError> { + Self::require_admin(&env, &admin)?; + admin.require_auth(); + let key = DataKey::Governor(governor.clone()); + if env.storage().persistent().get(&key).unwrap_or(false) { + return Ok(()); + } + let count: u32 = env + .storage() + .instance() + .get(&DataKey::GovernorCount) + .unwrap_or(0); + if count >= MAX_GOVERNORS { + return Err(MarketError::RateLimitExceeded); + } + env.storage().persistent().set(&key, &true); + env.storage() + .instance() + .set(&DataKey::GovernorCount, &(count + 1)); + Ok(()) + } + + pub fn remove_governor(env: Env, admin: Address, governor: Address) -> Result<(), MarketError> { + Self::require_admin(&env, &admin)?; + admin.require_auth(); + let count: u32 = env + .storage() + .instance() + .get(&DataKey::GovernorCount) + .unwrap_or(0); + let threshold: u32 = env + .storage() + .instance() + .get(&DataKey::GovernorThreshold) + .unwrap_or(1); + if count <= threshold { + return Err(MarketError::InvalidThreshold); + } + let key = DataKey::Governor(governor); + if !env.storage().persistent().get(&key).unwrap_or(false) { + return Err(MarketError::NotAuthorized); + } + env.storage().persistent().remove(&key); + env.storage() + .instance() + .set(&DataKey::GovernorCount, &(count - 1)); + Ok(()) + } + + pub fn set_governor_threshold( + env: Env, + admin: Address, + threshold: u32, + ) -> Result<(), MarketError> { + Self::require_admin(&env, &admin)?; + admin.require_auth(); + let count: u32 = env + .storage() + .instance() + .get(&DataKey::GovernorCount) + .unwrap_or(0); + if threshold == 0 || threshold > count { + return Err(MarketError::InvalidThreshold); + } + env.storage() + .instance() + .set(&DataKey::GovernorThreshold, &threshold); + Ok(()) + } + /// Read the current Config (for verification/admin tooling). pub fn get_config(env: Env) -> Config { env.storage().instance().get(&DataKey::Cfg).unwrap() } + pub fn get_pending_config(env: Env) -> Option { + env.storage().instance().get(&DataKey::PendingConfig) + } + + pub fn get_pinned_hashes(env: Env) -> Option { + env.storage().instance().get(&DataKey::PinnedHashes) + } + + pub fn is_governor(env: Env, account: Address) -> bool { + env.storage() + .persistent() + .get(&DataKey::Governor(account)) + .unwrap_or(false) + } + + pub fn get_governor_threshold(env: Env) -> u32 { + env.storage() + .instance() + .get(&DataKey::GovernorThreshold) + .unwrap_or(1) + } + + pub fn get_governor_count(env: Env) -> u32 { + env.storage() + .instance() + .get(&DataKey::GovernorCount) + .unwrap_or(0) /// The cross-contract ABI version this deployment implements (issue #84). pub fn interface_version(_env: Env) -> u32 { INTERFACE_VERSION @@ -1170,6 +1446,67 @@ impl PredictionMarketContract { // ── Internal Helpers ────────────────────────────────────────────────── + fn sac_sentinel(env: &Env) -> BytesN<32> { + BytesN::from_array(env, &[0u8; 32]) + } + + /// Live executable fingerprint for a dependency. + /// token / referral / leaderboard must be WASM; xlm_sac must be the SAC. + fn fingerprint(env: &Env, addr: &Address, expect_sac: bool) -> Result, MarketError> { + match addr.executable() { + Some(Executable::Wasm(hash)) => { + if expect_sac { + return Err(MarketError::InvalidDependency); + } + Ok(hash) + } + Some(Executable::StellarAsset) => { + if !expect_sac { + return Err(MarketError::InvalidDependency); + } + Ok(Self::sac_sentinel(env)) + } + Some(Executable::Account) | None => Err(MarketError::InvalidDependency), + } + } + + fn fingerprint_config( + env: &Env, + token: &Address, + referral: &Address, + leaderboard: &Address, + xlm_sac: &Address, + ) -> Result { + Ok(PinnedHashes { + token: Self::fingerprint(env, token, false)?, + referral: Self::fingerprint(env, referral, false)?, + leaderboard: Self::fingerprint(env, leaderboard, false)?, + xlm_sac: Self::fingerprint(env, xlm_sac, true)?, + }) + } + + fn approver_index(approvers: &Vec
, who: &Address) -> Option { + let n = approvers.len(); + for i in 0..n { + if approvers.get(i).unwrap() == *who { + return Some(i); + } + } + None + } + + fn require_governor(env: &Env, caller: &Address) -> Result<(), MarketError> { + if env + .storage() + .persistent() + .get(&DataKey::Governor(caller.clone())) + .unwrap_or(false) + { + return Ok(()); + } + Err(MarketError::NotAuthorized) + } + #[inline] fn load_market(env: &Env, market_id: u64) -> Result { env.storage() diff --git a/prediction_market/src/tests.rs b/prediction_market/src/tests.rs index 08390af..346fef2 100644 --- a/prediction_market/src/tests.rs +++ b/prediction_market/src/tests.rs @@ -4,6 +4,7 @@ use soroban_sdk::{ contract, contractimpl, testutils::{storage::Persistent as _, Address as _, Ledger, LedgerInfo}, token::{Client as TokenClient, StellarAssetClient}, + BytesN, Env, String, Env, String, Symbol, TryFromVal, Val, }; @@ -1826,6 +1827,180 @@ fn test_cancel_withdrawal_request_still_works_while_paused() { assert!(t.client.get_pending_withdrawal(&recipient).is_none()); } +// ═══════════════════════════════════════════════════════════════════════════ +// SECURITY REGRESSION — issue #51 (set_config pinning / governance) +// ═══════════════════════════════════════════════════════════════════════════ + +fn second_leaderboard(t: &TestSetup) -> Address { + let id = t.env.register(LeaderboardContract, ()); + let client = leaderboard::LeaderboardContractClient::new(&t.env, &id); + client.initialize(&t.admin, &t.client.address, &t.referral_client.address); + id +} + +#[test] +fn test_set_config_does_not_apply_immediately() { + let t = setup(); + let cfg = t.client.get_config(); + let new_lb = second_leaderboard(&t); + + t.client.set_config( + &t.admin, + &cfg.token, + &cfg.referral, + &new_lb, + &cfg.xlm_sac, + ); + + // Live config is unchanged until execute_set_config after the delay. + assert_eq!(t.client.get_config().leaderboard, cfg.leaderboard); + let pending = t.client.get_pending_config().expect("pending change"); + assert_eq!(pending.cfg.leaderboard, new_lb); + assert_eq!(pending.approvers.len(), 1); +} + +#[test] +#[should_panic(expected = "Error(Contract, #28)")] +fn test_set_config_rejects_arbitrary_address() { + let t = setup(); + let cfg = t.client.get_config(); + let attacker = Address::generate(&t.env); + t.client.set_config( + &t.admin, + &cfg.token, + &attacker, + &cfg.leaderboard, + &cfg.xlm_sac, + ); +} + +#[test] +#[should_panic(expected = "Error(Contract, #28)")] +fn test_set_config_rejects_wasm_as_xlm_sac() { + let t = setup(); + let cfg = t.client.get_config(); + // A WASM/native contract must not be installable as the XLM SAC. + t.client.set_config( + &t.admin, + &cfg.token, + &cfg.referral, + &cfg.leaderboard, + &cfg.token, + ); +} + +#[test] +#[should_panic(expected = "Error(Contract, #32)")] +fn test_set_config_execute_before_delay() { + let t = setup(); + let cfg = t.client.get_config(); + let new_lb = second_leaderboard(&t); + t.client.set_config( + &t.admin, + &cfg.token, + &cfg.referral, + &new_lb, + &cfg.xlm_sac, + ); + t.client.execute_set_config(&t.admin); +} + +#[test] +fn test_set_config_execute_after_delay_and_pin() { + let t = setup(); + let cfg = t.client.get_config(); + let new_lb = second_leaderboard(&t); + t.client.set_config( + &t.admin, + &cfg.token, + &cfg.referral, + &new_lb, + &cfg.xlm_sac, + ); + advance_time(&t.env, CONFIG_DELAY_SECS); + t.client.execute_set_config(&t.admin); + + assert_eq!(t.client.get_config().leaderboard, new_lb); + assert!(t.client.get_pending_config().is_none()); + let pins = t.client.get_pinned_hashes().expect("pins"); + assert_eq!(pins.xlm_sac, BytesN::from_array(&t.env, &[0u8; 32])); +} + +#[test] +fn test_cancel_set_config_during_dispute_window() { + let t = setup(); + let cfg = t.client.get_config(); + let new_lb = second_leaderboard(&t); + t.client.set_config( + &t.admin, + &cfg.token, + &cfg.referral, + &new_lb, + &cfg.xlm_sac, + ); + t.client.cancel_set_config(&t.admin); + assert!(t.client.get_pending_config().is_none()); + assert_eq!(t.client.get_config().leaderboard, cfg.leaderboard); +} + +#[test] +#[should_panic(expected = "Error(Contract, #33)")] +fn test_set_config_multisig_requires_threshold() { + let t = setup(); + let g2 = Address::generate(&t.env); + t.client.add_governor(&t.admin, &g2); + t.client.set_governor_threshold(&t.admin, &2_u32); + + let cfg = t.client.get_config(); + let new_lb = second_leaderboard(&t); + t.client.set_config( + &t.admin, + &cfg.token, + &cfg.referral, + &new_lb, + &cfg.xlm_sac, + ); + advance_time(&t.env, CONFIG_DELAY_SECS); + // Only the proposer approved (1 of 2). + t.client.execute_set_config(&t.admin); +} + +#[test] +fn test_set_config_multisig_execute_with_second_approval() { + let t = setup(); + let g2 = Address::generate(&t.env); + t.client.add_governor(&t.admin, &g2); + t.client.set_governor_threshold(&t.admin, &2_u32); + + let cfg = t.client.get_config(); + let new_lb = second_leaderboard(&t); + t.client.set_config( + &t.admin, + &cfg.token, + &cfg.referral, + &new_lb, + &cfg.xlm_sac, + ); + t.client.approve_set_config(&g2); + advance_time(&t.env, CONFIG_DELAY_SECS); + t.client.execute_set_config(&g2); + + assert_eq!(t.client.get_config().leaderboard, new_lb); +} + +#[test] +#[should_panic(expected = "Error(Contract, #18)")] +fn test_set_config_non_governor_rejected() { + let t = setup(); + let cfg = t.client.get_config(); + let stranger = Address::generate(&t.env); + t.client.set_config( + &stranger, + &cfg.token, + &cfg.referral, + &cfg.leaderboard, + &cfg.xlm_sac, + ); fn last_event_name(env: &Env) -> Symbol { let events = env.events().all(); let last = events.get(events.len() - 1).unwrap();