From d6220d1c1c79e8459df770e1cd9ba4541e0c1d8f Mon Sep 17 00:00:00 2001 From: kikiola Date: Mon, 19 Oct 2026 10:55:02 +0100 Subject: [PATCH 01/10] fix(auth): authenticate once per contract frame Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- contracts/predictify-hybrid/src/disputes.rs | 52 ++- contracts/predictify-hybrid/src/fees.rs | 14 +- contracts/predictify-hybrid/src/lib.rs | 10 +- .../src/single_frame_auth_tests.rs | 320 ++++++++++++++++++ contracts/predictify-hybrid/src/voting.rs | 4 +- docs/README.md | 4 + docs/TESTING.md | 39 +++ 7 files changed, 402 insertions(+), 41 deletions(-) create mode 100644 contracts/predictify-hybrid/src/single_frame_auth_tests.rs create mode 100644 docs/TESTING.md diff --git a/contracts/predictify-hybrid/src/disputes.rs b/contracts/predictify-hybrid/src/disputes.rs index a8dd36c0..69326a14 100644 --- a/contracts/predictify-hybrid/src/disputes.rs +++ b/contracts/predictify-hybrid/src/disputes.rs @@ -53,7 +53,7 @@ use soroban_sdk::{contracttype, symbol_short, Address, Env, Map, String, Symbol, /// 2. **Community Voting**: Other users vote on dispute validity /// 3. **Resolution**: Dispute is resolved based on community consensus /// 4. **Stake Refund**: If the dispute overturns the oracle result, every -/// disputer's locked stake is returned in full (see [`DisputeManager::resolve_dispute`]). +/// 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 @@ -699,7 +699,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(), @@ -708,7 +708,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() @@ -764,7 +764,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(), @@ -802,16 +802,13 @@ 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, stake: i128, reason: Option, ) -> Result<(), Error> { - // Require authentication from the user - user.require_auth(); - // Get and validate market let mut market = MarketStateManager::get_market(env, &market_id)?; DisputeValidator::validate_market_for_dispute(env, &market)?; @@ -922,7 +919,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() @@ -972,14 +969,11 @@ 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(); - // Validate admin permissions DisputeValidator::validate_admin_permissions(env, &admin)?; @@ -1360,7 +1354,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(), @@ -1377,7 +1371,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, @@ -1418,7 +1412,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, @@ -1427,9 +1421,6 @@ impl DisputeManager { stake: i128, reason: Option, ) -> Result<(), Error> { - // Require authentication from the user - user.require_auth(); - // Validate dispute voting conditions DisputeValidator::validate_dispute_voting_conditions(env, &market_id, &dispute_id)?; @@ -3016,15 +3007,22 @@ mod tests { let initial_balance = token_client.balance(&user); let stake = MIN_DISPUTE_STAKE; - DisputeManager::process_dispute(&env, user.clone(), market_id.clone(), stake, None) + 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(&env, market_id.clone(), admin.clone()) - .unwrap(); + 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); @@ -3158,7 +3156,7 @@ mod tests { let initial_balance = token_client.balance(&disputer); let stake = MIN_DISPUTE_STAKE; - DisputeManager::process_dispute( + DisputeManager::process_dispute_authorized( &env, disputer.clone(), market_id.clone(), @@ -3175,7 +3173,7 @@ mod tests { ); let resolution = - DisputeManager::resolve_dispute(&env, market_id.clone(), admin.clone()) + DisputeManager::resolve_dispute_authorized(&env, market_id.clone(), admin.clone()) .unwrap(); // The oracle result was NOT overturned. @@ -3269,7 +3267,7 @@ mod tests { MarketStateManager::update_market(&env, &market_id, &market); let resolution = - DisputeManager::resolve_dispute(&env, market_id.clone(), admin.clone()) + DisputeManager::resolve_dispute_authorized(&env, market_id.clone(), admin.clone()) .unwrap(); // The oracle result should have been overturned by community consensus. @@ -3378,7 +3376,7 @@ mod tests { MarketStateManager::update_market(&env, &market_id, &market); let resolution = - DisputeManager::resolve_dispute(&env, market_id.clone(), admin.clone()) + DisputeManager::resolve_dispute_authorized(&env, market_id.clone(), admin.clone()) .unwrap(); assert_eq!( @@ -3463,7 +3461,7 @@ mod tests { // First resolution β€” should succeed and issue a refund. let resolution = - DisputeManager::resolve_dispute(&env, market_id.clone(), admin.clone()) + DisputeManager::resolve_dispute_authorized(&env, market_id.clone(), admin.clone()) .unwrap(); assert_eq!(resolution.final_outcome, String::from_str(&env, "no")); diff --git a/contracts/predictify-hybrid/src/fees.rs b/contracts/predictify-hybrid/src/fees.rs index 2c75728e..e222285d 100644 --- a/contracts/predictify-hybrid/src/fees.rs +++ b/contracts/predictify-hybrid/src/fees.rs @@ -667,7 +667,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() @@ -716,13 +716,11 @@ pub struct FeeManager; impl FeeManager { /// Collect platform fees from a market - pub fn collect_fees(env: &Env, admin: Address, market_id: Symbol) -> Result { - // Require authentication from the admin - // Note: admin.require_auth() causes "Error(Auth, ExistingValue)" panic in tests with mock_all_auths - // We disable it for tests but keep it for production safety. - #[cfg(not(test))] - admin.require_auth(); - + pub(crate) fn collect_fees_authorized( + env: &Env, + admin: Address, + market_id: Symbol, + ) -> Result { // Validate admin permissions FeeValidator::validate_admin_permissions(env, &admin)?; diff --git a/contracts/predictify-hybrid/src/lib.rs b/contracts/predictify-hybrid/src/lib.rs index 58800bd0..16218ca6 100644 --- a/contracts/predictify-hybrid/src/lib.rs +++ b/contracts/predictify-hybrid/src/lib.rs @@ -114,6 +114,8 @@ mod balance_tests; #[cfg(test)] mod event_management_tests; +#[cfg(test)] +mod single_frame_auth_tests; #[cfg(any())] mod category_tags_tests; @@ -2598,7 +2600,7 @@ impl PredictifyHybrid { reason: Option, ) -> Result<(), Error> { user.require_auth(); - disputes::DisputeManager::process_dispute(&env, user, market_id, stake, reason) + disputes::DisputeManager::process_dispute_authorized(&env, user, market_id, stake, reason) } /// Vote on a dispute @@ -2620,7 +2622,7 @@ impl PredictifyHybrid { reason: Option, ) -> Result<(), Error> { user.require_auth(); - disputes::DisputeManager::vote_on_dispute( + disputes::DisputeManager::vote_on_dispute_authorized( &env, user, market_id, dispute_id, vote, stake, reason, ) } @@ -2654,7 +2656,7 @@ impl PredictifyHybrid { panic_with_error!(env, Error::Unauthorized); } - disputes::DisputeManager::resolve_dispute(&env, market_id, admin) + disputes::DisputeManager::resolve_dispute_authorized(&env, market_id, admin) } /// Collect fees from a market (admin only) @@ -2682,7 +2684,7 @@ impl PredictifyHybrid { panic_with_error!(env, Error::Unauthorized); } - fees::FeeManager::collect_fees(&env, admin, market_id) + fees::FeeManager::collect_fees_authorized(&env, admin, market_id) } /// Automatically distribute payouts to all winners after market resolution. 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..b0d978e2 --- /dev/null +++ b/contracts/predictify-hybrid/src/single_frame_auth_tests.rs @@ -0,0 +1,320 @@ +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<'_>) { + 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 stake = 10_000_000i128; + let reason: Option = None; + 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 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: &user, + invoke: &invoke, + }]) + .try_vote_on_dispute(&user, &market_id, &dispute_id, &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/voting.rs b/contracts/predictify-hybrid/src/voting.rs index 8242e4a5..e911e650 100644 --- a/contracts/predictify-hybrid/src/voting.rs +++ b/contracts/predictify-hybrid/src/voting.rs @@ -389,10 +389,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 diff --git a/docs/README.md b/docs/README.md index 9e8b7b9e..25f64319 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 new file mode 100644 index 00000000..4a626adb --- /dev/null +++ b/docs/TESTING.md @@ -0,0 +1,39 @@ +# Contract testing + +Run the contract unit tests from the workspace root: + +```sh +cargo test -p predictify-hybrid --lib +``` + +The single-frame authorization regressions are in +`contracts/predictify-hybrid/src/single_frame_auth_tests.rs`. They invoke the +generated contract client with `Env::mock_auths` and exactly one authorization +entry per invocation. They deliberately avoid `mock_all_auths`, which would +mask repeated `require_auth` calls. + +Authentication is performed at the contract entrypoint. The dispute and fee +managers are called through crate-private `*_authorized` methods, which retain +business validation and applicable permission checks but do not authenticate +again. Entrypoints that require the primary admin also retain their identity +check. +The corresponding call chains are: + +| Entrypoint | Before | After | +| --- | --- | --- | +| `dispute_market` | Entrypoint and `DisputeManager::process_dispute` both authenticated `user` | Entrypoint calls `user.require_auth()` once, then `process_dispute_authorized` | +| `vote_on_dispute` | Entrypoint and `DisputeManager::vote_on_dispute` both authenticated `user` | Entrypoint calls `user.require_auth()` once, then `vote_on_dispute_authorized` | +| `resolve_dispute` | Entrypoint and `DisputeManager::resolve_dispute` both authenticated `admin` | Entrypoint authenticates then checks primary-admin identity; `resolve_dispute_authorized` retains manager permission validation | +| `collect_fees` | Entrypoint authenticated `admin`; manager authenticated again in non-test builds | Entrypoint authenticates then checks primary-admin identity; `collect_fees_authorized` retains manager permission validation | + +There are zero active `#[ignore]` test attributes in `src/`; no previously +ignored tests could be restored from this checkout. +There are zero active `#[ignore]` test attributes in `src/`; the matches in +`bet_tests.rs` are comments, not ignored tests. +`upgrade_contract` and `rollback_upgrade` remain excluded as requested. + +For the contract's no-default-features compilation check, run: + +```sh +cargo check -p predictify-hybrid --no-default-features +``` From 56b07b1ebac43f5d750644486c96e623fe32e3d7 Mon Sep 17 00:00:00 2001 From: kikiola Date: Sat, 24 Oct 2026 08:47:53 +0100 Subject: [PATCH 02/10] =?UTF-8?q?fix(analytics):=20implement=20cross-contr?= =?UTF-8?q?act=20analytics=20fa=C3=A7ade?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Cargo.lock | 8 ++ contracts/predictify-analytics/Cargo.toml | 10 +- contracts/predictify-analytics/src/lib.rs | 121 ++++++++++++++++++++++ contracts/predictify-hybrid/src/lib.rs | 2 +- docs/TESTING.md | 26 +++-- docs/test-output/pr-1470-master-sync.md | 40 ++++--- scripts/build_analytics_wasm.sh | 29 ++++-- 7 files changed, 183 insertions(+), 53 deletions(-) create mode 100644 contracts/predictify-analytics/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index ff631709..714f1a2c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -997,6 +997,14 @@ dependencies = [ "zerocopy 0.8.24", ] +[[package]] +name = "predictify-analytics" +version = "0.1.0" +dependencies = [ + "predictify-hybrid", + "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..302f5075 100644 --- a/contracts/predictify-analytics/Cargo.toml +++ b/contracts/predictify-analytics/Cargo.toml @@ -9,17 +9,9 @@ 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..cde7135d --- /dev/null +++ b/contracts/predictify-analytics/src/lib.rs @@ -0,0 +1,121 @@ +#![no_std] + +use predictify_hybrid::market_analytics::{ + DisputeAnalytics, FeeAnalytics, MarketComparisonAnalytics, MarketStatistics, + OraclePerformanceStats, ParticipationMetrics, TimeFrame, VotingAnalytics, +}; +use predictify_hybrid::{OracleProvider, PredictifyHybridClient}; +use soroban_sdk::{contract, contractimpl, Address, Env, Symbol, Vec}; + +/// 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 predictify_hybrid::Error; + 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, + ) -> Result { + Ok(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/src/lib.rs b/contracts/predictify-hybrid/src/lib.rs index 55518c8b..61db4429 100644 --- a/contracts/predictify-hybrid/src/lib.rs +++ b/contracts/predictify-hybrid/src/lib.rs @@ -71,7 +71,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; diff --git a/docs/TESTING.md b/docs/TESTING.md index d4e7e80b..a436bcf3 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -1,11 +1,11 @@ # Testing status -After syncing with upstream master `49a4762`, workspace builds remain blocked by -the incomplete `predictify-analytics` package: its manifest declares a library -but the package has no `src/lib.rs`. See -[the synchronization report](test-output/pr-1470-master-sync.md) for the errors -and isolated hybrid validation. The #1463 results below predate the latest -upstream sync; 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. +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 @@ -13,15 +13,19 @@ 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 +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 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 index 2e711213..803a89f1 100644 --- a/scripts/build_analytics_wasm.sh +++ b/scripts/build_analytics_wasm.sh @@ -1,13 +1,22 @@ -#!/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" +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 From 1c3ad5cf0535432d1ac39555fd93227e3a540e62 Mon Sep 17 00:00:00 2001 From: kikiola Date: Sat, 24 Oct 2026 08:53:21 +0100 Subject: [PATCH 03/10] fix(markets): correct Soroban byte and map iteration Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/market_id_generator.rs | 28 +++++++++---------- 1 file changed, 13 insertions(+), 15 deletions(-) diff --git a/contracts/predictify-hybrid/src/market_id_generator.rs b/contracts/predictify-hybrid/src/market_id_generator.rs index f2871c63..280fd0fc 100644 --- a/contracts/predictify-hybrid/src/market_id_generator.rs +++ b/contracts/predictify-hybrid/src/market_id_generator.rs @@ -240,7 +240,7 @@ impl MarketIdGenerator { 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 { @@ -260,7 +260,7 @@ impl MarketIdGenerator { // Format: mkt_{8 hex}_{counter} // Need to find the position of the second underscore let mut underscore_count = 0; - let mut counter_start = 0usize; + let mut counter_start = 0u32; for i in 4..(4 + len) { if i >= xdr_bytes.len() { @@ -556,19 +556,17 @@ impl MarketIdGenerator { 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; } } From f2741afb4d2ce97e7ac9408e3ae36a12f7d52ec3 Mon Sep 17 00:00:00 2001 From: kikiola Date: Sat, 24 Oct 2026 08:57:41 +0100 Subject: [PATCH 04/10] test(hybrid): enable market id generator tests Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- contracts/predictify-hybrid/src/lib.rs | 3 +++ contracts/predictify-hybrid/src/market_id_generator.rs | 1 + 2 files changed, 4 insertions(+) diff --git a/contracts/predictify-hybrid/src/lib.rs b/contracts/predictify-hybrid/src/lib.rs index 61db4429..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; diff --git a/contracts/predictify-hybrid/src/market_id_generator.rs b/contracts/predictify-hybrid/src/market_id_generator.rs index 280fd0fc..ac97ec66 100644 --- a/contracts/predictify-hybrid/src/market_id_generator.rs +++ b/contracts/predictify-hybrid/src/market_id_generator.rs @@ -706,6 +706,7 @@ impl MarketIdGenerator { #[cfg(test)] mod tests { use super::*; + use crate::alloc::string::ToString; use soroban_sdk::testutils::{Address as _, Ledger, LedgerInfo}; // ── Helpers ────────────────────────────────────────────────────────────── From 4058b467a215c0cc6709549899da9fa171ffa6fa Mon Sep 17 00:00:00 2001 From: kikiola Date: Sat, 24 Oct 2026 09:00:31 +0100 Subject: [PATCH 05/10] test(markets): allow 500-entry registry stress test Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- contracts/predictify-hybrid/src/market_id_generator.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/contracts/predictify-hybrid/src/market_id_generator.rs b/contracts/predictify-hybrid/src/market_id_generator.rs index ac97ec66..ce30ed83 100644 --- a/contracts/predictify-hybrid/src/market_id_generator.rs +++ b/contracts/predictify-hybrid/src/market_id_generator.rs @@ -1249,6 +1249,7 @@ 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 From ff7927ff0f0ecfde68562a3567bebc463eb02921 Mon Sep 17 00:00:00 2001 From: kikiola Date: Sat, 24 Oct 2026 09:07:56 +0100 Subject: [PATCH 06/10] fix(ci): resolve hybrid contract test failures Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- contracts/predictify-hybrid/src/bets.rs | 8 ++- contracts/predictify-hybrid/src/disputes.rs | 2 +- .../src/fee_source_unification_tests.rs | 43 ++++++++----- contracts/predictify-hybrid/src/fees.rs | 2 + .../src/market_id_generator.rs | 61 +++++++++---------- .../src/single_frame_auth_tests.rs | 1 + contracts/predictify-hybrid/src/storage.rs | 2 +- 7 files changed, 68 insertions(+), 51 deletions(-) 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 ad24bbdd..07cf47f3 100644 --- a/contracts/predictify-hybrid/src/disputes.rs +++ b/contracts/predictify-hybrid/src/disputes.rs @@ -3705,7 +3705,7 @@ mod tests { #[test] fn test_dispute_stake_is_refunded_when_resolution_favors_disputer() { let env = Env::default(); - env.mock_all_auths(); + env.mock_all_auths_allowing_non_root_auth(); let admin = Address::generate(&env); let user = Address::generate(&env); 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 15881b94..d7c16334 100644 --- a/contracts/predictify-hybrid/src/fees.rs +++ b/contracts/predictify-hybrid/src/fees.rs @@ -2520,6 +2520,8 @@ 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_authorized(&env, admin.clone(), market_id.clone()); diff --git a/contracts/predictify-hybrid/src/market_id_generator.rs b/contracts/predictify-hybrid/src/market_id_generator.rs index ce30ed83..455e2d97 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. @@ -229,28 +226,27 @@ impl MarketIdGenerator { // 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), + 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), ]); - // Check for "mkt_" prefix - if len < 4 || xdr_bytes.len() < 8 { + 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 }); @@ -262,7 +258,7 @@ impl MarketIdGenerator { let mut underscore_count = 0; let mut counter_start = 0u32; - for i in 4..(4 + len) { + for i in 8..(8 + len) { if i >= xdr_bytes.len() { break; } @@ -275,13 +271,13 @@ 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; } @@ -713,6 +709,8 @@ mod tests { fn setup() -> (Env, Address, Address) { let env = Env::default(); + env.cost_estimate().disable_resource_limits(); + env.mock_all_auths_allowing_non_root_auth(); let contract_id = env.register(crate::PredictifyHybrid, ()); let admin = Address::generate(&env); (env, contract_id, admin) @@ -1249,7 +1247,6 @@ 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 diff --git a/contracts/predictify-hybrid/src/single_frame_auth_tests.rs b/contracts/predictify-hybrid/src/single_frame_auth_tests.rs index b0d978e2..d2fff1b7 100644 --- a/contracts/predictify-hybrid/src/single_frame_auth_tests.rs +++ b/contracts/predictify-hybrid/src/single_frame_auth_tests.rs @@ -6,6 +6,7 @@ use soroban_sdk::{ }; 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) diff --git a/contracts/predictify-hybrid/src/storage.rs b/contracts/predictify-hybrid/src/storage.rs index f69fe845..47bcbef1 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( From 0386514963fc5c16bcfdcc24aed606cbb0ee95a3 Mon Sep 17 00:00:00 2001 From: kikiola Date: Sat, 24 Oct 2026 09:17:18 +0100 Subject: [PATCH 07/10] fix(ci): resolve remaining hybrid test failures Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- contracts/predictify-hybrid/src/disputes.rs | 15 ++ .../src/market_id_generator.rs | 152 ++++++++++-------- .../src/single_frame_auth_tests.rs | 9 +- contracts/predictify-hybrid/src/storage.rs | 2 +- contracts/predictify-hybrid/src/voting.rs | 4 + 5 files changed, 110 insertions(+), 72 deletions(-) diff --git a/contracts/predictify-hybrid/src/disputes.rs b/contracts/predictify-hybrid/src/disputes.rs index 07cf47f3..840ff643 100644 --- a/contracts/predictify-hybrid/src/disputes.rs +++ b/contracts/predictify-hybrid/src/disputes.rs @@ -1175,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); @@ -1194,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() diff --git a/contracts/predictify-hybrid/src/market_id_generator.rs b/contracts/predictify-hybrid/src/market_id_generator.rs index 455e2d97..f130f3a9 100644 --- a/contracts/predictify-hybrid/src/market_id_generator.rs +++ b/contracts/predictify-hybrid/src/market_id_generator.rs @@ -307,11 +307,20 @@ impl MarketIdGenerator { 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); } } @@ -710,7 +719,7 @@ mod tests { fn setup() -> (Env, Address, Address) { let env = Env::default(); env.cost_estimate().disable_resource_limits(); - env.mock_all_auths_allowing_non_root_auth(); + env.mock_all_auths(); let contract_id = env.register(crate::PredictifyHybrid, ()); let admin = Address::generate(&env); (env, contract_id, admin) @@ -1247,6 +1256,7 @@ 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 @@ -1295,33 +1305,31 @@ mod tests { }); } - 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] @@ -1342,22 +1350,20 @@ mod tests { }); } - 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] @@ -1378,21 +1384,26 @@ mod tests { }); } - env.storage().persistent().set(&legacy_key, &legacy_registry); + env.storage() + .persistent() + .set(&legacy_key, &legacy_registry); + }); - // Partial migration + 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] @@ -1407,22 +1418,29 @@ mod tests { legacy_counters.set(admin.clone(), 42u32); legacy_counters.set(admin2.clone(), 17u32); - env.storage().persistent().set(&legacy_key, &legacy_counters); + env.storage() + .persistent() + .set(&legacy_key, &legacy_counters); + }); - // Migrate - let migrated = MarketIdGenerator::migrate_admin_counters(&env, &admin); - assert_eq!(migrated, 2); + let migrated = with_contract(&env, &contract_id, || { + MarketIdGenerator::migrate_admin_counters(&env, &admin) + }); + assert_eq!(migrated, 2); - // Verify counters are accessible via sharded storage + // 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] @@ -1430,8 +1448,8 @@ mod tests { 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(|| { diff --git a/contracts/predictify-hybrid/src/single_frame_auth_tests.rs b/contracts/predictify-hybrid/src/single_frame_auth_tests.rs index d2fff1b7..51f6cc75 100644 --- a/contracts/predictify-hybrid/src/single_frame_auth_tests.rs +++ b/contracts/predictify-hybrid/src/single_frame_auth_tests.rs @@ -125,9 +125,10 @@ fn vote_on_dispute_reaches_business_validation_with_one_auth_entry() { let market_id = Symbol::new(&env, "missing_market"); let dispute_id = Symbol::new(&env, "missing_dispute"); let vote = true; - let stake = 10_000_000i128; + let market_stake = 10_000_000i128; + let vote_stake = 0i128; let reason: Option = None; - setup_disputable_market(&env, &contract_id, &user, &market_id, stake); + 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(), @@ -145,7 +146,7 @@ fn vote_on_dispute_reaches_business_validation_with_one_auth_entry() { let invoke = MockAuthInvoke { contract: &contract_id, fn_name: "vote_on_dispute", - args: (&user, &market_id, &dispute_id, &vote, &stake, &reason).into_val(&env), + args: (&user, &market_id, &dispute_id, &vote, &vote_stake, &reason).into_val(&env), sub_invokes: &[], }; @@ -154,7 +155,7 @@ fn vote_on_dispute_reaches_business_validation_with_one_auth_entry() { address: &user, invoke: &invoke, }]) - .try_vote_on_dispute(&user, &market_id, &dispute_id, &vote, &stake, &reason); + .try_vote_on_dispute(&user, &market_id, &dispute_id, &vote, &vote_stake, &reason); assert_eq!(result, Err(Ok(Error::InvalidInput))); } diff --git a/contracts/predictify-hybrid/src/storage.rs b/contracts/predictify-hybrid/src/storage.rs index 47bcbef1..5dc6fdcf 100644 --- a/contracts/predictify-hybrid/src/storage.rs +++ b/contracts/predictify-hybrid/src/storage.rs @@ -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 70e8c5f9..959f2fc4 100644 --- a/contracts/predictify-hybrid/src/voting.rs +++ b/contracts/predictify-hybrid/src/voting.rs @@ -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 From 2d956706f4a3101a26fd63dc72c3930c7c87d23f Mon Sep 17 00:00:00 2001 From: kikiola Date: Sat, 24 Oct 2026 09:21:04 +0100 Subject: [PATCH 08/10] fix(ci): make analytics wasm script executable Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- scripts/build_analytics_wasm.sh | 0 1 file changed, 0 insertions(+), 0 deletions(-) mode change 100644 => 100755 scripts/build_analytics_wasm.sh diff --git a/scripts/build_analytics_wasm.sh b/scripts/build_analytics_wasm.sh old mode 100644 new mode 100755 From 17f9eb156ec01d353fc171c21ac8511de09fef2b Mon Sep 17 00:00:00 2001 From: kikiola Date: Sat, 24 Oct 2026 09:25:46 +0100 Subject: [PATCH 09/10] fix(ci): install analytics wasm target Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/TESTING.md | 1 + scripts/build_analytics_wasm.sh | 1 + 2 files changed, 2 insertions(+) diff --git a/docs/TESTING.md b/docs/TESTING.md index a436bcf3..f1435add 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -14,6 +14,7 @@ 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 diff --git a/scripts/build_analytics_wasm.sh b/scripts/build_analytics_wasm.sh index 803a89f1..4d6c17b9 100755 --- a/scripts/build_analytics_wasm.sh +++ b/scripts/build_analytics_wasm.sh @@ -6,6 +6,7 @@ MAX_SIZE_BYTES="${WASM_SIZE_BUDGET:-131072}" WASM_FILE="$ROOT_DIR/target/wasm32v1-none/release/predictify_analytics.wasm" cd "$ROOT_DIR" +rustup target add wasm32v1-none cargo build --release --target wasm32v1-none -p predictify-analytics if [[ ! -f "$WASM_FILE" ]]; then From 96351b4af72f2748917dca5b3865dee1c14e7f8c Mon Sep 17 00:00:00 2001 From: kikiola Date: Sat, 24 Oct 2026 09:32:27 +0100 Subject: [PATCH 10/10] fix(analytics): keep facade wasm within size budget Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Cargo.lock | 1 - contracts/predictify-analytics/Cargo.toml | 1 - contracts/predictify-analytics/src/lib.rs | 157 ++++++++++++++++++++-- docs/TESTING.md | 2 + 4 files changed, 147 insertions(+), 14 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 714f1a2c..cb49a319 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1001,7 +1001,6 @@ dependencies = [ name = "predictify-analytics" version = "0.1.0" dependencies = [ - "predictify-hybrid", "soroban-sdk", ] diff --git a/contracts/predictify-analytics/Cargo.toml b/contracts/predictify-analytics/Cargo.toml index 302f5075..7270344e 100644 --- a/contracts/predictify-analytics/Cargo.toml +++ b/contracts/predictify-analytics/Cargo.toml @@ -11,7 +11,6 @@ crate-type = ["cdylib", "rlib"] [dependencies] soroban-sdk = { workspace = true } -predictify-hybrid = { path = "../predictify-hybrid", features = ["full"] } [dev-dependencies] soroban-sdk = { workspace = true, features = ["testutils"] } diff --git a/contracts/predictify-analytics/src/lib.rs b/contracts/predictify-analytics/src/lib.rs index cde7135d..6f79e05a 100644 --- a/contracts/predictify-analytics/src/lib.rs +++ b/contracts/predictify-analytics/src/lib.rs @@ -1,11 +1,148 @@ #![no_std] -use predictify_hybrid::market_analytics::{ - DisputeAnalytics, FeeAnalytics, MarketComparisonAnalytics, MarketStatistics, - OraclePerformanceStats, ParticipationMetrics, TimeFrame, VotingAnalytics, +use soroban_sdk::{ + contract, contractclient, contractimpl, contracttype, Address, Env, Map, String, Symbol, Vec, }; -use predictify_hybrid::{OracleProvider, PredictifyHybridClient}; -use soroban_sdk::{contract, contractimpl, Address, Env, 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] @@ -74,7 +211,6 @@ impl PredictifyAnalytics { #[cfg(test)] mod tests { use super::*; - use predictify_hybrid::Error; use soroban_sdk::{contract, contractimpl, Env, Map, String}; #[contract] @@ -82,11 +218,8 @@ mod tests { #[contractimpl] impl MockCoreContract { - pub fn get_market_analytics_statistics( - env: Env, - market_id: Symbol, - ) -> Result { - Ok(MarketStatistics { + pub fn get_market_analytics_statistics(env: Env, market_id: Symbol) -> MarketStatistics { + MarketStatistics { market_id, total_participants: 3, total_stake: 120, @@ -99,7 +232,7 @@ mod tests { consensus_strength: 80, time_to_resolution: 600, resolution_method: String::from_str(&env, "oracle"), - }) + } } } diff --git a/docs/TESTING.md b/docs/TESTING.md index f1435add..977df851 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -4,6 +4,8 @@ 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.