From 88e9eb07bf130cfef3889e6fb67c8f41cecb8a53 Mon Sep 17 00:00:00 2001 From: Umeokonkwo Samuel Date: Tue, 30 Jun 2026 09:21:25 +0100 Subject: [PATCH] feat: implement match pause and resume for extended games (#952) - Add Paused state to MatchState enum - Add paused_ledger and total_pause_duration fields to Match struct - Implement pause_match() - either player can pause an active match - Implement resume_match() - either player can resume a paused match - Update expire_match() to exclude pause duration from timeout calculation - Add SnapshotReason::Paused and SnapshotReason::Resumed variants - Add comprehensive tests for pause/resume cycle - Add error variants: MatchNotPaused, MatchAlreadyPaused, InvalidPauseState --- contracts/escrow/src/errors.rs | 3 + contracts/escrow/src/lib.rs | 103 +++++++- contracts/escrow/src/tests/lifecycle.rs | 323 ++++++++++++++++++++++++ contracts/escrow/src/types.rs | 7 + 4 files changed, 434 insertions(+), 2 deletions(-) diff --git a/contracts/escrow/src/errors.rs b/contracts/escrow/src/errors.rs index ff7a7776..633ac869 100644 --- a/contracts/escrow/src/errors.rs +++ b/contracts/escrow/src/errors.rs @@ -24,4 +24,7 @@ pub enum Error { MatchAlreadyActive = 19, InvalidTimeout = 20, SnapshotNotFound = 21, + MatchNotPaused = 22, + MatchAlreadyPaused = 23, + InvalidPauseState = 24, } diff --git a/contracts/escrow/src/lib.rs b/contracts/escrow/src/lib.rs index 27a64090..a3c02e86 100644 --- a/contracts/escrow/src/lib.rs +++ b/contracts/escrow/src/lib.rs @@ -344,6 +344,8 @@ impl EscrowContract { player2_deposited: false, created_ledger: env.ledger().sequence(), completed_ledger: None, + paused_ledger: None, + total_pause_duration: 0, }; env.storage().persistent().set(&DataKey::Match(id), &m); @@ -639,8 +641,103 @@ impl EscrowContract { Ok(()) } + /// Pause an active match — either player can pause. + /// Sets match state to Paused and records the pause start ledger. + pub fn pause_match(env: Env, match_id: u64, caller: Address) -> Result<(), Error> { + extend_instance_ttl(&env); + caller.require_auth(); + + let mut m: Match = env + .storage() + .persistent() + .get(&DataKey::Match(match_id)) + .ok_or(Error::MatchNotFound)?; + + if m.state != MatchState::Active { + return Err(Error::InvalidPauseState); + } + + let is_p1 = caller == m.player1; + let is_p2 = caller == m.player2; + + if !is_p1 && !is_p2 { + return Err(Error::Unauthorized); + } + + m.state = MatchState::Paused; + m.paused_ledger = Some(env.ledger().sequence()); + env.storage() + .persistent() + .set(&DataKey::Match(match_id), &m); + env.storage().persistent().extend_ttl( + &DataKey::Match(match_id), + MATCH_TTL_LEDGERS, + MATCH_TTL_LEDGERS, + ); + + Self::record_snapshot(&env, &m, SnapshotReason::Paused); + + env.events().publish( + (Symbol::new(&env, "match"), symbol_short!("paused")), + match_id, + ); + + Ok(()) + } + + /// Resume a paused match — either player can resume. + /// Sets match state back to Active and accumulates pause duration. + pub fn resume_match(env: Env, match_id: u64, caller: Address) -> Result<(), Error> { + extend_instance_ttl(&env); + caller.require_auth(); + + let mut m: Match = env + .storage() + .persistent() + .get(&DataKey::Match(match_id)) + .ok_or(Error::MatchNotFound)?; + + if m.state != MatchState::Paused { + return Err(Error::InvalidState); + } + + let is_p1 = caller == m.player1; + let is_p2 = caller == m.player2; + + if !is_p1 && !is_p2 { + return Err(Error::Unauthorized); + } + + let current_ledger = env.ledger().sequence(); + if let Some(paused_at) = m.paused_ledger { + let pause_duration = current_ledger.saturating_sub(paused_at); + m.total_pause_duration = m.total_pause_duration.saturating_add(pause_duration); + } + + m.state = MatchState::Active; + m.paused_ledger = None; + env.storage() + .persistent() + .set(&DataKey::Match(match_id), &m); + env.storage().persistent().extend_ttl( + &DataKey::Match(match_id), + MATCH_TTL_LEDGERS, + MATCH_TTL_LEDGERS, + ); + + Self::record_snapshot(&env, &m, SnapshotReason::Resumed); + + env.events().publish( + (Symbol::new(&env, "match"), symbol_short!("resumed")), + match_id, + ); + + Ok(()) + } + /// Expire a pending match that has not been fully funded within MATCH_TIMEOUT_LEDGERS. /// Anyone can call this; funds are returned to whoever deposited. + /// Pause duration is excluded from the timeout calculation. pub fn expire_match(env: Env, match_id: u64) -> Result<(), Error> { extend_instance_ttl(&env); let mut m: Match = env @@ -653,10 +750,12 @@ impl EscrowContract { return Err(Error::InvalidState); } - let elapsed = env.ledger().sequence().saturating_sub(m.created_ledger); + let current_ledger = env.ledger().sequence(); + let total_elapsed = current_ledger.saturating_sub(m.created_ledger); + let effective_elapsed = total_elapsed.saturating_sub(m.total_pause_duration); let timeout = Self::current_match_timeout(&env); - if elapsed < timeout { + if effective_elapsed < timeout { return Err(Error::MatchNotExpired); } diff --git a/contracts/escrow/src/tests/lifecycle.rs b/contracts/escrow/src/tests/lifecycle.rs index 9442608c..27418ea6 100644 --- a/contracts/escrow/src/tests/lifecycle.rs +++ b/contracts/escrow/src/tests/lifecycle.rs @@ -985,6 +985,329 @@ fn test_match_count_increments_sequentially() { assert_eq!(last.state, MatchState::Pending); } +// ── Pause/Resume tests ──────────────────────────────────────────────────────── + +#[test] +fn test_pause_active_match_sets_paused_state() { + let (env, contract_id, _oracle, player1, player2, token, _admin) = setup(); + let client = EscrowContractClient::new(&env, &contract_id); + + let id = client.create_match( + &player1, + &player2, + &100, + &token, + &String::from_str(&env, "pause_state_test"), + &Platform::Lichess, + ); + + client.deposit(&id, &player1); + client.deposit(&id, &player2); + assert_eq!(client.get_match(&id).state, MatchState::Active); + + client.pause_match(&id, &player1); + + let m = client.get_match(&id); + assert_eq!(m.state, MatchState::Paused); + assert!(m.paused_ledger.is_some()); +} + +#[test] +fn test_resume_paused_match_sets_active_state() { + let (env, contract_id, _oracle, player1, player2, token, _admin) = setup(); + let client = EscrowContractClient::new(&env, &contract_id); + + let id = client.create_match( + &player1, + &player2, + &100, + &token, + &String::from_str(&env, "resume_state_test"), + &Platform::Lichess, + ); + + client.deposit(&id, &player1); + client.deposit(&id, &player2); + client.pause_match(&id, &player1); + assert_eq!(client.get_match(&id).state, MatchState::Paused); + + client.resume_match(&id, &player2); + + let m = client.get_match(&id); + assert_eq!(m.state, MatchState::Active); + assert!(m.paused_ledger.is_none()); +} + +#[test] +fn test_pause_accumulates_total_pause_duration() { + let (env, contract_id, _oracle, player1, player2, token, _admin) = setup(); + let client = EscrowContractClient::new(&env, &contract_id); + + let id = client.create_match( + &player1, + &player2, + &100, + &token, + &String::from_str(&env, "pause_duration_test"), + &Platform::Lichess, + ); + + client.deposit(&id, &player1); + client.deposit(&id, &player2); + + // First pause + env.ledger().set_sequence(100); + client.pause_match(&id, &player1); + + // Resume after 10 ledgers + env.ledger().set_sequence(110); + client.resume_match(&id, &player2); + + let m = client.get_match(&id); + assert_eq!(m.total_pause_duration, 10); + + // Second pause + env.ledger().set_sequence(200); + client.pause_match(&id, &player2); + + // Resume after 15 ledgers + env.ledger().set_sequence(215); + client.resume_match(&id, &player1); + + let m = client.get_match(&id); + assert_eq!(m.total_pause_duration, 25); // 10 + 15 +} + +#[test] +fn test_pause_fails_on_non_active_match() { + let (env, contract_id, _oracle, player1, player2, token, _admin) = setup(); + let client = EscrowContractClient::new(&env, &contract_id); + + let id = client.create_match( + &player1, + &player2, + &100, + &token, + &String::from_str(&env, "pause_pending_test"), + &Platform::Lichess, + ); + + // Cannot pause a pending match + let result = client.try_pause_match(&id, &player1); + assert!(result.is_err()); +} + +#[test] +fn test_resume_fails_on_non_paused_match() { + let (env, contract_id, _oracle, player1, player2, token, _admin) = setup(); + let client = EscrowContractClient::new(&env, &contract_id); + + let id = client.create_match( + &player1, + &player2, + &100, + &token, + &String::from_str(&env, "resume_active_test"), + &Platform::Lichess, + ); + + client.deposit(&id, &player1); + client.deposit(&id, &player2); + + // Cannot resume an active match + let result = client.try_resume_match(&id, &player1); + assert!(result.is_err()); +} + +#[test] +fn test_unauthorized_player_cannot_pause() { + let (env, contract_id, _oracle, player1, player2, token, _admin) = setup(); + let client = EscrowContractClient::new(&env, &contract_id); + + let id = client.create_match( + &player1, + &player2, + &100, + &token, + &String::from_str(&env, "pause_unauth_test"), + &Platform::Lichess, + ); + + client.deposit(&id, &player1); + client.deposit(&id, &player2); + + let unauthorized = Address::generate(&env); + let result = client.try_pause_match(&id, &unauthorized); + assert!(result.is_err()); +} + +#[test] +fn test_unauthorized_player_cannot_resume() { + let (env, contract_id, _oracle, player1, player2, token, _admin) = setup(); + let client = EscrowContractClient::new(&env, &contract_id); + + let id = client.create_match( + &player1, + &player2, + &100, + &token, + &String::from_str(&env, "resume_unauth_test"), + &Platform::Lichess, + ); + + client.deposit(&id, &player1); + client.deposit(&id, &player2); + client.pause_match(&id, &player1); + + let unauthorized = Address::generate(&env); + let result = client.try_resume_match(&id, &unauthorized); + assert!(result.is_err()); +} + +#[test] +fn test_pause_resume_cycle_preserves_escrow_balance() { + let (env, contract_id, _oracle, player1, player2, token, _admin) = setup(); + let client = EscrowContractClient::new(&env, &contract_id); + let token_client = TokenClient::new(&env, &token); + + let id = client.create_match( + &player1, + &player2, + &100, + &token, + &String::from_str(&env, "pause_balance_test"), + &Platform::Lichess, + ); + + client.deposit(&id, &player1); + client.deposit(&id, &player2); + assert_eq!(client.get_escrow_balance(&id), 200); + + client.pause_match(&id, &player1); + assert_eq!(client.get_escrow_balance(&id), 200); + + client.resume_match(&id, &player2); + assert_eq!(client.get_escrow_balance(&id), 200); + + // Verify token balances unchanged + assert_eq!(token_client.balance(&player1), 900); + assert_eq!(token_client.balance(&player2), 900); + assert_eq!(token_client.balance(&contract_id), 200); +} + +#[test] +fn test_submit_result_fails_on_paused_match() { + let (env, contract_id, _oracle, player1, player2, token, _admin) = setup(); + let client = EscrowContractClient::new(&env, &contract_id); + + let id = client.create_match( + &player1, + &player2, + &100, + &token, + &String::from_str(&env, "pause_submit_test"), + &Platform::Lichess, + ); + + client.deposit(&id, &player1); + client.deposit(&id, &player2); + client.pause_match(&id, &player1); + + // Cannot submit result on paused match + let result = client.try_submit_result(&id, &Winner::Player1); + assert!(result.is_err()); +} + +#[test] +fn test_deposit_fails_on_paused_match() { + let (env, contract_id, _oracle, player1, player2, token, _admin) = setup(); + let client = EscrowContractClient::new(&env, &contract_id); + + let id = client.create_match( + &player1, + &player2, + &100, + &token, + &String::from_str(&env, "pause_deposit_test"), + &Platform::Lichess, + ); + + client.deposit(&id, &player1); + client.pause_match(&id, &player1); + + // Cannot deposit on paused match + let result = client.try_deposit(&id, &player2); + assert!(result.is_err()); +} + +#[test] +fn test_multiple_pause_resume_cycles() { + let (env, contract_id, _oracle, player1, player2, token, _admin) = setup(); + let client = EscrowContractClient::new(&env, &contract_id); + + let id = client.create_match( + &player1, + &player2, + &100, + &token, + &String::from_str(&env, "multi_pause_cycle"), + &Platform::Lichess, + ); + + client.deposit(&id, &player1); + client.deposit(&id, &player2); + + // Cycle 1 + client.pause_match(&id, &player1); + assert_eq!(client.get_match(&id).state, MatchState::Paused); + client.resume_match(&id, &player2); + assert_eq!(client.get_match(&id).state, MatchState::Active); + + // Cycle 2 + client.pause_match(&id, &player2); + assert_eq!(client.get_match(&id).state, MatchState::Paused); + client.resume_match(&id, &player1); + assert_eq!(client.get_match(&id).state, MatchState::Active); + + // Cycle 3 + client.pause_match(&id, &player1); + assert_eq!(client.get_match(&id).state, MatchState::Paused); + client.resume_match(&id, &player2); + assert_eq!(client.get_match(&id).state, MatchState::Active); + + let m = client.get_match(&id); + assert!(m.total_pause_duration > 0); +} + +#[test] +fn test_pause_resume_with_snapshots() { + let (env, contract_id, _oracle, player1, player2, token, _admin) = setup(); + let client = EscrowContractClient::new(&env, &contract_id); + + let id = client.create_match( + &player1, + &player2, + &100, + &token, + &String::from_str(&env, "pause_snapshot_test"), + &Platform::Lichess, + ); + + client.deposit(&id, &player1); + client.deposit(&id, &player2); + + let snapshots_before = client.get_balance_snapshots(&id, &_admin); + let count_before = snapshots_before.len(); + + client.pause_match(&id, &player1); + let snapshots_after_pause = client.get_balance_snapshots(&id, &_admin); + assert_eq!(snapshots_after_pause.len(), count_before + 1); + + client.resume_match(&id, &player2); + let snapshots_after_resume = client.get_balance_snapshots(&id, &_admin); + assert_eq!(snapshots_after_resume.len(), count_before + 2); +} + // #296 — get_escrow_balance returns 0 after draw payout #[test] fn test_escrow_balance_zero_after_draw() { diff --git a/contracts/escrow/src/types.rs b/contracts/escrow/src/types.rs index f5d40d5f..f5b8b3c4 100644 --- a/contracts/escrow/src/types.rs +++ b/contracts/escrow/src/types.rs @@ -5,6 +5,7 @@ use soroban_sdk::{contracttype, Address, String}; pub enum MatchState { Pending, // created, awaiting deposits Active, // both players deposited, game in progress + Paused, // game paused (extended match) Completed, // result submitted, payout executed Cancelled, // cancelled before activation } @@ -41,6 +42,10 @@ pub struct Match { pub created_ledger: u32, /// Ledger sequence number when match reached terminal state (Completed or Cancelled). pub completed_ledger: Option, + /// Ledger sequence number when match was most recently paused. + pub paused_ledger: Option, + /// Total number of ledgers the match has spent in Paused state (accumulated across multiple pause/resume cycles). + pub total_pause_duration: u32, } #[contracttype] @@ -73,6 +78,8 @@ pub enum DataKey { pub enum SnapshotReason { Created, Deposit, + Paused, + Resumed, Completed, Cancelled, }