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;