hotshot_task_impls/quorum_proposal_recv/
handlers.rs

1// Copyright (c) 2021-2024 Espresso Systems (espressosys.com)
2// This file is part of the HotShot repository.
3
4// You should have received a copy of the MIT License
5// along with the HotShot repository. If not, see <https://mit-license.org/>.
6
7#![allow(dead_code)]
8
9use std::sync::Arc;
10
11use async_broadcast::{broadcast, Receiver, Sender};
12use async_lock::{RwLock, RwLockUpgradableReadGuard};
13use committable::Committable;
14use hotshot_types::{
15    consensus::OuterConsensus,
16    data::{Leaf2, QuorumProposal, QuorumProposalWrapper},
17    epoch_membership::EpochMembershipCoordinator,
18    message::Proposal,
19    simple_certificate::{QuorumCertificate, QuorumCertificate2},
20    simple_vote::HasEpoch,
21    traits::{
22        block_contents::{BlockHeader, BlockPayload},
23        election::Membership,
24        node_implementation::{ConsensusTime, NodeImplementation, NodeType},
25        signature_key::SignatureKey,
26        storage::Storage,
27        ValidatedState,
28    },
29    utils::{
30        epoch_from_block_number, is_epoch_root, is_epoch_transition, is_transition_block,
31        option_epoch_from_block_number, View, ViewInner,
32    },
33    vote::{Certificate, HasViewNumber},
34};
35use hotshot_utils::anytrace::*;
36use tokio::spawn;
37use tracing::instrument;
38use vbs::version::StaticVersionType;
39
40use super::{QuorumProposalRecvTaskState, ValidationInfo};
41use crate::{
42    events::HotShotEvent,
43    helpers::{
44        broadcast_event, broadcast_view_change, check_qc_state_cert_correspondence, fetch_proposal,
45        update_high_qc, validate_epoch_transition_qc,
46        validate_light_client_state_update_certificate, validate_proposal_safety_and_liveness,
47        validate_proposal_view_and_certs, validate_qc_and_next_epoch_qc,
48    },
49    quorum_proposal_recv::{UpgradeLock, Versions},
50};
51
52/// Spawn a task which will fire a request to get a proposal, and store it.
53#[allow(clippy::too_many_arguments)]
54fn spawn_fetch_proposal<TYPES: NodeType, V: Versions>(
55    qc: &QuorumCertificate2<TYPES>,
56    event_sender: Sender<Arc<HotShotEvent<TYPES>>>,
57    event_receiver: Receiver<Arc<HotShotEvent<TYPES>>>,
58    membership: EpochMembershipCoordinator<TYPES>,
59    consensus: OuterConsensus<TYPES>,
60    sender_public_key: TYPES::SignatureKey,
61    sender_private_key: <TYPES::SignatureKey as SignatureKey>::PrivateKey,
62    upgrade_lock: UpgradeLock<TYPES, V>,
63    epoch_height: u64,
64) {
65    let qc = qc.clone();
66    spawn(async move {
67        let lock = upgrade_lock;
68
69        let _ = fetch_proposal(
70            &qc,
71            event_sender,
72            event_receiver,
73            membership,
74            consensus,
75            sender_public_key,
76            sender_private_key,
77            &lock,
78            epoch_height,
79        )
80        .await;
81    });
82}
83
84/// Update states in the event that the parent state is not found for a given `proposal`.
85#[instrument(skip_all)]
86pub async fn validate_proposal_liveness<
87    TYPES: NodeType,
88    I: NodeImplementation<TYPES>,
89    V: Versions,
90>(
91    proposal: &Proposal<TYPES, QuorumProposalWrapper<TYPES>>,
92    validation_info: &ValidationInfo<TYPES, I, V>,
93) -> Result<()> {
94    let mut valid_epoch_transition = false;
95    if validation_info
96        .upgrade_lock
97        .version(proposal.data.view_number())
98        .await
99        .is_ok_and(|v| v >= V::Epochs::VERSION)
100    {
101        let Some(block_number) = proposal.data.justify_qc().data.block_number else {
102            bail!("Quorum Proposal has no block number but it's after the epoch upgrade");
103        };
104        if is_epoch_transition(block_number, validation_info.epoch_height) {
105            validate_epoch_transition_qc(proposal, validation_info).await?;
106            valid_epoch_transition = true;
107        }
108    }
109    let mut consensus_writer = validation_info.consensus.write().await;
110
111    let leaf = Leaf2::from_quorum_proposal(&proposal.data);
112
113    let state = Arc::new(
114        <TYPES::ValidatedState as ValidatedState<TYPES>>::from_header(proposal.data.block_header()),
115    );
116
117    if let Err(e) = consensus_writer.update_leaf(leaf.clone(), state, None) {
118        tracing::trace!("{e:?}");
119    }
120
121    let liveness_check = proposal.data.justify_qc().view_number() > consensus_writer.locked_view();
122    // if we are using HS2 we update our locked view for any QC from a leader greater than our current lock
123    if liveness_check
124        && validation_info
125            .upgrade_lock
126            .version(leaf.view_number())
127            .await
128            .is_ok_and(|v| v >= V::Epochs::VERSION)
129    {
130        consensus_writer.update_locked_view(proposal.data.justify_qc().view_number())?;
131    }
132
133    drop(consensus_writer);
134
135    if !liveness_check && !valid_epoch_transition {
136        bail!("Quorum Proposal failed the liveness check");
137    }
138
139    Ok(())
140}
141
142async fn validate_epoch_transition_block<
143    TYPES: NodeType,
144    I: NodeImplementation<TYPES>,
145    V: Versions,
146>(
147    proposal: &Proposal<TYPES, QuorumProposalWrapper<TYPES>>,
148    validation_info: &ValidationInfo<TYPES, I, V>,
149) -> Result<()> {
150    if !validation_info
151        .upgrade_lock
152        .epochs_enabled(proposal.data.view_number())
153        .await
154    {
155        return Ok(());
156    }
157    if !is_epoch_transition(
158        proposal.data.block_header().block_number(),
159        validation_info.epoch_height,
160    ) {
161        return Ok(());
162    }
163    // transition block does not have to be empty
164    if is_transition_block(
165        proposal.data.block_header().block_number(),
166        validation_info.epoch_height,
167    ) {
168        return Ok(());
169    }
170    // TODO: Is this the best way to do this?
171    let (empty_payload, metadata) = <TYPES as NodeType>::BlockPayload::empty();
172    let header = proposal.data.block_header();
173    ensure!(
174        empty_payload.builder_commitment(&metadata) == header.builder_commitment()
175            && &metadata == header.metadata(),
176        "Block is not empty"
177    );
178    Ok(())
179}
180
181async fn validate_current_epoch<TYPES: NodeType, I: NodeImplementation<TYPES>, V: Versions>(
182    proposal: &Proposal<TYPES, QuorumProposalWrapper<TYPES>>,
183    validation_info: &ValidationInfo<TYPES, I, V>,
184) -> Result<()> {
185    let upgrade_view = validation_info
186        .upgrade_lock
187        .upgrade_view()
188        .await
189        .unwrap_or(TYPES::View::new(0));
190    if !validation_info
191        .upgrade_lock
192        .epochs_enabled(proposal.data.view_number())
193        .await
194        || proposal.data.justify_qc().view_number() <= upgrade_view
195    {
196        return Ok(());
197    }
198    if validation_info
199        .consensus
200        .read()
201        .await
202        .high_qc()
203        .view_number()
204        <= upgrade_view
205    {
206        return Ok(());
207    }
208
209    let block_number = proposal.data.block_header().block_number();
210
211    let Some(high_block_number) = validation_info
212        .consensus
213        .read()
214        .await
215        .high_qc()
216        .data
217        .block_number
218    else {
219        bail!("High QC has no block number");
220    };
221
222    ensure!(
223        epoch_from_block_number(block_number, validation_info.epoch_height)
224            >= epoch_from_block_number(high_block_number + 1, validation_info.epoch_height),
225        "Quorum proposal has an inconsistent epoch"
226    );
227
228    Ok(())
229}
230
231/// Validate that the proposal's block height is one greater than the justification QC's block height.
232async fn validate_block_height<TYPES: NodeType>(
233    proposal: &Proposal<TYPES, QuorumProposalWrapper<TYPES>>,
234) -> Result<()> {
235    let Some(qc_block_number) = proposal.data.justify_qc().data.block_number else {
236        return Ok(());
237    };
238    ensure!(
239        qc_block_number + 1 == proposal.data.block_header().block_number(),
240        "Quorum proposal has an inconsistent block height"
241    );
242    Ok(())
243}
244
245/// Handles the `QuorumProposalRecv` event by first validating the cert itself for the view, and then
246/// updating the states, which runs when the proposal cannot be found in the internal state map.
247///
248/// This code can fail when:
249/// - The justify qc is invalid.
250/// - The task is internally inconsistent.
251/// - The sequencer storage update fails.
252#[allow(clippy::too_many_lines)]
253#[instrument(skip_all)]
254pub(crate) async fn handle_quorum_proposal_recv<
255    TYPES: NodeType,
256    I: NodeImplementation<TYPES>,
257    V: Versions,
258>(
259    proposal: &Proposal<TYPES, QuorumProposalWrapper<TYPES>>,
260    quorum_proposal_sender_key: &TYPES::SignatureKey,
261    event_sender: &Sender<Arc<HotShotEvent<TYPES>>>,
262    event_receiver: &Receiver<Arc<HotShotEvent<TYPES>>>,
263    validation_info: ValidationInfo<TYPES, I, V>,
264) -> Result<()> {
265    proposal
266        .data
267        .validate_epoch(&validation_info.upgrade_lock, validation_info.epoch_height)
268        .await?;
269    // validate the proposal's epoch matches ours
270    validate_current_epoch(proposal, &validation_info).await?;
271    let quorum_proposal_sender_key = quorum_proposal_sender_key.clone();
272
273    validate_proposal_view_and_certs(proposal, &validation_info)
274        .await
275        .context(warn!("Failed to validate proposal view or attached certs"))?;
276
277    validate_block_height(proposal).await?;
278
279    let view_number = proposal.data.view_number();
280
281    let justify_qc = proposal.data.justify_qc().clone();
282    let maybe_next_epoch_justify_qc = proposal.data.next_epoch_justify_qc().clone();
283
284    let proposal_block_number = proposal.data.block_header().block_number();
285    let proposal_epoch = option_epoch_from_block_number::<TYPES>(
286        proposal.data.epoch().is_some(),
287        proposal_block_number,
288        validation_info.epoch_height,
289    );
290
291    if justify_qc
292        .data
293        .block_number
294        .is_some_and(|bn| is_epoch_root(bn, validation_info.epoch_height))
295    {
296        let Some(state_cert) = proposal.data.state_cert() else {
297            bail!("Epoch root QC has no state cert");
298        };
299        ensure!(
300            check_qc_state_cert_correspondence(
301                &justify_qc,
302                state_cert,
303                validation_info.epoch_height
304            ),
305            "Epoch root QC has no corresponding state cert"
306        );
307        validate_light_client_state_update_certificate(
308            state_cert,
309            &validation_info.membership.coordinator,
310        )
311        .await?;
312    }
313
314    validate_epoch_transition_block(proposal, &validation_info).await?;
315
316    validate_qc_and_next_epoch_qc(
317        &justify_qc,
318        maybe_next_epoch_justify_qc.as_ref(),
319        &validation_info.consensus,
320        &validation_info.membership.coordinator,
321        &validation_info.upgrade_lock,
322        validation_info.epoch_height,
323    )
324    .await?;
325
326    broadcast_event(
327        Arc::new(HotShotEvent::QuorumProposalPreliminarilyValidated(
328            proposal.clone(),
329        )),
330        event_sender,
331    )
332    .await;
333
334    // Get the parent leaf and state.
335    let parent_leaf = validation_info
336        .consensus
337        .read()
338        .await
339        .saved_leaves()
340        .get(&justify_qc.data.leaf_commit)
341        .cloned();
342
343    if parent_leaf.is_none() {
344        spawn_fetch_proposal(
345            &justify_qc,
346            event_sender.clone(),
347            event_receiver.clone(),
348            validation_info.membership.coordinator.clone(),
349            OuterConsensus::new(Arc::clone(&validation_info.consensus.inner_consensus)),
350            // Note that we explicitly use the node key here instead of the provided key in the signature.
351            // This is because the key that we receive is for the prior leader, so the payload would be routed
352            // incorrectly.
353            validation_info.public_key.clone(),
354            validation_info.private_key.clone(),
355            validation_info.upgrade_lock.clone(),
356            validation_info.epoch_height,
357        );
358    }
359    let consensus_reader = validation_info.consensus.read().await;
360
361    let parent = match parent_leaf {
362        Some(leaf) => {
363            if let (Some(state), _) = consensus_reader.state_and_delta(leaf.view_number()) {
364                Some((leaf, Arc::clone(&state)))
365            } else {
366                bail!("Parent state not found! Consensus internally inconsistent");
367            }
368        },
369        None => None,
370    };
371    drop(consensus_reader);
372    if justify_qc.view_number()
373        > validation_info
374            .consensus
375            .read()
376            .await
377            .high_qc()
378            .view_number()
379    {
380        update_high_qc(proposal, &validation_info).await?;
381    }
382
383    let Some((parent_leaf, _parent_state)) = parent else {
384        tracing::warn!(
385            "Proposal's parent missing from storage with commitment: {:?}",
386            justify_qc.data.leaf_commit
387        );
388        validate_proposal_liveness(proposal, &validation_info).await?;
389        validation_info
390            .consensus
391            .write()
392            .await
393            .update_highest_block(proposal_block_number);
394        broadcast_view_change(
395            event_sender,
396            view_number,
397            proposal_epoch,
398            validation_info.first_epoch,
399        )
400        .await;
401        return Ok(());
402    };
403
404    // Validate the proposal
405    validate_proposal_safety_and_liveness::<TYPES, I, V>(
406        proposal.clone(),
407        parent_leaf,
408        &validation_info,
409        event_sender.clone(),
410        quorum_proposal_sender_key,
411    )
412    .await?;
413
414    validation_info
415        .consensus
416        .write()
417        .await
418        .update_highest_block(proposal_block_number);
419    {
420        validation_info.consensus.write().await.highest_block = proposal_block_number;
421    }
422    broadcast_view_change(
423        event_sender,
424        view_number,
425        proposal_epoch,
426        validation_info.first_epoch,
427    )
428    .await;
429
430    Ok(())
431}