espresso_node/startup_catchup.rs
1//! Startup stake-table catchup for new-protocol nodes.
2//!
3//! Cliquenet only connects to validators in the current epoch's stake table
4//! window (`N-1`, `N`, `N+1`). On a fresh-join or cold-restart node, no
5//! consensus messages can be received until those stake tables are populated,
6//! so the existing reactive catchup (triggered by an unknown-epoch proposal)
7//! never fires.
8//!
9//! [`bootstrap_epoch_window`] drives the existing catchup machinery
10//! synchronously at startup: it walks forward one epoch at a time from the
11//! highest already-known epoch (loaded from persistence by `reload_stake`)
12//! until peers can no longer serve the next epoch root leaf — which is the
13//! point at which the live network currently is.
14
15use std::time::Duration;
16
17use anyhow::ensure;
18use espresso_types::SeqTypes;
19use hotshot_types::{
20 data::EpochNumber, epoch_membership::EpochMembershipCoordinator, traits::election::Membership,
21};
22
23/// Walk forward from the highest already-known epoch until peers can no
24/// longer serve the next epoch root leaf, populating the membership with
25/// stake tables for every epoch up through `N+1` (where `N` is the current
26/// epoch). Returns `N`.
27///
28/// Preconditions: `reload_stake` should have run before this — it populates
29/// the membership from local persistence so the walk skips epochs we
30/// already know.
31pub async fn bootstrap_epoch_window(
32 coordinator: &EpochMembershipCoordinator<SeqTypes>,
33 epoch_height: u64,
34 step_timeout: Duration,
35) -> anyhow::Result<EpochNumber> {
36 if epoch_height == 0 {
37 // Pre-epoch chain: epochs aren't enabled yet, the non-epoch
38 // committee path is what gets used.
39 return Ok(EpochNumber::genesis());
40 }
41
42 let membership = coordinator.membership();
43 let first_epoch = membership.first_epoch();
44
45 // Catchup resumes from the latest consecutive pair of stake tables we
46 // hold, so a gap near the tip is bridged by the step that runs into it
47 // rather than having to be found here.
48 let mut highest = membership.highest_known_epoch().unwrap_or(first_epoch + 1);
49
50 tracing::info!(
51 %first_epoch,
52 starting_from = %highest,
53 "bootstrap_epoch_window: walking forward",
54 );
55
56 // Walk forward; each successful iteration drives `add_epoch_root` via
57 // the existing catchup machinery, persisting the new stake table. The
58 // walk terminates when the catchup chain returns an error
59 // (`Ok(Err(_))`) or a single step exceeds the hang bound (`Err(_)`).
60 loop {
61 let target = highest + 1;
62 let result =
63 tokio::time::timeout(step_timeout, coordinator.wait_for_stake_table(target)).await;
64 match result {
65 Ok(Ok(_)) => {
66 tracing::info!(%target, "bootstrap_epoch_window: derived stake table");
67 highest = target;
68 },
69 Ok(Err(err)) => {
70 tracing::info!(
71 %target,
72 %err,
73 "bootstrap_epoch_window: catchup failed; treating as live tip",
74 );
75 break;
76 },
77 Err(_) => {
78 tracing::warn!(
79 %target,
80 timeout_secs = step_timeout.as_secs(),
81 "bootstrap_epoch_window: catchup timed out; proceeding with a possibly \
82 stale epoch window",
83 );
84 break;
85 },
86 }
87 }
88
89 // `highest` corresponds to N+1 (the leaf at root_block_in_epoch(N-1) is
90 // the last finalized one peers can serve). So current epoch N = highest - 1.
91 let current = EpochNumber::new(highest.saturating_sub(1));
92
93 ensure!(
94 membership.snapshot(current).is_some(),
95 "missing stake table for current epoch {current} after bootstrap"
96 );
97 ensure!(
98 membership.snapshot(highest).is_some(),
99 "missing stake table for next epoch {highest} after bootstrap"
100 );
101
102 tracing::info!(%current, "bootstrap_epoch_window: complete");
103 Ok(current)
104}