Skip to main content

linera_chain/
justification.rs

1// Copyright (c) Zefchain Labs, Inc.
2// SPDX-License-Identifier: Apache-2.0
3
4//! # Fault attributability
5//!
6//! When two `ConfirmedBlock` certificates exist for different blocks at the same height, the
7//! protocol must be able to *attribute* the fault: name validators that provably misbehaved,
8//! using only data the certificates carry. This module provides that data — the
9//! [`JustificationChain`] — and the algorithm that extracts a proof from it.
10//!
11//! Each `ValidatedBlock` vote signs an unlocking round (see [`VoteValue`]), asserting that the
12//! voter has not voted to confirm a *different* block in any round at or above it. A vote only
13//! counts if its unlocking round is `0` (no justification needed) or it is justified by a quorum
14//! of `ValidatedBlock` votes for the same block in a round at or above the unlocking round and
15//! below `r`. That quorum is itself justified, so the justifications form a chain of quorums with
16//! strictly increasing rounds, rising from the round where the block was first validated (where
17//! the unlocking round is `0`, represented as `None`) up to the certifying round.
18//!
19//! Votes also sign a *justification commitment*: the hash of the quorum they cite, as a
20//! [`CommittedQuorum`] — which itself contains the hash of the quorum below it, and so on down to
21//! the fresh proposal. The chain is thus hash-linked, and by signing its head a voter attests
22//! that they verified the cited quorum, whose voters in turn attested the quorum below. A
23//! certificate is therefore verified with a *single* signature check over its top quorum plus a
24//! hash walk down the carried chain: any quorum contains honest voters, so an invalid link can
25//! only sit beneath a quorum whose signers all lied — and signing over an invalid quorum is
26//! itself an attributable fault.
27//!
28//! A confirmation in a chain's *first* round needs no chain: its `ConfirmedBlock` votes instead
29//! carry a first-round attestation (see [`VoteValue`]), asserting that no lower round exists at
30//! this height. A vote in a lower round together with an attestation in a higher one is therefore
31//! itself an attributable fault.
32//!
33//! Because every certificate carries this chain (or attestation), two conflicting certificates
34//! are self-contained evidence: walking one block's chain against the other block's confirmation
35//! quorum reaches validators whose unlocking-round claims are contradicted by their own
36//! confirmation votes, or the two confirmation quorums intersect in validators that contradicted
37//! themselves. See [`extract_equivocations`].
38//!
39//! [`VoteValue`]: crate::data_types::VoteValue
40
41use std::collections::BTreeMap;
42
43use allocative::Allocative;
44use linera_base::{
45    crypto::{BcsHashable, CryptoHash, ValidatorPublicKey, ValidatorSignature},
46    data_types::Round,
47    ensure,
48};
49use linera_execution::committee::Committee;
50use serde::{Deserialize, Serialize};
51
52use crate::{
53    block::BlockHeader,
54    data_types::{check_signatures, VoteValue},
55    types::CertificateKind,
56    ChainError,
57};
58
59#[cfg(with_metrics)]
60mod metrics {
61    use std::sync::LazyLock;
62
63    use linera_base::prometheus_util::{exponential_bucket_interval, register_histogram_vec};
64    use prometheus::HistogramVec;
65
66    /// The number of links in a justification chain a node verified. A chain grows by one link
67    /// per round a block had to fight through, so a rising tail here signals contention on some
68    /// height before it becomes a certificate-size or finalization problem.
69    pub static JUSTIFICATION_CHAIN_LENGTH: LazyLock<HistogramVec> = LazyLock::new(|| {
70        register_histogram_vec(
71            "justification_chain_length",
72            "Number of links in a verified justification chain",
73            &[],
74            exponential_bucket_interval(1.0, 1024.0),
75        )
76    });
77}
78
79/// One link in a justification chain: a quorum of validators that all voted to validate the
80/// same block in `round`.
81#[derive(Clone, Debug, Serialize, Deserialize, Allocative)]
82#[cfg_attr(with_testing, derive(Eq, PartialEq))]
83pub struct JustificationLink {
84    /// The round in which these `ValidatedBlock` votes were cast.
85    pub round: Round,
86    /// The validators' signatures over the corresponding [`VoteValue`].
87    pub signatures: Vec<(ValidatorPublicKey, ValidatorSignature)>,
88}
89
90/// A quorum of `ValidatedBlock` votes as the votes built on top of it commit to it: the block
91/// and round it validated, the payload fields its own voters signed — their unlocking round and
92/// their commitment to the quorum below — and its signatures.
93///
94/// Hashing this struct yields the *justification commitment* (see [`VoteValue`]) that votes
95/// citing this quorum sign. Because the struct contains the previous quorum's commitment, the
96/// hash transitively commits to the entire chain below: verifying one quorum's signatures over
97/// it attests, link by link, that every quorum underneath was checked by the honest voters who
98/// signed above it.
99#[derive(Clone, Debug, Serialize, Deserialize)]
100#[cfg_attr(with_testing, derive(Eq, PartialEq))]
101pub struct CommittedQuorum {
102    /// The hash of the block this quorum validated.
103    pub value_hash: CryptoHash,
104    /// The round in which the quorum's votes were cast.
105    pub round: Round,
106    /// The unlocking round the quorum's voters signed: the round of the quorum below, or `None`
107    /// for a fresh proposal.
108    pub unlocking_round: Option<Round>,
109    /// The justification commitment the quorum's voters signed: the hash of the quorum below as
110    /// a [`CommittedQuorum`], or `None` for a fresh proposal.
111    pub previous: Option<CryptoHash>,
112    /// The `ValidatedBlock` signatures forming the quorum.
113    pub signatures: Vec<(ValidatorPublicKey, ValidatorSignature)>,
114}
115
116impl BcsHashable<'_> for CommittedQuorum {}
117
118impl CommittedQuorum {
119    /// Returns the justification commitment for this quorum: the value a vote citing it signs.
120    pub fn commitment(&self) -> CryptoHash {
121        CryptoHash::new(self)
122    }
123}
124
125/// The chain of `ValidatedBlock` quorums that justifies a validated or confirmed block, from
126/// the round where the block was first validated up to the certifying round.
127///
128/// Links are ordered by **strictly increasing** round. The quorum in link `i` was cast with
129/// unlocking round `links[i - 1].round` — i.e. it is justified by the previous, lower link — and
130/// the first link (index `0`) was cast with unlocking round `0` (`None`), the fresh proposal that
131/// grounds the chain. An empty chain means the block was confirmed in the fast round, which needs
132/// no validation.
133#[derive(Clone, Debug, Default, Serialize, Deserialize, Allocative)]
134#[cfg_attr(with_testing, derive(Eq, PartialEq))]
135pub struct JustificationChain {
136    links: Vec<JustificationLink>,
137}
138
139impl JustificationChain {
140    /// Creates a justification chain from its links, ordered by increasing round.
141    pub fn new(links: Vec<JustificationLink>) -> Self {
142        Self { links }
143    }
144
145    /// Returns the links, ordered by increasing round.
146    pub fn links(&self) -> &[JustificationLink] {
147        &self.links
148    }
149
150    /// Returns a new chain with the given quorum appended as a new top link in `round`,
151    /// i.e. the highest, certifying round. The existing links must all be in lower rounds.
152    pub fn append(
153        &self,
154        round: Round,
155        signatures: Vec<(ValidatorPublicKey, ValidatorSignature)>,
156    ) -> Self {
157        let mut links = self.links.clone();
158        links.push(JustificationLink { round, signatures });
159        Self { links }
160    }
161
162    /// Returns the unlocking round that a certificate sitting on top of this chain signed: the
163    /// round of the highest link, or `None` if the chain is empty.
164    pub fn top_unlocking_round(&self) -> Option<Round> {
165        self.links.last().map(|link| link.round)
166    }
167
168    /// Returns the justification commitment that a vote sitting on top of this chain signs (see
169    /// [`VoteValue`]): the hash of the top link as a [`CommittedQuorum`], which transitively
170    /// commits to every link below. `None` iff the chain is empty.
171    pub fn commitment(&self, value_hash: CryptoHash) -> Option<CryptoHash> {
172        let mut previous = None;
173        let mut unlocking_round = None;
174        for link in &self.links {
175            previous = Some(
176                CommittedQuorum {
177                    value_hash,
178                    round: link.round,
179                    unlocking_round,
180                    previous,
181                    signatures: link.signatures.clone(),
182                }
183                .commitment(),
184            );
185            unlocking_round = Some(link.round);
186        }
187        previous
188    }
189
190    /// Verifies the chain structurally — rounds strictly increase, so each link sits in a higher
191    /// round than the one justifying it — and returns its justification commitment.
192    ///
193    /// Link signatures are deliberately *not* verified here. The quorum built on top of this
194    /// chain signs the chain's commitment, so a single signature check over that quorum attests
195    /// every link below it: each link's voters verified the quorum beneath them before signing
196    /// over its hash. Signing over an invalid quorum is itself an attributable fault, so link
197    /// signatures are only re-checked when auditing a chain to blame the validators that attested
198    /// an invalid link.
199    ///
200    /// The chain length needs no explicit cap: strictly increasing rounds mean a chain of `n`
201    /// links spans `n` distinct rounds, and each link must carry a genuine quorum, so a longer
202    /// chain necessarily reaches a higher round and cannot be inflated cheaply. The observed
203    /// length is recorded as a metric so real contention shows up in monitoring.
204    pub fn verify(&self, value_hash: CryptoHash) -> Result<Option<CryptoHash>, ChainError> {
205        #[cfg(with_metrics)]
206        metrics::JUSTIFICATION_CHAIN_LENGTH
207            .with_label_values(&[])
208            .observe(self.links.len() as f64);
209        for window in self.links.windows(2) {
210            ensure!(
211                window[0].round < window[1].round,
212                ChainError::JustificationRoundsNotIncreasing
213            );
214        }
215        Ok(self.commitment(value_hash))
216    }
217}
218
219/// A confirmed block's *header* together with the justification that makes it self-contained
220/// evidence: the round and quorum of `ConfirmedBlock` votes that finalized it, and the chain of
221/// `ValidatedBlock` quorums for the same block, with its top link in the round the block was
222/// confirmed. Only the header travels, never the block body. The header hashes to the value the
223/// votes sign (`CryptoHash::new(&header)`) and carries the chain ID and height that scope the
224/// fault. This is the shape a `ConfirmedBlockCertificate` reduces to for fault attribution.
225#[derive(Clone, Debug)]
226pub struct JustifiedConfirmation {
227    /// The header of the confirmed block. Its hash is what the chain's `ValidatedBlock` and
228    /// `ConfirmedBlock` votes sign (`ValidatedBlock` and `ConfirmedBlock` wrap the same block).
229    pub header: BlockHeader,
230    /// The round in which the block was confirmed.
231    pub round: Round,
232    /// The first-round attestation the `ConfirmedBlock` votes signed (see [`VoteValue`]).
233    pub first_round: bool,
234    /// The quorum of `ConfirmedBlock` votes finalizing the block.
235    pub confirmed_signatures: Vec<(ValidatorPublicKey, ValidatorSignature)>,
236    /// The `ValidatedBlock` justification chain (empty iff confirmed in the chain's first round).
237    pub justification: JustificationChain,
238}
239
240impl JustifiedConfirmation {
241    /// The hash of the confirmed block, which is what its votes sign.
242    pub fn block_hash(&self) -> CryptoHash {
243        CryptoHash::new(&self.header)
244    }
245
246    /// The justification commitment the confirmed votes signed: the hash of the justification
247    /// chain's top link, or `None` for a first-round confirmation (empty chain).
248    pub fn confirmed_commitment(&self) -> Option<CryptoHash> {
249        self.justification.commitment(self.block_hash())
250    }
251}
252
253/// A quorum of `ValidatedBlock` votes for one block, cast in one round under one unlocking round.
254/// This is the top of a `ValidatedBlockCertificate`; comparing two of them in the same round
255/// attributes a double-validation fault.
256#[derive(Clone, Debug)]
257pub struct ValidatedQuorum {
258    /// The header of the validated block. Its hash is what the votes sign, and it carries the
259    /// chain ID and height that scope the fault.
260    pub header: BlockHeader,
261    /// The round in which the block was validated.
262    pub round: Round,
263    /// The unlocking round these `ValidatedBlock` votes signed.
264    pub unlocking_round: Option<Round>,
265    /// The justification commitment these `ValidatedBlock` votes signed.
266    pub justification_commitment: Option<CryptoHash>,
267    /// The quorum of `ValidatedBlock` votes.
268    pub signatures: Vec<(ValidatorPublicKey, ValidatorSignature)>,
269}
270
271/// A self-contained proof that a single validator misbehaved.
272#[derive(Clone, Debug)]
273pub enum EquivocationProof {
274    /// The validator voted to validate one block under an unlocking round while having voted to
275    /// confirm a *different* block in a round at or above it — contradicting its own
276    /// unlocking-round claim.
277    LockViolation {
278        /// The misbehaving validator.
279        validator: ValidatorPublicKey,
280        /// The header of the block the validator voted to confirm. Its hash, chain ID and height
281        /// are read from here; it must share the chain and height of `validated_header`.
282        confirmed_header: BlockHeader,
283        /// The round in which it voted to confirm.
284        confirmed_round: Round,
285        /// The first-round attestation the confirmation vote signed (see [`VoteValue`]).
286        ///
287        /// [`VoteValue`]: crate::data_types::VoteValue
288        confirmed_attested: bool,
289        /// The justification commitment the confirmation vote signed.
290        confirmed_commitment: Option<CryptoHash>,
291        /// Its `ConfirmedBlock` signature.
292        confirmed_signature: ValidatorSignature,
293        /// The header of the different block the validator voted to validate.
294        validated_header: BlockHeader,
295        /// The round in which it voted to validate.
296        validated_round: Round,
297        /// The unlocking round the validation vote signed, contradicted by the confirmation.
298        validated_unlocking_round: Option<Round>,
299        /// The justification commitment the validation vote signed.
300        validated_commitment: Option<CryptoHash>,
301        /// Its `ValidatedBlock` signature.
302        validated_signature: ValidatorSignature,
303    },
304    /// The validator cast two votes of the same kind for different blocks in the same round:
305    /// either two `ConfirmedBlock` votes, or two `ValidatedBlock` votes (illegal regardless of
306    /// the unlocking rounds, since a validator may vote for at most one block per round and kind).
307    DoubleVote {
308        /// The misbehaving validator.
309        validator: ValidatorPublicKey,
310        /// The round both votes share.
311        round: Round,
312        /// The certificate kind both votes share.
313        kind: CertificateKind,
314        /// The header of the first block voted for. The two headers must share a chain and height.
315        first_header: BlockHeader,
316        /// The unlocking round the first vote signed (`None` for `ConfirmedBlock` votes).
317        first_unlocking_round: Option<Round>,
318        /// The first-round attestation the first vote signed (`false` for `ValidatedBlock` votes).
319        first_attested: bool,
320        /// The justification commitment the first vote signed.
321        first_commitment: Option<CryptoHash>,
322        /// The signature on the first vote.
323        first_signature: ValidatorSignature,
324        /// The header of the second, different block voted for.
325        second_header: BlockHeader,
326        /// The unlocking round the second vote signed (`None` for `ConfirmedBlock` votes).
327        second_unlocking_round: Option<Round>,
328        /// The first-round attestation the second vote signed (`false` for `ValidatedBlock` votes).
329        second_attested: bool,
330        /// The justification commitment the second vote signed.
331        second_commitment: Option<CryptoHash>,
332        /// The signature on the second vote.
333        second_signature: ValidatorSignature,
334    },
335    /// The validator voted to confirm a block with the first-round attestation — asserting that
336    /// no lower round exists at this height — while having also voted to confirm a block in a
337    /// lower round. The two votes cannot both be honest, regardless of the blocks they are for.
338    FirstRoundViolation {
339        /// The misbehaving validator.
340        validator: ValidatorPublicKey,
341        /// The header of the block confirmed with the attestation. It must share the chain and
342        /// height of `earlier_header`.
343        attested_header: BlockHeader,
344        /// The round the attestation asserts to be the chain's first.
345        attested_round: Round,
346        /// The justification commitment the attested vote signed.
347        attested_commitment: Option<CryptoHash>,
348        /// The `ConfirmedBlock` signature carrying the attestation.
349        attested_signature: ValidatorSignature,
350        /// The header of the block the earlier vote confirmed.
351        earlier_header: BlockHeader,
352        /// The round of the earlier vote — strictly below `attested_round`.
353        earlier_round: Round,
354        /// The first-round attestation the earlier vote signed.
355        earlier_attested: bool,
356        /// The justification commitment the earlier vote signed.
357        earlier_commitment: Option<CryptoHash>,
358        /// Its `ConfirmedBlock` signature.
359        earlier_signature: ValidatorSignature,
360    },
361    /// The validator signed a justification commitment whose opening is not a quorum it could
362    /// honestly have cited: the opening's signatures are invalid or below the quorum threshold,
363    /// it validates a different block, or its round does not ground the vote that cited it. By
364    /// signing the commitment the validator attested that it had verified the cited quorum —
365    /// certificate verification relies on that attestation instead of re-checking the chain — so
366    /// an invalid opening is individually attributable, with no conflicting certificate needed.
367    InvalidJustification {
368        /// The misbehaving validator.
369        validator: ValidatorPublicKey,
370        /// The header of the block the attesting vote was for.
371        header: BlockHeader,
372        /// The round of the attesting vote.
373        round: Round,
374        /// The kind of the attesting vote.
375        kind: CertificateKind,
376        /// The unlocking round the attesting vote signed.
377        unlocking_round: Option<Round>,
378        /// The first-round attestation the attesting vote signed.
379        first_round: bool,
380        /// The attesting vote's signature. Its payload contains the opening's hash as the
381        /// justification commitment, binding the signature to the opening.
382        signature: ValidatorSignature,
383        /// The opening of the signed commitment: the cited quorum, which is not valid.
384        opening: CommittedQuorum,
385    },
386}
387
388impl EquivocationProof {
389    /// Returns the misbehaving validator.
390    pub fn validator(&self) -> ValidatorPublicKey {
391        match self {
392            EquivocationProof::LockViolation { validator, .. }
393            | EquivocationProof::DoubleVote { validator, .. }
394            | EquivocationProof::FirstRoundViolation { validator, .. }
395            | EquivocationProof::InvalidJustification { validator, .. } => *validator,
396        }
397    }
398
399    /// Verifies that this is a genuine proof of misbehavior: the referenced votes are actually
400    /// incompatible (or the opened justification actually invalid, judged against `committee`)
401    /// and signed by the named validator.
402    pub fn check(&self, committee: &Committee) -> Result<(), ChainError> {
403        match self {
404            EquivocationProof::LockViolation {
405                validator,
406                confirmed_header,
407                confirmed_round,
408                confirmed_attested,
409                confirmed_commitment,
410                confirmed_signature,
411                validated_header,
412                validated_round,
413                validated_unlocking_round,
414                validated_commitment,
415                validated_signature,
416            } => {
417                let confirmed_block_hash = CryptoHash::new(confirmed_header);
418                let validated_block_hash = CryptoHash::new(validated_header);
419                ensure!(
420                    confirmed_block_hash != validated_block_hash,
421                    ChainError::EquivocationProofSameBlock
422                );
423                // The two votes must concern the same height on the same chain; otherwise there
424                // is no lock relationship between them — a validator may freely confirm a block at
425                // one height and validate a different one at another height or on another chain.
426                ensure!(
427                    confirmed_header.chain_id == validated_header.chain_id
428                        && confirmed_header.height == validated_header.height,
429                    ChainError::EquivocationProofDifferentChainOrHeight
430                );
431                // The unlocking-round claim — "no confirmation of a different block in any round
432                // at or above the unlocking round" — is made while validating in
433                // `validated_round`, so it covers only the rounds the voter had already acted in:
434                // the window `[unlocking_round, validated_round)` (an unlocking round of `None`
435                // means `0`). The confirmation contradicts it only if it falls in that window,
436                // i.e. `unlocking_round ≤ confirmed_round < validated_round`. A confirmation at or
437                // after `validated_round` is a legitimate later switch, not a violation.
438                ensure!(
439                    *confirmed_round < *validated_round
440                        && validated_unlocking_round
441                            .is_none_or(|unlocking_round| *confirmed_round >= unlocking_round),
442                    ChainError::EquivocationProofNoLockViolation
443                );
444                let confirmed = VoteValue(
445                    confirmed_block_hash,
446                    *confirmed_round,
447                    CertificateKind::Confirmed,
448                    None,
449                    *confirmed_attested,
450                    *confirmed_commitment,
451                );
452                confirmed_signature.check(&confirmed, *validator)?;
453                let validated = VoteValue(
454                    validated_block_hash,
455                    *validated_round,
456                    CertificateKind::Validated,
457                    *validated_unlocking_round,
458                    false,
459                    *validated_commitment,
460                );
461                validated_signature.check(&validated, *validator)?;
462                Ok(())
463            }
464            EquivocationProof::DoubleVote {
465                validator,
466                round,
467                kind,
468                first_header,
469                first_unlocking_round,
470                first_attested,
471                first_commitment,
472                first_signature,
473                second_header,
474                second_unlocking_round,
475                second_attested,
476                second_commitment,
477                second_signature,
478            } => {
479                let first_block_hash = CryptoHash::new(first_header);
480                let second_block_hash = CryptoHash::new(second_header);
481                ensure!(
482                    first_block_hash != second_block_hash,
483                    ChainError::EquivocationProofSameBlock
484                );
485                // Both votes must concern the same height on the same chain: a validator voting
486                // for different blocks at different heights or on different chains in the same
487                // round number is not double-voting.
488                ensure!(
489                    first_header.chain_id == second_header.chain_id
490                        && first_header.height == second_header.height,
491                    ChainError::EquivocationProofDifferentChainOrHeight
492                );
493                let first = VoteValue(
494                    first_block_hash,
495                    *round,
496                    *kind,
497                    *first_unlocking_round,
498                    *first_attested,
499                    *first_commitment,
500                );
501                first_signature.check(&first, *validator)?;
502                let second = VoteValue(
503                    second_block_hash,
504                    *round,
505                    *kind,
506                    *second_unlocking_round,
507                    *second_attested,
508                    *second_commitment,
509                );
510                second_signature.check(&second, *validator)?;
511                Ok(())
512            }
513            EquivocationProof::FirstRoundViolation {
514                validator,
515                attested_header,
516                attested_round,
517                attested_commitment,
518                attested_signature,
519                earlier_header,
520                earlier_round,
521                earlier_attested,
522                earlier_commitment,
523                earlier_signature,
524            } => {
525                // Both votes must concern the same height on the same chain: the attestation only
526                // asserts that no lower round exists at *this* height, so a vote at a lower round
527                // number elsewhere does not contradict it. The blocks themselves may be equal —
528                // the contradiction is between the rounds, not the blocks.
529                ensure!(
530                    attested_header.chain_id == earlier_header.chain_id
531                        && attested_header.height == earlier_header.height,
532                    ChainError::EquivocationProofDifferentChainOrHeight
533                );
534                ensure!(
535                    *earlier_round < *attested_round,
536                    ChainError::EquivocationProofNoFirstRoundViolation
537                );
538                let attested = VoteValue(
539                    CryptoHash::new(attested_header),
540                    *attested_round,
541                    CertificateKind::Confirmed,
542                    None,
543                    true,
544                    *attested_commitment,
545                );
546                attested_signature.check(&attested, *validator)?;
547                let earlier = VoteValue(
548                    CryptoHash::new(earlier_header),
549                    *earlier_round,
550                    CertificateKind::Confirmed,
551                    None,
552                    *earlier_attested,
553                    *earlier_commitment,
554                );
555                earlier_signature.check(&earlier, *validator)?;
556                Ok(())
557            }
558            EquivocationProof::InvalidJustification {
559                validator,
560                header,
561                round,
562                kind,
563                unlocking_round,
564                first_round,
565                signature,
566                opening,
567            } => {
568                // The signed payload contains the opening's hash, so verifying the signature
569                // binds the validator to exactly this opening.
570                let value = VoteValue(
571                    CryptoHash::new(header),
572                    *round,
573                    *kind,
574                    *unlocking_round,
575                    *first_round,
576                    Some(opening.commitment()),
577                );
578                signature.check(&value, *validator)?;
579                // The opening must be one that no honest voter could have cited.
580                ensure!(
581                    check_cited_quorum(header, *round, *kind, *unlocking_round, opening, committee)
582                        .is_err(),
583                    ChainError::EquivocationProofValidJustification
584                );
585                Ok(())
586            }
587        }
588    }
589}
590
591/// Checks that `opening` is a quorum an honest voter could cite from a vote of the given kind,
592/// round and unlocking round for the block with the given header: it validates the same block, in
593/// the round the vote's payload grounds on, and its signatures form a genuine quorum of
594/// `committee` over the reconstructed `ValidatedBlock` payload. These are exactly the checks a
595/// voter performs before signing the opening's commitment, so their failure on a signed opening
596/// convicts the signer.
597fn check_cited_quorum(
598    header: &BlockHeader,
599    round: Round,
600    kind: CertificateKind,
601    unlocking_round: Option<Round>,
602    opening: &CommittedQuorum,
603    committee: &Committee,
604) -> Result<(), ChainError> {
605    ensure!(
606        opening.value_hash == CryptoHash::new(header),
607        ChainError::JustificationCommitmentMismatch
608    );
609    match kind {
610        // A validated vote cites the quorum grounding its unlocking round, in a lower round.
611        CertificateKind::Validated => ensure!(
612            unlocking_round == Some(opening.round) && opening.round < round,
613            ChainError::JustificationUnlockingRoundMismatch
614        ),
615        // A confirmed vote cites the quorum that validated the block in the same round.
616        CertificateKind::Confirmed => ensure!(
617            opening.round == round,
618            ChainError::JustificationUnlockingRoundMismatch
619        ),
620        // Timeout votes cite nothing; any commitment is dishonest.
621        CertificateKind::Timeout => ensure!(false, ChainError::JustificationCommitmentMismatch),
622    }
623    // A quorum with an unlocking round cites a quorum itself, and vice versa: its own commitment
624    // and unlocking round come from one chain, so they are both present or both absent.
625    ensure!(
626        opening.unlocking_round.is_some() == opening.previous.is_some(),
627        ChainError::JustificationUnlockingRoundMismatch
628    );
629    let value = VoteValue(
630        opening.value_hash,
631        opening.round,
632        CertificateKind::Validated,
633        opening.unlocking_round,
634        false,
635        opening.previous,
636    );
637    check_signatures(&value, &opening.signatures, committee)
638}
639
640/// Audits a justified confirmation by re-checking every link of its carried chain — the work
641/// certificate verification skips, because each link's validity is attested by the signatures
642/// above it. If a link is not a genuine quorum, returns one [`InvalidJustification`] proof per
643/// signer of the level above it (the next link, or the confirmation quorum for the top link):
644/// they all signed the invalid quorum's commitment. Returns an empty list if the chain is sound.
645///
646/// [`InvalidJustification`]: EquivocationProof::InvalidJustification
647pub fn audit_confirmation(
648    confirmation: &JustifiedConfirmation,
649    committee: &Committee,
650) -> Vec<EquivocationProof> {
651    let block_hash = confirmation.block_hash();
652    let mut previous = None;
653    let mut unlocking_round = None;
654    for (index, link) in confirmation.justification.links().iter().enumerate() {
655        let opening = CommittedQuorum {
656            value_hash: block_hash,
657            round: link.round,
658            unlocking_round,
659            previous,
660            signatures: link.signatures.clone(),
661        };
662        let value = VoteValue(
663            block_hash,
664            link.round,
665            CertificateKind::Validated,
666            unlocking_round,
667            false,
668            previous,
669        );
670        if check_signatures(&value, &link.signatures, committee).is_err() {
671            // This link is not a genuine quorum. Its own signatures prove nothing, but every
672            // signer of the level above committed to its hash, attesting they had verified it.
673            return match confirmation.justification.links().get(index + 1) {
674                Some(above) => above
675                    .signatures
676                    .iter()
677                    .map(
678                        |(validator, signature)| EquivocationProof::InvalidJustification {
679                            validator: *validator,
680                            header: confirmation.header.clone(),
681                            round: above.round,
682                            kind: CertificateKind::Validated,
683                            unlocking_round: Some(link.round),
684                            first_round: false,
685                            signature: *signature,
686                            opening: opening.clone(),
687                        },
688                    )
689                    .collect(),
690                None => confirmation
691                    .confirmed_signatures
692                    .iter()
693                    .map(
694                        |(validator, signature)| EquivocationProof::InvalidJustification {
695                            validator: *validator,
696                            header: confirmation.header.clone(),
697                            round: confirmation.round,
698                            kind: CertificateKind::Confirmed,
699                            unlocking_round: None,
700                            first_round: confirmation.first_round,
701                            signature: *signature,
702                            opening: opening.clone(),
703                        },
704                    )
705                    .collect(),
706            };
707        }
708        previous = Some(opening.commitment());
709        unlocking_round = Some(link.round);
710    }
711    Vec::new()
712}
713
714/// Extracts proofs of equivocation from two `ConfirmedBlock` certificates that finalize
715/// *different* blocks at the same height: one proof for every validator whose own signatures on
716/// the two certificates (including their justification chains) contradict each other.
717///
718/// Returns an empty list only if the inputs do not actually conflict (same block, or different
719/// chains or heights) or are malformed. For two genuinely conflicting, well-formed certificates
720/// the proven validators always hold at least a third of the total weight, because each of the
721/// following cases blames a full intersection of two quorums. With the lower confirmation in
722/// round `r` and the higher in round `s`:
723///
724/// - `r == s`: every validator in the intersection of the two confirmation quorums double-voted.
725/// - `r < s` and the higher certificate carries a justification chain: the chain's links tile
726///   all rounds from its grounding round up to `s` with unlocking-round windows, and the
727///   grounding link's window is unbounded below, so some link's window contains `r`. Every
728///   validator in that link's intersection with the lower confirmation quorum violated its lock.
729/// - `r < s` and the higher certificate instead carries the first-round attestation: every
730///   validator in the intersection of the two confirmation quorums confirmed in `r` while
731///   attesting that `s` is the chain's first round — a first-round violation.
732pub fn extract_equivocations(
733    a: &JustifiedConfirmation,
734    b: &JustifiedConfirmation,
735) -> Vec<EquivocationProof> {
736    // A conflict is two *different* blocks at the *same* height on the *same* chain.
737    if a.header.chain_id != b.header.chain_id || a.header.height != b.header.height {
738        return Vec::new();
739    }
740    if a.block_hash() == b.block_hash() {
741        return Vec::new(); // Not a conflict.
742    }
743    let mut proofs = BTreeMap::new();
744    // Walk each block's justification chain against the other's confirmation quorum.
745    walk_chain(a, b, &mut proofs);
746    walk_chain(b, a, &mut proofs);
747    // Both blocks were confirmed in the same round: two confirmation votes for different blocks
748    // in that round.
749    double_confirm(a, b, &mut proofs);
750    // One block was confirmed with a first-round attestation and the other in a lower round: a
751    // confirmation below the attested first round.
752    first_round_violation(a, b, &mut proofs);
753    first_round_violation(b, a, &mut proofs);
754    proofs.into_values().collect()
755}
756
757/// Records a proof for every validator that confirmed `confirmer`'s block and also appears in
758/// some link of `chained`'s justification chain with an unlocking round the confirmation
759/// contradicts.
760fn walk_chain(
761    confirmer: &JustifiedConfirmation,
762    chained: &JustifiedConfirmation,
763    proofs: &mut BTreeMap<ValidatorPublicKey, EquivocationProof>,
764) {
765    let chained_block_hash = chained.block_hash();
766    let chain = &chained.justification;
767    // The commitment each link's voters signed is the hash of the link below, folded up as we
768    // walk. It is needed to reconstruct the exact payload of the extracted signatures.
769    let mut commitment = None;
770    let mut unlocking_round = None;
771    for link in chain.links() {
772        // This link's votes (cast in `link.round`) claim no conflicting confirmation in any
773        // round at or above the unlocking round, covering the window `[unlocking_round,
774        // link.round)`. A confirmation in `confirmer.round` contradicts it only if it falls in
775        // that window: the link must have been cast strictly after the confirmation
776        // (`link.round > confirmer.round`) with an unlocking round reaching back over it
777        // (`unlocking_round ≤ confirmer.round`). Otherwise it's a legitimate later switch; another
778        // link may still straddle the confirmation, so keep scanning.
779        if link.round > confirmer.round
780            && unlocking_round.is_none_or(|unlocking_round| unlocking_round <= confirmer.round)
781        {
782            for (validator, validated_signature) in &link.signatures {
783                if let Some(confirmed_signature) =
784                    signature_of(&confirmer.confirmed_signatures, validator)
785                {
786                    proofs
787                        .entry(*validator)
788                        .or_insert_with(|| EquivocationProof::LockViolation {
789                            validator: *validator,
790                            confirmed_header: confirmer.header.clone(),
791                            confirmed_round: confirmer.round,
792                            confirmed_attested: confirmer.first_round,
793                            confirmed_commitment: confirmer.confirmed_commitment(),
794                            confirmed_signature,
795                            validated_header: chained.header.clone(),
796                            validated_round: link.round,
797                            validated_unlocking_round: unlocking_round,
798                            validated_commitment: commitment,
799                            validated_signature: *validated_signature,
800                        });
801                }
802            }
803        }
804        commitment = Some(
805            CommittedQuorum {
806                value_hash: chained_block_hash,
807                round: link.round,
808                unlocking_round,
809                previous: commitment,
810                signatures: link.signatures.clone(),
811            }
812            .commitment(),
813        );
814        unlocking_round = Some(link.round);
815    }
816}
817
818/// Extracts proofs that validators validated two *different* blocks in the same round, which
819/// is illegal regardless of the locks: a validator may vote to validate at most one block per
820/// round. One proof per validator that signed both quorums; empty if the quorums are for the
821/// same block or different rounds (validating conflicting blocks in different rounds is not
822/// itself a fault — only confirming is locked).
823pub fn extract_double_validations(
824    a: &ValidatedQuorum,
825    b: &ValidatedQuorum,
826) -> Vec<EquivocationProof> {
827    let mut proofs = BTreeMap::new();
828    if a.round == b.round {
829        double_vote(
830            a.round,
831            CertificateKind::Validated,
832            &a.header,
833            a.unlocking_round,
834            false,
835            a.justification_commitment,
836            &a.signatures,
837            &b.header,
838            b.unlocking_round,
839            false,
840            b.justification_commitment,
841            &b.signatures,
842            &mut proofs,
843        );
844    }
845    proofs.into_values().collect()
846}
847
848/// Records a proof for every validator that confirmed both blocks in the same round.
849fn double_confirm(
850    a: &JustifiedConfirmation,
851    b: &JustifiedConfirmation,
852    proofs: &mut BTreeMap<ValidatorPublicKey, EquivocationProof>,
853) {
854    if a.round != b.round {
855        return;
856    }
857    double_vote(
858        a.round,
859        CertificateKind::Confirmed,
860        &a.header,
861        None,
862        a.first_round,
863        a.confirmed_commitment(),
864        &a.confirmed_signatures,
865        &b.header,
866        None,
867        b.first_round,
868        b.confirmed_commitment(),
869        &b.confirmed_signatures,
870        proofs,
871    );
872}
873
874/// Records a proof for every validator that confirmed `attested`'s block with the first-round
875/// attestation and also confirmed `earlier`'s block in a lower round — contradicting the
876/// attestation's claim that no lower round exists at this height.
877fn first_round_violation(
878    attested: &JustifiedConfirmation,
879    earlier: &JustifiedConfirmation,
880    proofs: &mut BTreeMap<ValidatorPublicKey, EquivocationProof>,
881) {
882    if !attested.first_round || earlier.round >= attested.round {
883        return;
884    }
885    for (validator, attested_signature) in &attested.confirmed_signatures {
886        if let Some(earlier_signature) = signature_of(&earlier.confirmed_signatures, validator) {
887            proofs
888                .entry(*validator)
889                .or_insert_with(|| EquivocationProof::FirstRoundViolation {
890                    validator: *validator,
891                    attested_header: attested.header.clone(),
892                    attested_round: attested.round,
893                    attested_commitment: attested.confirmed_commitment(),
894                    attested_signature: *attested_signature,
895                    earlier_header: earlier.header.clone(),
896                    earlier_round: earlier.round,
897                    earlier_attested: earlier.first_round,
898                    earlier_commitment: earlier.confirmed_commitment(),
899                    earlier_signature,
900                });
901        }
902    }
903}
904
905/// Records an [`EquivocationProof::DoubleVote`] for every validator that signed both quorums.
906#[allow(clippy::too_many_arguments)]
907fn double_vote(
908    round: Round,
909    kind: CertificateKind,
910    first_header: &BlockHeader,
911    first_unlocking_round: Option<Round>,
912    first_attested: bool,
913    first_commitment: Option<CryptoHash>,
914    first_signatures: &[(ValidatorPublicKey, ValidatorSignature)],
915    second_header: &BlockHeader,
916    second_unlocking_round: Option<Round>,
917    second_attested: bool,
918    second_commitment: Option<CryptoHash>,
919    second_signatures: &[(ValidatorPublicKey, ValidatorSignature)],
920    proofs: &mut BTreeMap<ValidatorPublicKey, EquivocationProof>,
921) {
922    // Only a conflict if the two votes are for different blocks at the same height on the same
923    // chain.
924    if first_header.chain_id != second_header.chain_id
925        || first_header.height != second_header.height
926        || CryptoHash::new(first_header) == CryptoHash::new(second_header)
927    {
928        return;
929    }
930    for (validator, first_signature) in first_signatures {
931        if let Some(second_signature) = signature_of(second_signatures, validator) {
932            proofs
933                .entry(*validator)
934                .or_insert_with(|| EquivocationProof::DoubleVote {
935                    validator: *validator,
936                    round,
937                    kind,
938                    first_header: first_header.clone(),
939                    first_unlocking_round,
940                    first_attested,
941                    first_commitment,
942                    first_signature: *first_signature,
943                    second_header: second_header.clone(),
944                    second_unlocking_round,
945                    second_attested,
946                    second_commitment,
947                    second_signature,
948                });
949        }
950    }
951}
952
953/// Returns the validator's signature in `signatures`, if present.
954fn signature_of(
955    signatures: &[(ValidatorPublicKey, ValidatorSignature)],
956    validator: &ValidatorPublicKey,
957) -> Option<ValidatorSignature> {
958    signatures
959        .iter()
960        .find(|(key, _)| key == validator)
961        .map(|(_, signature)| *signature)
962}
963
964#[cfg(test)]
965#[path = "unit_tests/justification_tests.rs"]
966mod justification_tests;