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
209 changes: 209 additions & 0 deletions contracts/poker-table/src/anti_cheat.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
//! Anti-cheat chip dumping detection module.
//!
//! Detects suspicious patterns indicating chip dumping:
//! - Repeated small losses from one player to another
//! - Abnormal fold rates against specific opponents
//! - Short-stack targeting behavior

use soroban_sdk::{Address, Env, Vec};

/// Threshold for repeated losses to trigger flagging (number of hands)
const REPEATED_LOSS_THRESHOLD: u32 = 5;
/// Minimum fold rate against an opponent to be considered suspicious (percentage)
const ABNORMAL_FOLD_RATE_THRESHOLD: u32 = 80;
/// Number of hands to track for pattern detection
const TRACKING_WINDOW: u32 = 20;

/// Pattern detection data for a player pair
#[derive(Clone, Debug)]
pub struct PlayerInteractionStats {
/// Number of hands where player A lost to player B
pub losses_to_opponent: u32,
/// Number of hands where player A folded when facing player B
pub folds_against_opponent: u32,
/// Total hands where player A and player B were both active
pub total_interactions: u32,
/// Average amount lost per hand to opponent
pub avg_loss_amount: i128,
}

impl PlayerInteractionStats {
pub fn new() -> Self {
Self {
losses_to_opponent: 0,
folds_against_opponent: 0,
total_interactions: 0,
avg_loss_amount: 0,
}
}

/// Calculate fold rate as percentage
pub fn fold_rate(&self) -> u32 {
if self.total_interactions == 0 {
return 0;
}
(self.folds_against_opponent * 100) / self.total_interactions
}

/// Check if pattern indicates chip dumping
pub fn is_suspicious(&self) -> bool {
// Check for repeated losses
if self.losses_to_opponent >= REPEATED_LOSS_THRESHOLD {
return true;
}

// Check for abnormally high fold rate
if self.fold_rate() >= ABNORMAL_FOLD_RATE_THRESHOLD {
return true;
}

// Check for consistent small losses (potential intentional dumping)
if self.losses_to_opponent > 3
&& self.avg_loss_amount > 0
&& self.avg_loss_amount < 500
{
// Consistent small losses might indicate controlled dumping
return true;
}

false
}
}

/// Chip dumping detection result
#[derive(Clone, Debug)]
pub struct ChipDumpingFlag {
pub suspected_dumper: Address,
pub suspected_receiver: Address,
pub reason: ChipDumpingReason,
pub confidence: u32, // 0-100 percentage
}

#[derive(Clone, Debug)]
pub enum ChipDumpingReason {
RepeatedLosses,
AbnormalFoldRate,
ShortStackTargeting,
SuspiciousLossPattern,
}

/// Analyze player interaction history for chip dumping patterns
pub fn detect_chip_dumping(
_env: &Env,
player_a: &Address,
player_b: &Address,
stats: &PlayerInteractionStats,
) -> Option<ChipDumpingFlag> {
if !stats.is_suspicious() {
return None;
}

let mut confidence: u32 = 0;
let mut reason = ChipDumpingReason::SuspiciousLossPattern;

// Calculate confidence based on multiple factors
if stats.losses_to_opponent >= REPEATED_LOSS_THRESHOLD {
confidence += 40;
reason = ChipDumpingReason::RepeatedLosses;
}

let fold_rate = stats.fold_rate();
if fold_rate >= ABNORMAL_FOLD_RATE_THRESHOLD {
confidence += 35;
if confidence == 35 {
reason = ChipDumpingReason::AbnormalFoldRate;
}
}

// Small consistent losses pattern
if stats.losses_to_opponent > 3
&& stats.avg_loss_amount > 0
&& stats.avg_loss_amount < 500
{
confidence += 25;
}

// Cap confidence at 100
confidence = confidence.min(100);

if confidence >= 50 {
Some(ChipDumpingFlag {
suspected_dumper: player_a.clone(),
suspected_receiver: player_b.clone(),
reason,
confidence,
})
} else {
None
}
}

/// Track a hand outcome for chip dumping analysis
pub fn record_hand_outcome(
stats: &mut PlayerInteractionStats,
player_a_won: bool,
player_a_folded: bool,
pot_amount: i128,
) {
stats.total_interactions += 1;

if player_a_folded {
stats.folds_against_opponent += 1;
}

if !player_a_won && !player_a_folded {
stats.losses_to_opponent += 1;

// Update average loss amount
let total_losses = stats.losses_to_opponent as i128;
if total_losses > 0 {
let prev_total = stats.avg_loss_amount * (total_losses - 1);
stats.avg_loss_amount = (prev_total + pot_amount) / total_losses;
}
}

// Keep window size limited
if stats.total_interactions > TRACKING_WINDOW {
// Simple decay: reduce all counters proportionally
let decay_factor = TRACKING_WINDOW as f64 / stats.total_interactions as f64;
stats.losses_to_opponent =
(stats.losses_to_opponent as f64 * decay_factor) as u32;
stats.folds_against_opponent =
(stats.folds_against_opponent as f64 * decay_factor) as u32;
stats.total_interactions = TRACKING_WINDOW;
}
}

/// Get all flagged player pairs for admin review
pub fn get_flagged_interactions(
env: &Env,
all_stats: &Vec<(Address, Address, PlayerInteractionStats)>,
) -> Vec<ChipDumpingFlag> {
let mut flags: Vec<ChipDumpingFlag> = Vec::new(env);

for i in 0..all_stats.len() {
if let Some((player_a, player_b, stats)) = all_stats.get(i) {
if let Some(flag) = detect_chip_dumping(env, &player_a, &player_b, &stats) {
flags.push_back(flag);
}
}
}

flags
}

/// Short-stack targeting detection: check if a player consistently
/// targets opponents with low chip counts
pub fn detect_short_stack_targeting(
_env: &Env,
aggressive_player_wins_vs_short_stacks: u32,
total_wins: u32,
) -> bool {
if total_wins < 5 {
return false; // Not enough data
}

// If more than 70% of wins are against short stacks, flag it
let short_stack_win_rate = (aggressive_player_wins_vs_short_stacks * 100) / total_wins;
short_stack_win_rate >= 70
}
86 changes: 86 additions & 0 deletions contracts/poker-table/src/ban_list.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
use soroban_sdk::{Address, Env, Map, Symbol};
use crate::types::*;

/// Player ban/unban list for table owners
/// Issue #195

const BAN_LIST: Symbol = Symbol::short("BANLIST");

/// Ban a player from the table (owner only)
pub fn ban_player(
env: &Env,
table: &TableState,
caller: &Address,
player: Address,
) -> Result<(), PokerTableError> {
// Only table admin can ban players
if caller != &table.admin {
return Err(PokerTableError::NotAuthorizedCommittee);
}

caller.require_auth();

let mut ban_list: Map<Address, bool> = env
.storage()
.persistent()
.get(&BAN_LIST)
.unwrap_or(Map::new(env));

ban_list.set(player.clone(), true);
env.storage().persistent().set(&BAN_LIST, &ban_list);

// Emit event
env.events()
.publish((Symbol::new(env, "player_banned"),), player);

Ok(())
}

/// Unban a player from the table (owner only)
pub fn unban_player(
env: &Env,
table: &TableState,
caller: &Address,
player: Address,
) -> Result<(), PokerTableError> {
// Only table admin can unban players
if caller != &table.admin {
return Err(PokerTableError::NotAuthorizedCommittee);
}

caller.require_auth();

let mut ban_list: Map<Address, bool> = env
.storage()
.persistent()
.get(&BAN_LIST)
.unwrap_or(Map::new(env));

ban_list.set(player.clone(), false);
env.storage().persistent().set(&BAN_LIST, &ban_list);

// Emit event
env.events()
.publish((Symbol::new(env, "player_unbanned"),), player);

Ok(())
}

/// Check if a player is banned
pub fn is_player_banned(env: &Env, player: &Address) -> bool {
let ban_list: Map<Address, bool> = env
.storage()
.persistent()
.get(&BAN_LIST)
.unwrap_or(Map::new(env));

ban_list.get(player.clone()).unwrap_or(false)
}

/// Get all banned players
pub fn get_banned_players(env: &Env) -> Map<Address, bool> {
env.storage()
.persistent()
.get(&BAN_LIST)
.unwrap_or(Map::new(env))
}
70 changes: 70 additions & 0 deletions contracts/poker-table/src/hand_cancellation.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
use soroban_sdk::{Env, Symbol};
use crate::types::*;

/// Hand cancellation mechanism for invalid states
/// Issue #194

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum CancellationReason {
InvalidProof,
MpcFailure,
PlayerDisconnect,
Timeout,
}

/// Cancel current hand and refund all bets
pub fn cancel_hand(
env: &Env,
table: &mut TableState,
reason: CancellationReason,
) -> Result<i128, PokerTableError> {
// Only allow cancellation during active gameplay
if table.phase == GamePhase::Settlement || table.phase == GamePhase::WaitingForPlayers {
return Err(PokerTableError::InvalidAction);
}

// Refund all active bets to players
let refunded = crate::refund_table_players(env, table)?;

// Reset game state
table.phase = GamePhase::Settlement;
table.pot = 0;
table.current_bet = 0;
table.last_raise_amount = 0;

// Clear board cards
table.board_card_indices = soroban_sdk::Vec::new(env);

// Emit cancellation event
let event_name = match reason {
CancellationReason::InvalidProof => Symbol::new(env, "hand_cancelled_invalid_proof"),
CancellationReason::MpcFailure => Symbol::new(env, "hand_cancelled_mpc_failure"),
CancellationReason::PlayerDisconnect => Symbol::new(env, "hand_cancelled_disconnect"),
CancellationReason::Timeout => Symbol::new(env, "hand_cancelled_timeout"),
};

env.events().publish((event_name,), refunded);

Ok(refunded)
}

/// Check if hand should be auto-cancelled due to invalid state
pub fn should_cancel_hand(table: &TableState, current_ledger: u32) -> bool {
// Cancel if stuck in same phase for too long (5 minutes = ~60 ledgers)
if current_ledger > table.last_action_ledger + 60 {
return true;
}

// Cancel if too few active players mid-hand
let active_players = table
.players
.iter()
.filter(|p| !p.folded && p.stack > 0)
.count();

if table.phase != GamePhase::Settlement && active_players < 2 {
return true;
}

false
}
Loading
Loading