Skip to content
Merged
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
208 changes: 208 additions & 0 deletions node/src/tests/checkpointing/joining.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading