Skip to main content

hotshot_new_protocol/
cutover.rs

1//! Legacy → new-protocol cutover machinery.
2//!
3//! Three concerns live here:
4//! - [`extract_pre_cutover_seed`] walks a live legacy [`SystemContextHandle`]
5//!   and produces a [`PreCutoverSeed`].
6//! - [`CutoverGate`] latches once legacy has crossed into the new version,
7//!   extracts the seed, and dispatches it into the new coordinator.
8//! - [`forward_legacy_timeout_votes`] and [`forward_legacy_epoch_changes`]
9//!   tail the legacy event stream and bridge those events into the
10//!   coordinator's client API so the new protocol can form TC2s and
11//!   refresh its peer set at epoch boundaries.
12
13use std::{
14    collections::BTreeMap,
15    sync::atomic::{AtomicBool, Ordering},
16};
17
18use async_broadcast::InactiveReceiver;
19use futures::StreamExt;
20use hotshot::{traits::NodeImplementation, types::SystemContextHandle};
21use hotshot_types::{
22    data::{EpochNumber, Leaf2},
23    event::{Event, EventType},
24    traits::{block_contents::BlockHeader, node_implementation::NodeType},
25    utils::epoch_from_block_number,
26};
27use versions::NEW_PROTOCOL_VERSION;
28
29use crate::{client::ClientApi, consensus::PreCutoverSeed};
30
31/// Walk legacy state to produce a [`PreCutoverSeed`]; `None` on
32/// a broken walk.
33pub async fn extract_pre_cutover_seed<T, I>(
34    handle: &SystemContextHandle<T, I>,
35) -> Option<PreCutoverSeed<T>>
36where
37    T: NodeType,
38    I: NodeImplementation<T>,
39{
40    let cutover_view = match handle.hotshot.upgrade_lock.decided_upgrade_cert() {
41        Some(cert) => cert.data.new_version_first_view,
42        None => {
43            tracing::warn!("no decided upgrade certificate; aborting seed extraction");
44            return None;
45        },
46    };
47
48    let consensus_arc = handle.hotshot.consensus();
49    let consensus = consensus_arc.read().await;
50    let decided_anchor = consensus.decided_leaf();
51    let decided_view = decided_anchor.view_number();
52
53    let high_qc = consensus.high_qc().clone();
54    let saved = consensus.saved_leaves();
55
56    // `saved_leaves` is canonical — a non-canonical entry would break legacy
57    // decide — so we can take every leaf above `decided_view` without
58    // re-validating via a `justify_qc` walk.
59    let mut undecided: Vec<Leaf2<T>> = saved
60        .values()
61        .filter(|leaf| leaf.view_number() > decided_view)
62        .cloned()
63        .collect();
64    undecided.sort_by_key(|leaf| leaf.view_number());
65
66    let mut validated_states = BTreeMap::new();
67    if let Some(state) = consensus.state(decided_view) {
68        validated_states.insert(decided_view, state.clone());
69    } else {
70        tracing::warn!(%decided_view, "no validated state for decided anchor");
71    }
72    for leaf in &undecided {
73        let view = leaf.view_number();
74        if let Some(state) = consensus.state(view) {
75            validated_states.insert(view, state.clone());
76        } else {
77            tracing::warn!(%view, "no validated state for undecided leaf");
78        }
79    }
80
81    Some(PreCutoverSeed {
82        decided_anchor,
83        undecided,
84        high_qc: Some(high_qc),
85        validated_states,
86        cutover_view,
87    })
88}
89
90/// Latches once legacy crosses into the new protocol. The single API
91/// production and tests share: poll [`check`](Self::check) on every
92/// coordinator loop iteration; once it returns `true` the cutover has
93/// happened and subsequent calls short-circuit.
94#[derive(Debug, Default)]
95pub struct CutoverGate {
96    active: AtomicBool,
97}
98
99impl CutoverGate {
100    pub fn new() -> Self {
101        Self::default()
102    }
103
104    /// `true` once a previous `check` succeeded. Lets callers skip the
105    /// legacy read lock when already crossed.
106    pub fn is_active(&self) -> bool {
107        self.active.load(Ordering::Relaxed)
108    }
109
110    /// Returns `true` once legacy has crossed into the new protocol.
111    /// On the first call that observes the crossing, extracts the
112    /// pre-cutover seed and dispatches it into the coordinator;
113    /// subsequent calls short-circuit.
114    pub async fn check<T, I>(
115        &self,
116        legacy: &SystemContextHandle<T, I>,
117        client_api: &ClientApi<T>,
118    ) -> bool
119    where
120        T: NodeType,
121        I: NodeImplementation<T>,
122    {
123        if self.is_active() {
124            return true;
125        }
126        let cur_view = legacy.cur_view().await;
127        let crossed =
128            legacy.hotshot.upgrade_lock.version_infallible(cur_view) >= NEW_PROTOCOL_VERSION;
129        if !crossed {
130            return false;
131        }
132
133        if let Some(seed) = extract_pre_cutover_seed(legacy).await {
134            if let Err(err) = client_api.seed_pre_cutover(seed).await {
135                tracing::warn!(%err, "seed_pre_cutover client request failed");
136            }
137        } else {
138            tracing::warn!("seed extraction returned None; coordinator will not be seeded");
139        }
140
141        self.active.store(true, Ordering::Relaxed);
142        true
143    }
144}
145
146/// Forward legacy `TimeoutVote2` events into the new-protocol timeout
147/// collectors so the first new leader can form TC2 at the boundary.
148pub async fn forward_legacy_timeout_votes<T: NodeType>(
149    legacy_event_rx: InactiveReceiver<Event<T>>,
150    client_api: ClientApi<T>,
151) {
152    let mut rx = legacy_event_rx.activate_cloned();
153    while let Some(event) = rx.next().await {
154        if let EventType::LegacyTimeoutVoteEmitted { vote } = event.event
155            && let Err(err) = client_api.submit_timeout_vote(vote).await
156        {
157            tracing::warn!(%err, "failed to forward legacy TimeoutVote2 to new-protocol coordinator");
158        }
159    }
160}
161
162/// Forward legacy epoch transitions into `bump_network_epoch`.
163/// `epoch_height == 0` disables forwarding.
164pub async fn forward_legacy_epoch_changes<T: NodeType>(
165    legacy_event_rx: InactiveReceiver<Event<T>>,
166    client_api: ClientApi<T>,
167    epoch_height: u64,
168) {
169    if epoch_height == 0 {
170        return;
171    }
172    let mut rx = legacy_event_rx.activate_cloned();
173    let mut last_forwarded: Option<EpochNumber> = None;
174    while let Some(event) = rx.next().await {
175        let EventType::Decide { leaf_chain, .. } = &event.event else {
176            continue;
177        };
178        let Some(newest) = leaf_chain.first() else {
179            continue;
180        };
181        let block_number = newest.leaf.block_header().block_number();
182        let epoch = EpochNumber::new(epoch_from_block_number(block_number, epoch_height));
183        if last_forwarded.is_some_and(|prev| epoch <= prev) {
184            continue;
185        }
186        if let Err(err) = client_api.bump_network_epoch(epoch).await {
187            tracing::warn!(%epoch, %err, "failed to forward legacy epoch change to new-protocol coordinator");
188            continue;
189        }
190        last_forwarded = Some(epoch);
191    }
192}