Skip to main content

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}