From 015b8aa81375df28c486c579b8df28c86a8e188c Mon Sep 17 00:00:00 2001 From: Agbasimere Date: Tue, 26 May 2026 19:29:04 +0100 Subject: [PATCH 1/6] fix: use checked arithmetic in platform fee calculation --- contracts/predictify-hybrid/src/err.rs | 4 + contracts/predictify-hybrid/src/fees.rs | 182 +++++++++++++++++++++--- contracts/predictify-hybrid/src/lib.rs | 20 --- docs/contracts/FEES.md | 53 +++++++ 4 files changed, 218 insertions(+), 41 deletions(-) create mode 100644 docs/contracts/FEES.md diff --git a/contracts/predictify-hybrid/src/err.rs b/contracts/predictify-hybrid/src/err.rs index 0bee6530..100fea0d 100644 --- a/contracts/predictify-hybrid/src/err.rs +++ b/contracts/predictify-hybrid/src/err.rs @@ -121,6 +121,8 @@ pub enum Error { DisputeFeeFailed = 409, /// Generic dispute subsystem error. Check dispute state and configuration. DisputeError = 410, + /// Fee arithmetic overflowed or otherwise failed during checked calculation. + FeeArithmeticOverflow = 411, /// Platform fee has already been collected from this market. FeeAlreadyCollected = 413, /// No fees are available to collect from this market. @@ -1376,6 +1378,7 @@ impl Error { Error::DisputeCondNotMet => "Dispute resolution conditions not met", Error::DisputeFeeFailed => "Dispute fee distribution failed", Error::DisputeError => "Generic dispute subsystem error", + Error::FeeArithmeticOverflow => "Fee arithmetic overflowed", Error::FeeAlreadyCollected => "Platform fee already collected", Error::NoFeesToCollect => "No fees available to collect", Error::InvalidExtensionDays => "Invalid extension days value", @@ -1468,6 +1471,7 @@ impl Error { Error::DisputeCondNotMet => "DISPUTE_RESOLUTION_CONDITIONS_NOT_MET", Error::DisputeFeeFailed => "DISPUTE_FEE_DISTRIBUTION_FAILED", Error::DisputeError => "DISPUTE_ERROR", + Error::FeeArithmeticOverflow => "FEE_ARITHMETIC_OVERFLOW", Error::FeeAlreadyCollected => "FEE_ALREADY_COLLECTED", Error::NoFeesToCollect => "NO_FEES_TO_COLLECT", Error::InvalidExtensionDays => "INVALID_EXTENSION_DAYS", diff --git a/contracts/predictify-hybrid/src/fees.rs b/contracts/predictify-hybrid/src/fees.rs index 569f97da..91247832 100644 --- a/contracts/predictify-hybrid/src/fees.rs +++ b/contracts/predictify-hybrid/src/fees.rs @@ -909,14 +909,42 @@ impl FeeManager { pub struct FeeCalculator; impl FeeCalculator { + fn checked_fee_add(lhs: i128, rhs: i128) -> Result { + lhs.checked_add(rhs).ok_or(Error::FeeArithmeticOverflow) + } + + fn checked_fee_sub(lhs: i128, rhs: i128) -> Result { + lhs.checked_sub(rhs).ok_or(Error::FeeArithmeticOverflow) + } + + fn checked_mul_div_floor(value: i128, multiplier: i128, divisor: i128) -> Result { + if value < 0 || multiplier < 0 || divisor <= 0 { + return Err(Error::InvalidFeeConfig); + } + + value + .checked_mul(multiplier) + .ok_or(Error::FeeArithmeticOverflow)? + .checked_div(divisor) + .ok_or(Error::FeeArithmeticOverflow) + } + + fn checked_bps_floor(amount: i128, bps: i128) -> Result { + Self::checked_mul_div_floor(amount, bps, crate::PERCENTAGE_DENOMINATOR) + } + /// Calculate platform fee for a market + /// + /// Rounds fees down toward zero. Because all fee inputs are non-negative, this is + /// equivalent to floor rounding and guarantees: + /// `platform_fee + user_payout_amount == total_staked`. pub fn calculate_platform_fee(market: &Market) -> Result { - if market.total_staked == 0 { + if market.total_staked <= 0 { return Err(Error::InvalidFeeConfig); } let fee_percentage = PLATFORM_FEE_PERCENTAGE; - let fee_amount = (market.total_staked * fee_percentage) / crate::PERCENTAGE_DENOMINATOR; + let fee_amount = Self::checked_bps_floor(market.total_staked, fee_percentage)?; if fee_amount < MIN_FEE_AMOUNT { return Err(Error::InsufficientStake); @@ -939,21 +967,28 @@ impl FeeCalculator { if winning_total == 0 { return Err(Error::NothingToClaim); } + if user_stake < 0 || winning_total < 0 || total_pool < 0 { + return Err(Error::InvalidFeeConfig); + } let fee_percentage = PLATFORM_FEE_PERCENTAGE; - let user_share = (user_stake * (crate::PERCENTAGE_DENOMINATOR - fee_percentage)) / crate::PERCENTAGE_DENOMINATOR; - let payout = (user_share * total_pool) / winning_total; + let user_share = + Self::checked_bps_floor(user_stake, crate::PERCENTAGE_DENOMINATOR - fee_percentage)?; + let payout = Self::checked_mul_div_floor(user_share, total_pool, winning_total)?; Ok(payout) } /// Calculate fee breakdown for a market + /// + /// The platform fee is rounded down (floor), and the user payout is computed as the exact + /// checked remainder so the two amounts always reconcile to `total_staked`. pub fn calculate_fee_breakdown(market: &Market) -> Result { let total_staked = market.total_staked; let fee_percentage = PLATFORM_FEE_PERCENTAGE; let fee_amount = Self::calculate_platform_fee(market)?; let platform_fee = fee_amount; - let user_payout_amount = total_staked - fee_amount; + let user_payout_amount = Self::checked_fee_sub(total_staked, fee_amount)?; Ok(FeeBreakdown { total_staked, @@ -977,7 +1012,7 @@ impl FeeCalculator { 100 // No adjustment for small markets }; - let adjusted_fee = (base_fee * size_multiplier) / 100; + let adjusted_fee = Self::checked_mul_div_floor(base_fee, size_multiplier, 100)?; // Ensure minimum fee if adjusted_fee < MIN_FEE_AMOUNT { @@ -1068,7 +1103,7 @@ impl FeeCalculator { 100 // No adjustment for very low activity }; - let adjusted_fee = (base_fee * activity_multiplier) / 100; + let adjusted_fee = Self::checked_mul_div_floor(base_fee, activity_multiplier, 100)?; // Ensure fee is within limits if adjusted_fee < MIN_FEE_AMOUNT { @@ -1095,8 +1130,8 @@ impl FeeCalculator { let tier = Self::get_fee_tier_by_market_size(env, market.total_staked)?; // Allow some flexibility around the tier fee - let min_allowed = (tier.fee_percentage * 80) / 100; // 20% below tier - let max_allowed = (tier.fee_percentage * 120) / 100; // 20% above tier + let min_allowed = Self::checked_mul_div_floor(tier.fee_percentage, 80, 100)?; // 20% below tier + let max_allowed = Self::checked_mul_div_floor(tier.fee_percentage, 120, 100)?; // 20% above tier if fee < min_allowed || fee > max_allowed { return Err(Error::InvalidInput); @@ -1155,9 +1190,12 @@ impl FeeCalculator { let complexity_factor = 100; // No complexity adjustment for now // Calculate final fee percentage + let size_adjusted = + Self::checked_mul_div_floor(tier.fee_percentage, size_multiplier, 100)?; + let activity_adjusted = + Self::checked_mul_div_floor(size_adjusted, activity_multiplier, 100)?; let final_fee_percentage = - (tier.fee_percentage * size_multiplier * activity_multiplier * complexity_factor) - / (100 * 100 * 100); + Self::checked_mul_div_floor(activity_adjusted, complexity_factor, 100)?; // Ensure final fee is within limits let final_fee_percentage = if final_fee_percentage < MIN_FEE_PERCENTAGE { @@ -1393,9 +1431,8 @@ impl FeeTracker { let total_key = symbol_short!("tot_fees"); let current_total: i128 = env.storage().persistent().get(&total_key).unwrap_or(0); - env.storage() - .persistent() - .set(&total_key, &(current_total + amount)); + let updated_total = FeeCalculator::checked_fee_add(current_total, amount)?; + env.storage().persistent().set(&total_key, &updated_total); Ok(()) } @@ -1407,9 +1444,8 @@ impl FeeTracker { let creation_key = symbol_short!("creat_fee"); let current_total: i128 = env.storage().persistent().get(&creation_key).unwrap_or(0); - env.storage() - .persistent() - .set(&creation_key, &(current_total + amount)); + let updated_total = FeeCalculator::checked_fee_add(current_total, amount)?; + env.storage().persistent().set(&creation_key, &updated_total); Ok(()) } @@ -1620,10 +1656,8 @@ impl FeeWithdrawalManager { // Apply cap (bps of current available fees). If cap is <100% and the computed // value rounds down to 0, allow at least 1 stroop to avoid permanent lock. - let mut cap_amount = (available_fees - .checked_mul(schedule.max_withdrawal_bps as i128) - .ok_or(Error::InvalidInput)?) - / 10_000; + let mut cap_amount = + FeeCalculator::checked_bps_floor(available_fees, schedule.max_withdrawal_bps as i128)?; if schedule.max_withdrawal_bps < 10_000 && cap_amount == 0 { cap_amount = 1; } @@ -1929,6 +1963,112 @@ pub mod testing { // ===== MODULE TESTS ===== +#[cfg(test)] +mod checked_arithmetic_tests { + extern crate std; + + use super::*; + use crate::validation::ValidationTestingUtils; + use soroban_sdk::{testutils::Address as _, Address, String, Symbol, Vec}; + + fn resolved_market(env: &Env, total_staked: i128) -> Market { + let mut market = ValidationTestingUtils::create_test_market(env); + let mut winning_outcomes = Vec::new(env); + winning_outcomes.push_back(String::from_str(env, "yes")); + market.winning_outcomes = Some(winning_outcomes); + market.total_staked = total_staked; + market + } + + #[test] + fn test_calculate_platform_fee_rejects_zero_pool() { + let env = Env::default(); + let market = resolved_market(&env, 0); + + let result = FeeCalculator::calculate_platform_fee(&market); + + assert_eq!(result, Err(Error::InvalidFeeConfig)); + } + + #[test] + fn test_checked_bps_floor_rounds_down_for_one_basis_point() { + let result = FeeCalculator::checked_bps_floor(10_001, 1).unwrap(); + + assert_eq!(result, 1); + } + + #[test] + fn test_calculate_platform_fee_errors_on_overflow_adjacent_pool() { + let env = Env::default(); + let market = resolved_market(&env, i128::MAX); + + let result = FeeCalculator::calculate_platform_fee(&market); + + assert_eq!(result, Err(Error::FeeArithmeticOverflow)); + } + + #[test] + fn test_fee_breakdown_reconciles_after_floor_rounding() { + let env = Env::default(); + let market = resolved_market(&env, 50_000_001); + + let breakdown = FeeCalculator::calculate_fee_breakdown(&market).unwrap(); + + assert_eq!(breakdown.fee_amount, 1_000_000); + assert_eq!( + breakdown.platform_fee + breakdown.user_payout_amount, + breakdown.total_staked + ); + } + + #[test] + fn test_collect_fees_returns_overflow_error_without_mutation() { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register(crate::PredictifyHybrid, ()); + let admin = Address::generate(&env); + let market_id = Symbol::new(&env, "fee_overflow"); + let market = resolved_market(&env, i128::MAX); + + env.as_contract(&contract_id, || { + env.storage() + .persistent() + .set(&Symbol::new(&env, "Admin"), &admin); + MarketStateManager::update_market(&env, &market_id, &market); + + let result = FeeManager::collect_fees(&env, admin.clone(), market_id.clone()); + + assert_eq!(result, Err(Error::FeeArithmeticOverflow)); + assert_eq!( + env.storage() + .persistent() + .get::(&FEE_VAULT_KEY) + .unwrap_or(0), + 0 + ); + assert!(!MarketStateManager::get_market(&env, &market_id) + .unwrap() + .fee_collected); + }); + } + + #[test] + fn test_record_fee_collection_errors_on_total_overflow() { + let env = Env::default(); + let contract_id = env.register(crate::PredictifyHybrid, ()); + let admin = Address::generate(&env); + let market_id = Symbol::new(&env, "fee_sum"); + + env.as_contract(&contract_id, || { + env.storage().persistent().set(&FEE_VAULT_KEY, &i128::MAX); + + let result = FeeTracker::record_fee_collection(&env, &market_id, 1, &admin); + + assert_eq!(result, Err(Error::FeeArithmeticOverflow)); + }); + } +} + #[cfg(any())] mod tests { use super::*; diff --git a/contracts/predictify-hybrid/src/lib.rs b/contracts/predictify-hybrid/src/lib.rs index 1a835553..f8fb9cab 100644 --- a/contracts/predictify-hybrid/src/lib.rs +++ b/contracts/predictify-hybrid/src/lib.rs @@ -6976,23 +6976,3 @@ impl PredictifyHybrid { #[cfg(any())] mod test; - -fn assert_can_participate(env: &Env, user: &Address, event: &Event) { - if event.is_private { - let is_allowed = event.allowlist.iter().any(|addr| addr == user); - if !is_allowed { - panic!("User not allowlisted for private event"); - } - } -} -let event = get_event(&env, event_id); - -assert_can_participate(&env, &user, &event); - -/// Places a bet on a given event. -/// -/// # Panics -/// - If the event is private and the caller is not in the allowlist. -/// -/// # Security -/// Enforces event-level access control before any state mutation. \ No newline at end of file diff --git a/docs/contracts/FEES.md b/docs/contracts/FEES.md new file mode 100644 index 00000000..3084a36e --- /dev/null +++ b/docs/contracts/FEES.md @@ -0,0 +1,53 @@ +# Fee Arithmetic Invariants + +This document captures the arithmetic guarantees for platform-fee calculation in +`contracts/predictify-hybrid/src/fees.rs`. + +## Core Rules + +1. All fee percentage math uses checked arithmetic. +2. Any overflow in fee multiplication, addition, subtraction, or division returns + `Error::FeeArithmeticOverflow`. +3. Basis-point calculations round down using integer division. +4. Fee breakdowns must reconcile exactly: + +```text +platform_fee + user_payout_amount == total_staked +``` + +## Basis-Point Rounding + +Platform fees are computed with checked multiply-then-divide logic: + +```text +floor(total_staked * fee_bps / 10_000) +``` + +Because all supported fee inputs are non-negative, integer division is equivalent +to floor rounding. This intentionally favors reconciliation over accidental +over-collection. + +## Overflow Handling + +The following operations must never wrap: + +- `FeeCalculator::calculate_platform_fee` +- `FeeCalculator::calculate_user_payout_after_fees` +- `FeeCalculator::calculate_fee_breakdown` +- dynamic fee multiplier math +- `FeeManager::collect_fees` through its fee calculation path +- fee vault and creation-fee total accumulation in `FeeTracker` + +If an intermediate value exceeds `i128`, the operation fails with +`Error::FeeArithmeticOverflow` and no fee state is updated. + +## Regression Coverage + +Regression tests cover: + +- zero pool rejection +- 1-bps floor rounding +- overflow-adjacent `i128::MAX` stake pools +- fee breakdown reconciliation after floor rounding +- no mutation when `collect_fees` encounters overflow +- fee vault accumulation overflow rejection From 4956bba668c3c08b8b9debdfcb881fc39c9ddbde Mon Sep 17 00:00:00 2001 From: Agbasimere Date: Tue, 26 May 2026 19:42:42 +0100 Subject: [PATCH 2/6] test: unblock fee suite verification --- .../src/admin_auth_audit_tests.rs | 18 ++++++++++----- .../src/category_tags_tests.rs | 4 ++-- contracts/predictify-hybrid/src/disputes.rs | 2 +- contracts/predictify-hybrid/src/fees.rs | 23 ++++++++++++++----- contracts/predictify-hybrid/src/lib.rs | 1 - .../src/multi_admin_multisig_tests.rs | 10 ++++---- contracts/predictify-hybrid/src/oracles.rs | 3 +-- contracts/predictify-hybrid/src/queries.rs | 10 +++++--- .../src/storage_layout_tests.rs | 6 ++++- 9 files changed, 50 insertions(+), 27 deletions(-) diff --git a/contracts/predictify-hybrid/src/admin_auth_audit_tests.rs b/contracts/predictify-hybrid/src/admin_auth_audit_tests.rs index 8b549bd9..c302c75b 100644 --- a/contracts/predictify-hybrid/src/admin_auth_audit_tests.rs +++ b/contracts/predictify-hybrid/src/admin_auth_audit_tests.rs @@ -27,7 +27,7 @@ impl TestSetup { fn initialized() -> Self { let setup = Self::uninitialized(); - setup.client().initialize(&setup.admin, &None); + setup.client().initialize(&setup.admin, &None, &None); setup } @@ -41,7 +41,9 @@ fn test_upgrade_contract_requires_persistent_primary_admin() { let setup = TestSetup::uninitialized(); let wasm_hash = BytesN::from_array(&setup.env, &[7; 32]); - let result = setup.client().try_upgrade_contract(&setup.admin, &wasm_hash); + let result = setup + .client() + .try_upgrade_contract(&setup.admin, &wasm_hash); assert_eq!(result, Err(Ok(Error::AdminNotSet))); } @@ -97,16 +99,20 @@ fn test_delegated_super_admin_can_manage_admins_after_migration() { .client() .add_admin(&setup.admin, &delegated_admin, &AdminRole::SuperAdmin); - let result = setup - .client() - .try_add_admin(&delegated_admin, &target_admin, &AdminRole::MarketAdmin); + let result = + setup + .client() + .try_add_admin(&delegated_admin, &target_admin, &AdminRole::MarketAdmin); assert_eq!(result, Ok(Ok(()))); let assignment = setup.env.as_contract(&setup.contract_id, || { AdminManager::get_admin_assignment(&setup.env, &target_admin) }); - assert_eq!(assignment.map(|value| value.role), Some(AdminRole::MarketAdmin)); + assert_eq!( + assignment.map(|value| value.role), + Some(AdminRole::MarketAdmin) + ); } #[test] diff --git a/contracts/predictify-hybrid/src/category_tags_tests.rs b/contracts/predictify-hybrid/src/category_tags_tests.rs index 35db5518..2d23a31e 100644 --- a/contracts/predictify-hybrid/src/category_tags_tests.rs +++ b/contracts/predictify-hybrid/src/category_tags_tests.rs @@ -18,7 +18,7 @@ fn setup_test() -> (Env, PredictifyHybridClient<'static>, Address) { let admin = Address::generate(&env); // Initialize contract - client.initialize(&admin, &Some(2)); // 2% fee + client.initialize(&admin, &Some(2), &None); // 2% fee (env, client, admin) } @@ -342,7 +342,7 @@ impl TokenTestSetup { // Initialize the contract let client = PredictifyHybridClient::new(&env, &contract_id); - client.initialize(&admin, &Some(2)); + client.initialize(&admin, &Some(2), &None); // Create users and fund them let user1 = Address::generate(&env); diff --git a/contracts/predictify-hybrid/src/disputes.rs b/contracts/predictify-hybrid/src/disputes.rs index 18e5c3b1..da3331e8 100644 --- a/contracts/predictify-hybrid/src/disputes.rs +++ b/contracts/predictify-hybrid/src/disputes.rs @@ -833,7 +833,7 @@ impl DisputeManager { market_id: market_id.clone(), stake, timestamp: env.ledger().timestamp(), - reason, + reason: reason.clone(), status: DisputeStatus::Active, }; diff --git a/contracts/predictify-hybrid/src/fees.rs b/contracts/predictify-hybrid/src/fees.rs index 91247832..a79c4e3f 100644 --- a/contracts/predictify-hybrid/src/fees.rs +++ b/contracts/predictify-hybrid/src/fees.rs @@ -1969,7 +1969,18 @@ mod checked_arithmetic_tests { use super::*; use crate::validation::ValidationTestingUtils; - use soroban_sdk::{testutils::Address as _, Address, String, Symbol, Vec}; + use soroban_sdk::{ + testutils::{Address as _, EnvTestConfig}, + Address, String, Symbol, Vec, + }; + + fn test_env() -> Env { + let mut env = Env::default(); + env.set_config(EnvTestConfig { + capture_snapshot_at_drop: false, + }); + env + } fn resolved_market(env: &Env, total_staked: i128) -> Market { let mut market = ValidationTestingUtils::create_test_market(env); @@ -1982,7 +1993,7 @@ mod checked_arithmetic_tests { #[test] fn test_calculate_platform_fee_rejects_zero_pool() { - let env = Env::default(); + let env = test_env(); let market = resolved_market(&env, 0); let result = FeeCalculator::calculate_platform_fee(&market); @@ -1999,7 +2010,7 @@ mod checked_arithmetic_tests { #[test] fn test_calculate_platform_fee_errors_on_overflow_adjacent_pool() { - let env = Env::default(); + let env = test_env(); let market = resolved_market(&env, i128::MAX); let result = FeeCalculator::calculate_platform_fee(&market); @@ -2009,7 +2020,7 @@ mod checked_arithmetic_tests { #[test] fn test_fee_breakdown_reconciles_after_floor_rounding() { - let env = Env::default(); + let env = test_env(); let market = resolved_market(&env, 50_000_001); let breakdown = FeeCalculator::calculate_fee_breakdown(&market).unwrap(); @@ -2023,7 +2034,7 @@ mod checked_arithmetic_tests { #[test] fn test_collect_fees_returns_overflow_error_without_mutation() { - let env = Env::default(); + let env = test_env(); env.mock_all_auths(); let contract_id = env.register(crate::PredictifyHybrid, ()); let admin = Address::generate(&env); @@ -2054,7 +2065,7 @@ mod checked_arithmetic_tests { #[test] fn test_record_fee_collection_errors_on_total_overflow() { - let env = Env::default(); + let env = test_env(); let contract_id = env.register(crate::PredictifyHybrid, ()); let admin = Address::generate(&env); let market_id = Symbol::new(&env, "fee_sum"); diff --git a/contracts/predictify-hybrid/src/lib.rs b/contracts/predictify-hybrid/src/lib.rs index f8fb9cab..fb011cb4 100644 --- a/contracts/predictify-hybrid/src/lib.rs +++ b/contracts/predictify-hybrid/src/lib.rs @@ -40,7 +40,6 @@ mod market_analytics; mod market_id_generator; mod markets; mod metadata_limits; -mod tokens; #[cfg(test)] mod metadata_limits_tests; #[cfg(test)] diff --git a/contracts/predictify-hybrid/src/multi_admin_multisig_tests.rs b/contracts/predictify-hybrid/src/multi_admin_multisig_tests.rs index fb2e199b..0242bb8c 100644 --- a/contracts/predictify-hybrid/src/multi_admin_multisig_tests.rs +++ b/contracts/predictify-hybrid/src/multi_admin_multisig_tests.rs @@ -31,7 +31,7 @@ fn setup_contract() -> (Env, Address, Address) { let admin = Address::generate(&env); let client = PredictifyHybridClient::new(&env, &contract_id); - client.initialize(&admin, &None); + client.initialize(&admin, &None, &None); (env, contract_id, admin) } @@ -472,7 +472,7 @@ fn test_admin_added_event_emission() { AdminManager::add_admin(&env, &admin, &new_admin, AdminRole::MarketAdmin).unwrap(); let events = env.events().all(); - let event_count = events.len(); + let event_count = events.events().len(); assert!(event_count > 0); }); } @@ -487,7 +487,7 @@ fn test_admin_removed_event_emission() { AdminManager::remove_admin(&env, &admin, &new_admin).unwrap(); let events = env.events().all(); - assert!(events.len() > 0); + assert!(events.events().len() > 0); }); } @@ -871,7 +871,7 @@ fn test_contract_address_can_act_as_primary_admin() { let multisig_contract = env.register(PredictifyHybrid, ()); let client = PredictifyHybridClient::new(&env, &contract_id); - client.initialize(&multisig_contract, &None); + client.initialize(&multisig_contract, &None, &None); env.as_contract(&contract_id, || { let target = Address::generate(&env); @@ -896,7 +896,7 @@ fn test_contract_admin_rotation_to_new_contract_admin() { let new_multisig = env.register(PredictifyHybrid, ()); let client = PredictifyHybridClient::new(&env, &contract_id); - client.initialize(&old_multisig, &None); + client.initialize(&old_multisig, &None, &None); env.as_contract(&contract_id, || { ContractPauseManager::transfer_admin(&env, &old_multisig, &new_multisig).unwrap(); diff --git a/contracts/predictify-hybrid/src/oracles.rs b/contracts/predictify-hybrid/src/oracles.rs index 2dafe772..153c32b2 100644 --- a/contracts/predictify-hybrid/src/oracles.rs +++ b/contracts/predictify-hybrid/src/oracles.rs @@ -1,6 +1,7 @@ #![allow(dead_code)] use alloc::format; +use alloc::string::ToString; use crate::bandprotocol; use crate::errors::Error; use soroban_sdk::{contracttype, symbol_short, vec, Address, Bytes, Env, IntoVal, String, Symbol, Vec}; @@ -3838,8 +3839,6 @@ impl OracleCallbackAuth { // Store just the price (i128) since OraclePriceData lacks contracttype self.env.storage().persistent().set(&data_key, &callback_data.price); - self.env.storage().persistent().set(&data_key, &oracle_data); - // Emit oracle callback event crate::events::EventEmitter::emit_oracle_callback( &self.env, diff --git a/contracts/predictify-hybrid/src/queries.rs b/contracts/predictify-hybrid/src/queries.rs index 7d4dd03b..5ac73a2a 100644 --- a/contracts/predictify-hybrid/src/queries.rs +++ b/contracts/predictify-hybrid/src/queries.rs @@ -29,6 +29,7 @@ //! - **Missing Config Getters**: `get_config`, `get_configuration_history` //! - **Inconsistencies**: `query_event_details` (missing `created_at`), `query_user_bet` (missing `voted_at`), `query_contract_state` (stubbed metrics) +use alloc::string::ToString; use crate::{ errors::Error, markets::{MarketAnalytics, MarketStateManager, MarketValidator}, @@ -96,7 +97,7 @@ impl QueryManager { env.storage() .persistent() .get(&key) - .ok_or(Error::ContractStateError) + .ok_or(Error::ConfigNotFound) } /// Check if an action requires multisig approval. @@ -211,12 +212,15 @@ impl QueryManager { // Get oracle provider name let oracle_provider = market.oracle_config.provider.name(); let winning_outcome = market.get_winning_outcome(); + let created_at = EventManager::get_event(env, &market_id) + .map(|e| e.created_at) + .unwrap_or(0); let response = EventDetailsQuery { - market_id, + market_id: market_id.clone(), question: market.question, outcomes: market.outcomes, - created_at: EventManager::get_event(env, &market_id).map(|e| e.created_at).unwrap_or(0), + created_at, end_time: market.end_time, status: MarketStatus::from_market_state(market.state), oracle_provider, diff --git a/contracts/predictify-hybrid/src/storage_layout_tests.rs b/contracts/predictify-hybrid/src/storage_layout_tests.rs index 81590523..d8935afd 100644 --- a/contracts/predictify-hybrid/src/storage_layout_tests.rs +++ b/contracts/predictify-hybrid/src/storage_layout_tests.rs @@ -9,11 +9,15 @@ //! - Data structures can be safely extended //! - Migration patterns work correctly +use alloc::format; use soroban_sdk::{ testutils::Address as _, vec, Address, Env, Map, String, Symbol, Vec as SorobanVec, }; -use crate::storage::{BalanceStorage, CreatorLimitsManager, EventManager, StorageOptimizer}; +use crate::markets::MarketStateManager; +use crate::storage::{ + BalanceStorage, CreatorLimitsManager, EventManager, StorageFormat, StorageOptimizer, +}; use crate::types::*; // ===== TEST UTILITIES ===== From 8755044cddea92c606ad4cd9026ca2cd939a80e0 Mon Sep 17 00:00:00 2001 From: Agbasimere Date: Tue, 26 May 2026 22:53:13 +0100 Subject: [PATCH 3/6] fix: restore wasm-safe string handling --- .../src/market_id_generator.rs | 23 ++++++++++++++ contracts/predictify-hybrid/src/oracles.rs | 3 +- contracts/predictify-hybrid/src/queries.rs | 30 +++++++++++++++++-- 3 files changed, 52 insertions(+), 4 deletions(-) diff --git a/contracts/predictify-hybrid/src/market_id_generator.rs b/contracts/predictify-hybrid/src/market_id_generator.rs index 92014653..c1689b7e 100644 --- a/contracts/predictify-hybrid/src/market_id_generator.rs +++ b/contracts/predictify-hybrid/src/market_id_generator.rs @@ -140,13 +140,23 @@ impl MarketIdGenerator { /// Legacy IDs (created before this module existed) do not carry the prefix /// and will return `false` here; callers should treat them as valid but /// unstructured. + #[cfg(not(target_family = "wasm"))] pub fn validate_market_id_format(_env: &Env, market_id: &Symbol) -> bool { market_id.to_string().starts_with("mkt_") } + #[cfg(target_family = "wasm")] + pub fn validate_market_id_format(_env: &Env, _market_id: &Symbol) -> bool { + // Symbol-to-host-string conversion is not available in contract wasm. + // Runtime callers should treat persisted market IDs as valid if they + // otherwise resolve correctly. + true + } + /// Parse the counter and legacy flag out of a market ID symbol. /// /// Returns [`Error::InvalidInput`] if the ID cannot be parsed. + #[cfg(not(target_family = "wasm"))] pub fn parse_market_id_components( _env: &Env, market_id: &Symbol, @@ -171,6 +181,19 @@ impl MarketIdGenerator { }) } + #[cfg(target_family = "wasm")] + pub fn parse_market_id_components( + _env: &Env, + _market_id: &Symbol, + ) -> Result { + // Contract wasm cannot parse Symbol values into host strings. Treat the + // identifier as an unstructured legacy ID in this environment. + Ok(MarketIdComponents { + counter: 0, + is_legacy: true, + }) + } + /// Return a paginated slice of the market ID registry. pub fn get_market_id_registry(env: &Env, start: u32, limit: u32) -> Vec { let key = Symbol::new(env, Self::REGISTRY_KEY); diff --git a/contracts/predictify-hybrid/src/oracles.rs b/contracts/predictify-hybrid/src/oracles.rs index 153c32b2..23303f5a 100644 --- a/contracts/predictify-hybrid/src/oracles.rs +++ b/contracts/predictify-hybrid/src/oracles.rs @@ -3943,7 +3943,7 @@ impl OracleCallbackAuth { fn log_successful_authentication(&self, caller: &Address, callback_data: &OracleCallbackData) { let log_message = String::from_str( &self.env, - &format!("Oracle callback authenticated: {}", callback_data.feed_id.to_string()), + "Oracle callback authenticated", ); let ctx = String::from_str(&self.env, "oracle_auth"); crate::events::EventEmitter::emit_error_logged( @@ -3954,6 +3954,7 @@ impl OracleCallbackAuth { Some(caller.clone()), None, ); + let _ = callback_data; } fn log_authentication_failure(&self, caller: &Address, reason: &str) { diff --git a/contracts/predictify-hybrid/src/queries.rs b/contracts/predictify-hybrid/src/queries.rs index 5ac73a2a..507d3aa0 100644 --- a/contracts/predictify-hybrid/src/queries.rs +++ b/contracts/predictify-hybrid/src/queries.rs @@ -29,7 +29,6 @@ //! - **Missing Config Getters**: `get_config`, `get_configuration_history` //! - **Inconsistencies**: `query_event_details` (missing `created_at`), `query_user_bet` (missing `voted_at`), `query_contract_state` (stubbed metrics) -use alloc::string::ToString; use crate::{ errors::Error, markets::{MarketAnalytics, MarketStateManager, MarketValidator}, @@ -86,8 +85,33 @@ impl QueryManager { /// Check if an admin has a specific permission for an action. pub fn query_has_permission(env: &Env, admin: Address, action: String) -> Result { - let action_str = action.to_string(); - let permission = crate::admin::AdminAccessControl::map_action_to_permission(&action_str)?; + let permission = if action == String::from_str(env, "initialize") { + crate::admin::AdminPermission::Initialize + } else if action == String::from_str(env, "create_market") { + crate::admin::AdminPermission::CreateMarket + } else if action == String::from_str(env, "close_market") { + crate::admin::AdminPermission::CloseMarket + } else if action == String::from_str(env, "finalize_market") { + crate::admin::AdminPermission::FinalizeMarket + } else if action == String::from_str(env, "extend_market") { + crate::admin::AdminPermission::ExtendMarket + } else if action == String::from_str(env, "update_fees") { + crate::admin::AdminPermission::UpdateFees + } else if action == String::from_str(env, "update_config") { + crate::admin::AdminPermission::UpdateConfig + } else if action == String::from_str(env, "reset_config") { + crate::admin::AdminPermission::ResetConfig + } else if action == String::from_str(env, "collect_fees") { + crate::admin::AdminPermission::CollectFees + } else if action == String::from_str(env, "manage_disputes") { + crate::admin::AdminPermission::ManageDispute + } else if action == String::from_str(env, "view_analytics") { + crate::admin::AdminPermission::ViewAnalytic + } else if action == String::from_str(env, "emergency_actions") { + crate::admin::AdminPermission::Emergency + } else { + return Err(Error::InvalidInput); + }; Ok(AdminManager::validate_admin_permission(env, &admin, permission).is_ok()) } From 53a373fa58a1e79ce2cdca9c71ae8dd3db98cbf9 Mon Sep 17 00:00:00 2001 From: Agbasimere Date: Tue, 26 May 2026 22:26:36 +0100 Subject: [PATCH 4/6] fix: restore predictify hybrid test initialization --- .../src/category_tags_tests.rs | 4 +-- contracts/predictify-hybrid/src/config.rs | 2 +- contracts/predictify-hybrid/src/lib.rs | 19 ++++++++++++++ .../src/market_id_generator.rs | 26 ++++++++++--------- 4 files changed, 36 insertions(+), 15 deletions(-) diff --git a/contracts/predictify-hybrid/src/category_tags_tests.rs b/contracts/predictify-hybrid/src/category_tags_tests.rs index 2d23a31e..90e0ff0b 100644 --- a/contracts/predictify-hybrid/src/category_tags_tests.rs +++ b/contracts/predictify-hybrid/src/category_tags_tests.rs @@ -43,7 +43,7 @@ fn create_test_market( ), feed_id: String::from_str(env, "BTC/USD"), threshold: 100, - comparison: String::from_str(env, "gte"), + comparison: String::from_str(env, "gt"), }; client.create_market( @@ -376,7 +376,7 @@ impl TokenTestSetup { ), feed_id: String::from_str(&env, "BTC/USD"), threshold: 100, - comparison: String::from_str(&env, "gte"), + comparison: String::from_str(&env, "gt"), }, &None, &86400u64, diff --git a/contracts/predictify-hybrid/src/config.rs b/contracts/predictify-hybrid/src/config.rs index 9c5507de..4a521f83 100644 --- a/contracts/predictify-hybrid/src/config.rs +++ b/contracts/predictify-hybrid/src/config.rs @@ -3068,7 +3068,7 @@ mod tests { // Test invalid fee configuration let mut invalid_config = config.clone(); - invalid_config.fees.platform_fee_percentage = 15; // Too high + invalid_config.fees.platform_fee_percentage = MAX_PLATFORM_FEE_PERCENTAGE + 1; assert!(ConfigValidator::validate_contract_config(&invalid_config).is_err()); // Test invalid voting configuration diff --git a/contracts/predictify-hybrid/src/lib.rs b/contracts/predictify-hybrid/src/lib.rs index fb011cb4..ccc879ef 100644 --- a/contracts/predictify-hybrid/src/lib.rs +++ b/contracts/predictify-hybrid/src/lib.rs @@ -306,6 +306,25 @@ impl PredictifyHybrid { .persistent() .set(&Symbol::new(&env, "platform_fee"), &fee_percentage); + // Seed default runtime configuration so validators and query paths have + // deterministic bounds immediately after deployment. + let default_config = ConfigManager::get_development_config(&env); + ConfigManager::store_config(&env, &default_config)?; + + // Seed permissive-but-valid rate limits so admin entrypoints do not + // fail before a custom policy is configured. + crate::rate_limiter::RateLimiter::new(env.clone()).init_rate_limiter( + admin.clone(), + crate::rate_limiter::RateLimitConfig { + voting_limit: 10_000, + dispute_limit: 1_000, + oracle_call_limit: 1_000, + bet_limit: 10_000, + events_per_admin_limit: 1_000, + time_window_seconds: 3_600, + }, + ).map_err(Error::from)?; + // Initialize allowed assets if let Some(assets) = allowed_assets { // Store custom allowed assets diff --git a/contracts/predictify-hybrid/src/market_id_generator.rs b/contracts/predictify-hybrid/src/market_id_generator.rs index c1689b7e..e6943882 100644 --- a/contracts/predictify-hybrid/src/market_id_generator.rs +++ b/contracts/predictify-hybrid/src/market_id_generator.rs @@ -433,15 +433,16 @@ mod tests { }); // Advance the ledger sequence. + let current = env.ledger().get(); env.ledger().set(LedgerInfo { sequence_number: env.ledger().sequence() + 1, timestamp: env.ledger().timestamp() + 5, - protocol_version: 22, - network_id: Default::default(), - base_reserve: 10, - min_temp_entry_ttl: 1, - min_persistent_entry_ttl: 1, - max_entry_ttl: 100, + protocol_version: current.protocol_version, + network_id: current.network_id, + base_reserve: current.base_reserve, + min_temp_entry_ttl: current.min_temp_entry_ttl, + min_persistent_entry_ttl: current.min_persistent_entry_ttl, + max_entry_ttl: current.max_entry_ttl, }); let id2 = with_contract(&env, &contract_id, || { @@ -637,15 +638,16 @@ mod tests { let mut all_ids = alloc::vec::Vec::new(); for ledger_bump in 0u32..5 { + let current = env.ledger().get(); env.ledger().set(LedgerInfo { sequence_number: 100 + ledger_bump, timestamp: 1_000_000 + (ledger_bump as u64) * 5, - protocol_version: 22, - network_id: Default::default(), - base_reserve: 10, - min_temp_entry_ttl: 1, - min_persistent_entry_ttl: 1, - max_entry_ttl: 100, + protocol_version: current.protocol_version, + network_id: current.network_id, + base_reserve: current.base_reserve, + min_temp_entry_ttl: current.min_temp_entry_ttl, + min_persistent_entry_ttl: current.min_persistent_entry_ttl, + max_entry_ttl: current.max_entry_ttl, }); with_contract(&env, &contract_id, || { From 9169213103b1b38b99cf612bcde525a349f39548 Mon Sep 17 00:00:00 2001 From: Agbasimere Date: Tue, 26 May 2026 20:59:48 +0100 Subject: [PATCH 5/6] test: stabilize multisig and storage layout CI --- contracts/predictify-hybrid/src/admin.rs | 25 ++ .../src/multi_admin_multisig_tests.rs | 16 +- .../src/storage_layout_tests.rs | 233 ++++++++++-------- 3 files changed, 166 insertions(+), 108 deletions(-) diff --git a/contracts/predictify-hybrid/src/admin.rs b/contracts/predictify-hybrid/src/admin.rs index 619c54a0..6cf8b536 100644 --- a/contracts/predictify-hybrid/src/admin.rs +++ b/contracts/predictify-hybrid/src/admin.rs @@ -1362,6 +1362,18 @@ impl AdminManager { // Store in multi-admin storage env.storage().persistent().set(&admin_key, &assignment); + // Track admins in a list so role enumeration stays in sync. + let list_key = Symbol::new(env, "AdminList"); + let mut admin_list: Vec
= env + .storage() + .persistent() + .get(&list_key) + .unwrap_or_else(|| Vec::new(env)); + if !admin_list.contains(new_admin) { + admin_list.push_back(new_admin.clone()); + env.storage().persistent().set(&list_key, &admin_list); + } + // Update admin count let count_key = Symbol::new(env, "AdminCount"); let current_count: u32 = env.storage().persistent().get(&count_key).unwrap_or(0); @@ -1398,6 +1410,14 @@ impl AdminManager { env.storage().persistent().remove(&admin_key); + let list_key = Symbol::new(env, "AdminList"); + if let Some(mut admin_list) = env.storage().persistent().get::<_, Vec
>(&list_key) { + if let Some(index) = admin_list.first_index_of(admin_to_remove) { + admin_list.remove(index); + env.storage().persistent().set(&list_key, &admin_list); + } + } + // Update count let count_key = Symbol::new(env, "AdminCount"); let current_count: u32 = env.storage().persistent().get(&count_key).unwrap_or(0); @@ -3409,6 +3429,11 @@ impl AdminSystemIntegration { let admin_key = AdminManager::get_admin_key(env, &original_admin); env.storage().persistent().set(&admin_key, &assignment); + let mut admin_list = Vec::new(env); + admin_list.push_back(original_admin.clone()); + env.storage() + .persistent() + .set(&Symbol::new(env, "AdminList"), &admin_list); env.storage() .persistent() .set(&Symbol::new(env, "AdminCount"), &1u32); diff --git a/contracts/predictify-hybrid/src/multi_admin_multisig_tests.rs b/contracts/predictify-hybrid/src/multi_admin_multisig_tests.rs index 0242bb8c..54ae6b79 100644 --- a/contracts/predictify-hybrid/src/multi_admin_multisig_tests.rs +++ b/contracts/predictify-hybrid/src/multi_admin_multisig_tests.rs @@ -32,6 +32,9 @@ fn setup_contract() -> (Env, Address, Address) { let client = PredictifyHybridClient::new(&env, &contract_id); client.initialize(&admin, &None, &None); + env.as_contract(&contract_id, || { + AdminSystemIntegration::ensure_migration(&env).unwrap(); + }); (env, contract_id, admin) } @@ -757,15 +760,16 @@ fn setup_env_primary_admin(env: &Env) -> Address { /// Warp the ledger clock forward by `delta_secs`, preserving other fields. fn warp_time(env: &Env, delta_secs: u64) { let now = env.ledger().timestamp(); + let current = env.ledger().get(); env.ledger().set(LedgerInfo { timestamp: now + delta_secs, - protocol_version: 22, + protocol_version: current.protocol_version, sequence_number: env.ledger().sequence(), - network_id: Default::default(), - base_reserve: 10, - min_temp_entry_ttl: 1, - min_persistent_entry_ttl: 1, - max_entry_ttl: 10_000, + network_id: current.network_id, + base_reserve: current.base_reserve, + min_temp_entry_ttl: current.min_temp_entry_ttl, + min_persistent_entry_ttl: current.min_persistent_entry_ttl, + max_entry_ttl: current.max_entry_ttl, }); } diff --git a/contracts/predictify-hybrid/src/storage_layout_tests.rs b/contracts/predictify-hybrid/src/storage_layout_tests.rs index d8935afd..d980018a 100644 --- a/contracts/predictify-hybrid/src/storage_layout_tests.rs +++ b/contracts/predictify-hybrid/src/storage_layout_tests.rs @@ -82,6 +82,11 @@ fn create_test_market(env: &Env, admin: &Address) -> (Symbol, Market) { (market_id, market) } +fn run_as_contract(env: &Env, f: impl FnOnce() -> T) -> T { + let contract_id = env.register(crate::PredictifyHybrid, ()); + env.as_contract(&contract_id, f) +} + // ===== STORAGE KEY COLLISION TESTS ===== #[test] @@ -356,17 +361,19 @@ fn test_event_storage_key_uniqueness() { allowlist: SorobanVec::new(&env), }; - // Store events - EventManager::store_event(&env, &event1); - EventManager::store_event(&env, &event2); + run_as_contract(&env, || { + // Store events + EventManager::store_event(&env, &event1); + EventManager::store_event(&env, &event2); - // Verify events are stored independently - let retrieved1 = EventManager::get_event(&env, &event1.id).unwrap(); - let retrieved2 = EventManager::get_event(&env, &event2.id).unwrap(); + // Verify events are stored independently + let retrieved1 = EventManager::get_event(&env, &event1.id).unwrap(); + let retrieved2 = EventManager::get_event(&env, &event2.id).unwrap(); - assert_eq!(retrieved1.id, event1.id); - assert_eq!(retrieved2.id, event2.id); - assert_ne!(retrieved1.id, retrieved2.id); + assert_eq!(retrieved1.id, event1.id); + assert_eq!(retrieved2.id, event2.id); + assert_ne!(retrieved1.id, retrieved2.id); + }); } // ===== CREATOR LIMITS STORAGE KEY TESTS ===== @@ -377,19 +384,21 @@ fn test_creator_limits_key_uniqueness() { let creator1 = Address::generate(&env); let creator2 = Address::generate(&env); - // Increment active events for different creators - CreatorLimitsManager::increment_active_events(&env, &creator1); - CreatorLimitsManager::increment_active_events(&env, &creator1); - CreatorLimitsManager::increment_active_events(&env, &creator2); + run_as_contract(&env, || { + // Increment active events for different creators + CreatorLimitsManager::increment_active_events(&env, &creator1); + CreatorLimitsManager::increment_active_events(&env, &creator1); + CreatorLimitsManager::increment_active_events(&env, &creator2); - // Verify counts are independent - assert_eq!(CreatorLimitsManager::get_active_events(&env, &creator1), 2); - assert_eq!(CreatorLimitsManager::get_active_events(&env, &creator2), 1); + // Verify counts are independent + assert_eq!(CreatorLimitsManager::get_active_events(&env, &creator1), 2); + assert_eq!(CreatorLimitsManager::get_active_events(&env, &creator2), 1); - // Decrement and verify independence - CreatorLimitsManager::decrement_active_events(&env, &creator1); - assert_eq!(CreatorLimitsManager::get_active_events(&env, &creator1), 1); - assert_eq!(CreatorLimitsManager::get_active_events(&env, &creator2), 1); + // Decrement and verify independence + CreatorLimitsManager::decrement_active_events(&env, &creator1); + assert_eq!(CreatorLimitsManager::get_active_events(&env, &creator1), 1); + assert_eq!(CreatorLimitsManager::get_active_events(&env, &creator2), 1); + }); } // ===== DATA STRUCTURE EXTENSION TESTS ===== @@ -400,24 +409,27 @@ fn test_market_structure_serialization() { let admin = create_test_admin(&env); let (market_id, market) = create_test_market(&env, &admin); - // Store market - env.storage().persistent().set(&market_id, &market); + run_as_contract(&env, || { + // Store market + env.storage().persistent().set(&market_id, &market); - // Retrieve market - let retrieved: Market = env.storage().persistent().get(&market_id).unwrap(); + // Retrieve market + let retrieved: Market = env.storage().persistent().get(&market_id).unwrap(); - // Verify all fields are preserved - assert_eq!(retrieved.admin, market.admin); - assert_eq!(retrieved.question, market.question); - assert_eq!(retrieved.outcomes, market.outcomes); - assert_eq!(retrieved.end_time, market.end_time); - assert_eq!(retrieved.total_staked, market.total_staked); - assert_eq!(retrieved.state, market.state); + // Verify all fields are preserved + assert_eq!(retrieved.admin, market.admin); + assert_eq!(retrieved.question, market.question); + assert_eq!(retrieved.outcomes, market.outcomes); + assert_eq!(retrieved.end_time, market.end_time); + assert_eq!(retrieved.total_staked, market.total_staked); + assert_eq!(retrieved.state, market.state); + }); } #[test] fn test_claim_info_structure_serialization() { let env = create_test_env(); + let contract_id = env.register(crate::PredictifyHybrid, ()); let claim_info = ClaimInfo { claimed: true, @@ -425,17 +437,19 @@ fn test_claim_info_structure_serialization() { payout_amount: 1_000_000, }; - // Store claim info - let key = Symbol::new(&env, "test_claim"); - env.storage().persistent().set(&key, &claim_info); + env.as_contract(&contract_id, || { + // Store claim info + let key = Symbol::new(&env, "test_claim"); + env.storage().persistent().set(&key, &claim_info); - // Retrieve claim info - let retrieved: ClaimInfo = env.storage().persistent().get(&key).unwrap(); + // Retrieve claim info + let retrieved: ClaimInfo = env.storage().persistent().get(&key).unwrap(); - // Verify all fields are preserved - assert_eq!(retrieved.claimed, claim_info.claimed); - assert_eq!(retrieved.timestamp, claim_info.timestamp); - assert_eq!(retrieved.payout_amount, claim_info.payout_amount); + // Verify all fields are preserved + assert_eq!(retrieved.claimed, claim_info.claimed); + assert_eq!(retrieved.timestamp, claim_info.timestamp); + assert_eq!(retrieved.payout_amount, claim_info.payout_amount); + }); } #[test] @@ -450,18 +464,20 @@ fn test_oracle_config_structure_serialization() { comparison: String::from_str(&env, "gt"), }; - // Store oracle config - let key = Symbol::new(&env, "test_oracle"); - env.storage().persistent().set(&key, &oracle_config); + run_as_contract(&env, || { + // Store oracle config + let key = Symbol::new(&env, "test_oracle"); + env.storage().persistent().set(&key, &oracle_config); - // Retrieve oracle config - let retrieved: OracleConfig = env.storage().persistent().get(&key).unwrap(); + // Retrieve oracle config + let retrieved: OracleConfig = env.storage().persistent().get(&key).unwrap(); - // Verify all fields are preserved - assert_eq!(retrieved.provider, oracle_config.provider); - assert_eq!(retrieved.feed_id, oracle_config.feed_id); - assert_eq!(retrieved.threshold, oracle_config.threshold); - assert_eq!(retrieved.comparison, oracle_config.comparison); + // Verify all fields are preserved + assert_eq!(retrieved.provider, oracle_config.provider); + assert_eq!(retrieved.feed_id, oracle_config.feed_id); + assert_eq!(retrieved.threshold, oracle_config.threshold); + assert_eq!(retrieved.comparison, oracle_config.comparison); + }); } // ===== STORAGE KEY PATTERN TESTS ===== @@ -470,31 +486,35 @@ fn test_oracle_config_structure_serialization() { fn test_simple_symbol_key_pattern() { let env = create_test_env(); - // Test simple symbol key storage and retrieval - let key = Symbol::new(&env, "TestKey"); - let value = String::from_str(&env, "TestValue"); + run_as_contract(&env, || { + // Test simple symbol key storage and retrieval + let key = Symbol::new(&env, "TestKey"); + let value = String::from_str(&env, "TestValue"); - env.storage().persistent().set(&key, &value); - let retrieved: String = env.storage().persistent().get(&key).unwrap(); + env.storage().persistent().set(&key, &value); + let retrieved: String = env.storage().persistent().get(&key).unwrap(); - assert_eq!(retrieved, value); + assert_eq!(retrieved, value); + }); } #[test] fn test_tuple_key_pattern() { let env = create_test_env(); - // Test tuple key storage and retrieval - let key = ( - Symbol::new(&env, "Namespace"), - Symbol::new(&env, "identifier"), - ); - let value = String::from_str(&env, "TupleValue"); + run_as_contract(&env, || { + // Test tuple key storage and retrieval + let key = ( + Symbol::new(&env, "Namespace"), + Symbol::new(&env, "identifier"), + ); + let value = String::from_str(&env, "TupleValue"); - env.storage().persistent().set(&key, &value); - let retrieved: String = env.storage().persistent().get(&key).unwrap(); + env.storage().persistent().set(&key, &value); + let retrieved: String = env.storage().persistent().get(&key).unwrap(); - assert_eq!(retrieved, value); + assert_eq!(retrieved, value); + }); } #[test] @@ -502,14 +522,16 @@ fn test_tuple_with_address_key_pattern() { let env = create_test_env(); let address = Address::generate(&env); - // Test tuple with address key storage and retrieval - let key = (Symbol::new(&env, "UserData"), address.clone()); - let value = 12345u32; + run_as_contract(&env, || { + // Test tuple with address key storage and retrieval + let key = (Symbol::new(&env, "UserData"), address.clone()); + let value = 12345u32; - env.storage().persistent().set(&key, &value); - let retrieved: u32 = env.storage().persistent().get(&key).unwrap(); + env.storage().persistent().set(&key, &value); + let retrieved: u32 = env.storage().persistent().get(&key).unwrap(); - assert_eq!(retrieved, value); + assert_eq!(retrieved, value); + }); } // ===== MIGRATION SAFETY TESTS ===== @@ -520,17 +542,19 @@ fn test_market_backward_compatibility() { let admin = create_test_admin(&env); let (market_id, market) = create_test_market(&env, &admin); - // Store market - MarketStateManager::update_market(&env, &market_id, &market); + run_as_contract(&env, || { + // Store market + MarketStateManager::update_market(&env, &market_id, &market); - // Retrieve and verify - let retrieved = MarketStateManager::get_market(&env, &market_id).unwrap(); + // Retrieve and verify + let retrieved = MarketStateManager::get_market(&env, &market_id).unwrap(); - // Verify critical fields are preserved - assert_eq!(retrieved.admin, market.admin); - assert_eq!(retrieved.question, market.question); - assert_eq!(retrieved.total_staked, market.total_staked); - assert_eq!(retrieved.state, market.state); + // Verify critical fields are preserved + assert_eq!(retrieved.admin, market.admin); + assert_eq!(retrieved.question, market.question); + assert_eq!(retrieved.total_staked, market.total_staked); + assert_eq!(retrieved.state, market.state); + }); } #[test] @@ -555,7 +579,8 @@ fn test_compressed_market_key_uniqueness() { let env = create_test_env(); let admin = create_test_admin(&env); let (_, market1) = create_test_market(&env, &admin); - let (_, market2) = create_test_market(&env, &admin); + let (_, mut market2) = create_test_market(&env, &admin); + market2.question = String::from_str(&env, "Will ETH surpass 5k this year?"); // Compress markets let compressed1 = StorageOptimizer::compress_market_data(&env, &market1).unwrap(); @@ -569,14 +594,16 @@ fn test_compressed_market_key_uniqueness() { fn test_storage_config_isolation() { let env = create_test_env(); - // Get default storage config - let config = StorageOptimizer::get_storage_config(&env); + run_as_contract(&env, || { + // Get default storage config + let config = StorageOptimizer::get_storage_config(&env); - // Verify config has expected defaults - assert!(config.compression_enabled); - assert_eq!(config.min_compression_age_days, 30); - assert_eq!(config.max_storage_per_market, 1024 * 1024); - assert_eq!(config.cleanup_threshold_days, 365); + // Verify config has expected defaults + assert!(config.compression_enabled); + assert_eq!(config.min_compression_age_days, 30); + assert_eq!(config.max_storage_per_market, 1024 * 1024); + assert_eq!(config.cleanup_threshold_days, 365); + }); } // ===== COMPREHENSIVE COLLISION TEST ===== @@ -643,20 +670,22 @@ fn test_no_regression_in_market_storage() { let admin = create_test_admin(&env); let (market_id, market) = create_test_market(&env, &admin); - // Store market directly in storage - env.storage().persistent().set(&market_id, &market); - - // Retrieve using storage - let retrieved: Market = env.storage().persistent().get(&market_id).unwrap(); - - // Verify no data loss - assert_eq!(retrieved.admin, market.admin); - assert_eq!(retrieved.question, market.question); - assert_eq!(retrieved.outcomes.len(), market.outcomes.len()); - assert_eq!(retrieved.end_time, market.end_time); - assert_eq!(retrieved.total_staked, market.total_staked); - assert_eq!(retrieved.state, market.state); - assert_eq!(retrieved.fee_collected, market.fee_collected); + run_as_contract(&env, || { + // Store market directly in storage + env.storage().persistent().set(&market_id, &market); + + // Retrieve using storage + let retrieved: Market = env.storage().persistent().get(&market_id).unwrap(); + + // Verify no data loss + assert_eq!(retrieved.admin, market.admin); + assert_eq!(retrieved.question, market.question); + assert_eq!(retrieved.outcomes.len(), market.outcomes.len()); + assert_eq!(retrieved.end_time, market.end_time); + assert_eq!(retrieved.total_staked, market.total_staked); + assert_eq!(retrieved.state, market.state); + assert_eq!(retrieved.fee_collected, market.fee_collected); + }); } #[test] From ef166a87f15de326032681173123f5954e5feaf5 Mon Sep 17 00:00:00 2001 From: Agbasimere Date: Tue, 26 May 2026 23:14:56 +0100 Subject: [PATCH 6/6] test: wrap balance storage layout access in contract context --- .../src/storage_layout_tests.rs | 76 ++++++++++--------- 1 file changed, 40 insertions(+), 36 deletions(-) diff --git a/contracts/predictify-hybrid/src/storage_layout_tests.rs b/contracts/predictify-hybrid/src/storage_layout_tests.rs index d980018a..fff7012b 100644 --- a/contracts/predictify-hybrid/src/storage_layout_tests.rs +++ b/contracts/predictify-hybrid/src/storage_layout_tests.rs @@ -292,34 +292,36 @@ fn test_balance_storage_key_uniqueness() { let asset1 = ReflectorAsset::BTC; let asset2 = ReflectorAsset::ETH; - // Get balances to trigger key generation - let balance1 = BalanceStorage::get_balance(&env, &user1, &asset1); - let balance2 = BalanceStorage::get_balance(&env, &user1, &asset2); - let balance3 = BalanceStorage::get_balance(&env, &user2, &asset1); - - // Verify balances are independent (different keys) - assert_eq!(balance1.amount, 0); - assert_eq!(balance2.amount, 0); - assert_eq!(balance3.amount, 0); - - // Set different amounts - BalanceStorage::add_balance(&env, &user1, &asset1, 100).unwrap(); - BalanceStorage::add_balance(&env, &user1, &asset2, 200).unwrap(); - BalanceStorage::add_balance(&env, &user2, &asset1, 300).unwrap(); - - // Verify each balance is stored independently - assert_eq!( - BalanceStorage::get_balance(&env, &user1, &asset1).amount, - 100 - ); - assert_eq!( - BalanceStorage::get_balance(&env, &user1, &asset2).amount, - 200 - ); - assert_eq!( - BalanceStorage::get_balance(&env, &user2, &asset1).amount, - 300 - ); + run_as_contract(&env, || { + // Get balances to trigger key generation + let balance1 = BalanceStorage::get_balance(&env, &user1, &asset1); + let balance2 = BalanceStorage::get_balance(&env, &user1, &asset2); + let balance3 = BalanceStorage::get_balance(&env, &user2, &asset1); + + // Verify balances are independent (different keys) + assert_eq!(balance1.amount, 0); + assert_eq!(balance2.amount, 0); + assert_eq!(balance3.amount, 0); + + // Set different amounts + BalanceStorage::add_balance(&env, &user1, &asset1, 100).unwrap(); + BalanceStorage::add_balance(&env, &user1, &asset2, 200).unwrap(); + BalanceStorage::add_balance(&env, &user2, &asset1, 300).unwrap(); + + // Verify each balance is stored independently + assert_eq!( + BalanceStorage::get_balance(&env, &user1, &asset1).amount, + 100 + ); + assert_eq!( + BalanceStorage::get_balance(&env, &user1, &asset2).amount, + 200 + ); + assert_eq!( + BalanceStorage::get_balance(&env, &user2, &asset1).amount, + 300 + ); + }); } // ===== EVENT STORAGE KEY TESTS ===== @@ -694,16 +696,18 @@ fn test_no_regression_in_balance_storage() { let user = Address::generate(&env); let asset = ReflectorAsset::BTC; - // Add balance using current implementation - BalanceStorage::add_balance(&env, &user, &asset, 1_000_000).unwrap(); + run_as_contract(&env, || { + // Add balance using current implementation + BalanceStorage::add_balance(&env, &user, &asset, 1_000_000).unwrap(); - // Retrieve using current implementation - let balance = BalanceStorage::get_balance(&env, &user, &asset); + // Retrieve using current implementation + let balance = BalanceStorage::get_balance(&env, &user, &asset); - // Verify no data loss - assert_eq!(balance.amount, 1_000_000); - assert_eq!(balance.user, user); - assert_eq!(balance.asset, asset); + // Verify no data loss + assert_eq!(balance.amount, 1_000_000); + assert_eq!(balance.user, user); + assert_eq!(balance.asset, asset); + }); } // ===== PERFORMANCE TESTS =====