From 31c8b4ebcc7390313937322f22c2a98f89d44c49 Mon Sep 17 00:00:00 2001 From: Duro Date: Fri, 26 Jun 2026 23:20:12 +0000 Subject: [PATCH] fix: reject self-vote by dispute opener in DisputeManager --- contracts/predictify-hybrid/src/disputes.rs | 111 ++++++++++++++++++ contracts/predictify-hybrid/src/err.rs | 4 + contracts/predictify-hybrid/src/events.rs | 37 ++++++ .../src/require_auth_coverage_tests.rs | 8 +- 4 files changed, 156 insertions(+), 4 deletions(-) diff --git a/contracts/predictify-hybrid/src/disputes.rs b/contracts/predictify-hybrid/src/disputes.rs index 0fca5efc..81cfd03e 100644 --- a/contracts/predictify-hybrid/src/disputes.rs +++ b/contracts/predictify-hybrid/src/disputes.rs @@ -1415,6 +1415,21 @@ impl DisputeManager { // Require authentication from the user user.require_auth(); + // Reject self-vote: the dispute opener cannot vote on their own dispute + let market = MarketStateManager::get_market(env, &market_id)?; + if market.dispute_stakes.contains_key(user.clone()) { + crate::events::EventEmitter::emit_dispute_vote_rejected( + env, + &dispute_id, + &user, + &soroban_sdk::String::from_str( + env, + "Dispute opener cannot vote on their own dispute", + ), + ); + return Err(Error::DisputerCannotVote); + } + // Validate dispute voting conditions DisputeValidator::validate_dispute_voting_conditions(env, &market_id, &dispute_id)?; @@ -3245,4 +3260,100 @@ mod tests { assert_eq!(analytics.is_expired, false); assert_eq!(analytics.status, DisputeTimeoutStatus::Active); } + + #[test] + fn test_disputer_cannot_vote_on_own_dispute() { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register(crate::PredictifyHybrid, ()); + let admin = Address::generate(&env); + crate::PredictifyHybridClient::new(&env, &contract_id) + .initialize(&admin, &Some(200i128), &None); + + let market_id = Symbol::new(&env, "market_self_vote"); + let disputer = Address::generate(&env); + + env.as_contract(&contract_id, || { + let mut market = create_test_market(&env, env.ledger().timestamp().saturating_sub(1)); + market.oracle_result = Some(String::from_str(&env, "yes")); + market.dispute_stakes.set(disputer.clone(), 1000); + MarketStateManager::update_market(&env, &market_id, &market); + + let voting = DisputeVoting { + dispute_id: market_id.clone(), + voting_start: 0, + voting_end: env.ledger().timestamp() + 86400, + total_votes: 0, + support_votes: 0, + against_votes: 0, + total_support_stake: 0, + total_against_stake: 0, + status: DisputeVotingStatus::Active, + }; + DisputeUtils::store_dispute_voting(&env, &market_id, &voting).unwrap(); + }); + + let result = env.as_contract(&contract_id, || { + DisputeManager::vote_on_dispute( + &env, + disputer.clone(), + market_id.clone(), + market_id.clone(), + false, + 500, + Some(String::from_str(&env, "Voting against own dispute")), + ) + }); + + assert_eq!(result, Err(Error::DisputerCannotVote)); + } + + #[test] + fn test_non_disputer_can_pass_self_vote_check() { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register(crate::PredictifyHybrid, ()); + let admin = Address::generate(&env); + crate::PredictifyHybridClient::new(&env, &contract_id) + .initialize(&admin, &Some(200i128), &None); + + let market_id = Symbol::new(&env, "market_non_disputer"); + let disputer = Address::generate(&env); + let voter = Address::generate(&env); + + env.as_contract(&contract_id, || { + let mut market = create_test_market(&env, env.ledger().timestamp().saturating_sub(1)); + market.oracle_result = Some(String::from_str(&env, "yes")); + market.dispute_stakes.set(disputer.clone(), 1000); + MarketStateManager::update_market(&env, &market_id, &market); + + let voting = DisputeVoting { + dispute_id: market_id.clone(), + voting_start: 0, + voting_end: env.ledger().timestamp() + 86400, + total_votes: 0, + support_votes: 0, + against_votes: 0, + total_support_stake: 0, + total_against_stake: 0, + status: DisputeVotingStatus::Active, + }; + DisputeUtils::store_dispute_voting(&env, &market_id, &voting).unwrap(); + }); + + let result = env.as_contract(&contract_id, || { + DisputeManager::vote_on_dispute( + &env, + voter.clone(), + market_id.clone(), + market_id.clone(), + false, + 500, + Some(String::from_str(&env, "Voting against")), + ) + }); + + // Should NOT be DisputerCannotVote (though it will likely fail on token transfer) + assert_ne!(result, Err(Error::DisputerCannotVote)); + } } diff --git a/contracts/predictify-hybrid/src/err.rs b/contracts/predictify-hybrid/src/err.rs index dbfa75ce..dd60dd07 100644 --- a/contracts/predictify-hybrid/src/err.rs +++ b/contracts/predictify-hybrid/src/err.rs @@ -121,6 +121,8 @@ pub enum Error { DisputeFeeFailed = 409, /// Generic dispute subsystem error. Check dispute state and configuration. DisputeError = 410, + /// The dispute opener cannot vote on their own dispute. + DisputerCannotVote = 419, /// Unclaimed winnings have already been swept for this market. Repeat sweeps are not allowed. SweepAlreadyDone = 411, /// Fee arithmetic overflowed during checked platform-fee calculation. @@ -1380,6 +1382,7 @@ impl Error { Error::DisputeCondNotMet => "Dispute resolution conditions not met", Error::DisputeFeeFailed => "Dispute fee distribution failed", Error::DisputeError => "Generic dispute subsystem error", + Error::DisputerCannotVote => "Dispute opener cannot vote on their own dispute", Error::SweepAlreadyDone => "Unclaimed winnings already swept for this market", Error::FeeArithmeticOverflow => "Fee arithmetic overflowed", Error::FeeAlreadyCollected => "Platform fee already collected", @@ -1474,6 +1477,7 @@ impl Error { Error::DisputeCondNotMet => "DISPUTE_RESOLUTION_CONDITIONS_NOT_MET", Error::DisputeFeeFailed => "DISPUTE_FEE_DISTRIBUTION_FAILED", Error::DisputeError => "DISPUTE_ERROR", + Error::DisputerCannotVote => "DISPUTER_CANNOT_VOTE", Error::SweepAlreadyDone => "SWEEP_ALREADY_DONE", Error::FeeArithmeticOverflow => "FEE_ARITHMETIC_OVERFLOW", Error::FeeAlreadyCollected => "FEE_ALREADY_COLLECTED", diff --git a/contracts/predictify-hybrid/src/events.rs b/contracts/predictify-hybrid/src/events.rs index ee6b1de4..eae0fff6 100644 --- a/contracts/predictify-hybrid/src/events.rs +++ b/contracts/predictify-hybrid/src/events.rs @@ -1254,6 +1254,24 @@ pub struct DisputeTimeoutExtendedEvent { pub timestamp: u64, } +/// Event emitted when a vote on a dispute is rejected because the voter +/// is the same address that opened the dispute. +/// +/// This prevents the dispute opener from biasing the tally by voting +/// on their own dispute. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct DisputeVoteRejectedEvent { + /// Dispute ID + pub dispute_id: Symbol, + /// Voter address (same as the dispute opener) + pub voter: Address, + /// Reason for rejection + pub reason: String, + /// Rejection timestamp + pub timestamp: u64, +} + /// Dispute auto-resolved event #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] @@ -2844,6 +2862,25 @@ impl EventEmitter { .publish((symbol_short!("tout_ext"), dispute_id.clone()), event); } + /// Emit dispute vote rejected event + pub fn emit_dispute_vote_rejected( + env: &Env, + dispute_id: &Symbol, + voter: &Address, + reason: &String, + ) { + let event = DisputeVoteRejectedEvent { + dispute_id: dispute_id.clone(), + voter: voter.clone(), + reason: reason.clone(), + timestamp: env.ledger().timestamp(), + }; + + Self::store_event(env, &symbol_short!("d_v_rej"), &event); + env.events() + .publish((symbol_short!("d_v_rej"), dispute_id.clone()), event); + } + /// Emit dispute auto-resolved event pub fn emit_dispute_auto_resolved( env: &Env, diff --git a/contracts/predictify-hybrid/src/require_auth_coverage_tests.rs b/contracts/predictify-hybrid/src/require_auth_coverage_tests.rs index 47c5fcae..43f36bf6 100644 --- a/contracts/predictify-hybrid/src/require_auth_coverage_tests.rs +++ b/contracts/predictify-hybrid/src/require_auth_coverage_tests.rs @@ -1005,7 +1005,7 @@ fn test_set_event_bet_limits_forged_admin_rejected() { #[test] fn test_set_oracle_val_cfg_global_authorized_admin_succeeds() { let (env, cid, admin) = setup(); - let result = client(&env, &cid).try_set_oracle_val_cfg_global(&admin, &300u64, &9500u32); + let result = client(&env, &cid).try_set_oracle_val_cfg_global(&admin, &300u64, &9500u32, &None::); assert_auth_ok_contract!(result, "set_oracle_val_cfg_global rejected authorized admin"); } @@ -1014,7 +1014,7 @@ fn test_set_oracle_val_cfg_global_authorized_admin_succeeds() { fn test_set_oracle_val_cfg_global_forged_admin_rejected() { let (env, cid, _admin) = setup(); let attacker = Address::generate(&env); - let result = client(&env, &cid).try_set_oracle_val_cfg_global(&attacker, &300u64, &9500u32); + let result = client(&env, &cid).try_set_oracle_val_cfg_global(&attacker, &300u64, &9500u32, &None::); assert_unauthorized_contract!(result); } @@ -1026,7 +1026,7 @@ fn test_set_oracle_val_cfg_event_authorized_admin_succeeds() { let (env, cid, admin) = setup(); let market_id = make_market(&env, &cid, &admin); let result = client(&env, &cid).try_set_oracle_val_cfg_event( - &admin, &market_id, &300u64, &9500u32, + &admin, &market_id, &300u64, &9500u32, &None::, ); assert_auth_ok_contract!(result, "set_oracle_val_cfg_event rejected authorized admin"); } @@ -1038,7 +1038,7 @@ fn test_set_oracle_val_cfg_event_forged_admin_rejected() { let market_id = make_market(&env, &cid, &admin); let attacker = Address::generate(&env); let result = client(&env, &cid).try_set_oracle_val_cfg_event( - &attacker, &market_id, &300u64, &9500u32, + &attacker, &market_id, &300u64, &9500u32, &None::, ); assert_unauthorized_contract!(result); }