diff --git a/contracts/predict-iq/benches/gas_benchmark.rs b/contracts/predict-iq/benches/gas_benchmark.rs index 5672f64..35416c3 100644 --- a/contracts/predict-iq/benches/gas_benchmark.rs +++ b/contracts/predict-iq/benches/gas_benchmark.rs @@ -8,10 +8,7 @@ #![cfg(test)] -use soroban_sdk::{ - testutils::Address as _, - token, Address, Env, String, Vec, -}; +use soroban_sdk::{testutils::Address as _, token, Address, Env, String, Vec}; extern crate predict_iq; use predict_iq::{PredictIQ, PredictIQClient}; @@ -147,7 +144,10 @@ fn bench_create_market_max_outcomes() { &0u64, &0u32, ); - assert!(result.is_ok(), "MAX_OUTCOMES_PER_MARKET market creation must succeed"); + assert!( + result.is_ok(), + "MAX_OUTCOMES_PER_MARKET market creation must succeed" + ); } #[test] @@ -165,7 +165,10 @@ fn bench_reject_excessive_outcomes() { &0u64, &0u32, ); - assert!(result.is_err(), "exceeding MAX_OUTCOMES_PER_MARKET must be rejected"); + assert!( + result.is_err(), + "exceeding MAX_OUTCOMES_PER_MARKET must be rejected" + ); } // ── Gas threshold assertions for dispute/payout flows ───────────────────────── @@ -377,7 +380,11 @@ fn bench_dispute_vote_single_participant() { gov_stellar.mint(&voter, &5_000); let result = client.try_cast_vote(&voter, &market_id, &0, &5_000); - assert!(result.is_ok(), "single-participant vote must succeed: {:?}", result); + assert!( + result.is_ok(), + "single-participant vote must succeed: {:?}", + result + ); } // ── Benchmark 3: vote on dispute (multiple participants) ───────────────────── @@ -406,12 +413,7 @@ fn bench_dispute_vote_multiple_participants() { // Split votes: even-indexed voters choose outcome 0, odd choose outcome 1. let outcome: u32 = if i % 2 == 0 { 0 } else { 1 }; let result = client.try_cast_vote(&voter, &market_id, &outcome, &1_000); - assert!( - result.is_ok(), - "vote {} must succeed: {:?}", - i, - result - ); + assert!(result.is_ok(), "vote {} must succeed: {:?}", i, result); } } @@ -444,7 +446,8 @@ fn bench_dispute_resolve() { client.cast_vote(&voter_b, &market_id, &1, &3_000); // Advance past the 72-hour voting period (dispute filed at timestamp 2_100). - env.ledger().with_mut(|li| li.timestamp = 2_100 + 259_200 + 1); + env.ledger() + .with_mut(|li| li.timestamp = 2_100 + 259_200 + 1); let result = client.try_finalize_resolution(&market_id); assert!( diff --git a/contracts/predict-iq/src/lib.rs b/contracts/predict-iq/src/lib.rs index 1c048b3..cc54435 100644 --- a/contracts/predict-iq/src/lib.rs +++ b/contracts/predict-iq/src/lib.rs @@ -3,10 +3,10 @@ use soroban_sdk::{contract, contractimpl, Address, Env, String, Vec}; mod errors; mod modules; -mod test; pub mod pyth_client; -pub mod types; +mod test; mod test_pyth_integration; +pub mod types; use crate::errors::ErrorCode; use crate::modules::admin; @@ -231,7 +231,12 @@ impl PredictIQ { crate::modules::fees::claim_referral_rewards(&e, &address, &token) } - pub fn set_oracle_result(e: Env, market_id: u64, oracle_id: u32, outcome: u32) -> Result<(), ErrorCode> { + pub fn set_oracle_result( + e: Env, + market_id: u64, + oracle_id: u32, + outcome: u32, + ) -> Result<(), ErrorCode> { crate::modules::admin::require_admin(&e)?; crate::modules::oracles::set_oracle_result(&e, market_id, oracle_id, outcome) } @@ -246,8 +251,8 @@ impl PredictIQ { /// Issue #508: Validate oracle staleness for a market pub fn validate_oracle_staleness(e: Env, market_id: u64) -> Result<(), ErrorCode> { - let market = crate::modules::markets::get_market(&e, market_id) - .ok_or(ErrorCode::MarketNotFound)?; + let market = + crate::modules::markets::get_market(&e, market_id).ok_or(ErrorCode::MarketNotFound)?; crate::modules::oracles::validate_oracle_staleness(&e, market_id, &market.oracle_config) } @@ -369,7 +374,11 @@ impl PredictIQ { crate::modules::governance::remove_guardian(&e, address) } - pub fn vote_on_guardian_removal(e: Env, voter: Address, approve: bool) -> Result<(), ErrorCode> { + pub fn vote_on_guardian_removal( + e: Env, + voter: Address, + approve: bool, + ) -> Result<(), ErrorCode> { crate::modules::governance::vote_on_guardian_removal(&e, voter, approve) } diff --git a/contracts/predict-iq/src/modules/bets.rs b/contracts/predict-iq/src/modules/bets.rs index 16455c0..0b439a9 100644 --- a/contracts/predict-iq/src/modules/bets.rs +++ b/contracts/predict-iq/src/modules/bets.rs @@ -1,6 +1,6 @@ use crate::errors::ErrorCode; use crate::modules::{markets, sac}; -use crate::types::{Bet, MarketStatus, BET_TTL_LOW_THRESHOLD, BET_TTL_HIGH_THRESHOLD}; +use crate::types::{Bet, MarketStatus, BET_TTL_HIGH_THRESHOLD, BET_TTL_LOW_THRESHOLD}; use soroban_sdk::{contracttype, Address, Env}; /// TTL Strategy for per-user bet records (Issue #100) @@ -30,7 +30,6 @@ use soroban_sdk::{contracttype, Address, Env}; /// Claimed(u64, Address) sentinel records use the same TTL so the /// AlreadyClaimed guard remains valid for the full prune grace period. - #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] pub enum DataKey { @@ -132,13 +131,26 @@ pub fn place_bet( }); // Store the net (post-fee) amount so the payout formula is always correct. - existing_bet.amount = existing_bet.amount.checked_add(net_amount).ok_or(ErrorCode::ArithmeticOverflow)?; + existing_bet.amount = existing_bet + .amount + .checked_add(net_amount) + .ok_or(ErrorCode::ArithmeticOverflow)?; existing_bet.fee_paid += fee; existing_bet.outcome = outcome; - market.total_staked = market.total_staked.checked_add(net_amount).ok_or(ErrorCode::ArithmeticOverflow)?; + market.total_staked = market + .total_staked + .checked_add(net_amount) + .ok_or(ErrorCode::ArithmeticOverflow)?; let outcome_stake = markets::get_outcome_stake(e, market_id, outcome); - markets::set_outcome_stake(e, market_id, outcome, outcome_stake.checked_add(net_amount).ok_or(ErrorCode::ArithmeticOverflow)?); + markets::set_outcome_stake( + e, + market_id, + outcome, + outcome_stake + .checked_add(net_amount) + .ok_or(ErrorCode::ArithmeticOverflow)?, + ); markets::increment_outcome_bet_count(e, market_id, outcome); // Issue #24: Maintain actual winner count per outcome @@ -271,10 +283,15 @@ pub fn claim_winnings(e: &Env, bettor: Address, market_id: u64) -> Result 0 { winning_outcome_stake } else { bet.amount }; - + let winning_outcome_stake = if winning_outcome_stake > 0 { + winning_outcome_stake + } else { + bet.amount + }; + // Issue #192: Use checked arithmetic to prevent overflow in high-inflation scenarios - let winnings = bet.amount + let winnings = bet + .amount .checked_mul(market.total_staked) .and_then(|product| product.checked_div(winning_outcome_stake)) .ok_or(ErrorCode::ArithmeticOverflow)?; @@ -364,4 +381,4 @@ pub fn set_minimum_bet_amount(e: &Env, amount: i128) -> Result<(), ErrorCode> { .persistent() .set(&crate::types::ConfigKey::MinimumBetAmount, &amount); Ok(()) -} \ No newline at end of file +} diff --git a/contracts/predict-iq/src/modules/cancellation.rs b/contracts/predict-iq/src/modules/cancellation.rs index 5b2c4d6..b2ec529 100644 --- a/contracts/predict-iq/src/modules/cancellation.rs +++ b/contracts/predict-iq/src/modules/cancellation.rs @@ -99,7 +99,11 @@ pub fn withdraw_refund( &deposit, )?; e.events().publish( - (Symbol::new(e, "deposit_refunded"), market_id, bettor.clone()), + ( + Symbol::new(e, "deposit_refunded"), + market_id, + bettor.clone(), + ), deposit, ); // If the creator also placed bets, fall through to refund those too. @@ -114,7 +118,10 @@ pub fn withdraw_refund( // Gross refund = net amount + fee that was deducted at bet time. // The bettor paid `amount` originally; the contract kept `fee_paid` as // protocol revenue. On cancellation both must be returned. - let refund_amount = bet.amount.checked_add(bet.fee_paid).ok_or(crate::errors::ErrorCode::ArithmeticOverflow)?; + let refund_amount = bet + .amount + .checked_add(bet.fee_paid) + .ok_or(crate::errors::ErrorCode::ArithmeticOverflow)?; let fee_paid = bet.fee_paid; e.storage().persistent().remove(&bet_key); @@ -123,8 +130,15 @@ pub fn withdraw_refund( // Reverse any referral reward that was credited when this bet was placed. // The referrer only earns rewards from markets that complete — not cancelled ones. - if let Some(referrer) = crate::modules::bets::get_bet_referrer(e, market_id, bettor.clone(), outcome) { - crate::modules::fees::reverse_referral_reward(e, &referrer, &market.token_address, fee_paid); + if let Some(referrer) = + crate::modules::bets::get_bet_referrer(e, market_id, bettor.clone(), outcome) + { + crate::modules::fees::reverse_referral_reward( + e, + &referrer, + &market.token_address, + fee_paid, + ); crate::modules::bets::remove_bet_referrer(e, market_id, &bettor, outcome); } diff --git a/contracts/predict-iq/src/modules/circuit_breaker.rs b/contracts/predict-iq/src/modules/circuit_breaker.rs index 04308c6..27689d6 100644 --- a/contracts/predict-iq/src/modules/circuit_breaker.rs +++ b/contracts/predict-iq/src/modules/circuit_breaker.rs @@ -31,7 +31,9 @@ pub fn set_state(e: &Env, state: CircuitBreakerState) -> Result<(), ErrorCode> { fn _set_state_internal(e: &Env, state: CircuitBreakerState) -> Result<(), ErrorCode> { match state { CircuitBreakerState::Open => { - e.storage().instance().set(&DataKey::OpenedAt, &e.ledger().timestamp()); + e.storage() + .instance() + .set(&DataKey::OpenedAt, &e.ledger().timestamp()); } CircuitBreakerState::HalfOpen => { e.storage().instance().set(&DataKey::HalfOpenOps, &0u32); @@ -71,11 +73,7 @@ pub fn maybe_recover(e: &Env) { return; } - let opened_at: u64 = e - .storage() - .instance() - .get(&DataKey::OpenedAt) - .unwrap_or(0); + let opened_at: u64 = e.storage().instance().get(&DataKey::OpenedAt).unwrap_or(0); if e.ledger().timestamp() >= opened_at + COOLDOWN_SECONDS { let _ = _set_state_internal(e, CircuitBreakerState::HalfOpen); @@ -86,17 +84,21 @@ pub fn require_closed(e: &Env) -> Result<(), ErrorCode> { maybe_recover(e); let state = get_state(e); match state { - CircuitBreakerState::Open | CircuitBreakerState::Paused => { - Err(ErrorCode::ContractPaused) - } + CircuitBreakerState::Open | CircuitBreakerState::Paused => Err(ErrorCode::ContractPaused), CircuitBreakerState::HalfOpen => { - let ops: u32 = e.storage().instance().get(&DataKey::HalfOpenOps).unwrap_or(0); + let ops: u32 = e + .storage() + .instance() + .get(&DataKey::HalfOpenOps) + .unwrap_or(0); if ops >= HALF_OPEN_MAX_OPS { // Probe limit exceeded — trip back to Open let _ = _set_state_internal(e, CircuitBreakerState::Open); return Err(ErrorCode::ContractPaused); } - e.storage().instance().set(&DataKey::HalfOpenOps, &(ops + 1)); + e.storage() + .instance() + .set(&DataKey::HalfOpenOps, &(ops + 1)); Ok(()) } CircuitBreakerState::Closed => Ok(()), @@ -182,7 +184,10 @@ mod threshold_tests { e.mock_all_auths(); setup_admin(&e); set_threshold(&e, 42).unwrap(); - let stored: Option = e.storage().instance().get(&ConfigKey::CircuitBreakerThreshold); + let stored: Option = e + .storage() + .instance() + .get(&ConfigKey::CircuitBreakerThreshold); assert_eq!(stored, Some(42)); } } diff --git a/contracts/predict-iq/src/modules/disputes_weight_test.rs b/contracts/predict-iq/src/modules/disputes_weight_test.rs index 884c433..194c3ba 100644 --- a/contracts/predict-iq/src/modules/disputes_weight_test.rs +++ b/contracts/predict-iq/src/modules/disputes_weight_test.rs @@ -10,17 +10,14 @@ /// - Snapshot ledger is set at dispute-filing time and is immutable /// - Fallback tokens are locked so the same tokens cannot be double-voted /// - Per-user LockedBalance tracking prevents pool drain - use crate::errors::ErrorCode; use crate::modules::{markets, voting}; -use crate::types::{ - ConfigKey, MarketStatus, MarketTier, OracleConfig, -}; +use crate::types::{ConfigKey, MarketStatus, MarketTier, OracleConfig}; +use crate::{PredictIQ, PredictIQClient}; use soroban_sdk::{ testutils::{Address as _, Ledger as _}, Address, Env, String, Vec, }; -use crate::{PredictIQ, PredictIQClient}; // ── Helpers ─────────────────────────────────────────────────────────────────── @@ -182,7 +179,7 @@ fn test_vote_revision_decrements_old_tally() { // Revision: subtract from outcome 1, add to outcome 0 inject_tally(&env, &contract_addr, market_id, 1, -1000); // decrement - inject_tally(&env, &contract_addr, market_id, 0, 1000); // increment + inject_tally(&env, &contract_addr, market_id, 0, 1000); // increment env.as_contract(&contract_addr, || { let tally_0 = voting::get_tally(&env, market_id, 0); diff --git a/contracts/predict-iq/src/modules/event_archive.rs b/contracts/predict-iq/src/modules/event_archive.rs index 003281d..9d89808 100644 --- a/contracts/predict-iq/src/modules/event_archive.rs +++ b/contracts/predict-iq/src/modules/event_archive.rs @@ -8,7 +8,7 @@ pub enum DataKey { /// Record a market ID as pruned (archived). /// -/// This provides a lightweight tombstone record so external indexers can +/// This provides a lightweight tombstone record so external indexers can /// recognize that a market's on-chain data has been deleted for gas optimization. pub fn archive_market(e: &Env, market_id: u64) { let mut count: u64 = e @@ -16,10 +16,14 @@ pub fn archive_market(e: &Env, market_id: u64) { .instance() .get(&DataKey::ArchivedMarketCount) .unwrap_or(0); - + count += 1; - e.storage().instance().set(&DataKey::ArchivedMarket(count), &market_id); - e.storage().instance().set(&DataKey::ArchivedMarketCount, &count); + e.storage() + .instance() + .set(&DataKey::ArchivedMarket(count), &market_id); + e.storage() + .instance() + .set(&DataKey::ArchivedMarketCount, &count); } /// Paginated retrieval of archived market IDs. @@ -35,18 +39,18 @@ pub fn get_archived_market_ids(e: &Env, offset: u32, limit: u32) -> Vec { .instance() .get(&DataKey::ArchivedMarketCount) .unwrap_or(0); - + let mut archived_vec = Vec::new(e); let start = (offset as u64).min(count); let end = (start + limit as u64).min(count); - + // IDs are stored using 1-based indexing for the archive map keys for i in (start + 1)..=(end) { if let Some(id) = e.storage().instance().get(&DataKey::ArchivedMarket(i)) { archived_vec.push_back(id); } } - + archived_vec } diff --git a/contracts/predict-iq/src/modules/events.rs b/contracts/predict-iq/src/modules/events.rs index b7464b3..aa433fe 100644 --- a/contracts/predict-iq/src/modules/events.rs +++ b/contracts/predict-iq/src/modules/events.rs @@ -144,28 +144,38 @@ pub fn emit_dispute_resolved(e: &Env, market_id: u64, resolver: Address, winning } pub fn emit_market_cancelled(e: &Env, market_id: u64, admin: Address) { - e.events() - .publish((symbol_short!("mkt_cncl"), market_id, admin), (EVENT_VERSION,)); + e.events().publish( + (symbol_short!("mkt_cncl"), market_id, admin), + (EVENT_VERSION,), + ); } pub fn emit_market_cancelled_vote(e: &Env, market_id: u64, resolver: Address) { - e.events() - .publish((symbol_short!("mk_cn_vt"), market_id, resolver), (EVENT_VERSION,)); + e.events().publish( + (symbol_short!("mk_cn_vt"), market_id, resolver), + (EVENT_VERSION,), + ); } pub fn emit_referral_reward(e: &Env, market_id: u64, referrer: Address, amount: i128) { - e.events() - .publish((symbol_short!("ref_rwrd"), market_id, referrer), (EVENT_VERSION, amount)); + e.events().publish( + (symbol_short!("ref_rwrd"), market_id, referrer), + (EVENT_VERSION, amount), + ); } pub fn emit_referral_claimed(e: &Env, market_id: u64, claimer: Address, amount: i128) { - e.events() - .publish((symbol_short!("ref_claim"), market_id, claimer), (EVENT_VERSION, amount)); + e.events().publish( + (symbol_short!("ref_claim"), market_id, claimer), + (EVENT_VERSION, amount), + ); } pub fn emit_referral_distribution(e: &Env, market_id: u64, token: Address) { - e.events() - .publish((symbol_short!("ref_dist"), market_id, token), (EVENT_VERSION,)); + e.events().publish( + (symbol_short!("ref_dist"), market_id, token), + (EVENT_VERSION,), + ); } pub fn emit_circuit_breaker_auto(e: &Env, contract_address: Address, error_count: u32) { @@ -176,8 +186,10 @@ pub fn emit_circuit_breaker_auto(e: &Env, contract_address: Address, error_count } pub fn emit_fee_collected(e: &Env, _market_id: u64, contract_address: Address, amount: i128) { - e.events() - .publish((symbol_short!("fee_colct"), 0u64, contract_address), (EVENT_VERSION, amount)); + e.events().publish( + (symbol_short!("fee_colct"), 0u64, contract_address), + (EVENT_VERSION, amount), + ); } /// Issue #63: Emit AdminFallbackResolution event @@ -215,7 +227,11 @@ pub fn emit_monitoring_state_reset( ) { e.events().publish( (symbol_short!("mon_reset"), resetter), - (EVENT_VERSION, previous_error_count, previous_last_observation), + ( + EVENT_VERSION, + previous_error_count, + previous_last_observation, + ), ); } @@ -248,10 +264,8 @@ pub fn emit_upgrade_executed(e: &Env, executor: Address, wasm_hash: soroban_sdk: } pub fn emit_upgrade_rejected(e: &Env, wasm_hash: soroban_sdk::BytesN<32>) { - e.events().publish( - (symbol_short!("upg_rej"),), - (EVENT_VERSION, wasm_hash), - ); + e.events() + .publish((symbol_short!("upg_rej"),), (EVENT_VERSION, wasm_hash)); } /// Issue #506: Emit MarketStateChanged event for indexing diff --git a/contracts/predict-iq/src/modules/fees.rs b/contracts/predict-iq/src/modules/fees.rs index eb6d91f..95e9fd0 100644 --- a/contracts/predict-iq/src/modules/fees.rs +++ b/contracts/predict-iq/src/modules/fees.rs @@ -58,10 +58,10 @@ fn require_fee_withdraw_auth(e: &Env) -> Result<(), ErrorCode> { pub fn calculate_fee(e: &Env, amount: i128) -> Result { let base_fee = get_base_fee(e); - let numerator = amount - .checked_mul(base_fee) - .ok_or(ErrorCode::Overflow)?; - numerator.checked_div(BPS_DENOMINATOR).ok_or(ErrorCode::Overflow) + let numerator = amount.checked_mul(base_fee).ok_or(ErrorCode::Overflow)?; + numerator + .checked_div(BPS_DENOMINATOR) + .ok_or(ErrorCode::Overflow) } fn tier_multiplier_bps(tier: &MarketTier) -> i128 { @@ -72,7 +72,11 @@ fn tier_multiplier_bps(tier: &MarketTier) -> i128 { } } -fn calculate_tiered_fee_with_base(amount: i128, base_fee_bps: i128, tier: &MarketTier) -> Result { +fn calculate_tiered_fee_with_base( + amount: i128, + base_fee_bps: i128, + tier: &MarketTier, +) -> Result { // Single-pass high-precision arithmetic: amount * base_fee_bps * tier_multiplier / (10_000 * 10_000) // This avoids early truncation from computing discounted base_fee first. let numerator = amount @@ -136,8 +140,11 @@ pub fn withdraw_protocol_fees( // Zero out the balance before the transfer (checks-effects-interactions). e.storage().persistent().set(&key, &0i128); - soroban_sdk::token::Client::new(e, token) - .transfer(&e.current_contract_address(), recipient, &balance); + soroban_sdk::token::Client::new(e, token).transfer( + &e.current_contract_address(), + recipient, + &balance, + ); e.events().publish( (Symbol::new(e, "fees_withdrawn"), recipient.clone()), @@ -148,7 +155,12 @@ pub fn withdraw_protocol_fees( } /// Issue #1: Referral reward keyed by (referrer, token) to prevent cross-asset mixing. -pub fn add_referral_reward(e: &Env, referrer: &Address, token: &Address, fee_amount: i128) -> Result<(), ErrorCode> { +pub fn add_referral_reward( + e: &Env, + referrer: &Address, + token: &Address, + fee_amount: i128, +) -> Result<(), ErrorCode> { let reward = fee_amount .checked_mul(10) .and_then(|n| n.checked_div(100)) @@ -195,9 +207,10 @@ pub fn reverse_fee(e: &Env, token: Address, amount: i128) { .persistent() .get(&DataKey::TotalFeesCollected) .unwrap_or(0); - e.storage() - .persistent() - .set(&DataKey::TotalFeesCollected, &overall.saturating_sub(amount)); + e.storage().persistent().set( + &DataKey::TotalFeesCollected, + &overall.saturating_sub(amount), + ); } /// Issue #1: Claim referral rewards for a specific token only. @@ -227,15 +240,11 @@ pub fn claim_referral_rewards( /// Issue #511: Distribute referral fees on market resolution /// Called during market resolution to distribute accumulated referral rewards -pub fn distribute_referral_fees( - e: &Env, - market_id: u64, - token: &Address, -) -> Result<(), ErrorCode> { +pub fn distribute_referral_fees(e: &Env, market_id: u64, token: &Address) -> Result<(), ErrorCode> { // Get all referrers for this market and distribute their accumulated rewards // This is a placeholder that would iterate through referrers in production // For now, the rewards are already tracked in ReferrerBalance and can be claimed - + crate::modules::events::emit_referral_distribution(e, market_id, token.clone()); Ok(()) } @@ -259,7 +268,8 @@ mod tests { fn tiered_fee_uses_expected_discount_ratio() { let basic_fee = calculate_tiered_fee_with_base(10_000, 100, &MarketTier::Basic).unwrap(); let pro_fee = calculate_tiered_fee_with_base(10_000, 100, &MarketTier::Pro).unwrap(); - let inst_fee = calculate_tiered_fee_with_base(10_000, 100, &MarketTier::Institutional).unwrap(); + let inst_fee = + calculate_tiered_fee_with_base(10_000, 100, &MarketTier::Institutional).unwrap(); assert_eq!(basic_fee, 100); assert_eq!(pro_fee, 75); @@ -278,18 +288,18 @@ mod tests { #[test] fn max_i128_amount_returns_overflow_error() { let result = calculate_tiered_fee_with_base(i128::MAX, 10_000, &MarketTier::Basic); - assert!(result.is_err(), "i128::MAX * 10_000 must overflow and return Err"); + assert!( + result.is_err(), + "i128::MAX * 10_000 must overflow and return Err" + ); } } #[cfg(test)] mod withdrawal_tests { - use crate::{PredictIQ, PredictIQClient}; - use soroban_sdk::{ - testutils::Address as _, - token, Address, Env, - }; use crate::errors::ErrorCode; + use crate::{PredictIQ, PredictIQClient}; + use soroban_sdk::{testutils::Address as _, token, Address, Env}; fn setup() -> (Env, PredictIQClient<'static>, Address, Address, Address) { let env = Env::default(); @@ -306,8 +316,7 @@ mod withdrawal_tests { let token_address = token_id.address(); // Seed the contract with tokens so it can pay out fees - token::StellarAssetClient::new(&env, &token_address) - .mint(&contract_id, &1_000_000); + token::StellarAssetClient::new(&env, &token_address).mint(&contract_id, &1_000_000); (env, client, admin, token_address, contract_id) } @@ -335,10 +344,7 @@ mod withdrawal_tests { assert_eq!(withdrawn, 500_000); assert_eq!(client.get_revenue(&token), 0); - assert_eq!( - token::Client::new(&env, &token).balance(&treasury), - 500_000 - ); + assert_eq!(token::Client::new(&env, &token).balance(&treasury), 500_000); } #[test] diff --git a/contracts/predict-iq/src/modules/governance.rs b/contracts/predict-iq/src/modules/governance.rs index 22b76af..08ef8be 100644 --- a/contracts/predict-iq/src/modules/governance.rs +++ b/contracts/predict-iq/src/modules/governance.rs @@ -1,7 +1,7 @@ use crate::errors::ErrorCode; use crate::types::{ - ConfigKey, Guardian, PendingUpgrade, TTL_HIGH_THRESHOLD, TTL_LOW_THRESHOLD, - MAJORITY_THRESHOLD_PERCENT, TIMELOCK_DURATION, TIMELOCK_MIN_SECONDS, TIMELOCK_MAX_SECONDS, + ConfigKey, Guardian, PendingUpgrade, MAJORITY_THRESHOLD_PERCENT, TIMELOCK_DURATION, + TIMELOCK_MAX_SECONDS, TIMELOCK_MIN_SECONDS, TTL_HIGH_THRESHOLD, TTL_LOW_THRESHOLD, UPGRADE_COOLDOWN_DURATION, }; use soroban_sdk::{Address, BytesN, Env, Vec}; @@ -82,7 +82,7 @@ pub fn remove_guardian(e: &Env, address: Address) -> Result<(), ErrorCode> { crate::modules::admin::require_admin(e)?; let guardians = get_guardians(e); - + // Check if guardian exists let mut found = false; for g in guardians.iter() { @@ -91,7 +91,7 @@ pub fn remove_guardian(e: &Env, address: Address) -> Result<(), ErrorCode> { break; } } - + if !found { return Err(ErrorCode::GuardianNotSet); } @@ -116,7 +116,7 @@ pub fn remove_guardian(e: &Env, address: Address) -> Result<(), ErrorCode> { /// Vote on a pending guardian removal. Requires majority of other guardians. pub fn vote_on_guardian_removal(e: &Env, voter: Address, approve: bool) -> Result<(), ErrorCode> { let guardians = get_guardians(e); - + // Verify voter is a guardian let mut voter_is_guardian = false; for g in guardians.iter() { @@ -125,12 +125,13 @@ pub fn vote_on_guardian_removal(e: &Env, voter: Address, approve: bool) -> Resul break; } } - + if !voter_is_guardian { return Err(ErrorCode::NotAuthorized); } - let mut pending_removal = e.storage() + let mut pending_removal = e + .storage() .persistent() .get::<_, crate::types::PendingGuardianRemoval>(&ConfigKey::PendingGuardianRemoval) .ok_or(ErrorCode::GuardianNotSet)?; @@ -149,7 +150,7 @@ pub fn vote_on_guardian_removal(e: &Env, voter: Address, approve: bool) -> Resul // Calculate if majority reached (excluding target guardian) let other_guardians_count = guardians.len() as u32 - 1; let votes_needed = (other_guardians_count * MAJORITY_THRESHOLD_PERCENT) / 100 + 1; - + if pending_removal.votes_for.len() as u32 >= votes_needed && get_guardian_removal_passed_at(e).is_none() { @@ -168,7 +169,8 @@ pub fn vote_on_guardian_removal(e: &Env, voter: Address, approve: bool) -> Resul pub fn execute_guardian_removal(e: &Env) -> Result<(), ErrorCode> { crate::modules::admin::require_admin(e)?; - let pending_removal = e.storage() + let pending_removal = e + .storage() .persistent() .get::<_, crate::types::PendingGuardianRemoval>(&ConfigKey::PendingGuardianRemoval) .ok_or(ErrorCode::GuardianNotSet)?; @@ -217,9 +219,10 @@ fn get_guardian_removal_passed_at(e: &Env) -> Option { } fn set_guardian_removal_passed_at(e: &Env) { - e.storage() - .persistent() - .set(&ConfigKey::PendingGuardianRemovalPassedAt, &e.ledger().timestamp()); + e.storage().persistent().set( + &ConfigKey::PendingGuardianRemovalPassedAt, + &e.ledger().timestamp(), + ); bump_gov_ttl(e, &ConfigKey::PendingGuardianRemovalPassedAt); } diff --git a/contracts/predict-iq/src/modules/markets.rs b/contracts/predict-iq/src/modules/markets.rs index 1fea5d5..56ddfa5 100644 --- a/contracts/predict-iq/src/modules/markets.rs +++ b/contracts/predict-iq/src/modules/markets.rs @@ -1,5 +1,8 @@ use crate::errors::ErrorCode; -use crate::types::{ConfigKey, CreatorReputation, Market, MarketStatus, MarketTier, OracleConfig, TTL_LOW_THRESHOLD, TTL_HIGH_THRESHOLD, PRUNE_GRACE_PERIOD}; +use crate::types::{ + ConfigKey, CreatorReputation, Market, MarketStatus, MarketTier, OracleConfig, + PRUNE_GRACE_PERIOD, TTL_HIGH_THRESHOLD, TTL_LOW_THRESHOLD, +}; use soroban_sdk::{contracttype, token, Address, Env, String, Vec}; #[contracttype] @@ -192,7 +195,7 @@ pub fn create_market_with_dispute_window( if creation_fee > 0 { let treasury = get_protocol_treasury(e); token_client.transfer(&creator, &treasury, &creation_fee); - + // Emit fee collection event crate::modules::events::emit_fee_collected(e, 0, treasury, creation_fee); } @@ -210,10 +213,8 @@ pub fn create_market_with_dispute_window( count += 1; let num_outcomes = options.len() as u32; - let dispute_window = crate::modules::resolution::resolve_market_dispute_window( - e, - dispute_window_seconds, - )?; + let dispute_window = + crate::modules::resolution::resolve_market_dispute_window(e, dispute_window_seconds)?; let market = Market { id: count, @@ -248,14 +249,18 @@ pub fn create_market_with_dispute_window( e.storage() .persistent() .set(&DataKey::MarketDisputeWindow(count), &dispute_window); - + // Set initial TTL for the market data - e.storage() - .persistent() - .extend_ttl(&DataKey::Market(count), TTL_LOW_THRESHOLD, TTL_HIGH_THRESHOLD); - e.storage() - .persistent() - .extend_ttl(&DataKey::MarketDisputeWindow(count), TTL_LOW_THRESHOLD, TTL_HIGH_THRESHOLD); + e.storage().persistent().extend_ttl( + &DataKey::Market(count), + TTL_LOW_THRESHOLD, + TTL_HIGH_THRESHOLD, + ); + e.storage().persistent().extend_ttl( + &DataKey::MarketDisputeWindow(count), + TTL_LOW_THRESHOLD, + TTL_HIGH_THRESHOLD, + ); // Maintain status index so get_markets_by_status can probe O(limit) keys. e.storage() @@ -422,9 +427,11 @@ pub fn release_creation_deposit( /// Bump TTL for market data to prevent state expiration pub fn bump_market_ttl(e: &Env, market_id: u64) { - e.storage() - .persistent() - .extend_ttl(&DataKey::Market(market_id), TTL_LOW_THRESHOLD, TTL_HIGH_THRESHOLD); + e.storage().persistent().extend_ttl( + &DataKey::Market(market_id), + TTL_LOW_THRESHOLD, + TTL_HIGH_THRESHOLD, + ); } /// Maximum number of markets returned per paginated query @@ -449,7 +456,7 @@ pub fn prune_market(e: &Env, market_id: u64) -> Result<(), ErrorCode> { // Check if 30 days have passed since resolution let resolved_at = market.resolved_at.ok_or(ErrorCode::MarketNotActive)?; let current_time = e.ledger().timestamp(); - + if current_time < resolved_at + PRUNE_GRACE_PERIOD { return Err(ErrorCode::MarketNotActive); } diff --git a/contracts/predict-iq/src/modules/markets_conditional_test.rs b/contracts/predict-iq/src/modules/markets_conditional_test.rs index 5bf3b83..3e202d2 100644 --- a/contracts/predict-iq/src/modules/markets_conditional_test.rs +++ b/contracts/predict-iq/src/modules/markets_conditional_test.rs @@ -5,10 +5,7 @@ use crate::errors::ErrorCode; use crate::modules::markets; use crate::types::{CreatorReputation, MarketStatus, MarketTier, OracleConfig}; use crate::{PredictIQ, PredictIQClient}; -use soroban_sdk::{ - testutils::Address as _, - Address, Env, String, Vec, -}; +use soroban_sdk::{testutils::Address as _, Address, Env, String, Vec}; fn setup() -> (Env, PredictIQClient<'static>, Address, Address) { let env = Env::default(); @@ -357,7 +354,11 @@ fn test_tier_institutional_reputation_all_tiers() { client.set_creator_reputation(&creator, &CreatorReputation::Institutional); let token = Address::generate(&env); - for tier in [MarketTier::Basic, MarketTier::Pro, MarketTier::Institutional] { + for tier in [ + MarketTier::Basic, + MarketTier::Pro, + MarketTier::Institutional, + ] { client.create_market( &creator, &String::from_str(&env, "Market"), diff --git a/contracts/predict-iq/src/modules/migration.rs b/contracts/predict-iq/src/modules/migration.rs index d88c1e5..9ff93d5 100644 --- a/contracts/predict-iq/src/modules/migration.rs +++ b/contracts/predict-iq/src/modules/migration.rs @@ -35,7 +35,7 @@ pub fn execute_migration( restore_storage_state(e, from_version)?; return Err(ErrorCode::MigrationValidationError); } - + // Record successful migration record_migration(e, from_version, to_version)?; Ok(()) @@ -52,18 +52,16 @@ pub fn execute_migration( fn backup_storage_state(e: &Env, version: u32) -> Result<(), ErrorCode> { let backup_key = format!("migration:backup:v{}", version); let timestamp = e.ledger().timestamp(); - - e.storage() - .persistent() - .set(&backup_key, ×tamp); - + + e.storage().persistent().set(&backup_key, ×tamp); + Ok(()) } /// Restore storage state from backup fn restore_storage_state(e: &Env, version: u32) -> Result<(), ErrorCode> { let backup_key = format!("migration:backup:v{}", version); - + if !e.storage().persistent().has(&backup_key) { return Err(ErrorCode::NotAuthorized); } @@ -76,13 +74,11 @@ fn restore_storage_state(e: &Env, version: u32) -> Result<(), ErrorCode> { fn record_migration(e: &Env, from_version: u32, to_version: u32) -> Result<(), ErrorCode> { let migration_log_key = "migration:log"; let timestamp = e.ledger().timestamp(); - + let entry = format!("v{}->v{}@{}", from_version, to_version, timestamp); - - e.storage() - .persistent() - .set(&migration_log_key, &entry); - + + e.storage().persistent().set(&migration_log_key, &entry); + Ok(()) } @@ -91,10 +87,7 @@ fn record_migration(e: &Env, from_version: u32, to_version: u32) -> Result<(), E /// Returns Ok(true) if all invariants pass, Ok(false) if validation fails. pub fn verify_migration_integrity(e: &Env) -> Result { // Check critical storage keys exist - let required_keys = vec![ - ConfigKey::Admin, - ConfigKey::GuardianSet, - ]; + let required_keys = vec![ConfigKey::Admin, ConfigKey::GuardianSet]; for key in required_keys.iter() { if !e.storage().persistent().has(key) { @@ -119,7 +112,9 @@ pub fn validate_stake_invariant(e: &Env, market_id: u64) -> Result Resul } /// Reverse a migration to previous version -pub fn reverse_migration( - e: &Env, - from_version: u32, - to_version: u32, -) -> Result<(), ErrorCode> { +pub fn reverse_migration(e: &Env, from_version: u32, to_version: u32) -> Result<(), ErrorCode> { if from_version >= to_version { return Err(ErrorCode::NotAuthorized); } @@ -174,52 +165,39 @@ mod tests { #[test] fn test_migration_version_validation() { // Version must progress forward - let result = execute_migration( - &soroban_sdk::Env::default(), - 2, - 1, - |_| Ok(()), - ); + let result = execute_migration(&soroban_sdk::Env::default(), 2, 1, |_| Ok(())); assert!(result.is_err()); } #[test] fn test_migration_with_rollback() { let env = soroban_sdk::Env::default(); - - let result = execute_migration( - &env, - 1, - 2, - |_| Err(ErrorCode::NotAuthorized), - ); - + + let result = execute_migration(&env, 1, 2, |_| Err(ErrorCode::NotAuthorized)); + assert!(result.is_err()); } #[test] fn test_migration_validation_failure_rolls_back() { use soroban_sdk::Address; - + let env = soroban_sdk::Env::default(); let admin = Address::generate(&env); - + env.storage().persistent().set(&ConfigKey::Admin, &admin); - env.storage().persistent().set(&ConfigKey::GuardianSet, &admin); + env.storage() + .persistent() + .set(&ConfigKey::GuardianSet, &admin); // Migration that removes admin key (invalidates state) - let result = execute_migration( - &env, - 1, - 2, - |_e| { - _e.storage().persistent().remove(&ConfigKey::Admin); - Ok(()) - }, - ); + let result = execute_migration(&env, 1, 2, |_e| { + _e.storage().persistent().remove(&ConfigKey::Admin); + Ok(()) + }); assert!(result.is_err()); assert_eq!(result.unwrap_err(), ErrorCode::MigrationValidationError); assert!(env.storage().persistent().has(&ConfigKey::Admin)); } -} \ No newline at end of file +} diff --git a/contracts/predict-iq/src/modules/mod.rs b/contracts/predict-iq/src/modules/mod.rs index 2167fa6..4309bb8 100644 --- a/contracts/predict-iq/src/modules/mod.rs +++ b/contracts/predict-iq/src/modules/mod.rs @@ -1,10 +1,9 @@ pub mod admin; -pub mod queries; -pub mod event_archive; pub mod bets; pub mod cancellation; pub mod circuit_breaker; pub mod disputes; +pub mod event_archive; pub mod events; pub mod fees; pub mod governance; @@ -12,6 +11,7 @@ pub mod markets; pub mod migration; pub mod monitoring; pub mod oracles; +pub mod queries; pub mod resolution; pub mod sac; pub mod voting; diff --git a/contracts/predict-iq/src/modules/monitoring.rs b/contracts/predict-iq/src/modules/monitoring.rs index 59404da..1a4d14f 100644 --- a/contracts/predict-iq/src/modules/monitoring.rs +++ b/contracts/predict-iq/src/modules/monitoring.rs @@ -33,8 +33,7 @@ pub fn track_error(e: &Env) { &crate::types::CircuitBreakerState::Open, ); - e.events() - .publish((symbol_short!("cb_auto"),), count); + e.events().publish((symbol_short!("cb_auto"),), count); } } @@ -65,12 +64,12 @@ pub fn reset_monitoring(e: &Env) { /// Track and emit storage entry count as a contract event. /// This helps monitor Soroban rent costs for storage. -/// +/// /// Returns the current storage entry count. /// Emits a `storage_count` event when the count changes significantly. pub fn track_storage_count(e: &Env) -> u32 { let count = e.storage().persistent().count(); - + // Emit event periodically to track storage costs if count >= STORAGE_ALERT_THRESHOLD { e.events().publish( @@ -78,18 +77,15 @@ pub fn track_storage_count(e: &Env) -> u32 { (count, STORAGE_ALERT_THRESHOLD), ); } - + count } /// Emit storage count event for monitoring purposes pub fn emit_storage_metrics(e: &Env) { let count = track_storage_count(e); - - e.events().publish( - (symbol_short!("storage"),), - count, - ); + + e.events().publish((symbol_short!("storage"),), count); } /// Clean up expired/resolved market data to reduce storage costs. @@ -97,7 +93,7 @@ pub fn emit_storage_metrics(e: &Env) { pub fn cleanup_expired_market_index(e: &Env, market_id: u64) -> Result<(), ErrorCode> { use crate::modules::markets::DataKey as MarketDataKey; use crate::types::MarketStatus; - + // Check if market is resolved and past prune grace period if let Some(market) = crate::modules::markets::get_market(e, market_id) { if market.status == MarketStatus::Resolved { @@ -105,19 +101,27 @@ pub fn cleanup_expired_market_index(e: &Env, market_id: u64) -> Result<(), Error let current_time = e.ledger().timestamp(); if current_time >= resolved_at + crate::types::PRUNE_GRACE_PERIOD { // Remove status index entry - main market record will be pruned separately - e.storage().persistent().remove(&MarketDataKey::StatusIndex(market_id, MarketStatus::Resolved)); + e.storage().persistent().remove(&MarketDataKey::StatusIndex( + market_id, + MarketStatus::Resolved, + )); } } } } - + Ok(()) } #[cfg(test)] mod tests { - use super::{reset_monitoring, track_error, DataKey, track_storage_count, STORAGE_ALERT_THRESHOLD}; - use soroban_sdk::{testutils::{Events, Ledger}, Env}; + use super::{ + reset_monitoring, track_error, track_storage_count, DataKey, STORAGE_ALERT_THRESHOLD, + }; + use soroban_sdk::{ + testutils::{Events, Ledger}, + Env, + }; #[test] fn reset_monitoring_clears_error_trackers() { @@ -127,7 +131,11 @@ mod tests { track_error(&e); track_error(&e); - let before_count: u32 = e.storage().instance().get(&DataKey::ErrorCount).unwrap_or(0); + let before_count: u32 = e + .storage() + .instance() + .get(&DataKey::ErrorCount) + .unwrap_or(0); let before_obs: u64 = e .storage() .instance() @@ -138,7 +146,11 @@ mod tests { reset_monitoring(&e); - let after_count: u32 = e.storage().instance().get(&DataKey::ErrorCount).unwrap_or(1); + let after_count: u32 = e + .storage() + .instance() + .get(&DataKey::ErrorCount) + .unwrap_or(1); let after_obs: u64 = e .storage() .instance() @@ -174,4 +186,4 @@ mod tests { let count = track_storage_count(&e); assert!(count >= 0); } -} \ No newline at end of file +} diff --git a/contracts/predict-iq/src/modules/oracles.rs b/contracts/predict-iq/src/modules/oracles.rs index dc04d5c..b4e16fc 100644 --- a/contracts/predict-iq/src/modules/oracles.rs +++ b/contracts/predict-iq/src/modules/oracles.rs @@ -22,7 +22,10 @@ pub struct PythPrice { } /// Decode a 64-char hex feed_id string into a 32-byte BytesN<32>. -fn decode_feed_id(e: &Env, feed_id: &soroban_sdk::String) -> Result, ErrorCode> { +fn decode_feed_id( + e: &Env, + feed_id: &soroban_sdk::String, +) -> Result, ErrorCode> { if feed_id.len() != 64 { return Err(ErrorCode::OracleFailure); } @@ -50,7 +53,12 @@ pub fn fetch_pyth_price(e: &Env, config: &OracleConfig) -> Result Result<(), ErrorCode> { @@ -104,7 +112,12 @@ pub fn validate_oracle_staleness( } } -pub fn resolve_with_pyth(e: &Env, market_id: u64, oracle_id: u32, config: &OracleConfig) -> Result { +pub fn resolve_with_pyth( + e: &Env, + market_id: u64, + oracle_id: u32, + config: &OracleConfig, +) -> Result { let price = fetch_pyth_price(e, config)?; validate_price(e, &price, config)?; @@ -121,7 +134,11 @@ pub fn resolve_with_pyth(e: &Env, market_id: u64, oracle_id: u32, config: &Oracl ); e.events().publish( - (symbol_short!("oracle_ok"), market_id, config.oracle_address.clone()), + ( + symbol_short!("oracle_ok"), + market_id, + config.oracle_address.clone(), + ), (outcome, price.price, price.conf), ); @@ -130,7 +147,11 @@ pub fn resolve_with_pyth(e: &Env, market_id: u64, oracle_id: u32, config: &Oracl fn determine_outcome(price: &PythPrice, config: &OracleConfig) -> u32 { let threshold = config.strike_price.unwrap_or(0); - if price.price >= threshold { 0 } else { 1 } + if price.price >= threshold { + 0 + } else { + 1 + } } pub fn get_oracle_result(e: &Env, market_id: u64, oracle_id: u32) -> Option { @@ -145,7 +166,12 @@ pub fn get_last_update(e: &Env, market_id: u64, oracle_id: u32) -> Option { .get(&OracleData::LastUpdate(market_id, oracle_id as u64)) } -pub fn set_oracle_result(e: &Env, market_id: u64, oracle_id: u32, outcome: u32) -> Result<(), ErrorCode> { +pub fn set_oracle_result( + e: &Env, + market_id: u64, + oracle_id: u32, + outcome: u32, +) -> Result<(), ErrorCode> { e.storage() .persistent() .set(&OracleData::Result(market_id, oracle_id), &outcome); diff --git a/contracts/predict-iq/src/modules/queries.rs b/contracts/predict-iq/src/modules/queries.rs index 6333925..57b6b85 100644 --- a/contracts/predict-iq/src/modules/queries.rs +++ b/contracts/predict-iq/src/modules/queries.rs @@ -1,5 +1,5 @@ -use crate::types::{Market, MarketStatus, Guardian}; -use crate::modules::{markets, governance}; +use crate::modules::{governance, markets}; +use crate::types::{Guardian, Market, MarketStatus}; use soroban_sdk::{Env, Vec}; /// Hard cap on the number of records returned by any single paginated query. @@ -43,7 +43,12 @@ pub fn get_markets(e: &Env, offset: u32, limit: u32) -> Vec { /// * `status` - The status to filter by (e.g., Active, Resolved) /// * `offset` - Starting element in the filtered list /// * `limit` - Maximum number of markets to return; clamped to [`MAX_PAGE_LIMIT`] -pub fn get_markets_by_status(e: &Env, status: MarketStatus, offset: u32, limit: u32) -> Vec { +pub fn get_markets_by_status( + e: &Env, + status: MarketStatus, + offset: u32, + limit: u32, +) -> Vec { let limit = limit.min(MAX_PAGE_LIMIT); let count: u64 = e .storage() @@ -96,8 +101,8 @@ pub fn get_guardians_paginated(e: &Env, offset: u32, limit: u32) -> Vec (Env, PredictIQClient<'static>, Address, Address) { @@ -112,7 +117,8 @@ mod tests { } fn make_market(e: &Env, client: &PredictIQClient, creator: &Address) -> u64 { - let options = SdkVec::from_array(e, [String::from_str(e, "Yes"), String::from_str(e, "No")]); + let options = + SdkVec::from_array(e, [String::from_str(e, "Yes"), String::from_str(e, "No")]); let token = Address::generate(e); let oracle_cfg = OracleConfig { oracle_address: Address::generate(e), @@ -120,9 +126,20 @@ mod tests { min_responses: None, max_staleness_seconds: 3600, max_confidence_bps: 100, - strike_price: None, + strike_price: None, }; - client.create_market(creator, &String::from_str(e, "M"), &options, &1000, &2000, &oracle_cfg, &MarketTier::Basic, &token, &0, &0) + client.create_market( + creator, + &String::from_str(e, "M"), + &options, + &1000, + &2000, + &oracle_cfg, + &MarketTier::Basic, + &token, + &0, + &0, + ) } #[test] @@ -143,7 +160,8 @@ mod tests { for _ in 0..(MAX_PAGE_LIMIT + 10) { make_market(&e, &client, &creator); } - let result = client.get_markets_by_status(&MarketStatus::Active, &0, &(MAX_PAGE_LIMIT + 50)); + let result = + client.get_markets_by_status(&MarketStatus::Active, &0, &(MAX_PAGE_LIMIT + 50)); assert_eq!(result.len(), MAX_PAGE_LIMIT); } diff --git a/contracts/predict-iq/src/modules/resolution.rs b/contracts/predict-iq/src/modules/resolution.rs index 2f36da8..98085fb 100644 --- a/contracts/predict-iq/src/modules/resolution.rs +++ b/contracts/predict-iq/src/modules/resolution.rs @@ -1,7 +1,7 @@ -use soroban_sdk::{Env, Symbol}; -use crate::types::MarketStatus; -use crate::modules::{markets, oracles, voting}; use crate::errors::ErrorCode; +use crate::modules::{markets, oracles, voting}; +use crate::types::MarketStatus; +use soroban_sdk::{Env, Symbol}; pub const DEFAULT_DISPUTE_WINDOW_SECONDS: u64 = 259_200; // 72 hours pub const MIN_DISPUTE_WINDOW_SECONDS: u64 = 3_600; // 1 hour @@ -87,29 +87,29 @@ fn validate_dispute_window(e: &Env, seconds: u64) -> Result<(), ErrorCode> { /// T+0: Attempt oracle resolution at resolution deadline pub fn attempt_oracle_resolution(e: &Env, market_id: u64) -> Result<(), ErrorCode> { let mut market = markets::get_market(e, market_id).ok_or(ErrorCode::MarketNotFound)?; - + if market.status != MarketStatus::Active { return Err(ErrorCode::MarketNotActive); } - + if e.ledger().timestamp() < market.resolution_deadline { return Err(ErrorCode::ResolutionNotReady); } - + // Issue #508: Validate oracle staleness before resolution oracles::validate_oracle_staleness(e, market_id, &market.oracle_config)?; - + // Attempt oracle resolution if let Some(oracle_outcome) = oracles::get_oracle_result(e, market_id, 0) { let old_status = soroban_sdk::String::from_slice(e, "Active"); let new_status = soroban_sdk::String::from_slice(e, "PendingResolution"); - + market.status = MarketStatus::PendingResolution; market.winning_outcome = Some(oracle_outcome); market.pending_resolution_timestamp = Some(e.ledger().timestamp()); - + markets::update_market(e, market); - + // Emit market state change event for indexing crate::modules::events::emit_market_state_changed( e, @@ -118,12 +118,12 @@ pub fn attempt_oracle_resolution(e: &Env, market_id: u64) -> Result<(), ErrorCod new_status, e.ledger().timestamp(), ); - + e.events().publish( (Symbol::new(e, "oracle_resolved"), market_id), oracle_outcome, ); - + Ok(()) } else { Err(ErrorCode::OracleFailure) @@ -133,25 +133,27 @@ pub fn attempt_oracle_resolution(e: &Env, market_id: u64) -> Result<(), ErrorCod /// T+24h: Finalize resolution if no dispute filed pub fn finalize_resolution(e: &Env, market_id: u64) -> Result<(), ErrorCode> { let mut market = markets::get_market(e, market_id).ok_or(ErrorCode::MarketNotFound)?; - + match market.status { MarketStatus::PendingResolution => { // Check if 24h dispute window has passed - let pending_ts = market.pending_resolution_timestamp.ok_or(ErrorCode::ResolutionNotReady)?; + let pending_ts = market + .pending_resolution_timestamp + .ok_or(ErrorCode::ResolutionNotReady)?; let dispute_window = markets::get_market_dispute_window(e, market_id); if e.ledger().timestamp() < pending_ts + dispute_window { return Err(ErrorCode::DisputeWindowStillOpen); } - + // No dispute filed, finalize with oracle result let winning_outcome = market.winning_outcome.unwrap(); let old_status = soroban_sdk::String::from_slice(e, "PendingResolution"); let new_status = soroban_sdk::String::from_slice(e, "Resolved"); - + market.status = MarketStatus::Resolved; market.resolved_at = Some(e.ledger().timestamp()); markets::update_market(e, market); - + // Emit market state change event for indexing crate::modules::events::emit_market_state_changed( e, @@ -160,32 +162,34 @@ pub fn finalize_resolution(e: &Env, market_id: u64) -> Result<(), ErrorCode> { new_status, e.ledger().timestamp(), ); - + e.events().publish( (Symbol::new(e, "market_finalized"), market_id), winning_outcome, ); - + Ok(()) - }, + } MarketStatus::Disputed => { // Check if 72h voting period has passed since dispute was filed // dispute sets resolution_deadline += 3 days; use pending_resolution_timestamp as base - let dispute_ts = market.pending_resolution_timestamp.ok_or(ErrorCode::MarketNotDisputed)?; + let dispute_ts = market + .pending_resolution_timestamp + .ok_or(ErrorCode::MarketNotDisputed)?; if e.ledger().timestamp() < dispute_ts + VOTING_PERIOD_SECONDS { return Err(ErrorCode::VotingNotStarted); } - + // Calculate voting outcome let winning_outcome = calculate_voting_outcome(e, &market)?; let old_status = soroban_sdk::String::from_slice(e, "Disputed"); let new_status = soroban_sdk::String::from_slice(e, "Resolved"); - + market.status = MarketStatus::Resolved; market.winning_outcome = Some(winning_outcome); market.resolved_at = Some(e.ledger().timestamp()); markets::update_market(e, market); - + // Emit market state change event for indexing crate::modules::events::emit_market_state_changed( e, @@ -194,14 +198,14 @@ pub fn finalize_resolution(e: &Env, market_id: u64) -> Result<(), ErrorCode> { new_status, e.ledger().timestamp(), ); - + e.events().publish( (Symbol::new(e, "dispute_resolved"), market_id), winning_outcome, ); - + Ok(()) - }, + } MarketStatus::Resolved => Err(ErrorCode::CannotChangeOutcome), _ => Err(ErrorCode::ResolutionNotReady), } @@ -211,21 +215,21 @@ pub fn finalize_resolution(e: &Env, market_id: u64) -> Result<(), ErrorCode> { fn calculate_voting_outcome(e: &Env, market: &crate::types::Market) -> Result { let mut total_votes: i128 = 0; let mut tallies: soroban_sdk::Vec<(u32, i128)> = soroban_sdk::Vec::new(e); - + for outcome in 0..market.options.len() { let tally = voting::get_tally(e, market.id, outcome); total_votes += tally; tallies.push_back((outcome, tally)); } - + if total_votes == 0 { return Err(ErrorCode::NoMajorityReached); } - + // Find outcome with highest votes let mut max_outcome = 0u32; let mut max_votes = 0i128; - + for i in 0..tallies.len() { let (outcome, votes) = tallies.get(i).unwrap(); if votes > max_votes { @@ -233,7 +237,7 @@ fn calculate_voting_outcome(e: &Env, market: &crate::types::Market) -> Result= MAJORITY_THRESHOLD_BPS { diff --git a/contracts/predict-iq/src/modules/sac.rs b/contracts/predict-iq/src/modules/sac.rs index df0561a..fbfbecf 100644 --- a/contracts/predict-iq/src/modules/sac.rs +++ b/contracts/predict-iq/src/modules/sac.rs @@ -49,7 +49,11 @@ pub fn check_token_not_frozen( Ok(is_frozen) => { if is_frozen { e.events().publish( - (symbol_short!("token_frz"), token_address.clone(), user.clone()), + ( + symbol_short!("token_frz"), + token_address.clone(), + user.clone(), + ), (), ); Err(ErrorCode::TokenFrozen) diff --git a/contracts/predict-iq/src/modules/voting.rs b/contracts/predict-iq/src/modules/voting.rs index a803abd..247e29a 100644 --- a/contracts/predict-iq/src/modules/voting.rs +++ b/contracts/predict-iq/src/modules/voting.rs @@ -37,7 +37,7 @@ pub fn cast_vote( } let vote_key = DataKey::Vote(market_id, voter.clone()); - + // Issue #175: Allow vote revision - voters can change their vote before resolution deadline // This enables more flexible governance where voters can respond to new information let old_vote: Option = e.storage().persistent().get(&vote_key); @@ -143,7 +143,11 @@ pub fn cast_vote( if old_vote.is_none() { let reg_key = DataKey::DisputeVoters(market_id); - let mut voters: Vec
= e.storage().persistent().get(®_key).unwrap_or(Vec::new(e)); + let mut voters: Vec
= e + .storage() + .persistent() + .get(®_key) + .unwrap_or(Vec::new(e)); voters.push_back(voter.clone()); e.storage().persistent().set(®_key, &voters); } @@ -207,11 +211,7 @@ pub fn unlock_tokens(e: &Env, voter: Address, market_id: u64) -> Result<(), Erro // Issue #37: Use LockedBalance as the authoritative per-user amount to // prevent a user from withdrawing more than they individually locked. let balance_key = DataKey::LockedBalance(market_id, voter.clone()); - let amount: i128 = e - .storage() - .persistent() - .get(&balance_key) - .unwrap_or(0); + let amount: i128 = e.storage().persistent().get(&balance_key).unwrap_or(0); if amount <= 0 { return Err(ErrorCode::BetNotFound); @@ -247,7 +247,9 @@ pub fn prune_market_voting_state(e: &Env, market_id: u64, num_outcomes: u32) { if let Some(voters) = e.storage().persistent().get::<_, Vec
>(®_key) { for i in 0..voters.len() { let v = voters.get(i).unwrap(); - e.storage().persistent().remove(&DataKey::Vote(market_id, v.clone())); + e.storage() + .persistent() + .remove(&DataKey::Vote(market_id, v.clone())); e.storage() .persistent() .remove(&DataKey::LockedTokens(market_id, v.clone())); @@ -275,7 +277,9 @@ mod import_tests { fn governance_token_config_key_round_trips() { let e = Env::default(); let token = Address::generate(&e); - e.storage().instance().set(&ConfigKey::GovernanceToken, &token); + e.storage() + .instance() + .set(&ConfigKey::GovernanceToken, &token); let stored: Option
= e.storage().instance().get(&ConfigKey::GovernanceToken); assert_eq!(stored, Some(token)); } @@ -286,7 +290,10 @@ mod import_tests { let e = Env::default(); // GovernanceToken not set in storage — get returns None let stored: Option
= e.storage().instance().get(&ConfigKey::GovernanceToken); - assert!(stored.is_none(), "GovernanceToken must be absent to trigger the error"); + assert!( + stored.is_none(), + "GovernanceToken must be absent to trigger the error" + ); } } @@ -321,8 +328,12 @@ mod prune_tests { weight: 200, }, ); - e.storage().persistent().set(&DataKey::VoteTally(market_id, 0), &100_i128); - e.storage().persistent().set(&DataKey::VoteTally(market_id, 1), &200_i128); + e.storage() + .persistent() + .set(&DataKey::VoteTally(market_id, 0), &100_i128); + e.storage() + .persistent() + .set(&DataKey::VoteTally(market_id, 1), &200_i128); e.storage().persistent().set( &DataKey::LockedTokens(market_id, v1.clone()), &LockedTokens { @@ -332,10 +343,9 @@ mod prune_tests { unlock_time: 0, }, ); - e.storage().persistent().set( - &DataKey::LockedBalance(market_id, v1.clone()), - &50_i128, - ); + e.storage() + .persistent() + .set(&DataKey::LockedBalance(market_id, v1.clone()), &50_i128); let mut reg = soroban_sdk::Vec::new(&e); reg.push_back(v1.clone()); @@ -421,7 +431,7 @@ mod decimal_normalization_tests { #[test] fn equal_weights_after_normalization() { // 1 token regardless of decimal precision should normalize to the same value - let one_7dec = normalize(10_000_000, 7); // 1 token at 7 decimals + let one_7dec = normalize(10_000_000, 7); // 1 token at 7 decimals let one_18dec = normalize(1_000_000_000_000_000_000, 18); // 1 token at 18 decimals assert_eq!(one_7dec, one_18dec); } diff --git a/contracts/predict-iq/src/test.rs b/contracts/predict-iq/src/test.rs index 6364cae..8c981db 100644 --- a/contracts/predict-iq/src/test.rs +++ b/contracts/predict-iq/src/test.rs @@ -62,7 +62,9 @@ fn test_config_setters_reject_non_admin() { expect_not_authorized!(client.try_set_minimum_bet_amount(&1000)); // set_creator_reputation - expect_not_authorized!(client.try_set_creator_reputation(&non_admin, &types::CreatorReputation::Pro)); + expect_not_authorized!( + client.try_set_creator_reputation(&non_admin, &types::CreatorReputation::Pro) + ); // set_guardian expect_not_authorized!(client.try_set_guardian(&guardian)); @@ -130,7 +132,7 @@ fn make_stored_market(e: &Env, id: u64) -> types::Market { min_responses: Some(1), max_staleness_seconds: 3600, max_confidence_bps: 200, - strike_price: None, + strike_price: None, }, total_staked: 0, payout_mode: types::PayoutMode::Pull, @@ -178,16 +180,16 @@ fn test_market_creation_fails_without_deposit() { min_responses: 1, max_staleness_seconds: 300, max_confidence_bps: 200, - strike_price: None, + strike_price: None, min_responses: Some(1), max_staleness_seconds: 3600, max_confidence_bps: 200, - strike_price: None, - max_staleness_seconds: 3600, - max_confidence_bps: 200, - strike_price: None, - max_confidence_bps: 100, - strike_price: None, + strike_price: None, + max_staleness_seconds: 3600, + max_confidence_bps: 200, + strike_price: None, + max_confidence_bps: 100, + strike_price: None, }, &types::MarketTier::Basic, &native_token, @@ -784,7 +786,10 @@ fn test_add_admin_as_guardian_rejected() { let guardian = Address::generate(&e); let mut guardians = Vec::new(&e); - guardians.push_back(types::Guardian { address: guardian.clone(), voting_power: 1 }); + guardians.push_back(types::Guardian { + address: guardian.clone(), + voting_power: 1, + }); client.initialize_guardians(&guardians); // Attempt to add the admin address as a guardian — must be rejected @@ -800,7 +805,10 @@ fn test_initialize_guardians_with_admin_rejected() { let (e, admin, _contract_id, client) = setup_test_env(); let mut guardians = Vec::new(&e); - guardians.push_back(types::Guardian { address: admin.clone(), voting_power: 1 }); + guardians.push_back(types::Guardian { + address: admin.clone(), + voting_power: 1, + }); let result = client.try_initialize_guardians(&guardians); assert_eq!(result, Err(Ok(ErrorCode::NotAuthorized))); @@ -880,7 +888,10 @@ fn test_execute_upgrade_timelock_starts_when_vote_passes() { e.ledger().set_timestamp(1000 + 24 * 3600 + 1); client.vote_for_upgrade(&guardian, &true); - assert_eq!(client.try_execute_upgrade(), Err(Ok(ErrorCode::TimelockActive))); + assert_eq!( + client.try_execute_upgrade(), + Err(Ok(ErrorCode::TimelockActive)) + ); e.ledger().set_timestamp(1000 + (2 * 24 * 3600) + 1); assert!(client.try_execute_upgrade().is_ok()); @@ -984,7 +995,10 @@ fn test_set_timelock_duration_and_early_execution() { // Still blocked before 24 hours e.ledger().set_timestamp(1000 + one_day - 1); - assert_eq!(client.try_execute_upgrade(), Err(Ok(ErrorCode::TimelockActive))); + assert_eq!( + client.try_execute_upgrade(), + Err(Ok(ErrorCode::TimelockActive)) + ); // Succeeds exactly at 24 hours e.ledger().set_timestamp(1000 + one_day); @@ -2020,7 +2034,10 @@ fn test_vote_on_upgrade_refreshes_ttl() { }); let pending = client.get_pending_upgrade(); - assert!(pending.is_some(), "PendingUpgrade expired after vote + 3 months inactivity"); + assert!( + pending.is_some(), + "PendingUpgrade expired after vote + 3 months inactivity" + ); let votes_for = client.get_upgrade_votes().votes_for; assert_eq!(votes_for, 1); } @@ -2153,8 +2170,6 @@ fn test_double_vote_still_rejected_with_optimized_struct() { assert_eq!(result, Err(Ok(crate::errors::ErrorCode::AlreadyVoted))); } - - // ===================== Dispute Deadline Idempotency Test ===================== #[test] @@ -2188,7 +2203,7 @@ fn test_dispute_deadline_extension_is_one_time_and_idempotent() { min_responses: Some(1), max_staleness_seconds: 3600, max_confidence_bps: 200, - strike_price: None, + strike_price: None, }, &types::MarketTier::Basic, &native_token, @@ -2203,7 +2218,8 @@ fn test_dispute_deadline_extension_is_one_time_and_idempotent() { // First dispute — must succeed and extend deadline by one dispute window (72h) let disputer = Address::generate(&e); - e.ledger().with_mut(|li| li.timestamp = resolution_deadline + 1000); + e.ledger() + .with_mut(|li| li.timestamp = resolution_deadline + 1000); client.file_dispute(&disputer, &market_id); let market_after_first = client.get_market(&market_id).unwrap(); @@ -2222,7 +2238,10 @@ fn test_dispute_deadline_extension_is_one_time_and_idempotent() { // Deadline must be unchanged after the rejected second attempt let market_after_second = client.get_market(&market_id).unwrap(); - assert_eq!(market_after_second.resolution_deadline, deadline_after_first); + assert_eq!( + market_after_second.resolution_deadline, + deadline_after_first + ); assert_eq!(market_after_second.status, types::MarketStatus::Disputed); } @@ -2246,7 +2265,6 @@ fn test_initialize_rejects_non_deployer() { assert!(result.is_err()); } - // ─── Fee Calculation Unit Tests ─────────────────────────────────────────────── mod fee_calculation_tests { @@ -2411,7 +2429,10 @@ mod fee_calculation_tests { let referrer = Address::generate(&env); // No reward seeded — claim must fail let result = client.try_claim_referral_rewards(&referrer, &token); - assert_eq!(result, Err(Ok(crate::errors::ErrorCode::InsufficientBalance))); + assert_eq!( + result, + Err(Ok(crate::errors::ErrorCode::InsufficientBalance)) + ); } #[test] @@ -2442,7 +2463,10 @@ mod fee_calculation_tests { // Second claim must fail — balance is zero let result = client.try_claim_referral_rewards(&referrer, &token); - assert_eq!(result, Err(Ok(crate::errors::ErrorCode::InsufficientBalance))); + assert_eq!( + result, + Err(Ok(crate::errors::ErrorCode::InsufficientBalance)) + ); } // ── Fee distribution / edge cases ───────────────────────────────────────── @@ -2503,7 +2527,10 @@ mod dispute_resolution_tests { use crate::modules::{markets, voting}; use crate::types::{MarketStatus, MarketTier, OracleConfig}; use crate::{PredictIQ, PredictIQClient}; - use soroban_sdk::{testutils::{Address as _, Ledger as _}, token, Address, Env, String, Vec}; + use soroban_sdk::{ + testutils::{Address as _, Ledger as _}, + token, Address, Env, String, Vec, + }; const DISPUTE_WINDOW: u64 = 86_400; // 24h — matches resolution::DISPUTE_WINDOW_SECONDS @@ -2542,7 +2569,7 @@ mod dispute_resolution_tests { min_responses: Some(1), max_staleness_seconds: 3600, max_confidence_bps: 200, - strike_price: None, + strike_price: None, }, &MarketTier::Basic, &token, @@ -2578,7 +2605,10 @@ mod dispute_resolution_tests { let disputer = Address::generate(&env); let result = client.try_file_dispute(&disputer, &market_id); - assert_eq!(result, Err(Ok(crate::errors::ErrorCode::MarketNotPendingResolution))); + assert_eq!( + result, + Err(Ok(crate::errors::ErrorCode::MarketNotPendingResolution)) + ); } #[test] @@ -2625,7 +2655,10 @@ mod dispute_resolution_tests { env.ledger().set_timestamp(pending_ts + DISPUTE_WINDOW); let disputer = Address::generate(&env); let result = client.try_file_dispute(&disputer, &market_id); - assert_eq!(result, Err(Ok(crate::errors::ErrorCode::DisputeWindowClosed))); + assert_eq!( + result, + Err(Ok(crate::errors::ErrorCode::DisputeWindowClosed)) + ); } #[test] @@ -2644,7 +2677,10 @@ mod dispute_resolution_tests { let disputer = Address::generate(&env); let result = client.try_file_dispute(&disputer, &market_id); - assert_eq!(result, Err(Ok(crate::errors::ErrorCode::MarketNotPendingResolution))); + assert_eq!( + result, + Err(Ok(crate::errors::ErrorCode::MarketNotPendingResolution)) + ); } #[test] @@ -2652,11 +2688,21 @@ mod dispute_resolution_tests { let (env, client, _admin, contract_id) = setup(); let market_id = create_market(&env, &client, &contract_id); - seed_market_status(&env, &contract_id, market_id, MarketStatus::Resolved, None, None); + seed_market_status( + &env, + &contract_id, + market_id, + MarketStatus::Resolved, + None, + None, + ); let disputer = Address::generate(&env); let result = client.try_file_dispute(&disputer, &market_id); - assert_eq!(result, Err(Ok(crate::errors::ErrorCode::MarketNotPendingResolution))); + assert_eq!( + result, + Err(Ok(crate::errors::ErrorCode::MarketNotPendingResolution)) + ); } #[test] @@ -2777,7 +2823,10 @@ mod dispute_resolution_tests { let voter = Address::generate(&env); let result = client.try_cast_vote(&voter, &market_id, &0, &100); - assert_eq!(result, Err(Ok(crate::errors::ErrorCode::GovernanceTokenNotSet))); + assert_eq!( + result, + Err(Ok(crate::errors::ErrorCode::GovernanceTokenNotSet)) + ); } #[test] @@ -2950,6 +2999,9 @@ mod dispute_resolution_tests { let metrics_0 = client.get_resolution_metrics(&market_id, &0); // gas_estimate = 100_000 + winner_count * 50_000 - assert_eq!(metrics_0.gas_estimate, 100_000 + metrics_0.winner_count as u64 * 50_000); + assert_eq!( + metrics_0.gas_estimate, + 100_000 + metrics_0.winner_count as u64 * 50_000 + ); } } diff --git a/contracts/predict-iq/src/test_pyth_integration.rs b/contracts/predict-iq/src/test_pyth_integration.rs index 00bf103..0e77110 100644 --- a/contracts/predict-iq/src/test_pyth_integration.rs +++ b/contracts/predict-iq/src/test_pyth_integration.rs @@ -81,11 +81,19 @@ impl MockPythContract { /// A 64-char hex string that decodes to a valid 32-byte Pyth feed ID. /// Matches the BTC/USD feed ID on Pyth mainnet. fn btc_usd_feed_id(e: &Env) -> String { - String::from_str(e, "e62df6c8b4a85fe1a67db44dc12de5db330f7ac66b72dc658afedf0f4a415b43") + String::from_str( + e, + "e62df6c8b4a85fe1a67db44dc12de5db330f7ac66b72dc658afedf0f4a415b43", + ) } /// Build an [`OracleConfig`] pointing at the mock Pyth contract. -fn oracle_config(e: &Env, pyth_addr: Address, max_staleness: u64, max_conf_bps: u64) -> OracleConfig { +fn oracle_config( + e: &Env, + pyth_addr: Address, + max_staleness: u64, + max_conf_bps: u64, +) -> OracleConfig { OracleConfig { oracle_address: pyth_addr, feed_id: btc_usd_feed_id(e), @@ -178,12 +186,28 @@ fn test_different_markets_can_have_different_feed_ids() { opts.push_back(String::from_str(&e, "No")); let btc_market = client.create_market( - &admin, &String::from_str(&e, "BTC market"), &opts, - &1000, &2000, &btc_config, &MarketTier::Basic, &token, &0, &0, + &admin, + &String::from_str(&e, "BTC market"), + &opts, + &1000, + &2000, + &btc_config, + &MarketTier::Basic, + &token, + &0, + &0, ); let eth_market = client.create_market( - &admin, &String::from_str(&e, "ETH market"), &opts, - &1000, &2000, ð_config, &MarketTier::Basic, &token, &0, &0, + &admin, + &String::from_str(&e, "ETH market"), + &opts, + &1000, + &2000, + ð_config, + &MarketTier::Basic, + &token, + &0, + &0, ); let btc = client.get_market(&btc_market).unwrap(); @@ -206,7 +230,11 @@ fn test_fetch_pyth_price_returns_correct_fields() { let config = oracle_config(&e, pyth_addr, u64::MAX, 500); let result = fetch_pyth_price(&e, &config); - assert!(result.is_ok(), "fetch_pyth_price should succeed: {:?}", result); + assert!( + result.is_ok(), + "fetch_pyth_price should succeed: {:?}", + result + ); let price = result.unwrap(); assert_eq!(price.price, 5_000_000); @@ -247,7 +275,10 @@ fn test_fetch_pyth_price_no_older_than_succeeds_when_fresh() { let config = oracle_config(&e, pyth_addr, 3600, 500); let result = fetch_pyth_price(&e, &config); - assert!(result.is_ok(), "price should be accepted when within staleness window"); + assert!( + result.is_ok(), + "price should be accepted when within staleness window" + ); assert_eq!(result.unwrap().price, 5_000_000); } @@ -302,7 +333,10 @@ fn test_validate_price_rejects_stale_price() { publish_time: 1_700_000_000, }; - assert_eq!(validate_price(&e, &price, &config), Err(ErrorCode::StalePrice)); + assert_eq!( + validate_price(&e, &price, &config), + Err(ErrorCode::StalePrice) + ); } #[test] @@ -321,7 +355,10 @@ fn test_validate_price_rejects_low_confidence() { publish_time: 1_700_000_000, }; - assert_eq!(validate_price(&e, &price, &config), Err(ErrorCode::ConfidenceTooLow)); + assert_eq!( + validate_price(&e, &price, &config), + Err(ErrorCode::ConfidenceTooLow) + ); } #[test] @@ -337,7 +374,10 @@ fn test_validate_price_rejects_negative_publish_time() { publish_time: -1, }; - assert_eq!(validate_price(&e, &price, &config), Err(ErrorCode::InvalidTimestamp)); + assert_eq!( + validate_price(&e, &price, &config), + Err(ErrorCode::InvalidTimestamp) + ); } // --------------------------------------------------------------------------- @@ -355,8 +395,16 @@ fn test_resolve_with_pyth_stores_outcome_and_timestamp() { let config = oracle_config(&e, pyth_addr, u64::MAX, 500); let result = resolve_with_pyth(&e, 1u64, 0u32, &config); - assert!(result.is_ok(), "resolve_with_pyth should succeed: {:?}", result); - assert_eq!(result.unwrap(), 0u32, "outcome should be 0 (price >= strike)"); + assert!( + result.is_ok(), + "resolve_with_pyth should succeed: {:?}", + result + ); + assert_eq!( + result.unwrap(), + 0u32, + "outcome should be 0 (price >= strike)" + ); assert_eq!(get_oracle_result(&e, 1u64, 0u32), Some(0u32)); assert!(get_last_update(&e, 1u64, 0u32).is_some()); @@ -405,8 +453,14 @@ fn test_resolve_with_pyth_stale_price_leaves_no_storage() { let result = resolve_with_pyth(&e, 3u64, 0u32, &config); assert_eq!(result, Err(ErrorCode::StalePrice)); - assert!(get_oracle_result(&e, 3u64, 0u32).is_none(), "no result should be stored"); - assert!(get_last_update(&e, 3u64, 0u32).is_none(), "no timestamp should be stored"); + assert!( + get_oracle_result(&e, 3u64, 0u32).is_none(), + "no result should be stored" + ); + assert!( + get_last_update(&e, 3u64, 0u32).is_none(), + "no timestamp should be stored" + ); } #[test] @@ -451,7 +505,11 @@ fn test_contract_attempt_oracle_resolution_with_mock_pyth() { client.set_oracle_result(&market_id, &0, &0); let result = client.try_attempt_oracle_resolution(&market_id); - assert!(result.is_ok(), "oracle resolution should succeed: {:?}", result); + assert!( + result.is_ok(), + "oracle resolution should succeed: {:?}", + result + ); let market = client.get_market(&market_id).unwrap(); assert_eq!(market.status, MarketStatus::PendingResolution); diff --git a/contracts/predict-iq/src/types.rs b/contracts/predict-iq/src/types.rs index e607cdd..21d2ded 100644 --- a/contracts/predict-iq/src/types.rs +++ b/contracts/predict-iq/src/types.rs @@ -26,16 +26,16 @@ pub struct Market { pub payout_mode: PayoutMode, // New: determines push vs pull payouts pub tier: MarketTier, pub creation_deposit: i128, - pub parent_id: u64, // 0 means no parent (independent market) - pub parent_outcome_idx: u32, // Required outcome of parent market - pub resolved_at: Option, // Timestamp when market was resolved (for TTL pruning) - pub token_address: Address, // Token used for betting + pub parent_id: u64, // 0 means no parent (independent market) + pub parent_outcome_idx: u32, // Required outcome of parent market + pub resolved_at: Option, // Timestamp when market was resolved (for TTL pruning) + pub token_address: Address, // Token used for betting pub outcome_stakes: Map, // Stake per outcome pub pending_resolution_timestamp: Option, // Timestamp when resolution was initiated pub dispute_snapshot_ledger: Option, // Ledger sequence for snapshot voting pub dispute_timestamp: Option, // Timestamp when dispute was filed - pub winner_counts: Map, // Unique bettor count per outcome - pub total_claimed: i128, // Total amount claimed by winners + pub winner_counts: Map, // Unique bettor count per outcome + pub total_claimed: i128, // Total amount claimed by winners } #[contracttype] @@ -194,4 +194,4 @@ pub const BET_TTL_HIGH_THRESHOLD: u32 = 1_555_200; // ~180 days (13000000 second // Governance TTL constants (same values, governance-specific aliases) pub const GOV_TTL_LOW_THRESHOLD: u32 = TTL_LOW_THRESHOLD; -pub const GOV_TTL_HIGH_THRESHOLD: u32 = TTL_HIGH_THRESHOLD; \ No newline at end of file +pub const GOV_TTL_HIGH_THRESHOLD: u32 = TTL_HIGH_THRESHOLD; diff --git a/contracts/predict-iq/tests/full_flow_integration_test.rs b/contracts/predict-iq/tests/full_flow_integration_test.rs index 1d67c8a..859737f 100644 --- a/contracts/predict-iq/tests/full_flow_integration_test.rs +++ b/contracts/predict-iq/tests/full_flow_integration_test.rs @@ -61,8 +61,14 @@ fn test_full_bet_place_resolve_claim_flow() { let winnings2 = client.claim_winnings(&bettor2, &market_id, &token); // Verify winnings are greater than original bets (includes pool share) - assert!(winnings1 > bet1_amount, "Winnings should exceed original bet"); - assert!(winnings2 > bet2_amount, "Winnings should exceed original bet"); + assert!( + winnings1 > bet1_amount, + "Winnings should exceed original bet" + ); + assert!( + winnings2 > bet2_amount, + "Winnings should exceed original bet" + ); // 8. Verify balances after claiming let balance1_after_claim = token_client.balance(&bettor1); @@ -149,8 +155,14 @@ fn test_full_flow_fee_deduction_verification() { // Verify fee was deducted: winnings should be less than total pool // Total pool = 20_000, minus 5% fee = 19_000 // Bettor1 gets share of remaining pool - assert!(winnings < 20_000, "Winnings should be less than total pool due to fees"); - assert!(winnings > bet_amount, "Winnings should still exceed original bet"); + assert!( + winnings < 20_000, + "Winnings should be less than total pool due to fees" + ); + assert!( + winnings > bet_amount, + "Winnings should still exceed original bet" + ); // Verify revenue was collected let revenue = client.get_revenue(&token); @@ -267,5 +279,8 @@ fn test_full_flow_payout_distribution_accuracy() { // Bettor2 bet 20_000 out of 30_000 total winning bets (2/3) // So winnings2 should be approximately 2x winnings1 let ratio = winnings2 as f64 / winnings1 as f64; - assert!(ratio > 1.8 && ratio < 2.2, "Payout ratio should be approximately 2:1"); + assert!( + ratio > 1.8 && ratio < 2.2, + "Payout ratio should be approximately 2:1" + ); } diff --git a/contracts/predict-iq/tests/integration_test.rs b/contracts/predict-iq/tests/integration_test.rs index 522d501..43abe19 100644 --- a/contracts/predict-iq/tests/integration_test.rs +++ b/contracts/predict-iq/tests/integration_test.rs @@ -254,7 +254,14 @@ fn test_governance_upgrade_workflow() { // Initiate upgrade env.ledger().with_mut(|li| li.timestamp = 1000); - let wasm_hash = soroban_sdk::BytesN::from_array(&env, &[0x01,0x02,0x03,0x04,0x05,0x06,0x07,0x08,0x09,0x0a,0x0b,0x0c,0x0d,0x0e,0x0f,0x10,0x11,0x12,0x13,0x14,0x15,0x16,0x17,0x18,0x19,0x1a,0x1b,0x1c,0x1d,0x1e,0x1f,0x20]); + let wasm_hash = soroban_sdk::BytesN::from_array( + &env, + &[ + 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, + 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c, + 0x1d, 0x1e, 0x1f, 0x20, + ], + ); client.initiate_upgrade(&wasm_hash); // Guardians vote @@ -262,7 +269,9 @@ fn test_governance_upgrade_workflow() { client.vote_for_upgrade(&guardian2, &true); // Check votes - let upgrade_votes = client.get_upgrade_votes(); let for_votes = upgrade_votes.votes_for; let against_votes = upgrade_votes.votes_against; + let upgrade_votes = client.get_upgrade_votes(); + let for_votes = upgrade_votes.votes_for; + let against_votes = upgrade_votes.votes_against; assert_eq!(for_votes, 2); assert_eq!(against_votes, 0); @@ -374,4 +383,3 @@ fn test_reputation_based_deposit_waiver() { let market = client.get_market(&market_id).unwrap(); assert_eq!(market.creation_deposit, 0); } - diff --git a/contracts/predict-iq/tests/lifecycle_test.rs b/contracts/predict-iq/tests/lifecycle_test.rs index 70b5cc4..dee9b03 100644 --- a/contracts/predict-iq/tests/lifecycle_test.rs +++ b/contracts/predict-iq/tests/lifecycle_test.rs @@ -1,11 +1,11 @@ // End-to-end integration test for the full disputed market lifecycle // Active -> PendingResolution -> Disputed -> Resolved -> Claim -use predict_iq::types::{MarketStatus, OracleConfig, MarketTier, Guardian}; +use predict_iq::types::{Guardian, MarketStatus, MarketTier, OracleConfig}; use predict_iq::{PredictIQ, PredictIQClient}; use soroban_sdk::{ testutils::{Address as _, Ledger}, - token, Address, Env, String, Vec, Symbol, + token, Address, Env, String, Symbol, Vec, }; mod common; @@ -23,7 +23,7 @@ fn test_full_disputed_lifecycle() { let gov_token_admin = Address::generate(&env); let gov_token_id = env.register_stellar_asset_contract_v2(gov_token_admin.clone()); let gov_token = gov_token_id.address(); - + let native_token_admin = Address::generate(&env); let native_token_id = env.register_stellar_asset_contract_v2(native_token_admin.clone()); let native_token = native_token_id.address(); @@ -44,7 +44,10 @@ fn test_full_disputed_lifecycle() { let creator = Address::generate(&env); let options = Vec::from_array( &env, - [String::from_str(&env, "Outcome 0"), String::from_str(&env, "Outcome 1")], + [ + String::from_str(&env, "Outcome 0"), + String::from_str(&env, "Outcome 1"), + ], ); let oracle_config = OracleConfig { @@ -87,11 +90,11 @@ fn test_full_disputed_lifecycle() { // 4. Resolve via Oracle (Proposed Outcome: 1) env.ledger().with_mut(|li| li.timestamp = 3001); // Past resolution deadline - + // Set oracle result as admin client.set_oracle_result(&market_id, &0, &1); client.attempt_oracle_resolution(&market_id); - + assert_market_status(&client, market_id, MarketStatus::PendingResolution); let market = client.get_market(&market_id).unwrap(); assert_eq!(market.winning_outcome, Some(1)); @@ -99,7 +102,7 @@ fn test_full_disputed_lifecycle() { // 5. File Dispute (User A disagrees) env.ledger().with_mut(|li| li.timestamp = 3100); // Within 48h dispute window client.file_dispute(&user_a, &market_id); - + assert_market_status(&client, market_id, MarketStatus::Disputed); // 6. Community Voting (Majority decides Outcome 0) @@ -116,9 +119,10 @@ fn test_full_disputed_lifecycle() { client.cast_vote(&voter_2, &market_id, &1, &400); // 7. Finalize Resolution after voting period (72h after dispute) - env.ledger().with_mut(|li| li.timestamp = 3100 + (72 * 3601)); // Past 72h + env.ledger() + .with_mut(|li| li.timestamp = 3100 + (72 * 3601)); // Past 72h client.finalize_resolution(&market_id); - + assert_market_status(&client, market_id, MarketStatus::Resolved); let market = client.get_market(&market_id).unwrap(); assert_eq!(market.winning_outcome, Some(0)); // Majority won @@ -127,7 +131,7 @@ fn test_full_disputed_lifecycle() { let balance_before = token::Client::new(&env, &native_token).balance(&user_a); let claimed = client.claim_winnings(&user_a, &market_id); assert!(claimed > 1000); // Original 1000 + share of user B's bet - fees - + let balance_after = token::Client::new(&env, &native_token).balance(&user_a); assert_eq!(balance_after, balance_before + claimed); diff --git a/services/api/src/compression.rs b/services/api/src/compression.rs index 20832a6..da25788 100644 --- a/services/api/src/compression.rs +++ b/services/api/src/compression.rs @@ -1,5 +1,48 @@ +use tower_http::compression::predicate::{NotForContentType, Predicate}; use tower_http::compression::CompressionLayer; +fn should_compress_text_based(content_type: Option<&str>) -> bool { + let Some(ct) = content_type else { + // If we can't determine content type, avoid wasting CPU. + return false; + }; + + // Remove common parameters like `charset=utf-8`. + let ct = ct.split(';').next().unwrap_or(ct).trim(); + + // Only compress text-ish payloads. + // Note: application/json is explicitly included. + ct == "application/json" || ct.starts_with("text/") +} + pub fn compression_layer() -> CompressionLayer { - CompressionLayer::new().gzip(true).br(true) + // Exclude already-compressed/binary formats to avoid CPU waste. + // (This primarily protects against cases where `content_type` might be + // missing/incorrect while still keeping the middleware safe.) + let not_for_binary = NotForContentType::new(vec![ + "application/zip", + "application/gzip", + "application/x-gzip", + "application/x-zip-compressed", + "application/pdf", + "image/jpeg", + "image/png", + "image/webp", + "image/gif", + "image/svg+xml", + "audio/mpeg", + "audio/mp4", + "video/mp4", + "application/octet-stream", + "application/x-bzip2", + "application/x-7z-compressed", + ]); + + CompressionLayer::new() + .gzip(true) + .br(true) + // Only apply compression to text-based responses. + .compress_when(Predicate::from_fn(should_compress_text_based)) + .filter(not_for_binary) } +