Skip to content
Merged
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
16 changes: 14 additions & 2 deletions contracts/predict-iq/src/errors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -193,9 +193,21 @@ pub enum ErrorCode {
/// The operation would reduce the guardian set below the required minimum.
BelowMinimumGuardianCount = 162,

/// The provided token does not match the market's expected token.
InvalidToken = 163,

/// The caller has already voted on this guardian removal proposal.
AlreadyVotedOnRemoval = 164,

/// No guardian removal proposal is pending for the given target.
RemovalNotPending = 165,

/// The guardian removal proposal's voting period has expired.
RemovalExpired = 166,

/// The bet amount is below the configured minimum bet amount.
MinimumBetNotMet = 163,
MinimumBetNotMet = 167,

/// The provided base fee is outside the allowed range (0 to 10_000 bps).
InvalidBaseFee = 164,
InvalidBaseFee = 168,
}
2 changes: 1 addition & 1 deletion contracts/predict-iq/src/modules/bets.rs
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ pub fn place_bet(
}

if token_address != market.token_address {
return Err(ErrorCode::InvalidBetAmount);
return Err(ErrorCode::InvalidToken);
}

// Check if user's tokens are frozen for SAC-wrapped assets
Expand Down
121 changes: 121 additions & 0 deletions contracts/predict-iq/src/modules/bets_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1105,3 +1105,124 @@ fn test_resolve_market_invalid_outcome() {
let result = client.try_resolve_market(&market_id, &99);
assert_eq!(result, Err(Ok(ErrorCode::InvalidOutcome)));
}

// =============================================================================
// Issue #1689: Creator can reclaim creation deposit on a cancelled market
// without having placed a bet.
// =============================================================================

/// A creator who never bet on their own market must still be able to reclaim
/// the creation deposit once the market is cancelled.
#[test]
fn test_creator_reclaims_deposit_on_cancelled_market_without_bet() {
let (env, client, _admin, creator, token) = setup_test_with_token();

env.ledger().with_mut(|li| li.timestamp = 500);

let market_id = create_simple_market(&client, &env, &creator, &token);

// Creator never places a bet on the market.
assert!(client.get_bet(&market_id, &creator, &0).is_none());

client.cancel_market_admin(&market_id);

let token_client = token::Client::new(&env, &token);
let balance_before = token_client.balance(&creator);

// Deposit refund must be reachable even though no bet record exists.
let refunded = client.release_creation_deposit(&market_id);
assert!(refunded > 0, "creator deposit must be refunded");

let balance_after = token_client.balance(&creator);
assert_eq!(
balance_after - balance_before,
refunded,
"creator balance must increase by the refunded deposit"
);

// A second release must not pay out again.
let second = client.try_release_creation_deposit(&market_id);
assert!(
second.is_err() || second == Ok(Ok(0)),
"deposit must not be released twice"
);
}

/// `withdraw_refund` called by the creator (who has no bet) on a cancelled
/// market must not fail with `MarketNotFound`; the deposit path must run.
#[test]
fn test_withdraw_refund_creator_without_bet_on_cancelled_market() {
let (env, client, _admin, creator, token) = setup_test_with_token();

env.ledger().with_mut(|li| li.timestamp = 500);

let market_id = create_simple_market(&client, &env, &creator, &token);
client.cancel_market_admin(&market_id);

let result = client.try_withdraw_refund(&creator, &market_id, &0, &token);
assert_ne!(
result,
Err(Ok(ErrorCode::MarketNotFound)),
"creator without a bet must not get MarketNotFound on a cancelled market"
);
}

// =============================================================================
// Issue #1690: Token-mismatch and missing-bet paths return accurate errors
// =============================================================================

/// `place_bet` with a token that is not the market token must return
/// `InvalidToken`, not `InvalidBetAmount`.
#[test]
fn test_place_bet_wrong_token_returns_invalid_token() {
let (env, client, _admin, user, token) = setup_test_with_token();

env.ledger().set_timestamp(500);

let market_id = create_simple_market(&client, &env, &user, &token);

let wrong_token = Address::generate(&env);
let result = client.try_place_bet(&user, &market_id, &0, &1000, &wrong_token, &None);
assert_eq!(result, Err(Ok(ErrorCode::InvalidToken)));
}

/// `withdraw_refund` with a token that is not the market token must return
/// `InvalidToken`, not `InvalidBetAmount`.
#[test]
fn test_withdraw_refund_wrong_token_returns_invalid_token() {
let (env, client, _admin, user, token) = setup_test_with_token();

env.ledger().set_timestamp(500);

let market_id = create_simple_market(&client, &env, &user, &token);
client.place_bet(&user, &market_id, &0, &1000, &token, &None);
client.cancel_market_admin(&market_id);

let wrong_token = Address::generate(&env);
let result = client.try_withdraw_refund(&user, &market_id, &0, &wrong_token);
assert_eq!(result, Err(Ok(ErrorCode::InvalidToken)));
}

/// `withdraw_refund` by a caller who simply has no bet must report
/// `BetNotFound` (or a zero refund), never `MarketNotFound`.
#[test]
fn test_withdraw_refund_no_bet_returns_bet_not_found() {
let (env, client, _admin, user, token) = setup_test_with_token();

env.ledger().with_mut(|li| li.timestamp = 500);

let market_id = create_simple_market(&client, &env, &user, &token);
client.cancel_market_admin(&market_id);

// `user` is the creator but never placed a bet on outcome 1.
let result = client.try_withdraw_refund(&user, &market_id, &1, &token);
assert_ne!(
result,
Err(Ok(ErrorCode::MarketNotFound)),
"missing bet must not be reported as MarketNotFound"
);
assert!(
result == Err(Ok(ErrorCode::BetNotFound)) || result == Ok(Ok(0)),
"missing bet must yield BetNotFound or a zero refund"
);
}
101 changes: 99 additions & 2 deletions contracts/predict-iq/src/modules/cancellation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@ use crate::types::{MarketStatus, CANCEL_OUTCOME_INDEX};
use soroban_sdk::{Address, Env};

const FAILED_MARKET_THRESHOLD_BPS: i128 = 7500; // 75% vote required to cancel
/// Voting window length, matching the 72h rule used by `finalize_resolution`.
const VOTING_PERIOD_SECONDS: u64 = 72 * 60 * 60;
/// Minimum number of votes cast (summed across all outcomes) required before a
/// vote-driven cancellation can be evaluated.
const CANCEL_QUORUM: i128 = 1;

/// Admin override to cancel a market
pub fn cancel_market_admin(e: &Env, market_id: u64) -> Result<(), ErrorCode> {
Expand Down Expand Up @@ -35,15 +40,34 @@ pub fn cancel_market_vote(e: &Env, market_id: u64) -> Result<(), ErrorCode> {
return Err(ErrorCode::MarketNotDisputed);
}

// Issue #1692: the voting window must have closed before a vote-driven
// cancellation can be evaluated, using the same 72h rule as
// `finalize_resolution`. Otherwise a single early voter holding 100% of a
// tiny tally could cancel the market immediately.
let voting_start = market
.dispute_timestamp
.ok_or(ErrorCode::VotingNotStarted)?;
let voting_end = voting_start
.checked_add(VOTING_PERIOD_SECONDS)
.ok_or(ErrorCode::ArithmeticOverflow)?;
if e.ledger().timestamp() < voting_end {
return Err(ErrorCode::VotingNotEnded);
}

// Calculate if cancellation threshold is met
let cancel_votes = crate::modules::voting::get_tally(e, market_id, CANCEL_OUTCOME_INDEX);
let mut total_votes = cancel_votes;

for outcome in 0..market.options.len() {
total_votes += crate::modules::voting::get_tally(e, market_id, outcome);
let tally = crate::modules::voting::get_tally(e, market_id, outcome);
total_votes = total_votes
.checked_add(tally)
.ok_or(ErrorCode::ArithmeticOverflow)?;
}

if total_votes == 0 {
// Issue #1692: require a quorum of votes cast before evaluating the
// cancellation threshold.
if total_votes < CANCEL_QUORUM {
return Err(ErrorCode::InsufficientVotingWeight);
}

Expand Down Expand Up @@ -163,6 +187,8 @@ mod cancellation_event_tests {
use soroban_sdk::String;

// Seed market in Disputed status (required by cancel_market_vote).
// The dispute timestamp is set far enough in the past that the 72h
// voting window has already closed.
let market = Market {
id: market_id,
creator: Address::generate(&e),
Expand Down Expand Up @@ -212,4 +238,75 @@ mod cancellation_event_tests {
"EVENT_VERSION ({version_debug}) not found in event data: {events_debug}"
);
}

/// Issue #1692: a vote-driven cancellation must not succeed while the
/// voting window is still open, even if the cancel tally already exceeds
/// the 75% threshold.
#[test]
fn cancel_market_vote_rejected_before_voting_window_closes() {
let e = Env::default();
e.mock_all_auths();

let (contract_id, _admin) = setup_contract_with_admin(&e);
let market_id: u64 = 8;

e.as_contract(&contract_id, || {
use crate::types::{Market, MarketStatus, OracleConfig};

let now = e.ledger().timestamp();
let market = Market {
id: market_id,
creator: Address::generate(&e),
status: MarketStatus::Disputed,
winning_outcome: None,
resolved_at: None,
dispute_timestamp: Some(now),
pending_resolution_timestamp: None,
resolution_deadline: 9_999_999,
oracle_config: OracleConfig::default(),
};
crate::modules::markets::update_market(&e, market);

crate::modules::voting::set_tally_for_test(
&e,
market_id,
crate::types::CANCEL_OUTCOME_INDEX,
10_000,
);

let result = cancel_market_vote(&e, market_id);
assert_eq!(result, Err(ErrorCode::VotingNotEnded));
});
}

/// Issue #1692: a vote-driven cancellation must be rejected when no votes
/// have been cast (quorum not met), even after the window has closed.
#[test]
fn cancel_market_vote_rejected_without_quorum() {
let e = Env::default();
e.mock_all_auths();

let (contract_id, _admin) = setup_contract_with_admin(&e);
let market_id: u64 = 9;

e.as_contract(&contract_id, || {
use crate::types::{Market, MarketStatus, OracleConfig};

let market = Market {
id: market_id,
creator: Address::generate(&e),
status: MarketStatus::Disputed,
winning_outcome: None,
resolved_at: None,
dispute_timestamp: Some(0),
pending_resolution_timestamp: None,
resolution_deadline: 9_999_999,
oracle_config: OracleConfig::default(),
};
crate::modules::markets::update_market(&e, market);

let result = cancel_market_vote(&e, market_id);
assert_eq!(result, Err(ErrorCode::InsufficientVotingWeight));
});
}
}
8 changes: 4 additions & 4 deletions contracts/predict-iq/src/modules/governance.rs
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ pub fn vote_on_guardian_removal(e: &Env, voter: Address, approve: bool) -> Resul
.storage()
.persistent()
.get::<_, crate::types::PendingGuardianRemoval>(&ConfigKey::PendingGuardianRemoval)
.ok_or(ErrorCode::GuardianNotSet)?;
.ok_or(ErrorCode::RemovalNotPending)?;

// Issue #1194: a stale proposal that nobody resolved must be re-initiated
// rather than staying open forever, slowly accumulating "yes" votes.
Expand All @@ -149,18 +149,18 @@ pub fn vote_on_guardian_removal(e: &Env, voter: Address, approve: bool) -> Resul
e.storage()
.persistent()
.remove(&ConfigKey::PendingGuardianRemovalPassedAt);
return Err(ErrorCode::GuardianNotSet);
return Err(ErrorCode::RemovalExpired);
}

// Check if voter already voted, either way
for v in pending_removal.votes_for.iter() {
if v == voter {
return Err(ErrorCode::AlreadyVotedOnUpgrade);
return Err(ErrorCode::AlreadyVotedOnRemoval);
}
}
for v in pending_removal.votes_against.iter() {
if v == voter {
return Err(ErrorCode::AlreadyVotedOnUpgrade);
return Err(ErrorCode::AlreadyVotedOnRemoval);
}
}

Expand Down
9 changes: 8 additions & 1 deletion contracts/predict-iq/src/modules/markets.rs
Original file line number Diff line number Diff line change
Expand Up @@ -463,7 +463,14 @@ pub fn release_creation_deposit(
// Only the market creator may reclaim their own deposit
market.creator.require_auth();

if market.status != MarketStatus::Resolved {
// The deposit is reclaimable once the market reaches a terminal state:
// either it resolved (creator's job is done) or it was cancelled (the
// market never ran, so the creator should not forfeit the deposit).
// This is intentionally independent of whether the creator placed a bet.
if !matches!(
market.status,
MarketStatus::Resolved | MarketStatus::Cancelled
) {
return Err(ErrorCode::MarketNotActive);
}

Expand Down
Loading