Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 17 additions & 14 deletions contracts/predict-iq/benches/gas_benchmark.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -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]
Expand All @@ -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 ─────────────────────────
Expand Down Expand Up @@ -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) ─────────────────────
Expand Down Expand Up @@ -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);
}
}

Expand Down Expand Up @@ -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!(
Expand Down
21 changes: 15 additions & 6 deletions contracts/predict-iq/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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)
}
Expand All @@ -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)
}

Expand Down Expand Up @@ -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)
}

Expand Down
35 changes: 26 additions & 9 deletions contracts/predict-iq/src/modules/bets.rs
Original file line number Diff line number Diff line change
@@ -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)
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -271,10 +283,15 @@ pub fn claim_winnings(e: &Env, bettor: Address, market_id: u64) -> Result<i128,
// winnings = (bet.amount * total_staked) / winning_outcome_stake
// Integer division truncates down, favouring the protocol.
let winning_outcome_stake = markets::get_outcome_stake(e, market_id, winning_outcome);
let winning_outcome_stake = if winning_outcome_stake > 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)?;
Expand Down Expand Up @@ -364,4 +381,4 @@ pub fn set_minimum_bet_amount(e: &Env, amount: i128) -> Result<(), ErrorCode> {
.persistent()
.set(&crate::types::ConfigKey::MinimumBetAmount, &amount);
Ok(())
}
}
22 changes: 18 additions & 4 deletions contracts/predict-iq/src/modules/cancellation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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);

Expand All @@ -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);
}

Expand Down
29 changes: 17 additions & 12 deletions contracts/predict-iq/src/modules/circuit_breaker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand All @@ -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(()),
Expand Down Expand Up @@ -182,7 +184,10 @@ mod threshold_tests {
e.mock_all_auths();
setup_admin(&e);
set_threshold(&e, 42).unwrap();
let stored: Option<i128> = e.storage().instance().get(&ConfigKey::CircuitBreakerThreshold);
let stored: Option<i128> = e
.storage()
.instance()
.get(&ConfigKey::CircuitBreakerThreshold);
assert_eq!(stored, Some(42));
}
}
9 changes: 3 additions & 6 deletions contracts/predict-iq/src/modules/disputes_weight_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 ───────────────────────────────────────────────────────────────────

Expand Down Expand Up @@ -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);
Expand Down
18 changes: 11 additions & 7 deletions contracts/predict-iq/src/modules/event_archive.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,18 +8,22 @@ 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
.storage()
.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.
Expand All @@ -35,18 +39,18 @@ pub fn get_archived_market_ids(e: &Env, offset: u32, limit: u32) -> Vec<u64> {
.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
}

Expand Down
Loading