Skip to main content

hotshot_new_protocol/
proposal.rs

1use std::sync::Arc;
2
3use committable::Committable;
4use hotshot::types::SignatureKey;
5use hotshot_contract_adapter::light_client::validate_light_client_state_update_certificate;
6use hotshot_types::{
7    data::{EpochNumber, Leaf2, VidDisperseShare2, ViewNumber, vid_disperse::vid_total_weight},
8    epoch_membership::{EpochMembership, EpochMembershipCoordinator},
9    message::{Proposal as SignedProposal, UpgradeLock},
10    simple_certificate::check_qc_state_cert_correspondence,
11    simple_vote::HasEpoch,
12    stake_table::StakeTableEntries,
13    traits::{block_contents::BlockHeader, node_implementation::NodeType},
14    utils::{is_epoch_root, is_last_block},
15    vote::{Certificate, HasViewNumber},
16};
17use hotshot_utils::anytrace;
18use tokio::task::JoinSet;
19use tracing::error;
20
21use crate::message::{Proposal, ProposalMessage, Unchecked, Validated, VidShareMessage};
22
23type Result<T> = std::result::Result<T, ValidationError>;
24
25/// A validated proposal.
26pub struct ValidatedProposal<T: NodeType> {
27    pub sender: T::SignatureKey,
28    pub message: ProposalMessage<T, Validated>,
29    /// True if this proposal was fetched from a peer
30    pub fetched: bool,
31}
32
33/// A proposal validator checks proposal signature and integrity.
34pub struct ProposalValidator<T: NodeType> {
35    /// Validation tasks.
36    tasks: JoinSet<Result<ValidatedProposal<T>>>,
37
38    /// The actual validation logic.
39    validator: Arc<Validator<T>>,
40}
41
42/// A validator dedicated to VID share messages.
43///
44/// Lives as a separate field on `Coordinator` so that its `next()` and
45/// `ProposalValidator::next()` can be polled in the same `tokio::select!`
46/// without conflicting `&mut self` borrows on a single field.
47pub struct VidShareValidator<T: NodeType> {
48    /// VID share validation tasks.
49    tasks: JoinSet<Result<VidDisperseShare2<T>>>,
50
51    /// Shared validation logic (same shape as `ProposalValidator`'s).
52    validator: Arc<Validator<T>>,
53}
54
55struct Validator<T: NodeType> {
56    membership_coordinator: EpochMembershipCoordinator<T>,
57    epoch_height: u64,
58    upgrade_lock: UpgradeLock<T>,
59}
60
61impl<T: NodeType> ProposalValidator<T> {
62    pub fn new(
63        c: EpochMembershipCoordinator<T>,
64        epoch_height: u64,
65        upgrade_lock: UpgradeLock<T>,
66    ) -> Self {
67        Self {
68            tasks: JoinSet::new(),
69            validator: Arc::new(Validator {
70                membership_coordinator: c,
71                epoch_height,
72                upgrade_lock,
73            }),
74        }
75    }
76
77    pub fn validate(&mut self, p: ProposalMessage<T, Unchecked>) {
78        self.spawn_validation(p, false)
79    }
80
81    /// Validate a proposal fetched from a peer.
82    /// The coordinator routes it into the decide
83    /// backfill path instead of the vote path.
84    pub fn validate_fetched(&mut self, p: ProposalMessage<T, Unchecked>) {
85        self.spawn_validation(p, true)
86    }
87
88    fn spawn_validation(&mut self, p: ProposalMessage<T, Unchecked>, fetched: bool) {
89        let v = self.validator.clone();
90        self.tasks.spawn(async move {
91            let sender = v.signature(&p.proposal).await?;
92            v.justify_qc(&p.proposal.data).await?;
93            v.next_epoch_justify_qc(&p.proposal.data).await?;
94            v.view_change_evidence(&p.proposal.data).await?;
95            v.state_cert(&p.proposal.data).await?;
96            let validated_proposal = ValidatedProposal {
97                sender,
98                message: ProposalMessage::validated(p.proposal),
99                fetched,
100            };
101            Ok(validated_proposal)
102        });
103    }
104
105    pub async fn next(&mut self) -> Option<Result<ValidatedProposal<T>>> {
106        loop {
107            match self.tasks.join_next().await {
108                Some(Ok(prop)) => return Some(prop),
109                Some(Err(err)) => {
110                    error!(%err, "proposal validation task panic");
111                },
112                None => return None,
113            }
114        }
115    }
116}
117
118impl<T: NodeType> VidShareValidator<T> {
119    pub fn new(
120        c: EpochMembershipCoordinator<T>,
121        epoch_height: u64,
122        upgrade_lock: UpgradeLock<T>,
123    ) -> Self {
124        Self {
125            tasks: JoinSet::new(),
126            validator: Arc::new(Validator {
127                membership_coordinator: c,
128                epoch_height,
129                upgrade_lock,
130            }),
131        }
132    }
133
134    pub fn validate(&mut self, share: VidShareMessage<T>) {
135        let v = self.validator.clone();
136        self.tasks.spawn(async move {
137            v.vid_share_proposal(&share).await?;
138            Ok(share.data)
139        });
140    }
141
142    pub async fn next(&mut self) -> Option<Result<VidDisperseShare2<T>>> {
143        loop {
144            match self.tasks.join_next().await {
145                Some(Ok(share)) => return Some(share),
146                Some(Err(err)) => {
147                    error!(%err, "vid share validation task panic");
148                },
149                None => return None,
150            }
151        }
152    }
153}
154
155impl<T: NodeType> Validator<T> {
156    /// Verify the proposal signature and return the leader
157    async fn signature(
158        &self,
159        proposal: &SignedProposal<T, Proposal<T>>,
160    ) -> Result<T::SignatureKey> {
161        let view = proposal.data.view_number();
162        let epoch = proposal.data.epoch;
163        let membership = self.membership(epoch).await?;
164        let leader = match membership.leader(view) {
165            Ok(leader) => leader,
166            Err(err) => return Err(ValidationError::NoLeader(view, epoch, err)),
167        };
168        let leaf: Leaf2<T> = proposal.data.clone().into();
169        if leader.validate(&proposal.signature, leaf.commit().as_ref()) {
170            Ok(leader)
171        } else {
172            Err(ValidationError::InvalidProposalSignature)
173        }
174    }
175
176    async fn vid_share_proposal(
177        &self,
178        vid_proposal: &SignedProposal<T, VidDisperseShare2<T>>,
179    ) -> Result<()> {
180        let view = vid_proposal.data.view_number();
181        let epoch = vid_proposal
182            .data
183            .epoch
184            .ok_or(ValidationError::MissingEpoch(view, "vid share"))?;
185        let membership = self.membership(epoch).await?;
186        let stake_table = membership.stake_table();
187        let leader = match membership.leader(view) {
188            Ok(leader) => leader,
189            Err(err) => return Err(ValidationError::NoLeader(view, epoch, err)),
190        };
191        // TODO(Chengyu): this also check the consistency of vid common and vid commitment.
192        let total_weight = vid_total_weight(stake_table, Some(epoch));
193        if !leader.validate(
194            &vid_proposal.signature,
195            vid_proposal.data.payload_commitment.as_ref(),
196        ) {
197            return Err(ValidationError::InvalidVidShareProposalSignature);
198        }
199        if vid_proposal.data.verify(total_weight) {
200            Ok(())
201        } else {
202            Err(ValidationError::VidShareNotVerified)
203        }
204    }
205
206    /// Verify the QC of the proposal
207    async fn justify_qc(&self, proposal: &Proposal<T>) -> Result<()> {
208        let Some(epoch) = proposal.justify_qc.epoch() else {
209            return Err(ValidationError::MissingEpoch(
210                proposal.view_number,
211                "justify_qc",
212            ));
213        };
214        let membership = self.membership(epoch).await?;
215        let entries = StakeTableEntries::from_iter(membership.stake_table()).0;
216        let threshold = membership.success_threshold();
217        match proposal
218            .justify_qc
219            .is_valid_cert(&entries, threshold, &self.upgrade_lock)
220        {
221            Ok(()) => Ok(()),
222            Err(e) => Err(ValidationError::InvalidJustifyQc(e)),
223        }
224    }
225
226    /// Verify the next-epoch justify QC on the first proposal of an epoch.
227    ///
228    /// If the previous block is the last block of an epoch, this proposal is
229    /// the first of the new epoch and must carry a `next_epoch_justify_qc`
230    /// over the same leaf as its justify QC.
231    async fn next_epoch_justify_qc(&self, proposal: &Proposal<T>) -> Result<()> {
232        let block_number = proposal.block_header.block_number();
233        if !is_last_block(block_number.saturating_sub(1), self.epoch_height) {
234            return Ok(());
235        }
236        let Some(cert2) = proposal.next_epoch_justify_qc.as_ref() else {
237            return Err(ValidationError::MissingNextEpochJustifyQc);
238        };
239        if cert2.data.leaf_commit != proposal.justify_qc.data.leaf_commit {
240            return Err(ValidationError::NextEpochJustifyQcMismatch);
241        }
242        let Some(epoch) = proposal.justify_qc.epoch() else {
243            return Err(ValidationError::MissingEpoch(
244                proposal.view_number,
245                "justify_qc",
246            ));
247        };
248        let membership = self.membership(epoch).await?;
249        let entries = StakeTableEntries::from_iter(membership.stake_table()).0;
250        let threshold = membership.success_threshold();
251        cert2
252            .is_valid_cert(&entries, threshold, &self.upgrade_lock)
253            .map_err(ValidationError::InvalidNextEpochJustifyQc)
254    }
255
256    /// Validate the proposal's view-change evidence (timeout certificate).
257    async fn view_change_evidence(&self, proposal: &Proposal<T>) -> Result<()> {
258        let view = proposal.view_number();
259
260        // If proposal chains directly off the immediately preceding view, no evidence is required.
261        if proposal.justify_qc.view_number() + 1 == view {
262            return Ok(());
263        }
264
265        let Some(tc) = proposal.view_change_evidence.as_ref() else {
266            return Err(ValidationError::MissingViewChangeEvidence(view));
267        };
268
269        // The timeout certificate must certify the immediately preceding view.
270        if tc.data().view + 1 != view {
271            return Err(ValidationError::ViewChangeEvidenceWrongView {
272                proposal_view: view,
273                evidence_view: tc.data().view,
274            });
275        }
276
277        let Some(tc_epoch) = tc.epoch() else {
278            return Err(ValidationError::MissingEpoch(view, "view_change_evidence"));
279        };
280        let membership = self.membership(tc_epoch).await?;
281        let entries = StakeTableEntries::from_iter(membership.stake_table()).0;
282        let threshold = membership.success_threshold();
283        tc.is_valid_cert(&entries, threshold, &self.upgrade_lock)
284            .map_err(ValidationError::InvalidViewChangeEvidence)
285    }
286
287    /// Validate the state_cert on an epoch-root proposal.
288    ///
289    /// If the justify_qc points at an epoch-root block, the proposal MUST
290    /// carry a matching `LightClientStateUpdateCertificateV2`.
291    async fn state_cert(&self, proposal: &Proposal<T>) -> Result<()> {
292        let Some(qc_block_number) = proposal.justify_qc.data.block_number else {
293            return Ok(());
294        };
295        if !is_epoch_root(qc_block_number, self.epoch_height) {
296            // Non-epoch-root parent → no state_cert required.
297            return Ok(());
298        }
299        let Some(state_cert) = proposal.state_cert.as_ref() else {
300            return Err(ValidationError::MissingStateCert);
301        };
302        if !check_qc_state_cert_correspondence(&proposal.justify_qc, state_cert, self.epoch_height)
303        {
304            return Err(ValidationError::StateCertCorrespondence);
305        }
306        validate_light_client_state_update_certificate(
307            state_cert,
308            &self.membership_coordinator,
309            &self.upgrade_lock,
310        )
311        .await
312        .map_err(ValidationError::InvalidStateCert)
313    }
314
315    async fn membership(&self, epoch: EpochNumber) -> Result<EpochMembership<T>> {
316        match self
317            .membership_coordinator
318            .membership_for_epoch(Some(epoch))
319        {
320            Ok(m) => Ok(m),
321            Err(_) => self
322                .membership_coordinator
323                .wait_for_catchup(epoch) // TODO: timeout?
324                .await
325                .map_err(|e| ValidationError::NoMembershipForEpoch(epoch, e)),
326        }
327    }
328}
329
330#[derive(Debug, thiserror::Error)]
331pub enum ValidationError {
332    #[error("invalid proposal signature")]
333    InvalidProposalSignature,
334
335    #[error("invalid proposal justify qc: {0}")]
336    InvalidJustifyQc(#[source] anytrace::Error),
337
338    #[error("first proposal of an epoch is missing next_epoch_justify_qc")]
339    MissingNextEpochJustifyQc,
340
341    #[error("next_epoch_justify_qc does not match the justify qc leaf commitment")]
342    NextEpochJustifyQcMismatch,
343
344    #[error("invalid next_epoch_justify_qc: {0}")]
345    InvalidNextEpochJustifyQc(#[source] anytrace::Error),
346
347    #[error("vid share does not match proposal")]
348    VidCommitmentDoesNotMatchProposal,
349
350    #[error("failed to verify vid share")]
351    VidShareNotVerified,
352
353    #[error("vid commitment not v2")]
354    InvalidVidCommitmentVersion,
355
356    #[error("missing epoch number in view {0} ({1})")]
357    MissingEpoch(ViewNumber, &'static str),
358
359    #[error("failed to get membership for epoch {0}: {1}")]
360    NoMembershipForEpoch(EpochNumber, #[source] anytrace::Error),
361
362    #[error("failed to get leader for view {0}, epoch {1}: {2}")]
363    NoLeader(ViewNumber, EpochNumber, #[source] anytrace::Error),
364
365    #[error("proposal justify_qc is epoch-root but state_cert is missing")]
366    MissingStateCert,
367
368    #[error("state_cert does not correspond to justify_qc")]
369    StateCertCorrespondence,
370
371    #[error("state_cert signature validation failed: {0}")]
372    InvalidStateCert(#[source] anytrace::Error),
373
374    #[error("invalid vid share proposal signature")]
375    InvalidVidShareProposalSignature,
376
377    #[error("proposal at view {0} skips views but carries no view-change evidence")]
378    MissingViewChangeEvidence(ViewNumber),
379
380    #[error(
381        "view-change evidence for proposal view {proposal_view} certifies view {evidence_view}, \
382         not the immediately preceding view"
383    )]
384    ViewChangeEvidenceWrongView {
385        proposal_view: ViewNumber,
386        evidence_view: ViewNumber,
387    },
388
389    #[error("view-change evidence (timeout certificate) is invalid: {0}")]
390    InvalidViewChangeEvidence(#[source] anytrace::Error),
391}