Skip to content
Open
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
1 change: 1 addition & 0 deletions contracts/escrow/src/errors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,4 +41,5 @@ pub enum Error {
NotInitialized = 36,
InvalidPauseState = 37,
InvalidConversionRate = 38,
DisputeWindowElapsed = 39,
}
102 changes: 102 additions & 0 deletions contracts/escrow/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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 {
Expand Down
163 changes: 163 additions & 0 deletions contracts/escrow/src/tests/dispute.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
3 changes: 3 additions & 0 deletions contracts/escrow/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,9 @@ pub struct Match {
pub paused_ledger: Option<u32>,
/// 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]
Expand Down