From 5f73a456c5af9a4532b0ad6fe6303414386f2d13 Mon Sep 17 00:00:00 2001
From: Serban Iorga <serban@parity.io>
Date: Mon, 24 Jul 2023 16:26:23 +0300
Subject: [PATCH] GRANDPA justifications: equivocation detection primitives
 (#2295) (#2297)

* GRANDPA justifications: equivocation detection primitives

- made the justification verification logic more customizable
- added support for parsing multiple justifications and extracting
  equivocations
- split the logic into multiple files
- split the errors into multiple enums
---
 .../header-chain/src/justification.rs         | 444 ------------------
 .../header-chain/src/justification/mod.rs     | 127 +++++
 .../verification/equivocation.rs              | 179 +++++++
 .../src/justification/verification/mod.rs     | 279 +++++++++++
 .../justification/verification/optimizer.rs   | 132 ++++++
 .../src/justification/verification/strict.rs  | 106 +++++
 .../tests/implementation_match.rs             |  27 +-
 .../tests/justification/equivocation.rs       | 124 +++++
 .../optimizer.rs}                             | 178 +------
 .../tests/justification/strict.rs             | 196 ++++++++
 .../primitives/header-chain/tests/tests.rs    |   7 +
 11 files changed, 1163 insertions(+), 636 deletions(-)
 delete mode 100644 bridges/primitives/header-chain/src/justification.rs
 create mode 100644 bridges/primitives/header-chain/src/justification/mod.rs
 create mode 100644 bridges/primitives/header-chain/src/justification/verification/equivocation.rs
 create mode 100644 bridges/primitives/header-chain/src/justification/verification/mod.rs
 create mode 100644 bridges/primitives/header-chain/src/justification/verification/optimizer.rs
 create mode 100644 bridges/primitives/header-chain/src/justification/verification/strict.rs
 create mode 100644 bridges/primitives/header-chain/tests/justification/equivocation.rs
 rename bridges/primitives/header-chain/tests/{justification.rs => justification/optimizer.rs} (55%)
 create mode 100644 bridges/primitives/header-chain/tests/justification/strict.rs
 create mode 100644 bridges/primitives/header-chain/tests/tests.rs

diff --git a/bridges/primitives/header-chain/src/justification.rs b/bridges/primitives/header-chain/src/justification.rs
deleted file mode 100644
index 714546a42ef..00000000000
--- a/bridges/primitives/header-chain/src/justification.rs
+++ /dev/null
@@ -1,444 +0,0 @@
-// Copyright 2019-2021 Parity Technologies (UK) Ltd.
-// This file is part of Parity Bridges Common.
-
-// Parity Bridges Common is free software: you can redistribute it and/or modify
-// it under the terms of the GNU General Public License as published by
-// the Free Software Foundation, either version 3 of the License, or
-// (at your option) any later version.
-
-// Parity Bridges Common is distributed in the hope that it will be useful,
-// but WITHOUT ANY WARRANTY; without even the implied warranty of
-// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-// GNU General Public License for more details.
-
-// You should have received a copy of the GNU General Public License
-// along with Parity Bridges Common.  If not, see <http://www.gnu.org/licenses/>.
-
-//! Pallet for checking GRANDPA Finality Proofs.
-//!
-//! Adapted copy of substrate/client/finality-grandpa/src/justification.rs. If origin
-//! will ever be moved to the sp_consensus_grandpa, we should reuse that implementation.
-
-use crate::ChainWithGrandpa;
-
-use bp_runtime::{BlockNumberOf, Chain, HashOf, HeaderId};
-use codec::{Decode, Encode, MaxEncodedLen};
-use finality_grandpa::voter_set::VoterSet;
-use frame_support::{RuntimeDebug, RuntimeDebugNoBound};
-use scale_info::TypeInfo;
-use sp_consensus_grandpa::{AuthorityId, AuthoritySignature, SetId};
-use sp_runtime::{traits::Header as HeaderT, SaturatedConversion};
-use sp_std::{
-	collections::{btree_map::BTreeMap, btree_set::BTreeSet},
-	prelude::*,
-};
-
-/// A GRANDPA Justification is a proof that a given header was finalized
-/// at a certain height and with a certain set of authorities.
-///
-/// This particular proof is used to prove that headers on a bridged chain
-/// (so not our chain) have been finalized correctly.
-#[derive(Encode, Decode, Clone, PartialEq, Eq, TypeInfo, RuntimeDebugNoBound)]
-pub struct GrandpaJustification<Header: HeaderT> {
-	/// The round (voting period) this justification is valid for.
-	pub round: u64,
-	/// The set of votes for the chain which is to be finalized.
-	pub commit:
-		finality_grandpa::Commit<Header::Hash, Header::Number, AuthoritySignature, AuthorityId>,
-	/// A proof that the chain of blocks in the commit are related to each other.
-	pub votes_ancestries: Vec<Header>,
-}
-
-impl<H: HeaderT> GrandpaJustification<H> {
-	/// Returns reasonable size of justification using constants from the provided chain.
-	///
-	/// An imprecise analogue of `MaxEncodedLen` implementation. We don't use it for
-	/// any precise calculations - that's just an estimation.
-	pub fn max_reasonable_size<C>(required_precommits: u32) -> u32
-	where
-		C: Chain + ChainWithGrandpa,
-	{
-		// we don't need precise results here - just estimations, so some details
-		// are removed from computations (e.g. bytes required to encode vector length)
-
-		// structures in `finality_grandpa` crate are not implementing `MaxEncodedLength`, so
-		// here's our estimation for the `finality_grandpa::Commit` struct size
-		//
-		// precommit is: hash + number
-		// signed precommit is: precommit + signature (64b) + authority id
-		// commit is: hash + number + vec of signed precommits
-		let signed_precommit_size: u32 = BlockNumberOf::<C>::max_encoded_len()
-			.saturating_add(HashOf::<C>::max_encoded_len().saturated_into())
-			.saturating_add(64)
-			.saturating_add(AuthorityId::max_encoded_len().saturated_into())
-			.saturated_into();
-		let max_expected_signed_commit_size = signed_precommit_size
-			.saturating_mul(required_precommits)
-			.saturating_add(BlockNumberOf::<C>::max_encoded_len().saturated_into())
-			.saturating_add(HashOf::<C>::max_encoded_len().saturated_into());
-
-		// justification is a signed GRANDPA commit, `votes_ancestries` vector and round number
-		let max_expected_votes_ancestries_size = C::REASONABLE_HEADERS_IN_JUSTIFICATON_ANCESTRY
-			.saturating_mul(C::AVERAGE_HEADER_SIZE_IN_JUSTIFICATION);
-
-		8u32.saturating_add(max_expected_signed_commit_size)
-			.saturating_add(max_expected_votes_ancestries_size)
-	}
-
-	pub fn commit_target_id(&self) -> HeaderId<H::Hash, H::Number> {
-		HeaderId(self.commit.target_number, self.commit.target_hash)
-	}
-}
-
-impl<H: HeaderT> crate::FinalityProof<H::Number> for GrandpaJustification<H> {
-	fn target_header_number(&self) -> H::Number {
-		self.commit.target_number
-	}
-}
-
-/// Justification verification error.
-#[derive(Eq, RuntimeDebug, PartialEq)]
-pub enum Error {
-	/// Failed to decode justification.
-	JustificationDecode,
-	/// Justification is finalizing unexpected header.
-	InvalidJustificationTarget,
-	/// Justification contains redundant votes.
-	RedundantVotesInJustification,
-	/// Justification contains unknown authority precommit.
-	UnknownAuthorityVote,
-	/// Justification contains duplicate authority precommit.
-	DuplicateAuthorityVote,
-	/// The authority has provided an invalid signature.
-	InvalidAuthoritySignature,
-	/// The justification contains precommit for header that is not a descendant of the commit
-	/// header.
-	UnrelatedAncestryVote,
-	/// The cumulative weight of all votes in the justification is not enough to justify commit
-	/// header finalization.
-	TooLowCumulativeWeight,
-	/// The justification contains extra (unused) headers in its `votes_ancestries` field.
-	RedundantVotesAncestries,
-}
-
-/// Given GRANDPA authorities set size, return number of valid authorities votes that the
-/// justification must have to be valid.
-///
-/// This function assumes that all authorities have the same vote weight.
-pub fn required_justification_precommits(authorities_set_length: u32) -> u32 {
-	authorities_set_length - authorities_set_length.saturating_sub(1) / 3
-}
-
-/// Decode justification target.
-pub fn decode_justification_target<Header: HeaderT>(
-	raw_justification: &[u8],
-) -> Result<(Header::Hash, Header::Number), Error> {
-	GrandpaJustification::<Header>::decode(&mut &*raw_justification)
-		.map(|justification| (justification.commit.target_hash, justification.commit.target_number))
-		.map_err(|_| Error::JustificationDecode)
-}
-
-/// Verify and optimize given justification by removing unknown and duplicate votes.
-pub fn verify_and_optimize_justification<Header: HeaderT>(
-	finalized_target: (Header::Hash, Header::Number),
-	authorities_set_id: SetId,
-	authorities_set: &VoterSet<AuthorityId>,
-	justification: &mut GrandpaJustification<Header>,
-) -> Result<(), Error> {
-	let mut optimizer = OptimizationCallbacks {
-		extra_precommits: vec![],
-		redundant_votes_ancestries: Default::default(),
-	};
-	verify_justification_with_callbacks(
-		finalized_target,
-		authorities_set_id,
-		authorities_set,
-		justification,
-		&mut optimizer,
-	)?;
-	optimizer.optimize(justification);
-
-	Ok(())
-}
-
-/// Verify that justification, that is generated by given authority set, finalizes given header.
-pub fn verify_justification<Header: HeaderT>(
-	finalized_target: (Header::Hash, Header::Number),
-	authorities_set_id: SetId,
-	authorities_set: &VoterSet<AuthorityId>,
-	justification: &GrandpaJustification<Header>,
-) -> Result<(), Error> {
-	verify_justification_with_callbacks(
-		finalized_target,
-		authorities_set_id,
-		authorities_set,
-		justification,
-		&mut StrictVerificationCallbacks,
-	)
-}
-
-/// Verification callbacks.
-trait VerificationCallbacks<Header: HeaderT> {
-	/// Called when we see a precommit from unknown authority.
-	fn on_unkown_authority(&mut self, precommit_idx: usize) -> Result<(), Error>;
-	/// Called when we see a precommit with duplicate vote from known authority.
-	fn on_duplicate_authority_vote(&mut self, precommit_idx: usize) -> Result<(), Error>;
-	/// Called when we see a precommit with an invalid signature.
-	fn on_invalid_authority_signature(&mut self, precommit_idx: usize) -> Result<(), Error>;
-	/// Called when we see a precommit after we've collected enough votes from authorities.
-	fn on_redundant_authority_vote(&mut self, precommit_idx: usize) -> Result<(), Error>;
-	/// Called when we see a precommit that is not a descendant of the commit target.
-	fn on_unrelated_ancestry_vote(&mut self, precommit_idx: usize) -> Result<(), Error>;
-	/// Called when there are redundant headers in the votes ancestries.
-	fn on_redundant_votes_ancestries(
-		&mut self,
-		redundant_votes_ancestries: BTreeSet<Header::Hash>,
-	) -> Result<(), Error>;
-}
-
-/// Verification callbacks that reject all unknown, duplicate or redundant votes.
-struct StrictVerificationCallbacks;
-
-impl<Header: HeaderT> VerificationCallbacks<Header> for StrictVerificationCallbacks {
-	fn on_unkown_authority(&mut self, _precommit_idx: usize) -> Result<(), Error> {
-		Err(Error::UnknownAuthorityVote)
-	}
-
-	fn on_duplicate_authority_vote(&mut self, _precommit_idx: usize) -> Result<(), Error> {
-		Err(Error::DuplicateAuthorityVote)
-	}
-
-	fn on_invalid_authority_signature(&mut self, _precommit_idx: usize) -> Result<(), Error> {
-		Err(Error::InvalidAuthoritySignature)
-	}
-
-	fn on_redundant_authority_vote(&mut self, _precommit_idx: usize) -> Result<(), Error> {
-		Err(Error::RedundantVotesInJustification)
-	}
-
-	fn on_unrelated_ancestry_vote(&mut self, _precommit_idx: usize) -> Result<(), Error> {
-		Err(Error::UnrelatedAncestryVote)
-	}
-
-	fn on_redundant_votes_ancestries(
-		&mut self,
-		_redundant_votes_ancestries: BTreeSet<Header::Hash>,
-	) -> Result<(), Error> {
-		Err(Error::RedundantVotesAncestries)
-	}
-}
-
-/// Verification callbacks for justification optimization.
-struct OptimizationCallbacks<Header: HeaderT> {
-	extra_precommits: Vec<usize>,
-	redundant_votes_ancestries: BTreeSet<Header::Hash>,
-}
-
-impl<Header: HeaderT> OptimizationCallbacks<Header> {
-	fn optimize(self, justification: &mut GrandpaJustification<Header>) {
-		for invalid_precommit_idx in self.extra_precommits.into_iter().rev() {
-			justification.commit.precommits.remove(invalid_precommit_idx);
-		}
-		if !self.redundant_votes_ancestries.is_empty() {
-			justification
-				.votes_ancestries
-				.retain(|header| !self.redundant_votes_ancestries.contains(&header.hash()))
-		}
-	}
-}
-
-impl<Header: HeaderT> VerificationCallbacks<Header> for OptimizationCallbacks<Header> {
-	fn on_unkown_authority(&mut self, precommit_idx: usize) -> Result<(), Error> {
-		self.extra_precommits.push(precommit_idx);
-		Ok(())
-	}
-
-	fn on_duplicate_authority_vote(&mut self, precommit_idx: usize) -> Result<(), Error> {
-		self.extra_precommits.push(precommit_idx);
-		Ok(())
-	}
-
-	fn on_invalid_authority_signature(&mut self, precommit_idx: usize) -> Result<(), Error> {
-		self.extra_precommits.push(precommit_idx);
-		Ok(())
-	}
-
-	fn on_redundant_authority_vote(&mut self, precommit_idx: usize) -> Result<(), Error> {
-		self.extra_precommits.push(precommit_idx);
-		Ok(())
-	}
-
-	fn on_unrelated_ancestry_vote(&mut self, precommit_idx: usize) -> Result<(), Error> {
-		self.extra_precommits.push(precommit_idx);
-		Ok(())
-	}
-
-	fn on_redundant_votes_ancestries(
-		&mut self,
-		redundant_votes_ancestries: BTreeSet<Header::Hash>,
-	) -> Result<(), Error> {
-		self.redundant_votes_ancestries = redundant_votes_ancestries;
-		Ok(())
-	}
-}
-
-/// Verify that justification, that is generated by given authority set, finalizes given header.
-fn verify_justification_with_callbacks<Header: HeaderT, C: VerificationCallbacks<Header>>(
-	finalized_target: (Header::Hash, Header::Number),
-	authorities_set_id: SetId,
-	authorities_set: &VoterSet<AuthorityId>,
-	justification: &GrandpaJustification<Header>,
-	callbacks: &mut C,
-) -> Result<(), Error> {
-	// ensure that it is justification for the expected header
-	if (justification.commit.target_hash, justification.commit.target_number) != finalized_target {
-		return Err(Error::InvalidJustificationTarget)
-	}
-
-	let threshold = authorities_set.threshold().get();
-	let mut chain = AncestryChain::new(justification);
-	let mut signature_buffer = Vec::new();
-	let mut votes = BTreeSet::new();
-	let mut cumulative_weight = 0u64;
-
-	for (precommit_idx, signed) in justification.commit.precommits.iter().enumerate() {
-		// if we have collected enough precommits, we probabably want to fail/remove extra
-		// precommits
-		if cumulative_weight >= threshold {
-			callbacks.on_redundant_authority_vote(precommit_idx)?;
-			continue
-		}
-
-		// authority must be in the set
-		let authority_info = match authorities_set.get(&signed.id) {
-			Some(authority_info) => authority_info,
-			None => {
-				callbacks.on_unkown_authority(precommit_idx)?;
-				continue
-			},
-		};
-
-		// check if authority has already voted in the same round.
-		//
-		// there's a lot of code in `validate_commit` and `import_precommit` functions inside
-		// `finality-grandpa` crate (mostly related to reporting equivocations). But the only thing
-		// that we care about is that only first vote from the authority is accepted
-		if votes.contains(&signed.id) {
-			callbacks.on_duplicate_authority_vote(precommit_idx)?;
-			continue
-		}
-
-		// all precommits must be descendants of the target block
-		let route =
-			match chain.ancestry(&signed.precommit.target_hash, &signed.precommit.target_number) {
-				Some(route) => route,
-				None => {
-					callbacks.on_unrelated_ancestry_vote(precommit_idx)?;
-					continue
-				},
-			};
-
-		// verify authority signature
-		if !sp_consensus_grandpa::check_message_signature_with_buffer(
-			&finality_grandpa::Message::Precommit(signed.precommit.clone()),
-			&signed.id,
-			&signed.signature,
-			justification.round,
-			authorities_set_id,
-			&mut signature_buffer,
-		) {
-			callbacks.on_invalid_authority_signature(precommit_idx)?;
-			continue
-		}
-
-		// now we can count the vote since we know that it is valid
-		votes.insert(signed.id.clone());
-		chain.mark_route_as_visited(route);
-		cumulative_weight = cumulative_weight.saturating_add(authority_info.weight().get());
-	}
-
-	// check that the cumulative weight of validators that voted for the justification target (or
-	// one of its descendents) is larger than the required threshold.
-	if cumulative_weight < threshold {
-		return Err(Error::TooLowCumulativeWeight)
-	}
-
-	// check that there are no extra headers in the justification
-	if !chain.is_fully_visited() {
-		callbacks.on_redundant_votes_ancestries(chain.unvisited)?;
-	}
-
-	Ok(())
-}
-
-/// Votes ancestries with useful methods.
-#[derive(RuntimeDebug)]
-pub struct AncestryChain<Header: HeaderT> {
-	/// We expect all forks in the ancestry chain to be descendants of base.
-	base: HeaderId<Header::Hash, Header::Number>,
-	/// Header hash => parent header hash mapping.
-	pub parents: BTreeMap<Header::Hash, Header::Hash>,
-	/// Hashes of headers that were not visited by `ancestry()`.
-	pub unvisited: BTreeSet<Header::Hash>,
-}
-
-impl<Header: HeaderT> AncestryChain<Header> {
-	/// Create new ancestry chain.
-	pub fn new(justification: &GrandpaJustification<Header>) -> AncestryChain<Header> {
-		let mut parents = BTreeMap::new();
-		let mut unvisited = BTreeSet::new();
-		for ancestor in &justification.votes_ancestries {
-			let hash = ancestor.hash();
-			let parent_hash = *ancestor.parent_hash();
-			parents.insert(hash, parent_hash);
-			unvisited.insert(hash);
-		}
-		AncestryChain { base: justification.commit_target_id(), parents, unvisited }
-	}
-
-	/// Returns a route if the precommit target block is a descendant of the `base` block.
-	pub fn ancestry(
-		&self,
-		precommit_target_hash: &Header::Hash,
-		precommit_target_number: &Header::Number,
-	) -> Option<Vec<Header::Hash>> {
-		if precommit_target_number < &self.base.number() {
-			return None
-		}
-
-		let mut route = vec![];
-		let mut current_hash = *precommit_target_hash;
-		loop {
-			if current_hash == self.base.hash() {
-				break
-			}
-
-			current_hash = match self.parents.get(&current_hash) {
-				Some(parent_hash) => {
-					let is_visited_before = self.unvisited.get(&current_hash).is_none();
-					if is_visited_before {
-						// If the current header has been visited in a previous call, it is a
-						// descendent of `base` (we assume that the previous call was successful).
-						return Some(route)
-					}
-					route.push(current_hash);
-
-					*parent_hash
-				},
-				None => return None,
-			};
-		}
-
-		Some(route)
-	}
-
-	fn mark_route_as_visited(&mut self, route: Vec<Header::Hash>) {
-		for hash in route {
-			self.unvisited.remove(&hash);
-		}
-	}
-
-	fn is_fully_visited(&self) -> bool {
-		self.unvisited.is_empty()
-	}
-}
diff --git a/bridges/primitives/header-chain/src/justification/mod.rs b/bridges/primitives/header-chain/src/justification/mod.rs
new file mode 100644
index 00000000000..17be6cfd796
--- /dev/null
+++ b/bridges/primitives/header-chain/src/justification/mod.rs
@@ -0,0 +1,127 @@
+// Copyright 2019-2023 Parity Technologies (UK) Ltd.
+// This file is part of Parity Bridges Common.
+
+// Parity Bridges Common is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+
+// Parity Bridges Common is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+
+// You should have received a copy of the GNU General Public License
+// along with Parity Bridges Common.  If not, see <http://www.gnu.org/licenses/>.
+
+//! Logic for checking GRANDPA Finality Proofs.
+//!
+//! Adapted copy of substrate/client/finality-grandpa/src/justification.rs. If origin
+//! will ever be moved to the sp_consensus_grandpa, we should reuse that implementation.
+
+mod verification;
+
+use crate::ChainWithGrandpa;
+pub use verification::{
+	equivocation::{EquivocationsCollector, Error as EquivocationsCollectorError},
+	optimizer::verify_and_optimize_justification,
+	strict::verify_justification,
+	AncestryChain, Error as JustificationVerificationError, PrecommitError,
+};
+
+use bp_runtime::{BlockNumberOf, Chain, HashOf, HeaderId};
+use codec::{Decode, Encode, MaxEncodedLen};
+use frame_support::{RuntimeDebug, RuntimeDebugNoBound};
+use scale_info::TypeInfo;
+use sp_consensus_grandpa::{AuthorityId, AuthoritySignature};
+use sp_runtime::{traits::Header as HeaderT, SaturatedConversion};
+use sp_std::prelude::*;
+
+/// A GRANDPA Justification is a proof that a given header was finalized
+/// at a certain height and with a certain set of authorities.
+///
+/// This particular proof is used to prove that headers on a bridged chain
+/// (so not our chain) have been finalized correctly.
+#[derive(Encode, Decode, Clone, PartialEq, Eq, TypeInfo, RuntimeDebugNoBound)]
+pub struct GrandpaJustification<Header: HeaderT> {
+	/// The round (voting period) this justification is valid for.
+	pub round: u64,
+	/// The set of votes for the chain which is to be finalized.
+	pub commit:
+		finality_grandpa::Commit<Header::Hash, Header::Number, AuthoritySignature, AuthorityId>,
+	/// A proof that the chain of blocks in the commit are related to each other.
+	pub votes_ancestries: Vec<Header>,
+}
+
+impl<H: HeaderT> GrandpaJustification<H> {
+	/// Returns reasonable size of justification using constants from the provided chain.
+	///
+	/// An imprecise analogue of `MaxEncodedLen` implementation. We don't use it for
+	/// any precise calculations - that's just an estimation.
+	pub fn max_reasonable_size<C>(required_precommits: u32) -> u32
+	where
+		C: Chain + ChainWithGrandpa,
+	{
+		// we don't need precise results here - just estimations, so some details
+		// are removed from computations (e.g. bytes required to encode vector length)
+
+		// structures in `finality_grandpa` crate are not implementing `MaxEncodedLength`, so
+		// here's our estimation for the `finality_grandpa::Commit` struct size
+		//
+		// precommit is: hash + number
+		// signed precommit is: precommit + signature (64b) + authority id
+		// commit is: hash + number + vec of signed precommits
+		let signed_precommit_size: u32 = BlockNumberOf::<C>::max_encoded_len()
+			.saturating_add(HashOf::<C>::max_encoded_len().saturated_into())
+			.saturating_add(64)
+			.saturating_add(AuthorityId::max_encoded_len().saturated_into())
+			.saturated_into();
+		let max_expected_signed_commit_size = signed_precommit_size
+			.saturating_mul(required_precommits)
+			.saturating_add(BlockNumberOf::<C>::max_encoded_len().saturated_into())
+			.saturating_add(HashOf::<C>::max_encoded_len().saturated_into());
+
+		let max_expected_votes_ancestries_size = C::REASONABLE_HEADERS_IN_JUSTIFICATON_ANCESTRY
+			.saturating_mul(C::AVERAGE_HEADER_SIZE_IN_JUSTIFICATION);
+
+		// justification is round number (u64=8b), a signed GRANDPA commit and the
+		// `votes_ancestries` vector
+		8u32.saturating_add(max_expected_signed_commit_size)
+			.saturating_add(max_expected_votes_ancestries_size)
+	}
+
+	/// Return identifier of header that this justification claims to finalize.
+	pub fn commit_target_id(&self) -> HeaderId<H::Hash, H::Number> {
+		HeaderId(self.commit.target_number, self.commit.target_hash)
+	}
+}
+
+impl<H: HeaderT> crate::FinalityProof<H::Number> for GrandpaJustification<H> {
+	fn target_header_number(&self) -> H::Number {
+		self.commit.target_number
+	}
+}
+
+/// Justification verification error.
+#[derive(Eq, RuntimeDebug, PartialEq)]
+pub enum Error {
+	/// Failed to decode justification.
+	JustificationDecode,
+}
+
+/// Given GRANDPA authorities set size, return number of valid authorities votes that the
+/// justification must have to be valid.
+///
+/// This function assumes that all authorities have the same vote weight.
+pub fn required_justification_precommits(authorities_set_length: u32) -> u32 {
+	authorities_set_length - authorities_set_length.saturating_sub(1) / 3
+}
+
+/// Decode justification target.
+pub fn decode_justification_target<Header: HeaderT>(
+	raw_justification: &[u8],
+) -> Result<(Header::Hash, Header::Number), Error> {
+	GrandpaJustification::<Header>::decode(&mut &*raw_justification)
+		.map(|justification| (justification.commit.target_hash, justification.commit.target_number))
+		.map_err(|_| Error::JustificationDecode)
+}
diff --git a/bridges/primitives/header-chain/src/justification/verification/equivocation.rs b/bridges/primitives/header-chain/src/justification/verification/equivocation.rs
new file mode 100644
index 00000000000..0ade3736c22
--- /dev/null
+++ b/bridges/primitives/header-chain/src/justification/verification/equivocation.rs
@@ -0,0 +1,179 @@
+// Copyright 2019-2023 Parity Technologies (UK) Ltd.
+// This file is part of Parity Bridges Common.
+
+// Parity Bridges Common is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+
+// Parity Bridges Common is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+
+// You should have received a copy of the GNU General Public License
+// along with Parity Bridges Common.  If not, see <http://www.gnu.org/licenses/>.
+
+//! Logic for extracting equivocations from multiple GRANDPA Finality Proofs.
+
+use crate::justification::{
+	verification::{
+		Error as JustificationVerificationError, JustificationVerifier, PrecommitError,
+		SignedPrecommit,
+	},
+	GrandpaJustification,
+};
+
+use crate::justification::verification::IterationFlow;
+use finality_grandpa::voter_set::VoterSet;
+use frame_support::RuntimeDebug;
+use sp_consensus_grandpa::{AuthorityId, AuthoritySignature, EquivocationProof, Precommit, SetId};
+use sp_runtime::traits::Header as HeaderT;
+use sp_std::{
+	collections::{btree_map::BTreeMap, btree_set::BTreeSet},
+	prelude::*,
+};
+
+/// Justification verification error.
+#[derive(Eq, RuntimeDebug, PartialEq)]
+pub enum Error {
+	/// Justification is targeting unexpected round.
+	InvalidRound,
+	/// Justification verification error.
+	JustificationVerification(JustificationVerificationError),
+}
+
+enum AuthorityVotes<Header: HeaderT> {
+	SingleVote(SignedPrecommit<Header>),
+	Equivocation(
+		finality_grandpa::Equivocation<AuthorityId, Precommit<Header>, AuthoritySignature>,
+	),
+}
+
+/// Structure that can extract equivocations from multiple GRANDPA justifications.
+pub struct EquivocationsCollector<'a, Header: HeaderT> {
+	round: u64,
+	authorities_set_id: SetId,
+	authorities_set: &'a VoterSet<AuthorityId>,
+
+	votes: BTreeMap<AuthorityId, AuthorityVotes<Header>>,
+}
+
+impl<'a, Header: HeaderT> EquivocationsCollector<'a, Header> {
+	/// Create a new instance of `EquivocationsCollector`.
+	pub fn new(
+		authorities_set_id: SetId,
+		authorities_set: &'a VoterSet<AuthorityId>,
+		base_justification: &GrandpaJustification<Header>,
+	) -> Result<Self, Error> {
+		let mut checker = Self {
+			round: base_justification.round,
+			authorities_set_id,
+			authorities_set,
+			votes: BTreeMap::new(),
+		};
+
+		checker.parse_justification(base_justification)?;
+		Ok(checker)
+	}
+
+	/// Parse an additional justification for equivocations.
+	pub fn parse_justification(
+		&mut self,
+		justification: &GrandpaJustification<Header>,
+	) -> Result<(), Error> {
+		// The justification should target the same round as the base justification.
+		if self.round != justification.round {
+			return Err(Error::InvalidRound)
+		}
+
+		self.verify_justification(
+			(justification.commit.target_hash, justification.commit.target_number),
+			self.authorities_set_id,
+			self.authorities_set,
+			justification,
+		)
+		.map_err(Error::JustificationVerification)
+	}
+
+	/// Extract the equivocation proofs that have been collected.
+	pub fn into_equivocation_proofs(self) -> Vec<EquivocationProof<Header::Hash, Header::Number>> {
+		let mut equivocations = vec![];
+		for (_authority, vote) in self.votes {
+			if let AuthorityVotes::Equivocation(equivocation) = vote {
+				equivocations.push(EquivocationProof::new(
+					self.authorities_set_id,
+					sp_consensus_grandpa::Equivocation::Precommit(equivocation),
+				));
+			}
+		}
+
+		equivocations
+	}
+}
+
+impl<'a, Header: HeaderT> JustificationVerifier<Header> for EquivocationsCollector<'a, Header> {
+	fn process_redundant_vote(
+		&mut self,
+		_precommit_idx: usize,
+	) -> Result<IterationFlow, PrecommitError> {
+		Ok(IterationFlow::Run)
+	}
+
+	fn process_known_authority_vote(
+		&mut self,
+		_precommit_idx: usize,
+		_signed: &SignedPrecommit<Header>,
+	) -> Result<IterationFlow, PrecommitError> {
+		Ok(IterationFlow::Run)
+	}
+
+	fn process_unknown_authority_vote(
+		&mut self,
+		_precommit_idx: usize,
+	) -> Result<(), PrecommitError> {
+		Ok(())
+	}
+
+	fn process_unrelated_ancestry_vote(
+		&mut self,
+		_precommit_idx: usize,
+	) -> Result<IterationFlow, PrecommitError> {
+		Ok(IterationFlow::Run)
+	}
+
+	fn process_invalid_signature_vote(
+		&mut self,
+		_precommit_idx: usize,
+	) -> Result<(), PrecommitError> {
+		Ok(())
+	}
+
+	fn process_valid_vote(&mut self, signed: &SignedPrecommit<Header>) {
+		match self.votes.get_mut(&signed.id) {
+			Some(vote) => match vote {
+				AuthorityVotes::SingleVote(first_vote) => {
+					if first_vote.precommit != signed.precommit {
+						*vote = AuthorityVotes::Equivocation(finality_grandpa::Equivocation {
+							round_number: self.round,
+							identity: signed.id.clone(),
+							first: (first_vote.precommit.clone(), first_vote.signature.clone()),
+							second: (signed.precommit.clone(), signed.signature.clone()),
+						});
+					}
+				},
+				AuthorityVotes::Equivocation(_) => {},
+			},
+			None => {
+				self.votes.insert(signed.id.clone(), AuthorityVotes::SingleVote(signed.clone()));
+			},
+		}
+	}
+
+	fn process_redundant_votes_ancestries(
+		&mut self,
+		_redundant_votes_ancestries: BTreeSet<Header::Hash>,
+	) -> Result<(), JustificationVerificationError> {
+		Ok(())
+	}
+}
diff --git a/bridges/primitives/header-chain/src/justification/verification/mod.rs b/bridges/primitives/header-chain/src/justification/verification/mod.rs
new file mode 100644
index 00000000000..7cec1f14e96
--- /dev/null
+++ b/bridges/primitives/header-chain/src/justification/verification/mod.rs
@@ -0,0 +1,279 @@
+// Copyright 2019-2023 Parity Technologies (UK) Ltd.
+// This file is part of Parity Bridges Common.
+
+// Parity Bridges Common is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+
+// Parity Bridges Common is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+
+// You should have received a copy of the GNU General Public License
+// along with Parity Bridges Common.  If not, see <http://www.gnu.org/licenses/>.
+
+//! Logic for checking GRANDPA Finality Proofs.
+
+pub mod equivocation;
+pub mod optimizer;
+pub mod strict;
+
+use crate::justification::GrandpaJustification;
+
+use bp_runtime::HeaderId;
+use finality_grandpa::voter_set::VoterSet;
+use frame_support::RuntimeDebug;
+use sp_consensus_grandpa::{AuthorityId, AuthoritySignature, SetId};
+use sp_runtime::traits::Header as HeaderT;
+use sp_std::{
+	collections::{btree_map::BTreeMap, btree_set::BTreeSet},
+	prelude::*,
+};
+
+type SignedPrecommit<Header> = finality_grandpa::SignedPrecommit<
+	<Header as HeaderT>::Hash,
+	<Header as HeaderT>::Number,
+	AuthoritySignature,
+	AuthorityId,
+>;
+
+/// Votes ancestries with useful methods.
+#[derive(RuntimeDebug)]
+pub struct AncestryChain<Header: HeaderT> {
+	/// We expect all forks in the ancestry chain to be descendants of base.
+	base: HeaderId<Header::Hash, Header::Number>,
+	/// Header hash => parent header hash mapping.
+	pub parents: BTreeMap<Header::Hash, Header::Hash>,
+	/// Hashes of headers that were not visited by `ancestry()`.
+	pub unvisited: BTreeSet<Header::Hash>,
+}
+
+impl<Header: HeaderT> AncestryChain<Header> {
+	/// Create new ancestry chain.
+	pub fn new(justification: &GrandpaJustification<Header>) -> AncestryChain<Header> {
+		let mut parents = BTreeMap::new();
+		let mut unvisited = BTreeSet::new();
+		for ancestor in &justification.votes_ancestries {
+			let hash = ancestor.hash();
+			let parent_hash = *ancestor.parent_hash();
+			parents.insert(hash, parent_hash);
+			unvisited.insert(hash);
+		}
+		AncestryChain { base: justification.commit_target_id(), parents, unvisited }
+	}
+
+	/// Returns a route if the precommit target block is a descendant of the `base` block.
+	pub fn ancestry(
+		&self,
+		precommit_target_hash: &Header::Hash,
+		precommit_target_number: &Header::Number,
+	) -> Option<Vec<Header::Hash>> {
+		if precommit_target_number < &self.base.number() {
+			return None
+		}
+
+		let mut route = vec![];
+		let mut current_hash = *precommit_target_hash;
+		loop {
+			if current_hash == self.base.hash() {
+				break
+			}
+
+			current_hash = match self.parents.get(&current_hash) {
+				Some(parent_hash) => {
+					let is_visited_before = self.unvisited.get(&current_hash).is_none();
+					if is_visited_before {
+						// If the current header has been visited in a previous call, it is a
+						// descendent of `base` (we assume that the previous call was successful).
+						return Some(route)
+					}
+					route.push(current_hash);
+
+					*parent_hash
+				},
+				None => return None,
+			};
+		}
+
+		Some(route)
+	}
+
+	fn mark_route_as_visited(&mut self, route: Vec<Header::Hash>) {
+		for hash in route {
+			self.unvisited.remove(&hash);
+		}
+	}
+
+	fn is_fully_visited(&self) -> bool {
+		self.unvisited.is_empty()
+	}
+}
+
+/// Justification verification error.
+#[derive(Eq, RuntimeDebug, PartialEq)]
+pub enum Error {
+	/// Justification is finalizing unexpected header.
+	InvalidJustificationTarget,
+	/// Error validating a precommit
+	Precommit(PrecommitError),
+	/// The cumulative weight of all votes in the justification is not enough to justify commit
+	/// header finalization.
+	TooLowCumulativeWeight,
+	/// The justification contains extra (unused) headers in its `votes_ancestries` field.
+	RedundantVotesAncestries,
+}
+
+/// Justification verification error.
+#[derive(Eq, RuntimeDebug, PartialEq)]
+pub enum PrecommitError {
+	/// Justification contains redundant votes.
+	RedundantAuthorityVote,
+	/// Justification contains unknown authority precommit.
+	UnknownAuthorityVote,
+	/// Justification contains duplicate authority precommit.
+	DuplicateAuthorityVote,
+	/// The authority has provided an invalid signature.
+	InvalidAuthoritySignature,
+	/// The justification contains precommit for header that is not a descendant of the commit
+	/// header.
+	UnrelatedAncestryVote,
+}
+
+enum IterationFlow {
+	Run,
+	Skip,
+}
+
+/// Verification callbacks.
+trait JustificationVerifier<Header: HeaderT> {
+	fn process_redundant_vote(
+		&mut self,
+		precommit_idx: usize,
+	) -> Result<IterationFlow, PrecommitError>;
+
+	fn process_known_authority_vote(
+		&mut self,
+		precommit_idx: usize,
+		signed: &SignedPrecommit<Header>,
+	) -> Result<IterationFlow, PrecommitError>;
+
+	fn process_unknown_authority_vote(
+		&mut self,
+		precommit_idx: usize,
+	) -> Result<(), PrecommitError>;
+
+	fn process_unrelated_ancestry_vote(
+		&mut self,
+		precommit_idx: usize,
+	) -> Result<IterationFlow, PrecommitError>;
+
+	fn process_invalid_signature_vote(
+		&mut self,
+		precommit_idx: usize,
+	) -> Result<(), PrecommitError>;
+
+	fn process_valid_vote(&mut self, signed: &SignedPrecommit<Header>);
+
+	/// Called when there are redundant headers in the votes ancestries.
+	fn process_redundant_votes_ancestries(
+		&mut self,
+		redundant_votes_ancestries: BTreeSet<Header::Hash>,
+	) -> Result<(), Error>;
+
+	fn verify_justification(
+		&mut self,
+		finalized_target: (Header::Hash, Header::Number),
+		authorities_set_id: SetId,
+		authorities_set: &VoterSet<AuthorityId>,
+		justification: &GrandpaJustification<Header>,
+	) -> Result<(), Error> {
+		// ensure that it is justification for the expected header
+		if (justification.commit.target_hash, justification.commit.target_number) !=
+			finalized_target
+		{
+			return Err(Error::InvalidJustificationTarget)
+		}
+
+		let threshold = authorities_set.threshold().get();
+		let mut chain = AncestryChain::new(justification);
+		let mut signature_buffer = Vec::new();
+		let mut cumulative_weight = 0u64;
+
+		for (precommit_idx, signed) in justification.commit.precommits.iter().enumerate() {
+			if cumulative_weight >= threshold {
+				let action =
+					self.process_redundant_vote(precommit_idx).map_err(Error::Precommit)?;
+				if matches!(action, IterationFlow::Skip) {
+					continue
+				}
+			}
+
+			// authority must be in the set
+			let authority_info = match authorities_set.get(&signed.id) {
+				Some(authority_info) => {
+					// The implementer may want to do extra checks here.
+					// For example to see if the authority has already voted in the same round.
+					let action = self
+						.process_known_authority_vote(precommit_idx, signed)
+						.map_err(Error::Precommit)?;
+					if matches!(action, IterationFlow::Skip) {
+						continue
+					}
+
+					authority_info
+				},
+				None => {
+					self.process_unknown_authority_vote(precommit_idx).map_err(Error::Precommit)?;
+					continue
+				},
+			};
+
+			// all precommits must be descendants of the target block
+			let maybe_route =
+				chain.ancestry(&signed.precommit.target_hash, &signed.precommit.target_number);
+			if maybe_route.is_none() {
+				let action = self
+					.process_unrelated_ancestry_vote(precommit_idx)
+					.map_err(Error::Precommit)?;
+				if matches!(action, IterationFlow::Skip) {
+					continue
+				}
+			}
+
+			// verify authority signature
+			if !sp_consensus_grandpa::check_message_signature_with_buffer(
+				&finality_grandpa::Message::Precommit(signed.precommit.clone()),
+				&signed.id,
+				&signed.signature,
+				justification.round,
+				authorities_set_id,
+				&mut signature_buffer,
+			) {
+				self.process_invalid_signature_vote(precommit_idx).map_err(Error::Precommit)?;
+				continue
+			}
+
+			// now we can count the vote since we know that it is valid
+			self.process_valid_vote(signed);
+			if let Some(route) = maybe_route {
+				chain.mark_route_as_visited(route);
+				cumulative_weight = cumulative_weight.saturating_add(authority_info.weight().get());
+			}
+		}
+
+		// check that the cumulative weight of validators that voted for the justification target
+		// (or one of its descendents) is larger than the required threshold.
+		if cumulative_weight < threshold {
+			return Err(Error::TooLowCumulativeWeight)
+		}
+
+		// check that there are no extra headers in the justification
+		if !chain.is_fully_visited() {
+			self.process_redundant_votes_ancestries(chain.unvisited)?;
+		}
+
+		Ok(())
+	}
+}
diff --git a/bridges/primitives/header-chain/src/justification/verification/optimizer.rs b/bridges/primitives/header-chain/src/justification/verification/optimizer.rs
new file mode 100644
index 00000000000..4cc6778ff51
--- /dev/null
+++ b/bridges/primitives/header-chain/src/justification/verification/optimizer.rs
@@ -0,0 +1,132 @@
+// Copyright 2019-2023 Parity Technologies (UK) Ltd.
+// This file is part of Parity Bridges Common.
+
+// Parity Bridges Common is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+
+// Parity Bridges Common is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+
+// You should have received a copy of the GNU General Public License
+// along with Parity Bridges Common.  If not, see <http://www.gnu.org/licenses/>.
+
+//! Logic for optimizing GRANDPA Finality Proofs.
+
+use crate::justification::{
+	verification::{Error, JustificationVerifier, PrecommitError},
+	GrandpaJustification,
+};
+
+use crate::justification::verification::{IterationFlow, SignedPrecommit};
+use finality_grandpa::voter_set::VoterSet;
+use sp_consensus_grandpa::{AuthorityId, SetId};
+use sp_runtime::traits::Header as HeaderT;
+use sp_std::{collections::btree_set::BTreeSet, prelude::*};
+
+// Verification callbacks for justification optimization.
+struct JustificationOptimizer<Header: HeaderT> {
+	votes: BTreeSet<AuthorityId>,
+
+	extra_precommits: Vec<usize>,
+	redundant_votes_ancestries: BTreeSet<Header::Hash>,
+}
+
+impl<Header: HeaderT> JustificationOptimizer<Header> {
+	fn optimize(self, justification: &mut GrandpaJustification<Header>) {
+		for invalid_precommit_idx in self.extra_precommits.into_iter().rev() {
+			justification.commit.precommits.remove(invalid_precommit_idx);
+		}
+		if !self.redundant_votes_ancestries.is_empty() {
+			justification
+				.votes_ancestries
+				.retain(|header| !self.redundant_votes_ancestries.contains(&header.hash()))
+		}
+	}
+}
+
+impl<Header: HeaderT> JustificationVerifier<Header> for JustificationOptimizer<Header> {
+	fn process_redundant_vote(
+		&mut self,
+		precommit_idx: usize,
+	) -> Result<IterationFlow, PrecommitError> {
+		self.extra_precommits.push(precommit_idx);
+		Ok(IterationFlow::Skip)
+	}
+
+	fn process_known_authority_vote(
+		&mut self,
+		precommit_idx: usize,
+		signed: &SignedPrecommit<Header>,
+	) -> Result<IterationFlow, PrecommitError> {
+		// Skip duplicate votes
+		if self.votes.contains(&signed.id) {
+			self.extra_precommits.push(precommit_idx);
+			return Ok(IterationFlow::Skip)
+		}
+
+		Ok(IterationFlow::Run)
+	}
+
+	fn process_unknown_authority_vote(
+		&mut self,
+		precommit_idx: usize,
+	) -> Result<(), PrecommitError> {
+		self.extra_precommits.push(precommit_idx);
+		Ok(())
+	}
+
+	fn process_unrelated_ancestry_vote(
+		&mut self,
+		precommit_idx: usize,
+	) -> Result<IterationFlow, PrecommitError> {
+		self.extra_precommits.push(precommit_idx);
+		Ok(IterationFlow::Skip)
+	}
+
+	fn process_invalid_signature_vote(
+		&mut self,
+		precommit_idx: usize,
+	) -> Result<(), PrecommitError> {
+		self.extra_precommits.push(precommit_idx);
+		Ok(())
+	}
+
+	fn process_valid_vote(&mut self, signed: &SignedPrecommit<Header>) {
+		self.votes.insert(signed.id.clone());
+	}
+
+	fn process_redundant_votes_ancestries(
+		&mut self,
+		redundant_votes_ancestries: BTreeSet<Header::Hash>,
+	) -> Result<(), Error> {
+		self.redundant_votes_ancestries = redundant_votes_ancestries;
+		Ok(())
+	}
+}
+
+/// Verify and optimize given justification by removing unknown and duplicate votes.
+pub fn verify_and_optimize_justification<Header: HeaderT>(
+	finalized_target: (Header::Hash, Header::Number),
+	authorities_set_id: SetId,
+	authorities_set: &VoterSet<AuthorityId>,
+	justification: &mut GrandpaJustification<Header>,
+) -> Result<(), Error> {
+	let mut optimizer = JustificationOptimizer {
+		votes: BTreeSet::new(),
+		extra_precommits: vec![],
+		redundant_votes_ancestries: Default::default(),
+	};
+	optimizer.verify_justification(
+		finalized_target,
+		authorities_set_id,
+		authorities_set,
+		justification,
+	)?;
+	optimizer.optimize(justification);
+
+	Ok(())
+}
diff --git a/bridges/primitives/header-chain/src/justification/verification/strict.rs b/bridges/primitives/header-chain/src/justification/verification/strict.rs
new file mode 100644
index 00000000000..da936c23582
--- /dev/null
+++ b/bridges/primitives/header-chain/src/justification/verification/strict.rs
@@ -0,0 +1,106 @@
+// Copyright 2019-2023 Parity Technologies (UK) Ltd.
+// This file is part of Parity Bridges Common.
+
+// Parity Bridges Common is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+
+// Parity Bridges Common is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+
+// You should have received a copy of the GNU General Public License
+// along with Parity Bridges Common.  If not, see <http://www.gnu.org/licenses/>.
+
+//! Logic for checking if GRANDPA Finality Proofs are valid and optimal.
+
+use crate::justification::{
+	verification::{Error, JustificationVerifier, PrecommitError},
+	GrandpaJustification,
+};
+
+use crate::justification::verification::{IterationFlow, SignedPrecommit};
+use finality_grandpa::voter_set::VoterSet;
+use sp_consensus_grandpa::{AuthorityId, SetId};
+use sp_runtime::traits::Header as HeaderT;
+use sp_std::collections::btree_set::BTreeSet;
+
+/// Verification callbacks that reject all unknown, duplicate or redundant votes.
+struct StrictJustificationVerifier {
+	votes: BTreeSet<AuthorityId>,
+}
+
+impl<Header: HeaderT> JustificationVerifier<Header> for StrictJustificationVerifier {
+	fn process_redundant_vote(
+		&mut self,
+		_precommit_idx: usize,
+	) -> Result<IterationFlow, PrecommitError> {
+		Err(PrecommitError::RedundantAuthorityVote)
+	}
+
+	fn process_known_authority_vote(
+		&mut self,
+		_precommit_idx: usize,
+		signed: &SignedPrecommit<Header>,
+	) -> Result<IterationFlow, PrecommitError> {
+		if self.votes.contains(&signed.id) {
+			// There's a lot of code in `validate_commit` and `import_precommit` functions
+			// inside `finality-grandpa` crate (mostly related to reporting equivocations).
+			// But the only thing that we care about is that only first vote from the
+			// authority is accepted
+			return Err(PrecommitError::DuplicateAuthorityVote)
+		}
+
+		Ok(IterationFlow::Run)
+	}
+
+	fn process_unknown_authority_vote(
+		&mut self,
+		_precommit_idx: usize,
+	) -> Result<(), PrecommitError> {
+		Err(PrecommitError::UnknownAuthorityVote)
+	}
+
+	fn process_unrelated_ancestry_vote(
+		&mut self,
+		_precommit_idx: usize,
+	) -> Result<IterationFlow, PrecommitError> {
+		Err(PrecommitError::UnrelatedAncestryVote)
+	}
+
+	fn process_invalid_signature_vote(
+		&mut self,
+		_precommit_idx: usize,
+	) -> Result<(), PrecommitError> {
+		Err(PrecommitError::InvalidAuthoritySignature)
+	}
+
+	fn process_valid_vote(&mut self, signed: &SignedPrecommit<Header>) {
+		self.votes.insert(signed.id.clone());
+	}
+
+	fn process_redundant_votes_ancestries(
+		&mut self,
+		_redundant_votes_ancestries: BTreeSet<Header::Hash>,
+	) -> Result<(), Error> {
+		Err(Error::RedundantVotesAncestries)
+	}
+}
+
+/// Verify that justification, that is generated by given authority set, finalizes given header.
+pub fn verify_justification<Header: HeaderT>(
+	finalized_target: (Header::Hash, Header::Number),
+	authorities_set_id: SetId,
+	authorities_set: &VoterSet<AuthorityId>,
+	justification: &GrandpaJustification<Header>,
+) -> Result<(), Error> {
+	let mut verifier = StrictJustificationVerifier { votes: BTreeSet::new() };
+	verifier.verify_justification(
+		finalized_target,
+		authorities_set_id,
+		authorities_set,
+		justification,
+	)
+}
diff --git a/bridges/primitives/header-chain/tests/implementation_match.rs b/bridges/primitives/header-chain/tests/implementation_match.rs
index d5e42e21497..c4cd7f5f5b2 100644
--- a/bridges/primitives/header-chain/tests/implementation_match.rs
+++ b/bridges/primitives/header-chain/tests/implementation_match.rs
@@ -21,7 +21,9 @@
 //! Some of tests in this module may partially duplicate tests from `justification.rs`,
 //! but their purpose is different.
 
-use bp_header_chain::justification::{verify_justification, Error, GrandpaJustification};
+use bp_header_chain::justification::{
+	verify_justification, GrandpaJustification, JustificationVerificationError, PrecommitError,
+};
 use bp_test_utils::{
 	header_id, make_justification_for_header, signed_precommit, test_header, Account,
 	JustificationGeneratorParams, ALICE, BOB, CHARLIE, DAVE, EVE, FERDIE, TEST_GRANDPA_SET_ID,
@@ -85,13 +87,6 @@ fn minimal_accounts_set() -> Vec<(Account, AuthorityWeight)> {
 	vec![(ALICE, 1), (BOB, 1), (CHARLIE, 1), (DAVE, 1)]
 }
 
-/// Get a minimal subset of GRANDPA authorities that have enough cumulative vote weight to justify a
-/// header finality.
-pub fn minimal_voter_set() -> VoterSet<AuthorityId> {
-	VoterSet::new(minimal_accounts_set().iter().map(|(id, w)| (AuthorityId::from(*id), *w)))
-		.unwrap()
-}
-
 /// Make a valid GRANDPA justification with sensible defaults.
 pub fn make_default_justification(header: &TestHeader) -> GrandpaJustification<TestHeader> {
 	make_justification_for_header(JustificationGeneratorParams {
@@ -124,7 +119,7 @@ fn same_result_when_precommit_target_has_lower_number_than_commit_target() {
 			&full_voter_set(),
 			&justification,
 		),
-		Err(Error::UnrelatedAncestryVote),
+		Err(JustificationVerificationError::Precommit(PrecommitError::UnrelatedAncestryVote)),
 	);
 
 	// original implementation returns `Ok(validation_result)`
@@ -157,7 +152,7 @@ fn same_result_when_precommit_target_is_not_descendant_of_commit_target() {
 			&full_voter_set(),
 			&justification,
 		),
-		Err(Error::UnrelatedAncestryVote),
+		Err(JustificationVerificationError::Precommit(PrecommitError::UnrelatedAncestryVote)),
 	);
 
 	// original implementation returns `Ok(validation_result)`
@@ -191,7 +186,7 @@ fn same_result_when_there_are_not_enough_cumulative_weight_to_finalize_commit_ta
 			&full_voter_set(),
 			&justification,
 		),
-		Err(Error::TooLowCumulativeWeight),
+		Err(JustificationVerificationError::TooLowCumulativeWeight),
 	);
 	// original implementation returns `Ok(validation_result)`
 	// with `validation_result.is_valid() == false`.
@@ -229,7 +224,7 @@ fn different_result_when_justification_contains_duplicate_vote() {
 			&full_voter_set(),
 			&justification,
 		),
-		Err(Error::DuplicateAuthorityVote),
+		Err(JustificationVerificationError::Precommit(PrecommitError::DuplicateAuthorityVote)),
 	);
 	// original implementation returns `Ok(validation_result)`
 	// with `validation_result.is_valid() == true`.
@@ -270,7 +265,7 @@ fn different_results_when_authority_equivocates_once_in_a_round() {
 			&full_voter_set(),
 			&justification,
 		),
-		Err(Error::DuplicateAuthorityVote),
+		Err(JustificationVerificationError::Precommit(PrecommitError::DuplicateAuthorityVote)),
 	);
 	// original implementation returns `Ok(validation_result)`
 	// with `validation_result.is_valid() == true`.
@@ -323,7 +318,7 @@ fn different_results_when_authority_equivocates_twice_in_a_round() {
 			&full_voter_set(),
 			&justification,
 		),
-		Err(Error::DuplicateAuthorityVote),
+		Err(JustificationVerificationError::Precommit(PrecommitError::DuplicateAuthorityVote)),
 	);
 	// original implementation returns `Ok(validation_result)`
 	// with `validation_result.is_valid() == true`.
@@ -362,7 +357,7 @@ fn different_results_when_there_are_more_than_enough_votes() {
 			&full_voter_set(),
 			&justification,
 		),
-		Err(Error::RedundantVotesInJustification),
+		Err(JustificationVerificationError::Precommit(PrecommitError::RedundantAuthorityVote)),
 	);
 	// original implementation returns `Ok(validation_result)`
 	// with `validation_result.is_valid() == true`.
@@ -403,7 +398,7 @@ fn different_results_when_there_is_a_vote_of_unknown_authority() {
 			&full_voter_set(),
 			&justification,
 		),
-		Err(Error::UnknownAuthorityVote),
+		Err(JustificationVerificationError::Precommit(PrecommitError::UnknownAuthorityVote)),
 	);
 	// original implementation returns `Ok(validation_result)`
 	// with `validation_result.is_valid() == true`.
diff --git a/bridges/primitives/header-chain/tests/justification/equivocation.rs b/bridges/primitives/header-chain/tests/justification/equivocation.rs
new file mode 100644
index 00000000000..072d5668ede
--- /dev/null
+++ b/bridges/primitives/header-chain/tests/justification/equivocation.rs
@@ -0,0 +1,124 @@
+// Copyright 2020-2021 Parity Technologies (UK) Ltd.
+// This file is part of Parity Bridges Common.
+
+// Parity Bridges Common is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+
+// Parity Bridges Common is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+
+// You should have received a copy of the GNU General Public License
+// along with Parity Bridges Common.  If not, see <http://www.gnu.org/licenses/>.
+
+//! Tests for Grandpa equivocations collector code.
+
+use bp_header_chain::justification::EquivocationsCollector;
+use bp_test_utils::*;
+use finality_grandpa::Precommit;
+use sp_consensus_grandpa::EquivocationProof;
+
+type TestHeader = sp_runtime::testing::Header;
+
+#[test]
+fn duplicate_votes_are_not_considered_equivocations() {
+	let voter_set = voter_set();
+	let base_justification = make_default_justification::<TestHeader>(&test_header(1));
+
+	let mut collector =
+		EquivocationsCollector::new(TEST_GRANDPA_SET_ID, &voter_set, &base_justification).unwrap();
+	collector.parse_justification(&base_justification.clone()).unwrap();
+
+	assert_eq!(collector.into_equivocation_proofs().len(), 0);
+}
+
+#[test]
+fn equivocations_are_detected_in_base_justification_redundant_votes() {
+	let voter_set = voter_set();
+	let mut base_justification = make_default_justification::<TestHeader>(&test_header(1));
+
+	let first_vote = base_justification.commit.precommits[0].clone();
+	let equivocation = signed_precommit::<TestHeader>(
+		&ALICE,
+		header_id::<TestHeader>(1),
+		base_justification.round,
+		TEST_GRANDPA_SET_ID,
+	);
+	base_justification.commit.precommits.push(equivocation.clone());
+
+	let collector =
+		EquivocationsCollector::new(TEST_GRANDPA_SET_ID, &voter_set, &base_justification).unwrap();
+
+	assert_eq!(
+		collector.into_equivocation_proofs(),
+		vec![EquivocationProof::new(
+			1,
+			sp_consensus_grandpa::Equivocation::Precommit(finality_grandpa::Equivocation {
+				round_number: 1,
+				identity: ALICE.into(),
+				first: (
+					Precommit {
+						target_hash: first_vote.precommit.target_hash,
+						target_number: first_vote.precommit.target_number
+					},
+					first_vote.signature
+				),
+				second: (
+					Precommit {
+						target_hash: equivocation.precommit.target_hash,
+						target_number: equivocation.precommit.target_number
+					},
+					equivocation.signature
+				)
+			})
+		)]
+	);
+}
+
+#[test]
+fn equivocations_are_detected_in_extra_justification_redundant_votes() {
+	let voter_set = voter_set();
+	let base_justification = make_default_justification::<TestHeader>(&test_header(1));
+	let first_vote = base_justification.commit.precommits[0].clone();
+
+	let mut extra_justification = base_justification.clone();
+	let equivocation = signed_precommit::<TestHeader>(
+		&ALICE,
+		header_id::<TestHeader>(1),
+		base_justification.round,
+		TEST_GRANDPA_SET_ID,
+	);
+	extra_justification.commit.precommits.push(equivocation.clone());
+
+	let mut collector =
+		EquivocationsCollector::new(TEST_GRANDPA_SET_ID, &voter_set, &base_justification).unwrap();
+	collector.parse_justification(&extra_justification).unwrap();
+
+	assert_eq!(
+		collector.into_equivocation_proofs(),
+		vec![EquivocationProof::new(
+			1,
+			sp_consensus_grandpa::Equivocation::Precommit(finality_grandpa::Equivocation {
+				round_number: 1,
+				identity: ALICE.into(),
+				first: (
+					Precommit {
+						target_hash: first_vote.precommit.target_hash,
+						target_number: first_vote.precommit.target_number
+					},
+					first_vote.signature
+				),
+				second: (
+					Precommit {
+						target_hash: equivocation.precommit.target_hash,
+						target_number: equivocation.precommit.target_number
+					},
+					equivocation.signature
+				)
+			})
+		)]
+	);
+}
diff --git a/bridges/primitives/header-chain/tests/justification.rs b/bridges/primitives/header-chain/tests/justification/optimizer.rs
similarity index 55%
rename from bridges/primitives/header-chain/tests/justification.rs
rename to bridges/primitives/header-chain/tests/justification/optimizer.rs
index 26ed67fa65f..8d1ba5ac6fa 100644
--- a/bridges/primitives/header-chain/tests/justification.rs
+++ b/bridges/primitives/header-chain/tests/justification/optimizer.rs
@@ -14,189 +14,15 @@
 // You should have received a copy of the GNU General Public License
 // along with Parity Bridges Common.  If not, see <http://www.gnu.org/licenses/>.
 
-//! Tests for Grandpa Justification code.
+//! Tests for Grandpa Justification optimizer code.
 
-use bp_header_chain::justification::{
-	required_justification_precommits, verify_and_optimize_justification, verify_justification,
-	Error,
-};
+use bp_header_chain::justification::verify_and_optimize_justification;
 use bp_test_utils::*;
 use finality_grandpa::SignedPrecommit;
 use sp_consensus_grandpa::AuthoritySignature;
 
 type TestHeader = sp_runtime::testing::Header;
 
-#[test]
-fn valid_justification_accepted() {
-	let authorities = vec![(ALICE, 1), (BOB, 1), (CHARLIE, 1)];
-	let params = JustificationGeneratorParams {
-		header: test_header(1),
-		round: TEST_GRANDPA_ROUND,
-		set_id: TEST_GRANDPA_SET_ID,
-		authorities: authorities.clone(),
-		ancestors: 7,
-		forks: 3,
-	};
-
-	let justification = make_justification_for_header::<TestHeader>(params.clone());
-	assert_eq!(
-		verify_justification::<TestHeader>(
-			header_id::<TestHeader>(1),
-			TEST_GRANDPA_SET_ID,
-			&voter_set(),
-			&justification,
-		),
-		Ok(()),
-	);
-
-	assert_eq!(justification.commit.precommits.len(), authorities.len());
-	assert_eq!(justification.votes_ancestries.len(), params.ancestors as usize);
-}
-
-#[test]
-fn valid_justification_accepted_with_single_fork() {
-	let params = JustificationGeneratorParams {
-		header: test_header(1),
-		round: TEST_GRANDPA_ROUND,
-		set_id: TEST_GRANDPA_SET_ID,
-		authorities: vec![(ALICE, 1), (BOB, 1), (CHARLIE, 1)],
-		ancestors: 5,
-		forks: 1,
-	};
-
-	assert_eq!(
-		verify_justification::<TestHeader>(
-			header_id::<TestHeader>(1),
-			TEST_GRANDPA_SET_ID,
-			&voter_set(),
-			&make_justification_for_header::<TestHeader>(params)
-		),
-		Ok(()),
-	);
-}
-
-#[test]
-fn valid_justification_accepted_with_arbitrary_number_of_authorities() {
-	use finality_grandpa::voter_set::VoterSet;
-	use sp_consensus_grandpa::AuthorityId;
-
-	let n = 15;
-	let required_signatures = required_justification_precommits(n as _);
-	let authorities = accounts(n).iter().map(|k| (*k, 1)).collect::<Vec<_>>();
-
-	let params = JustificationGeneratorParams {
-		header: test_header(1),
-		round: TEST_GRANDPA_ROUND,
-		set_id: TEST_GRANDPA_SET_ID,
-		authorities: authorities.clone().into_iter().take(required_signatures as _).collect(),
-		ancestors: n.into(),
-		forks: required_signatures,
-	};
-
-	let authorities = authorities
-		.iter()
-		.map(|(id, w)| (AuthorityId::from(*id), *w))
-		.collect::<Vec<(AuthorityId, _)>>();
-	let voter_set = VoterSet::new(authorities).unwrap();
-
-	assert_eq!(
-		verify_justification::<TestHeader>(
-			header_id::<TestHeader>(1),
-			TEST_GRANDPA_SET_ID,
-			&voter_set,
-			&make_justification_for_header::<TestHeader>(params)
-		),
-		Ok(()),
-	);
-}
-
-#[test]
-fn justification_with_invalid_target_rejected() {
-	assert_eq!(
-		verify_justification::<TestHeader>(
-			header_id::<TestHeader>(2),
-			TEST_GRANDPA_SET_ID,
-			&voter_set(),
-			&make_default_justification::<TestHeader>(&test_header(1)),
-		),
-		Err(Error::InvalidJustificationTarget),
-	);
-}
-
-#[test]
-fn justification_with_invalid_commit_rejected() {
-	let mut justification = make_default_justification::<TestHeader>(&test_header(1));
-	justification.commit.precommits.clear();
-
-	assert_eq!(
-		verify_justification::<TestHeader>(
-			header_id::<TestHeader>(1),
-			TEST_GRANDPA_SET_ID,
-			&voter_set(),
-			&justification,
-		),
-		Err(Error::TooLowCumulativeWeight),
-	);
-}
-
-#[test]
-fn justification_with_invalid_authority_signature_rejected() {
-	let mut justification = make_default_justification::<TestHeader>(&test_header(1));
-	justification.commit.precommits[0].signature =
-		sp_core::crypto::UncheckedFrom::unchecked_from([1u8; 64]);
-
-	assert_eq!(
-		verify_justification::<TestHeader>(
-			header_id::<TestHeader>(1),
-			TEST_GRANDPA_SET_ID,
-			&voter_set(),
-			&justification,
-		),
-		Err(Error::InvalidAuthoritySignature),
-	);
-}
-
-#[test]
-fn justification_with_invalid_precommit_ancestry() {
-	let mut justification = make_default_justification::<TestHeader>(&test_header(1));
-	justification.votes_ancestries.push(test_header(10));
-
-	assert_eq!(
-		verify_justification::<TestHeader>(
-			header_id::<TestHeader>(1),
-			TEST_GRANDPA_SET_ID,
-			&voter_set(),
-			&justification,
-		),
-		Err(Error::RedundantVotesAncestries),
-	);
-}
-
-#[test]
-fn justification_is_invalid_if_we_dont_meet_threshold() {
-	// Need at least three authorities to sign off or else the voter set threshold can't be reached
-	let authorities = vec![(ALICE, 1), (BOB, 1)];
-
-	let params = JustificationGeneratorParams {
-		header: test_header(1),
-		round: TEST_GRANDPA_ROUND,
-		set_id: TEST_GRANDPA_SET_ID,
-		authorities: authorities.clone(),
-		ancestors: 2 * authorities.len() as u32,
-		forks: 2,
-	};
-
-	assert_eq!(
-		verify_justification::<TestHeader>(
-			header_id::<TestHeader>(1),
-			TEST_GRANDPA_SET_ID,
-			&voter_set(),
-			&make_justification_for_header::<TestHeader>(params)
-		),
-		Err(Error::TooLowCumulativeWeight),
-	);
-}
-
 #[test]
 fn optimizer_does_noting_with_minimal_justification() {
 	let mut justification = make_default_justification::<TestHeader>(&test_header(1));
diff --git a/bridges/primitives/header-chain/tests/justification/strict.rs b/bridges/primitives/header-chain/tests/justification/strict.rs
new file mode 100644
index 00000000000..bf8fa5c9f45
--- /dev/null
+++ b/bridges/primitives/header-chain/tests/justification/strict.rs
@@ -0,0 +1,196 @@
+// Copyright 2020-2021 Parity Technologies (UK) Ltd.
+// This file is part of Parity Bridges Common.
+
+// Parity Bridges Common is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+
+// Parity Bridges Common is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+
+// You should have received a copy of the GNU General Public License
+// along with Parity Bridges Common.  If not, see <http://www.gnu.org/licenses/>.
+
+//! Tests for Grandpa strict justification verifier code.
+
+use bp_header_chain::justification::{
+	required_justification_precommits, verify_justification, JustificationVerificationError,
+	PrecommitError,
+};
+use bp_test_utils::*;
+
+type TestHeader = sp_runtime::testing::Header;
+
+#[test]
+fn valid_justification_accepted() {
+	let authorities = vec![(ALICE, 1), (BOB, 1), (CHARLIE, 1)];
+	let params = JustificationGeneratorParams {
+		header: test_header(1),
+		round: TEST_GRANDPA_ROUND,
+		set_id: TEST_GRANDPA_SET_ID,
+		authorities: authorities.clone(),
+		ancestors: 7,
+		forks: 3,
+	};
+
+	let justification = make_justification_for_header::<TestHeader>(params.clone());
+	assert_eq!(
+		verify_justification::<TestHeader>(
+			header_id::<TestHeader>(1),
+			TEST_GRANDPA_SET_ID,
+			&voter_set(),
+			&justification,
+		),
+		Ok(()),
+	);
+
+	assert_eq!(justification.commit.precommits.len(), authorities.len());
+	assert_eq!(justification.votes_ancestries.len(), params.ancestors as usize);
+}
+
+#[test]
+fn valid_justification_accepted_with_single_fork() {
+	let params = JustificationGeneratorParams {
+		header: test_header(1),
+		round: TEST_GRANDPA_ROUND,
+		set_id: TEST_GRANDPA_SET_ID,
+		authorities: vec![(ALICE, 1), (BOB, 1), (CHARLIE, 1)],
+		ancestors: 5,
+		forks: 1,
+	};
+
+	assert_eq!(
+		verify_justification::<TestHeader>(
+			header_id::<TestHeader>(1),
+			TEST_GRANDPA_SET_ID,
+			&voter_set(),
+			&make_justification_for_header::<TestHeader>(params)
+		),
+		Ok(()),
+	);
+}
+
+#[test]
+fn valid_justification_accepted_with_arbitrary_number_of_authorities() {
+	use finality_grandpa::voter_set::VoterSet;
+	use sp_consensus_grandpa::AuthorityId;
+
+	let n = 15;
+	let required_signatures = required_justification_precommits(n as _);
+	let authorities = accounts(n).iter().map(|k| (*k, 1)).collect::<Vec<_>>();
+
+	let params = JustificationGeneratorParams {
+		header: test_header(1),
+		round: TEST_GRANDPA_ROUND,
+		set_id: TEST_GRANDPA_SET_ID,
+		authorities: authorities.clone().into_iter().take(required_signatures as _).collect(),
+		ancestors: n.into(),
+		forks: required_signatures,
+	};
+
+	let authorities = authorities
+		.iter()
+		.map(|(id, w)| (AuthorityId::from(*id), *w))
+		.collect::<Vec<(AuthorityId, _)>>();
+	let voter_set = VoterSet::new(authorities).unwrap();
+
+	assert_eq!(
+		verify_justification::<TestHeader>(
+			header_id::<TestHeader>(1),
+			TEST_GRANDPA_SET_ID,
+			&voter_set,
+			&make_justification_for_header::<TestHeader>(params)
+		),
+		Ok(()),
+	);
+}
+
+#[test]
+fn justification_with_invalid_target_rejected() {
+	assert_eq!(
+		verify_justification::<TestHeader>(
+			header_id::<TestHeader>(2),
+			TEST_GRANDPA_SET_ID,
+			&voter_set(),
+			&make_default_justification::<TestHeader>(&test_header(1)),
+		),
+		Err(JustificationVerificationError::InvalidJustificationTarget),
+	);
+}
+
+#[test]
+fn justification_with_invalid_commit_rejected() {
+	let mut justification = make_default_justification::<TestHeader>(&test_header(1));
+	justification.commit.precommits.clear();
+
+	assert_eq!(
+		verify_justification::<TestHeader>(
+			header_id::<TestHeader>(1),
+			TEST_GRANDPA_SET_ID,
+			&voter_set(),
+			&justification,
+		),
+		Err(JustificationVerificationError::TooLowCumulativeWeight),
+	);
+}
+
+#[test]
+fn justification_with_invalid_authority_signature_rejected() {
+	let mut justification = make_default_justification::<TestHeader>(&test_header(1));
+	justification.commit.precommits[0].signature =
+		sp_core::crypto::UncheckedFrom::unchecked_from([1u8; 64]);
+
+	assert_eq!(
+		verify_justification::<TestHeader>(
+			header_id::<TestHeader>(1),
+			TEST_GRANDPA_SET_ID,
+			&voter_set(),
+			&justification,
+		),
+		Err(JustificationVerificationError::Precommit(PrecommitError::InvalidAuthoritySignature)),
+	);
+}
+
+#[test]
+fn justification_with_invalid_precommit_ancestry() {
+	let mut justification = make_default_justification::<TestHeader>(&test_header(1));
+	justification.votes_ancestries.push(test_header(10));
+
+	assert_eq!(
+		verify_justification::<TestHeader>(
+			header_id::<TestHeader>(1),
+			TEST_GRANDPA_SET_ID,
+			&voter_set(),
+			&justification,
+		),
+		Err(JustificationVerificationError::RedundantVotesAncestries),
+	);
+}
+
+#[test]
+fn justification_is_invalid_if_we_dont_meet_threshold() {
+	// Need at least three authorities to sign off or else the voter set threshold can't be reached
+	let authorities = vec![(ALICE, 1), (BOB, 1)];
+
+	let params = JustificationGeneratorParams {
+		header: test_header(1),
+		round: TEST_GRANDPA_ROUND,
+		set_id: TEST_GRANDPA_SET_ID,
+		authorities: authorities.clone(),
+		ancestors: 2 * authorities.len() as u32,
+		forks: 2,
+	};
+
+	assert_eq!(
+		verify_justification::<TestHeader>(
+			header_id::<TestHeader>(1),
+			TEST_GRANDPA_SET_ID,
+			&voter_set(),
+			&make_justification_for_header::<TestHeader>(params)
+		),
+		Err(JustificationVerificationError::TooLowCumulativeWeight),
+	);
+}
diff --git a/bridges/primitives/header-chain/tests/tests.rs b/bridges/primitives/header-chain/tests/tests.rs
new file mode 100644
index 00000000000..7c525a9303a
--- /dev/null
+++ b/bridges/primitives/header-chain/tests/tests.rs
@@ -0,0 +1,7 @@
+mod justification {
+	mod equivocation;
+	mod optimizer;
+	mod strict;
+}
+
+mod implementation_match;
-- 
GitLab