diff --git a/node/src/tests/checkpointing/joining.rs b/node/src/tests/checkpointing/joining.rs index 7b62b421..9d81f3da 100644 --- a/node/src/tests/checkpointing/joining.rs +++ b/node/src/tests/checkpointing/joining.rs @@ -369,6 +369,214 @@ fn test_node_joins_later_with_checkpoint() { }); } +/// A node joining from a checkpoint that carries the `last_block` + +/// `finalized_header` artifacts (a `SyncCheckpoint`) must seed the checkpoint +/// epoch's finalized header into finalizer storage at startup, via the syncer's +/// pre-consensus replay of the terminal block — NOT via live block delivery. +/// +/// Boundary aux-data derives `prev_epoch_header_hash` from +/// `get_most_recent_finalized_header()`, falling back to the genesis hash when +/// the finalized-header DB is empty. If the checkpoint header is never seeded, a +/// checkpoint-joined node's first boundary header would link to genesis instead +/// of the checkpoint epoch's header. The pre-consensus replay is what prevents +/// that, yet every other checkpoint test exercises only the state-only / +/// live-sync path (`checkpoint_last_block: None`), so the replay-seeding path is +/// otherwise uncovered. +/// +/// To prove the header is seeded by the replay and not by network sync, the +/// joiner is registered but deliberately left UNLINKED: it can receive no blocks +/// from peers, so a populated `get_finalized_header(E)` can only be the result of +/// the startup replay. +#[test_traced("INFO")] +fn test_checkpoint_join_replays_and_seeds_finalized_header() { + let n = 5; + let link = Link { + latency: Duration::from_millis(80), + jitter: Duration::from_millis(10), + success_rate: 1.0, + }; + let cfg = deterministic::Config::default().with_seed(0); + let executor = Runner::from(cfg); + executor.start(|context| async move { + let (network, mut oracle) = Network::new( + context.with_label("network"), + simulated::Config { + max_size: 1024 * 1024, + disconnect_on_block: false, + tracked_peer_sets: NZUsize!(n as usize * 10), + }, + ); + network.start(); + + // Register participants. + let mut key_stores = Vec::new(); + let mut validators = Vec::new(); + for i in 0..n { + let mut rng = StdRng::seed_from_u64(i as u64); + let node_key = PrivateKey::random(&mut rng); + let node_public_key = node_key.public_key(); + let consensus_key = bls12381::PrivateKey::random(&mut rng); + let consensus_public_key = consensus_key.public_key(); + key_stores.push(KeyStore { + node_key, + consensus_key, + }); + validators.push((node_public_key, consensus_public_key)); + } + validators.sort_by(|lhs, rhs| lhs.0.cmp(&rhs.0)); + key_stores.sort_by_key(|ks| ks.node_key.public_key()); + + let node_public_keys: Vec<_> = validators.iter().map(|(pk, _)| pk.clone()).collect(); + let initial_node_public_keys = &node_public_keys[..node_public_keys.len() - 1]; + + let mut registrations = + common::register_validators(&oracle, initial_node_public_keys).await; + common::link_validators(&mut oracle, initial_node_public_keys, link.clone(), None).await; + + let genesis_hash = + from_hex_formatted(common::GENESIS_HASH).expect("failed to decode genesis hash"); + let genesis_hash: [u8; 32] = genesis_hash + .try_into() + .expect("failed to convert genesis hash"); + + let engine_client_network = MockEngineNetworkBuilder::new(genesis_hash).build(); + let initial_state = + get_initial_state(genesis_hash, &validators, None, None, 32_000_000_000); + + // Start the initial validators. + let key_store_joining_later = key_stores.pop().unwrap(); + let mut consensus_state_queries = HashMap::new(); + for (idx, key_store) in key_stores.into_iter().enumerate() { + let public_key = key_store.node_key.public_key(); + let uid = format!("validator_{public_key}"); + let namespace = String::from("_SUMMIT"); + let engine_client = engine_client_network.create_client(uid.clone()); + let config = get_default_engine_config( + engine_client, + SimulatedOracle::new(oracle.clone()), + uid.clone(), + genesis_hash, + namespace, + key_store, + validators.clone(), + initial_state.clone(), + ); + let engine = Engine::new(context.with_label(&uid), config).await; + consensus_state_queries.insert(idx, engine.finalizer_mailbox.clone()); + let (pending, recovered, resolver, orchestrator, broadcast) = + registrations.remove(&public_key).unwrap(); + engine.start(pending, recovered, resolver, orchestrator, broadcast); + } + + // Wait for a checkpoint, then capture the full checkpoint artifacts: + // the checkpoint bytes, the epoch-terminal `last_block`, and the + // certified finalized header for that epoch. + let source_query = consensus_state_queries.get(&0).unwrap(); + let (checkpoint, last_block) = loop { + if let Some(pair) = source_query.clone().get_latest_checkpoint().await.0 { + break pair; + } + context.sleep(Duration::from_secs(1)).await; + }; + + // Let the network advance well past the checkpoint epoch's terminal so + // the source node has durably stored that epoch's finalized header. + loop { + if source_query.get_latest_height().await >= 20 { + break; + } + context.sleep(Duration::from_secs(1)).await; + } + + // The checkpoint state is captured at the penultimate block, so its epoch + // is the checkpoint epoch E; `last_block` is E's terminal. + let checkpoint_state = ConsensusState::try_from(&checkpoint).unwrap(); + let checkpoint_epoch = checkpoint_state.get_epoch(); + let source_header = source_query + .clone() + .get_finalized_header(checkpoint_epoch) + .await + .expect("source node must have stored the checkpoint epoch's finalized header"); + // Sanity: the finalized header certifies exactly the terminal `last_block`. + assert_eq!( + source_header.finalization().proposal.payload, + last_block.digest(), + "finalized header must certify the checkpoint's last_block" + ); + + // Register the joining validator but do NOT link it: with no peers it + // cannot receive any block over the network, so anything the finalizer + // stores can only have come from the checkpoint replay. + let public_key = key_store_joining_later.node_key.public_key(); + let mut late_registrations = + common::register_validators(&oracle, &[public_key.clone()]).await; + + let uid = format!("validator_{public_key}"); + let namespace = String::from("_SUMMIT"); + let engine_client = engine_client_network.create_client(uid.clone()); + // Prime the joiner's execution client at the checkpoint (penultimate) + // height so replaying the terminal block executes as the next block. + let eth_hash = checkpoint_state.get_forkchoice().head_block_hash.into(); + engine_client.load_checkpoint(checkpoint_state.get_latest_height(), eth_hash); + + let mut config = get_default_engine_config( + engine_client, + SimulatedOracle::new(oracle.clone()), + uid.clone(), + genesis_hash, + namespace, + key_store_joining_later, + validators.clone(), + checkpoint_state.clone(), + ); + // The artifacts that make this a `SyncCheckpoint` join rather than a + // state-only join. This is exactly what the other checkpoint tests omit. + config.checkpoint_last_block = Some(last_block.clone()); + config.checkpoint_finalized_header = Some(source_header.clone()); + + let engine = Engine::new(context.with_label(&uid), config).await; + let joiner_query = engine.finalizer_mailbox.clone(); + let (pending, recovered, resolver, orchestrator, broadcast) = + late_registrations.remove(&public_key).unwrap(); + engine.start(pending, recovered, resolver, orchestrator, broadcast); + + // The replay runs in the syncer's `run()` before the consensus loop. + // Poll until the finalizer has processed it (bounded); an unlinked node + // has no other way to obtain the terminal block. + let mut seeded = None; + for _ in 0..30 { + if let Some(header) = joiner_query + .clone() + .get_finalized_header(checkpoint_epoch) + .await + { + seeded = Some(header); + break; + } + context.sleep(Duration::from_secs(1)).await; + } + + let seeded = seeded.expect( + "checkpoint-joined finalizer must seed the checkpoint epoch's finalized header \ + from the startup replay (get_most_recent_finalized_header would otherwise fall \ + back to genesis at the next boundary)", + ); + assert_eq!( + seeded.header().get_digest(), + source_header.header().get_digest(), + "seeded finalized header must match the checkpoint epoch's terminal header" + ); + // The terminal was executed and the finalizer advanced past the + // penultimate checkpoint height, confirming the replay ran end to end. + assert!( + joiner_query.get_latest_height().await > checkpoint_state.get_latest_height(), + "finalizer must have advanced past the checkpoint (penultimate) height via replay" + ); + + context.auditor().state() + }); +} + #[test_traced("INFO")] fn test_node_joins_later_with_checkpoint_not_in_genesis() { // Creates a network of 5 nodes, and starts only 4 of them.