Skip to content
48 changes: 37 additions & 11 deletions fault-proof/src/proposer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -788,6 +788,13 @@ where

match status {
GameStatus::IN_PROGRESS => {
// Challenged + expired = guaranteed CHALLENGER_WINS, cascades to
// descendants.
if claim_data.status == ProposalStatus::Challenged && now_ts >= deadline {
actions.push(GameSyncAction::RemoveSubtree(index));
continue;
}

let game_type = contract.gameType().call().await?;
let parent_resolved =
is_parent_resolved(parent_index, self.factory.as_ref()).await?;
Expand Down Expand Up @@ -1135,6 +1142,26 @@ where

let agg_proof = self.prover.generate_agg_proof(sp1_stdin).await?;

// Best-effort check: don't discard an expensive proof on a transient RPC failure.
// If the game is truly over, the on-chain prove() will revert (cheap gas).
match game.gameOver().call().await {
Ok(true) => {
tracing::warn!(
?game_address,
"Game ended during proof generation, aborting submission"
);
bail!("Game is over (expired or already proven), aborting proof submission");
}
Ok(false) => {}
Err(e) => {
tracing::warn!(
?game_address,
error = ?e,
"Failed to check gameOver(), proceeding with submission"
);
}
}

let transaction_request = game.prove(agg_proof.bytes().into()).into_transaction_request();
let receipt = self
.signer
Expand Down Expand Up @@ -2148,22 +2175,14 @@ where
Ok(true)
}

/// Check if proving should be skipped for any reason.
///
/// Returns `Ok(true)` if proving should be skipped:
/// - Game not found in cache
/// - Game not owned (vkeys don't match)
/// - Deadline has passed
///
/// Returns `Ok(false)` if proving should proceed.
/// Logs a warning if the deadline is approaching.
/// Check if proving should be skipped. Checks are ordered cheapest-first:
/// cache lookup → deadline (local) → gameOver() (on-chain RPC).
async fn should_skip_proving(
&self,
game_address: Address,
deadline: Option<u64>,
is_defense: bool,
) -> Result<bool> {
// Check ownership - only prove games we own
{
let state = self.state.read().await;
let game = state.games.values().find(|g| g.address == game_address);
Expand All @@ -2181,7 +2200,6 @@ where
}
}

// Check deadline if provided
if let Some(deadline) = deadline {
let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH)?.as_secs();

Expand Down Expand Up @@ -2218,6 +2236,14 @@ where
}
}

// On-chain check for cases the deadline check can't catch (e.g., already proven by another
// party).
let contract = OPSuccinctFaultDisputeGame::new(game_address, self.l1_provider.clone());
if contract.gameOver().call().await? {
tracing::info!(?game_address, "Game is over (expired or already proven), skipping");
return Ok(true);
}

Ok(false)
}

Expand Down
55 changes: 26 additions & 29 deletions fault-proof/tests/sync.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1164,47 +1164,42 @@ mod proposer_sync {
tracing::info!("✓ Game {game_address} resolved");
}

// Step 4: Sync state
// Step 4: Sync state — game 3 is Challenged + expired, so it and its child (game 4)
// are evicted immediately by the RemoveSubtree path.
proposer.sync_state().await?;

// Verify: All games are cached
let snapshot = proposer.state_snapshot().await;
snapshot.assert_game_len(6);
snapshot.assert_game_len(4); // 0, 1, 2, 5 (games 3, 4 evicted)

// Verify: Canonical head is game 5 (highest L2 block among valid games)
snapshot.assert_canonical_head(Some(5), 6, starting_l2_block);

// Step 5: Resolve game 3, 4 and 5
for game_address in game_addresses.iter().skip(3) {
env.resolve_game(*game_address).await?;
tracing::info!("✓ Game {game_address} resolved");
}

// Step 6: Sync state
proposer.sync_state().await?;

// Verify: Games 0, 1, 2, 5 retained; games 3, 4 removed
let snapshot = proposer.state_snapshot().await;
snapshot.assert_game_len(4); // 0, 1, 2, 5

// Canonical head should be game 5 (highest block among reachable games)
snapshot.assert_canonical_head(Some(5), 6, starting_l2_block);

// Verify specific games are present
let game_indices: Vec<U256> = snapshot.games.iter().map(|(idx, _)| *idx).collect();
assert!(game_indices.contains(&U256::from(0)), "Game 0 should be retained");
assert!(game_indices.contains(&U256::from(1)), "Game 1 should be retained");
assert!(game_indices.contains(&U256::from(2)), "Game 2 should be retained");
assert!(game_indices.contains(&U256::from(5)), "Game 5 should be retained");
assert!(
!game_indices.contains(&U256::from(3)),
"Game 3 should be removed (CHALLENGER_WINS)"
"Game 3 should be evicted (Challenged + expired)"
);
assert!(
!game_indices.contains(&U256::from(4)),
"Game 4 should be removed (child of CHALLENGER_WINS)"
"Game 4 should be evicted (child of Challenged + expired)"
);

snapshot.assert_canonical_head(Some(5), 6, starting_l2_block);

// Step 5: Resolve remaining on-chain games (3, 4, 5) and re-sync.
// Games 3 and 4 were already evicted; this confirms a second sync is stable.
for game_address in game_addresses.iter().skip(3) {
env.resolve_game(*game_address).await?;
tracing::info!("✓ Game {game_address} resolved");
}

proposer.sync_state().await?;

let snapshot = proposer.state_snapshot().await;
snapshot.assert_game_len(4); // still 0, 1, 2, 5
snapshot.assert_canonical_head(Some(5), 6, starting_l2_block);

Ok(())
}

Expand Down Expand Up @@ -1389,18 +1384,20 @@ mod proposer_sync {
env.warp_time(MAX_CHALLENGE_DURATION + MAX_PROVE_DURATION + 1).await?;
env.resolve_game(game_addresses[0]).await?;

// Sync and inspect per-branch flags.
// Sync — game 1 is Challenged + expired, so it is evicted. Game 2 (proved) is retained.
proposer.sync_state().await?;

let game_1 = proposer.get_game(U256::from(1)).await.unwrap();
assert_eq!(game_1.proposal_status, ProposalStatus::Challenged);
assert!(!game_1.should_attempt_to_resolve, "Challenged game should not auto-resolve");
assert!(
proposer.get_game(U256::from(1)).await.is_none(),
"Game 1 should be evicted (Challenged + expired)"
);

let game_2 = proposer.get_game(U256::from(2)).await.unwrap();
assert_eq!(game_2.proposal_status, ProposalStatus::UnchallengedAndValidProofProvided);
assert!(game_2.should_attempt_to_resolve, "Proof-provided game should attempt to resolve");

let snapshot = proposer.state_snapshot().await;
snapshot.assert_game_len(2); // only root (0) and game 2 remain
snapshot.assert_canonical_head(Some(2), 3, starting_l2_block);

Ok(())
Expand Down
Loading