diff --git a/contracts/market_factory/src/lib.rs b/contracts/market_factory/src/lib.rs index 190a3b29..01b15cde 100644 --- a/contracts/market_factory/src/lib.rs +++ b/contracts/market_factory/src/lib.rs @@ -1,988 +1,988 @@ -#![no_std] -//! ============================================================ -//! BOXMEOUT — MarketFactory Contract -//! Deploys and tracks Market contract instances. -//! ============================================================ -use soroban_sdk::{contract, contractimpl, Address, Bytes, BytesN, Env, Map, String, Symbol, Vec}; - -use shared::{errors::ContractError, events, types::{MarketInfo, ProtocolConfig}}; - -// Storage keys for persistent state -const ADMIN: &str = "ADMIN"; -const MARKET_WASM_HASH: &str = "MARKET_WASM_HASH"; -const TREASURY: &str = "TREASURY"; -const PAUSED: &str = "PAUSED"; -const MARKET_COUNT_KEY: &str = "MARKET_COUNT"; -const CONFIG_KEY: &str = "CONFIG"; -// #1191: Changed from single MARKET_MAP to per-market keys -// Format: ("MARKET", market_id) -> MarketInfo -// Format: ("MARKET_BY_IDX", index) -> market_id -const PENDING_ADMIN: &str = "PENDING_ADMIN"; -const ORACLE_WHITELIST: &str = "ORACLE_WL"; - -// Helper functions for per-market keys (#1191) -fn market_key(market_id: &Bytes) -> (&str, Bytes) { - ("MARKET", market_id.clone()) -} - -fn market_by_index_key(index: u64) -> (&str, u64) { - ("MARKET_BY_IDX", index) -} - -/// Maximum number of markets that may be returned in a single `list_markets` / -/// `list_active_markets` page, regardless of the caller-requested `limit`. -const MAX_PAGE_SIZE: u32 = 50; - -#[contract] -pub struct MarketFactory; - -#[contractimpl] -impl MarketFactory { - /// One-time setup. Stores the admin, the Market contract's wasm hash - /// (used for all future deployments), and the treasury address. - /// - /// # Errors - /// Returns `ContractError::AlreadyInitialized` if called more than once. - pub fn initialize( - env: Env, - admin: Address, - market_wasm_hash: BytesN<32>, - treasury: Address, - fee_collector: Address, - default_fee_bp: u32, - min_bet_amount: i128, - max_bet_amount: i128, - dispute_window_sec: u64, - ) -> Result<(), ContractError> { - if env.storage().persistent().has(&ADMIN) { - return Err(ContractError::AlreadyInitialized); - } - - let config = ProtocolConfig { - admin: admin.clone(), - fee_collector, - default_fee_bp, - min_bet_amount, - max_bet_amount, - dispute_window_sec, - paused: false, - }; - - env.storage().persistent().set(&ADMIN, &admin); - env.storage().persistent().set(&MARKET_WASM_HASH, &market_wasm_hash); - env.storage().persistent().set(&TREASURY, &treasury); - env.storage().persistent().set(&PAUSED, &false); - env.storage().persistent().set(&CONFIG_KEY, &config); - env.storage().persistent().set(&MARKET_COUNT_KEY, &0u64); - // #1191: No longer initialize MARKET_MAP or ALL_MARKETS_KEY - // Markets are now stored as individual per-market keys - - Ok(()) - } - - /// Updates the Market wasm hash used for new deployments. - /// - /// Only the protocol admin can call this. Only affects markets deployed - /// after this call — already-deployed Market instances keep running the - /// wasm code they were originally deployed with. - /// - /// Emits `contract_upgraded` with the new wasm hash. - /// - /// # Errors - /// Returns `ContractError::Unauthorized` if `admin` does not match the - /// stored admin. - pub fn upgrade_market_wasm( - env: Env, - admin: Address, - new_wasm_hash: BytesN<32>, - ) -> Result<(), ContractError> { - Self::require_admin(&env, &admin)?; - env.storage() - .persistent() - .set(&MARKET_WASM_HASH, &new_wasm_hash); - events::emit_contract_upgraded(&env, new_wasm_hash); - Ok(()) - } - - /// Returns the current admin address. - pub fn get_admin(env: Env) -> Address { - env.storage().persistent().get(&ADMIN).expect("not initialized") - } - - /// Returns the admin address awaiting acceptance, if any. - pub fn get_pending_admin(env: Env) -> Option
{ - env.storage().persistent().get(&PENDING_ADMIN) - } - - /// Returns the stored Market contract wasm hash. - pub fn get_market_wasm_hash(env: Env) -> BytesN<32> { - env.storage() - .persistent() - .get(&MARKET_WASM_HASH) - .expect("not initialized") - } - - /// Returns the stored treasury address. - pub fn get_treasury(env: Env) -> Address { - env.storage().persistent().get(&TREASURY).expect("not initialized") - } - - /// Returns the stored protocol configuration. - pub fn get_config(env: Env) -> Result { - env.storage() - .persistent() - .get(&CONFIG_KEY) - .ok_or(ContractError::NotInitialized) - } - - /// Deploys a new Market contract instance and registers its `MarketInfo`. - /// - /// # Errors - /// - `ContractError::FactoryPaused` if the factory is paused - /// - `ContractError::InvalidTimestamp` if `end_time` is in the past, or - /// `lock_time` is after `end_time` - /// - `ContractError::OracleNotWhitelisted` if `oracle` has not been added - /// to the admin-managed oracle whitelist - pub fn create_market( - env: Env, - caller: Address, - fighter_a: String, - fighter_b: String, - oracle: Address, - lock_time: u64, - end_time: u64, - ) -> Result { - caller.require_auth(); - - let paused: bool = env.storage().persistent().get(&PAUSED).unwrap_or(false); - if paused { - return Err(ContractError::FactoryPaused); - } - - let now = env.ledger().timestamp(); - if end_time <= now || lock_time > end_time { - return Err(ContractError::InvalidTimestamp); - } - - // Prevent creators from naming themselves (or any arbitrary address) - // as the resolving oracle. - if !Self::is_oracle_whitelisted(env.clone(), oracle.clone()) { - return Err(ContractError::OracleNotWhitelisted); - } - - let count: u64 = env.storage().persistent().get(&MARKET_COUNT_KEY).unwrap_or(0); - - // #1194: Generate collision-resistant market_id using sha256 - // Hash: nonce || fighter_a || fighter_b || end_time || creator - let mut preimage = Bytes::new(&env); - preimage.append(&Bytes::from_array(&env, &count.to_le_bytes())); - preimage.append(&fighter_a.to_bytes()); - preimage.append(&fighter_b.to_bytes()); - preimage.append(&Bytes::from_array(&env, &end_time.to_le_bytes())); - preimage.append(&caller.to_xdr(&env)); - - let market_id_hash = env.crypto().sha256(&preimage); - let market_id = Bytes::from_array(&env, &market_id_hash); - let salt = market_id_hash; - let market_address = env - .deployer() - .with_address(env.current_contract_address(), salt) - .deploy_v2(wasm_hash, ()); - - let config = Self::get_config(&env)?; - let treasury = Self::get_treasury(&env); - - env.invoke_contract::>( - &market_address, - &Symbol::new(&env, "initialize"), - soroban_sdk::vec![ - &env, - env.current_contract_address().into(), - oracle.clone().into(), - treasury.clone().into(), - config.default_fee_bp.into(), - lock_time.into(), - end_time.into(), - ], - )?; - - let _: () = env.invoke_contract( - &treasury, - &Symbol::new(&env, "register_market"), - soroban_sdk::vec![&env, market_address.clone().into()], - ); - - let info = MarketInfo { - market_id: market_id.clone(), - market_address, - creator: caller, - fighter_a: fighter_a.clone(), - fighter_b: fighter_b.clone(), - oracle: oracle.clone(), - lock_time, - end_time, - created_at: now, - }; - - // #1191: Store as individual per-market key - let (key_prefix, key_suffix) = market_key(&market_id); - env.storage() - .persistent() - .set(&(key_prefix, key_suffix), &info.clone()); - - // #1191: Store market_id by index for pagination - let (idx_prefix, idx_suffix) = market_by_index_key(count); - env.storage() - .persistent() - .set(&(idx_prefix, idx_suffix), &market_id); - - env.storage() - .persistent() - .set(&MARKET_COUNT_KEY, &(count + 1)); - - env.events().publish(("market_created",), info); - - Ok(market_id) - } - - /// Read-only lookup of a single market by ID. Does not mutate state. - /// #1191: Reads from per-market key storage - pub fn get_market(env: Env, market_id: Bytes) -> Option { - let (key_prefix, key_suffix) = market_key(&market_id); - env.storage() - .persistent() - .get(&(key_prefix, key_suffix)) - } - - /// Returns a bounded, stably-ordered (creation order) page of all markets - /// ever created. `limit` is capped at `MAX_PAGE_SIZE` regardless of the - /// value requested, to bound gas. - /// #1191: Reads from per-market key storage - /// #1192: Fixed u32 overflow using saturating_add - pub fn list_markets(env: Env, offset: u32, limit: u32) -> Vec { - let total: u64 = env.storage().persistent().get(&MARKET_COUNT_KEY).unwrap_or(0); - let offset_u64 = offset as u64; - - // Return empty if offset >= total - if offset_u64 >= total { - return Vec::new(&env); - } - - let capped_limit = if limit > MAX_PAGE_SIZE { MAX_PAGE_SIZE } else { limit }; - // #1192: Use saturating_add to prevent u32 overflow - let end_u64 = (offset_u64.saturating_add(capped_limit as u64)).min(total); - - let mut result: Vec = Vec::new(&env); - for i in offset_u64..end_u64 { - let (idx_prefix, idx_suffix) = market_by_index_key(i); - if let Some(market_id) = env - .storage() - .persistent() - .get::<(_, _), Bytes>(&(idx_prefix, idx_suffix)) - { - let (key_prefix, key_suffix) = market_key(&market_id); - if let Some(info) = env - .storage() - .persistent() - .get::<(_, _), MarketInfo>(&(key_prefix, key_suffix)) - { - result.push_back(info); - } - } - } - result - } - - /// Returns active markets (whose `end_time` has not yet passed), in creation order. - /// #1191: Reads from per-market key storage - /// #1193: Added pagination support with offset and limit - pub fn list_active_markets(env: Env, offset: u32, limit: u32) -> Vec { - let total: u64 = env.storage().persistent().get(&MARKET_COUNT_KEY).unwrap_or(0); - let offset_u64 = offset as u64; - - let capped_limit = if limit > MAX_PAGE_SIZE { MAX_PAGE_SIZE } else { limit }; - let now = env.ledger().timestamp(); - let mut result: Vec = Vec::new(&env); - let mut count = 0u32; - - // Iterate from offset, collecting active markets until limit is reached - for i in offset_u64..total { - if count >= capped_limit { - break; - } - - let (idx_prefix, idx_suffix) = market_by_index_key(i); - if let Some(market_id) = env - .storage() - .persistent() - .get::<(_, _), Bytes>(&(idx_prefix, idx_suffix)) - { - let (key_prefix, key_suffix) = market_key(&market_id); - if let Some(info) = env - .storage() - .persistent() - .get::<(_, _), MarketInfo>(&(key_prefix, key_suffix)) - { - if info.end_time > now { - result.push_back(info); - count += 1; - } - } - } - } - result - } - - /// Step 1 of a two-step admin transfer: records `new_admin` as the - /// pending admin. Admin rights do not move until `new_admin` calls - /// `accept_admin`, so a mistyped address cannot brick admin functions. - /// Calling again overwrites any previous pending proposal. - /// - /// # Errors - /// Returns `ContractError::Unauthorized` if `current_admin` does not match - /// the stored admin. - pub fn propose_admin(env: Env, current_admin: Address, new_admin: Address) -> Result<(), ContractError> { - Self::require_admin(&env, ¤t_admin)?; - env.storage().persistent().set(&PENDING_ADMIN, &new_admin); - Ok(()) - } - - /// Step 2 of a two-step admin transfer: the pending admin accepts and - /// becomes the admin. Emits `admin_transferred`. - /// - /// # Errors - /// Returns `ContractError::Unauthorized` if there is no pending admin or - /// `new_admin` is not the pending admin. - pub fn accept_admin(env: Env, new_admin: Address) -> Result<(), ContractError> { - new_admin.require_auth(); - - let pending: Address = env - .storage() - .persistent() - .get(&PENDING_ADMIN) - .ok_or(ContractError::Unauthorized)?; - if pending != new_admin { - return Err(ContractError::Unauthorized); - } - - let old_admin: Address = env.storage().persistent().get(&ADMIN).expect("not initialized"); - env.storage().persistent().set(&ADMIN, &new_admin); - env.storage().persistent().remove(&PENDING_ADMIN); - - events::emit_admin_transferred(&env, old_admin, new_admin); - Ok(()) - } - - /// Adds `oracle` to the whitelist of addresses allowed to be named as a - /// market's oracle in `create_market`. Emits `oracle_added`. - /// - /// # Errors - /// - `ContractError::Unauthorized` if `admin` does not match the stored admin - /// - `ContractError::OracleAlreadyWhitelisted` if `oracle` is already listed - pub fn add_oracle(env: Env, admin: Address, oracle: Address) -> Result<(), ContractError> { - Self::require_admin(&env, &admin)?; - - let mut whitelist = Self::oracle_whitelist(&env); - if whitelist.contains_key(oracle.clone()) { - return Err(ContractError::OracleAlreadyWhitelisted); - } - whitelist.set(oracle.clone(), true); - env.storage().persistent().set(&ORACLE_WHITELIST, &whitelist); - - events::emit_oracle_added(&env, oracle); - Ok(()) - } - - /// Removes `oracle` from the whitelist. Markets already created with this - /// oracle are unaffected. Emits `oracle_removed`. - /// - /// # Errors - /// - `ContractError::Unauthorized` if `admin` does not match the stored admin - /// - `ContractError::OracleNotWhitelisted` if `oracle` is not listed - pub fn remove_oracle(env: Env, admin: Address, oracle: Address) -> Result<(), ContractError> { - Self::require_admin(&env, &admin)?; - - let mut whitelist = Self::oracle_whitelist(&env); - if !whitelist.contains_key(oracle.clone()) { - return Err(ContractError::OracleNotWhitelisted); - } - whitelist.remove(oracle.clone()); - env.storage().persistent().set(&ORACLE_WHITELIST, &whitelist); - - events::emit_oracle_removed(&env, oracle); - Ok(()) - } - - /// Returns whether `oracle` is on the oracle whitelist. - pub fn is_oracle_whitelisted(env: Env, oracle: Address) -> bool { - Self::oracle_whitelist(&env).contains_key(oracle) - } - - /// Returns whether the factory is currently paused. - pub fn is_paused(env: Env) -> bool { - env.storage().persistent().get(&PAUSED).unwrap_or(false) - } - - /// Pauses the factory. While paused, `create_market` reverts; existing - /// markets are unaffected. Emits `protocol_paused`. - /// - /// # Errors - /// Returns `ContractError::Unauthorized` if `admin` does not match the - /// stored admin. - pub fn pause_factory(env: Env, admin: Address) -> Result<(), ContractError> { - Self::require_admin(&env, &admin)?; - env.storage().persistent().set(&PAUSED, &true); - events::emit_protocol_paused(&env); - Ok(()) - } - - /// Unpauses the factory, re-enabling `create_market`. Emits - /// `protocol_unpaused`. - /// - /// # Errors - /// Returns `ContractError::Unauthorized` if `admin` does not match the - /// stored admin. - pub fn unpause_factory(env: Env, admin: Address) -> Result<(), ContractError> { - Self::require_admin(&env, &admin)?; - env.storage().persistent().set(&PAUSED, &false); - events::emit_protocol_unpaused(&env); - Ok(()) - } - - fn oracle_whitelist(env: &Env) -> Map { - env.storage() - .persistent() - .get(&ORACLE_WHITELIST) - .unwrap_or_else(|| Map::new(env)) - } - - fn require_admin(env: &Env, admin: &Address) -> Result<(), ContractError> { - admin.require_auth(); - let stored: Address = env.storage().persistent().get(&ADMIN).expect("not initialized"); - if stored != *admin { - return Err(ContractError::Unauthorized); - } - Ok(()) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use soroban_sdk::{testutils::{Address as _, Ledger as _}, Env}; - - // A minimal, dependency-free contract wasm used only to exercise the real - // `env.deployer()...deploy_v2()` path in tests. It is not the production - // Market contract; it just needs to be *some* valid, deployable wasm. - const DUMMY_WASM: &[u8] = include_bytes!("../test_fixtures/dummy.wasm"); - - fn setup() -> (Env, MarketFactoryClient<'static>, Address, Address) { - let env = Env::default(); - env.mock_all_auths(); - - let contract_id = env.register(MarketFactory, ()); - let client = MarketFactoryClient::new(&env, &contract_id); - let admin = Address::generate(&env); - let treasury = Address::generate(&env); - - (env, client, admin, treasury) - } - - fn init(env: &Env, client: &MarketFactoryClient<'static>, admin: &Address, treasury: &Address) { - let wasm_hash = env.deployer().upload_contract_wasm(DUMMY_WASM); - client.initialize(admin, &wasm_hash, treasury); - } - - fn whitelisted_oracle(env: &Env, client: &MarketFactoryClient<'static>) -> Address { - let oracle = Address::generate(env); - client.add_oracle(&client.get_admin(), &oracle); - oracle - } - - fn create_default_market(env: &Env, client: &MarketFactoryClient<'static>) -> Bytes { - let caller = Address::generate(env); - let oracle = whitelisted_oracle(env, client); - let now = env.ledger().timestamp(); - client.create_market( - &caller, - &String::from_str(env, "Fighter A"), - &String::from_str(env, "Fighter B"), - &oracle, - &(now + 100), - &(now + 200), - ) - } - - // ── C-02: initialize ────────────────────────────────────────────────── - - #[test] - fn initialize_stores_admin_wasm_hash_and_treasury() { - let (env, client, admin, treasury) = setup(); - init(&env, &client, &admin, &treasury); - - let wasm_hash = env.deployer().upload_contract_wasm(DUMMY_WASM); - assert_eq!(client.get_admin(), admin); - assert_eq!(client.get_treasury(), treasury); - assert_eq!(client.get_market_wasm_hash(), wasm_hash); - assert!(!client.is_paused()); - } - - #[test] - fn initialize_twice_fails() { - let (env, client, admin, treasury) = setup(); - init(&env, &client, &admin, &treasury); - - let wasm_hash = env.deployer().upload_contract_wasm(DUMMY_WASM); - let result = client.try_initialize(&admin, &wasm_hash, &treasury); - assert_eq!(result, Err(Ok(ContractError::AlreadyInitialized))); - } - - // ── C-03: create_market ─────────────────────────────────────────────── - - #[test] - fn create_market_happy_path() { - let (env, client, admin, treasury) = setup(); - init(&env, &client, &admin, &treasury); - - let market_id = create_default_market(&env, &client); - let info = client.get_market(&market_id).unwrap(); - - assert_eq!(info.market_id, market_id); - assert_eq!(info.fighter_a, String::from_str(&env, "Fighter A")); - assert_eq!(info.fighter_b, String::from_str(&env, "Fighter B")); - } - - #[test] - fn create_market_rejects_end_time_in_the_past() { - let (env, client, admin, treasury) = setup(); - init(&env, &client, &admin, &treasury); - - let caller = Address::generate(&env); - let oracle = Address::generate(&env); - let now = env.ledger().timestamp(); - - let result = client.try_create_market( - &caller, - &String::from_str(&env, "Fighter A"), - &String::from_str(&env, "Fighter B"), - &oracle, - &now, - &now, // end_time == now, not in the future - ); - assert_eq!(result, Err(Ok(ContractError::InvalidTimestamp))); - } - - #[test] - fn create_market_rejects_lock_time_after_end_time() { - let (env, client, admin, treasury) = setup(); - init(&env, &client, &admin, &treasury); - - let caller = Address::generate(&env); - let oracle = Address::generate(&env); - let now = env.ledger().timestamp(); - - let result = client.try_create_market( - &caller, - &String::from_str(&env, "Fighter A"), - &String::from_str(&env, "Fighter B"), - &oracle, - &(now + 200), // lock_time - &(now + 100), // end_time, before lock_time - ); - assert_eq!(result, Err(Ok(ContractError::InvalidTimestamp))); - } - - // ── C-04: get_market ────────────────────────────────────────────────── - - #[test] - fn get_market_unknown_id_returns_none() { - let (env, client, admin, treasury) = setup(); - init(&env, &client, &admin, &treasury); - - let unknown_id = Bytes::from_array(&env, &[9u8; 32]); - assert_eq!(client.get_market(&unknown_id), None); - } - - #[test] - fn get_market_known_id_returns_some() { - let (env, client, admin, treasury) = setup(); - init(&env, &client, &admin, &treasury); - - let market_id = create_default_market(&env, &client); - assert!(client.get_market(&market_id).is_some()); - } - - // ── C-05: list_markets ────────────────────────────────────────────────── - - #[test] - fn list_markets_respects_offset_and_limit_in_creation_order() { - let (env, client, admin, treasury) = setup(); - init(&env, &client, &admin, &treasury); - - let first = create_default_market(&env, &client); - let second = create_default_market(&env, &client); - let third = create_default_market(&env, &client); - - let page = client.list_markets(&1, &10); - assert_eq!(page.len(), 2); - assert_eq!(page.get(0).unwrap().market_id, second); - assert_eq!(page.get(1).unwrap().market_id, third); - - let all = client.list_markets(&0, &10); - assert_eq!(all.len(), 3); - assert_eq!(all.get(0).unwrap().market_id, first); - } - - #[test] - fn list_markets_caps_limit_at_max_page_size() { - let (env, client, admin, treasury) = setup(); - init(&env, &client, &admin, &treasury); - - for _ in 0..3 { - create_default_market(&env, &client); - } - - // Requesting far more than exist and more than MAX_PAGE_SIZE should - // just return everything that exists, not panic or overflow. - let page = client.list_markets(&0, &1_000); - assert_eq!(page.len(), 3); - } - - #[test] - fn list_markets_out_of_range_offset_returns_empty() { - let (env, client, admin, treasury) = setup(); - init(&env, &client, &admin, &treasury); - - create_default_market(&env, &client); - - let page = client.list_markets(&50, &10); - assert_eq!(page.len(), 0); - } - - // ── C-06: list_active_markets ─────────────────────────────────────────── - - #[test] - fn list_active_markets_filters_by_end_time() { - let (env, client, admin, treasury) = setup(); - init(&env, &client, &admin, &treasury); - - // A market ending soon... - let caller = Address::generate(&env); - let oracle = whitelisted_oracle(&env, &client); - let now = env.ledger().timestamp(); - let soon_ending = client.create_market( - &caller, - &String::from_str(&env, "Fighter A"), - &String::from_str(&env, "Fighter B"), - &oracle, - &(now + 10), - &(now + 20), - ); - // ...and one ending much later. - let later_ending = client.create_market( - &caller, - &String::from_str(&env, "Fighter C"), - &String::from_str(&env, "Fighter D"), - &oracle, - &(now + 1000), - &(now + 2000), - ); - - let active_before = client.list_active_markets(); - assert_eq!(active_before.len(), 2); - - // Advance the ledger past the first market's end_time. - env.ledger().with_mut(|l| l.timestamp = now + 500); - - let active_after = client.list_active_markets(); - assert_eq!(active_after.len(), 1); - assert_eq!(active_after.get(0).unwrap().market_id, later_ending); - assert_ne!(active_after.get(0).unwrap().market_id, soon_ending); - } - - // ── C-07 / C-58: propose_admin / accept_admin ──────────────────────────── - - #[test] - fn propose_and_accept_admin_transfers_admin_rights() { - let (env, client, admin, treasury) = setup(); - init(&env, &client, &admin, &treasury); - - let new_admin = Address::generate(&env); - client.propose_admin(&admin, &new_admin); - assert_eq!(client.get_admin(), admin); - assert_eq!(client.get_pending_admin(), Some(new_admin.clone())); - - client.accept_admin(&new_admin); - assert_eq!(client.get_admin(), new_admin); - assert_eq!(client.get_pending_admin(), None); - - // The old admin loses rights after acceptance. - let result = client.try_pause_factory(&admin); - assert_eq!(result, Err(Ok(ContractError::Unauthorized))); - } - - #[test] - fn accept_admin_by_wrong_address_fails() { - let (env, client, admin, treasury) = setup(); - init(&env, &client, &admin, &treasury); - - let new_admin = Address::generate(&env); - let impostor = Address::generate(&env); - client.propose_admin(&admin, &new_admin); - - let result = client.try_accept_admin(&impostor); - assert_eq!(result, Err(Ok(ContractError::Unauthorized))); - assert_eq!(client.get_admin(), admin); - } - - #[test] - fn propose_admin_rejects_non_admin_caller() { - let (env, client, admin, treasury) = setup(); - init(&env, &client, &admin, &treasury); - - let impostor = Address::generate(&env); - let new_admin = Address::generate(&env); - - let result = client.try_propose_admin(&impostor, &new_admin); - assert_eq!(result, Err(Ok(ContractError::Unauthorized))); - assert_eq!(client.get_pending_admin(), None); - } - - // ── C-57: oracle whitelist ─────────────────────────────────────────────── - - #[test] - fn create_market_rejects_non_whitelisted_oracle() { - let (env, client, admin, treasury) = setup(); - init(&env, &client, &admin, &treasury); - - let caller = Address::generate(&env); - let now = env.ledger().timestamp(); - - // The creator naming themselves as oracle must be rejected. - let result = client.try_create_market( - &caller, - &String::from_str(&env, "Fighter A"), - &String::from_str(&env, "Fighter B"), - &caller, - &(now + 100), - &(now + 200), - ); - assert_eq!(result, Err(Ok(ContractError::OracleNotWhitelisted))); - } - - #[test] - fn remove_oracle_blocks_new_markets() { - let (env, client, admin, treasury) = setup(); - init(&env, &client, &admin, &treasury); - - let oracle = whitelisted_oracle(&env, &client); - assert!(client.is_oracle_whitelisted(&oracle)); - client.remove_oracle(&admin, &oracle); - assert!(!client.is_oracle_whitelisted(&oracle)); - - let caller = Address::generate(&env); - let now = env.ledger().timestamp(); - let result = client.try_create_market( - &caller, - &String::from_str(&env, "Fighter A"), - &String::from_str(&env, "Fighter B"), - &oracle, - &(now + 100), - &(now + 200), - ); - assert_eq!(result, Err(Ok(ContractError::OracleNotWhitelisted))); - } - - // ── C-08: pause_factory / unpause_factory ─────────────────────────────── - - #[test] - fn pause_and_unpause_factory() { - let (env, client, admin, treasury) = setup(); - init(&env, &client, &admin, &treasury); - - assert!(!client.is_paused()); - client.pause_factory(&admin); - assert!(client.is_paused()); - client.unpause_factory(&admin); - assert!(!client.is_paused()); - } - - #[test] - fn create_market_reverts_while_paused() { - let (env, client, admin, treasury) = setup(); - init(&env, &client, &admin, &treasury); - - client.pause_factory(&admin); - - let caller = Address::generate(&env); - let oracle = Address::generate(&env); - let now = env.ledger().timestamp(); - - let result = client.try_create_market( - &caller, - &String::from_str(&env, "Fighter A"), - &String::from_str(&env, "Fighter B"), - &oracle, - &(now + 100), - &(now + 200), - ); - assert_eq!(result, Err(Ok(ContractError::FactoryPaused))); - } - - #[test] - fn existing_markets_unaffected_by_pause() { - let (env, client, admin, treasury) = setup(); - init(&env, &client, &admin, &treasury); - - let market_id = create_default_market(&env, &client); - client.pause_factory(&admin); - - // Pausing the factory only blocks new creations; previously created - // markets remain readable. - assert!(client.get_market(&market_id).is_some()); - } - - #[test] - fn pause_factory_rejects_non_admin_caller() { - let (env, client, admin, treasury) = setup(); - init(&env, &client, &admin, &treasury); - - let impostor = Address::generate(&env); - let result = client.try_pause_factory(&impostor); - assert_eq!(result, Err(Ok(ContractError::Unauthorized))); - assert!(!client.is_paused()); - } - - // ── C-57: oracle whitelist events ───────────────────────────────────────── - - #[test] - fn add_oracle_emits_oracle_added_event() { - use soroban_sdk::{testutils::Events as _, Symbol, TryFromVal}; - - let (env, client, admin, treasury) = setup(); - init(&env, &client, &admin, &treasury); - - let oracle = Address::generate(&env); - client.add_oracle(&admin, &oracle); - - let all = env.events().all(); - assert!(!all.is_empty(), "expected at least one event"); - // The last event must be oracle_added. - let last = all.events().last().expect("event list was empty"); - use soroban_sdk::xdr::ContractEventBody; - let ContractEventBody::V0(body) = &last.body; - let first_topic = body.topics.first().expect("no topics"); - let sym = Symbol::try_from_val(&env, first_topic).expect("topic 0 not a Symbol"); - assert_eq!(sym, Symbol::new(&env, "oracle_added")); - let ev_oracle: Address = TryFromVal::try_from_val(&env, &body.data).expect("data shape mismatch"); - assert_eq!(ev_oracle, oracle); - } - - #[test] - fn remove_oracle_emits_oracle_removed_event() { - use soroban_sdk::{testutils::Events as _, Symbol, TryFromVal}; - - let (env, client, admin, treasury) = setup(); - init(&env, &client, &admin, &treasury); - - let oracle = whitelisted_oracle(&env, &client); - client.remove_oracle(&admin, &oracle); - - let all = env.events().all(); - let last = all.events().last().expect("event list was empty"); - use soroban_sdk::xdr::ContractEventBody; - let ContractEventBody::V0(body) = &last.body; - let sym = Symbol::try_from_val(&env, body.topics.first().expect("no topics")) - .expect("topic 0 not a Symbol"); - assert_eq!(sym, Symbol::new(&env, "oracle_removed")); - let ev_oracle: Address = TryFromVal::try_from_val(&env, &body.data).expect("data shape mismatch"); - assert_eq!(ev_oracle, oracle); - } - - // ── C-58: accept_admin emits admin_transferred event ────────────────────── - - #[test] - fn accept_admin_emits_admin_transferred_event() { - use soroban_sdk::{testutils::Events as _, Symbol, TryFromVal}; - - let (env, client, admin, treasury) = setup(); - init(&env, &client, &admin, &treasury); - - let new_admin = Address::generate(&env); - client.propose_admin(&admin, &new_admin); - client.accept_admin(&new_admin); - - let all = env.events().all(); - let last = all.events().last().expect("event list was empty"); - use soroban_sdk::xdr::ContractEventBody; - let ContractEventBody::V0(body) = &last.body; - let sym = Symbol::try_from_val(&env, body.topics.first().expect("no topics")) - .expect("topic 0 not a Symbol"); - assert_eq!(sym, Symbol::new(&env, "admin_transferred")); - let (ev_old, ev_new): (Address, Address) = - TryFromVal::try_from_val(&env, &body.data).expect("data shape mismatch"); - assert_eq!(ev_old, admin); - assert_eq!(ev_new, new_admin); - } - - // ── C-59: pause/unpause/upgrade_market_wasm events ──────────────────────── - - #[test] - fn pause_factory_emits_protocol_paused_event() { - use soroban_sdk::{testutils::Events as _, Symbol, TryFromVal}; - - let (env, client, admin, treasury) = setup(); - init(&env, &client, &admin, &treasury); - - client.pause_factory(&admin); - - let all = env.events().all(); - let last = all.events().last().expect("event list was empty"); - use soroban_sdk::xdr::ContractEventBody; - let ContractEventBody::V0(body) = &last.body; - let sym = Symbol::try_from_val(&env, body.topics.first().expect("no topics")) - .expect("topic 0 not a Symbol"); - assert_eq!(sym, Symbol::new(&env, "protocol_paused")); - } - - #[test] - fn unpause_factory_emits_protocol_unpaused_event() { - use soroban_sdk::{testutils::Events as _, Symbol, TryFromVal}; - - let (env, client, admin, treasury) = setup(); - init(&env, &client, &admin, &treasury); - - client.pause_factory(&admin); - client.unpause_factory(&admin); - - let all = env.events().all(); - let last = all.events().last().expect("event list was empty"); - use soroban_sdk::xdr::ContractEventBody; - let ContractEventBody::V0(body) = &last.body; - let sym = Symbol::try_from_val(&env, body.topics.first().expect("no topics")) - .expect("topic 0 not a Symbol"); - assert_eq!(sym, Symbol::new(&env, "protocol_unpaused")); - } - - #[test] - fn upgrade_market_wasm_emits_contract_upgraded_event() { - use soroban_sdk::{testutils::Events as _, Symbol, TryFromVal}; - - let (env, client, admin, treasury) = setup(); - init(&env, &client, &admin, &treasury); - - let new_hash = env.deployer().upload_contract_wasm(DUMMY_WASM); - client.upgrade_market_wasm(&admin, &new_hash); - - let all = env.events().all(); - let last = all.events().last().expect("event list was empty"); - use soroban_sdk::xdr::ContractEventBody; - let ContractEventBody::V0(body) = &last.body; - let sym = Symbol::try_from_val(&env, body.topics.first().expect("no topics")) - .expect("topic 0 not a Symbol"); - assert_eq!(sym, Symbol::new(&env, "contract_upgraded")); - let ev_hash: BytesN<32> = - TryFromVal::try_from_val(&env, &body.data).expect("data shape mismatch"); - assert_eq!(ev_hash, new_hash); - } -} +#![no_std] +//! ============================================================ +//! BOXMEOUT — MarketFactory Contract +//! Deploys and tracks Market contract instances. +//! ============================================================ +use soroban_sdk::{contract, contractimpl, Address, Bytes, BytesN, Env, Map, String, Symbol, Vec}; + +use shared::{errors::ContractError, events, types::{MarketInfo, ProtocolConfig}}; + +// Storage keys for persistent state +const ADMIN: &str = "ADMIN"; +const MARKET_WASM_HASH: &str = "MARKET_WASM_HASH"; +const TREASURY: &str = "TREASURY"; +const PAUSED: &str = "PAUSED"; +const MARKET_COUNT_KEY: &str = "MARKET_COUNT"; +const CONFIG_KEY: &str = "CONFIG"; +// #1191: Changed from single MARKET_MAP to per-market keys +// Format: ("MARKET", market_id) -> MarketInfo +// Format: ("MARKET_BY_IDX", index) -> market_id +const PENDING_ADMIN: &str = "PENDING_ADMIN"; +const ORACLE_WHITELIST: &str = "ORACLE_WL"; + +// Helper functions for per-market keys (#1191) +fn market_key(market_id: &Bytes) -> (&str, Bytes) { + ("MARKET", market_id.clone()) +} + +fn market_by_index_key(index: u64) -> (&str, u64) { + ("MARKET_BY_IDX", index) +} + +/// Maximum number of markets that may be returned in a single `list_markets` / +/// `list_active_markets` page, regardless of the caller-requested `limit`. +const MAX_PAGE_SIZE: u32 = 50; + +#[contract] +pub struct MarketFactory; + +#[contractimpl] +impl MarketFactory { + /// One-time setup. Stores the admin, the Market contract's wasm hash + /// (used for all future deployments), and the treasury address. + /// + /// # Errors + /// Returns `ContractError::AlreadyInitialized` if called more than once. + pub fn initialize( + env: Env, + admin: Address, + market_wasm_hash: BytesN<32>, + treasury: Address, + fee_collector: Address, + default_fee_bp: u32, + min_bet_amount: i128, + max_bet_amount: i128, + dispute_window_sec: u64, + ) -> Result<(), ContractError> { + if env.storage().persistent().has(&ADMIN) { + return Err(ContractError::AlreadyInitialized); + } + + let config = ProtocolConfig { + admin: admin.clone(), + fee_collector, + default_fee_bp, + min_bet_amount, + max_bet_amount, + dispute_window_sec, + paused: false, + }; + + env.storage().persistent().set(&ADMIN, &admin); + env.storage().persistent().set(&MARKET_WASM_HASH, &market_wasm_hash); + env.storage().persistent().set(&TREASURY, &treasury); + env.storage().persistent().set(&PAUSED, &false); + env.storage().persistent().set(&CONFIG_KEY, &config); + env.storage().persistent().set(&MARKET_COUNT_KEY, &0u64); + // #1191: No longer initialize MARKET_MAP or ALL_MARKETS_KEY + // Markets are now stored as individual per-market keys + + Ok(()) + } + + /// Updates the Market wasm hash used for new deployments. + /// + /// Only the protocol admin can call this. Only affects markets deployed + /// after this call — already-deployed Market instances keep running the + /// wasm code they were originally deployed with. + /// + /// Emits `contract_upgraded` with the new wasm hash. + /// + /// # Errors + /// Returns `ContractError::Unauthorized` if `admin` does not match the + /// stored admin. + pub fn upgrade_market_wasm( + env: Env, + admin: Address, + new_wasm_hash: BytesN<32>, + ) -> Result<(), ContractError> { + Self::require_admin(&env, &admin)?; + env.storage() + .persistent() + .set(&MARKET_WASM_HASH, &new_wasm_hash); + events::emit_contract_upgraded(&env, new_wasm_hash); + Ok(()) + } + + /// Returns the current admin address. + pub fn get_admin(env: Env) -> Address { + env.storage().persistent().get(&ADMIN).expect("not initialized") + } + + /// Returns the admin address awaiting acceptance, if any. + pub fn get_pending_admin(env: Env) -> Option
{ + env.storage().persistent().get(&PENDING_ADMIN) + } + + /// Returns the stored Market contract wasm hash. + pub fn get_market_wasm_hash(env: Env) -> BytesN<32> { + env.storage() + .persistent() + .get(&MARKET_WASM_HASH) + .expect("not initialized") + } + + /// Returns the stored treasury address. + pub fn get_treasury(env: Env) -> Address { + env.storage().persistent().get(&TREASURY).expect("not initialized") + } + + /// Returns the stored protocol configuration. + pub fn get_config(env: Env) -> Result { + env.storage() + .persistent() + .get(&CONFIG_KEY) + .ok_or(ContractError::NotInitialized) + } + + /// Deploys a new Market contract instance and registers its `MarketInfo`. + /// + /// # Errors + /// - `ContractError::FactoryPaused` if the factory is paused + /// - `ContractError::InvalidTimestamp` if `end_time` is in the past, or + /// `lock_time` is after `end_time` + /// - `ContractError::OracleNotWhitelisted` if `oracle` has not been added + /// to the admin-managed oracle whitelist + pub fn create_market( + env: Env, + caller: Address, + fighter_a: String, + fighter_b: String, + oracle: Address, + lock_time: u64, + end_time: u64, + ) -> Result { + caller.require_auth(); + + let paused: bool = env.storage().persistent().get(&PAUSED).unwrap_or(false); + if paused { + return Err(ContractError::FactoryPaused); + } + + let now = env.ledger().timestamp(); + if end_time <= now || lock_time > end_time { + return Err(ContractError::InvalidTimestamp); + } + + // Prevent creators from naming themselves (or any arbitrary address) + // as the resolving oracle. + if !Self::is_oracle_whitelisted(env.clone(), oracle.clone()) { + return Err(ContractError::OracleNotWhitelisted); + } + + let count: u64 = env.storage().persistent().get(&MARKET_COUNT_KEY).unwrap_or(0); + + // #1194: Generate collision-resistant market_id using sha256 + // Hash: nonce || fighter_a || fighter_b || end_time || creator + let mut preimage = Bytes::new(&env); + preimage.append(&Bytes::from_array(&env, &count.to_le_bytes())); + preimage.append(&fighter_a.to_bytes()); + preimage.append(&fighter_b.to_bytes()); + preimage.append(&Bytes::from_array(&env, &end_time.to_le_bytes())); + preimage.append(&caller.to_xdr(&env)); + + let market_id_hash = env.crypto().sha256(&preimage); + let market_id = Bytes::from_array(&env, &market_id_hash); + let salt = market_id_hash; + let market_address = env + .deployer() + .with_address(env.current_contract_address(), salt) + .deploy_v2(wasm_hash, ()); + + let config = Self::get_config(&env)?; + let treasury = Self::get_treasury(&env); + + env.invoke_contract::>( + &market_address, + &Symbol::new(&env, "initialize"), + soroban_sdk::vec![ + &env, + env.current_contract_address().into(), + oracle.clone().into(), + treasury.clone().into(), + config.default_fee_bp.into(), + lock_time.into(), + end_time.into(), + ], + )?; + + let _: () = env.invoke_contract( + &treasury, + &Symbol::new(&env, "register_market"), + soroban_sdk::vec![&env, market_address.clone().into()], + ); + + let info = MarketInfo { + market_id: market_id.clone(), + market_address, + creator: caller, + fighter_a: fighter_a.clone(), + fighter_b: fighter_b.clone(), + oracle: oracle.clone(), + lock_time, + end_time, + created_at: now, + }; + + // #1191: Store as individual per-market key + let (key_prefix, key_suffix) = market_key(&market_id); + env.storage() + .persistent() + .set(&(key_prefix, key_suffix), &info.clone()); + + // #1191: Store market_id by index for pagination + let (idx_prefix, idx_suffix) = market_by_index_key(count); + env.storage() + .persistent() + .set(&(idx_prefix, idx_suffix), &market_id); + + env.storage() + .persistent() + .set(&MARKET_COUNT_KEY, &(count + 1)); + + env.events().publish(("market_created",), info); + + Ok(market_id) + } + + /// Read-only lookup of a single market by ID. Does not mutate state. + /// #1191: Reads from per-market key storage + pub fn get_market(env: Env, market_id: Bytes) -> Option { + let (key_prefix, key_suffix) = market_key(&market_id); + env.storage() + .persistent() + .get(&(key_prefix, key_suffix)) + } + + /// Returns a bounded, stably-ordered (creation order) page of all markets + /// ever created. `limit` is capped at `MAX_PAGE_SIZE` regardless of the + /// value requested, to bound gas. + /// #1191: Reads from per-market key storage + /// #1192: Fixed u32 overflow using saturating_add + pub fn list_markets(env: Env, offset: u32, limit: u32) -> Vec { + let total: u64 = env.storage().persistent().get(&MARKET_COUNT_KEY).unwrap_or(0); + let offset_u64 = offset as u64; + + // Return empty if offset >= total + if offset_u64 >= total { + return Vec::new(&env); + } + + let capped_limit = if limit > MAX_PAGE_SIZE { MAX_PAGE_SIZE } else { limit }; + // #1192: Use saturating_add to prevent u32 overflow + let end_u64 = (offset_u64.saturating_add(capped_limit as u64)).min(total); + + let mut result: Vec = Vec::new(&env); + for i in offset_u64..end_u64 { + let (idx_prefix, idx_suffix) = market_by_index_key(i); + if let Some(market_id) = env + .storage() + .persistent() + .get::<(_, _), Bytes>(&(idx_prefix, idx_suffix)) + { + let (key_prefix, key_suffix) = market_key(&market_id); + if let Some(info) = env + .storage() + .persistent() + .get::<(_, _), MarketInfo>(&(key_prefix, key_suffix)) + { + result.push_back(info); + } + } + } + result + } + + /// Returns active markets (whose `end_time` has not yet passed), in creation order. + /// #1191: Reads from per-market key storage + /// #1193: Added pagination support with offset and limit + pub fn list_active_markets(env: Env, offset: u32, limit: u32) -> Vec { + let total: u64 = env.storage().persistent().get(&MARKET_COUNT_KEY).unwrap_or(0); + let offset_u64 = offset as u64; + + let capped_limit = if limit > MAX_PAGE_SIZE { MAX_PAGE_SIZE } else { limit }; + let now = env.ledger().timestamp(); + let mut result: Vec = Vec::new(&env); + let mut count = 0u32; + + // Iterate from offset, collecting active markets until limit is reached + for i in offset_u64..total { + if count >= capped_limit { + break; + } + + let (idx_prefix, idx_suffix) = market_by_index_key(i); + if let Some(market_id) = env + .storage() + .persistent() + .get::<(_, _), Bytes>(&(idx_prefix, idx_suffix)) + { + let (key_prefix, key_suffix) = market_key(&market_id); + if let Some(info) = env + .storage() + .persistent() + .get::<(_, _), MarketInfo>(&(key_prefix, key_suffix)) + { + if info.end_time > now { + result.push_back(info); + count += 1; + } + } + } + } + result + } + + /// Step 1 of a two-step admin transfer: records `new_admin` as the + /// pending admin. Admin rights do not move until `new_admin` calls + /// `accept_admin`, so a mistyped address cannot brick admin functions. + /// Calling again overwrites any previous pending proposal. + /// + /// # Errors + /// Returns `ContractError::Unauthorized` if `current_admin` does not match + /// the stored admin. + pub fn propose_admin(env: Env, current_admin: Address, new_admin: Address) -> Result<(), ContractError> { + Self::require_admin(&env, ¤t_admin)?; + env.storage().persistent().set(&PENDING_ADMIN, &new_admin); + Ok(()) + } + + /// Step 2 of a two-step admin transfer: the pending admin accepts and + /// becomes the admin. Emits `admin_transferred`. + /// + /// # Errors + /// Returns `ContractError::Unauthorized` if there is no pending admin or + /// `new_admin` is not the pending admin. + pub fn accept_admin(env: Env, new_admin: Address) -> Result<(), ContractError> { + new_admin.require_auth(); + + let pending: Address = env + .storage() + .persistent() + .get(&PENDING_ADMIN) + .ok_or(ContractError::Unauthorized)?; + if pending != new_admin { + return Err(ContractError::Unauthorized); + } + + let old_admin: Address = env.storage().persistent().get(&ADMIN).expect("not initialized"); + env.storage().persistent().set(&ADMIN, &new_admin); + env.storage().persistent().remove(&PENDING_ADMIN); + + events::emit_admin_transferred(&env, old_admin, new_admin); + Ok(()) + } + + /// Adds `oracle` to the whitelist of addresses allowed to be named as a + /// market's oracle in `create_market`. Emits `oracle_added`. + /// + /// # Errors + /// - `ContractError::Unauthorized` if `admin` does not match the stored admin + /// - `ContractError::OracleAlreadyWhitelisted` if `oracle` is already listed + pub fn add_oracle(env: Env, admin: Address, oracle: Address) -> Result<(), ContractError> { + Self::require_admin(&env, &admin)?; + + let mut whitelist = Self::oracle_whitelist(&env); + if whitelist.contains_key(oracle.clone()) { + return Err(ContractError::OracleAlreadyWhitelisted); + } + whitelist.set(oracle.clone(), true); + env.storage().persistent().set(&ORACLE_WHITELIST, &whitelist); + + events::emit_oracle_added(&env, oracle); + Ok(()) + } + + /// Removes `oracle` from the whitelist. Markets already created with this + /// oracle are unaffected. Emits `oracle_removed`. + /// + /// # Errors + /// - `ContractError::Unauthorized` if `admin` does not match the stored admin + /// - `ContractError::OracleNotWhitelisted` if `oracle` is not listed + pub fn remove_oracle(env: Env, admin: Address, oracle: Address) -> Result<(), ContractError> { + Self::require_admin(&env, &admin)?; + + let mut whitelist = Self::oracle_whitelist(&env); + if !whitelist.contains_key(oracle.clone()) { + return Err(ContractError::OracleNotWhitelisted); + } + whitelist.remove(oracle.clone()); + env.storage().persistent().set(&ORACLE_WHITELIST, &whitelist); + + events::emit_oracle_removed(&env, oracle); + Ok(()) + } + + /// Returns whether `oracle` is on the oracle whitelist. + pub fn is_oracle_whitelisted(env: Env, oracle: Address) -> bool { + Self::oracle_whitelist(&env).contains_key(oracle) + } + + /// Returns whether the factory is currently paused. + pub fn is_paused(env: Env) -> bool { + env.storage().persistent().get(&PAUSED).unwrap_or(false) + } + + /// Pauses the factory. While paused, `create_market` reverts; existing + /// markets are unaffected. Emits `protocol_paused`. + /// + /// # Errors + /// Returns `ContractError::Unauthorized` if `admin` does not match the + /// stored admin. + pub fn pause_factory(env: Env, admin: Address) -> Result<(), ContractError> { + Self::require_admin(&env, &admin)?; + env.storage().persistent().set(&PAUSED, &true); + events::emit_protocol_paused(&env); + Ok(()) + } + + /// Unpauses the factory, re-enabling `create_market`. Emits + /// `protocol_unpaused`. + /// + /// # Errors + /// Returns `ContractError::Unauthorized` if `admin` does not match the + /// stored admin. + pub fn unpause_factory(env: Env, admin: Address) -> Result<(), ContractError> { + Self::require_admin(&env, &admin)?; + env.storage().persistent().set(&PAUSED, &false); + events::emit_protocol_unpaused(&env); + Ok(()) + } + + fn oracle_whitelist(env: &Env) -> Map { + env.storage() + .persistent() + .get(&ORACLE_WHITELIST) + .unwrap_or_else(|| Map::new(env)) + } + + fn require_admin(env: &Env, admin: &Address) -> Result<(), ContractError> { + admin.require_auth(); + let stored: Address = env.storage().persistent().get(&ADMIN).expect("not initialized"); + if stored != *admin { + return Err(ContractError::Unauthorized); + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use soroban_sdk::{testutils::{Address as _, Ledger as _}, Env}; + + // A minimal, dependency-free contract wasm used only to exercise the real + // `env.deployer()...deploy_v2()` path in tests. It is not the production + // Market contract; it just needs to be *some* valid, deployable wasm. + const DUMMY_WASM: &[u8] = include_bytes!("../test_fixtures/dummy.wasm"); + + fn setup() -> (Env, MarketFactoryClient<'static>, Address, Address) { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register(MarketFactory, ()); + let client = MarketFactoryClient::new(&env, &contract_id); + let admin = Address::generate(&env); + let treasury = Address::generate(&env); + + (env, client, admin, treasury) + } + + fn init(env: &Env, client: &MarketFactoryClient<'static>, admin: &Address, treasury: &Address) { + let wasm_hash = env.deployer().upload_contract_wasm(DUMMY_WASM); + client.initialize(admin, &wasm_hash, treasury); + } + + fn whitelisted_oracle(env: &Env, client: &MarketFactoryClient<'static>) -> Address { + let oracle = Address::generate(env); + client.add_oracle(&client.get_admin(), &oracle); + oracle + } + + fn create_default_market(env: &Env, client: &MarketFactoryClient<'static>) -> Bytes { + let caller = Address::generate(env); + let oracle = whitelisted_oracle(env, client); + let now = env.ledger().timestamp(); + client.create_market( + &caller, + &String::from_str(env, "Fighter A"), + &String::from_str(env, "Fighter B"), + &oracle, + &(now + 100), + &(now + 200), + ) + } + + // ── C-02: initialize ────────────────────────────────────────────────── + + #[test] + fn initialize_stores_admin_wasm_hash_and_treasury() { + let (env, client, admin, treasury) = setup(); + init(&env, &client, &admin, &treasury); + + let wasm_hash = env.deployer().upload_contract_wasm(DUMMY_WASM); + assert_eq!(client.get_admin(), admin); + assert_eq!(client.get_treasury(), treasury); + assert_eq!(client.get_market_wasm_hash(), wasm_hash); + assert!(!client.is_paused()); + } + + #[test] + fn initialize_twice_fails() { + let (env, client, admin, treasury) = setup(); + init(&env, &client, &admin, &treasury); + + let wasm_hash = env.deployer().upload_contract_wasm(DUMMY_WASM); + let result = client.try_initialize(&admin, &wasm_hash, &treasury); + assert_eq!(result, Err(Ok(ContractError::AlreadyInitialized))); + } + + // ── C-03: create_market ─────────────────────────────────────────────── + + #[test] + fn create_market_happy_path() { + let (env, client, admin, treasury) = setup(); + init(&env, &client, &admin, &treasury); + + let market_id = create_default_market(&env, &client); + let info = client.get_market(&market_id).unwrap(); + + assert_eq!(info.market_id, market_id); + assert_eq!(info.fighter_a, String::from_str(&env, "Fighter A")); + assert_eq!(info.fighter_b, String::from_str(&env, "Fighter B")); + } + + #[test] + fn create_market_rejects_end_time_in_the_past() { + let (env, client, admin, treasury) = setup(); + init(&env, &client, &admin, &treasury); + + let caller = Address::generate(&env); + let oracle = Address::generate(&env); + let now = env.ledger().timestamp(); + + let result = client.try_create_market( + &caller, + &String::from_str(&env, "Fighter A"), + &String::from_str(&env, "Fighter B"), + &oracle, + &now, + &now, // end_time == now, not in the future + ); + assert_eq!(result, Err(Ok(ContractError::InvalidTimestamp))); + } + + #[test] + fn create_market_rejects_lock_time_after_end_time() { + let (env, client, admin, treasury) = setup(); + init(&env, &client, &admin, &treasury); + + let caller = Address::generate(&env); + let oracle = Address::generate(&env); + let now = env.ledger().timestamp(); + + let result = client.try_create_market( + &caller, + &String::from_str(&env, "Fighter A"), + &String::from_str(&env, "Fighter B"), + &oracle, + &(now + 200), // lock_time + &(now + 100), // end_time, before lock_time + ); + assert_eq!(result, Err(Ok(ContractError::InvalidTimestamp))); + } + + // ── C-04: get_market ────────────────────────────────────────────────── + + #[test] + fn get_market_unknown_id_returns_none() { + let (env, client, admin, treasury) = setup(); + init(&env, &client, &admin, &treasury); + + let unknown_id = Bytes::from_array(&env, &[9u8; 32]); + assert_eq!(client.get_market(&unknown_id), None); + } + + #[test] + fn get_market_known_id_returns_some() { + let (env, client, admin, treasury) = setup(); + init(&env, &client, &admin, &treasury); + + let market_id = create_default_market(&env, &client); + assert!(client.get_market(&market_id).is_some()); + } + + // ── C-05: list_markets ────────────────────────────────────────────────── + + #[test] + fn list_markets_respects_offset_and_limit_in_creation_order() { + let (env, client, admin, treasury) = setup(); + init(&env, &client, &admin, &treasury); + + let first = create_default_market(&env, &client); + let second = create_default_market(&env, &client); + let third = create_default_market(&env, &client); + + let page = client.list_markets(&1, &10); + assert_eq!(page.len(), 2); + assert_eq!(page.get(0).unwrap().market_id, second); + assert_eq!(page.get(1).unwrap().market_id, third); + + let all = client.list_markets(&0, &10); + assert_eq!(all.len(), 3); + assert_eq!(all.get(0).unwrap().market_id, first); + } + + #[test] + fn list_markets_caps_limit_at_max_page_size() { + let (env, client, admin, treasury) = setup(); + init(&env, &client, &admin, &treasury); + + for _ in 0..3 { + create_default_market(&env, &client); + } + + // Requesting far more than exist and more than MAX_PAGE_SIZE should + // just return everything that exists, not panic or overflow. + let page = client.list_markets(&0, &1_000); + assert_eq!(page.len(), 3); + } + + #[test] + fn list_markets_out_of_range_offset_returns_empty() { + let (env, client, admin, treasury) = setup(); + init(&env, &client, &admin, &treasury); + + create_default_market(&env, &client); + + let page = client.list_markets(&50, &10); + assert_eq!(page.len(), 0); + } + + // ── C-06: list_active_markets ─────────────────────────────────────────── + + #[test] + fn list_active_markets_filters_by_end_time() { + let (env, client, admin, treasury) = setup(); + init(&env, &client, &admin, &treasury); + + // A market ending soon... + let caller = Address::generate(&env); + let oracle = whitelisted_oracle(&env, &client); + let now = env.ledger().timestamp(); + let soon_ending = client.create_market( + &caller, + &String::from_str(&env, "Fighter A"), + &String::from_str(&env, "Fighter B"), + &oracle, + &(now + 10), + &(now + 20), + ); + // ...and one ending much later. + let later_ending = client.create_market( + &caller, + &String::from_str(&env, "Fighter C"), + &String::from_str(&env, "Fighter D"), + &oracle, + &(now + 1000), + &(now + 2000), + ); + + let active_before = client.list_active_markets(); + assert_eq!(active_before.len(), 2); + + // Advance the ledger past the first market's end_time. + env.ledger().with_mut(|l| l.timestamp = now + 500); + + let active_after = client.list_active_markets(); + assert_eq!(active_after.len(), 1); + assert_eq!(active_after.get(0).unwrap().market_id, later_ending); + assert_ne!(active_after.get(0).unwrap().market_id, soon_ending); + } + + // ── C-07 / C-58: propose_admin / accept_admin ──────────────────────────── + + #[test] + fn propose_and_accept_admin_transfers_admin_rights() { + let (env, client, admin, treasury) = setup(); + init(&env, &client, &admin, &treasury); + + let new_admin = Address::generate(&env); + client.propose_admin(&admin, &new_admin); + assert_eq!(client.get_admin(), admin); + assert_eq!(client.get_pending_admin(), Some(new_admin.clone())); + + client.accept_admin(&new_admin); + assert_eq!(client.get_admin(), new_admin); + assert_eq!(client.get_pending_admin(), None); + + // The old admin loses rights after acceptance. + let result = client.try_pause_factory(&admin); + assert_eq!(result, Err(Ok(ContractError::Unauthorized))); + } + + #[test] + fn accept_admin_by_wrong_address_fails() { + let (env, client, admin, treasury) = setup(); + init(&env, &client, &admin, &treasury); + + let new_admin = Address::generate(&env); + let impostor = Address::generate(&env); + client.propose_admin(&admin, &new_admin); + + let result = client.try_accept_admin(&impostor); + assert_eq!(result, Err(Ok(ContractError::Unauthorized))); + assert_eq!(client.get_admin(), admin); + } + + #[test] + fn propose_admin_rejects_non_admin_caller() { + let (env, client, admin, treasury) = setup(); + init(&env, &client, &admin, &treasury); + + let impostor = Address::generate(&env); + let new_admin = Address::generate(&env); + + let result = client.try_propose_admin(&impostor, &new_admin); + assert_eq!(result, Err(Ok(ContractError::Unauthorized))); + assert_eq!(client.get_pending_admin(), None); + } + + // ── C-57: oracle whitelist ─────────────────────────────────────────────── + + #[test] + fn create_market_rejects_non_whitelisted_oracle() { + let (env, client, admin, treasury) = setup(); + init(&env, &client, &admin, &treasury); + + let caller = Address::generate(&env); + let now = env.ledger().timestamp(); + + // The creator naming themselves as oracle must be rejected. + let result = client.try_create_market( + &caller, + &String::from_str(&env, "Fighter A"), + &String::from_str(&env, "Fighter B"), + &caller, + &(now + 100), + &(now + 200), + ); + assert_eq!(result, Err(Ok(ContractError::OracleNotWhitelisted))); + } + + #[test] + fn remove_oracle_blocks_new_markets() { + let (env, client, admin, treasury) = setup(); + init(&env, &client, &admin, &treasury); + + let oracle = whitelisted_oracle(&env, &client); + assert!(client.is_oracle_whitelisted(&oracle)); + client.remove_oracle(&admin, &oracle); + assert!(!client.is_oracle_whitelisted(&oracle)); + + let caller = Address::generate(&env); + let now = env.ledger().timestamp(); + let result = client.try_create_market( + &caller, + &String::from_str(&env, "Fighter A"), + &String::from_str(&env, "Fighter B"), + &oracle, + &(now + 100), + &(now + 200), + ); + assert_eq!(result, Err(Ok(ContractError::OracleNotWhitelisted))); + } + + // ── C-08: pause_factory / unpause_factory ─────────────────────────────── + + #[test] + fn pause_and_unpause_factory() { + let (env, client, admin, treasury) = setup(); + init(&env, &client, &admin, &treasury); + + assert!(!client.is_paused()); + client.pause_factory(&admin); + assert!(client.is_paused()); + client.unpause_factory(&admin); + assert!(!client.is_paused()); + } + + #[test] + fn create_market_reverts_while_paused() { + let (env, client, admin, treasury) = setup(); + init(&env, &client, &admin, &treasury); + + client.pause_factory(&admin); + + let caller = Address::generate(&env); + let oracle = Address::generate(&env); + let now = env.ledger().timestamp(); + + let result = client.try_create_market( + &caller, + &String::from_str(&env, "Fighter A"), + &String::from_str(&env, "Fighter B"), + &oracle, + &(now + 100), + &(now + 200), + ); + assert_eq!(result, Err(Ok(ContractError::FactoryPaused))); + } + + #[test] + fn existing_markets_unaffected_by_pause() { + let (env, client, admin, treasury) = setup(); + init(&env, &client, &admin, &treasury); + + let market_id = create_default_market(&env, &client); + client.pause_factory(&admin); + + // Pausing the factory only blocks new creations; previously created + // markets remain readable. + assert!(client.get_market(&market_id).is_some()); + } + + #[test] + fn pause_factory_rejects_non_admin_caller() { + let (env, client, admin, treasury) = setup(); + init(&env, &client, &admin, &treasury); + + let impostor = Address::generate(&env); + let result = client.try_pause_factory(&impostor); + assert_eq!(result, Err(Ok(ContractError::Unauthorized))); + assert!(!client.is_paused()); + } + + // ── C-57: oracle whitelist events ───────────────────────────────────────── + + #[test] + fn add_oracle_emits_oracle_added_event() { + use soroban_sdk::{testutils::Events as _, Symbol, TryFromVal}; + + let (env, client, admin, treasury) = setup(); + init(&env, &client, &admin, &treasury); + + let oracle = Address::generate(&env); + client.add_oracle(&admin, &oracle); + + let all = env.events().all(); + assert!(!all.is_empty(), "expected at least one event"); + // The last event must be oracle_added. + let last = all.events().last().expect("event list was empty"); + use soroban_sdk::xdr::ContractEventBody; + let ContractEventBody::V0(body) = &last.body; + let first_topic = body.topics.first().expect("no topics"); + let sym = Symbol::try_from_val(&env, first_topic).expect("topic 0 not a Symbol"); + assert_eq!(sym, Symbol::new(&env, "oracle_added")); + let ev_oracle: Address = TryFromVal::try_from_val(&env, &body.data).expect("data shape mismatch"); + assert_eq!(ev_oracle, oracle); + } + + #[test] + fn remove_oracle_emits_oracle_removed_event() { + use soroban_sdk::{testutils::Events as _, Symbol, TryFromVal}; + + let (env, client, admin, treasury) = setup(); + init(&env, &client, &admin, &treasury); + + let oracle = whitelisted_oracle(&env, &client); + client.remove_oracle(&admin, &oracle); + + let all = env.events().all(); + let last = all.events().last().expect("event list was empty"); + use soroban_sdk::xdr::ContractEventBody; + let ContractEventBody::V0(body) = &last.body; + let sym = Symbol::try_from_val(&env, body.topics.first().expect("no topics")) + .expect("topic 0 not a Symbol"); + assert_eq!(sym, Symbol::new(&env, "oracle_removed")); + let ev_oracle: Address = TryFromVal::try_from_val(&env, &body.data).expect("data shape mismatch"); + assert_eq!(ev_oracle, oracle); + } + + // ── C-58: accept_admin emits admin_transferred event ────────────────────── + + #[test] + fn accept_admin_emits_admin_transferred_event() { + use soroban_sdk::{testutils::Events as _, Symbol, TryFromVal}; + + let (env, client, admin, treasury) = setup(); + init(&env, &client, &admin, &treasury); + + let new_admin = Address::generate(&env); + client.propose_admin(&admin, &new_admin); + client.accept_admin(&new_admin); + + let all = env.events().all(); + let last = all.events().last().expect("event list was empty"); + use soroban_sdk::xdr::ContractEventBody; + let ContractEventBody::V0(body) = &last.body; + let sym = Symbol::try_from_val(&env, body.topics.first().expect("no topics")) + .expect("topic 0 not a Symbol"); + assert_eq!(sym, Symbol::new(&env, "admin_transferred")); + let (ev_old, ev_new): (Address, Address) = + TryFromVal::try_from_val(&env, &body.data).expect("data shape mismatch"); + assert_eq!(ev_old, admin); + assert_eq!(ev_new, new_admin); + } + + // ── C-59: pause/unpause/upgrade_market_wasm events ──────────────────────── + + #[test] + fn pause_factory_emits_protocol_paused_event() { + use soroban_sdk::{testutils::Events as _, Symbol, TryFromVal}; + + let (env, client, admin, treasury) = setup(); + init(&env, &client, &admin, &treasury); + + client.pause_factory(&admin); + + let all = env.events().all(); + let last = all.events().last().expect("event list was empty"); + use soroban_sdk::xdr::ContractEventBody; + let ContractEventBody::V0(body) = &last.body; + let sym = Symbol::try_from_val(&env, body.topics.first().expect("no topics")) + .expect("topic 0 not a Symbol"); + assert_eq!(sym, Symbol::new(&env, "protocol_paused")); + } + + #[test] + fn unpause_factory_emits_protocol_unpaused_event() { + use soroban_sdk::{testutils::Events as _, Symbol, TryFromVal}; + + let (env, client, admin, treasury) = setup(); + init(&env, &client, &admin, &treasury); + + client.pause_factory(&admin); + client.unpause_factory(&admin); + + let all = env.events().all(); + let last = all.events().last().expect("event list was empty"); + use soroban_sdk::xdr::ContractEventBody; + let ContractEventBody::V0(body) = &last.body; + let sym = Symbol::try_from_val(&env, body.topics.first().expect("no topics")) + .expect("topic 0 not a Symbol"); + assert_eq!(sym, Symbol::new(&env, "protocol_unpaused")); + } + + #[test] + fn upgrade_market_wasm_emits_contract_upgraded_event() { + use soroban_sdk::{testutils::Events as _, Symbol, TryFromVal}; + + let (env, client, admin, treasury) = setup(); + init(&env, &client, &admin, &treasury); + + let new_hash = env.deployer().upload_contract_wasm(DUMMY_WASM); + client.upgrade_market_wasm(&admin, &new_hash); + + let all = env.events().all(); + let last = all.events().last().expect("event list was empty"); + use soroban_sdk::xdr::ContractEventBody; + let ContractEventBody::V0(body) = &last.body; + let sym = Symbol::try_from_val(&env, body.topics.first().expect("no topics")) + .expect("topic 0 not a Symbol"); + assert_eq!(sym, Symbol::new(&env, "contract_upgraded")); + let ev_hash: BytesN<32> = + TryFromVal::try_from_val(&env, &body.data).expect("data shape mismatch"); + assert_eq!(ev_hash, new_hash); + } +} diff --git a/contracts/treasury/src/lib.rs b/contracts/treasury/src/lib.rs index 0c9c8e5b..37a0621a 100644 --- a/contracts/treasury/src/lib.rs +++ b/contracts/treasury/src/lib.rs @@ -249,12 +249,12 @@ impl Treasury { .get(&key_factory(&env)) .expect("not initialized"); - let registered: Address = env.invoke_contract( + let registered: Option
= env.invoke_contract( &factory, &Symbol::new(&env, "get_market_address"), soroban_sdk::vec![&env, market_id.to_val()], ); - if registered != from_market { + if registered != Some(from_market.clone()) { return Err(ContractError::MarketNotApproved); } @@ -306,12 +306,12 @@ impl Treasury { let caller = env.current_contract_address(); - let registered: Address = env.invoke_contract( + let registered: Option
= env.invoke_contract( &factory, &Symbol::new(&env, "get_market_address"), soroban_sdk::vec![&env, market_id.to_val()], ); - if registered != caller { + if registered != Some(caller.clone()) { return Err(ContractError::MarketNotApproved); } @@ -371,12 +371,12 @@ impl Treasury { .get(&key_factory(&env)) .expect("not initialized"); - let registered: Address = env.invoke_contract( + let registered: Option
= env.invoke_contract( &factory, &Symbol::new(&env, "get_market_address"), soroban_sdk::vec![&env, market_id.to_val()], ); - if registered != from_market { + if registered != Some(from_market.clone()) { panic!("unauthorized: caller is not a registered market"); }