diff --git a/packages/contracts/call_registry/src/admin.rs b/packages/contracts/call_registry/src/admin.rs new file mode 100644 index 0000000..45a8f24 --- /dev/null +++ b/packages/contracts/call_registry/src/admin.rs @@ -0,0 +1,84 @@ +use soroban_sdk::{Address, Env}; + +use crate::events::{ + emit_admin_params_changed_address, emit_admin_params_changed_u32, PARAM_ADMIN, PARAM_FEE_BPS, + PARAM_OUTCOME_MANAGER, +}; +use crate::storage::{extend_storage_ttl, get_config, set_config}; + +/// Transfer admin privileges to a new address. +/// +/// # Authorization +/// Current admin must sign. +/// +/// # Panics +/// * Contract not initialized +pub fn set_admin(env: Env, new_admin: Address) { + let mut config = get_config(&env).expect("Contract not initialized"); + + config.admin.require_auth(); + + let old_admin = config.admin.clone(); + config.admin = new_admin.clone(); + + set_config(&env, &config); + extend_storage_ttl(&env); + + emit_admin_params_changed_address(&env, PARAM_ADMIN, &new_admin, &old_admin, &new_admin); +} + +/// Replace the outcome manager. +/// +/// # Authorization +/// Current admin must sign. +/// +/// # Panics +/// * Contract not initialized +pub fn set_outcome_manager(env: Env, new_manager: Address) { + let mut config = get_config(&env).expect("Contract not initialized"); + + config.admin.require_auth(); + + let old_manager = config.outcome_manager.clone(); + config.outcome_manager = new_manager.clone(); + + set_config(&env, &config); + extend_storage_ttl(&env); + + emit_admin_params_changed_address( + &env, + PARAM_OUTCOME_MANAGER, + &config.admin, + &old_manager, + &new_manager, + ); +} + +/// Set the protocol fee in basis points (1 bp = 0.01 %). +/// +/// # Arguments +/// * `new_fee_bps` — fee in basis points, must be ≤ 10_000 (100 %) +/// +/// # Authorization +/// Current admin must sign. +/// +/// # Panics +/// * Contract not initialized +/// * `new_fee_bps` > 10_000 +pub fn set_fee(env: Env, new_fee_bps: u32) { + if new_fee_bps > 10_000 { + panic!("fee_bps cannot exceed 10_000 (100%)"); + } + + let mut config = get_config(&env).expect("Contract not initialized"); + + config.admin.require_auth(); + + let old_fee_bps = config.fee_bps; + config.fee_bps = new_fee_bps; + + set_config(&env, &config); + extend_storage_ttl(&env); + + emit_admin_params_changed_u32(&env, PARAM_FEE_BPS, &config.admin, old_fee_bps, new_fee_bps); +} diff --git a/packages/contracts/call_registry/src/events.rs b/packages/contracts/call_registry/src/events.rs index b7389bd..a041f55 100644 --- a/packages/contracts/call_registry/src/events.rs +++ b/packages/contracts/call_registry/src/events.rs @@ -1,4 +1,6 @@ -use soroban_sdk::{Address, Bytes, Env}; +use soroban_sdk::{Address, Bytes, Env, Symbol}; + +// ── Existing events (unchanged) ─────────────────────────────────────────────── /// Emitted when a new call is created pub fn emit_call_created( @@ -49,18 +51,53 @@ pub fn emit_call_settled(env: &Env, call_id: u64, winner_count: u64) { .publish(("call_registry", "call_settled"), (call_id, winner_count)); } -/// Emitted when admin changes -pub fn emit_admin_changed(env: &Env, old_admin: &Address, new_admin: &Address) { +// ── Admin param events ──────────────────────────────────────────────────────── + +/// Discriminant passed as the `param` field so the indexer can tell which +/// field changed without decoding the full payload. +pub const PARAM_ADMIN: &str = "admin"; +pub const PARAM_OUTCOME_MANAGER: &str = "outcome_manager"; +pub const PARAM_FEE_BPS: &str = "fee_bps"; + +/// Unified event emitted whenever **any** admin-controlled parameter changes. +/// +/// Topic : `("call_registry", "admin_params_changed")` +/// Payload: `(param: Symbol, changed_by: Address, old_value: Val, new_value: Val)` +/// +/// The indexer subscribes to a single topic and uses `param` to route +/// each mutation to the correct handler. +pub fn emit_admin_params_changed_address( + env: &Env, + param: &str, + changed_by: &Address, + old_value: &Address, + new_value: &Address, +) { env.events().publish( - ("call_registry", "admin_changed"), - (old_admin.clone(), new_admin.clone()), + ("call_registry", "admin_params_changed"), + ( + Symbol::new(env, param), + changed_by.clone(), + old_value.clone(), + new_value.clone(), + ), ); } -/// Emitted when outcome manager changes -pub fn emit_outcome_manager_changed(env: &Env, old_manager: &Address, new_manager: &Address) { +pub fn emit_admin_params_changed_u32( + env: &Env, + param: &str, + changed_by: &Address, + old_value: u32, + new_value: u32, +) { env.events().publish( - ("call_registry", "outcome_manager_changed"), - (old_manager.clone(), new_manager.clone()), + ("call_registry", "admin_params_changed"), + ( + Symbol::new(env, param), + changed_by.clone(), + old_value, + new_value, + ), ); } diff --git a/packages/contracts/call_registry/src/lib.rs b/packages/contracts/call_registry/src/lib.rs index 8c21116..0142412 100644 --- a/packages/contracts/call_registry/src/lib.rs +++ b/packages/contracts/call_registry/src/lib.rs @@ -1,7 +1,8 @@ #![no_std] -use soroban_sdk::{contract, contractimpl, Address, Bytes, Env, Vec, token}; +use soroban_sdk::{contract, contractimpl, token, Address, Bytes, Env, Vec}; +mod admin; mod events; mod storage; mod types; @@ -18,53 +19,27 @@ pub struct CallRegistry; #[contractimpl] impl CallRegistry { /// Initialize the contract with admin and outcome manager - /// - /// # Arguments - /// * `admin` - Address with administrative privileges - /// * `outcome_manager` - Address authorized to submit call outcomes - /// - /// # Panics - /// If the contract has already been initialized pub fn initialize(env: Env, admin: Address, outcome_manager: Address) { - // Check if already initialized if let Some(_) = get_config(&env) { panic!("Contract already initialized"); } - // Validate addresses admin.require_auth(); - // Store configuration let config = ContractConfig { admin: admin.clone(), outcome_manager: outcome_manager.clone(), + fee_bps: 0, }; set_config(&env, &config); extend_storage_ttl(&env); - env.events().publish( - ("call_registry", "initialized"), - (admin, outcome_manager), - ); + env.events() + .publish(("call_registry", "initialized"), (admin, outcome_manager)); } /// Create a new prediction call - /// - /// # Arguments - /// * `creator` - Address creating the call (must authorize) - /// * `stake_token` - Token address for staking - /// * `stake_amount` - Minimum stake amount for participants - /// * `end_ts` - Timestamp when the call ends - /// * `token_address` - Asset being predicted - /// * `pair_id` - DexScreener pair ID for price data - /// * `ipfs_cid` - IPFS content hash for call metadata - /// - /// # Returns - /// The created Call struct - /// - /// # Panics - /// If creator hasn't authorized, stake_amount is invalid, or end_ts is in past pub fn create_call( env: Env, creator: Address, @@ -75,10 +50,8 @@ impl CallRegistry { pair_id: Bytes, ipfs_cid: Bytes, ) -> Call { - // Require authorization creator.require_auth(); - // Validate parameters if stake_amount <= 0 { panic!("Stake amount must be positive"); } @@ -88,10 +61,8 @@ impl CallRegistry { panic!("End timestamp must be in the future"); } - // Get next call ID let call_id = next_call_id(&env); - // Create call instance let call = Call { id: call_id, creator: creator.clone(), @@ -105,18 +76,16 @@ impl CallRegistry { total_down_stake: 0, up_stakes: soroban_sdk::Map::new(&env), down_stakes: soroban_sdk::Map::new(&env), - outcome: 0, // 0 = unresolved + outcome: 0, start_price: 0, end_price: 0, settled: false, created_at: current_timestamp, }; - // Store the call set_call(&env, &call); extend_storage_ttl(&env); - // Emit event emit_call_created( &env, call_id, @@ -133,18 +102,6 @@ impl CallRegistry { } /// Add stake to an existing call - /// - /// # Arguments - /// * `staker` - Address adding stake (must authorize) - /// * `call_id` - ID of the call to stake on - /// * `amount` - Amount to stake - /// * `position` - 1 for UP, 2 for DOWN - /// - /// # Returns - /// Updated Call struct - /// - /// # Panics - /// If call doesn't exist, has ended, amount is invalid, or position is invalid pub fn stake_on_call( env: Env, staker: Address, @@ -152,77 +109,60 @@ impl CallRegistry { amount: i128, position: u32, ) -> Call { - // Require authorization staker.require_auth(); - // Validate amount if amount <= 0 { panic!("Stake amount must be positive"); } - // Get the call let mut call = match get_call(&env, call_id) { Some(c) => c, None => panic!("Call does not exist"), }; - // Check if call has ended let current_timestamp = env.ledger().timestamp(); if current_timestamp >= call.end_ts { panic!("Call has ended"); } - // Check if call is already settled if call.settled { panic!("Call has been settled"); } - // Validate position let stake_position = match StakePosition::from_u32(position) { Some(p) => p, None => panic!("Invalid position: must be 1 (UP) or 2 (DOWN)"), }; - // Transfer tokens from staker to contract let token_client = token::Client::new(&env, &call.stake_token); token_client.transfer(&staker, &env.current_contract_address(), &amount); - /// We rely on events for attribution. The indexer already handles this. - /// Hence I commented out call.up_stakes.set(...) and call.down_stakes.set(...) - /// uncomment in the future if rule changes - - // Update stakes + // We rely on events for attribution. The indexer already handles this. + // Hence call.up_stakes.set(...) / call.down_stakes.set(...) are commented out; + // uncomment in the future if the rule changes. match stake_position { StakePosition::Up => { - let current_stake = call.up_stakes.get(staker.clone()).unwrap_or(0); - // call.up_stakes.set(staker.clone(), current_stake + amount); + let _current_stake = call.up_stakes.get(staker.clone()).unwrap_or(0); + // call.up_stakes.set(staker.clone(), _current_stake + amount); call.total_up_stake += amount; } StakePosition::Down => { - let current_stake = call.down_stakes.get(staker.clone()).unwrap_or(0); - // call.down_stakes.set(staker.clone(), current_stake + amount); + let _current_stake = call.down_stakes.get(staker.clone()).unwrap_or(0); + // call.down_stakes.set(staker.clone(), _current_stake + amount); call.total_down_stake += amount; } } - // Store updated call set_call(&env, &call); add_staker_call(&env, &staker, call_id); extend_storage_ttl(&env); - // Emit event emit_stake_added(&env, call_id, &staker, amount, position); call } /// Get call data by ID - /// - /// # Arguments - /// * `call_id` - ID of the call to retrieve - /// - /// # Returns - /// The Call struct if it exists, panics otherwise pub fn get_call(env: Env, call_id: u64) -> Call { match get_call(&env, call_id) { Some(call) => call, @@ -231,12 +171,6 @@ impl CallRegistry { } /// Get all calls created by a specific address - /// - /// # Arguments - /// * `creator` - Address to filter calls by - /// - /// # Returns - /// Vector of calls created by the address pub fn get_calls_by_creator(env: Env, creator: Address) -> Vec { let mut calls = Vec::new(&env); let total_calls = get_call_counter(&env); @@ -253,12 +187,6 @@ impl CallRegistry { } /// Get statistics for a specific call - /// - /// # Arguments - /// * `call_id` - ID of the call - /// - /// # Returns - /// CallStats struct with aggregated data pub fn get_call_stats(env: Env, call_id: u64) -> CallStats { let call = match get_call(&env, call_id) { Some(c) => c, @@ -275,12 +203,6 @@ impl CallRegistry { } /// Get all calls a staker has participated in - /// - /// # Arguments - /// * `staker` - Address to get calls for - /// - /// # Returns - /// Vector of Call structs pub fn get_staker_calls(env: Env, staker: Address) -> Vec { let call_ids = get_staker_calls(&env, &staker); let mut calls = Vec::new(&env); @@ -294,104 +216,62 @@ impl CallRegistry { calls } - /// Resolve a call with an outcome (admin only) - /// - /// # Arguments - /// * `call_id` - ID of the call to resolve - /// * `outcome` - 1 for UP, 2 for DOWN - /// * `end_price` - Final price for the asset - /// - /// # Panics - /// If caller is not outcome_manager or call doesn't exist + /// Resolve a call with an outcome (outcome_manager only) pub fn resolve_call(env: Env, call_id: u64, outcome: u32, end_price: i128) -> Call { let config = match get_config(&env) { Some(c) => c, None => panic!("Contract not initialized"), }; - // Require authorization from outcome manager config.outcome_manager.require_auth(); - // Get the call let mut call = match get_call(&env, call_id) { Some(c) => c, None => panic!("Call does not exist"), }; - // Validate outcome if outcome != 1 && outcome != 2 { panic!("Invalid outcome: must be 1 (UP) or 2 (DOWN)"); } - // Check that call has ended let current_timestamp = env.ledger().timestamp(); if current_timestamp < call.end_ts { panic!("Call has not ended yet"); } - // Update call call.outcome = outcome; call.end_price = end_price; - // Store updated call set_call(&env, &call); extend_storage_ttl(&env); - // Emit event emit_call_resolved(&env, call_id, outcome, end_price); call } - /// Update admin (current admin only) - /// - /// # Arguments - /// * `new_admin` - New admin address + /// Transfer admin privileges to a new address (admin only). + /// Emits AdminParamsChanged { param: "admin", ... }. pub fn set_admin(env: Env, new_admin: Address) { - let mut config = match get_config(&env) { - Some(c) => c, - None => panic!("Contract not initialized"), - }; - - // Require authorization from current admin - config.admin.require_auth(); - - let old_admin = config.admin.clone(); - config.admin = new_admin.clone(); - - set_config(&env, &config); - extend_storage_ttl(&env); - - emit_admin_changed(&env, &old_admin, &new_admin); + admin::set_admin(env, new_admin); } - /// Update outcome manager (admin only) - /// - /// # Arguments - /// * `new_manager` - New outcome manager address + /// Replace the outcome manager (admin only). + /// Emits AdminParamsChanged { param: "outcome_manager", ... }. pub fn set_outcome_manager(env: Env, new_manager: Address) { - let config = match get_config(&env) { - Some(c) => c, - None => panic!("Contract not initialized"), - }; - - // Require authorization from admin - config.admin.require_auth(); - - let mut new_config = config.clone(); - let old_manager = new_config.outcome_manager.clone(); - new_config.outcome_manager = new_manager.clone(); - - set_config(&env, &new_config); - extend_storage_ttl(&env); + admin::set_outcome_manager(env, new_manager); + } - emit_outcome_manager_changed(&env, &old_manager, &new_manager); + /// Set the protocol fee in basis points, e.g. 100 = 1 % (admin only). + /// Emits AdminParamsChanged { param: "fee_bps", ... }. + /// + /// # Panics + /// * `new_fee_bps` > 10_000 + pub fn set_fee(env: Env, new_fee_bps: u32) { + admin::set_fee(env, new_fee_bps); } /// Get current contract configuration - /// - /// # Returns - /// ContractConfig struct pub fn get_config(env: Env) -> ContractConfig { match get_config(&env) { Some(c) => c, @@ -405,14 +285,6 @@ impl CallRegistry { } /// Get staker's stake amount on a specific call for a position - /// - /// # Arguments - /// * `call_id` - ID of the call - /// * `staker` - Address of the staker - /// * `position` - 1 for UP, 2 for DOWN - /// - /// # Returns - /// Amount staked, or 0 if no stake pub fn get_staker_stake(env: Env, call_id: u64, staker: Address, position: u32) -> i128 { let call = match get_call(&env, call_id) { Some(c) => c, @@ -426,33 +298,27 @@ impl CallRegistry { } } - pub fn release_escrow( - env: Env, - call_id: u64, - to: Address, - amount: i128, -) { - let config = get_config(&env).expect("Not initialized"); - config.outcome_manager.require_auth(); - - let call = get_call(&env, call_id).expect("Call not found"); + pub fn release_escrow(env: Env, call_id: u64, to: Address, amount: i128) { + let config = get_config(&env).expect("Not initialized"); + config.outcome_manager.require_auth(); - let token_client = token::Client::new(&env, &call.stake_token); - token_client.transfer(&env.current_contract_address(), &to, &amount); -} + let call = get_call(&env, call_id).expect("Call not found"); + let token_client = token::Client::new(&env, &call.stake_token); + token_client.transfer(&env.current_contract_address(), &to, &amount); + } pub fn mark_settled(env: Env, call_id: u64) { - let config = get_config(&env).expect("Not initialized"); - config.outcome_manager.require_auth(); + let config = get_config(&env).expect("Not initialized"); + config.outcome_manager.require_auth(); - let mut call = get_call(&env, call_id).expect("Call not found"); + let mut call = get_call(&env, call_id).expect("Call not found"); - if call.settled { - panic!("Already settled"); - } + if call.settled { + panic!("Already settled"); + } - call.settled = true; - set_call(&env, &call); -} + call.settled = true; + set_call(&env, &call); + } } diff --git a/packages/contracts/call_registry/src/test.rs b/packages/contracts/call_registry/src/test.rs index b355fa8..784b4d0 100644 --- a/packages/contracts/call_registry/src/test.rs +++ b/packages/contracts/call_registry/src/test.rs @@ -1,11 +1,146 @@ #![cfg(test)] -use soroban_sdk::{testutils::*, Address, Bytes, Env, String as SorobanString}; +use soroban_sdk::{testutils::Events, Vec, Address, Env, IntoVal, Symbol, Bytes, String as SorobanString}; mod call_registry { use super::*; use crate::{CallRegistry, CallRegistryClient}; + fn setup() -> (Env, CallRegistryClient<'static>, Address, Address) { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register_contract(None, CallRegistry); + let client = CallRegistryClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + let outcome_manager = Address::generate(&env); + + client.initialize(&admin, &outcome_manager); + + (env, client, admin, outcome_manager) +} + +// ── set_admin ───────────────────────────────────────────────────────────────── + +#[test] +fn test_set_admin_updates_config() { + let (env, client, _admin, _om) = setup(); + let new_admin = Address::generate(&env); + + client.set_admin(&new_admin); + + assert_eq!(client.get_config().admin, new_admin); +} + +#[test] +fn test_set_admin_emits_admin_params_changed() { + let (env, client, old_admin, _om) = setup(); + let new_admin = Address::generate(&env); + + client.set_admin(&new_admin); + + let events = env.events().all(); + let last = events.last().expect("no events"); + + // Topic: ("call_registry", "admin_params_changed") + assert_eq!( + last.0, + vec![ + &env, + "call_registry".into_val(&env), + "admin_params_changed".into_val(&env), + ] + ); + + // First element of the payload tuple is the param discriminant + let (param, _changed_by, old_val, new_val): (Symbol, Address, Address, Address) = + last.1.into_val(&env); + + assert_eq!(param, Symbol::new(&env, "admin")); + assert_eq!(old_val, old_admin); + assert_eq!(new_val, new_admin); +} + +// ── set_outcome_manager ─────────────────────────────────────────────────────── + +#[test] +fn test_set_outcome_manager_updates_config() { + let (env, client, _admin, _om) = setup(); + let new_om = Address::generate(&env); + + client.set_outcome_manager(&new_om); + + assert_eq!(client.get_config().outcome_manager, new_om); +} + +#[test] +fn test_set_outcome_manager_emits_admin_params_changed() { + let (env, client, _admin, old_om) = setup(); + let new_om = Address::generate(&env); + + client.set_outcome_manager(&new_om); + + let events = env.events().all(); + let last = events.last().expect("no events"); + + let (param, _changed_by, old_val, new_val): (Symbol, Address, Address, Address) = + last.1.into_val(&env); + + assert_eq!(param, Symbol::new(&env, "outcome_manager")); + assert_eq!(old_val, old_om); + assert_eq!(new_val, new_om); +} + +// ── set_fee ─────────────────────────────────────────────────────────────────── + +#[test] +fn test_set_fee_updates_config() { + let (_env, client, _admin, _om) = setup(); + + client.set_fee(&250_u32); // 2.5 % + + assert_eq!(client.get_config().fee_bps, 250); +} + +#[test] +fn test_set_fee_emits_admin_params_changed() { + let (env, client, _admin, _om) = setup(); + + client.set_fee(&100_u32); + + let events = env.events().all(); + let last = events.last().expect("no events"); + + let (param, _changed_by, old_val, new_val): (Symbol, Address, u32, u32) = + last.1.into_val(&env); + + assert_eq!(param, Symbol::new(&env, "fee_bps")); + assert_eq!(old_val, 0_u32); // default set in initialize() + assert_eq!(new_val, 100_u32); +} + +#[test] +fn test_set_fee_zero_is_valid() { + let (_env, client, _admin, _om) = setup(); + client.set_fee(&0_u32); + assert_eq!(client.get_config().fee_bps, 0); +} + +#[test] +fn test_set_fee_max_boundary_is_valid() { + let (_env, client, _admin, _om) = setup(); + client.set_fee(&10_000_u32); // exactly 100 % — allowed + assert_eq!(client.get_config().fee_bps, 10_000); +} + +#[test] +#[should_panic(expected = "fee_bps cannot exceed 10_000 (100%)")] +fn test_set_fee_above_max_panics() { + let (_env, client, _admin, _om) = setup(); + client.set_fee(&10_001_u32); +} + fn create_test_env() -> (Env, Address, Address, Address) { let env = Env::default(); let admin = Address::generate(&env); diff --git a/packages/contracts/call_registry/src/types.rs b/packages/contracts/call_registry/src/types.rs index 25e6527..28c32d0 100644 --- a/packages/contracts/call_registry/src/types.rs +++ b/packages/contracts/call_registry/src/types.rs @@ -75,6 +75,8 @@ pub struct ContractConfig { pub admin: Address, /// Address that can submit call outcomes pub outcome_manager: Address, + /// Protocol fee in basis points (e.g. 100 = 1%). Default: 0. + pub fee_bps: u32, } /// Statistics for a call diff --git a/packages/contracts/outcome_manager/src/auth.rs b/packages/contracts/outcome_manager/src/auth.rs index ea17012..a22706b 100644 --- a/packages/contracts/outcome_manager/src/auth.rs +++ b/packages/contracts/outcome_manager/src/auth.rs @@ -1,5 +1,5 @@ -use soroban_sdk::{Env, Address}; use crate::storage::InstanceKey; +use soroban_sdk::{Address, Env}; pub fn require_admin(env: &Env) -> Address { let admin: Address = env diff --git a/packages/contracts/outcome_manager/src/events.rs b/packages/contracts/outcome_manager/src/events.rs index e13ae43..a02cb4d 100644 --- a/packages/contracts/outcome_manager/src/events.rs +++ b/packages/contracts/outcome_manager/src/events.rs @@ -1,7 +1,12 @@ -use soroban_sdk::{Env, symbol_short}; +use soroban_sdk::{symbol_short, Env}; /// Emitted when a new oracle outcome report is accepted (before quorum) -pub fn emit_outcome_submitted(env: &Env, call_id: u64, oracle: &soroban_sdk::BytesN<32>, outcome: u32) { +pub fn emit_outcome_submitted( + env: &Env, + call_id: u64, + oracle: &soroban_sdk::BytesN<32>, + outcome: u32, +) { env.events().publish( (symbol_short!("outcome"), symbol_short!("submitted")), (call_id, oracle.clone(), outcome), diff --git a/packages/contracts/outcome_manager/src/lib.rs b/packages/contracts/outcome_manager/src/lib.rs index e3c6565..4c2e694 100644 --- a/packages/contracts/outcome_manager/src/lib.rs +++ b/packages/contracts/outcome_manager/src/lib.rs @@ -3,11 +3,11 @@ mod auth; mod events; mod storage; -mod verification; mod test; +mod verification; use soroban_sdk::{ - contract, contractimpl, symbol_short, Address, BytesN, Env, IntoVal, Map, Vec, Symbol, + contract, contractimpl, symbol_short, Address, BytesN, Env, IntoVal, Map, Symbol, Vec, }; use auth::require_admin; @@ -18,13 +18,25 @@ use verification::{build_message, verify_signature}; // ─── Cross-contract helpers ──────────────────────────────────────────────────── /// Call `resolve_call(call_id, outcome, end_price)` on the CallRegistry. -fn registry_resolve_call(env: &Env, registry: &Address, call_id: u64, outcome: u32, end_price: i128) { +fn registry_resolve_call( + env: &Env, + registry: &Address, + call_id: u64, + outcome: u32, + end_price: i128, +) { let args = (call_id, outcome, end_price).into_val(env); env.invoke_contract::<()>(registry, &Symbol::new(env, "resolve_call"), args); } /// Call `release_escrow(call_id, to, amount)` on the CallRegistry. -fn registry_release_escrow(env: &Env, registry: &Address, call_id: u64, to: &Address, amount: i128) { +fn registry_release_escrow( + env: &Env, + registry: &Address, + call_id: u64, + to: &Address, + amount: i128, +) { let args = (call_id, to.clone(), amount).into_val(env); env.invoke_contract::<()>(registry, &Symbol::new(env, "release_escrow"), args); } @@ -52,12 +64,7 @@ impl OutcomeManager { /// /// # Panics /// If called more than once (`already initialized`). - pub fn initialize( - env: Env, - admin: Address, - oracles: Vec>, - quorum: u32, - ) { + pub fn initialize(env: Env, admin: Address, oracles: Vec>, quorum: u32) { if env.storage().instance().has(&InstanceKey::Admin) { panic!("already initialized"); } @@ -74,7 +81,9 @@ impl OutcomeManager { } env.storage().instance().set(&InstanceKey::Admin, &admin); - env.storage().instance().set(&InstanceKey::Oracles, &oracle_map); + env.storage() + .instance() + .set(&InstanceKey::Oracles, &oracle_map); env.storage().instance().set(&InstanceKey::Quorum, &quorum); } @@ -85,7 +94,9 @@ impl OutcomeManager { let mut oracles: Map, bool> = env.storage().instance().get(&InstanceKey::Oracles).unwrap(); oracles.set(oracle, true); - env.storage().instance().set(&InstanceKey::Oracles, &oracles); + env.storage() + .instance() + .set(&InstanceKey::Oracles, &oracles); } pub fn remove_oracle(env: Env, oracle: BytesN<32>) { @@ -93,7 +104,9 @@ impl OutcomeManager { let mut oracles: Map, bool> = env.storage().instance().get(&InstanceKey::Oracles).unwrap(); oracles.remove(oracle); - env.storage().instance().set(&InstanceKey::Oracles, &oracles); + env.storage() + .instance() + .set(&InstanceKey::Oracles, &oracles); } pub fn set_quorum(env: Env, quorum: u32) { @@ -108,7 +121,9 @@ impl OutcomeManager { pub fn set_admin(env: Env, new_admin: Address) { require_admin(&env); - env.storage().instance().set(&InstanceKey::Admin, &new_admin); + env.storage() + .instance() + .set(&InstanceKey::Admin, &new_admin); } // ── Oracle Submission ────────────────────────────────────────────────────── @@ -134,7 +149,11 @@ impl OutcomeManager { } // 2. Reject if already settled - if env.storage().instance().has(&InstanceKey::FinalOutcome(signed.call_id)) { + if env + .storage() + .instance() + .has(&InstanceKey::FinalOutcome(signed.call_id)) + { panic!("already settled"); } @@ -163,7 +182,9 @@ impl OutcomeManager { let outcome_hash: BytesN<32> = env.crypto().sha256(&message).into(); // 7. Record oracle's vote (prevents duplicates) - env.storage().temporary().set(&submission_key, &outcome_hash); + env.storage() + .temporary() + .set(&submission_key, &outcome_hash); // 8. Tally votes for this outcome candidate let vote_key = TempKey::VoteCount(outcome_hash.clone(), signed.call_id); @@ -198,7 +219,13 @@ impl OutcomeManager { .set(&InstanceKey::FinalOutcome(outcome.call_id), &outcome); // Cross-contract: resolve the call in the registry - registry_resolve_call(env, registry, outcome.call_id, outcome.outcome, outcome.price); + registry_resolve_call( + env, + registry, + outcome.call_id, + outcome.outcome, + outcome.price, + ); emit_outcome_finalized(env, outcome.call_id, outcome.outcome, outcome.price); } @@ -238,7 +265,11 @@ impl OutcomeManager { staker.require_auth(); // 2. Verify the call is settled - if !env.storage().instance().has(&InstanceKey::FinalOutcome(call_id)) { + if !env + .storage() + .instance() + .has(&InstanceKey::FinalOutcome(call_id)) + { panic!("call not settled"); } @@ -284,7 +315,11 @@ impl OutcomeManager { pub fn mark_settled(env: Env, registry: Address, call_id: u64) { require_admin(&env); - if !env.storage().instance().has(&InstanceKey::FinalOutcome(call_id)) { + if !env + .storage() + .instance() + .has(&InstanceKey::FinalOutcome(call_id)) + { panic!("call not finalized"); } @@ -325,4 +360,4 @@ impl OutcomeManager { .expect("not initialized"); oracles.contains_key(oracle) } -} \ No newline at end of file +} diff --git a/packages/contracts/outcome_manager/src/test.rs b/packages/contracts/outcome_manager/src/test.rs index 3600803..92f465a 100644 --- a/packages/contracts/outcome_manager/src/test.rs +++ b/packages/contracts/outcome_manager/src/test.rs @@ -3,11 +3,11 @@ use soroban_sdk::{ contract, contractimpl, testutils::{Address as _, Ledger}, - Address, Bytes, BytesN, Env, Vec, Symbol, + Address, Bytes, BytesN, Env, Symbol, Vec, }; -use crate::{OutcomeManager, OutcomeManagerClient}; use crate::storage::{Outcome, SignedOutcome}; +use crate::{OutcomeManager, OutcomeManagerClient}; // ─── Test Helpers ───────────────────────────────────────────────────────────── @@ -30,7 +30,7 @@ fn gen_keypair(env: &Env) -> (BytesN<32>, BytesN<32>) { // Use a random seed for testing let mut seed = [0u8; 32]; rand::thread_rng().fill_bytes(&mut seed); - + let signing_key = SigningKey::from_bytes(&seed); let public_key = signing_key.verifying_key(); @@ -50,10 +50,10 @@ fn sign_outcome( timestamp: u64, ) -> BytesN<64> { use crate::verification::build_message; - use ed25519_dalek::{SigningKey, Signer}; + use ed25519_dalek::{Signer, SigningKey}; let msg = build_message(env, call_id, outcome, price, timestamp); - + // Convert soroban Bytes to fixed-size array for signing let mut msg_bytes = [0u8; 128]; let msg_len = msg.len() as usize; @@ -66,7 +66,15 @@ fn sign_outcome( } /// Register and initialize an OutcomeManager with a single oracle / quorum=1 -fn setup_single_oracle(env: &Env) -> (Address, Address, BytesN<32>, BytesN<32>, OutcomeManagerClient) { +fn setup_single_oracle( + env: &Env, +) -> ( + Address, + Address, + BytesN<32>, + BytesN<32>, + OutcomeManagerClient, +) { env.mock_all_auths(); let admin = Address::generate(env); let (oracle_secret, oracle_pubkey) = gen_keypair(env); diff --git a/packages/contracts/outcome_manager/src/verification.rs b/packages/contracts/outcome_manager/src/verification.rs index e04b233..2fe7132 100644 --- a/packages/contracts/outcome_manager/src/verification.rs +++ b/packages/contracts/outcome_manager/src/verification.rs @@ -1,16 +1,10 @@ -use soroban_sdk::{Env, Bytes, BytesN}; +use soroban_sdk::{Bytes, BytesN, Env}; /// Build the canonical message that oracles sign. /// /// Format (all big-endian): /// b"BACKit:Outcome:" | call_id(8B) | b":" | outcome(1B) | b":" | price(16B) | b":" | timestamp(8B) -pub fn build_message( - env: &Env, - call_id: u64, - outcome: u32, - price: i128, - timestamp: u64, -) -> Bytes { +pub fn build_message(env: &Env, call_id: u64, outcome: u32, price: i128, timestamp: u64) -> Bytes { let mut msg = Bytes::new(env); msg.append(&Bytes::from_slice(env, b"BACKit:Outcome:"));