From add2f6c2e04d821fd3f5eba21b5578545b6e142b Mon Sep 17 00:00:00 2001 From: Olawale880 Date: Thu, 2 Jul 2026 09:56:22 +0100 Subject: [PATCH] feat: Add Match Rollback for Disconnection Disputes --- contracts/escrow/src/errors.rs | 1 + contracts/escrow/src/lib.rs | 102 ++++++++++++++++ contracts/escrow/src/tests/dispute.rs | 163 ++++++++++++++++++++++++++ contracts/escrow/src/types.rs | 3 + 4 files changed, 269 insertions(+) diff --git a/contracts/escrow/src/errors.rs b/contracts/escrow/src/errors.rs index c11bff6..a20ef8b 100644 --- a/contracts/escrow/src/errors.rs +++ b/contracts/escrow/src/errors.rs @@ -41,4 +41,5 @@ pub enum Error { NotInitialized = 36, InvalidPauseState = 37, InvalidConversionRate = 38, + DisputeWindowElapsed = 39, } diff --git a/contracts/escrow/src/lib.rs b/contracts/escrow/src/lib.rs index 3921925..b5e4336 100644 --- a/contracts/escrow/src/lib.rs +++ b/contracts/escrow/src/lib.rs @@ -412,6 +412,7 @@ impl EscrowContract { token_b: None, paused_ledger: None, total_pause_duration: 0, + last_heartbeat: env.ledger().sequence() as u64, }; env.storage().persistent().set(&DataKey::Match(id), &m); @@ -577,6 +578,7 @@ impl EscrowContract { token_b: Some(token_b), paused_ledger: None, total_pause_duration: 0, + last_heartbeat: env.ledger().sequence() as u64, }; env.storage().persistent().set(&DataKey::Match(id), &m); @@ -2456,6 +2458,106 @@ impl EscrowContract { Ok(()) } + /// Dispute and rollback a completed match within the 24-hour window. + /// + /// Either player can dispute a completed match if they believe the result + /// was incorrect (e.g., due to opponent disconnection). The dispute must be + /// raised within 24 hours (17,280 ledgers at 5s/ledger) of match completion. + /// + /// Upon successful dispute, both players receive their stakes back and the + /// match is set to Cancelled state. + /// + /// # Parameters + /// - `match_id`: The ID of the completed match to dispute + /// - `disputer`: The address of the player raising the dispute + /// - `reason`: A description of why the match is being disputed + /// + /// # Errors + /// - `Error::MatchNotFound` — no match exists for `match_id` + /// - `Error::InvalidState` — match is not in Completed state + /// - `Error::Unauthorized` — caller is not one of the match players + /// `Error::DisputeWindowElapsed` — more than 24 hours have passed since completion + pub fn dispute_and_rollback_match( + env: Env, + match_id: u64, + disputer: Address, + reason: String, + ) -> Result<(), Error> { + extend_instance_ttl(&env); + disputer.require_auth(); + + let mut m: Match = env + .storage() + .persistent() + .get(&DataKey::Match(match_id)) + .ok_or(Error::MatchNotFound)?; + + // Match must be in Completed state + if m.state != MatchState::Completed { + return Err(Error::InvalidState); + } + + // Only match participants may dispute + let is_p1 = disputer == m.player1; + let is_p2 = disputer == m.player2; + + if !is_p1 && !is_p2 { + return Err(Error::Unauthorized); + } + + // Check if within 24-hour dispute window (17,280 ledgers at 5s/ledger) + let completed_ledger = m.completed_ledger.ok_or(Error::InvalidState)?; + let current_ledger = env.ledger().sequence(); + let dispute_window: u32 = 17_280; // 24 hours in ledgers + + if current_ledger.saturating_sub(completed_ledger) > dispute_window { + return Err(Error::DisputeWindowElapsed); + } + + // Refund both players + let client = token::Client::new(&env, &m.token); + + if m.player1_deposited { + client.transfer(&env.current_contract_address(), &m.player1, &m.stake_amount); + } + if m.player2_deposited { + client.transfer(&env.current_contract_address(), &m.player2, &m.stake_amount); + } + + // Update match state to Cancelled + m.state = MatchState::Cancelled; + m.winner = None; + m.vested_at = None; + m.player1_claimed = false; + m.player2_claimed = false; + + env.storage() + .persistent() + .set(&DataKey::Match(match_id), &m); + env.storage().persistent().extend_ttl( + &DataKey::Match(match_id), + MATCH_TTL_LEDGERS, + MATCH_TTL_LEDGERS, + ); + + // Record snapshots for audit trail + Self::record_snapshot(&env, &m, SnapshotReason::Cancelled); + if m.player1_deposited { + Self::record_player_snapshot(&env, &m.player1); + } + if m.player2_deposited { + Self::record_player_snapshot(&env, &m.player2); + } + + // Emit rollback event + env.events().publish( + (Symbol::new(&env, "match"), symbol_short!("rollback")), + (match_id, disputer, reason), + ); + + Ok(()) + } + } impl EscrowContract { diff --git a/contracts/escrow/src/tests/dispute.rs b/contracts/escrow/src/tests/dispute.rs index 43b751c..5f5996e 100644 --- a/contracts/escrow/src/tests/dispute.rs +++ b/contracts/escrow/src/tests/dispute.rs @@ -758,3 +758,166 @@ fn test_full_dispute_lifecycle_overturned() { let dispute = client.get_dispute(&dispute_id); assert_eq!(dispute.state, DisputeState::ResolvedOverturned); } + +// ── dispute_and_rollback_match ──────────────────────────────────────────────── + +#[test] +fn test_dispute_and_rollback_match_within_window() { + let (env, contract_id, oracle, player1, player2, token, _admin) = + setup_with_dispute_period(100); + let client = EscrowContractClient::new(&env, &contract_id); + let token_client = TokenClient::new(&env, &token); + + let match_id = create_funded_active_match(&client, &env, &player1, &player2, &token, "rollback_ok"); + + // Oracle submits result (immediate payout since dispute_period > 0 but we're testing rollback) + env.ledger().set_sequence_number(1000); + client.submit_result(&match_id, &Winner::Player1); + + let m = client.get_match(&match_id); + assert_eq!(m.state, MatchState::Completed); + assert_eq!(token_client.balance(&player1), 1100); + assert_eq!(token_client.balance(&player2), 900); + + // Player2 disputes within 24-hour window (17,280 ledgers) + env.ledger().set_sequence_number(1001); + client.dispute_and_rollback_match( + &match_id, + &player2, + &String::from_str(&env, "opponent disconnected"), + ); + + // Match should be cancelled and both players refunded + let m = client.get_match(&match_id); + assert_eq!(m.state, MatchState::Cancelled); + assert_eq!(m.winner, None); + assert_eq!(token_client.balance(&player1), 1000); + assert_eq!(token_client.balance(&player2), 1000); + assert_eq!(client.get_escrow_balance(&match_id), 0); +} + +#[test] +fn test_dispute_and_rollback_match_after_window_fails() { + let (env, contract_id, oracle, player1, player2, token, _admin) = + setup_with_dispute_period(100); + let client = EscrowContractClient::new(&env, &contract_id); + let token_client = TokenClient::new(&env, &token); + + let match_id = create_funded_active_match(&client, &env, &player1, &player2, &token, "rollback_late"); + + env.ledger().set_sequence_number(1000); + client.submit_result(&match_id, &Winner::Player1); + + assert_eq!(token_client.balance(&player1), 1100); + + // Try to dispute after 24-hour window (17,281 ledgers later) + env.ledger().set_sequence_number(1000 + 17_281); + let result = client.try_dispute_and_rollback_match( + &match_id, + &player2, + &String::from_str(&env, "too late"), + ); + assert_eq!(result, Err(Ok(Error::DisputeWindowElapsed))); + + // Funds should not be refunded + assert_eq!(token_client.balance(&player1), 1100); + assert_eq!(token_client.balance(&player2), 900); +} + +#[test] +fn test_dispute_and_rollback_match_rejects_non_player() { + let (env, contract_id, oracle, player1, player2, token, _admin) = + setup_with_dispute_period(100); + let client = EscrowContractClient::new(&env, &contract_id); + + let match_id = create_funded_active_match(&client, &env, &player1, &player2, &token, "rollback_unauth"); + + env.ledger().set_sequence_number(1000); + client.submit_result(&match_id, &Winner::Player1); + + let stranger = Address::generate(&env); + let result = client.try_dispute_and_rollback_match( + &match_id, + &stranger, + &String::from_str(&env, "not a player"), + ); + assert_eq!(result, Err(Ok(Error::Unauthorized))); +} + +#[test] +fn test_dispute_and_rollback_match_rejects_non_completed() { + let (env, contract_id, oracle, player1, player2, token, _admin) = + setup_with_dispute_period(100); + let client = EscrowContractClient::new(&env, &contract_id); + + let match_id = create_funded_active_match(&client, &env, &player1, &player2, &token, "rollback_badstate"); + + // Try to rollback an Active match (not Completed) + let result = client.try_dispute_and_rollback_match( + &match_id, + &player1, + &String::from_str(&env, "not completed"), + ); + assert_eq!(result, Err(Ok(Error::InvalidState))); +} + +#[test] +fn test_dispute_and_rollback_match_emits_event() { + let (env, contract_id, oracle, player1, player2, token, _admin) = + setup_with_dispute_period(100); + let client = EscrowContractClient::new(&env, &contract_id); + + let match_id = create_funded_active_match(&client, &env, &player1, &player2, &token, "rollback_evt"); + + env.ledger().set_sequence_number(1000); + client.submit_result(&match_id, &Winner::Player1); + + env.ledger().set_sequence_number(1001); + client.dispute_and_rollback_match( + &match_id, + &player2, + &String::from_str(&env, "disconnected"), + ); + + let events = env.events().all(); + let expected_topics = vec![ + &env, + Symbol::new(&env, "match").into_val(&env), + Symbol::new(&env, "rollback").into_val(&env), + ]; + let matched = events + .iter() + .find(|(_, topics, _)| *topics == expected_topics); + assert!(matched.is_some(), "match/rollback event not emitted"); + + let (_, _, data) = matched.unwrap(); + let (ev_match_id, ev_disputer, ev_reason): (u64, Address, String) = + TryFromVal::try_from_val(&env, &data).unwrap(); + assert_eq!(ev_match_id, match_id); + assert_eq!(ev_disputer, player2); + assert_eq!(ev_reason, String::from_str(&env, "disconnected")); +} + +#[test] +fn test_dispute_and_rollback_match_both_players_can_dispute() { + let (env, contract_id, oracle, player1, player2, token, _admin) = + setup_with_dispute_period(100); + let client = EscrowContractClient::new(&env, &contract_id); + let token_client = TokenClient::new(&env, &token); + + let match_id = create_funded_active_match(&client, &env, &player1, &player2, &token, "rollback_p1"); + + env.ledger().set_sequence_number(1000); + client.submit_result(&match_id, &Winner::Player2); + + // Player1 disputes + env.ledger().set_sequence_number(1001); + client.dispute_and_rollback_match( + &match_id, + &player1, + &String::from_str(&env, "opponent disconnected"), + ); + + assert_eq!(token_client.balance(&player1), 1000); + assert_eq!(token_client.balance(&player2), 1000); +} diff --git a/contracts/escrow/src/types.rs b/contracts/escrow/src/types.rs index e63ee24..3d415b8 100644 --- a/contracts/escrow/src/types.rs +++ b/contracts/escrow/src/types.rs @@ -72,6 +72,9 @@ pub struct Match { pub paused_ledger: Option, /// Total pause duration in ledgers. pub total_pause_duration: u32, + /// Last activity timestamp (ledger sequence) for tracking player disconnection. + /// Updated on match actions to enable rollback for disconnection disputes. + pub last_heartbeat: u64, } #[contracttype]