diff --git a/fault-proof/src/proposer.rs b/fault-proof/src/proposer.rs index 0cbb1a9fb..b03803e3c 100644 --- a/fault-proof/src/proposer.rs +++ b/fault-proof/src/proposer.rs @@ -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?; @@ -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 @@ -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, is_defense: bool, ) -> Result { - // Check ownership - only prove games we own { let state = self.state.read().await; let game = state.games.values().find(|g| g.address == game_address); @@ -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(); @@ -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) } diff --git a/fault-proof/tests/sync.rs b/fault-proof/tests/sync.rs index a4091790b..4e755d4fc 100644 --- a/fault-proof/tests/sync.rs +++ b/fault-proof/tests/sync.rs @@ -1164,33 +1164,13 @@ 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 = 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"); @@ -1198,13 +1178,28 @@ mod proposer_sync { 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(()) } @@ -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(())