diff --git a/contracts/predictify-hybrid/src/admin.rs b/contracts/predictify-hybrid/src/admin.rs index d7f98ffb..8ee93091 100644 --- a/contracts/predictify-hybrid/src/admin.rs +++ b/contracts/predictify-hybrid/src/admin.rs @@ -3,7 +3,7 @@ use alloc::format; use soroban_sdk::{contracttype, Address, Env, Map, String, Symbol, Vec}; // use alloc::string::ToString; // Unused import -use crate::config::{ConfigManager, ConfigUtils, ContractConfig, Environment}; +use crate::config::{ConfigManager, ConfigUtils, ConfigValidator, ContractConfig, Environment}; use crate::err::Error; use crate::events::EventEmitter; use crate::extensions::ExtensionManager; @@ -353,7 +353,7 @@ impl AdminInitializer { Environment::Mainnet => ConfigManager::get_mainnet_config(env), Environment::Custom => ConfigManager::get_development_config(env), }; - ConfigManager::validate_config(env, &config)?; + ConfigValidator::validate_contract_config(&config)?; // Initialize basic admin setup AdminInitializer::initialize(env, admin)?; diff --git a/contracts/predictify-hybrid/src/bets.rs b/contracts/predictify-hybrid/src/bets.rs index f3eb6f82..bc40e8b8 100644 --- a/contracts/predictify-hybrid/src/bets.rs +++ b/contracts/predictify-hybrid/src/bets.rs @@ -2442,10 +2442,8 @@ mod tests { stats.outcome_totals.set(outcome.clone(), 1); BetStorage::store_market_bet_stats(&env, &market_id, &stats).unwrap(); - assert_eq!( - BetManager::prepare_market_bet_stats(&env, &market_id, &outcome, 1), - Err(Error::Overflow) - ); + let result = BetManager::prepare_market_bet_stats(&env, &market_id, &outcome, 1); + assert!(matches!(result, Err(Error::Overflow)), "expected Overflow error, got {:?}", result); let stored = BetStorage::get_market_bet_stats(&env, &market_id); assert_eq!(stored.total_amount_locked, i128::MAX); diff --git a/contracts/predictify-hybrid/src/err.rs b/contracts/predictify-hybrid/src/err.rs index 3bd2ef8d..5539c88f 100644 --- a/contracts/predictify-hybrid/src/err.rs +++ b/contracts/predictify-hybrid/src/err.rs @@ -325,6 +325,10 @@ pub enum Error { NoPendingTreasuryUpdate = 690, /// A pending treasury update already exists. PendingTreasuryUpdateExists = 691, + /// Claims are not allowed after the market has reached a terminal settlement state + /// (Closed, Cancelled, Archived, or Restored). Claims are only permitted while + /// the market is in the Resolved state. + ClaimsRejectedAfterSettlement = 692, } // ===== ERROR CATEGORIZATION AND RECOVERY SYSTEM ===== @@ -742,6 +746,9 @@ impl ErrorHandler { Error::MarketAlreadyRestored => { "Market is already restored. Cannot restore a market that is not archived." } + Error::ClaimsRejectedAfterSettlement => { + "Claims are not allowed after the market has reached a terminal settlement state. Claims are only permitted while the market is in the Resolved state." + } _ => "An error occurred. Please verify your parameters and try again.", }; String::from_str(env, msg) @@ -877,6 +884,7 @@ impl ErrorHandler { | Error::CannotRestoreFromState | Error::MarketAlreadyArchived | Error::MarketAlreadyRestored => RecoveryStrategy::Abort, + Error::ClaimsRejectedAfterSettlement => RecoveryStrategy::Abort, Error::FeeExceedsMax => RecoveryStrategy::Retry, Error::BetExceedsCap => RecoveryStrategy::NoRecovery, Error::OperationWouldExceedBudget => RecoveryStrategy::NoRecovery, diff --git a/contracts/predictify-hybrid/src/event_topic_compat_tests.rs b/contracts/predictify-hybrid/src/event_topic_compat_tests.rs index b460c3f8..ec6bf9bd 100644 --- a/contracts/predictify-hybrid/src/event_topic_compat_tests.rs +++ b/contracts/predictify-hybrid/src/event_topic_compat_tests.rs @@ -27,6 +27,9 @@ use soroban_sdk::{symbol_short, testutils::Events, Env, Symbol, Vec}; +extern crate alloc; +use alloc::format; + use crate::event_topic_compat::{ EventCompatBridge, EventNonceGuard, EventTopicRegistry, TOPIC_ALIASES, TOPIC_REGISTRY, }; diff --git a/contracts/predictify-hybrid/src/lib.rs b/contracts/predictify-hybrid/src/lib.rs index b82600ba..08f98939 100644 --- a/contracts/predictify-hybrid/src/lib.rs +++ b/contracts/predictify-hybrid/src/lib.rs @@ -1822,6 +1822,14 @@ impl PredictifyHybrid { panic_with_error!(env, Error::MarketNotFound); }); + // Reject claims after terminal settlement state. + // Claims are only permitted while the market is in the Resolved state. + // Terminal states (Closed, Cancelled, Archived, Restored) indicate the market + // has reached finality and no further claim operations are allowed. + if market.state != MarketState::Resolved { + panic_with_error!(env, Error::ClaimsRejectedAfterSettlement); + } + // Check if user has claimed already (redundant safety check; nonce validation should prevent) if market .claimed diff --git a/contracts/predictify-hybrid/src/markets.rs b/contracts/predictify-hybrid/src/markets.rs index 430bbe8f..8a232964 100644 --- a/contracts/predictify-hybrid/src/markets.rs +++ b/contracts/predictify-hybrid/src/markets.rs @@ -2983,6 +2983,8 @@ impl MarketStateLogic { }; if allowed { Ok(()) + } else if function == "claim" { + Err(Error::ClaimsRejectedAfterSettlement) } else { Err(Error::MarketClosed) } diff --git a/contracts/predictify-hybrid/src/test.rs b/contracts/predictify-hybrid/src/test.rs index e8521f7c..346b9215 100644 --- a/contracts/predictify-hybrid/src/test.rs +++ b/contracts/predictify-hybrid/src/test.rs @@ -7820,4 +7820,352 @@ fn test_empty_lists_allow_access() { assert!(res.is_ok(), "Sin restricciones, el acceso debe ser libre"); } +// ===== CLAIMS AFTER TERMINAL SETTLEMENT STATE TESTS ===== + +/// Test that claims are rejected when the market is in Closed state (terminal settlement). +/// +/// Market lifecycle: Active → Ended → Resolved → Closed +/// Once a market reaches Closed state (terminal settlement), claims should be rejected +/// with `ClaimsRejectedAfterSettlement`. +#[test] +#[should_panic(expected = "Error(Contract, #692)")] // ClaimsRejectedAfterSettlement = 692 +fn test_claim_rejected_after_market_closed() { + let test = PredictifyTest::setup(); + let market_id = test.create_test_market(); + let client = PredictifyHybridClient::new(&test.env, &test.contract_id); + + // 1. User votes for "yes" + test.env.mock_all_auths(); + client.vote( + &test.user, + &market_id, + &String::from_str(&test.env, "yes"), + &100_0000000, + ); + + // 2. Another user votes for "no" + let loser = test.create_funded_user(); + test.env.mock_all_auths(); + client.vote( + &loser, + &market_id, + &String::from_str(&test.env, "no"), + &100_0000000, + ); + + // 3. Advance time past market end + let market = test.env.as_contract(&test.contract_id, || { + test.env + .storage() + .persistent() + .get::(&market_id) + .unwrap() + }); + + test.env.ledger().set(LedgerInfo { + timestamp: market.end_time + market.dispute_window_seconds + 1, + protocol_version: 22, + sequence_number: test.env.ledger().sequence(), + network_id: Default::default(), + base_reserve: 10, + min_temp_entry_ttl: 1, + min_persistent_entry_ttl: 1, + max_entry_ttl: 10000, + }); + + // 4. Resolve market manually (transitions to Resolved) + test.env.mock_all_auths(); + client.resolve_market_manual(&test.admin, &market_id, &String::from_str(&test.env, "yes")); + + // Verify market is in Resolved state + let market = test.env.as_contract(&test.contract_id, || { + test.env + .storage() + .persistent() + .get::(&market_id) + .unwrap() + }); + assert_eq!(market.state, MarketState::Resolved); + + // 5. Transition market to Closed state by directly setting it in storage + // (simulates the closed state after fee collection or admin closure) + test.env.as_contract(&test.contract_id, || { + let mut market: Market = test + .env + .storage() + .persistent() + .get(&market_id) + .unwrap(); + market.state = MarketState::Closed; + test.env.storage().persistent().set(&market_id, &market); + }); + + // Verify market is now in Closed state + let market = test.env.as_contract(&test.contract_id, || { + test.env + .storage() + .persistent() + .get::(&market_id) + .unwrap() + }); + assert_eq!(market.state, MarketState::Closed); + + // 6. Attempt to claim - should panic with ClaimsRejectedAfterSettlement + client.claim_winnings(&test.user, &market_id); +} + +/// Test that claims are rejected when the market is in Cancelled state. +/// +/// Market lifecycle: Active → Cancelled +/// Once a market is cancelled, claims should be rejected with +/// `ClaimsRejectedAfterSettlement`. +#[test] +#[should_panic(expected = "Error(Contract, #692)")] // ClaimsRejectedAfterSettlement = 692 +fn test_claim_rejected_after_market_cancelled() { + let test = PredictifyTest::setup(); + let market_id = test.create_test_market(); + let client = PredictifyHybridClient::new(&test.env, &test.contract_id); + + // 1. User votes for "yes" + test.env.mock_all_auths(); + client.vote( + &test.user, + &market_id, + &String::from_str(&test.env, "yes"), + &100_0000000, + ); + + // 2. Advance time past market end + let market = test.env.as_contract(&test.contract_id, || { + test.env + .storage() + .persistent() + .get::(&market_id) + .unwrap() + }); + + test.env.ledger().set(LedgerInfo { + timestamp: market.end_time + 1, + protocol_version: 22, + sequence_number: test.env.ledger().sequence(), + network_id: Default::default(), + base_reserve: 10, + min_temp_entry_ttl: 1, + min_persistent_entry_ttl: 1, + max_entry_ttl: 10000, + }); + + // 3. Resolve market with winning outcome first (to set winning_outcomes) + test.env.mock_all_auths(); + client.resolve_market_manual(&test.admin, &market_id, &String::from_str(&test.env, "yes")); + + // 4. Transition market to Cancelled state (simulates admin cancellation after resolution) + test.env.as_contract(&test.contract_id, || { + let mut market: Market = test + .env + .storage() + .persistent() + .get(&market_id) + .unwrap(); + market.state = MarketState::Cancelled; + test.env.storage().persistent().set(&market_id, &market); + }); + + // 5. Attempt to claim - should panic with ClaimsRejectedAfterSettlement + client.claim_winnings(&test.user, &market_id); +} + +/// Test that claims are rejected in Active state (before resolution). +/// +/// Claims should not be allowed while the market is still active and accepting votes. +#[test] +#[should_panic(expected = "Error(Contract, #692)")] // ClaimsRejectedAfterSettlement = 692 +fn test_claim_rejected_in_active_state() { + let test = PredictifyTest::setup(); + let market_id = test.create_test_market(); + let client = PredictifyHybridClient::new(&test.env, &test.contract_id); + + // 1. User votes for "yes" + test.env.mock_all_auths(); + client.vote( + &test.user, + &market_id, + &String::from_str(&test.env, "yes"), + &100_0000000, + ); + + // 2. Attempt to claim while market is still Active - should fail + client.claim_winnings(&test.user, &market_id); +} + +/// Test that claims are rejected in Ended state (after voting ends but before resolution). +/// +/// Claims should not be allowed while the market is ended but not yet resolved. +#[test] +#[should_panic(expected = "Error(Contract, #692)")] // ClaimsRejectedAfterSettlement = 692 +fn test_claim_rejected_in_ended_state() { + let test = PredictifyTest::setup(); + let market_id = test.create_test_market(); + let client = PredictifyHybridClient::new(&test.env, &test.contract_id); + + // 1. User votes for "yes" + test.env.mock_all_auths(); + client.vote( + &test.user, + &market_id, + &String::from_str(&test.env, "yes"), + &100_0000000, + ); + + // 2. Advance time past market end (market transitions to Ended) + let market = test.env.as_contract(&test.contract_id, || { + test.env + .storage() + .persistent() + .get::(&market_id) + .unwrap() + }); + + test.env.ledger().set(LedgerInfo { + timestamp: market.end_time + 1, + protocol_version: 22, + sequence_number: test.env.ledger().sequence(), + network_id: Default::default(), + base_reserve: 10, + min_temp_entry_ttl: 1, + min_persistent_entry_ttl: 1, + max_entry_ttl: 10000, + }); + + // 3. Attempt to claim while market is Ended but not resolved - should fail + client.claim_winnings(&test.user, &market_id); +} + +/// Test that claims are rejected in Disputed state. +/// +/// Claims should not be allowed while the market resolution is under dispute. +#[test] +#[should_panic(expected = "Error(Contract, #692)")] // ClaimsRejectedAfterSettlement = 692 +fn test_claim_rejected_in_disputed_state() { + let test = PredictifyTest::setup(); + let market_id = test.create_test_market(); + let client = PredictifyHybridClient::new(&test.env, &test.contract_id); + + // 1. User votes for "yes" + test.env.mock_all_auths(); + client.vote( + &test.user, + &market_id, + &String::from_str(&test.env, "yes"), + &100_0000000, + ); + + // 2. Another user votes for "no" + let loser = test.create_funded_user(); + test.env.mock_all_auths(); + client.vote( + &loser, + &market_id, + &String::from_str(&test.env, "no"), + &100_0000000, + ); + + // 3. Advance time past market end + let market = test.env.as_contract(&test.contract_id, || { + test.env + .storage() + .persistent() + .get::(&market_id) + .unwrap() + }); + + test.env.ledger().set(LedgerInfo { + timestamp: market.end_time + 1, + protocol_version: 22, + sequence_number: test.env.ledger().sequence(), + network_id: Default::default(), + base_reserve: 10, + min_temp_entry_ttl: 1, + min_persistent_entry_ttl: 1, + max_entry_ttl: 10000, + }); + + // 4. Transition market to Disputed state + test.env.as_contract(&test.contract_id, || { + let mut market: Market = test + .env + .storage() + .persistent() + .get(&market_id) + .unwrap(); + market.state = MarketState::Disputed; + // Add a dispute stake to make the state consistent + market + .dispute_stakes + .set(loser.clone(), 10_000_000); + test.env.storage().persistent().set(&market_id, &market); + }); + + // 5. Attempt to claim while market is Disputed - should fail + client.claim_winnings(&test.user, &market_id); +} + +/// Test that claims are rejected in Archived state. +/// +/// Archived markets are immutable and read-only; claims should be rejected. +#[test] +#[should_panic(expected = "Error(Contract, #692)")] // ClaimsRejectedAfterSettlement = 692 +fn test_claim_rejected_in_archived_state() { + let test = PredictifyTest::setup(); + let market_id = test.create_test_market(); + let client = PredictifyHybridClient::new(&test.env, &test.contract_id); + + // 1. User votes for "yes" + test.env.mock_all_auths(); + client.vote( + &test.user, + &market_id, + &String::from_str(&test.env, "yes"), + &100_0000000, + ); + + // 2. Advance time and resolve market + let market = test.env.as_contract(&test.contract_id, || { + test.env + .storage() + .persistent() + .get::(&market_id) + .unwrap() + }); + + test.env.ledger().set(LedgerInfo { + timestamp: market.end_time + market.dispute_window_seconds + 1, + protocol_version: 22, + sequence_number: test.env.ledger().sequence(), + network_id: Default::default(), + base_reserve: 10, + min_temp_entry_ttl: 1, + min_persistent_entry_ttl: 1, + max_entry_ttl: 10000, + }); + + test.env.mock_all_auths(); + client.resolve_market_manual(&test.admin, &market_id, &String::from_str(&test.env, "yes")); + + // 3. Transition market to Archived state + test.env.as_contract(&test.contract_id, || { + let mut market: Market = test + .env + .storage() + .persistent() + .get(&market_id) + .unwrap(); + market.state = MarketState::Archived; + test.env.storage().persistent().set(&market_id, &market); + }); + + // 4. Attempt to claim - should fail + client.claim_winnings(&test.user, &market_id); +} + mod oracle_cooldown_tests; diff --git a/contracts/predictify-hybrid/src/validation.rs b/contracts/predictify-hybrid/src/validation.rs index 6c50d534..b119976c 100644 --- a/contracts/predictify-hybrid/src/validation.rs +++ b/contracts/predictify-hybrid/src/validation.rs @@ -5704,7 +5704,7 @@ impl ContractInitializationValidator { .map_err(|_| Error::InvalidDuration)?; // Oracle configuration must be internally consistent before storage. - OracleValidator::validate_oracle_config_all_together(oracle_config) + OracleConfigValidator::validate_oracle_config_all_together(oracle_config) .map_err(|_| Error::InvalidOracleConfig)?; Ok(())