diff --git a/Cargo.lock b/Cargo.lock index ff631709..cb49a319 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -997,6 +997,13 @@ dependencies = [ "zerocopy 0.8.24", ] +[[package]] +name = "predictify-analytics" +version = "0.1.0" +dependencies = [ + "soroban-sdk", +] + [[package]] name = "predictify-hybrid" version = "0.0.0" diff --git a/contracts/predictify-analytics/Cargo.toml b/contracts/predictify-analytics/Cargo.toml index fe4d3876..7270344e 100644 --- a/contracts/predictify-analytics/Cargo.toml +++ b/contracts/predictify-analytics/Cargo.toml @@ -9,17 +9,8 @@ publish = false [lib] crate-type = ["cdylib", "rlib"] -[features] -default = [] -testutils = [] - [dependencies] soroban-sdk = { workspace = true } -predictify-hybrid = { path = "../predictify-hybrid", features = ["full"] } - -[depdevelopment-dependencies] -soroban-sdk = { workspace = true, features = ["testutils"] } -proptest = "1.4" [dev-dependencies] -predictify-hybrid = { path = "../predictify-hybrid", features = ["full", "testutils"] } +soroban-sdk = { workspace = true, features = ["testutils"] } diff --git a/contracts/predictify-analytics/src/lib.rs b/contracts/predictify-analytics/src/lib.rs new file mode 100644 index 00000000..6f79e05a --- /dev/null +++ b/contracts/predictify-analytics/src/lib.rs @@ -0,0 +1,254 @@ +#![no_std] + +use soroban_sdk::{ + contract, contractclient, contractimpl, contracttype, Address, Env, Map, String, Symbol, Vec, +}; + +// Keep these ABI types local so this facade does not link the hybrid contract. +#[contracttype] +#[derive(Clone, Debug)] +pub struct MarketStatistics { + pub market_id: Symbol, + pub total_participants: u32, + pub total_stake: i128, + pub total_votes: u32, + pub outcome_distribution: Map, + pub stake_distribution: Map, + pub average_stake: i128, + pub participation_rate: u32, + pub market_volatility: u32, + pub consensus_strength: u32, + pub time_to_resolution: u64, + pub resolution_method: String, +} + +#[contracttype] +#[derive(Clone, Debug)] +pub struct VotingAnalytics { + pub market_id: Symbol, + pub total_votes: u32, + pub unique_voters: u32, + pub voting_timeline: Map, + pub outcome_preferences: Map, + pub stake_concentration: Map, + pub voting_patterns: Map, + pub participation_trends: Vec, + pub consensus_evolution: Vec, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum OracleProvider { + Reflector, + Pyth, + BandProtocol, + DIA, +} + +#[contracttype] +#[derive(Clone, Debug)] +pub struct OraclePerformanceStats { + pub oracle_provider: OracleProvider, + pub total_requests: u32, + pub successful_requests: u32, + pub failed_requests: u32, + pub average_response_time: u64, + pub accuracy_rate: u32, + pub uptime_percentage: u32, + pub last_update: u64, + pub reliability_score: u32, + pub performance_trends: Vec, +} + +#[contracttype] +#[derive(Clone, Debug)] +pub enum TimeFrame { + Hour, + Day, + Week, + Month, + Quarter, + Year, + AllTime, +} + +#[contracttype] +#[derive(Clone, Debug)] +pub struct FeeAnalytics { + pub timeframe: TimeFrame, + pub total_fees_collected: i128, + pub platform_fees: i128, + pub dispute_fees: i128, + pub creation_fees: i128, + pub fee_distribution: Map, + pub average_fee_per_market: i128, + pub fee_collection_rate: u32, + pub revenue_trends: Vec, + pub fee_optimization_score: u32, +} + +#[contracttype] +#[derive(Clone, Debug)] +pub struct DisputeAnalytics { + pub market_id: Symbol, + pub total_disputes: u32, + pub resolved_disputes: u32, + pub pending_disputes: u32, + pub dispute_stakes: i128, + pub average_resolution_time: u64, + pub dispute_success_rate: u32, + pub dispute_reasons: Map, + pub resolution_methods: Map, + pub dispute_trends: Vec, +} + +#[contracttype] +#[derive(Clone, Debug)] +pub struct ParticipationMetrics { + pub market_id: Symbol, + pub total_participants: u32, + pub active_participants: u32, + pub new_participants: u32, + pub returning_participants: u32, + pub participation_rate: u32, + pub engagement_score: u32, + pub retention_rate: u32, + pub participant_demographics: Map, + pub activity_patterns: Map, +} + +#[contracttype] +#[derive(Clone, Debug)] +pub struct MarketComparisonAnalytics { + pub markets: Vec, + pub total_markets: u32, + pub average_participation: u32, + pub average_stake: i128, + pub success_rate: u32, + pub resolution_efficiency: u32, + pub market_performance_ranking: Map, + pub comparative_metrics: Map, + pub market_categories: Map, + pub performance_insights: Vec, +} + +#[contractclient(name = "PredictifyHybridClient")] +pub trait PredictifyHybrid { + fn get_market_analytics_statistics(env: Env, market_id: Symbol) -> MarketStatistics; + fn get_voting_analytics(env: Env, market_id: Symbol) -> VotingAnalytics; + fn get_dispute_analytics(env: Env, market_id: Symbol) -> DisputeAnalytics; + fn get_participation_metrics(env: Env, market_id: Symbol) -> ParticipationMetrics; + fn get_market_comparison_analytics(env: Env, markets: Vec) + -> MarketComparisonAnalytics; + fn get_fee_analytics(env: Env, timeframe: TimeFrame) -> FeeAnalytics; + fn get_oracle_performance_stats(env: Env, oracle: OracleProvider) -> OraclePerformanceStats; +} + +/// Read-only analytics façade for data maintained by the hybrid contract. +#[contract] +pub struct PredictifyAnalytics; + +#[contractimpl] +impl PredictifyAnalytics { + pub fn get_market_statistics( + env: Env, + core_contract: Address, + market_id: Symbol, + ) -> MarketStatistics { + PredictifyHybridClient::new(&env, &core_contract) + .get_market_analytics_statistics(&market_id) + } + + pub fn get_voting_analytics( + env: Env, + core_contract: Address, + market_id: Symbol, + ) -> VotingAnalytics { + PredictifyHybridClient::new(&env, &core_contract).get_voting_analytics(&market_id) + } + + pub fn get_dispute_analytics( + env: Env, + core_contract: Address, + market_id: Symbol, + ) -> DisputeAnalytics { + PredictifyHybridClient::new(&env, &core_contract).get_dispute_analytics(&market_id) + } + + pub fn get_participation_metrics( + env: Env, + core_contract: Address, + market_id: Symbol, + ) -> ParticipationMetrics { + PredictifyHybridClient::new(&env, &core_contract).get_participation_metrics(&market_id) + } + + pub fn get_market_comparison_analytics( + env: Env, + core_contract: Address, + markets: Vec, + ) -> MarketComparisonAnalytics { + PredictifyHybridClient::new(&env, &core_contract).get_market_comparison_analytics(&markets) + } + + pub fn get_fee_analytics( + env: Env, + core_contract: Address, + timeframe: TimeFrame, + ) -> FeeAnalytics { + PredictifyHybridClient::new(&env, &core_contract).get_fee_analytics(&timeframe) + } + + pub fn get_oracle_performance_stats( + env: Env, + core_contract: Address, + oracle: OracleProvider, + ) -> OraclePerformanceStats { + PredictifyHybridClient::new(&env, &core_contract).get_oracle_performance_stats(&oracle) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use soroban_sdk::{contract, contractimpl, Env, Map, String}; + + #[contract] + struct MockCoreContract; + + #[contractimpl] + impl MockCoreContract { + pub fn get_market_analytics_statistics(env: Env, market_id: Symbol) -> MarketStatistics { + MarketStatistics { + market_id, + total_participants: 3, + total_stake: 120, + total_votes: 3, + outcome_distribution: Map::new(&env), + stake_distribution: Map::new(&env), + average_stake: 40, + participation_rate: 75, + market_volatility: 10, + consensus_strength: 80, + time_to_resolution: 600, + resolution_method: String::from_str(&env, "oracle"), + } + } + } + + #[test] + fn forwards_market_statistics_from_core_contract() { + let env = Env::default(); + let core_id = env.register(MockCoreContract, ()); + let analytics_id = env.register(PredictifyAnalytics, ()); + let client = PredictifyAnalyticsClient::new(&env, &analytics_id); + let market_id = Symbol::new(&env, "market_1"); + + let statistics = client.get_market_statistics(&core_id, &market_id); + + assert_eq!(statistics.market_id, market_id); + assert_eq!(statistics.total_participants, 3); + assert_eq!(statistics.total_stake, 120); + assert_eq!(statistics.average_stake, 40); + } +} diff --git a/contracts/predictify-hybrid/IMPLEMENTATION_SUMMARY.md b/contracts/predictify-hybrid/IMPLEMENTATION_SUMMARY.md index a9cabec1..0a40c54a 100644 --- a/contracts/predictify-hybrid/IMPLEMENTATION_SUMMARY.md +++ b/contracts/predictify-hybrid/IMPLEMENTATION_SUMMARY.md @@ -187,7 +187,7 @@ All 15 original tests pass unchanged: ```rust // Migrate market registry (repeat until complete) let (migrated, total, complete) = migrate_market_id_registry(admin, 100); - + // Migrate admin counters (one call) let migrated = migrate_admin_counters(admin); ``` diff --git a/contracts/predictify-hybrid/src/bets.rs b/contracts/predictify-hybrid/src/bets.rs index da2fe3f2..87da00a6 100644 --- a/contracts/predictify-hybrid/src/bets.rs +++ b/contracts/predictify-hybrid/src/bets.rs @@ -1318,7 +1318,11 @@ impl BetValidator { /// /// - `Error::FeeExceedsMax` - The effective fee exceeds `max_fee_bps` pub fn validate_fee_slippage(env: &Env, max_fee_bps: i128) -> Result<(), Error> { - let effective_fee_bps = crate::fees::resolve_effective_fee_bps(env)?; + let effective_fee_bps = match crate::fees::resolve_effective_fee_bps(env) { + Ok(fee_bps) => fee_bps, + Err(Error::ConfigNotFound) => crate::config::DEFAULT_PLATFORM_FEE_PERCENTAGE, + Err(error) => return Err(error), + }; if effective_fee_bps > max_fee_bps { return Err(Error::FeeExceedsMax); @@ -1956,7 +1960,7 @@ mod tests { // Store platform fee in legacy storage key (used when ConfigManager fails) env.storage() .persistent() - .set(&Symbol::new(&env, "plat_fee"), &250i128); + .set(&Symbol::new(&env, "platform_fee"), &250i128); // max_fee_bps equal to stored fee should pass assert!(BetValidator::validate_fee_slippage(&env, 250).is_ok()); diff --git a/contracts/predictify-hybrid/src/disputes.rs b/contracts/predictify-hybrid/src/disputes.rs index f8c68a87..840ff643 100644 --- a/contracts/predictify-hybrid/src/disputes.rs +++ b/contracts/predictify-hybrid/src/disputes.rs @@ -53,7 +53,9 @@ use soroban_sdk::{contracttype, symbol_short, Address, Env, Map, String, Symbol, /// 1. **Creation**: User stakes tokens and provides reasoning /// 2. **Community Voting**: Other users vote on dispute validity /// 3. **Resolution**: Dispute is resolved based on community consensus -/// 4. **Fee Distribution**: Stakes are distributed to winning side +/// 4. **Stake Refund**: If the dispute overturns the oracle result, every +/// disputer's locked stake is returned in full (see [`DisputeManager::resolve_dispute_authorized`]). +/// 5. **Fee Distribution**: Any remaining stakes are distributed to the winning side /// /// # Staking Requirements /// @@ -735,7 +737,7 @@ pub struct TimeoutAnalytics { /// # let market_id = Symbol::new(&env, "market_123"); /// /// // User disputes a market result -/// let result = DisputeManager::process_dispute( +/// let result = DisputeManager::process_dispute_authorized( /// &env, /// user.clone(), /// market_id.clone(), @@ -744,7 +746,7 @@ pub struct TimeoutAnalytics { /// ); /// /// // Admin resolves the dispute after community voting -/// let resolution = DisputeManager::resolve_dispute( +/// let resolution = DisputeManager::resolve_dispute_authorized( /// &env, /// market_id.clone(), /// admin.clone() @@ -893,7 +895,7 @@ impl DisputeManager { /// # let market_id = Symbol::new(&env, "btc_price_market"); /// /// // User disputes oracle result with reasoning - /// let result = DisputeManager::process_dispute( + /// let result = DisputeManager::process_dispute_authorized( /// &env, /// user.clone(), /// market_id.clone(), @@ -931,7 +933,7 @@ impl DisputeManager { /// - Validates market state to ensure disputes are only allowed when appropriate /// - Enforces minimum stake requirements to prevent spam /// - Checks for duplicate disputes from the same user - pub fn process_dispute( + pub(crate) fn process_dispute_authorized( env: &Env, user: Address, market_id: Symbol, @@ -1099,7 +1101,7 @@ impl DisputeManager { /// # let market_id = Symbol::new(&env, "disputed_market"); /// /// // Admin resolves dispute after voting period - /// let resolution = DisputeManager::resolve_dispute( + /// let resolution = DisputeManager::resolve_dispute_authorized( /// &env, /// market_id.clone(), /// admin.clone() @@ -1147,13 +1149,13 @@ impl DisputeManager { /// - Correct application of resolution algorithms /// - Appropriate handling of edge cases /// - Consistent resolution quality across markets - pub fn resolve_dispute( + pub(crate) fn resolve_dispute_authorized( env: &Env, market_id: Symbol, admin: Address, ) -> Result { - // Require authentication from the admin - admin.require_auth(); + // Authorization is enforced by the entrypoint, which calls + // `admin.require_auth()` before delegating here (single-frame auth). // SECURITY: Validate admin permissions - enforce strict authorization DisputeValidator::validate_admin_permissions(env, &admin)?; @@ -1173,6 +1175,10 @@ impl DisputeManager { // Determine final outcome with dispute consideration let final_outcome = DisputeUtils::determine_final_outcome_with_disputes(env, &market)?; + let oracle_was_overturned = match market.oracle_result.as_ref() { + Some(oracle_outcome) => oracle_outcome != &final_outcome, + None => false, + }; // Calculate weights let oracle_weight = DisputeAnalytics::calculate_oracle_weight(&market); @@ -1192,6 +1198,17 @@ impl DisputeManager { DisputeUtils::finalize_market_with_resolution(&mut market, final_outcome)?; MarketStateManager::update_market(env, &market_id, &market); + if oracle_was_overturned { + let token_client = crate::markets::MarketUtils::get_token_client(env)?; + let contract_address = env.current_contract_address(); + for (disputer, stake) in market.dispute_stakes.iter() { + if stake <= 0 { + return Err(Error::InvalidInput); + } + token_client.transfer(&contract_address, &disputer, &stake); + } + } + // SECURITY: Update history status with strict state transitions // Only Active disputes can transition to Resolved let mut history = env.storage().persistent() @@ -1552,7 +1569,7 @@ impl DisputeManager { /// # let dispute_id = Symbol::new(&env, "dispute_456"); /// /// // Vote to support the dispute - /// let result = DisputeManager::vote_on_dispute( + /// let result = DisputeManager::vote_on_dispute_authorized( /// &env, /// voter.clone(), /// market_id.clone(), @@ -1569,7 +1586,7 @@ impl DisputeManager { /// /// // Vote to reject the dispute /// let other_voter = Address::generate(&env); - /// let reject_result = DisputeManager::vote_on_dispute( + /// let reject_result = DisputeManager::vote_on_dispute_authorized( /// &env, /// other_voter, /// market_id, @@ -1610,7 +1627,7 @@ impl DisputeManager { /// - Stake amounts reflect voter confidence /// - Economic penalties discourage frivolous voting /// - Proportional rewards based on stake size - pub fn vote_on_dispute( + pub(crate) fn vote_on_dispute_authorized( env: &Env, user: Address, market_id: Symbol, @@ -1619,8 +1636,8 @@ impl DisputeManager { stake: i128, reason: Option, ) -> Result<(), Error> { - // Require authentication from the user - user.require_auth(); + // Authorization is enforced by the entrypoint, which calls + // `user.require_auth()` before delegating here (single-frame auth). // Reject self-vote: the dispute opener cannot vote on their own dispute let market = MarketStateManager::get_market(env, &market_id)?; @@ -3700,6 +3717,66 @@ mod tests { assert_eq!(stats.active_disputes, 1); } + #[test] + fn test_dispute_stake_is_refunded_when_resolution_favors_disputer() { + let env = Env::default(); + env.mock_all_auths_allowing_non_root_auth(); + + let admin = Address::generate(&env); + let user = Address::generate(&env); + let contract_id = env.register(crate::PredictifyHybrid, ()); + let market_id = Symbol::new(&env, "refund_market"); + + let token_admin = Address::generate(&env); + let token_contract = env.register_stellar_asset_contract_v2(token_admin.clone()); + let token_address = token_contract.address(); + let token_client = soroban_sdk::token::Client::new(&env, &token_address); + let stellar_client = soroban_sdk::token::StellarAssetClient::new(&env, &token_address); + stellar_client.mint(&user, &10_000_000_000i128); + + env.as_contract(&contract_id, || { + env.storage().persistent().set(&Symbol::new(&env, "Admin"), &admin); + env.storage() + .persistent() + .set(&Symbol::new(&env, "TokenID"), &token_address); + + let mut market = create_test_market(&env, env.ledger().timestamp().saturating_sub(1)); + market.oracle_result = Some(String::from_str(&env, "yes")); + market.state = crate::types::MarketState::Ended; + market.total_staked = 1_000; + + let voter = Address::generate(&env); + market.votes.set(voter.clone(), String::from_str(&env, "no")); + market.stakes.set(voter, 1_000); + MarketStateManager::update_market(&env, &market_id, &market); + + let initial_balance = token_client.balance(&user); + let stake = MIN_DISPUTE_STAKE; + DisputeManager::process_dispute_authorized( + &env, + user.clone(), + market_id.clone(), + stake, + None, + ) + .unwrap(); + + let balance_after_dispute = token_client.balance(&user); + assert_eq!(balance_after_dispute, initial_balance - stake); + + let contract_balance_before_refund = token_client.balance(&env.current_contract_address()); + let resolution = + DisputeManager::resolve_dispute_authorized(&env, market_id.clone(), admin.clone()) + .unwrap(); + + assert_eq!(resolution.final_outcome, String::from_str(&env, "no")); + let balance_after_refund = token_client.balance(&user); + assert_eq!(balance_after_refund, initial_balance); + assert_eq!(token_client.balance(&env.current_contract_address()), 0); + assert_eq!(contract_balance_before_refund, stake); + }); + } + #[test] fn test_testing_utilities() { let env = Env::default(); @@ -3863,14 +3940,13 @@ mod tests { // Stake 1200: higher than global (1000) but lower than market (1500). Should fail. assert_eq!( - DisputeManager::process_dispute(&env, user.clone(), market_id.clone(), 1200, None).unwrap_err(), + DisputeManager::process_dispute_authorized(&env, user.clone(), market_id.clone(), 1200, None).unwrap_err(), Error::InvalidStakeAmount ); // Stake 2000: higher than both. - // process_dispute doesn't check if user has balance in tests because the contract doesn't have the mock balance set up - // Wait, process_dispute internally locks funds, which will fail if there's no balance. - // I should use DisputeValidator directly to just check the dispute stake check. + // process_dispute_authorized internally locks funds, which will fail if there's no balance + // in this test setup, so use DisputeValidator directly to check the stake rules. assert_eq!( DisputeValidator::validate_dispute_parameters(&env, &market_id, &user, &market, 1200).unwrap_err(), Error::InvalidStakeAmount @@ -3886,4 +3962,4 @@ mod tests { assert!(DisputeValidator::validate_dispute_parameters(&env, &market_id, &user, &market, 1200).is_ok()); }); } -} +} \ No newline at end of file diff --git a/contracts/predictify-hybrid/src/fee_source_unification_tests.rs b/contracts/predictify-hybrid/src/fee_source_unification_tests.rs index c874eb83..6e665928 100644 --- a/contracts/predictify-hybrid/src/fee_source_unification_tests.rs +++ b/contracts/predictify-hybrid/src/fee_source_unification_tests.rs @@ -45,27 +45,40 @@ fn fee_setter_updates_canonical_config_and_legacy_mirror() { #[test] fn resolver_prefers_canonical_config_over_legacy_mirror() { let env = Env::default(); - let mut config = ConfigManager::get_development_config(&env); - config.fees.platform_fee_percentage = 500; - ConfigManager::store_config(&env, &config).unwrap(); - env.storage() - .persistent() - .set(&Symbol::new(&env, "platform_fee"), &200i128); + let contract_id = env.register(PredictifyHybrid, ()); + let test_market = market(&env); + + env.as_contract(&contract_id, || { + let mut config = ConfigManager::get_development_config(&env); + config.fees.platform_fee_percentage = 500; + ConfigManager::store_config(&env, &config).unwrap(); + env.storage() + .persistent() + .set(&Symbol::new(&env, "platform_fee"), &200i128); - assert_eq!(effective_fee_bps(&env, &market(&env)), Ok(500)); + assert_eq!(effective_fee_bps(&env, &test_market), Ok(500)); + }); } #[test] fn resolver_uses_legacy_compatibility_value_only_without_canonical_config() { let env = Env::default(); - env.storage() - .persistent() - .set(&Symbol::new(&env, "platform_fee"), &400i128); - assert_eq!(effective_fee_bps(&env, &market(&env)), Ok(400)); + let contract_id = env.register(PredictifyHybrid, ()); + let test_market = market(&env); + env.as_contract(&contract_id, || { + env.storage() + .persistent() + .set(&Symbol::new(&env, "platform_fee"), &400i128); + assert_eq!(effective_fee_bps(&env, &test_market), Ok(400)); + }); let missing_env = Env::default(); - assert_eq!( - effective_fee_bps(&missing_env, &market(&missing_env)), - Err(Error::ConfigNotFound) - ); + let missing_contract_id = missing_env.register(PredictifyHybrid, ()); + let missing_market = market(&missing_env); + missing_env.as_contract(&missing_contract_id, || { + assert_eq!( + effective_fee_bps(&missing_env, &missing_market), + Err(Error::ConfigNotFound) + ); + }); } diff --git a/contracts/predictify-hybrid/src/fees.rs b/contracts/predictify-hybrid/src/fees.rs index da65a649..d7c16334 100644 --- a/contracts/predictify-hybrid/src/fees.rs +++ b/contracts/predictify-hybrid/src/fees.rs @@ -704,7 +704,7 @@ pub struct FeeBreakdown { /// # let market_id = Symbol::new(&env, "btc_market"); /// /// // Collect fees from a resolved market -/// let collected_amount = FeeManager::collect_fees( +/// let collected_amount = FeeManager::collect_fees_authorized( /// &env, /// admin.clone(), /// market_id.clone() @@ -753,7 +753,11 @@ pub struct FeeManager; impl FeeManager { /// Collect platform fees from a market - pub fn collect_fees(env: &Env, admin: Address, market_id: Symbol) -> Result { + pub(crate) fn collect_fees_authorized( + env: &Env, + admin: Address, + market_id: Symbol, + ) -> Result { // Authorization is enforced by the `collect_fees` entrypoint via // `require_primary_admin` (require_auth + stored-admin identity check). // Re-authorizing the same address in the same frame here made the host @@ -2516,9 +2520,11 @@ mod checked_arithmetic_tests { env.storage() .persistent() .set(&Symbol::new(&env, "Admin"), &admin); + let config = crate::config::ConfigManager::get_development_config(&env); + crate::config::ConfigManager::store_config(&env, &config).unwrap(); MarketStateManager::update_market(&env, &market_id, &market); - let result = FeeManager::collect_fees(&env, admin.clone(), market_id.clone()); + let result = FeeManager::collect_fees_authorized(&env, admin.clone(), market_id.clone()); assert_eq!(result, Err(Error::FeeArithmeticOverflow)); assert_eq!( @@ -2578,7 +2584,7 @@ mod checked_arithmetic_tests { .set(&Symbol::new(&env, "Admin"), &admin); MarketStateManager::update_market(&env, &market_id, &market); - let result = FeeManager::collect_fees(&env, admin.clone(), market_id.clone()); + let result = FeeManager::collect_fees_authorized(&env, admin.clone(), market_id.clone()); assert!(result.is_ok()); let vault: i128 = env.storage().persistent().get(&FEE_VAULT_KEY).unwrap_or(0); @@ -2967,4 +2973,4 @@ mod tests { assert_eq!(result.unwrap_err(), Error::InvalidInput); }); } -} +} \ No newline at end of file diff --git a/contracts/predictify-hybrid/src/lib.rs b/contracts/predictify-hybrid/src/lib.rs index b25e9eb7..965fb809 100644 --- a/contracts/predictify-hybrid/src/lib.rs +++ b/contracts/predictify-hybrid/src/lib.rs @@ -5,6 +5,9 @@ static ALLOC: wee_alloc::WeeAlloc = wee_alloc::WeeAlloc::INIT; extern crate alloc; +#[cfg(test)] +extern crate std; + #[cfg(test)] pub(crate) mod testutil; @@ -71,7 +74,7 @@ mod validation; // mod validation_tests; // disabled - API drift mod versioning; mod voting; -mod market_analytics; +pub mod market_analytics; mod performance_benchmarks; mod disputes; mod edge_cases; @@ -211,6 +214,9 @@ mod fee_config_commit_reveal_tests; #[cfg(test)] mod fee_source_unification_tests; +#[cfg(test)] +mod single_frame_auth_tests; + // #[cfg(test)] // mod event_creation_tests; @@ -3174,7 +3180,7 @@ impl PredictifyHybrid { /// println!("Price: ${}", result.price / 100); /// println!("Confidence: {}%", result.confidence_score); /// println!("Sources consulted: {}", result.sources_count); - /// + /// /// if result.is_verified { /// println!("Result is verified and authoritative"); /// } @@ -3820,8 +3826,8 @@ impl PredictifyHybrid { /// // Access resolution statistics /// let total_resolved = analytics.total_markets_resolved; /// let oracle_accuracy = analytics.oracle_accuracy_rate; - /// let avg_resolution_time = analytics.average_resolution_time; - /// + /// let avg_resolution_time = analytics.average_resolution_time; + /// /// println!("Resolved markets: {}", total_resolved); /// println!("Oracle accuracy: {}%", oracle_accuracy); /// }, @@ -3905,8 +3911,8 @@ impl PredictifyHybrid { /// // Access market statistics /// let total_participants = stats.total_participants; /// let total_stake = stats.total_stake; - /// let leading_outcome = stats.leading_outcome; - /// + /// let leading_outcome = stats.leading_outcome; + /// /// println!("Participants: {}", total_participants); /// println!("Total stake: {}", total_stake); /// println!("Leading outcome: {:?}", leading_outcome); @@ -3998,14 +4004,23 @@ impl PredictifyHybrid { if let Err(rate_err) = crate::rate_limiter::RateLimiter::new(env.clone()) .rate_limit_disputes(user.clone(), market_id.clone()) { - if matches!(rate_err, crate::rate_limiter::RateLimiterError::ConfigNotFound) { + if matches!( + rate_err, + crate::rate_limiter::RateLimiterError::ConfigNotFound + ) { // No rate limit config — skip } else { return Err(Error::from(rate_err)); } } - let result = disputes::DisputeManager::process_dispute(&env, user.clone(), market_id.clone(), stake, reason); + let result = disputes::DisputeManager::process_dispute_authorized( + &env, + user.clone(), + market_id.clone(), + stake, + reason, + ); if result.is_ok() { // Invalidate analytics cache — dispute stakes have changed. analytics::AnalyticsCache::new(&env).invalidate(&market_id); @@ -4118,15 +4133,24 @@ impl PredictifyHybrid { if let Err(rate_err) = crate::rate_limiter::RateLimiter::new(env.clone()) .rate_limit_disputes(user.clone(), market_id.clone()) { - if matches!(rate_err, crate::rate_limiter::RateLimiterError::ConfigNotFound) { + if matches!( + rate_err, + crate::rate_limiter::RateLimiterError::ConfigNotFound + ) { // No rate limit config — skip } else { return Err(Error::from(rate_err)); } } - let result = disputes::DisputeManager::vote_on_dispute( - &env, user, market_id.clone(), dispute_id, vote, stake, reason, + let result = disputes::DisputeManager::vote_on_dispute_authorized( + &env, + user, + market_id.clone(), + dispute_id, + vote, + stake, + reason, ); if result.is_ok() { // Invalidate analytics cache — dispute stakes have changed. @@ -4150,10 +4174,13 @@ impl PredictifyHybrid { admin: Address, market_id: Symbol, ) -> Result { - // The manager authenticates and validates the stored primary admin. - // A second require_auth in this frame traps during simulation. + Self::require_primary_admin(&env, &admin)?; - let result = disputes::DisputeManager::resolve_dispute(&env, market_id.clone(), admin.clone()); + let result = disputes::DisputeManager::resolve_dispute_authorized( + &env, + market_id.clone(), + admin.clone(), + ); if result.is_ok() { // Per-market audit entry: DisputeResolved let details = Map::new(&env); @@ -4211,7 +4238,8 @@ impl PredictifyHybrid { } Self::require_primary_admin(&env, &admin)?; - let result = fees::FeeManager::collect_fees(&env, admin.clone(), market_id.clone()); + let result = + fees::FeeManager::collect_fees_authorized(&env, admin.clone(), market_id.clone()); if let Ok(amount) = result { // Per-market audit entry: FeesCollected let mut details = Map::new(&env); diff --git a/contracts/predictify-hybrid/src/market_id_generator.rs b/contracts/predictify-hybrid/src/market_id_generator.rs index e1d0d438..f130f3a9 100644 --- a/contracts/predictify-hybrid/src/market_id_generator.rs +++ b/contracts/predictify-hybrid/src/market_id_generator.rs @@ -187,32 +187,29 @@ impl MarketIdGenerator { /// string allocation. pub fn validate_market_id_format(env: &Env, market_id: &Symbol) -> bool { // Convert Symbol to XDR bytes to inspect the prefix without string allocation. - // Symbol XDR format: 4-byte length prefix (big-endian) followed by the string bytes. + // A ScVal symbol stores a 4-byte type tag, a 4-byte length, then its bytes. let xdr_bytes = market_id.to_xdr(env); - - // XDR format check: need at least 4 bytes for length + 4 bytes for "mkt_" - if xdr_bytes.len() < 8 { + + if xdr_bytes.len() < 12 { return false; } - - // Read the length prefix (first 4 bytes, big-endian) + + // Read the symbol length after the ScVal type tag. let len = u32::from_be_bytes([ - xdr_bytes.get(0).unwrap_or(0), - xdr_bytes.get(1).unwrap_or(0), - xdr_bytes.get(2).unwrap_or(0), - xdr_bytes.get(3).unwrap_or(0), + xdr_bytes.get(4).unwrap_or(0), + xdr_bytes.get(5).unwrap_or(0), + xdr_bytes.get(6).unwrap_or(0), + xdr_bytes.get(7).unwrap_or(0), ]); - - // Verify the string is at least 4 bytes long to contain "mkt_" - if len < 4 { + + if len < 4 || 8 + len > xdr_bytes.len() { return false; } - - // Check for "mkt_" prefix (bytes 4-7 in XDR encoding) - xdr_bytes.get(4).unwrap_or(0) == b'm' - && xdr_bytes.get(5).unwrap_or(0) == b'k' - && xdr_bytes.get(6).unwrap_or(0) == b't' - && xdr_bytes.get(7).unwrap_or(0) == b'_' + + xdr_bytes.get(8).unwrap_or(0) == b'm' + && xdr_bytes.get(9).unwrap_or(0) == b'k' + && xdr_bytes.get(10).unwrap_or(0) == b't' + && xdr_bytes.get(11).unwrap_or(0) == b'_' } /// Parse the counter and legacy flag out of a market ID symbol. @@ -228,41 +225,40 @@ impl MarketIdGenerator { ) -> Result { // Convert Symbol to XDR bytes to parse without string allocation let xdr_bytes = market_id.to_xdr(env); - - // XDR format: 4-byte length prefix (big-endian) followed by the string bytes - if xdr_bytes.len() < 4 { + + // A ScVal symbol stores a 4-byte type tag, a 4-byte length, then its bytes. + if xdr_bytes.len() < 8 { return Ok(MarketIdComponents { counter: 0, is_legacy: true }); } - - // Read the length prefix + + // Read the symbol length after the ScVal type tag. let len = u32::from_be_bytes([ - xdr_bytes.get(0).unwrap_or(0), - xdr_bytes.get(1).unwrap_or(0), - xdr_bytes.get(2).unwrap_or(0), - xdr_bytes.get(3).unwrap_or(0), - ]) as usize; - - // Check for "mkt_" prefix - if len < 4 || xdr_bytes.len() < 8 { + xdr_bytes.get(4).unwrap_or(0), + xdr_bytes.get(5).unwrap_or(0), + xdr_bytes.get(6).unwrap_or(0), + xdr_bytes.get(7).unwrap_or(0), + ]); + + if len < 4 || 8 + len > xdr_bytes.len() { return Ok(MarketIdComponents { counter: 0, is_legacy: true }); } - - let has_prefix = xdr_bytes.get(4).unwrap_or(0) == b'm' - && xdr_bytes.get(5).unwrap_or(0) == b'k' - && xdr_bytes.get(6).unwrap_or(0) == b't' - && xdr_bytes.get(7).unwrap_or(0) == b'_'; - + + let has_prefix = xdr_bytes.get(8).unwrap_or(0) == b'm' + && xdr_bytes.get(9).unwrap_or(0) == b'k' + && xdr_bytes.get(10).unwrap_or(0) == b't' + && xdr_bytes.get(11).unwrap_or(0) == b'_'; + if !has_prefix { return Ok(MarketIdComponents { counter: 0, is_legacy: true }); } - + // Parse counter from the suffix after the second underscore // Format: mkt_{8 hex}_{counter} // Need to find the position of the second underscore let mut underscore_count = 0; - let mut counter_start = 0usize; - - for i in 4..(4 + len) { + let mut counter_start = 0u32; + + for i in 8..(8 + len) { if i >= xdr_bytes.len() { break; } @@ -274,14 +270,14 @@ impl MarketIdGenerator { } } } - - if underscore_count < 2 || counter_start >= (4 + len) { + + if underscore_count < 2 || counter_start >= (8 + len) { return Err(Error::InvalidInput); } - + // Parse the counter bytes as ASCII digits let mut counter: u32 = 0; - for i in counter_start..(4 + len) { + for i in counter_start..(8 + len) { if i >= xdr_bytes.len() { break; } @@ -294,7 +290,7 @@ impl MarketIdGenerator { .and_then(|c| c.checked_add((byte - b'0') as u32)) .ok_or(Error::InvalidInput)?; } - + Ok(MarketIdComponents { counter, is_legacy: false }) } @@ -305,17 +301,26 @@ impl MarketIdGenerator { pub fn get_market_id_registry(env: &Env, start: u32, limit: u32) -> Vec { let mut result = Vec::new(env); let capped_limit = core::cmp::min(limit, 50); - + // Check if we have the sharded count let count_key = crate::storage::DataKey::MarketIdCount; let total_count: Option = env.storage().persistent().get(&count_key); - + if let Some(count) = total_count { - // New sharded registry path - let end = core::cmp::min(start + capped_limit, count); + // During migration, combine the sharded prefix with untouched legacy entries. + let legacy_key = Symbol::new(env, Self::REGISTRY_KEY); + let legacy_registry: Vec = env + .storage() + .persistent() + .get(&legacy_key) + .unwrap_or_else(|| Vec::new(env)); + let total_entries = core::cmp::max(count, legacy_registry.len()); + let end = core::cmp::min(start + capped_limit, total_entries); for i in start..end { - let entry_key = crate::storage::DataKey::MarketIdAt(i); - if let Some(entry) = env.storage().persistent().get::<_, MarketIdRegistryEntry>(&entry_key) { + let sharded_key = crate::storage::DataKey::MarketIdAt(i); + if let Some(entry) = env.storage().persistent().get::<_, MarketIdRegistryEntry>(&sharded_key) { + result.push_back(entry); + } else if let Some(entry) = legacy_registry.get(i) { result.push_back(entry); } } @@ -346,7 +351,7 @@ impl MarketIdGenerator { // Check sharded registry first let count_key = crate::storage::DataKey::MarketIdCount; let total_count: Option = env.storage().persistent().get(&count_key); - + if let Some(count) = total_count { // Search sharded entries for i in 0..count { @@ -358,7 +363,7 @@ impl MarketIdGenerator { } } } - + // Legacy fallback let key = Symbol::new(env, Self::REGISTRY_KEY); let registry: Vec = env @@ -390,11 +395,11 @@ impl MarketIdGenerator { let capped_limit = core::cmp::min(limit, 50); let mut found = 0u32; let mut skipped = 0u32; - + // Check sharded registry first let count_key = crate::storage::DataKey::MarketIdCount; let total_count: Option = env.storage().persistent().get(&count_key); - + if let Some(count) = total_count { // Search sharded entries for i in 0..count { @@ -472,9 +477,9 @@ impl MarketIdGenerator { ) -> (u32, u32, bool) { // Require admin authentication admin.require_auth(); - + let capped_batch = core::cmp::min(batch_size, 100); - + // Load legacy registry let legacy_key = Symbol::new(env, Self::REGISTRY_KEY); let legacy_registry: Vec = env @@ -482,40 +487,40 @@ impl MarketIdGenerator { .persistent() .get(&legacy_key) .unwrap_or(Vec::new(env)); - + let total_legacy = legacy_registry.len(); - + // Check current migration progress let count_key = crate::storage::DataKey::MarketIdCount; let current_count = Self::get_market_id_count(env); - + // If we've already migrated everything, return early if current_count >= total_legacy { return (0, total_legacy, true); } - + // Migrate the next batch let start_index = current_count; let end_index = core::cmp::min(start_index + capped_batch, total_legacy); let mut migrated = 0u32; - + for i in start_index..end_index { if let Some(entry) = legacy_registry.get(i) { // Write to sharded key let entry_key = crate::storage::DataKey::MarketIdAt(i); env.storage().persistent().set(&entry_key, &entry); - + // Extend TTL env.storage().persistent().extend_ttl( &entry_key, crate::storage::MARKET_TTL_LEDGERS, crate::storage::MARKET_TTL_LEDGERS, ); - + migrated += 1; } } - + // Update the count let new_count = current_count + migrated; env.storage().persistent().set(&count_key, &new_count); @@ -524,7 +529,7 @@ impl MarketIdGenerator { crate::storage::MARKET_TTL_LEDGERS, crate::storage::MARKET_TTL_LEDGERS, ); - + let is_complete = new_count >= total_legacy; (migrated, total_legacy, is_complete) } @@ -544,34 +549,32 @@ impl MarketIdGenerator { pub fn migrate_admin_counters(env: &Env, admin: &Address) -> u32 { // Require admin authentication admin.require_auth(); - + // Load legacy admin counters let legacy_key = Symbol::new(env, Self::ADMIN_COUNTERS_KEY); let legacy_counters: Option> = env .storage() .persistent() .get(&legacy_key); - + if let Some(counters) = legacy_counters { let mut migrated = 0u32; - + // Iterate through all admin counters and migrate them - for i in 0..counters.len() { - if let Some((addr, count)) = counters.get(i) { - // Check if already migrated - let sharded_key = crate::storage::DataKey::AdminMarketCounter(addr.clone()); - if !env.storage().persistent().has(&sharded_key) { - env.storage().persistent().set(&sharded_key, &count); - env.storage().persistent().extend_ttl( - &sharded_key, - crate::storage::MARKET_TTL_LEDGERS, - crate::storage::MARKET_TTL_LEDGERS, - ); - migrated += 1; - } + for (addr, count) in counters.iter() { + // Check if already migrated + let sharded_key = crate::storage::DataKey::AdminMarketCounter(addr); + if !env.storage().persistent().has(&sharded_key) { + env.storage().persistent().set(&sharded_key, &count); + env.storage().persistent().extend_ttl( + &sharded_key, + crate::storage::MARKET_TTL_LEDGERS, + crate::storage::MARKET_TTL_LEDGERS, + ); + migrated += 1; } } - + migrated } else { 0 @@ -647,18 +650,18 @@ impl MarketIdGenerator { // Use sharded per-admin key instead of a single Map let key = crate::storage::DataKey::AdminMarketCounter(admin.clone()); let counter: Option = env.storage().persistent().get(&key); - + if let Some(c) = counter { return c; } - + // Legacy fallback: check old Map-based storage let legacy_key = Symbol::new(env, Self::ADMIN_COUNTERS_KEY); let counters: Option> = env .storage() .persistent() .get(&legacy_key); - + if let Some(map) = counters { map.get(admin.clone()).unwrap_or(0) } else { @@ -670,7 +673,7 @@ impl MarketIdGenerator { // Use sharded per-admin key let key = crate::storage::DataKey::AdminMarketCounter(admin.clone()); env.storage().persistent().set(&key, &counter); - + // Extend TTL for the counter key env.storage().persistent().extend_ttl( &key, @@ -687,17 +690,17 @@ impl MarketIdGenerator { admin: admin.clone(), timestamp, }; - + let entry_key = crate::storage::DataKey::MarketIdAt(index); env.storage().persistent().set(&entry_key, &entry); - + // Extend TTL for the entry env.storage().persistent().extend_ttl( &entry_key, crate::storage::MARKET_TTL_LEDGERS, crate::storage::MARKET_TTL_LEDGERS, ); - + // Increment the count Self::increment_market_id_count(env); } @@ -708,12 +711,15 @@ impl MarketIdGenerator { #[cfg(test)] mod tests { use super::*; + use crate::alloc::string::ToString; use soroban_sdk::testutils::{Address as _, Ledger, LedgerInfo}; // ── Helpers ────────────────────────────────────────────────────────────── fn setup() -> (Env, Address, Address) { let env = Env::default(); + env.cost_estimate().disable_resource_limits(); + env.mock_all_auths(); let contract_id = env.register(crate::PredictifyHybrid, ()); let admin = Address::generate(&env); (env, contract_id, admin) @@ -1138,12 +1144,12 @@ mod tests { with_contract(&env, &contract_id, || { // Generate a market ID let id = MarketIdGenerator::generate_market_id(&env, &admin); - + // Verify it was stored in sharded storage let count_key = crate::storage::DataKey::MarketIdCount; let count: u32 = env.storage().persistent().get(&count_key).unwrap(); assert_eq!(count, 1); - + let entry_key = crate::storage::DataKey::MarketIdAt(0); let entry: MarketIdRegistryEntry = env.storage().persistent().get(&entry_key).unwrap(); assert_eq!(entry.market_id, id); @@ -1157,7 +1163,7 @@ mod tests { with_contract(&env, &contract_id, || { // Generate a market ID MarketIdGenerator::generate_market_id(&env, &admin); - + // Verify counter was stored in sharded storage let counter_key = crate::storage::DataKey::AdminMarketCounter(admin.clone()); let counter: u32 = env.storage().persistent().get(&counter_key).unwrap(); @@ -1173,11 +1179,11 @@ mod tests { for _ in 0..60 { MarketIdGenerator::generate_market_id(&env, &admin); } - + // Request 100 entries, should get capped at 50 let page = MarketIdGenerator::get_market_id_registry(&env, 0, 100); assert_eq!(page.len(), 50); - + // Request second page let page2 = MarketIdGenerator::get_market_id_registry(&env, 50, 100); assert_eq!(page2.len(), 10); @@ -1192,7 +1198,7 @@ mod tests { for _ in 0..5 { MarketIdGenerator::generate_market_id(&env, &admin); } - + // Request page beyond end let page = MarketIdGenerator::get_market_id_registry(&env, 100, 10); assert_eq!(page.len(), 0); @@ -1204,24 +1210,24 @@ mod tests { let (env, contract_id, _) = setup(); let admin1 = Address::generate(&env); let admin2 = Address::generate(&env); - + with_contract(&env, &contract_id, || { // Admin 1 creates 3 markets for _ in 0..3 { MarketIdGenerator::generate_market_id(&env, &admin1); } - + // Admin 2 creates 2 markets for _ in 0..2 { MarketIdGenerator::generate_market_id(&env, &admin2); } - + // Verify independent counters let counter1 = MarketIdGenerator::get_admin_counter(&env, &admin1); let counter2 = MarketIdGenerator::get_admin_counter(&env, &admin2); assert_eq!(counter1, 3); assert_eq!(counter2, 2); - + // Verify unique IDs let markets1 = MarketIdGenerator::get_admin_markets(&env, &admin1); let markets2 = MarketIdGenerator::get_admin_markets(&env, &admin2); @@ -1235,13 +1241,13 @@ mod tests { let (env, contract_id, admin) = setup(); with_contract(&env, &contract_id, || { let id = Symbol::new(&env, "test_collision_id"); - + // Key doesn't exist - no collision assert!(!MarketIdGenerator::check_market_id_collision(&env, &id)); - + // Store something at that key (not necessarily a Market type) env.storage().persistent().set(&id, &42u32); - + // Should detect collision via has(), not trap on type conversion assert!(MarketIdGenerator::check_market_id_collision(&env, &id)); }); @@ -1250,28 +1256,29 @@ mod tests { #[test] fn test_create_500_markets_cost_stability() { let (env, contract_id, admin) = setup(); - + env.budget().reset_unlimited(); + with_contract(&env, &contract_id, || { // Create first market and capture count MarketIdGenerator::generate_market_id(&env, &admin); let count_after_1 = MarketIdGenerator::get_market_id_count(&env); assert_eq!(count_after_1, 1); - + // Create 499 more markets for _ in 1..500 { MarketIdGenerator::generate_market_id(&env, &admin); } - + let count_after_500 = MarketIdGenerator::get_market_id_count(&env); assert_eq!(count_after_500, 500); - + // Verify we can still read entries efficiently let first_entry_key = crate::storage::DataKey::MarketIdAt(0); let last_entry_key = crate::storage::DataKey::MarketIdAt(499); - + assert!(env.storage().persistent().has(&first_entry_key)); assert!(env.storage().persistent().has(&last_entry_key)); - + // Each creation touched O(1) keys regardless of total count // This test verifies functional correctness; actual cost measurement // would require env.cost_estimate() which is not available in basic tests @@ -1283,12 +1290,12 @@ mod tests { #[test] fn test_migration_idempotency() { let (env, contract_id, admin) = setup(); - + with_contract(&env, &contract_id, || { // Create legacy registry with 10 entries let legacy_key = Symbol::new(&env, MarketIdGenerator::REGISTRY_KEY); let mut legacy_registry = Vec::new(&env); - + for i in 0..10 { let id = Symbol::new(&env, &alloc::format!("mkt_legacy_{}", i)); legacy_registry.push_back(MarketIdRegistryEntry { @@ -1297,45 +1304,43 @@ mod tests { timestamp: 1000 + i as u64, }); } - - env.storage().persistent().set(&legacy_key, &legacy_registry); - - // First migration: migrate 5 entries - let (migrated1, total1, complete1) = - MarketIdGenerator::migrate_market_id_registry(&env, &admin, 5); - assert_eq!(migrated1, 5); - assert_eq!(total1, 10); - assert!(!complete1); - - // Second migration: migrate remaining 5 - let (migrated2, total2, complete2) = - MarketIdGenerator::migrate_market_id_registry(&env, &admin, 5); - assert_eq!(migrated2, 5); - assert_eq!(total2, 10); - assert!(complete2); - - // Third migration: idempotent, nothing to migrate - let (migrated3, total3, complete3) = - MarketIdGenerator::migrate_market_id_registry(&env, &admin, 5); - assert_eq!(migrated3, 0); - assert_eq!(total3, 10); - assert!(complete3); - - // Verify all entries are accessible - let count = MarketIdGenerator::get_market_id_count(&env); - assert_eq!(count, 10); + + env.storage() + .persistent() + .set(&legacy_key, &legacy_registry); + }); + + // Each admin-authorized migration is a separate invocation. + let (migrated1, total1, complete1) = with_contract(&env, &contract_id, || { + MarketIdGenerator::migrate_market_id_registry(&env, &admin, 5) + }); + assert_eq!((migrated1, total1, complete1), (5, 10, false)); + + let (migrated2, total2, complete2) = with_contract(&env, &contract_id, || { + MarketIdGenerator::migrate_market_id_registry(&env, &admin, 5) + }); + assert_eq!((migrated2, total2, complete2), (5, 10, true)); + + let (migrated3, total3, complete3) = with_contract(&env, &contract_id, || { + MarketIdGenerator::migrate_market_id_registry(&env, &admin, 5) + }); + assert_eq!((migrated3, total3, complete3), (0, 10, true)); + + let count = with_contract(&env, &contract_id, || { + MarketIdGenerator::get_market_id_count(&env) }); + assert_eq!(count, 10); } #[test] fn test_migration_respects_batch_cap() { let (env, contract_id, admin) = setup(); - + with_contract(&env, &contract_id, || { // Create legacy registry with 150 entries let legacy_key = Symbol::new(&env, MarketIdGenerator::REGISTRY_KEY); let mut legacy_registry = Vec::new(&env); - + for i in 0..150 { let id = Symbol::new(&env, &alloc::format!("mkt_lg_{}", i)); legacy_registry.push_back(MarketIdRegistryEntry { @@ -1344,34 +1349,32 @@ mod tests { timestamp: 1000 + i as u64, }); } - - env.storage().persistent().set(&legacy_key, &legacy_registry); - - // Request 200 entries but should be capped at 100 - let (migrated, total, complete) = - MarketIdGenerator::migrate_market_id_registry(&env, &admin, 200); - assert_eq!(migrated, 100); - assert_eq!(total, 150); - assert!(!complete); - - // Second call gets remaining 50 - let (migrated2, total2, complete2) = - MarketIdGenerator::migrate_market_id_registry(&env, &admin, 200); - assert_eq!(migrated2, 50); - assert_eq!(total2, 150); - assert!(complete2); + + env.storage() + .persistent() + .set(&legacy_key, &legacy_registry); + }); + + let (migrated, total, complete) = with_contract(&env, &contract_id, || { + MarketIdGenerator::migrate_market_id_registry(&env, &admin, 200) + }); + assert_eq!((migrated, total, complete), (100, 150, false)); + + let (migrated2, total2, complete2) = with_contract(&env, &contract_id, || { + MarketIdGenerator::migrate_market_id_registry(&env, &admin, 200) }); + assert_eq!((migrated2, total2, complete2), (50, 150, true)); } #[test] fn test_reads_correct_during_migration() { let (env, contract_id, admin) = setup(); - + with_contract(&env, &contract_id, || { // Create legacy registry with 10 entries let legacy_key = Symbol::new(&env, MarketIdGenerator::REGISTRY_KEY); let mut legacy_registry = Vec::new(&env); - + for i in 0..10 { let id = Symbol::new(&env, &alloc::format!("mkt_mid_{}", i)); legacy_registry.push_back(MarketIdRegistryEntry { @@ -1380,67 +1383,79 @@ mod tests { timestamp: 1000 + i as u64, }); } - - env.storage().persistent().set(&legacy_key, &legacy_registry); - - // Partial migration + + env.storage() + .persistent() + .set(&legacy_key, &legacy_registry); + }); + + with_contract(&env, &contract_id, || { MarketIdGenerator::migrate_market_id_registry(&env, &admin, 5); - - // Reads should work correctly (mixing sharded + legacy) - let entries = MarketIdGenerator::get_market_id_registry(&env, 0, 10); - assert_eq!(entries.len(), 10); - - // Verify content correctness - for i in 0..10 { - let entry = entries.get(i).unwrap(); - assert_eq!(entry.admin, admin); - } }); + + // Reads should work correctly (mixing sharded + legacy). + let entries = with_contract(&env, &contract_id, || { + MarketIdGenerator::get_market_id_registry(&env, 0, 10) + }); + assert_eq!(entries.len(), 10); + + // Verify content correctness + for i in 0..10 { + let entry = entries.get(i).unwrap(); + assert_eq!(entry.admin, admin); + } } #[test] fn test_admin_counter_migration() { let (env, contract_id, admin) = setup(); let admin2 = Address::generate(&env); - + with_contract(&env, &contract_id, || { // Create legacy admin counters let legacy_key = Symbol::new(&env, MarketIdGenerator::ADMIN_COUNTERS_KEY); let mut legacy_counters = soroban_sdk::Map::new(&env); legacy_counters.set(admin.clone(), 42u32); legacy_counters.set(admin2.clone(), 17u32); - - env.storage().persistent().set(&legacy_key, &legacy_counters); - - // Migrate - let migrated = MarketIdGenerator::migrate_admin_counters(&env, &admin); - assert_eq!(migrated, 2); - - // Verify counters are accessible via sharded storage + + env.storage() + .persistent() + .set(&legacy_key, &legacy_counters); + }); + + let migrated = with_contract(&env, &contract_id, || { + MarketIdGenerator::migrate_admin_counters(&env, &admin) + }); + assert_eq!(migrated, 2); + + // Verify counters are accessible via sharded storage. + with_contract(&env, &contract_id, || { let counter1 = MarketIdGenerator::get_admin_counter(&env, &admin); let counter2 = MarketIdGenerator::get_admin_counter(&env, &admin2); assert_eq!(counter1, 42); assert_eq!(counter2, 17); - - // Second migration should be idempotent - let migrated2 = MarketIdGenerator::migrate_admin_counters(&env, &admin); - assert_eq!(migrated2, 0); }); + + // A repeated admin-authorized migration is a separate invocation. + let migrated2 = with_contract(&env, &contract_id, || { + MarketIdGenerator::migrate_admin_counters(&env, &admin) + }); + assert_eq!(migrated2, 0); } #[test] fn test_max_counter_reached() { let (env, contract_id, admin) = setup(); - + with_contract(&env, &contract_id, || { - // Set admin counter to MAX_COUNTER - MarketIdGenerator::set_admin_counter(&env, &admin, MarketIdGenerator::MAX_COUNTER); - + // The maximum counter is valid; the next value must be rejected. + MarketIdGenerator::set_admin_counter(&env, &admin, MarketIdGenerator::MAX_COUNTER + 1); + // Next generation should panic with InvalidInput let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { MarketIdGenerator::generate_market_id(&env, &admin); })); - + assert!(result.is_err(), "Should panic when MAX_COUNTER exceeded"); }); } @@ -1451,11 +1466,11 @@ mod tests { // way in both host tests and would work on-chain (WASM). // The implementation now uses XDR inspection, not string conversion. let (env, _, _) = setup(); - + let valid_id = Symbol::new(&env, "mkt_abcd1234_0"); let legacy_id = Symbol::new(&env, "legacy_id"); let malformed = Symbol::new(&env, "mk_short"); - + // All builds should agree on these validations assert!(MarketIdGenerator::validate_market_id_format(&env, &valid_id)); assert!(!MarketIdGenerator::validate_market_id_format(&env, &legacy_id)); @@ -1465,10 +1480,10 @@ mod tests { #[test] fn test_parse_components_deterministic() { let (env, _, _) = setup(); - + let id = Symbol::new(&env, "mkt_12345678_999"); let result = MarketIdGenerator::parse_market_id_components(&env, &id); - + // Should successfully parse in all builds (no more WASM returning InvalidInput) assert!(result.is_ok()); let components = result.unwrap(); @@ -1476,5 +1491,3 @@ mod tests { assert!(!components.is_legacy); } } - - diff --git a/contracts/predictify-hybrid/src/single_frame_auth_tests.rs b/contracts/predictify-hybrid/src/single_frame_auth_tests.rs new file mode 100644 index 00000000..51f6cc75 --- /dev/null +++ b/contracts/predictify-hybrid/src/single_frame_auth_tests.rs @@ -0,0 +1,322 @@ +use crate::{Error, PredictifyHybrid, PredictifyHybridClient}; +use soroban_sdk::{ + testutils::{Address as _, MockAuth, MockAuthInvoke}, + token::StellarAssetClient, + Address, Env, IntoVal, String, Symbol, Vec, +}; + +fn register_contract(env: &Env) -> (Address, PredictifyHybridClient<'_>) { + env.cost_estimate().disable_resource_limits(); + let contract_id = env.register(PredictifyHybrid, ()); + let client = PredictifyHybridClient::new(env, &contract_id); + (contract_id, client) +} + +fn store_admin(env: &Env, contract_id: &Address, admin: &Address) { + env.as_contract(contract_id, || { + env.storage() + .persistent() + .set(&Symbol::new(env, "Admin"), admin); + }); +} + +fn setup_disputable_market( + env: &Env, + contract_id: &Address, + user: &Address, + market_id: &Symbol, + stake: i128, +) -> Address { + let token_admin = Address::generate(env); + let token = env.register_stellar_asset_contract_v2(token_admin.clone()); + let token_address = token.address(); + let token_admin_client = StellarAssetClient::new(env, &token_address); + let mint_invoke = MockAuthInvoke { + contract: &token_address, + fn_name: "mint", + args: (user, &stake).into_val(env), + sub_invokes: &[], + }; + token_admin_client + .mock_auths(&[MockAuth { + address: &token_admin, + invoke: &mint_invoke, + }]) + .mint(user, &stake); + + env.as_contract(contract_id, || { + env.storage() + .persistent() + .set(&Symbol::new(env, "TokenID"), &token_address); + + let mut outcomes = Vec::new(env); + outcomes.push_back(String::from_str(env, "yes")); + outcomes.push_back(String::from_str(env, "no")); + let mut market = crate::Market::new( + env, + Address::generate(env), + String::from_str(env, "Single-frame auth regression"), + outcomes, + 0, + crate::OracleConfig::none_sentinel(env), + None, + 86_400, + crate::MarketState::Ended, + ); + market.oracle_result = Some(String::from_str(env, "yes")); + market.total_staked = stake; + env.storage().persistent().set(market_id, &market); + }); + + token_address +} + +#[test] +fn dispute_market_succeeds_with_one_auth_entry_and_updates_stakes() { + let env = Env::default(); + let (contract_id, client) = register_contract(&env); + let user = Address::generate(&env); + let market_id = Symbol::new(&env, "dispute_market"); + let stake = 10_000_000i128; + let reason: Option = None; + let token_address = setup_disputable_market(&env, &contract_id, &user, &market_id, stake); + let token_client = soroban_sdk::token::Client::new(&env, &token_address); + let contract_balance_before = token_client.balance(&contract_id); + let token_transfer = MockAuthInvoke { + contract: &token_address, + fn_name: "transfer", + args: (&user, &contract_id, &stake).into_val(&env), + sub_invokes: &[], + }; + let sub_invokes = [token_transfer]; + let invoke = MockAuthInvoke { + contract: &contract_id, + fn_name: "dispute_market", + args: (&user, &market_id, &stake, &reason).into_val(&env), + sub_invokes: &sub_invokes, + }; + + let result = client + .mock_auths(&[MockAuth { + address: &user, + invoke: &invoke, + }]) + .try_dispute_market(&user, &market_id, &stake, &reason); + + assert_eq!(result, Ok(Ok(()))); + let updated_market = env.as_contract(&contract_id, || { + env.storage() + .persistent() + .get::(&market_id) + .unwrap() + }); + assert_eq!(updated_market.dispute_stakes.get(user), Some(stake)); + assert_eq!( + token_client.balance(&contract_id), + contract_balance_before + stake + ); +} + +#[test] +fn vote_on_dispute_reaches_business_validation_with_one_auth_entry() { + let env = Env::default(); + let (contract_id, client) = register_contract(&env); + let user = Address::generate(&env); + let market_id = Symbol::new(&env, "missing_market"); + let dispute_id = Symbol::new(&env, "missing_dispute"); + let vote = true; + let market_stake = 10_000_000i128; + let vote_stake = 0i128; + let reason: Option = None; + setup_disputable_market(&env, &contract_id, &user, &market_id, market_stake); + env.as_contract(&contract_id, || { + let voting = crate::disputes::DisputeVoting { + dispute_id: dispute_id.clone(), + voting_start: env.ledger().timestamp(), + voting_end: env.ledger().timestamp() + 86_400, + total_votes: 0, + support_votes: 0, + against_votes: 0, + total_support_stake: 0, + total_against_stake: 0, + status: crate::disputes::DisputeVotingStatus::Active, + }; + crate::disputes::DisputeUtils::store_dispute_voting(&env, &dispute_id, &voting).unwrap(); + }); + let invoke = MockAuthInvoke { + contract: &contract_id, + fn_name: "vote_on_dispute", + args: (&user, &market_id, &dispute_id, &vote, &vote_stake, &reason).into_val(&env), + sub_invokes: &[], + }; + + let result = client + .mock_auths(&[MockAuth { + address: &user, + invoke: &invoke, + }]) + .try_vote_on_dispute(&user, &market_id, &dispute_id, &vote, &vote_stake, &reason); + + assert_eq!(result, Err(Ok(Error::InvalidInput))); +} + +#[test] +fn vote_on_dispute_rejects_a_different_authenticated_address() { + let env = Env::default(); + let (contract_id, client) = register_contract(&env); + let user = Address::generate(&env); + let other = Address::generate(&env); + let market_id = Symbol::new(&env, "missing_market"); + let dispute_id = Symbol::new(&env, "missing_dispute"); + let vote = true; + let stake = 1_000_000i128; + let reason: Option = None; + let invoke = MockAuthInvoke { + contract: &contract_id, + fn_name: "vote_on_dispute", + args: (&user, &market_id, &dispute_id, &vote, &stake, &reason).into_val(&env), + sub_invokes: &[], + }; + + let result = client + .mock_auths(&[MockAuth { + address: &other, + invoke: &invoke, + }]) + .try_vote_on_dispute(&user, &market_id, &dispute_id, &vote, &stake, &reason); + + assert!(matches!(result, Err(Err(_)))); + let votes_after_failure = env.as_contract(&contract_id, || { + crate::disputes::DisputeUtils::get_dispute_voting(&env, &dispute_id).unwrap() + }); + assert_eq!(votes_after_failure.total_votes, 0); +} + +#[test] +fn vote_on_dispute_succeeds_with_one_auth_entry_and_updates_voting() { + let env = Env::default(); + let (contract_id, client) = register_contract(&env); + let user = Address::generate(&env); + let market_id = Symbol::new(&env, "vote_market"); + let dispute_id = Symbol::new(&env, "active_dispute"); + let vote = true; + let stake = 10_000_000i128; + let reason: Option = None; + let token_address = setup_disputable_market(&env, &contract_id, &user, &market_id, stake); + + env.as_contract(&contract_id, || { + let voting = crate::disputes::DisputeVoting { + dispute_id: dispute_id.clone(), + voting_start: env.ledger().timestamp(), + voting_end: env.ledger().timestamp() + 86_400, + total_votes: 0, + support_votes: 0, + against_votes: 0, + total_support_stake: 0, + total_against_stake: 0, + status: crate::disputes::DisputeVotingStatus::Active, + }; + crate::disputes::DisputeUtils::store_dispute_voting(&env, &dispute_id, &voting).unwrap(); + }); + + let token_transfer = MockAuthInvoke { + contract: &token_address, + fn_name: "transfer", + args: (&user, &contract_id, &stake).into_val(&env), + sub_invokes: &[], + }; + let sub_invokes = [token_transfer]; + let invoke = MockAuthInvoke { + contract: &contract_id, + fn_name: "vote_on_dispute", + args: (&user, &market_id, &dispute_id, &vote, &stake, &reason).into_val(&env), + sub_invokes: &sub_invokes, + }; + + let result = client + .mock_auths(&[MockAuth { + address: &user, + invoke: &invoke, + }]) + .try_vote_on_dispute(&user, &market_id, &dispute_id, &vote, &stake, &reason); + + assert_eq!(result, Ok(Ok(()))); + let updated_voting = env.as_contract(&contract_id, || { + crate::disputes::DisputeUtils::get_dispute_voting(&env, &dispute_id).unwrap() + }); + assert_eq!(updated_voting.total_votes, 1); + assert_eq!(updated_voting.total_support_stake, stake); +} + +#[test] +fn resolve_dispute_reaches_business_validation_after_entrypoint_identity_check() { + let env = Env::default(); + let (contract_id, client) = register_contract(&env); + let admin = Address::generate(&env); + let market_id = Symbol::new(&env, "missing_market"); + store_admin(&env, &contract_id, &admin); + let invoke = MockAuthInvoke { + contract: &contract_id, + fn_name: "resolve_dispute", + args: (&admin, &market_id).into_val(&env), + sub_invokes: &[], + }; + + let result = client + .mock_auths(&[MockAuth { + address: &admin, + invoke: &invoke, + }]) + .try_resolve_dispute(&admin, &market_id); + + assert_eq!(result, Err(Ok(Error::MarketNotFound))); +} + +#[test] +fn resolve_dispute_preserves_unauthorized_error_for_non_admin() { + let env = Env::default(); + let (contract_id, client) = register_contract(&env); + let admin = Address::generate(&env); + let other = Address::generate(&env); + let market_id = Symbol::new(&env, "missing_market"); + store_admin(&env, &contract_id, &admin); + let invoke = MockAuthInvoke { + contract: &contract_id, + fn_name: "resolve_dispute", + args: (&other, &market_id).into_val(&env), + sub_invokes: &[], + }; + + let result = client + .mock_auths(&[MockAuth { + address: &other, + invoke: &invoke, + }]) + .try_resolve_dispute(&other, &market_id); + + assert_eq!(result, Err(Ok(Error::Unauthorized))); +} + +#[test] +fn collect_fees_reaches_business_validation_with_one_auth_entry() { + let env = Env::default(); + let (contract_id, client) = register_contract(&env); + let admin = Address::generate(&env); + let market_id = Symbol::new(&env, "missing_market"); + store_admin(&env, &contract_id, &admin); + let invoke = MockAuthInvoke { + contract: &contract_id, + fn_name: "collect_fees", + args: (&admin, &market_id).into_val(&env), + sub_invokes: &[], + }; + + let result = client + .mock_auths(&[MockAuth { + address: &admin, + invoke: &invoke, + }]) + .try_collect_fees(&admin, &market_id); + + assert_eq!(result, Err(Ok(Error::MarketNotFound))); +} diff --git a/contracts/predictify-hybrid/src/storage.rs b/contracts/predictify-hybrid/src/storage.rs index f69fe845..5dc6fdcf 100644 --- a/contracts/predictify-hybrid/src/storage.rs +++ b/contracts/predictify-hybrid/src/storage.rs @@ -1779,7 +1779,7 @@ mod tests { env.as_contract(&contract_id, || { let mut config = StorageOptimizer::get_storage_config(&env); - config.balance_ttl_ledgers = 10_000; + config.balance_ttl_ledgers = BALANCE_TTL_LEDGERS + 10_000; StorageOptimizer::update_storage_config(&env, &config).unwrap(); BalanceStorage::set_balance( @@ -1793,7 +1793,7 @@ mod tests { .unwrap(); let key = BalanceStorage::get_key(&env, &user, &asset); - let expected = StorageOptimizer::clamp_persistent_ttl(&env, 10_000); + let expected = StorageOptimizer::clamp_persistent_ttl(&env, config.balance_ttl_ledgers); assert_eq!(env.storage().persistent().get_ttl(&key), expected); }); } diff --git a/contracts/predictify-hybrid/src/voting.rs b/contracts/predictify-hybrid/src/voting.rs index 0e28e02b..959f2fc4 100644 --- a/contracts/predictify-hybrid/src/voting.rs +++ b/contracts/predictify-hybrid/src/voting.rs @@ -394,10 +394,10 @@ impl VotingManager { } /// Collect platform fees from a market (moved to fees module) - /// This function is deprecated and should use FeeManager::collect_fees instead + /// This function is deprecated and should use FeeManager::collect_fees_authorized instead pub fn collect_fees(env: &Env, admin: Address, market_id: Symbol) -> Result { // Delegate to the fees module - crate::fees::FeeManager::collect_fees(env, admin, market_id) + crate::fees::FeeManager::collect_fees_authorized(env, admin, market_id) } /// Calculate dynamic dispute threshold for a market using dynamic configuration @@ -1139,6 +1139,10 @@ pub struct VotingUtils; impl VotingUtils { /// Transfer stake from user to contract pub fn transfer_stake(env: &Env, user: &Address, stake: i128) -> Result<(), Error> { + if stake <= 0 { + return Err(Error::InvalidInput); + } + // Reentrancy guard removed - external call protection no longer needed let token_client = MarketUtils::get_token_client(env)?; // Soroban token transfer returns (), assume success if no panic diff --git a/docs/README.md b/docs/README.md index 5dc55285..cdc85490 100644 --- a/docs/README.md +++ b/docs/README.md @@ -16,6 +16,10 @@ Complete API reference for Predictify Hybrid contract, including: - **[Query Implementation Guide](./api/QUERY_IMPLEMENTATION_GUIDE.md)** - Paginated query API, `PagedResult`, security notes, and integrator quick-start - **NEW**: [Dashboard Statistics Queries](#-dashboard-statistics-queries) - Platform aggregates, market metrics, leaderboards with stable field versioning +### 🧪 [Contract Testing](./TESTING.md) + +Focused test commands and the single-frame authorization regression coverage. + ### 🔒 [Security Documentation](./security/) Comprehensive security documentation and guidelines: diff --git a/docs/TESTING.md b/docs/TESTING.md index 3a55f9fe..977df851 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -1,10 +1,13 @@ # Testing status -After merging upstream master `d806a26` for PR #1470, the workspace is blocked by -an incomplete upstream `predictify-analytics` package. See -[the synchronization report](test-output/pr-1470-master-sync.md) for the errors -and isolated hybrid validation. The #1463 results below describe validation -before that merge; full-workspace checks must be repeated after analytics is repaired. +The `predictify-analytics` contract provides read-only cross-contract views over +market, voting, dispute, participation, fee, comparison, and oracle analytics +from `predictify-hybrid`. It has no independent state; queries take the core +contract address explicitly and forward the core contract's returned data. +Its ABI-compatible client types are defined locally so the analytics WASM does +not link the full hybrid contract implementation. +See [the synchronization report](test-output/pr-1470-master-sync.md) for the +upstream package issue and its repair. ```sh cargo test -p predictify-hybrid --lib # 730 passed, 0 failed, 172 ignored @@ -12,15 +15,37 @@ cargo test -p predictify-hybrid --lib bets::tests # 41 passed, none ignored cargo test -p predictify-hybrid --lib balances # 5 passed, none ignored cargo test -p predictify-hybrid --tests cargo test -p hello-world -p oracles +cargo test -p predictify-analytics +rustup target add wasm32v1-none +cargo build --release --target wasm32v1-none -p predictify-analytics +./scripts/build_analytics_wasm.sh cargo check -p predictify-hybrid --no-default-features bash scripts/check_ignored_tests.sh ``` -CI (`.github/workflows/contract-ci.yml`) runs the library, all 11 active integration -targets, stable crates, the slim feature check, and the ignored-test ratchet on -every push/PR targeting master. Swatinem/rust-cache is shared across these steps. -Cargo's `--tests` also reruns the library tests. The integration step writes its -elapsed seconds, including that rerun, to the GitHub Actions job summary. +CI (`.github/workflows/contract-ci.yml`) runs the library, all 11 active +integration targets, stable crates, analytics tests and its 128 KiB WASM budget +check, the slim feature check, and the ignored-test ratchet on every push/PR +targeting master. Swatinem/rust-cache is shared across these steps. Cargo's +`--tests` also reruns the library tests. The integration step writes its elapsed +seconds, including that rerun, to the GitHub Actions job summary. + +## Single-frame authorization + +The regressions in +`contracts/predictify-hybrid/src/single_frame_auth_tests.rs` use the generated +contract client with exactly one `Env::mock_auths` entry per invocation. They +avoid `mock_all_auths`, which would mask repeated `require_auth` calls. + +Entrypoints authenticate once; authorized manager methods retain business +validation and applicable permission checks without authenticating again: + +| Entrypoint | Authentication behavior | +| --- | --- | +| `dispute_market` | Authenticates `user`, then calls `process_dispute_authorized`. | +| `vote_on_dispute` | Authenticates `user`, then calls `vote_on_dispute_authorized`. | +| `resolve_dispute` | `require_primary_admin` authenticates and checks primary-admin identity, then the authorized manager validates permissions. | +| `collect_fees` | `require_primary_admin` authenticates and checks primary-admin identity, then the authorized manager validates permissions. | ## Shared contract context diff --git a/docs/test-output/pr-1470-master-sync.md b/docs/test-output/pr-1470-master-sync.md index 8fdfb6ba..9e09890d 100644 --- a/docs/test-output/pr-1470-master-sync.md +++ b/docs/test-output/pr-1470-master-sync.md @@ -1,37 +1,36 @@ # PR #1470: master synchronization -The PR branch was merged with upstream master `d806a26` using merge commit -`8cdf946`. The merge preserves the original PR commits and all upstream changes. +The PR branch was synchronized with upstream master `49a4762` using merge commit +`91d8bf6`. The merge preserves the original PR commits and upstream changes. ## Conflict resolution -The only merge conflict was `.github/workflows/contract-ci.yml`. Both sides are -retained: the ignored-test budget and timed hybrid integration step from #1463, -and the analytics tests/WASM budget steps from master. The existing cache, -production build, library tests, stable-crate tests, and slim check remain. +The conflicts in `contracts/predictify-hybrid/src/lib.rs` and `docs/TESTING.md` +are resolved. The upstream contract API and testing guide are retained; the +single-frame authorization entrypoints and their regression tests are preserved. +The CI workflow retains both the ignored-test budget and hybrid integration +steps from #1463 and the analytics test/WASM budget steps from master. Verified: every relevant workflow step appears exactly once, the integration Bash block passes `bash -n`, no conflict markers remain, `git diff --check` passes, and the ignored-test ratchet passes at 174 / 174. Upstream master is an ancestor of the merged branch. No force-push is needed. -## Upstream validation blocker +## Analytics package repair The newly merged analytics package is incomplete in upstream commit `63d4d78`: - `git ls-tree -r --name-only upstream/master contracts/predictify-analytics` lists only `Cargo.toml`; the library source `src/lib.rs` is absent. -- `cargo metadata --no-deps --format-version 1` fails before any tests run: - `can't find library predictify_analytics, rename file to src/lib.rs or specify lib.path`. -- Its manifest requests the hybrid feature `testutils`, which the hybrid manifest - does not define, and contains a misspelled dependency-table heading. -- `scripts/build_analytics_wasm.sh` has a malformed shebang and ends with an - incomplete `ROOT_DIR` assignment. `bash -n` reports an unexpected EOF while - looking for a matching quotation mark on line 13. - -These files are preserved from master. The conflict resolution does not create a -replacement analytics contract or bypass its new CI checks. The complete workspace -cannot be reported as passing until the upstream analytics package is repaired. +- Its manifest contains a misspelled development-dependency section and requests + an undefined `predictify-hybrid` `testutils` feature. +- `scripts/build_analytics_wasm.sh` has a malformed shebang and is truncated. + +The branch now supplies a read-only analytics façade that forwards seven +analytics queries to the hybrid contract, corrects the manifest and build script, +and tests the cross-contract market-statistics path. The build script compiles +the analytics WASM and enforces the 131,072-byte limit. CI must pass the analytics +test and WASM build steps to validate these changes end-to-end. ## Additional isolated validation @@ -40,11 +39,8 @@ Validation of the merged hybrid code is run from an untracked copy under packages, without the incomplete analytics package. This is a diagnostic check; it does not substitute for running the complete merged workspace in CI. -The user explicitly requested retaining the scope of this PR and documenting the -upstream blocker rather than repairing analytics in this branch. - `cargo check --offline -p predictify-hybrid --no-default-features` passed in the isolated workspace. `cargo test --offline -p predictify-hybrid --tests --no-fail-fast` also passed: 759 library tests (172 pre-existing ignored) and all 119 integration tests across 11 targets. Both commands ran against the merged sources in the isolated workspace. -These results do not validate the incomplete analytics package. +These earlier results do not include the analytics façade added afterward. diff --git a/scripts/build_analytics_wasm.sh b/scripts/build_analytics_wasm.sh old mode 100644 new mode 100755 index 2e711213..4d6c17b9 --- a/scripts/build_analytics_wasm.sh +++ b/scripts/build_analytics_wasm.sh @@ -1,13 +1,23 @@ -#!/usb/env bash -# Build the predictify-analytics contract WASM and enforce the size budget. -# -# The deployable analytics contract must fit within the Soroban 128 KiB contract -# limit. This script fails if the produced WASM exceeds 131,072 bytes (128 KiB). -# +#!/usr/bin/env bash set -euo pipefail -MAX_SIZE_BYTES=131072 -CRATE_DIR="contracts/predictify-analytics" -WASM_NAME="predictify_analytics.wasm" +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +MAX_SIZE_BYTES="${WASM_SIZE_BUDGET:-131072}" +WASM_FILE="$ROOT_DIR/target/wasm32v1-none/release/predictify_analytics.wasm" -ROOT_DIR="$(cd "$(dirname "${"} \ No newline at end of file +cd "$ROOT_DIR" +rustup target add wasm32v1-none +cargo build --release --target wasm32v1-none -p predictify-analytics + +if [[ ! -f "$WASM_FILE" ]]; then + echo "Error: analytics WASM was not produced: $WASM_FILE" >&2 + exit 1 +fi + +SIZE_BYTES="$(wc -c < "$WASM_FILE")" +echo "Analytics WASM: $SIZE_BYTES bytes (limit: $MAX_SIZE_BYTES)" + +if (( SIZE_BYTES > MAX_SIZE_BYTES )); then + echo "Error: analytics WASM exceeds the configured size budget." >&2 + exit 1 +fi