From c481afcb0535cc9802f84f5b038d597aad95406f Mon Sep 17 00:00:00 2001 From: Agbasimere Date: Tue, 26 May 2026 18:38:07 +0100 Subject: [PATCH 1/5] task: audit balance ledger underflow and transfer ordering --- contracts/predictify-hybrid/src/balances.rs | 148 ++++++++++++++++---- contracts/predictify-hybrid/src/lib.rs | 20 --- contracts/predictify-hybrid/src/storage.rs | 101 +++++++++++-- docs/contracts/BALANCES.md | 33 +++-- 4 files changed, 229 insertions(+), 73 deletions(-) diff --git a/contracts/predictify-hybrid/src/balances.rs b/contracts/predictify-hybrid/src/balances.rs index e4075734..51026d09 100644 --- a/contracts/predictify-hybrid/src/balances.rs +++ b/contracts/predictify-hybrid/src/balances.rs @@ -103,15 +103,18 @@ impl BalanceManager { /// Withdraw funds from the user's balance. /// /// This function transfers tokens from the contract back to the user's wallet. - /// It follows the Checks-Effects-Interactions (CEI) pattern to prevent reentrancy and ensuring safety: + /// It follows a strict "Check-Transfer-then-Debit" pattern so a failed transfer never leaves + /// a phantom debit in storage: /// 1. Checks: Validate authorization, circuit breaker, and sufficient balance. - /// 2. Effects: Update (subtract) user balance in contract storage. + /// 2. Compute: Derive the post-withdraw balance without mutating storage. /// 3. Interactions: Execute token transfer (from contract to user). + /// 4. Effects: Persist the debited balance only after the transfer succeeds. /// /// # Invariants /// - `amount` must be strictly positive. /// - Withdrawal is only permitted if the user has sufficient available balance. /// - Circuit breaker must allow withdrawals. + /// - Balance storage is only mutated after the outbound transfer succeeds. /// /// # Parameters /// * `env` - The Soroban environment. @@ -145,21 +148,22 @@ impl BalanceManager { return Err(Error::CBOpen); } - // Check sufficient balance and subtract (Effects) - // sub_balance will return Error::InsufficientBalance if amount > current_balance.amount - let balance = BalanceStorage::sub_balance(env, &user, &asset, amount)?; - // Resolve token client let token_client = match asset { ReflectorAsset::Stellar => MarketUtils::get_token_client(env)?, _ => return Err(Error::InvalidInput), }; + // Compute the resulting balance before interacting with the token contract. + let balance = BalanceStorage::checked_sub_balance(env, &user, &asset, amount)?; + // Transfer funds from contract to user (Interactions) - // Note: Contract-to-user transfers in Soroban do not require user auth, - // but the contract address must have sufficient balance. + // If this panics, Soroban rolls back the call and the balance write below is skipped. token_client.transfer(&env.current_contract_address(), &user, &amount); + // Persist the debit only after the token transfer succeeds. + BalanceStorage::set_balance(env, &balance)?; + // Emit event EventEmitter::emit_balance_changed( env, @@ -181,49 +185,137 @@ impl BalanceManager { #[cfg(test)] mod tests { + extern crate std; + use super::*; - use soroban_sdk::testutils::Address as _; - use soroban_sdk::Env; + use crate::PredictifyHybridClient; + use soroban_sdk::{ + testutils::Address as _, + token::{Client as TokenClient, StellarAssetClient}, + Address, Env, Symbol, + }; + use std::panic::{catch_unwind, AssertUnwindSafe}; struct BalanceTestSetup { env: Env, + contract_id: Address, + token_id: Address, user: Address, + sink: Address, } impl BalanceTestSetup { fn new() -> Self { let env = Env::default(); + env.mock_all_auths(); + + let token_admin = Address::generate(&env); + let token_contract = env.register_stellar_asset_contract_v2(token_admin); + let token_id = token_contract.address(); + + let admin = Address::generate(&env); let user = Address::generate(&env); - BalanceTestSetup { env, user } + let sink = Address::generate(&env); + + let contract_id = env.register(crate::PredictifyHybrid, ()); + let client = PredictifyHybridClient::new(&env, &contract_id); + client.initialize(&admin, &None, &None); + + env.as_contract(&contract_id, || { + env.storage() + .persistent() + .set(&Symbol::new(&env, "TokenID"), &token_id); + }); + + StellarAssetClient::new(&env, &token_id).mint(&user, &1_000_0000000); + + BalanceTestSetup { + env, + contract_id, + token_id, + user, + sink, + } + } + + fn client(&self) -> PredictifyHybridClient<'_> { + PredictifyHybridClient::new(&self.env, &self.contract_id) + } + + fn token_client(&self) -> TokenClient<'_> { + TokenClient::new(&self.env, &self.token_id) } } #[test] - fn test_deposit_valid_amount() { - let _setup = BalanceTestSetup::new(); - let amount = 1_000_000i128; - assert!(amount > 0); + fn test_deposit_credits_balance_after_transfer() { + let setup = BalanceTestSetup::new(); + let client = setup.client(); + let token_client = setup.token_client(); + + let balance = client.deposit(&setup.user, &ReflectorAsset::Stellar, &500_0000000); + + assert_eq!(balance.amount, 500_0000000); + assert_eq!( + client.get_balance(&setup.user, &ReflectorAsset::Stellar).amount, + 500_0000000 + ); + assert_eq!(token_client.balance(&setup.user), 500_0000000); + assert_eq!(token_client.balance(&setup.contract_id), 500_0000000); } #[test] - fn test_deposit_zero_amount() { - let _setup = BalanceTestSetup::new(); - let amount = 0i128; - assert_eq!(amount, 0); + fn test_withdraw_exact_balance_reaches_zero() { + let setup = BalanceTestSetup::new(); + let client = setup.client(); + let token_client = setup.token_client(); + + client.deposit(&setup.user, &ReflectorAsset::Stellar, &500); + let balance = client.withdraw(&setup.user, &ReflectorAsset::Stellar, &500); + + assert_eq!(balance.amount, 0); + assert_eq!(client.get_balance(&setup.user, &ReflectorAsset::Stellar).amount, 0); + assert_eq!(token_client.balance(&setup.user), 1_000_0000000); + assert_eq!(token_client.balance(&setup.contract_id), 0); } #[test] - fn test_deposit_negative_amount() { - let _setup = BalanceTestSetup::new(); - let amount = -1_000_000i128; - assert!(amount < 0); + fn test_withdraw_over_balance_returns_typed_error_without_mutation() { + let setup = BalanceTestSetup::new(); + let client = setup.client(); + + client.deposit(&setup.user, &ReflectorAsset::Stellar, &100_0000000); + + let result = client.try_withdraw(&setup.user, &ReflectorAsset::Stellar, &150_0000000); + + assert_eq!(result, Err(Ok(Error::InsufficientBalance))); + assert_eq!( + client.get_balance(&setup.user, &ReflectorAsset::Stellar).amount, + 100_0000000 + ); } #[test] - fn test_withdraw_insufficient_balance() { - let _setup = BalanceTestSetup::new(); - let requested = 1_000_000i128; - let available = 100_000i128; - assert!(requested > available); + fn test_withdraw_transfer_failure_does_not_leave_phantom_debit() { + let setup = BalanceTestSetup::new(); + let client = setup.client(); + let token_client = setup.token_client(); + + client.deposit(&setup.user, &ReflectorAsset::Stellar, &400_0000000); + + token_client.transfer(&setup.contract_id, &setup.sink, &400_0000000); + assert_eq!(token_client.balance(&setup.contract_id), 0); + + let result = catch_unwind(AssertUnwindSafe(|| { + client.withdraw(&setup.user, &ReflectorAsset::Stellar, &100_0000000); + })); + + assert!(result.is_err()); + assert_eq!( + client.get_balance(&setup.user, &ReflectorAsset::Stellar).amount, + 400_0000000 + ); + assert_eq!(token_client.balance(&setup.user), 600_0000000); + assert_eq!(token_client.balance(&setup.contract_id), 0); } } 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/contracts/predictify-hybrid/src/storage.rs b/contracts/predictify-hybrid/src/storage.rs index ea601f3a..2b26e5f1 100644 --- a/contracts/predictify-hybrid/src/storage.rs +++ b/contracts/predictify-hybrid/src/storage.rs @@ -445,6 +445,13 @@ impl StorageOptimizer { pub struct BalanceStorage; impl BalanceStorage { + fn validate_balance_delta(amount: i128) -> Result<(), Error> { + if amount <= 0 { + return Err(Error::InvalidInput); + } + Ok(()) + } + /// Generates the storage key for a user's asset balance. fn get_key(env: &Env, user: &Address, asset: &ReflectorAsset) -> Vec { let mut key = Vec::new(env); @@ -467,29 +474,68 @@ impl BalanceStorage { } /// Stores the balance record in persistent storage and extends its TTL. - pub fn set_balance(env: &Env, balance: &Balance) { + pub fn set_balance(env: &Env, balance: &Balance) -> Result<(), Error> { + if balance.amount < 0 { + return Err(Error::InvalidState); + } + let key = Self::get_key(env, &balance.user, &balance.asset); env.storage().persistent().set(&key, balance); // Extend TTL to ensure balance persists (approx 30 days) env.storage().persistent().extend_ttl(&key, 535680, 535680); + Ok(()) } - /// Increments a user's balance by the specified amount. - /// - /// # Errors - /// - `Error::InvalidInput` if the resulting amount overflows i128. - pub fn add_balance( + /// Computes the resulting balance after a credit without mutating storage. + pub fn checked_add_balance( env: &Env, user: &Address, asset: &ReflectorAsset, amount: i128, ) -> Result { + Self::validate_balance_delta(amount)?; + let mut balance = Self::get_balance(env, user, asset); balance.amount = balance .amount .checked_add(amount) .ok_or(Error::InvalidInput)?; - Self::set_balance(env, &balance); + Ok(balance) + } + + /// Computes the resulting balance after a debit without mutating storage. + pub fn checked_sub_balance( + env: &Env, + user: &Address, + asset: &ReflectorAsset, + amount: i128, + ) -> Result { + Self::validate_balance_delta(amount)?; + + let mut balance = Self::get_balance(env, user, asset); + if amount > balance.amount { + return Err(Error::InsufficientBalance); + } + + balance.amount = balance + .amount + .checked_sub(amount) + .ok_or(Error::InvalidState)?; + Ok(balance) + } + + /// Increments a user's balance by the specified amount. + /// + /// # Errors + /// - `Error::InvalidInput` if the resulting amount overflows i128. + pub fn add_balance( + env: &Env, + user: &Address, + asset: &ReflectorAsset, + amount: i128, + ) -> Result { + let balance = Self::checked_add_balance(env, user, asset, amount)?; + Self::set_balance(env, &balance)?; Ok(balance) } @@ -503,12 +549,8 @@ impl BalanceStorage { asset: &ReflectorAsset, amount: i128, ) -> Result { - let mut balance = Self::get_balance(env, user, asset); - balance.amount = balance - .amount - .checked_sub(amount) - .ok_or(Error::InsufficientBalance)?; - Self::set_balance(env, &balance); + let balance = Self::checked_sub_balance(env, user, asset, amount)?; + Self::set_balance(env, &balance)?; Ok(balance) } } @@ -806,7 +848,38 @@ impl StorageUtils { #[cfg(test)] mod tests { use super::*; - use soroban_sdk::testutils::Address; + use soroban_sdk::testutils::Address as _; + + #[test] + fn test_sub_balance_rejects_overdraw_without_mutation() { + let env = Env::default(); + let user = soroban_sdk::Address::generate(&env); + let asset = ReflectorAsset::Stellar; + + BalanceStorage::add_balance(&env, &user, &asset, 250).unwrap(); + + let result = BalanceStorage::sub_balance(&env, &user, &asset, 251); + + assert_eq!(result, Err(Error::InsufficientBalance)); + assert_eq!(BalanceStorage::get_balance(&env, &user, &asset).amount, 250); + } + + #[test] + fn test_balance_mutators_reject_non_positive_amounts() { + let env = Env::default(); + let user = soroban_sdk::Address::generate(&env); + let asset = ReflectorAsset::Stellar; + + assert_eq!( + BalanceStorage::add_balance(&env, &user, &asset, 0), + Err(Error::InvalidInput) + ); + assert_eq!( + BalanceStorage::sub_balance(&env, &user, &asset, -1), + Err(Error::InvalidInput) + ); + assert_eq!(BalanceStorage::get_balance(&env, &user, &asset).amount, 0); + } #[test] fn test_storage_optimizer_compression() { diff --git a/docs/contracts/BALANCES.md b/docs/contracts/BALANCES.md index f974eb27..c82f3434 100644 --- a/docs/contracts/BALANCES.md +++ b/docs/contracts/BALANCES.md @@ -20,24 +20,35 @@ The `deposit` function follows a strict **Transfer-then-Credit** pattern to ensu - **Atomicity**: We rely on Soroban's transaction atomicity. If any step fails, no state changes (including token transfers and balance updates) are committed. - **Positive Amounts Only**: We explicitly reject zero or negative deposit amounts to prevent edge-case logic errors. - **Large Amount Handling**: Deposits are restricted to half of the `i128` maximum value to prevent theoretical overflow when aggregating balances, although `BalanceStorage` uses checked arithmetic. +- **Ledger Reconciliation**: A deposit credit is never written unless the incoming token transfer has already succeeded. ## Withdrawal Invariants -The `withdraw` function follows the **Checks-Effects-Interactions (CEI)** pattern to prevent reentrancy and ensuring safety: +The `withdraw` function follows a strict **Check-Transfer-then-Debit** pattern: 1. **Checks**: - Validate `amount` > 0. - Authenticate user. - Check if the Circuit Breaker allows withdrawals. - - Ensure user has sufficient available balance. -2. **Effects**: - - Subtract the amount from the user's internal balance in `BalanceStorage`. -3. **Interactions**: + - Compute the post-withdraw balance and reject `amount > balance` with `Error::InsufficientBalance`. +2. **Interactions**: - Transfer tokens from the contract's account back to the user's wallet. +3. **Effects**: + - Persist the debited balance in `BalanceStorage` only after the transfer succeeds. ### Safety Assumptions - **Balance Separation**: Internal balances track "Available" funds. Funds currently locked in active bets are handled separately by the `bets.rs` module and are not part of the `BalanceStorage` amount unless specifically credited back (e.g., through winnings or refunds). - **Circuit Breaker**: High-level platform safety is maintained through the circuit breaker mechanism. +- **Typed Underflow Protection**: `BalanceStorage::sub_balance` and `checked_sub_balance` reject over-withdrawal with `Error::InsufficientBalance` before any write, rather than relying on wrapping arithmetic. +- **Transfer Revert Safety**: If the outbound token transfer fails or reverts, the balance write is never reached and Soroban rolls back the call, preventing phantom debits. + +## Storage-Level Invariants + +`BalanceStorage` is the source of truth for idle user funds and enforces these rules at every mutation site: + +1. Balance deltas passed to `add_balance` and `sub_balance` must be strictly positive. +2. Stored balances must never become negative. +3. `checked_add_balance` and `checked_sub_balance` compute the exact next state before persistence so callers can pair the storage write with the corresponding token transfer. ## Fund Locking and Betting Integration @@ -55,12 +66,12 @@ There are two primary ways funds are held in the contract: ## Regression Testing Test coverage for balance invariants includes: -- `test_deposit_and_withdrawal_flow`: Basic success paths. -- `test_insufficient_balance_withdrawal`: Error handling for over-withdrawal. -- `test_invalid_deposit_amount`: Rejection of 0 or negative deposits. -- `test_invalid_withdraw_amount`: Rejection of 0 or negative withdrawals. -- `test_large_deposit_amount`: Boundary check for extreme values. -- `test_deposit_and_withdraw_full_balance`: Ensuring zero-balance state transitions. +- `test_deposit_credits_balance_after_transfer`: Deposit credits the internal ledger only after the token transfer succeeds. +- `test_withdraw_exact_balance_reaches_zero`: Withdrawing the full balance reaches an exact zero state. +- `test_withdraw_over_balance_returns_typed_error_without_mutation`: Over-withdrawal returns `Error::InsufficientBalance` and leaves storage untouched. +- `test_withdraw_transfer_failure_does_not_leave_phantom_debit`: A failed outbound transfer does not leave a debited internal balance behind. +- `test_sub_balance_rejects_overdraw_without_mutation`: The storage helper rejects underflow before persisting. +- `test_balance_mutators_reject_non_positive_amounts`: Balance mutation helpers reject zero or negative deltas. ## Security Notes From 6eccdc19d8eb4d909075b91289f1dafaa99575e1 Mon Sep 17 00:00:00 2001 From: Agbasimere Date: Tue, 26 May 2026 20:21:37 +0100 Subject: [PATCH 2/5] fix: unblock balance audit CI --- .../src/admin_auth_audit_tests.rs | 2 +- contracts/predictify-hybrid/src/balances.rs | 7 +- .../src/category_tags_tests.rs | 4 +- contracts/predictify-hybrid/src/disputes.rs | 2 +- contracts/predictify-hybrid/src/lib.rs | 1 - .../src/multi_admin_multisig_tests.rs | 10 +- contracts/predictify-hybrid/src/oracles.rs | 4 +- contracts/predictify-hybrid/src/queries.rs | 10 +- contracts/predictify-hybrid/src/storage.rs | 44 +++++---- .../src/storage_layout_tests.rs | 93 +++++++++++-------- 10 files changed, 104 insertions(+), 73 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..47c16f9b 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 } diff --git a/contracts/predictify-hybrid/src/balances.rs b/contracts/predictify-hybrid/src/balances.rs index 51026d09..75d8aa4e 100644 --- a/contracts/predictify-hybrid/src/balances.rs +++ b/contracts/predictify-hybrid/src/balances.rs @@ -190,7 +190,7 @@ mod tests { use super::*; use crate::PredictifyHybridClient; use soroban_sdk::{ - testutils::Address as _, + testutils::{Address as _, EnvTestConfig}, token::{Client as TokenClient, StellarAssetClient}, Address, Env, Symbol, }; @@ -206,7 +206,10 @@ mod tests { impl BalanceTestSetup { fn new() -> Self { - let env = Env::default(); + let mut env = Env::default(); + env.set_config(EnvTestConfig { + capture_snapshot_at_drop: false, + }); env.mock_all_auths(); let token_admin = Address::generate(&env); 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/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..65bb0241 100644 --- a/contracts/predictify-hybrid/src/oracles.rs +++ b/contracts/predictify-hybrid/src/oracles.rs @@ -1,6 +1,6 @@ #![allow(dead_code)] -use alloc::format; +use alloc::{format, 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 +3838,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..d3ff04ea 100644 --- a/contracts/predictify-hybrid/src/queries.rs +++ b/contracts/predictify-hybrid/src/queries.rs @@ -42,6 +42,7 @@ use crate::{ statistics::StatisticsManager, storage::EventManager, }; +use alloc::string::ToString; use soroban_sdk::{contracttype, vec, Address, Env, Map, String, Symbol, Vec}; use crate::types::{ @@ -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. @@ -201,6 +202,9 @@ impl QueryManager { /// ``` pub fn query_event_details(env: &Env, market_id: Symbol) -> Result { let market = Self::get_market_from_storage(env, &market_id)?; + let created_at = EventManager::get_event(env, &market_id) + .map(|e| e.created_at) + .unwrap_or(0); // Calculate participant count let participant_count = market.votes.len() as u32; @@ -213,10 +217,10 @@ impl QueryManager { let winning_outcome = market.get_winning_outcome(); 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.rs b/contracts/predictify-hybrid/src/storage.rs index 2b26e5f1..e9a196aa 100644 --- a/contracts/predictify-hybrid/src/storage.rs +++ b/contracts/predictify-hybrid/src/storage.rs @@ -848,37 +848,49 @@ impl StorageUtils { #[cfg(test)] mod tests { use super::*; - use soroban_sdk::testutils::Address as _; + use soroban_sdk::testutils::{Address as _, EnvTestConfig}; #[test] fn test_sub_balance_rejects_overdraw_without_mutation() { - let env = Env::default(); + let mut env = Env::default(); + env.set_config(EnvTestConfig { + capture_snapshot_at_drop: false, + }); + let contract_id = env.register(crate::PredictifyHybrid, ()); let user = soroban_sdk::Address::generate(&env); let asset = ReflectorAsset::Stellar; - BalanceStorage::add_balance(&env, &user, &asset, 250).unwrap(); + env.as_contract(&contract_id, || { + BalanceStorage::add_balance(&env, &user, &asset, 250).unwrap(); - let result = BalanceStorage::sub_balance(&env, &user, &asset, 251); + let result = BalanceStorage::sub_balance(&env, &user, &asset, 251); - assert_eq!(result, Err(Error::InsufficientBalance)); - assert_eq!(BalanceStorage::get_balance(&env, &user, &asset).amount, 250); + assert_eq!(result, Err(Error::InsufficientBalance)); + assert_eq!(BalanceStorage::get_balance(&env, &user, &asset).amount, 250); + }); } #[test] fn test_balance_mutators_reject_non_positive_amounts() { - let env = Env::default(); + let mut env = Env::default(); + env.set_config(EnvTestConfig { + capture_snapshot_at_drop: false, + }); + let contract_id = env.register(crate::PredictifyHybrid, ()); let user = soroban_sdk::Address::generate(&env); let asset = ReflectorAsset::Stellar; - assert_eq!( - BalanceStorage::add_balance(&env, &user, &asset, 0), - Err(Error::InvalidInput) - ); - assert_eq!( - BalanceStorage::sub_balance(&env, &user, &asset, -1), - Err(Error::InvalidInput) - ); - assert_eq!(BalanceStorage::get_balance(&env, &user, &asset).amount, 0); + env.as_contract(&contract_id, || { + assert_eq!( + BalanceStorage::add_balance(&env, &user, &asset, 0), + Err(Error::InvalidInput) + ); + assert_eq!( + BalanceStorage::sub_balance(&env, &user, &asset, -1), + Err(Error::InvalidInput) + ); + assert_eq!(BalanceStorage::get_balance(&env, &user, &asset).amount, 0); + }); } #[test] diff --git a/contracts/predictify-hybrid/src/storage_layout_tests.rs b/contracts/predictify-hybrid/src/storage_layout_tests.rs index 81590523..efe7feda 100644 --- a/contracts/predictify-hybrid/src/storage_layout_tests.rs +++ b/contracts/predictify-hybrid/src/storage_layout_tests.rs @@ -9,18 +9,27 @@ //! - 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, + testutils::{Address as _, EnvTestConfig}, + 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 ===== /// Test helper to create a test environment fn create_test_env() -> Env { - Env::default() + let mut env = Env::default(); + env.set_config(EnvTestConfig { + capture_snapshot_at_drop: false, + }); + env } /// Test helper to create a test admin @@ -278,39 +287,42 @@ fn test_audit_trail_namespace_prefix() { #[test] fn test_balance_storage_key_uniqueness() { let env = create_test_env(); + let contract_id = env.register(crate::PredictifyHybrid, ()); let user1 = Address::generate(&env); let user2 = Address::generate(&env); 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 - ); + env.as_contract(&contract_id, || { + // 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 ===== @@ -658,19 +670,22 @@ fn test_no_regression_in_market_storage() { #[test] fn test_no_regression_in_balance_storage() { let env = create_test_env(); + let contract_id = env.register(crate::PredictifyHybrid, ()); let user = Address::generate(&env); let asset = ReflectorAsset::BTC; - // Add balance using current implementation - BalanceStorage::add_balance(&env, &user, &asset, 1_000_000).unwrap(); + env.as_contract(&contract_id, || { + // 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 ===== From 8a0008f53f4016661824195262962ef5b7b8df98 Mon Sep 17 00:00:00 2001 From: Agbasimere Date: Tue, 26 May 2026 20:35:26 +0100 Subject: [PATCH 3/5] fix: restore wasm-safe contract string handling --- .../src/market_id_generator.rs | 21 ++++++++++++ contracts/predictify-hybrid/src/oracles.rs | 7 ++-- contracts/predictify-hybrid/src/queries.rs | 32 ++++++++++++++++--- 3 files changed, 51 insertions(+), 9 deletions(-) diff --git a/contracts/predictify-hybrid/src/market_id_generator.rs b/contracts/predictify-hybrid/src/market_id_generator.rs index 92014653..7b31e3d1 100644 --- a/contracts/predictify-hybrid/src/market_id_generator.rs +++ b/contracts/predictify-hybrid/src/market_id_generator.rs @@ -40,6 +40,7 @@ use crate::errors::Error; use crate::types::Market; use alloc::format; +#[cfg(not(target_family = "wasm"))] use alloc::string::ToString; use soroban_sdk::{contracttype, panic_with_error, Address, Bytes, Env, Symbol, Vec}; @@ -140,13 +141,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 { + // Soroban's contract-facing Symbol type does not expose string conversion + // on wasm builds. Market IDs are generated internally, so runtime callers + // rely on collision/registry checks rather than reparsing the prefix. + 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 +182,16 @@ impl MarketIdGenerator { }) } + #[cfg(target_family = "wasm")] + pub fn parse_market_id_components( + _env: &Env, + _market_id: &Symbol, + ) -> Result { + // Symbol string parsing is not available in the wasm contract build. + // This helper is only used for diagnostics/tests on host builds. + Err(Error::InvalidInput) + } + /// 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 65bb0241..6ba62d43 100644 --- a/contracts/predictify-hybrid/src/oracles.rs +++ b/contracts/predictify-hybrid/src/oracles.rs @@ -1,6 +1,6 @@ #![allow(dead_code)] -use alloc::{format, string::ToString}; +use alloc::format; use crate::bandprotocol; use crate::errors::Error; use soroban_sdk::{contracttype, symbol_short, vec, Address, Bytes, Env, IntoVal, String, Symbol, Vec}; @@ -3940,10 +3940,7 @@ impl OracleCallbackAuth { /// * `caller` - The address of the calling oracle contract /// * `callback_data` - The successfully authenticated callback data 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()), - ); + let log_message = String::from_str(&self.env, "Oracle callback authenticated"); let ctx = String::from_str(&self.env, "oracle_auth"); crate::events::EventEmitter::emit_error_logged( &self.env, diff --git a/contracts/predictify-hybrid/src/queries.rs b/contracts/predictify-hybrid/src/queries.rs index d3ff04ea..aa140e91 100644 --- a/contracts/predictify-hybrid/src/queries.rs +++ b/contracts/predictify-hybrid/src/queries.rs @@ -34,7 +34,7 @@ use crate::{ markets::{MarketAnalytics, MarketStateManager, MarketValidator}, types::{Market, MarketState, PagedMarketIds, PagedUserBets}, voting::VotingStats, - admin::{AdminManager, AdminRole, MultisigConfig}, + admin::{AdminManager, AdminPermission, AdminRole, MultisigConfig}, oracles::{OracleMetadata, OracleWhitelist}, disputes::{Dispute, DisputeManager, DisputeStats, DisputeVote}, governance::{GovernanceContract, GovernanceProposal}, @@ -42,7 +42,6 @@ use crate::{ statistics::StatisticsManager, storage::EventManager, }; -use alloc::string::ToString; use soroban_sdk::{contracttype, vec, Address, Env, Map, String, Symbol, Vec}; use crate::types::{ @@ -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") { + AdminPermission::Initialize + } else if action == String::from_str(env, "create_market") { + AdminPermission::CreateMarket + } else if action == String::from_str(env, "close_market") { + AdminPermission::CloseMarket + } else if action == String::from_str(env, "finalize_market") { + AdminPermission::FinalizeMarket + } else if action == String::from_str(env, "extend_market") { + AdminPermission::ExtendMarket + } else if action == String::from_str(env, "update_fees") { + AdminPermission::UpdateFees + } else if action == String::from_str(env, "update_config") { + AdminPermission::UpdateConfig + } else if action == String::from_str(env, "reset_config") { + AdminPermission::ResetConfig + } else if action == String::from_str(env, "collect_fees") { + AdminPermission::CollectFees + } else if action == String::from_str(env, "manage_disputes") { + AdminPermission::ManageDispute + } else if action == String::from_str(env, "view_analytics") { + AdminPermission::ViewAnalytic + } else if action == String::from_str(env, "emergency_actions") { + AdminPermission::Emergency + } else { + return Err(Error::InvalidInput); + }; Ok(AdminManager::validate_admin_permission(env, &admin, permission).is_ok()) } From d866e92890f82d431bc4625388cc99db02f797a0 Mon Sep 17 00:00:00 2001 From: Agbasimere Date: Tue, 26 May 2026 20:59:48 +0100 Subject: [PATCH 4/5] 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 | 229 ++++++++++-------- 3 files changed, 164 insertions(+), 106 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 efe7feda..8b833039 100644 --- a/contracts/predictify-hybrid/src/storage_layout_tests.rs +++ b/contracts/predictify-hybrid/src/storage_layout_tests.rs @@ -87,6 +87,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] @@ -364,17 +369,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 ===== @@ -385,19 +392,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 ===== @@ -408,24 +417,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, @@ -433,17 +445,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] @@ -458,18 +472,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 ===== @@ -478,31 +494,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] @@ -510,14 +530,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 ===== @@ -528,17 +550,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] @@ -563,7 +587,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(); @@ -577,14 +602,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 ===== @@ -651,20 +678,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); + 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(); + // 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); + // 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 42c481d4c698e44728f8a246d47a15c7b88d387a Mon Sep 17 00:00:00 2001 From: Agbasimere Date: Tue, 26 May 2026 22:26:36 +0100 Subject: [PATCH 5/5] 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 7b31e3d1..f8819927 100644 --- a/contracts/predictify-hybrid/src/market_id_generator.rs +++ b/contracts/predictify-hybrid/src/market_id_generator.rs @@ -431,15 +431,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, || { @@ -635,15 +636,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, || {