diff --git a/.gitlab/pipeline/zombienet.yml b/.gitlab/pipeline/zombienet.yml
index 52948e1eb719d9f8669523d9762f5662fd1b6e96..e306cb43c02779b02599f142a2f0ed0c81c23732 100644
--- a/.gitlab/pipeline/zombienet.yml
+++ b/.gitlab/pipeline/zombienet.yml
@@ -12,4 +12,6 @@ include:
   # polkadot tests
   - .gitlab/pipeline/zombienet/polkadot.yml
   # bridges tests
-  - .gitlab/pipeline/zombienet/bridges.yml
+  # TODO: https://github.com/paritytech/parity-bridges-common/pull/2884
+  # commenting until we have a new relatye, compatible with updated fees scheme
+  # - .gitlab/pipeline/zombienet/bridges.yml
diff --git a/Cargo.lock b/Cargo.lock
index ad7729d4b30e8f149fcdf5eec36a1b3b83eaad80..d64800fb085e52288c67598a4c3520036e7affe1 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -2153,6 +2153,7 @@ dependencies = [
  "static_assertions",
  "substrate-wasm-builder",
  "testnet-parachains-constants",
+ "tuplex",
 ]
 
 [[package]]
@@ -2311,6 +2312,7 @@ dependencies = [
  "static_assertions",
  "substrate-wasm-builder",
  "testnet-parachains-constants",
+ "tuplex",
  "westend-runtime-constants",
 ]
 
@@ -2349,6 +2351,7 @@ dependencies = [
  "staging-xcm",
  "staging-xcm-builder",
  "static_assertions",
+ "tuplex",
 ]
 
 [[package]]
@@ -22046,6 +22049,12 @@ dependencies = [
  "utf-8",
 ]
 
+[[package]]
+name = "tuplex"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "676ac81d5454c4dcf37955d34fa8626ede3490f744b86ca14a7b90168d2a08aa"
+
 [[package]]
 name = "twox-hash"
 version = "1.6.3"
diff --git a/bridges/bin/runtime-common/Cargo.toml b/bridges/bin/runtime-common/Cargo.toml
index 67b91a16a302d6214830241082b21c407b04c6d1..74049031afe63cf0d2bc95193541a2b1303a1bbf 100644
--- a/bridges/bin/runtime-common/Cargo.toml
+++ b/bridges/bin/runtime-common/Cargo.toml
@@ -16,6 +16,7 @@ hash-db = { version = "0.16.0", default-features = false }
 log = { workspace = true }
 scale-info = { version = "2.11.1", default-features = false, features = ["derive"] }
 static_assertions = { version = "1.1", optional = true }
+tuplex = { version = "0.1", default-features = false }
 
 # Bridge dependencies
 
@@ -82,6 +83,7 @@ std = [
 	"sp-runtime/std",
 	"sp-std/std",
 	"sp-trie/std",
+	"tuplex/std",
 	"xcm-builder/std",
 	"xcm/std",
 ]
diff --git a/bridges/bin/runtime-common/src/extensions/check_obsolete_extension.rs b/bridges/bin/runtime-common/src/extensions/check_obsolete_extension.rs
index 4b0c052df8008410cb531c21d173ead2c4fdd450..2c152aef68226aee36e791a882b5859427a9a33d 100644
--- a/bridges/bin/runtime-common/src/extensions/check_obsolete_extension.rs
+++ b/bridges/bin/runtime-common/src/extensions/check_obsolete_extension.rs
@@ -18,55 +18,229 @@
 //! obsolete (duplicated) data or do not pass some additional pallet-specific
 //! checks.
 
-use crate::messages_call_ext::MessagesCallSubType;
-use pallet_bridge_grandpa::CallSubType as GrandpaCallSubType;
-use pallet_bridge_parachains::CallSubType as ParachainsCallSubtype;
-use sp_runtime::transaction_validity::TransactionValidity;
+use crate::{
+	extensions::refund_relayer_extension::RefundableParachainId,
+	messages_call_ext::MessagesCallSubType,
+};
+use bp_relayers::ExplicitOrAccountParams;
+use bp_runtime::Parachain;
+use pallet_bridge_grandpa::{
+	BridgedBlockNumber, CallSubType as GrandpaCallSubType, SubmitFinalityProofHelper,
+};
+use pallet_bridge_parachains::{
+	CallSubType as ParachainsCallSubtype, SubmitParachainHeadsHelper, SubmitParachainHeadsInfo,
+};
+use pallet_bridge_relayers::Pallet as RelayersPallet;
+use sp_runtime::{
+	traits::{Get, PhantomData, UniqueSaturatedInto},
+	transaction_validity::{TransactionPriority, TransactionValidity, ValidTransactionBuilder},
+};
 
 /// A duplication of the `FilterCall` trait.
 ///
 /// We need this trait in order to be able to implement it for the messages pallet,
 /// since the implementation is done outside of the pallet crate.
-pub trait BridgeRuntimeFilterCall<Call> {
-	/// Checks if a runtime call is valid.
-	fn validate(call: &Call) -> TransactionValidity;
+pub trait BridgeRuntimeFilterCall<AccountId, Call> {
+	/// Data that may be passed from the validate to `post_dispatch`.
+	type ToPostDispatch;
+	/// Called during validation. Needs to checks whether a runtime call, submitted
+	/// by the `who` is valid. `who` may be `None` if transaction is not signed
+	/// by a regular account.
+	fn validate(who: &AccountId, call: &Call) -> (Self::ToPostDispatch, TransactionValidity);
+	/// Called after transaction is dispatched.
+	fn post_dispatch(_who: &AccountId, _has_failed: bool, _to_post_dispatch: Self::ToPostDispatch) {
+	}
+}
+
+/// Wrapper for the bridge GRANDPA pallet that checks calls for obsolete submissions
+/// and also boosts transaction priority if it has submitted by registered relayer.
+/// The boost is computed as
+/// `(BundledHeaderNumber - 1 - BestFinalizedHeaderNumber) * Priority::get()`.
+/// The boost is only applied if submitter has active registration in the relayers
+/// pallet.
+pub struct CheckAndBoostBridgeGrandpaTransactions<T, I, Priority, SlashAccount>(
+	PhantomData<(T, I, Priority, SlashAccount)>,
+);
+
+impl<T, I: 'static, Priority: Get<TransactionPriority>, SlashAccount: Get<T::AccountId>>
+	BridgeRuntimeFilterCall<T::AccountId, T::RuntimeCall>
+	for CheckAndBoostBridgeGrandpaTransactions<T, I, Priority, SlashAccount>
+where
+	T: pallet_bridge_relayers::Config + pallet_bridge_grandpa::Config<I>,
+	T::RuntimeCall: GrandpaCallSubType<T, I>,
+{
+	// bridged header number, bundled in transaction
+	type ToPostDispatch = Option<BridgedBlockNumber<T, I>>;
+
+	fn validate(
+		who: &T::AccountId,
+		call: &T::RuntimeCall,
+	) -> (Self::ToPostDispatch, TransactionValidity) {
+		match GrandpaCallSubType::<T, I>::check_obsolete_submit_finality_proof(call) {
+			Ok(Some(our_tx)) => {
+				let to_post_dispatch = Some(our_tx.base.block_number);
+				let total_priority_boost =
+					compute_priority_boost::<T, _, Priority>(who, our_tx.improved_by);
+				(
+					to_post_dispatch,
+					ValidTransactionBuilder::default().priority(total_priority_boost).build(),
+				)
+			},
+			Ok(None) => (None, ValidTransactionBuilder::default().build()),
+			Err(e) => (None, Err(e)),
+		}
+	}
+
+	fn post_dispatch(
+		relayer: &T::AccountId,
+		has_failed: bool,
+		bundled_block_number: Self::ToPostDispatch,
+	) {
+		// we are only interested in associated pallet submissions
+		let Some(bundled_block_number) = bundled_block_number else { return };
+		// we are only interested in failed or unneeded transactions
+		let has_failed =
+			has_failed || !SubmitFinalityProofHelper::<T, I>::was_successful(bundled_block_number);
+
+		if !has_failed {
+			return
+		}
+
+		// let's slash registered relayer
+		RelayersPallet::<T>::slash_and_deregister(
+			relayer,
+			ExplicitOrAccountParams::Explicit(SlashAccount::get()),
+		);
+	}
+}
+
+/// Wrapper for the bridge parachains pallet that checks calls for obsolete submissions
+/// and also boosts transaction priority if it has submitted by registered relayer.
+/// The boost is computed as
+/// `(BundledHeaderNumber - 1 - BestKnownHeaderNumber) * Priority::get()`.
+/// The boost is only applied if submitter has active registration in the relayers
+/// pallet.
+pub struct CheckAndBoostBridgeParachainsTransactions<T, RefPara, Priority, SlashAccount>(
+	PhantomData<(T, RefPara, Priority, SlashAccount)>,
+);
+
+impl<T, RefPara, Priority: Get<TransactionPriority>, SlashAccount: Get<T::AccountId>>
+	BridgeRuntimeFilterCall<T::AccountId, T::RuntimeCall>
+	for CheckAndBoostBridgeParachainsTransactions<T, RefPara, Priority, SlashAccount>
+where
+	T: pallet_bridge_relayers::Config + pallet_bridge_parachains::Config<RefPara::Instance>,
+	RefPara: RefundableParachainId,
+	T::RuntimeCall: ParachainsCallSubtype<T, RefPara::Instance>,
+{
+	// bridged header number, bundled in transaction
+	type ToPostDispatch = Option<SubmitParachainHeadsInfo>;
+
+	fn validate(
+		who: &T::AccountId,
+		call: &T::RuntimeCall,
+	) -> (Self::ToPostDispatch, TransactionValidity) {
+		match ParachainsCallSubtype::<T, RefPara::Instance>::check_obsolete_submit_parachain_heads(
+			call,
+		) {
+			Ok(Some(our_tx)) if our_tx.base.para_id.0 == RefPara::BridgedChain::PARACHAIN_ID => {
+				let to_post_dispatch = Some(our_tx.base);
+				let total_priority_boost =
+					compute_priority_boost::<T, _, Priority>(&who, our_tx.improved_by);
+				(
+					to_post_dispatch,
+					ValidTransactionBuilder::default().priority(total_priority_boost).build(),
+				)
+			},
+			Ok(_) => (None, ValidTransactionBuilder::default().build()),
+			Err(e) => (None, Err(e)),
+		}
+	}
+
+	fn post_dispatch(relayer: &T::AccountId, has_failed: bool, maybe_update: Self::ToPostDispatch) {
+		// we are only interested in associated pallet submissions
+		let Some(update) = maybe_update else { return };
+		// we are only interested in failed or unneeded transactions
+		let has_failed = has_failed ||
+			!SubmitParachainHeadsHelper::<T, RefPara::Instance>::was_successful(&update);
+
+		if !has_failed {
+			return
+		}
+
+		// let's slash registered relayer
+		RelayersPallet::<T>::slash_and_deregister(
+			relayer,
+			ExplicitOrAccountParams::Explicit(SlashAccount::get()),
+		);
+	}
 }
 
-impl<T, I: 'static> BridgeRuntimeFilterCall<T::RuntimeCall> for pallet_bridge_grandpa::Pallet<T, I>
+impl<T, I: 'static> BridgeRuntimeFilterCall<T::AccountId, T::RuntimeCall>
+	for pallet_bridge_grandpa::Pallet<T, I>
 where
 	T: pallet_bridge_grandpa::Config<I>,
 	T::RuntimeCall: GrandpaCallSubType<T, I>,
 {
-	fn validate(call: &T::RuntimeCall) -> TransactionValidity {
-		GrandpaCallSubType::<T, I>::check_obsolete_submit_finality_proof(call)
+	type ToPostDispatch = ();
+	fn validate(_who: &T::AccountId, call: &T::RuntimeCall) -> ((), TransactionValidity) {
+		(
+			(),
+			GrandpaCallSubType::<T, I>::check_obsolete_submit_finality_proof(call)
+				.and_then(|_| ValidTransactionBuilder::default().build()),
+		)
 	}
 }
 
-impl<T, I: 'static> BridgeRuntimeFilterCall<T::RuntimeCall>
+impl<T, I: 'static> BridgeRuntimeFilterCall<T::AccountId, T::RuntimeCall>
 	for pallet_bridge_parachains::Pallet<T, I>
 where
 	T: pallet_bridge_parachains::Config<I>,
 	T::RuntimeCall: ParachainsCallSubtype<T, I>,
 {
-	fn validate(call: &T::RuntimeCall) -> TransactionValidity {
-		ParachainsCallSubtype::<T, I>::check_obsolete_submit_parachain_heads(call)
+	type ToPostDispatch = ();
+	fn validate(_who: &T::AccountId, call: &T::RuntimeCall) -> ((), TransactionValidity) {
+		(
+			(),
+			ParachainsCallSubtype::<T, I>::check_obsolete_submit_parachain_heads(call)
+				.and_then(|_| ValidTransactionBuilder::default().build()),
+		)
 	}
 }
 
-impl<T: pallet_bridge_messages::Config<I>, I: 'static> BridgeRuntimeFilterCall<T::RuntimeCall>
-	for pallet_bridge_messages::Pallet<T, I>
+impl<T: pallet_bridge_messages::Config<I>, I: 'static>
+	BridgeRuntimeFilterCall<T::AccountId, T::RuntimeCall> for pallet_bridge_messages::Pallet<T, I>
 where
 	T::RuntimeCall: MessagesCallSubType<T, I>,
 {
+	type ToPostDispatch = ();
 	/// Validate messages in order to avoid "mining" messages delivery and delivery confirmation
 	/// transactions, that are delivering outdated messages/confirmations. Without this validation,
 	/// even honest relayers may lose their funds if there are multiple relays running and
 	/// submitting the same messages/confirmations.
-	fn validate(call: &T::RuntimeCall) -> TransactionValidity {
-		call.check_obsolete_call()
+	fn validate(_who: &T::AccountId, call: &T::RuntimeCall) -> ((), TransactionValidity) {
+		((), call.check_obsolete_call())
 	}
 }
 
+/// Computes priority boost that improved known header by `improved_by`
+fn compute_priority_boost<T, N, Priority>(
+	relayer: &T::AccountId,
+	improved_by: N,
+) -> TransactionPriority
+where
+	T: pallet_bridge_relayers::Config,
+	N: UniqueSaturatedInto<TransactionPriority>,
+	Priority: Get<TransactionPriority>,
+{
+	// we only boost priority if relayer has staked required balance
+	let is_relayer_registration_active = RelayersPallet::<T>::is_registration_active(relayer);
+	// if tx improves by just one, there's no need to bump its priority
+	let improved_by: TransactionPriority = improved_by.unique_saturated_into().saturating_sub(1);
+	// if relayer is registered, for every skipped header we improve by `Priority`
+	let boost_per_header = if is_relayer_registration_active { Priority::get() } else { 0 };
+	improved_by.saturating_mul(boost_per_header)
+}
+
 /// Declares a runtime-specific `BridgeRejectObsoleteHeadersAndMessages` signed extension.
 ///
 /// ## Example
@@ -92,7 +266,15 @@ macro_rules! generate_bridge_reject_obsolete_headers_and_messages {
 			type AccountId = $account_id;
 			type Call = $call;
 			type AdditionalSigned = ();
-			type Pre = ();
+			type Pre = (
+				$account_id,
+				( $(
+					<$filter_call as $crate::extensions::check_obsolete_extension::BridgeRuntimeFilterCall<
+						$account_id,
+						$call,
+					>>::ToPostDispatch,
+				)* ),
+			);
 
 			fn additional_signed(&self) -> sp_std::result::Result<
 				(),
@@ -101,29 +283,72 @@ macro_rules! generate_bridge_reject_obsolete_headers_and_messages {
 				Ok(())
 			}
 
+			#[allow(unused_variables)]
 			fn validate(
 				&self,
-				_who: &Self::AccountId,
+				who: &Self::AccountId,
 				call: &Self::Call,
 				_info: &sp_runtime::traits::DispatchInfoOf<Self::Call>,
 				_len: usize,
 			) -> sp_runtime::transaction_validity::TransactionValidity {
-				let valid = sp_runtime::transaction_validity::ValidTransaction::default();
+				let tx_validity = sp_runtime::transaction_validity::ValidTransaction::default();
+				let to_prepare = ();
 				$(
-					let valid = valid
-						.combine_with(<$filter_call as $crate::extensions::check_obsolete_extension::BridgeRuntimeFilterCall<$call>>::validate(call)?);
+					let (from_validate, call_filter_validity) = <
+						$filter_call as
+						$crate::extensions::check_obsolete_extension::BridgeRuntimeFilterCall<
+							Self::AccountId,
+							$call,
+						>>::validate(&who, call);
+					let tx_validity = tx_validity.combine_with(call_filter_validity?);
 				)*
-				Ok(valid)
+				Ok(tx_validity)
 			}
 
+			#[allow(unused_variables)]
 			fn pre_dispatch(
 				self,
-				who: &Self::AccountId,
+				relayer: &Self::AccountId,
 				call: &Self::Call,
 				info: &sp_runtime::traits::DispatchInfoOf<Self::Call>,
 				len: usize,
 			) -> Result<Self::Pre, sp_runtime::transaction_validity::TransactionValidityError> {
-				self.validate(who, call, info, len).map(drop)
+				use tuplex::PushBack;
+				let to_post_dispatch = ();
+				$(
+					let (from_validate, call_filter_validity) = <
+						$filter_call as
+						$crate::extensions::check_obsolete_extension::BridgeRuntimeFilterCall<
+							$account_id,
+							$call,
+						>>::validate(&relayer, call);
+					let _ = call_filter_validity?;
+					let to_post_dispatch = to_post_dispatch.push_back(from_validate);
+				)*
+				Ok((relayer.clone(), to_post_dispatch))
+			}
+
+			#[allow(unused_variables)]
+			fn post_dispatch(
+				to_post_dispatch: Option<Self::Pre>,
+				info: &sp_runtime::traits::DispatchInfoOf<Self::Call>,
+				post_info: &sp_runtime::traits::PostDispatchInfoOf<Self::Call>,
+				len: usize,
+				result: &sp_runtime::DispatchResult,
+			) -> Result<(), sp_runtime::transaction_validity::TransactionValidityError> {
+				use tuplex::PopFront;
+				let Some((relayer, to_post_dispatch)) = to_post_dispatch else { return Ok(()) };
+				let has_failed = result.is_err();
+				$(
+					let (item, to_post_dispatch) = to_post_dispatch.pop_front();
+					<
+						$filter_call as
+						$crate::extensions::check_obsolete_extension::BridgeRuntimeFilterCall<
+							$account_id,
+							$call,
+						>>::post_dispatch(&relayer, has_failed, item);
+				)*
+				Ok(())
 			}
 		}
 	};
@@ -132,10 +357,23 @@ macro_rules! generate_bridge_reject_obsolete_headers_and_messages {
 #[cfg(test)]
 mod tests {
 	use super::*;
+	use crate::{
+		extensions::refund_relayer_extension::{
+			tests::{
+				initialize_environment, relayer_account_at_this_chain,
+				submit_parachain_head_call_ex, submit_relay_header_call_ex,
+			},
+			RefundableParachain,
+		},
+		mock::*,
+	};
+	use bp_polkadot_core::parachains::ParaId;
+	use bp_runtime::HeaderId;
 	use frame_support::{assert_err, assert_ok};
 	use sp_runtime::{
-		traits::SignedExtension,
+		traits::{ConstU64, SignedExtension},
 		transaction_validity::{InvalidTransaction, TransactionValidity, ValidTransaction},
+		DispatchError,
 	};
 
 	pub struct MockCall {
@@ -143,7 +381,7 @@ mod tests {
 	}
 
 	impl sp_runtime::traits::Dispatchable for MockCall {
-		type RuntimeOrigin = ();
+		type RuntimeOrigin = u64;
 		type Config = ();
 		type Info = ();
 		type PostInfo = ();
@@ -156,50 +394,287 @@ mod tests {
 		}
 	}
 
-	struct FirstFilterCall;
-	impl BridgeRuntimeFilterCall<MockCall> for FirstFilterCall {
-		fn validate(call: &MockCall) -> TransactionValidity {
+	pub struct FirstFilterCall;
+	impl FirstFilterCall {
+		fn post_dispatch_called_with(success: bool) {
+			frame_support::storage::unhashed::put(&[1], &success);
+		}
+
+		fn verify_post_dispatch_called_with(success: bool) {
+			assert_eq!(frame_support::storage::unhashed::get::<bool>(&[1]), Some(success));
+		}
+	}
+
+	impl BridgeRuntimeFilterCall<u64, MockCall> for FirstFilterCall {
+		type ToPostDispatch = u64;
+		fn validate(_who: &u64, call: &MockCall) -> (u64, TransactionValidity) {
 			if call.data <= 1 {
-				return InvalidTransaction::Custom(1).into()
+				return (1, InvalidTransaction::Custom(1).into())
 			}
 
-			Ok(ValidTransaction { priority: 1, ..Default::default() })
+			(1, Ok(ValidTransaction { priority: 1, ..Default::default() }))
+		}
+
+		fn post_dispatch(_who: &u64, has_failed: bool, to_post_dispatch: Self::ToPostDispatch) {
+			Self::post_dispatch_called_with(!has_failed);
+			assert_eq!(to_post_dispatch, 1);
+		}
+	}
+
+	pub struct SecondFilterCall;
+
+	impl SecondFilterCall {
+		fn post_dispatch_called_with(success: bool) {
+			frame_support::storage::unhashed::put(&[2], &success);
+		}
+
+		fn verify_post_dispatch_called_with(success: bool) {
+			assert_eq!(frame_support::storage::unhashed::get::<bool>(&[2]), Some(success));
 		}
 	}
 
-	struct SecondFilterCall;
-	impl BridgeRuntimeFilterCall<MockCall> for SecondFilterCall {
-		fn validate(call: &MockCall) -> TransactionValidity {
+	impl BridgeRuntimeFilterCall<u64, MockCall> for SecondFilterCall {
+		type ToPostDispatch = u64;
+		fn validate(_who: &u64, call: &MockCall) -> (u64, TransactionValidity) {
 			if call.data <= 2 {
-				return InvalidTransaction::Custom(2).into()
+				return (2, InvalidTransaction::Custom(2).into())
 			}
 
-			Ok(ValidTransaction { priority: 2, ..Default::default() })
+			(2, Ok(ValidTransaction { priority: 2, ..Default::default() }))
+		}
+
+		fn post_dispatch(_who: &u64, has_failed: bool, to_post_dispatch: Self::ToPostDispatch) {
+			Self::post_dispatch_called_with(!has_failed);
+			assert_eq!(to_post_dispatch, 2);
 		}
 	}
 
 	#[test]
-	fn test() {
+	fn test_generated_obsolete_extension() {
 		generate_bridge_reject_obsolete_headers_and_messages!(
 			MockCall,
-			(),
+			u64,
 			FirstFilterCall,
 			SecondFilterCall
 		);
 
-		assert_err!(
-			BridgeRejectObsoleteHeadersAndMessages.validate(&(), &MockCall { data: 1 }, &(), 0),
-			InvalidTransaction::Custom(1)
-		);
+		run_test(|| {
+			assert_err!(
+				BridgeRejectObsoleteHeadersAndMessages.validate(&42, &MockCall { data: 1 }, &(), 0),
+				InvalidTransaction::Custom(1)
+			);
+			assert_err!(
+				BridgeRejectObsoleteHeadersAndMessages.pre_dispatch(
+					&42,
+					&MockCall { data: 1 },
+					&(),
+					0
+				),
+				InvalidTransaction::Custom(1)
+			);
 
-		assert_err!(
-			BridgeRejectObsoleteHeadersAndMessages.validate(&(), &MockCall { data: 2 }, &(), 0),
-			InvalidTransaction::Custom(2)
-		);
+			assert_err!(
+				BridgeRejectObsoleteHeadersAndMessages.validate(&42, &MockCall { data: 2 }, &(), 0),
+				InvalidTransaction::Custom(2)
+			);
+			assert_err!(
+				BridgeRejectObsoleteHeadersAndMessages.pre_dispatch(
+					&42,
+					&MockCall { data: 2 },
+					&(),
+					0
+				),
+				InvalidTransaction::Custom(2)
+			);
 
-		assert_ok!(
-			BridgeRejectObsoleteHeadersAndMessages.validate(&(), &MockCall { data: 3 }, &(), 0),
-			ValidTransaction { priority: 3, ..Default::default() }
-		)
+			assert_eq!(
+				BridgeRejectObsoleteHeadersAndMessages
+					.validate(&42, &MockCall { data: 3 }, &(), 0)
+					.unwrap(),
+				ValidTransaction { priority: 3, ..Default::default() },
+			);
+			assert_eq!(
+				BridgeRejectObsoleteHeadersAndMessages
+					.pre_dispatch(&42, &MockCall { data: 3 }, &(), 0)
+					.unwrap(),
+				(42, (1, 2)),
+			);
+
+			// when post_dispatch is called with `Ok(())`, it is propagated to all "nested"
+			// extensions
+			assert_ok!(BridgeRejectObsoleteHeadersAndMessages::post_dispatch(
+				Some((0, (1, 2))),
+				&(),
+				&(),
+				0,
+				&Ok(())
+			));
+			FirstFilterCall::verify_post_dispatch_called_with(true);
+			SecondFilterCall::verify_post_dispatch_called_with(true);
+
+			// when post_dispatch is called with `Err(())`, it is propagated to all "nested"
+			// extensions
+			assert_ok!(BridgeRejectObsoleteHeadersAndMessages::post_dispatch(
+				Some((0, (1, 2))),
+				&(),
+				&(),
+				0,
+				&Err(DispatchError::BadOrigin)
+			));
+			FirstFilterCall::verify_post_dispatch_called_with(false);
+			SecondFilterCall::verify_post_dispatch_called_with(false);
+		});
+	}
+
+	frame_support::parameter_types! {
+		pub SlashDestination: ThisChainAccountId = 42;
+	}
+
+	type BridgeGrandpaWrapper =
+		CheckAndBoostBridgeGrandpaTransactions<TestRuntime, (), ConstU64<1_000>, SlashDestination>;
+
+	#[test]
+	fn grandpa_wrapper_does_not_boost_extensions_for_unregistered_relayer() {
+		run_test(|| {
+			initialize_environment(100, 100, 100);
+
+			let priority_boost = BridgeGrandpaWrapper::validate(
+				&relayer_account_at_this_chain(),
+				&submit_relay_header_call_ex(200),
+			)
+			.1
+			.unwrap()
+			.priority;
+			assert_eq!(priority_boost, 0);
+		})
+	}
+
+	#[test]
+	fn grandpa_wrapper_boosts_extensions_for_registered_relayer() {
+		run_test(|| {
+			initialize_environment(100, 100, 100);
+			BridgeRelayers::register(RuntimeOrigin::signed(relayer_account_at_this_chain()), 1000)
+				.unwrap();
+
+			let priority_boost = BridgeGrandpaWrapper::validate(
+				&relayer_account_at_this_chain(),
+				&submit_relay_header_call_ex(200),
+			)
+			.1
+			.unwrap()
+			.priority;
+			assert_eq!(priority_boost, 99_000);
+		})
+	}
+
+	#[test]
+	fn grandpa_wrapper_slashes_registered_relayer_if_transaction_fails() {
+		run_test(|| {
+			initialize_environment(100, 100, 100);
+			BridgeRelayers::register(RuntimeOrigin::signed(relayer_account_at_this_chain()), 1000)
+				.unwrap();
+
+			assert!(BridgeRelayers::is_registration_active(&relayer_account_at_this_chain()));
+			BridgeGrandpaWrapper::post_dispatch(&relayer_account_at_this_chain(), true, Some(150));
+			assert!(!BridgeRelayers::is_registration_active(&relayer_account_at_this_chain()));
+		})
+	}
+
+	#[test]
+	fn grandpa_wrapper_does_not_slash_registered_relayer_if_transaction_succeeds() {
+		run_test(|| {
+			initialize_environment(100, 100, 100);
+			BridgeRelayers::register(RuntimeOrigin::signed(relayer_account_at_this_chain()), 1000)
+				.unwrap();
+
+			assert!(BridgeRelayers::is_registration_active(&relayer_account_at_this_chain()));
+			BridgeGrandpaWrapper::post_dispatch(&relayer_account_at_this_chain(), false, Some(100));
+			assert!(BridgeRelayers::is_registration_active(&relayer_account_at_this_chain()));
+		})
+	}
+
+	type BridgeParachainsWrapper = CheckAndBoostBridgeParachainsTransactions<
+		TestRuntime,
+		RefundableParachain<(), BridgedUnderlyingParachain>,
+		ConstU64<1_000>,
+		SlashDestination,
+	>;
+
+	#[test]
+	fn parachains_wrapper_does_not_boost_extensions_for_unregistered_relayer() {
+		run_test(|| {
+			initialize_environment(100, 100, 100);
+
+			let priority_boost = BridgeParachainsWrapper::validate(
+				&relayer_account_at_this_chain(),
+				&submit_parachain_head_call_ex(200),
+			)
+			.1
+			.unwrap()
+			.priority;
+			assert_eq!(priority_boost, 0);
+		})
+	}
+
+	#[test]
+	fn parachains_wrapper_boosts_extensions_for_registered_relayer() {
+		run_test(|| {
+			initialize_environment(100, 100, 100);
+			BridgeRelayers::register(RuntimeOrigin::signed(relayer_account_at_this_chain()), 1000)
+				.unwrap();
+
+			let priority_boost = BridgeParachainsWrapper::validate(
+				&relayer_account_at_this_chain(),
+				&submit_parachain_head_call_ex(200),
+			)
+			.1
+			.unwrap()
+			.priority;
+			assert_eq!(priority_boost, 99_000);
+		})
+	}
+
+	#[test]
+	fn parachains_wrapper_slashes_registered_relayer_if_transaction_fails() {
+		run_test(|| {
+			initialize_environment(100, 100, 100);
+			BridgeRelayers::register(RuntimeOrigin::signed(relayer_account_at_this_chain()), 1000)
+				.unwrap();
+
+			assert!(BridgeRelayers::is_registration_active(&relayer_account_at_this_chain()));
+			BridgeParachainsWrapper::post_dispatch(
+				&relayer_account_at_this_chain(),
+				true,
+				Some(SubmitParachainHeadsInfo {
+					at_relay_block: HeaderId(150, Default::default()),
+					para_id: ParaId(BridgedUnderlyingParachain::PARACHAIN_ID),
+					para_head_hash: [150u8; 32].into(),
+					is_free_execution_expected: false,
+				}),
+			);
+			assert!(!BridgeRelayers::is_registration_active(&relayer_account_at_this_chain()));
+		})
+	}
+
+	#[test]
+	fn parachains_wrapper_does_not_slash_registered_relayer_if_transaction_succeeds() {
+		run_test(|| {
+			initialize_environment(100, 100, 100);
+			BridgeRelayers::register(RuntimeOrigin::signed(relayer_account_at_this_chain()), 1000)
+				.unwrap();
+
+			assert!(BridgeRelayers::is_registration_active(&relayer_account_at_this_chain()));
+			BridgeParachainsWrapper::post_dispatch(
+				&relayer_account_at_this_chain(),
+				false,
+				Some(SubmitParachainHeadsInfo {
+					at_relay_block: HeaderId(100, Default::default()),
+					para_id: ParaId(BridgedUnderlyingParachain::PARACHAIN_ID),
+					para_head_hash: [100u8; 32].into(),
+					is_free_execution_expected: false,
+				}),
+			);
+			assert!(BridgeRelayers::is_registration_active(&relayer_account_at_this_chain()));
+		})
 	}
 }
diff --git a/bridges/bin/runtime-common/src/extensions/priority_calculator.rs b/bridges/bin/runtime-common/src/extensions/priority_calculator.rs
index 5035553f508dfea94a0cb5ddf9b916dd7d9b4ea5..92810290f95e77a7fdc04cafaa1e6ab290e1661a 100644
--- a/bridges/bin/runtime-common/src/extensions/priority_calculator.rs
+++ b/bridges/bin/runtime-common/src/extensions/priority_calculator.rs
@@ -22,7 +22,6 @@
 //! single message with nonce `N`, then the transaction with nonces `N..=N+100` will
 //! be rejected. This can lower bridge throughput down to one message per block.
 
-use bp_messages::MessageNonce;
 use frame_support::traits::Get;
 use sp_runtime::transaction_validity::TransactionPriority;
 
@@ -30,16 +29,19 @@ use sp_runtime::transaction_validity::TransactionPriority;
 #[allow(unused_imports)]
 pub use integrity_tests::*;
 
-/// Compute priority boost for message delivery transaction that delivers
-/// given number of messages.
-pub fn compute_priority_boost<PriorityBoostPerMessage>(
-	messages: MessageNonce,
-) -> TransactionPriority
+/// We'll deal with different bridge items here - messages, headers, ...
+/// To avoid being too verbose with generic code, let's just define a separate alias.
+pub type ItemCount = u64;
+
+/// Compute priority boost for transaction that brings given number of bridge
+/// items (messages, headers, ...), when every additional item adds `PriorityBoostPerItem`
+/// to transaction priority.
+pub fn compute_priority_boost<PriorityBoostPerItem>(n_items: ItemCount) -> TransactionPriority
 where
-	PriorityBoostPerMessage: Get<TransactionPriority>,
+	PriorityBoostPerItem: Get<TransactionPriority>,
 {
-	// we don't want any boost for transaction with single message => minus one
-	PriorityBoostPerMessage::get().saturating_mul(messages.saturating_sub(1))
+	// we don't want any boost for transaction with single (additional) item => minus one
+	PriorityBoostPerItem::get().saturating_mul(n_items.saturating_sub(1))
 }
 
 #[cfg(not(feature = "integrity-test"))]
@@ -47,7 +49,8 @@ mod integrity_tests {}
 
 #[cfg(feature = "integrity-test")]
 mod integrity_tests {
-	use super::compute_priority_boost;
+	use super::{compute_priority_boost, ItemCount};
+	use crate::extensions::refund_relayer_extension::RefundableParachainId;
 
 	use bp_messages::MessageNonce;
 	use bp_runtime::PreComputedSize;
@@ -55,7 +58,6 @@ mod integrity_tests {
 		dispatch::{DispatchClass, DispatchInfo, Pays, PostDispatchInfo},
 		traits::Get,
 	};
-	use pallet_bridge_messages::WeightInfoExt;
 	use pallet_transaction_payment::OnChargeTransaction;
 	use sp_runtime::{
 		traits::{Dispatchable, UniqueSaturatedInto, Zero},
@@ -68,37 +70,33 @@ mod integrity_tests {
 			T,
 		>>::Balance;
 
-	/// Ensures that the value of `PriorityBoostPerMessage` matches the value of
-	/// `tip_boost_per_message`.
+	/// Ensures that the value of `PriorityBoostPerItem` matches the value of
+	/// `tip_boost_per_item`.
 	///
-	/// We want two transactions, `TX1` with `N` messages and `TX2` with `N+1` messages, have almost
-	/// the same priority if we'll add `tip_boost_per_message` tip to the `TX1`. We want to be sure
-	/// that if we add plain `PriorityBoostPerMessage` priority to `TX1`, the priority will be close
+	/// We want two transactions, `TX1` with `N` items and `TX2` with `N+1` items, have almost
+	/// the same priority if we'll add `tip_boost_per_item` tip to the `TX1`. We want to be sure
+	/// that if we add plain `PriorityBoostPerItem` priority to `TX1`, the priority will be close
 	/// to `TX2` as well.
-	pub fn ensure_priority_boost_is_sane<Runtime, MessagesInstance, PriorityBoostPerMessage>(
-		tip_boost_per_message: BalanceOf<Runtime>,
+	fn ensure_priority_boost_is_sane<PriorityBoostPerItem, Balance>(
+		param_name: &str,
+		max_items: ItemCount,
+		tip_boost_per_item: Balance,
+		estimate_priority: impl Fn(ItemCount, Balance) -> TransactionPriority,
 	) where
-		Runtime:
-			pallet_transaction_payment::Config + pallet_bridge_messages::Config<MessagesInstance>,
-		MessagesInstance: 'static,
-		PriorityBoostPerMessage: Get<TransactionPriority>,
-		Runtime::RuntimeCall: Dispatchable<Info = DispatchInfo, PostInfo = PostDispatchInfo>,
-		BalanceOf<Runtime>: Send + Sync + FixedPointOperand,
+		PriorityBoostPerItem: Get<TransactionPriority>,
+		ItemCount: UniqueSaturatedInto<Balance>,
+		Balance: FixedPointOperand + Zero,
 	{
-		let priority_boost_per_message = PriorityBoostPerMessage::get();
-		let maximal_messages_in_delivery_transaction =
-			Runtime::MaxUnconfirmedMessagesAtInboundLane::get();
-		for messages in 1..=maximal_messages_in_delivery_transaction {
-			let base_priority = estimate_message_delivery_transaction_priority::<
-				Runtime,
-				MessagesInstance,
-			>(messages, Zero::zero());
-			let priority_boost = compute_priority_boost::<PriorityBoostPerMessage>(messages);
-			let priority_with_boost = base_priority + priority_boost;
-
-			let tip = tip_boost_per_message.saturating_mul((messages - 1).unique_saturated_into());
-			let priority_with_tip =
-				estimate_message_delivery_transaction_priority::<Runtime, MessagesInstance>(1, tip);
+		let priority_boost_per_item = PriorityBoostPerItem::get();
+		for n_items in 1..=max_items {
+			let base_priority = estimate_priority(n_items, Zero::zero());
+			let priority_boost = compute_priority_boost::<PriorityBoostPerItem>(n_items);
+			let priority_with_boost = base_priority
+				.checked_add(priority_boost)
+				.expect("priority overflow: try lowering `max_items` or `tip_boost_per_item`?");
+
+			let tip = tip_boost_per_item.saturating_mul((n_items - 1).unique_saturated_into());
+			let priority_with_tip = estimate_priority(1, tip);
 
 			const ERROR_MARGIN: TransactionPriority = 5; // 5%
 			if priority_with_boost.abs_diff(priority_with_tip).saturating_mul(100) /
@@ -106,97 +104,304 @@ mod integrity_tests {
 				ERROR_MARGIN
 			{
 				panic!(
-					"The PriorityBoostPerMessage value ({}) must be fixed to: {}",
-					priority_boost_per_message,
-					compute_priority_boost_per_message::<Runtime, MessagesInstance>(
-						tip_boost_per_message
+					"The {param_name} value ({}) must be fixed to: {}",
+					priority_boost_per_item,
+					compute_priority_boost_per_item(
+						max_items,
+						tip_boost_per_item,
+						estimate_priority
 					),
 				);
 			}
 		}
 	}
 
-	/// Compute priority boost that we give to message delivery transaction for additional message.
+	/// Compute priority boost that we give to bridge transaction for every
+	/// additional bridge item.
 	#[cfg(feature = "integrity-test")]
-	fn compute_priority_boost_per_message<Runtime, MessagesInstance>(
-		tip_boost_per_message: BalanceOf<Runtime>,
+	fn compute_priority_boost_per_item<Balance>(
+		max_items: ItemCount,
+		tip_boost_per_item: Balance,
+		estimate_priority: impl Fn(ItemCount, Balance) -> TransactionPriority,
 	) -> TransactionPriority
 	where
-		Runtime:
-			pallet_transaction_payment::Config + pallet_bridge_messages::Config<MessagesInstance>,
-		MessagesInstance: 'static,
-		Runtime::RuntimeCall: Dispatchable<Info = DispatchInfo, PostInfo = PostDispatchInfo>,
-		BalanceOf<Runtime>: Send + Sync + FixedPointOperand,
+		ItemCount: UniqueSaturatedInto<Balance>,
+		Balance: FixedPointOperand + Zero,
 	{
-		// estimate priority of transaction that delivers one message and has large tip
-		let maximal_messages_in_delivery_transaction =
-			Runtime::MaxUnconfirmedMessagesAtInboundLane::get();
+		// estimate priority of transaction that delivers one item and has large tip
 		let small_with_tip_priority =
-			estimate_message_delivery_transaction_priority::<Runtime, MessagesInstance>(
-				1,
-				tip_boost_per_message
-					.saturating_mul(maximal_messages_in_delivery_transaction.saturated_into()),
-			);
-		// estimate priority of transaction that delivers maximal number of messages, but has no tip
-		let large_without_tip_priority = estimate_message_delivery_transaction_priority::<
-			Runtime,
-			MessagesInstance,
-		>(maximal_messages_in_delivery_transaction, Zero::zero());
+			estimate_priority(1, tip_boost_per_item.saturating_mul(max_items.saturated_into()));
+		// estimate priority of transaction that delivers maximal number of items, but has no tip
+		let large_without_tip_priority = estimate_priority(max_items, Zero::zero());
 
 		small_with_tip_priority
 			.saturating_sub(large_without_tip_priority)
-			.saturating_div(maximal_messages_in_delivery_transaction - 1)
+			.saturating_div(max_items - 1)
 	}
 
-	/// Estimate message delivery transaction priority.
-	#[cfg(feature = "integrity-test")]
-	fn estimate_message_delivery_transaction_priority<Runtime, MessagesInstance>(
-		messages: MessageNonce,
-		tip: BalanceOf<Runtime>,
-	) -> TransactionPriority
-	where
-		Runtime:
-			pallet_transaction_payment::Config + pallet_bridge_messages::Config<MessagesInstance>,
-		MessagesInstance: 'static,
-		Runtime::RuntimeCall: Dispatchable<Info = DispatchInfo, PostInfo = PostDispatchInfo>,
-		BalanceOf<Runtime>: Send + Sync + FixedPointOperand,
-	{
-		// just an estimation of extra transaction bytes that are added to every transaction
-		// (including signature, signed extensions extra and etc + in our case it includes
-		// all call arguments except the proof itself)
-		let base_tx_size = 512;
-		// let's say we are relaying similar small messages and for every message we add more trie
-		// nodes to the proof (x0.5 because we expect some nodes to be reused)
-		let estimated_message_size = 512;
-		// let's say all our messages have the same dispatch weight
-		let estimated_message_dispatch_weight =
-			Runtime::WeightInfo::message_dispatch_weight(estimated_message_size);
-		// messages proof argument size is (for every message) messages size + some additional
-		// trie nodes. Some of them are reused by different messages, so let's take 2/3 of default
-		// "overhead" constant
-		let messages_proof_size = Runtime::WeightInfo::expected_extra_storage_proof_size()
-			.saturating_mul(2)
-			.saturating_div(3)
-			.saturating_add(estimated_message_size)
-			.saturating_mul(messages as _);
-
-		// finally we are able to estimate transaction size and weight
-		let transaction_size = base_tx_size.saturating_add(messages_proof_size);
-		let transaction_weight = Runtime::WeightInfo::receive_messages_proof_weight(
-			&PreComputedSize(transaction_size as _),
-			messages as _,
-			estimated_message_dispatch_weight.saturating_mul(messages),
-		);
-
-		pallet_transaction_payment::ChargeTransactionPayment::<Runtime>::get_priority(
-			&DispatchInfo {
-				weight: transaction_weight,
-				class: DispatchClass::Normal,
-				pays_fee: Pays::Yes,
-			},
-			transaction_size as _,
-			tip,
-			Zero::zero(),
-		)
+	/// Computations, specific to bridge relay chains transactions.
+	pub mod per_relay_header {
+		use super::*;
+
+		use bp_header_chain::{
+			max_expected_submit_finality_proof_arguments_size, ChainWithGrandpa,
+		};
+		use pallet_bridge_grandpa::WeightInfoExt;
+
+		/// Ensures that the value of `PriorityBoostPerHeader` matches the value of
+		/// `tip_boost_per_header`.
+		///
+		/// We want two transactions, `TX1` with `N` headers and `TX2` with `N+1` headers, have
+		/// almost the same priority if we'll add `tip_boost_per_header` tip to the `TX1`. We want
+		/// to be sure that if we add plain `PriorityBoostPerHeader` priority to `TX1`, the priority
+		/// will be close to `TX2` as well.
+		pub fn ensure_priority_boost_is_sane<Runtime, GrandpaInstance, PriorityBoostPerHeader>(
+			tip_boost_per_header: BalanceOf<Runtime>,
+		) where
+			Runtime:
+				pallet_transaction_payment::Config + pallet_bridge_grandpa::Config<GrandpaInstance>,
+			GrandpaInstance: 'static,
+			PriorityBoostPerHeader: Get<TransactionPriority>,
+			Runtime::RuntimeCall: Dispatchable<Info = DispatchInfo, PostInfo = PostDispatchInfo>,
+			BalanceOf<Runtime>: Send + Sync + FixedPointOperand,
+		{
+			// the meaning of `max_items` here is different when comparing with message
+			// transactions - with messages we have a strict limit on maximal number of
+			// messages we can fit into a single transaction. With headers, current best
+			// header may be improved by any "number of items". But this number is only
+			// used to verify priority boost, so it should be fine to select this arbitrary
+			// value - it SHALL NOT affect any value, it just adds more tests for the value.
+			let maximal_improved_by = 4_096;
+			super::ensure_priority_boost_is_sane::<PriorityBoostPerHeader, BalanceOf<Runtime>>(
+				"PriorityBoostPerRelayHeader",
+				maximal_improved_by,
+				tip_boost_per_header,
+				|_n_headers, tip| {
+					estimate_relay_header_submit_transaction_priority::<Runtime, GrandpaInstance>(
+						tip,
+					)
+				},
+			);
+		}
+
+		/// Estimate relay header delivery transaction priority.
+		#[cfg(feature = "integrity-test")]
+		fn estimate_relay_header_submit_transaction_priority<Runtime, GrandpaInstance>(
+			tip: BalanceOf<Runtime>,
+		) -> TransactionPriority
+		where
+			Runtime:
+				pallet_transaction_payment::Config + pallet_bridge_grandpa::Config<GrandpaInstance>,
+			GrandpaInstance: 'static,
+			Runtime::RuntimeCall: Dispatchable<Info = DispatchInfo, PostInfo = PostDispatchInfo>,
+			BalanceOf<Runtime>: Send + Sync + FixedPointOperand,
+		{
+			// just an estimation of extra transaction bytes that are added to every transaction
+			// (including signature, signed extensions extra and etc + in our case it includes
+			// all call arguments except the proof itself)
+			let base_tx_size = 512;
+			// let's say we are relaying largest relay chain headers
+			let tx_call_size = max_expected_submit_finality_proof_arguments_size::<
+				Runtime::BridgedChain,
+			>(true, Runtime::BridgedChain::MAX_AUTHORITIES_COUNT * 2 / 3 + 1);
+
+			// finally we are able to estimate transaction size and weight
+			let transaction_size = base_tx_size.saturating_add(tx_call_size);
+			let transaction_weight = Runtime::WeightInfo::submit_finality_proof_weight(
+				Runtime::BridgedChain::MAX_AUTHORITIES_COUNT * 2 / 3 + 1,
+				Runtime::BridgedChain::REASONABLE_HEADERS_IN_JUSTIFICATION_ANCESTRY,
+			);
+
+			pallet_transaction_payment::ChargeTransactionPayment::<Runtime>::get_priority(
+				&DispatchInfo {
+					weight: transaction_weight,
+					class: DispatchClass::Normal,
+					pays_fee: Pays::Yes,
+				},
+				transaction_size as _,
+				tip,
+				Zero::zero(),
+			)
+		}
+	}
+
+	/// Computations, specific to bridge parachains transactions.
+	pub mod per_parachain_header {
+		use super::*;
+
+		use bp_runtime::Parachain;
+		use pallet_bridge_parachains::WeightInfoExt;
+
+		/// Ensures that the value of `PriorityBoostPerHeader` matches the value of
+		/// `tip_boost_per_header`.
+		///
+		/// We want two transactions, `TX1` with `N` headers and `TX2` with `N+1` headers, have
+		/// almost the same priority if we'll add `tip_boost_per_header` tip to the `TX1`. We want
+		/// to be sure that if we add plain `PriorityBoostPerHeader` priority to `TX1`, the priority
+		/// will be close to `TX2` as well.
+		pub fn ensure_priority_boost_is_sane<Runtime, RefundableParachain, PriorityBoostPerHeader>(
+			tip_boost_per_header: BalanceOf<Runtime>,
+		) where
+			Runtime: pallet_transaction_payment::Config
+				+ pallet_bridge_parachains::Config<RefundableParachain::Instance>,
+			RefundableParachain: RefundableParachainId,
+			PriorityBoostPerHeader: Get<TransactionPriority>,
+			Runtime::RuntimeCall: Dispatchable<Info = DispatchInfo, PostInfo = PostDispatchInfo>,
+			BalanceOf<Runtime>: Send + Sync + FixedPointOperand,
+		{
+			// the meaning of `max_items` here is different when comparing with message
+			// transactions - with messages we have a strict limit on maximal number of
+			// messages we can fit into a single transaction. With headers, current best
+			// header may be improved by any "number of items". But this number is only
+			// used to verify priority boost, so it should be fine to select this arbitrary
+			// value - it SHALL NOT affect any value, it just adds more tests for the value.
+			let maximal_improved_by = 4_096;
+			super::ensure_priority_boost_is_sane::<PriorityBoostPerHeader, BalanceOf<Runtime>>(
+				"PriorityBoostPerParachainHeader",
+				maximal_improved_by,
+				tip_boost_per_header,
+				|_n_headers, tip| {
+					estimate_parachain_header_submit_transaction_priority::<
+						Runtime,
+						RefundableParachain,
+					>(tip)
+				},
+			);
+		}
+
+		/// Estimate parachain header delivery transaction priority.
+		#[cfg(feature = "integrity-test")]
+		fn estimate_parachain_header_submit_transaction_priority<Runtime, RefundableParachain>(
+			tip: BalanceOf<Runtime>,
+		) -> TransactionPriority
+		where
+			Runtime: pallet_transaction_payment::Config
+				+ pallet_bridge_parachains::Config<RefundableParachain::Instance>,
+			RefundableParachain: RefundableParachainId,
+			Runtime::RuntimeCall: Dispatchable<Info = DispatchInfo, PostInfo = PostDispatchInfo>,
+			BalanceOf<Runtime>: Send + Sync + FixedPointOperand,
+		{
+			// just an estimation of extra transaction bytes that are added to every transaction
+			// (including signature, signed extensions extra and etc + in our case it includes
+			// all call arguments except the proof itself)
+			let base_tx_size = 512;
+			// let's say we are relaying largest parachain headers and proof takes some more bytes
+			let tx_call_size = <Runtime as pallet_bridge_parachains::Config<
+				RefundableParachain::Instance,
+			>>::WeightInfo::expected_extra_storage_proof_size()
+			.saturating_add(RefundableParachain::BridgedChain::MAX_HEADER_SIZE);
+
+			// finally we are able to estimate transaction size and weight
+			let transaction_size = base_tx_size.saturating_add(tx_call_size);
+			let transaction_weight = <Runtime as pallet_bridge_parachains::Config<
+				RefundableParachain::Instance,
+			>>::WeightInfo::submit_parachain_heads_weight(
+				Runtime::DbWeight::get(),
+				&PreComputedSize(transaction_size as _),
+				// just one parachain - all other submissions won't receive any boost
+				1,
+			);
+
+			pallet_transaction_payment::ChargeTransactionPayment::<Runtime>::get_priority(
+				&DispatchInfo {
+					weight: transaction_weight,
+					class: DispatchClass::Normal,
+					pays_fee: Pays::Yes,
+				},
+				transaction_size as _,
+				tip,
+				Zero::zero(),
+			)
+		}
+	}
+
+	/// Computations, specific to bridge messages transactions.
+	pub mod per_message {
+		use super::*;
+
+		use pallet_bridge_messages::WeightInfoExt;
+
+		/// Ensures that the value of `PriorityBoostPerMessage` matches the value of
+		/// `tip_boost_per_message`.
+		///
+		/// We want two transactions, `TX1` with `N` messages and `TX2` with `N+1` messages, have
+		/// almost the same priority if we'll add `tip_boost_per_message` tip to the `TX1`. We want
+		/// to be sure that if we add plain `PriorityBoostPerMessage` priority to `TX1`, the
+		/// priority will be close to `TX2` as well.
+		pub fn ensure_priority_boost_is_sane<Runtime, MessagesInstance, PriorityBoostPerMessage>(
+			tip_boost_per_message: BalanceOf<Runtime>,
+		) where
+			Runtime: pallet_transaction_payment::Config
+				+ pallet_bridge_messages::Config<MessagesInstance>,
+			MessagesInstance: 'static,
+			PriorityBoostPerMessage: Get<TransactionPriority>,
+			Runtime::RuntimeCall: Dispatchable<Info = DispatchInfo, PostInfo = PostDispatchInfo>,
+			BalanceOf<Runtime>: Send + Sync + FixedPointOperand,
+		{
+			let maximal_messages_in_delivery_transaction =
+				Runtime::MaxUnconfirmedMessagesAtInboundLane::get();
+			super::ensure_priority_boost_is_sane::<PriorityBoostPerMessage, BalanceOf<Runtime>>(
+				"PriorityBoostPerMessage",
+				maximal_messages_in_delivery_transaction,
+				tip_boost_per_message,
+				|n_messages, tip| {
+					estimate_message_delivery_transaction_priority::<Runtime, MessagesInstance>(
+						n_messages, tip,
+					)
+				},
+			);
+		}
+
+		/// Estimate message delivery transaction priority.
+		#[cfg(feature = "integrity-test")]
+		fn estimate_message_delivery_transaction_priority<Runtime, MessagesInstance>(
+			messages: MessageNonce,
+			tip: BalanceOf<Runtime>,
+		) -> TransactionPriority
+		where
+			Runtime: pallet_transaction_payment::Config
+				+ pallet_bridge_messages::Config<MessagesInstance>,
+			MessagesInstance: 'static,
+			Runtime::RuntimeCall: Dispatchable<Info = DispatchInfo, PostInfo = PostDispatchInfo>,
+			BalanceOf<Runtime>: Send + Sync + FixedPointOperand,
+		{
+			// just an estimation of extra transaction bytes that are added to every transaction
+			// (including signature, signed extensions extra and etc + in our case it includes
+			// all call arguments except the proof itself)
+			let base_tx_size = 512;
+			// let's say we are relaying similar small messages and for every message we add more
+			// trie nodes to the proof (x0.5 because we expect some nodes to be reused)
+			let estimated_message_size = 512;
+			// let's say all our messages have the same dispatch weight
+			let estimated_message_dispatch_weight =
+				Runtime::WeightInfo::message_dispatch_weight(estimated_message_size);
+			// messages proof argument size is (for every message) messages size + some additional
+			// trie nodes. Some of them are reused by different messages, so let's take 2/3 of
+			// default "overhead" constant
+			let messages_proof_size = Runtime::WeightInfo::expected_extra_storage_proof_size()
+				.saturating_mul(2)
+				.saturating_div(3)
+				.saturating_add(estimated_message_size)
+				.saturating_mul(messages as _);
+
+			// finally we are able to estimate transaction size and weight
+			let transaction_size = base_tx_size.saturating_add(messages_proof_size);
+			let transaction_weight = Runtime::WeightInfo::receive_messages_proof_weight(
+				&PreComputedSize(transaction_size as _),
+				messages as _,
+				estimated_message_dispatch_weight.saturating_mul(messages),
+			);
+
+			pallet_transaction_payment::ChargeTransactionPayment::<Runtime>::get_priority(
+				&DispatchInfo {
+					weight: transaction_weight,
+					class: DispatchClass::Normal,
+					pays_fee: Pays::Yes,
+				},
+				transaction_size as _,
+				tip,
+				Zero::zero(),
+			)
+		}
 	}
 }
diff --git a/bridges/bin/runtime-common/src/extensions/refund_relayer_extension.rs b/bridges/bin/runtime-common/src/extensions/refund_relayer_extension.rs
index 64ae1d0b669f2ea8fdfba0df73752a9b0f6e8aec..5aa7f1c095d540a4ee5050aeb7d694c98b744683 100644
--- a/bridges/bin/runtime-common/src/extensions/refund_relayer_extension.rs
+++ b/bridges/bin/runtime-common/src/extensions/refund_relayer_extension.rs
@@ -24,7 +24,7 @@ use crate::messages_call_ext::{
 };
 use bp_messages::{LaneId, MessageNonce};
 use bp_relayers::{ExplicitOrAccountParams, RewardsAccountOwner, RewardsAccountParams};
-use bp_runtime::{Chain, Parachain, ParachainIdOf, RangeInclusiveExt, StaticStrProvider};
+use bp_runtime::{Parachain, RangeInclusiveExt, StaticStrProvider};
 use codec::{Codec, Decode, Encode};
 use frame_support::{
 	dispatch::{CallableCallFor, DispatchInfo, PostDispatchInfo},
@@ -33,8 +33,7 @@ use frame_support::{
 	CloneNoBound, DefaultNoBound, EqNoBound, PartialEqNoBound, RuntimeDebugNoBound,
 };
 use pallet_bridge_grandpa::{
-	CallSubType as GrandpaCallSubType, Config as GrandpaConfig, SubmitFinalityProofHelper,
-	SubmitFinalityProofInfo,
+	CallSubType as GrandpaCallSubType, SubmitFinalityProofHelper, SubmitFinalityProofInfo,
 };
 use pallet_bridge_messages::Config as MessagesConfig;
 use pallet_bridge_parachains::{
@@ -66,20 +65,9 @@ type CallOf<R> = <R as frame_system::Config>::RuntimeCall;
 /// coming from this parachain.
 pub trait RefundableParachainId {
 	/// The instance of the bridge parachains pallet.
-	type Instance;
+	type Instance: 'static;
 	/// The parachain Id.
-	type Id: Get<u32>;
-}
-
-/// Default implementation of `RefundableParachainId`.
-pub struct DefaultRefundableParachainId<Instance, Id>(PhantomData<(Instance, Id)>);
-
-impl<Instance, Id> RefundableParachainId for DefaultRefundableParachainId<Instance, Id>
-where
-	Id: Get<u32>,
-{
-	type Instance = Instance;
-	type Id = Id;
+	type BridgedChain: Parachain;
 }
 
 /// Implementation of `RefundableParachainId` for `trait Parachain`.
@@ -87,10 +75,11 @@ pub struct RefundableParachain<Instance, Para>(PhantomData<(Instance, Para)>);
 
 impl<Instance, Para> RefundableParachainId for RefundableParachain<Instance, Para>
 where
+	Instance: 'static,
 	Para: Parachain,
 {
 	type Instance = Instance;
-	type Id = ParachainIdOf<Para>;
+	type BridgedChain = Para;
 }
 
 /// Trait identifying a bridged messages lane. A relayer might be refunded for delivering messages
@@ -242,17 +231,10 @@ pub enum RelayerAccountAction<AccountId, Reward> {
 /// Everything common among our refund signed extensions.
 pub trait RefundSignedExtension:
 	'static + Clone + Codec + sp_std::fmt::Debug + Default + Eq + PartialEq + Send + Sync + TypeInfo
-where
-	<Self::Runtime as GrandpaConfig<Self::GrandpaInstance>>::BridgedChain:
-		Chain<BlockNumber = RelayBlockNumber>,
 {
 	/// This chain runtime.
-	type Runtime: UtilityConfig<RuntimeCall = CallOf<Self::Runtime>>
-		+ GrandpaConfig<Self::GrandpaInstance>
-		+ MessagesConfig<<Self::Msgs as RefundableMessagesLaneId>::Instance>
+	type Runtime: MessagesConfig<<Self::Msgs as RefundableMessagesLaneId>::Instance>
 		+ RelayersConfig;
-	/// Grandpa pallet reference.
-	type GrandpaInstance: 'static;
 	/// Messages pallet and lane reference.
 	type Msgs: RefundableMessagesLaneId;
 	/// Refund amount calculator.
@@ -276,11 +258,13 @@ where
 		call: &CallOf<Self::Runtime>,
 	) -> Result<&CallOf<Self::Runtime>, TransactionValidityError>;
 
-	/// Called from post-dispatch and shall perform additional checks (apart from relay
-	/// chain finality and messages transaction finality) of given call result.
+	/// Called from post-dispatch and shall perform additional checks (apart from messages
+	/// transaction success) of given call result.
 	fn additional_call_result_check(
 		relayer: &AccountIdOf<Self::Runtime>,
 		call_info: &CallInfo,
+		extra_weight: &mut Weight,
+		extra_size: &mut u32,
 	) -> bool;
 
 	/// Given post-dispatch information, analyze the outcome of relayer call and return
@@ -348,35 +332,6 @@ where
 			return slash_relayer_if_delivery_result
 		}
 
-		// check if relay chain state has been updated
-		if let Some(finality_proof_info) = call_info.submit_finality_proof_info() {
-			if !SubmitFinalityProofHelper::<Self::Runtime, Self::GrandpaInstance>::was_successful(
-				finality_proof_info.block_number,
-			) {
-				// we only refund relayer if all calls have updated chain state
-				log::trace!(
-					target: "runtime::bridge",
-					"{} via {:?}: relayer {:?} has submitted invalid relay chain finality proof",
-					Self::Id::STR,
-					<Self::Msgs as RefundableMessagesLaneId>::Id::get(),
-					relayer,
-				);
-				return slash_relayer_if_delivery_result
-			}
-
-			// there's a conflict between how bridge GRANDPA pallet works and a `utility.batchAll`
-			// transaction. If relay chain header is mandatory, the GRANDPA pallet returns
-			// `Pays::No`, because such transaction is mandatory for operating the bridge. But
-			// `utility.batchAll` transaction always requires payment. But in both cases we'll
-			// refund relayer - either explicitly here, or using `Pays::No` if he's choosing
-			// to submit dedicated transaction.
-
-			// submitter has means to include extra weight/bytes in the `submit_finality_proof`
-			// call, so let's subtract extra weight/size to avoid refunding for this extra stuff
-			extra_weight = finality_proof_info.extra_weight;
-			extra_size = finality_proof_info.extra_size;
-		}
-
 		// Check if the `ReceiveMessagesProof` call delivered at least some of the messages that
 		// it contained. If this happens, we consider the transaction "helpful" and refund it.
 		let msgs_call_info = call_info.messages_call_info();
@@ -391,8 +346,13 @@ where
 			return slash_relayer_if_delivery_result
 		}
 
-		// do additional check
-		if !Self::additional_call_result_check(&relayer, &call_info) {
+		// do additional checks
+		if !Self::additional_call_result_check(
+			&relayer,
+			&call_info,
+			&mut extra_weight,
+			&mut extra_size,
+		) {
 			return slash_relayer_if_delivery_result
 		}
 
@@ -468,18 +428,11 @@ where
 	RuntimeDebugNoBound,
 	TypeInfo,
 )]
-pub struct RefundSignedExtensionAdapter<T: RefundSignedExtension>(T)
-where
-	<T::Runtime as GrandpaConfig<T::GrandpaInstance>>::BridgedChain:
-		Chain<BlockNumber = RelayBlockNumber>;
+pub struct RefundSignedExtensionAdapter<T: RefundSignedExtension>(T);
 
 impl<T: RefundSignedExtension> SignedExtension for RefundSignedExtensionAdapter<T>
 where
-	<T::Runtime as GrandpaConfig<T::GrandpaInstance>>::BridgedChain:
-		Chain<BlockNumber = RelayBlockNumber>,
 	CallOf<T::Runtime>: Dispatchable<Info = DispatchInfo, PostInfo = PostDispatchInfo>
-		+ IsSubType<CallableCallFor<UtilityPallet<T::Runtime>, T::Runtime>>
-		+ GrandpaCallSubType<T::Runtime, T::GrandpaInstance>
 		+ MessagesCallSubType<T::Runtime, <T::Msgs as RefundableMessagesLaneId>::Instance>,
 {
 	const IDENTIFIER: &'static str = T::Id::STR;
@@ -644,6 +597,14 @@ impl<Runtime, Para, Msgs, Refund, Priority, Id> RefundSignedExtension
 	for RefundBridgedParachainMessages<Runtime, Para, Msgs, Refund, Priority, Id>
 where
 	Self: 'static + Send + Sync,
+	RefundBridgedGrandpaMessages<
+		Runtime,
+		Runtime::BridgesGrandpaPalletInstance,
+		Msgs,
+		Refund,
+		Priority,
+		Id,
+	>: 'static + Send + Sync,
 	Runtime: UtilityConfig<RuntimeCall = CallOf<Runtime>>
 		+ BoundedBridgeGrandpaConfig<Runtime::BridgesGrandpaPalletInstance>
 		+ ParachainsConfig<Para::Instance>
@@ -661,7 +622,6 @@ where
 		+ MessagesCallSubType<Runtime, Msgs::Instance>,
 {
 	type Runtime = Runtime;
-	type GrandpaInstance = Runtime::BridgesGrandpaPalletInstance;
 	type Msgs = Msgs;
 	type Refund = Refund;
 	type Priority = Priority;
@@ -687,7 +647,7 @@ where
 		let para_finality_call = calls
 			.next()
 			.transpose()?
-			.and_then(|c| c.submit_parachain_heads_info_for(Para::Id::get()));
+			.and_then(|c| c.submit_parachain_heads_info_for(Para::BridgedChain::PARACHAIN_ID));
 		let relay_finality_call =
 			calls.next().transpose()?.and_then(|c| c.submit_finality_proof_info());
 
@@ -711,7 +671,26 @@ where
 		Ok(call)
 	}
 
-	fn additional_call_result_check(relayer: &Runtime::AccountId, call_info: &CallInfo) -> bool {
+	fn additional_call_result_check(
+		relayer: &Runtime::AccountId,
+		call_info: &CallInfo,
+		extra_weight: &mut Weight,
+		extra_size: &mut u32,
+	) -> bool {
+		// check if relay chain state has been updated
+		let is_grandpa_call_successful =
+			RefundBridgedGrandpaMessages::<
+				Runtime,
+				Runtime::BridgesGrandpaPalletInstance,
+				Msgs,
+				Refund,
+				Priority,
+				Id,
+			>::additional_call_result_check(relayer, call_info, extra_weight, extra_size);
+		if !is_grandpa_call_successful {
+			return false
+		}
+
 		// check if parachain state has been updated
 		if let Some(para_proof_info) = call_info.submit_parachain_heads_info() {
 			if !SubmitParachainHeadsHelper::<Runtime, Para::Instance>::was_successful(
@@ -722,7 +701,7 @@ where
 					target: "runtime::bridge",
 					"{} from parachain {} via {:?}: relayer {:?} has submitted invalid parachain finality proof",
 					Id::STR,
-					Para::Id::get(),
+					Para::BridgedChain::PARACHAIN_ID,
 					Msgs::Id::get(),
 					relayer,
 				);
@@ -794,7 +773,6 @@ where
 		+ MessagesCallSubType<Runtime, Msgs::Instance>,
 {
 	type Runtime = Runtime;
-	type GrandpaInstance = GrandpaInstance;
 	type Msgs = Msgs;
 	type Refund = Refund;
 	type Priority = Priority;
@@ -836,13 +814,125 @@ where
 		Ok(call)
 	}
 
-	fn additional_call_result_check(_relayer: &Runtime::AccountId, _call_info: &CallInfo) -> bool {
+	fn additional_call_result_check(
+		relayer: &Runtime::AccountId,
+		call_info: &CallInfo,
+		extra_weight: &mut Weight,
+		extra_size: &mut u32,
+	) -> bool {
+		// check if relay chain state has been updated
+		if let Some(finality_proof_info) = call_info.submit_finality_proof_info() {
+			if !SubmitFinalityProofHelper::<Self::Runtime, GrandpaInstance>::was_successful(
+				finality_proof_info.block_number,
+			) {
+				// we only refund relayer if all calls have updated chain state
+				log::trace!(
+					target: "runtime::bridge",
+					"{} via {:?}: relayer {:?} has submitted invalid relay chain finality proof",
+					Self::Id::STR,
+					<Self::Msgs as RefundableMessagesLaneId>::Id::get(),
+					relayer,
+				);
+				return false
+			}
+
+			// there's a conflict between how bridge GRANDPA pallet works and a `utility.batchAll`
+			// transaction. If relay chain header is mandatory, the GRANDPA pallet returns
+			// `Pays::No`, because such transaction is mandatory for operating the bridge. But
+			// `utility.batchAll` transaction always requires payment. But in both cases we'll
+			// refund relayer - either explicitly here, or using `Pays::No` if he's choosing
+			// to submit dedicated transaction.
+
+			// submitter has means to include extra weight/bytes in the `submit_finality_proof`
+			// call, so let's subtract extra weight/size to avoid refunding for this extra stuff
+			*extra_weight = (*extra_weight).saturating_add(finality_proof_info.extra_weight);
+			*extra_size = (*extra_size).saturating_add(finality_proof_info.extra_size);
+		}
+
+		true
+	}
+}
+
+/// Transaction extension that refunds a relayer for standalone messages delivery and confirmation
+/// transactions. Finality transactions are not refunded.
+#[derive(
+	DefaultNoBound,
+	CloneNoBound,
+	Decode,
+	Encode,
+	EqNoBound,
+	PartialEqNoBound,
+	RuntimeDebugNoBound,
+	TypeInfo,
+)]
+#[scale_info(skip_type_params(Runtime, GrandpaInstance, Msgs, Refund, Priority, Id))]
+pub struct RefundBridgedMessages<Runtime, Msgs, Refund, Priority, Id>(
+	PhantomData<(
+		// runtime with `pallet-bridge-messages` and `pallet-bridge-relayers` pallets deployed
+		Runtime,
+		// implementation of `RefundableMessagesLaneId` trait, which specifies the instance of
+		// the used `pallet-bridge-messages` pallet and the lane within this pallet
+		Msgs,
+		// implementation of the `RefundCalculator` trait, that is used to compute refund that
+		// we give to relayer for his transaction
+		Refund,
+		// getter for per-message `TransactionPriority` boost that we give to message
+		// delivery transactions
+		Priority,
+		// the runtime-unique identifier of this signed extension
+		Id,
+	)>,
+);
+
+impl<Runtime, Msgs, Refund, Priority, Id> RefundSignedExtension
+	for RefundBridgedMessages<Runtime, Msgs, Refund, Priority, Id>
+where
+	Self: 'static + Send + Sync,
+	Runtime: MessagesConfig<Msgs::Instance> + RelayersConfig,
+	Msgs: RefundableMessagesLaneId,
+	Refund: RefundCalculator<Balance = Runtime::Reward>,
+	Priority: Get<TransactionPriority>,
+	Id: StaticStrProvider,
+	CallOf<Runtime>: Dispatchable<Info = DispatchInfo, PostInfo = PostDispatchInfo>
+		+ MessagesCallSubType<Runtime, Msgs::Instance>,
+{
+	type Runtime = Runtime;
+	type Msgs = Msgs;
+	type Refund = Refund;
+	type Priority = Priority;
+	type Id = Id;
+
+	fn expand_call(call: &CallOf<Runtime>) -> Vec<&CallOf<Runtime>> {
+		vec![call]
+	}
+
+	fn parse_and_check_for_obsolete_call(
+		call: &CallOf<Runtime>,
+	) -> Result<Option<CallInfo>, TransactionValidityError> {
+		let call = Self::check_obsolete_parsed_call(call)?;
+		Ok(call.call_info_for(Msgs::Id::get()).map(CallInfo::Msgs))
+	}
+
+	fn check_obsolete_parsed_call(
+		call: &CallOf<Runtime>,
+	) -> Result<&CallOf<Runtime>, TransactionValidityError> {
+		call.check_obsolete_call()?;
+		Ok(call)
+	}
+
+	fn additional_call_result_check(
+		_relayer: &Runtime::AccountId,
+		_call_info: &CallInfo,
+		_extra_weight: &mut Weight,
+		_extra_size: &mut u32,
+	) -> bool {
+		// everything is checked by the `RefundTransactionExtension`
 		true
 	}
 }
 
 #[cfg(test)]
-mod tests {
+pub(crate) mod tests {
 	use super::*;
 	use crate::{
 		messages::{
@@ -854,6 +944,7 @@ mod tests {
 		},
 		mock::*,
 	};
+	use bp_header_chain::StoredHeaderDataBuilder;
 	use bp_messages::{
 		DeliveredMessages, InboundLaneData, MessageNonce, MessagesOperatingMode, OutboundLaneData,
 		UnrewardedRelayer, UnrewardedRelayersState,
@@ -879,7 +970,6 @@ mod tests {
 	};
 
 	parameter_types! {
-		TestParachain: u32 = 1000;
 		pub TestLaneId: LaneId = TEST_LANE_ID;
 		pub MsgProofsRewardsAccount: RewardsAccountParams = RewardsAccountParams::new(
 			TEST_LANE_ID,
@@ -895,6 +985,14 @@ mod tests {
 
 	bp_runtime::generate_static_str_provider!(TestExtension);
 
+	type TestMessagesExtensionProvider = RefundBridgedMessages<
+		TestRuntime,
+		RefundableMessagesLane<(), TestLaneId>,
+		ActualFeeRefund<TestRuntime>,
+		ConstU64<1>,
+		StrTestExtension,
+	>;
+	type TestMessagesExtension = RefundSignedExtensionAdapter<TestMessagesExtensionProvider>;
 	type TestGrandpaExtensionProvider = RefundBridgedGrandpaMessages<
 		TestRuntime,
 		(),
@@ -906,7 +1004,7 @@ mod tests {
 	type TestGrandpaExtension = RefundSignedExtensionAdapter<TestGrandpaExtensionProvider>;
 	type TestExtensionProvider = RefundBridgedParachainMessages<
 		TestRuntime,
-		DefaultRefundableParachainId<(), TestParachain>,
+		RefundableParachain<(), BridgedUnderlyingParachain>,
 		RefundableMessagesLane<(), TestLaneId>,
 		ActualFeeRefund<TestRuntime>,
 		ConstU64<1>,
@@ -930,7 +1028,7 @@ mod tests {
 		TestPaymentProcedure::rewards_account(MsgDeliveryProofsRewardsAccount::get())
 	}
 
-	fn relayer_account_at_this_chain() -> ThisChainAccountId {
+	pub fn relayer_account_at_this_chain() -> ThisChainAccountId {
 		0
 	}
 
@@ -938,7 +1036,7 @@ mod tests {
 		0
 	}
 
-	fn initialize_environment(
+	pub fn initialize_environment(
 		best_relay_header_number: RelayBlockNumber,
 		parachain_head_at_relay_header_number: RelayBlockNumber,
 		best_message: MessageNonce,
@@ -949,8 +1047,12 @@ mod tests {
 			StoredAuthoritySet::try_new(authorities, TEST_GRANDPA_SET_ID).unwrap(),
 		);
 		pallet_bridge_grandpa::BestFinalized::<TestRuntime>::put(best_relay_header);
+		pallet_bridge_grandpa::ImportedHeaders::<TestRuntime>::insert(
+			best_relay_header.hash(),
+			bp_test_utils::test_header::<BridgedChainHeader>(0).build(),
+		);
 
-		let para_id = ParaId(TestParachain::get());
+		let para_id = ParaId(BridgedUnderlyingParachain::PARACHAIN_ID);
 		let para_info = ParaInfo {
 			best_head_hash: BestParaHeadHash {
 				at_relay_block_number: parachain_head_at_relay_header_number,
@@ -994,7 +1096,7 @@ mod tests {
 		})
 	}
 
-	fn submit_relay_header_call_ex(relay_header_number: RelayBlockNumber) -> RuntimeCall {
+	pub fn submit_relay_header_call_ex(relay_header_number: RelayBlockNumber) -> RuntimeCall {
 		let relay_header = BridgedChainHeader::new(
 			relay_header_number,
 			Default::default(),
@@ -1008,6 +1110,7 @@ mod tests {
 			finality_target: Box::new(relay_header),
 			justification: relay_justification,
 			current_set_id: TEST_GRANDPA_SET_ID,
+			is_free_execution_expected: false,
 		})
 	}
 
@@ -1017,10 +1120,24 @@ mod tests {
 		RuntimeCall::BridgeParachains(ParachainsCall::submit_parachain_heads {
 			at_relay_block: (parachain_head_at_relay_header_number, RelayBlockHash::default()),
 			parachains: vec![(
-				ParaId(TestParachain::get()),
+				ParaId(BridgedUnderlyingParachain::PARACHAIN_ID),
+				[parachain_head_at_relay_header_number as u8; 32].into(),
+			)],
+			parachain_heads_proof: ParaHeadsProof { storage_proof: vec![] },
+		})
+	}
+
+	pub fn submit_parachain_head_call_ex(
+		parachain_head_at_relay_header_number: RelayBlockNumber,
+	) -> RuntimeCall {
+		RuntimeCall::BridgeParachains(ParachainsCall::submit_parachain_heads_ex {
+			at_relay_block: (parachain_head_at_relay_header_number, RelayBlockHash::default()),
+			parachains: vec![(
+				ParaId(BridgedUnderlyingParachain::PARACHAIN_ID),
 				[parachain_head_at_relay_header_number as u8; 32].into(),
 			)],
 			parachain_heads_proof: ParaHeadsProof { storage_proof: vec![] },
+			is_free_execution_expected: false,
 		})
 	}
 
@@ -1151,7 +1268,7 @@ mod tests {
 		RuntimeCall::Utility(UtilityCall::batch_all {
 			calls: vec![
 				submit_relay_header_call_ex(relay_header_number),
-				submit_parachain_head_call(parachain_head_at_relay_header_number),
+				submit_parachain_head_call_ex(parachain_head_at_relay_header_number),
 				message_delivery_call(best_message),
 			],
 		})
@@ -1179,7 +1296,7 @@ mod tests {
 		RuntimeCall::Utility(UtilityCall::batch_all {
 			calls: vec![
 				submit_relay_header_call_ex(relay_header_number),
-				submit_parachain_head_call(parachain_head_at_relay_header_number),
+				submit_parachain_head_call_ex(parachain_head_at_relay_header_number),
 				message_confirmation_call(best_message),
 			],
 		})
@@ -1194,11 +1311,14 @@ mod tests {
 					current_set_id: None,
 					extra_weight: Weight::zero(),
 					extra_size: 0,
+					is_mandatory: false,
+					is_free_execution_expected: false,
 				},
 				SubmitParachainHeadsInfo {
-					at_relay_block_number: 200,
-					para_id: ParaId(TestParachain::get()),
+					at_relay_block: HeaderId(200, [0u8; 32].into()),
+					para_id: ParaId(BridgedUnderlyingParachain::PARACHAIN_ID),
 					para_head_hash: [200u8; 32].into(),
+					is_free_execution_expected: false,
 				},
 				MessagesCallInfo::ReceiveMessagesProof(ReceiveMessagesProofInfo {
 					base: BaseMessagesProofInfo {
@@ -1231,11 +1351,14 @@ mod tests {
 					current_set_id: None,
 					extra_weight: Weight::zero(),
 					extra_size: 0,
+					is_mandatory: false,
+					is_free_execution_expected: false,
 				},
 				SubmitParachainHeadsInfo {
-					at_relay_block_number: 200,
-					para_id: ParaId(TestParachain::get()),
+					at_relay_block: HeaderId(200, [0u8; 32].into()),
+					para_id: ParaId(BridgedUnderlyingParachain::PARACHAIN_ID),
 					para_head_hash: [200u8; 32].into(),
+					is_free_execution_expected: false,
 				},
 				MessagesCallInfo::ReceiveMessagesDeliveryProof(ReceiveMessagesDeliveryProofInfo(
 					BaseMessagesProofInfo {
@@ -1264,6 +1387,8 @@ mod tests {
 					current_set_id: None,
 					extra_weight: Weight::zero(),
 					extra_size: 0,
+					is_mandatory: false,
+					is_free_execution_expected: false,
 				},
 				MessagesCallInfo::ReceiveMessagesProof(ReceiveMessagesProofInfo {
 					base: BaseMessagesProofInfo {
@@ -1296,6 +1421,8 @@ mod tests {
 					current_set_id: None,
 					extra_weight: Weight::zero(),
 					extra_size: 0,
+					is_mandatory: false,
+					is_free_execution_expected: false,
 				},
 				MessagesCallInfo::ReceiveMessagesDeliveryProof(ReceiveMessagesDeliveryProofInfo(
 					BaseMessagesProofInfo {
@@ -1320,9 +1447,10 @@ mod tests {
 			relayer: relayer_account_at_this_chain(),
 			call_info: CallInfo::ParachainFinalityAndMsgs(
 				SubmitParachainHeadsInfo {
-					at_relay_block_number: 200,
-					para_id: ParaId(TestParachain::get()),
+					at_relay_block: HeaderId(200, [0u8; 32].into()),
+					para_id: ParaId(BridgedUnderlyingParachain::PARACHAIN_ID),
 					para_head_hash: [200u8; 32].into(),
+					is_free_execution_expected: false,
 				},
 				MessagesCallInfo::ReceiveMessagesProof(ReceiveMessagesProofInfo {
 					base: BaseMessagesProofInfo {
@@ -1344,9 +1472,10 @@ mod tests {
 			relayer: relayer_account_at_this_chain(),
 			call_info: CallInfo::ParachainFinalityAndMsgs(
 				SubmitParachainHeadsInfo {
-					at_relay_block_number: 200,
-					para_id: ParaId(TestParachain::get()),
+					at_relay_block: HeaderId(200, [0u8; 32].into()),
+					para_id: ParaId(BridgedUnderlyingParachain::PARACHAIN_ID),
 					para_head_hash: [200u8; 32].into(),
+					is_free_execution_expected: false,
 				},
 				MessagesCallInfo::ReceiveMessagesDeliveryProof(ReceiveMessagesDeliveryProofInfo(
 					BaseMessagesProofInfo {
@@ -1421,8 +1550,14 @@ mod tests {
 		extension.validate(&relayer_account_at_this_chain(), &call, &DispatchInfo::default(), 0)
 	}
 
-	fn run_validate_ignore_priority(call: RuntimeCall) -> TransactionValidity {
-		run_validate(call).map(|mut tx| {
+	fn run_messages_validate(call: RuntimeCall) -> TransactionValidity {
+		let extension: TestMessagesExtension =
+			RefundSignedExtensionAdapter(RefundBridgedMessages(PhantomData));
+		extension.validate(&relayer_account_at_this_chain(), &call, &DispatchInfo::default(), 0)
+	}
+
+	fn ignore_priority(tx: TransactionValidity) -> TransactionValidity {
+		tx.map(|mut tx| {
 			tx.priority = 0;
 			tx
 		})
@@ -1444,6 +1579,14 @@ mod tests {
 		extension.pre_dispatch(&relayer_account_at_this_chain(), &call, &DispatchInfo::default(), 0)
 	}
 
+	fn run_messages_pre_dispatch(
+		call: RuntimeCall,
+	) -> Result<Option<PreDispatchData<ThisChainAccountId>>, TransactionValidityError> {
+		let extension: TestMessagesExtension =
+			RefundSignedExtensionAdapter(RefundBridgedMessages(PhantomData));
+		extension.pre_dispatch(&relayer_account_at_this_chain(), &call, &DispatchInfo::default(), 0)
+	}
+
 	fn dispatch_info() -> DispatchInfo {
 		DispatchInfo {
 			weight: Weight::from_parts(
@@ -1502,40 +1645,48 @@ mod tests {
 			Balances::set_balance(&relayer_account_at_this_chain(), ExistentialDeposit::get());
 
 			// message delivery is failing
-			assert_eq!(run_validate(message_delivery_call(200)), Ok(Default::default()),);
-			assert_eq!(
-				run_validate(parachain_finality_and_delivery_batch_call(200, 200)),
-				Ok(Default::default()),
-			);
-			assert_eq!(
-				run_validate(all_finality_and_delivery_batch_call(200, 200, 200)),
-				Ok(Default::default()),
-			);
+			let fns = [run_validate, run_grandpa_validate, run_messages_validate];
+			for f in fns {
+				assert_eq!(f(message_delivery_call(200)), Ok(Default::default()),);
+				assert_eq!(
+					f(parachain_finality_and_delivery_batch_call(200, 200)),
+					Ok(Default::default()),
+				);
+				assert_eq!(
+					f(all_finality_and_delivery_batch_call(200, 200, 200)),
+					Ok(Default::default()),
+				);
+				assert_eq!(
+					f(all_finality_and_delivery_batch_call_ex(200, 200, 200)),
+					Ok(Default::default()),
+				);
+			}
+
+			// message confirmation validation is passing
 			assert_eq!(
-				run_validate(all_finality_and_delivery_batch_call_ex(200, 200, 200)),
+				ignore_priority(run_validate(message_confirmation_call(200))),
 				Ok(Default::default()),
 			);
-			// message confirmation validation is passing
 			assert_eq!(
-				run_validate_ignore_priority(message_confirmation_call(200)),
+				ignore_priority(run_messages_validate(message_confirmation_call(200))),
 				Ok(Default::default()),
 			);
 			assert_eq!(
-				run_validate_ignore_priority(parachain_finality_and_confirmation_batch_call(
+				ignore_priority(run_validate(parachain_finality_and_confirmation_batch_call(
 					200, 200
-				)),
+				))),
 				Ok(Default::default()),
 			);
 			assert_eq!(
-				run_validate_ignore_priority(all_finality_and_confirmation_batch_call(
+				ignore_priority(run_validate(all_finality_and_confirmation_batch_call(
 					200, 200, 200
-				)),
+				))),
 				Ok(Default::default()),
 			);
 			assert_eq!(
-				run_validate_ignore_priority(all_finality_and_confirmation_batch_call_ex(
+				ignore_priority(run_validate(all_finality_and_confirmation_batch_call_ex(
 					200, 200, 200
-				)),
+				))),
 				Ok(Default::default()),
 			);
 		});
@@ -1549,25 +1700,28 @@ mod tests {
 			BridgeRelayers::register(RuntimeOrigin::signed(relayer_account_at_this_chain()), 1000)
 				.unwrap();
 
-			let priority_of_100_messages_delivery =
-				run_validate(message_delivery_call(200)).unwrap().priority;
-			let priority_of_200_messages_delivery =
-				run_validate(message_delivery_call(300)).unwrap().priority;
-			assert!(
-				priority_of_200_messages_delivery > priority_of_100_messages_delivery,
-				"Invalid priorities: {} for 200 messages vs {} for 100 messages",
-				priority_of_200_messages_delivery,
-				priority_of_100_messages_delivery,
-			);
+			let fns = [run_validate, run_grandpa_validate, run_messages_validate];
+			for f in fns {
+				let priority_of_100_messages_delivery =
+					f(message_delivery_call(200)).unwrap().priority;
+				let priority_of_200_messages_delivery =
+					f(message_delivery_call(300)).unwrap().priority;
+				assert!(
+					priority_of_200_messages_delivery > priority_of_100_messages_delivery,
+					"Invalid priorities: {} for 200 messages vs {} for 100 messages",
+					priority_of_200_messages_delivery,
+					priority_of_100_messages_delivery,
+				);
 
-			let priority_of_100_messages_confirmation =
-				run_validate(message_confirmation_call(200)).unwrap().priority;
-			let priority_of_200_messages_confirmation =
-				run_validate(message_confirmation_call(300)).unwrap().priority;
-			assert_eq!(
-				priority_of_100_messages_confirmation,
-				priority_of_200_messages_confirmation
-			);
+				let priority_of_100_messages_confirmation =
+					f(message_confirmation_call(200)).unwrap().priority;
+				let priority_of_200_messages_confirmation =
+					f(message_confirmation_call(300)).unwrap().priority;
+				assert_eq!(
+					priority_of_100_messages_confirmation,
+					priority_of_200_messages_confirmation
+				);
+			}
 		});
 	}
 
@@ -1579,23 +1733,24 @@ mod tests {
 			BridgeRelayers::register(RuntimeOrigin::signed(relayer_account_at_this_chain()), 1000)
 				.unwrap();
 
-			let priority_of_max_messages_delivery = run_validate(message_delivery_call(
-				100 + MaxUnconfirmedMessagesAtInboundLane::get(),
-			))
-			.unwrap()
-			.priority;
-			let priority_of_more_than_max_messages_delivery = run_validate(message_delivery_call(
-				100 + MaxUnconfirmedMessagesAtInboundLane::get() + 1,
-			))
-			.unwrap()
-			.priority;
-
-			assert!(
-				priority_of_max_messages_delivery > priority_of_more_than_max_messages_delivery,
-				"Invalid priorities: {} for MAX messages vs {} for MAX+1 messages",
-				priority_of_max_messages_delivery,
-				priority_of_more_than_max_messages_delivery,
-			);
+			let fns = [run_validate, run_grandpa_validate, run_messages_validate];
+			for f in fns {
+				let priority_of_max_messages_delivery =
+					f(message_delivery_call(100 + MaxUnconfirmedMessagesAtInboundLane::get()))
+						.unwrap()
+						.priority;
+				let priority_of_more_than_max_messages_delivery =
+					f(message_delivery_call(100 + MaxUnconfirmedMessagesAtInboundLane::get() + 1))
+						.unwrap()
+						.priority;
+
+				assert!(
+					priority_of_max_messages_delivery > priority_of_more_than_max_messages_delivery,
+					"Invalid priorities: {} for MAX messages vs {} for MAX+1 messages",
+					priority_of_max_messages_delivery,
+					priority_of_more_than_max_messages_delivery,
+				);
+			}
 		});
 	}
 
@@ -1605,45 +1760,54 @@ mod tests {
 			initialize_environment(100, 100, 100);
 
 			assert_eq!(
-				run_validate_ignore_priority(message_delivery_call(200)),
+				ignore_priority(run_validate(message_delivery_call(200))),
+				Ok(ValidTransaction::default()),
+			);
+			assert_eq!(
+				ignore_priority(run_validate(message_confirmation_call(200))),
+				Ok(ValidTransaction::default()),
+			);
+
+			assert_eq!(
+				ignore_priority(run_messages_validate(message_delivery_call(200))),
 				Ok(ValidTransaction::default()),
 			);
 			assert_eq!(
-				run_validate_ignore_priority(message_confirmation_call(200)),
+				ignore_priority(run_messages_validate(message_confirmation_call(200))),
 				Ok(ValidTransaction::default()),
 			);
 
 			assert_eq!(
-				run_validate_ignore_priority(parachain_finality_and_delivery_batch_call(200, 200)),
+				ignore_priority(run_validate(parachain_finality_and_delivery_batch_call(200, 200))),
 				Ok(ValidTransaction::default()),
 			);
 			assert_eq!(
-				run_validate_ignore_priority(parachain_finality_and_confirmation_batch_call(
+				ignore_priority(run_validate(parachain_finality_and_confirmation_batch_call(
 					200, 200
-				)),
+				))),
 				Ok(ValidTransaction::default()),
 			);
 
 			assert_eq!(
-				run_validate_ignore_priority(all_finality_and_delivery_batch_call(200, 200, 200)),
+				ignore_priority(run_validate(all_finality_and_delivery_batch_call(200, 200, 200))),
 				Ok(ValidTransaction::default()),
 			);
 			assert_eq!(
-				run_validate_ignore_priority(all_finality_and_delivery_batch_call_ex(
+				ignore_priority(run_validate(all_finality_and_delivery_batch_call_ex(
 					200, 200, 200
-				)),
+				))),
 				Ok(ValidTransaction::default()),
 			);
 			assert_eq!(
-				run_validate_ignore_priority(all_finality_and_confirmation_batch_call(
+				ignore_priority(run_validate(all_finality_and_confirmation_batch_call(
 					200, 200, 200
-				)),
+				))),
 				Ok(ValidTransaction::default()),
 			);
 			assert_eq!(
-				run_validate_ignore_priority(all_finality_and_confirmation_batch_call_ex(
+				ignore_priority(run_validate(all_finality_and_confirmation_batch_call_ex(
 					200, 200, 200
-				)),
+				))),
 				Ok(ValidTransaction::default()),
 			);
 		});
@@ -1933,8 +2097,11 @@ mod tests {
 					RuntimeCall::BridgeParachains(ParachainsCall::submit_parachain_heads {
 						at_relay_block: (100, RelayBlockHash::default()),
 						parachains: vec![
-							(ParaId(TestParachain::get()), [1u8; 32].into()),
-							(ParaId(TestParachain::get() + 1), [1u8; 32].into()),
+							(ParaId(BridgedUnderlyingParachain::PARACHAIN_ID), [1u8; 32].into()),
+							(
+								ParaId(BridgedUnderlyingParachain::PARACHAIN_ID + 1),
+								[1u8; 32].into(),
+							),
 						],
 						parachain_heads_proof: ParaHeadsProof { storage_proof: vec![] },
 					}),
@@ -2318,6 +2485,148 @@ mod tests {
 		});
 	}
 
+	#[test]
+	fn messages_ext_only_parses_standalone_transactions() {
+		run_test(|| {
+			initialize_environment(100, 100, 100);
+
+			// relay + parachain + message delivery calls batch is ignored
+			assert_eq!(
+				TestMessagesExtensionProvider::parse_and_check_for_obsolete_call(
+					&all_finality_and_delivery_batch_call(200, 200, 200)
+				),
+				Ok(None),
+			);
+			assert_eq!(
+				TestMessagesExtensionProvider::parse_and_check_for_obsolete_call(
+					&all_finality_and_delivery_batch_call_ex(200, 200, 200)
+				),
+				Ok(None),
+			);
+
+			// relay + parachain + message confirmation calls batch is ignored
+			assert_eq!(
+				TestMessagesExtensionProvider::parse_and_check_for_obsolete_call(
+					&all_finality_and_confirmation_batch_call(200, 200, 200)
+				),
+				Ok(None),
+			);
+			assert_eq!(
+				TestMessagesExtensionProvider::parse_and_check_for_obsolete_call(
+					&all_finality_and_confirmation_batch_call_ex(200, 200, 200)
+				),
+				Ok(None),
+			);
+
+			// parachain + message delivery call batch is ignored
+			assert_eq!(
+				TestMessagesExtensionProvider::parse_and_check_for_obsolete_call(
+					&parachain_finality_and_delivery_batch_call(200, 200)
+				),
+				Ok(None),
+			);
+
+			// parachain + message confirmation call batch is ignored
+			assert_eq!(
+				TestMessagesExtensionProvider::parse_and_check_for_obsolete_call(
+					&parachain_finality_and_confirmation_batch_call(200, 200)
+				),
+				Ok(None),
+			);
+
+			// relay + message delivery call batch is ignored
+			assert_eq!(
+				TestMessagesExtensionProvider::parse_and_check_for_obsolete_call(
+					&relay_finality_and_delivery_batch_call(200, 200)
+				),
+				Ok(None),
+			);
+			assert_eq!(
+				TestMessagesExtensionProvider::parse_and_check_for_obsolete_call(
+					&relay_finality_and_delivery_batch_call_ex(200, 200)
+				),
+				Ok(None),
+			);
+
+			// relay + message confirmation call batch is ignored
+			assert_eq!(
+				TestMessagesExtensionProvider::parse_and_check_for_obsolete_call(
+					&relay_finality_and_confirmation_batch_call(200, 200)
+				),
+				Ok(None),
+			);
+			assert_eq!(
+				TestMessagesExtensionProvider::parse_and_check_for_obsolete_call(
+					&relay_finality_and_confirmation_batch_call_ex(200, 200)
+				),
+				Ok(None),
+			);
+
+			// message delivery call batch is accepted
+			assert_eq!(
+				TestMessagesExtensionProvider::parse_and_check_for_obsolete_call(
+					&message_delivery_call(200)
+				),
+				Ok(Some(delivery_pre_dispatch_data().call_info)),
+			);
+
+			// message confirmation call batch is accepted
+			assert_eq!(
+				TestMessagesExtensionProvider::parse_and_check_for_obsolete_call(
+					&message_confirmation_call(200)
+				),
+				Ok(Some(confirmation_pre_dispatch_data().call_info)),
+			);
+		});
+	}
+
+	#[test]
+	fn messages_ext_rejects_calls_with_obsolete_messages() {
+		run_test(|| {
+			initialize_environment(100, 100, 100);
+
+			assert_eq!(
+				run_messages_pre_dispatch(message_delivery_call(100)),
+				Err(TransactionValidityError::Invalid(InvalidTransaction::Stale)),
+			);
+			assert_eq!(
+				run_messages_pre_dispatch(message_confirmation_call(100)),
+				Err(TransactionValidityError::Invalid(InvalidTransaction::Stale)),
+			);
+
+			assert_eq!(
+				run_messages_validate(message_delivery_call(100)),
+				Err(TransactionValidityError::Invalid(InvalidTransaction::Stale)),
+			);
+			assert_eq!(
+				run_messages_validate(message_confirmation_call(100)),
+				Err(TransactionValidityError::Invalid(InvalidTransaction::Stale)),
+			);
+		});
+	}
+
+	#[test]
+	fn messages_ext_accepts_calls_with_new_messages() {
+		run_test(|| {
+			initialize_environment(100, 100, 100);
+
+			assert_eq!(
+				run_messages_pre_dispatch(message_delivery_call(200)),
+				Ok(Some(delivery_pre_dispatch_data())),
+			);
+			assert_eq!(
+				run_messages_pre_dispatch(message_confirmation_call(200)),
+				Ok(Some(confirmation_pre_dispatch_data())),
+			);
+
+			assert_eq!(run_messages_validate(message_delivery_call(200)), Ok(Default::default()),);
+			assert_eq!(
+				run_messages_validate(message_confirmation_call(200)),
+				Ok(Default::default()),
+			);
+		});
+	}
+
 	#[test]
 	fn grandpa_ext_only_parses_valid_batches() {
 		run_test(|| {
diff --git a/bridges/bin/runtime-common/src/mock.rs b/bridges/bin/runtime-common/src/mock.rs
index ad71cd0d456d827d3757433d214f7ea794406fca..e323f1edfc71da8c84fe8cabb977da85ce4d303e 100644
--- a/bridges/bin/runtime-common/src/mock.rs
+++ b/bridges/bin/runtime-common/src/mock.rs
@@ -183,7 +183,8 @@ impl pallet_transaction_payment::Config for TestRuntime {
 impl pallet_bridge_grandpa::Config for TestRuntime {
 	type RuntimeEvent = RuntimeEvent;
 	type BridgedChain = BridgedUnderlyingChain;
-	type MaxFreeMandatoryHeadersPerBlock = ConstU32<4>;
+	type MaxFreeHeadersPerBlock = ConstU32<4>;
+	type FreeHeadersInterval = ConstU32<1_024>;
 	type HeadersToKeep = ConstU32<8>;
 	type WeightInfo = pallet_bridge_grandpa::weights::BridgeWeight<TestRuntime>;
 }
@@ -406,6 +407,7 @@ impl Chain for BridgedUnderlyingParachain {
 
 impl Parachain for BridgedUnderlyingParachain {
 	const PARACHAIN_ID: u32 = 42;
+	const MAX_HEADER_SIZE: u32 = 1_024;
 }
 
 /// The other, bridged chain, used in tests.
diff --git a/bridges/chains/chain-bridge-hub-cumulus/src/lib.rs b/bridges/chains/chain-bridge-hub-cumulus/src/lib.rs
index c49aa4b856397d28746d017fd8333ae3ad10655e..a5c90ceba111e0c8a095f7e96e6d4a8dba92d183 100644
--- a/bridges/chains/chain-bridge-hub-cumulus/src/lib.rs
+++ b/bridges/chains/chain-bridge-hub-cumulus/src/lib.rs
@@ -39,6 +39,9 @@ use frame_support::{
 use frame_system::limits;
 use sp_std::time::Duration;
 
+/// Maximal bridge hub header size.
+pub const MAX_BRIDGE_HUB_HEADER_SIZE: u32 = 4_096;
+
 /// Average block interval in Cumulus-based parachains.
 ///
 /// Corresponds to the `MILLISECS_PER_BLOCK` from `parachains_common` crate.
diff --git a/bridges/chains/chain-bridge-hub-kusama/src/lib.rs b/bridges/chains/chain-bridge-hub-kusama/src/lib.rs
index 576e3dbee80d0babbdb7c0bbdfc420c5a636b68b..ef3ef4ab7b7a9bc111218e3c53091ac232f34721 100644
--- a/bridges/chains/chain-bridge-hub-kusama/src/lib.rs
+++ b/bridges/chains/chain-bridge-hub-kusama/src/lib.rs
@@ -62,6 +62,7 @@ impl Chain for BridgeHubKusama {
 
 impl Parachain for BridgeHubKusama {
 	const PARACHAIN_ID: u32 = BRIDGE_HUB_KUSAMA_PARACHAIN_ID;
+	const MAX_HEADER_SIZE: u32 = MAX_BRIDGE_HUB_HEADER_SIZE;
 }
 
 impl ChainWithMessages for BridgeHubKusama {
diff --git a/bridges/chains/chain-bridge-hub-polkadot/src/lib.rs b/bridges/chains/chain-bridge-hub-polkadot/src/lib.rs
index 6db389c92994d74fb0d8176509cd81d64b806df2..9db71af928e5df01170cf4ab8bf5f20cd72f7610 100644
--- a/bridges/chains/chain-bridge-hub-polkadot/src/lib.rs
+++ b/bridges/chains/chain-bridge-hub-polkadot/src/lib.rs
@@ -59,6 +59,7 @@ impl Chain for BridgeHubPolkadot {
 
 impl Parachain for BridgeHubPolkadot {
 	const PARACHAIN_ID: u32 = BRIDGE_HUB_POLKADOT_PARACHAIN_ID;
+	const MAX_HEADER_SIZE: u32 = MAX_BRIDGE_HUB_HEADER_SIZE;
 }
 
 impl ChainWithMessages for BridgeHubPolkadot {
diff --git a/bridges/chains/chain-bridge-hub-rococo/src/lib.rs b/bridges/chains/chain-bridge-hub-rococo/src/lib.rs
index abce872d7ba35cf24b013aa26b4b1f1d796b5785..d7097f01c5316a58851f400a86b98eda3d7e8bcc 100644
--- a/bridges/chains/chain-bridge-hub-rococo/src/lib.rs
+++ b/bridges/chains/chain-bridge-hub-rococo/src/lib.rs
@@ -59,6 +59,7 @@ impl Chain for BridgeHubRococo {
 
 impl Parachain for BridgeHubRococo {
 	const PARACHAIN_ID: u32 = BRIDGE_HUB_ROCOCO_PARACHAIN_ID;
+	const MAX_HEADER_SIZE: u32 = MAX_BRIDGE_HUB_HEADER_SIZE;
 }
 
 impl ChainWithMessages for BridgeHubRococo {
@@ -103,9 +104,9 @@ frame_support::parameter_types! {
 
 	/// Transaction fee that is paid at the Rococo BridgeHub for delivering single inbound message.
 	/// (initially was calculated by test `BridgeHubRococo::can_calculate_fee_for_complex_message_delivery_transaction` + `33%`)
-	pub const BridgeHubRococoBaseDeliveryFeeInRocs: u128 = 5_651_581_649;
+	pub const BridgeHubRococoBaseDeliveryFeeInRocs: u128 = 314_037_860;
 
 	/// Transaction fee that is paid at the Rococo BridgeHub for delivering single outbound message confirmation.
 	/// (initially was calculated by test `BridgeHubRococo::can_calculate_fee_for_complex_message_confirmation_transaction` + `33%`)
-	pub const BridgeHubRococoBaseConfirmationFeeInRocs: u128 = 5_380_901_781;
+	pub const BridgeHubRococoBaseConfirmationFeeInRocs: u128 = 57_414_813;
 }
diff --git a/bridges/chains/chain-bridge-hub-westend/src/lib.rs b/bridges/chains/chain-bridge-hub-westend/src/lib.rs
index 4af895cc6d328bdb350fa95b0e0a74f0cc731b04..800f290d7bfa41cec4139e80a7dc9ea8962a6da5 100644
--- a/bridges/chains/chain-bridge-hub-westend/src/lib.rs
+++ b/bridges/chains/chain-bridge-hub-westend/src/lib.rs
@@ -58,6 +58,7 @@ impl Chain for BridgeHubWestend {
 
 impl Parachain for BridgeHubWestend {
 	const PARACHAIN_ID: u32 = BRIDGE_HUB_WESTEND_PARACHAIN_ID;
+	const MAX_HEADER_SIZE: u32 = MAX_BRIDGE_HUB_HEADER_SIZE;
 }
 
 impl ChainWithMessages for BridgeHubWestend {
@@ -93,10 +94,10 @@ frame_support::parameter_types! {
 	pub const BridgeHubWestendBaseXcmFeeInWnds: u128 = 17_756_830_000;
 
 	/// Transaction fee that is paid at the Westend BridgeHub for delivering single inbound message.
-	/// (initially was calculated by test `BridgeHubWestend::can_calculate_fee_for_complex_message_delivery_transaction` + `33%`)
-	pub const BridgeHubWestendBaseDeliveryFeeInWnds: u128 = 1_695_489_961_344;
+	/// (initially was calculated by test `BridgeHubWestend::can_calculate_fee_for_standalone_message_delivery_transaction` + `33%`)
+	pub const BridgeHubWestendBaseDeliveryFeeInWnds: u128 = 94_211_536_452;
 
 	/// Transaction fee that is paid at the Westend BridgeHub for delivering single outbound message confirmation.
-	/// (initially was calculated by test `BridgeHubWestend::can_calculate_fee_for_complex_message_confirmation_transaction` + `33%`)
-	pub const BridgeHubWestendBaseConfirmationFeeInWnds: u128 = 1_618_309_961_344;
+	/// (initially was calculated by test `BridgeHubWestend::can_calculate_fee_for_standalone_message_confirmation_transaction` + `33%`)
+	pub const BridgeHubWestendBaseConfirmationFeeInWnds: u128 = 17_224_486_452;
 }
diff --git a/bridges/modules/grandpa/src/call_ext.rs b/bridges/modules/grandpa/src/call_ext.rs
index 4a7ebb3cc8d42d7cb9d97d5c6990bb33658416bd..6fa62ec0cff498e9c33cf3a1ccf7bc76c7b7279e 100644
--- a/bridges/modules/grandpa/src/call_ext.rs
+++ b/bridges/modules/grandpa/src/call_ext.rs
@@ -15,20 +15,24 @@
 // along with Parity Bridges Common.  If not, see <http://www.gnu.org/licenses/>.
 
 use crate::{
-	weights::WeightInfo, BridgedBlockNumber, BridgedHeader, Config, CurrentAuthoritySet, Error,
-	Pallet,
+	weights::WeightInfo, BestFinalized, BridgedBlockNumber, BridgedHeader, Config,
+	CurrentAuthoritySet, Error, FreeHeadersRemaining, Pallet,
 };
 use bp_header_chain::{
 	justification::GrandpaJustification, max_expected_submit_finality_proof_arguments_size,
 	ChainWithGrandpa, GrandpaConsensusLogReader,
 };
-use bp_runtime::{BlockNumberOf, OwnedBridgeModule};
+use bp_runtime::{BlockNumberOf, Chain, OwnedBridgeModule};
 use codec::Encode;
-use frame_support::{dispatch::CallableCallFor, traits::IsSubType, weights::Weight};
+use frame_support::{
+	dispatch::CallableCallFor,
+	traits::{Get, IsSubType},
+	weights::Weight,
+};
 use sp_consensus_grandpa::SetId;
 use sp_runtime::{
-	traits::{Header, Zero},
-	transaction_validity::{InvalidTransaction, TransactionValidity, ValidTransaction},
+	traits::{CheckedSub, Header, Zero},
+	transaction_validity::{InvalidTransaction, TransactionValidityError},
 	RuntimeDebug, SaturatedConversion,
 };
 
@@ -40,6 +44,11 @@ pub struct SubmitFinalityProofInfo<N> {
 	/// An identifier of the validators set that has signed the submitted justification.
 	/// It might be `None` if deprecated version of the `submit_finality_proof` is used.
 	pub current_set_id: Option<SetId>,
+	/// If `true`, then the call proves new **mandatory** header.
+	pub is_mandatory: bool,
+	/// If `true`, then the call must be free (assuming that everything else is valid) to
+	/// be treated as valid.
+	pub is_free_execution_expected: bool,
 	/// Extra weight that we assume is included in the call.
 	///
 	/// We have some assumptions about headers and justifications of the bridged chain.
@@ -54,6 +63,16 @@ pub struct SubmitFinalityProofInfo<N> {
 	pub extra_size: u32,
 }
 
+/// Verified `SubmitFinalityProofInfo<N>`.
+#[derive(Copy, Clone, PartialEq, RuntimeDebug)]
+pub struct VerifiedSubmitFinalityProofInfo<N> {
+	/// Base call information.
+	pub base: SubmitFinalityProofInfo<N>,
+	/// A difference between bundled bridged header and best bridged header known to us
+	/// before the call.
+	pub improved_by: N,
+}
+
 impl<N> SubmitFinalityProofInfo<N> {
 	/// Returns `true` if call size/weight is below our estimations for regular calls.
 	pub fn fits_limits(&self) -> bool {
@@ -67,14 +86,86 @@ pub struct SubmitFinalityProofHelper<T: Config<I>, I: 'static> {
 }
 
 impl<T: Config<I>, I: 'static> SubmitFinalityProofHelper<T, I> {
+	/// Returns `true` if we may fit more free headers into the current block. If `false` is
+	/// returned, the call will be paid even if `is_free_execution_expected` has been set
+	/// to `true`.
+	pub fn has_free_header_slots() -> bool {
+		// `unwrap_or(u32::MAX)` means that if `FreeHeadersRemaining` is `None`, we may accept
+		// this header for free. That is a small cheat - it is `None` if executed outside of
+		// transaction (e.g. during block initialization). Normal relayer would never submit
+		// such calls, but if he did, that is not our problem. During normal transactions,
+		// the `FreeHeadersRemaining` is always `Some(_)`.
+		let free_headers_remaining = FreeHeadersRemaining::<T, I>::get().unwrap_or(u32::MAX);
+		free_headers_remaining > 0
+	}
+
+	/// Check that the: (1) GRANDPA head provided by the `SubmitFinalityProof` is better than the
+	/// best one we know (2) if `current_set_id` matches the current authority set id, if specified
+	/// and (3) whether transaction MAY be free for the submitter if `is_free_execution_expected`
+	/// is `true`.
+	///
+	/// Returns number of headers between the current best finalized header, known to the pallet
+	/// and the bundled header.
+	pub fn check_obsolete_from_extension(
+		call_info: &SubmitFinalityProofInfo<BlockNumberOf<T::BridgedChain>>,
+	) -> Result<BlockNumberOf<T::BridgedChain>, Error<T, I>> {
+		// do basic checks first
+		let improved_by = Self::check_obsolete(call_info.block_number, call_info.current_set_id)?;
+
+		// if submitter has NOT specified that it wants free execution, then we are done
+		if !call_info.is_free_execution_expected {
+			return Ok(improved_by);
+		}
+
+		// else - if we can not accept more free headers, "reject" the transaction
+		if !Self::has_free_header_slots() {
+			log::trace!(
+				target: crate::LOG_TARGET,
+				"Cannot accept free {:?} header {:?}. No more free slots remaining",
+				T::BridgedChain::ID,
+				call_info.block_number,
+			);
+
+			return Err(Error::<T, I>::FreeHeadersLimitExceded);
+		}
+
+		// ensure that the `improved_by` is larger than the configured free interval
+		if !call_info.is_mandatory {
+			if let Some(free_headers_interval) = T::FreeHeadersInterval::get() {
+				if improved_by < free_headers_interval.into() {
+					log::trace!(
+						target: crate::LOG_TARGET,
+						"Cannot accept free {:?} header {:?}. Too small difference \
+						between submitted headers: {:?} vs {}",
+						T::BridgedChain::ID,
+						call_info.block_number,
+						improved_by,
+						free_headers_interval,
+					);
+
+					return Err(Error::<T, I>::BelowFreeHeaderInterval);
+				}
+			}
+		}
+
+		// we do not check whether the header matches free submission criteria here - it is the
+		// relayer responsibility to check that
+
+		Ok(improved_by)
+	}
+
 	/// Check that the GRANDPA head provided by the `SubmitFinalityProof` is better than the best
 	/// one we know. Additionally, checks if `current_set_id` matches the current authority set
-	/// id, if specified.
+	/// id, if specified. This method is called by the call code and the transaction extension,
+	/// so it does not check the free execution.
+	///
+	/// Returns number of headers between the current best finalized header, known to the pallet
+	/// and the bundled header.
 	pub fn check_obsolete(
 		finality_target: BlockNumberOf<T::BridgedChain>,
 		current_set_id: Option<SetId>,
-	) -> Result<(), Error<T, I>> {
-		let best_finalized = crate::BestFinalized::<T, I>::get().ok_or_else(|| {
+	) -> Result<BlockNumberOf<T::BridgedChain>, Error<T, I>> {
+		let best_finalized = BestFinalized::<T, I>::get().ok_or_else(|| {
 			log::trace!(
 				target: crate::LOG_TARGET,
 				"Cannot finalize header {:?} because pallet is not yet initialized",
@@ -83,16 +174,19 @@ impl<T: Config<I>, I: 'static> SubmitFinalityProofHelper<T, I> {
 			<Error<T, I>>::NotInitialized
 		})?;
 
-		if best_finalized.number() >= finality_target {
-			log::trace!(
-				target: crate::LOG_TARGET,
-				"Cannot finalize obsolete header: bundled {:?}, best {:?}",
-				finality_target,
-				best_finalized,
-			);
+		let improved_by = match finality_target.checked_sub(&best_finalized.number()) {
+			Some(improved_by) if improved_by > Zero::zero() => improved_by,
+			_ => {
+				log::trace!(
+					target: crate::LOG_TARGET,
+					"Cannot finalize obsolete header: bundled {:?}, best {:?}",
+					finality_target,
+					best_finalized,
+				);
 
-			return Err(Error::<T, I>::OldHeader)
-		}
+				return Err(Error::<T, I>::OldHeader)
+			},
+		};
 
 		if let Some(current_set_id) = current_set_id {
 			let actual_set_id = <CurrentAuthoritySet<T, I>>::get().set_id;
@@ -108,12 +202,12 @@ impl<T: Config<I>, I: 'static> SubmitFinalityProofHelper<T, I> {
 			}
 		}
 
-		Ok(())
+		Ok(improved_by)
 	}
 
 	/// Check if the `SubmitFinalityProof` was successfully executed.
 	pub fn was_successful(finality_target: BlockNumberOf<T::BridgedChain>) -> bool {
-		match crate::BestFinalized::<T, I>::get() {
+		match BestFinalized::<T, I>::get() {
 			Some(best_finalized) => best_finalized.number() == finality_target,
 			None => false,
 		}
@@ -135,17 +229,20 @@ pub trait CallSubType<T: Config<I, RuntimeCall = Self>, I: 'static>:
 				finality_target,
 				justification,
 				None,
+				false,
 			))
 		} else if let Some(crate::Call::<T, I>::submit_finality_proof_ex {
 			finality_target,
 			justification,
 			current_set_id,
+			is_free_execution_expected,
 		}) = self.is_sub_type()
 		{
 			return Some(submit_finality_proof_info_from_args::<T, I>(
 				finality_target,
 				justification,
 				Some(*current_set_id),
+				*is_free_execution_expected,
 			))
 		}
 
@@ -155,26 +252,36 @@ pub trait CallSubType<T: Config<I, RuntimeCall = Self>, I: 'static>:
 	/// Validate Grandpa headers in order to avoid "mining" transactions that provide outdated
 	/// bridged chain headers. Without this validation, even honest relayers may lose their funds
 	/// if there are multiple relays running and submitting the same information.
-	fn check_obsolete_submit_finality_proof(&self) -> TransactionValidity
+	///
+	/// Returns `Ok(None)` if the call is not the `submit_finality_proof` call of our pallet.
+	/// Returns `Ok(Some(_))` if the call is the `submit_finality_proof` call of our pallet and
+	/// we believe the call brings header that improves the pallet state.
+	/// Returns `Err(_)` if the call is the `submit_finality_proof` call of our pallet and we
+	/// believe that the call will fail.
+	fn check_obsolete_submit_finality_proof(
+		&self,
+	) -> Result<
+		Option<VerifiedSubmitFinalityProofInfo<BridgedBlockNumber<T, I>>>,
+		TransactionValidityError,
+	>
 	where
 		Self: Sized,
 	{
-		let finality_target = match self.submit_finality_proof_info() {
+		let call_info = match self.submit_finality_proof_info() {
 			Some(finality_proof) => finality_proof,
-			_ => return Ok(ValidTransaction::default()),
+			_ => return Ok(None),
 		};
 
 		if Pallet::<T, I>::ensure_not_halted().is_err() {
-			return InvalidTransaction::Call.into()
+			return Err(InvalidTransaction::Call.into())
 		}
 
-		match SubmitFinalityProofHelper::<T, I>::check_obsolete(
-			finality_target.block_number,
-			finality_target.current_set_id,
-		) {
-			Ok(_) => Ok(ValidTransaction::default()),
-			Err(Error::<T, I>::OldHeader) => InvalidTransaction::Stale.into(),
-			Err(_) => InvalidTransaction::Call.into(),
+		let result = SubmitFinalityProofHelper::<T, I>::check_obsolete_from_extension(&call_info);
+		match result {
+			Ok(improved_by) =>
+				Ok(Some(VerifiedSubmitFinalityProofInfo { base: call_info, improved_by })),
+			Err(Error::<T, I>::OldHeader) => Err(InvalidTransaction::Stale.into()),
+			Err(_) => Err(InvalidTransaction::Call.into()),
 		}
 	}
 }
@@ -189,6 +296,7 @@ pub(crate) fn submit_finality_proof_info_from_args<T: Config<I>, I: 'static>(
 	finality_target: &BridgedHeader<T, I>,
 	justification: &GrandpaJustification<BridgedHeader<T, I>>,
 	current_set_id: Option<SetId>,
+	is_free_execution_expected: bool,
 ) -> SubmitFinalityProofInfo<BridgedBlockNumber<T, I>> {
 	let block_number = *finality_target.number();
 
@@ -230,16 +338,26 @@ pub(crate) fn submit_finality_proof_info_from_args<T: Config<I>, I: 'static>(
 	);
 	let extra_size = actual_call_size.saturating_sub(max_expected_call_size);
 
-	SubmitFinalityProofInfo { block_number, current_set_id, extra_weight, extra_size }
+	SubmitFinalityProofInfo {
+		block_number,
+		current_set_id,
+		is_mandatory: is_mandatory_finality_target,
+		is_free_execution_expected,
+		extra_weight,
+		extra_size,
+	}
 }
 
 #[cfg(test)]
 mod tests {
 	use crate::{
 		call_ext::CallSubType,
-		mock::{run_test, test_header, RuntimeCall, TestBridgedChain, TestNumber, TestRuntime},
-		BestFinalized, Config, CurrentAuthoritySet, PalletOperatingMode, StoredAuthoritySet,
-		SubmitFinalityProofInfo, WeightInfo,
+		mock::{
+			run_test, test_header, FreeHeadersInterval, RuntimeCall, TestBridgedChain, TestNumber,
+			TestRuntime,
+		},
+		BestFinalized, Config, CurrentAuthoritySet, FreeHeadersRemaining, PalletOperatingMode,
+		StoredAuthoritySet, SubmitFinalityProofInfo, WeightInfo,
 	};
 	use bp_header_chain::ChainWithGrandpa;
 	use bp_runtime::{BasicOperatingMode, HeaderId};
@@ -247,6 +365,7 @@ mod tests {
 		make_default_justification, make_justification_for_header, JustificationGeneratorParams,
 		TEST_GRANDPA_SET_ID,
 	};
+	use codec::Encode;
 	use frame_support::weights::Weight;
 	use sp_runtime::{testing::DigestItem, traits::Header as _, SaturatedConversion};
 
@@ -256,6 +375,7 @@ mod tests {
 			justification: make_default_justification(&test_header(num)),
 			// not initialized => zero
 			current_set_id: 0,
+			is_free_execution_expected: false,
 		};
 		RuntimeCall::check_obsolete_submit_finality_proof(&RuntimeCall::Grandpa(
 			bridge_grandpa_call,
@@ -311,6 +431,121 @@ mod tests {
 		});
 	}
 
+	#[test]
+	fn extension_rejects_new_header_if_free_execution_is_requested_and_free_submissions_are_not_accepted(
+	) {
+		run_test(|| {
+			let bridge_grandpa_call = crate::Call::<TestRuntime, ()>::submit_finality_proof_ex {
+				finality_target: Box::new(test_header(10 + FreeHeadersInterval::get() as u64)),
+				justification: make_default_justification(&test_header(
+					10 + FreeHeadersInterval::get() as u64,
+				)),
+				current_set_id: 0,
+				is_free_execution_expected: true,
+			};
+			sync_to_header_10();
+
+			// when we can accept free headers => Ok
+			FreeHeadersRemaining::<TestRuntime, ()>::put(2);
+			assert!(RuntimeCall::check_obsolete_submit_finality_proof(&RuntimeCall::Grandpa(
+				bridge_grandpa_call.clone(),
+			),)
+			.is_ok());
+
+			// when we can NOT accept free headers => Err
+			FreeHeadersRemaining::<TestRuntime, ()>::put(0);
+			assert!(RuntimeCall::check_obsolete_submit_finality_proof(&RuntimeCall::Grandpa(
+				bridge_grandpa_call.clone(),
+			),)
+			.is_err());
+
+			// when called outside of transaction => Ok
+			FreeHeadersRemaining::<TestRuntime, ()>::kill();
+			assert!(RuntimeCall::check_obsolete_submit_finality_proof(&RuntimeCall::Grandpa(
+				bridge_grandpa_call,
+			),)
+			.is_ok());
+		})
+	}
+
+	#[test]
+	fn extension_rejects_new_header_if_free_execution_is_requested_and_improved_by_is_below_expected(
+	) {
+		run_test(|| {
+			let bridge_grandpa_call = crate::Call::<TestRuntime, ()>::submit_finality_proof_ex {
+				finality_target: Box::new(test_header(100)),
+				justification: make_default_justification(&test_header(100)),
+				current_set_id: 0,
+				is_free_execution_expected: true,
+			};
+			sync_to_header_10();
+
+			// when `improved_by` is less than the free interval
+			BestFinalized::<TestRuntime, ()>::put(HeaderId(
+				100 - FreeHeadersInterval::get() as u64 + 1,
+				sp_core::H256::default(),
+			));
+			assert!(RuntimeCall::check_obsolete_submit_finality_proof(&RuntimeCall::Grandpa(
+				bridge_grandpa_call.clone(),
+			),)
+			.is_err());
+
+			// when `improved_by` is equal to the free interval
+			BestFinalized::<TestRuntime, ()>::put(HeaderId(
+				100 - FreeHeadersInterval::get() as u64,
+				sp_core::H256::default(),
+			));
+			assert!(RuntimeCall::check_obsolete_submit_finality_proof(&RuntimeCall::Grandpa(
+				bridge_grandpa_call.clone(),
+			),)
+			.is_ok());
+
+			// when `improved_by` is larger than the free interval
+			BestFinalized::<TestRuntime, ()>::put(HeaderId(
+				100 - FreeHeadersInterval::get() as u64 - 1,
+				sp_core::H256::default(),
+			));
+			assert!(RuntimeCall::check_obsolete_submit_finality_proof(&RuntimeCall::Grandpa(
+				bridge_grandpa_call.clone(),
+			),)
+			.is_ok());
+
+			// when `improved_by` is less than the free interval BUT it is a mandatory header
+			let mut mandatory_header = test_header(100);
+			let consensus_log = sp_consensus_grandpa::ConsensusLog::<TestNumber>::ScheduledChange(
+				sp_consensus_grandpa::ScheduledChange {
+					next_authorities: bp_test_utils::authority_list(),
+					delay: 0,
+				},
+			);
+			mandatory_header.digest = sp_runtime::Digest {
+				logs: vec![DigestItem::Consensus(
+					sp_consensus_grandpa::GRANDPA_ENGINE_ID,
+					consensus_log.encode(),
+				)],
+			};
+			let justification = make_justification_for_header(JustificationGeneratorParams {
+				header: mandatory_header.clone(),
+				set_id: 1,
+				..Default::default()
+			});
+			let bridge_grandpa_call = crate::Call::<TestRuntime, ()>::submit_finality_proof_ex {
+				finality_target: Box::new(mandatory_header),
+				justification,
+				current_set_id: 0,
+				is_free_execution_expected: true,
+			};
+			BestFinalized::<TestRuntime, ()>::put(HeaderId(
+				100 - FreeHeadersInterval::get() as u64 + 1,
+				sp_core::H256::default(),
+			));
+			assert!(RuntimeCall::check_obsolete_submit_finality_proof(&RuntimeCall::Grandpa(
+				bridge_grandpa_call.clone(),
+			),)
+			.is_ok());
+		})
+	}
+
 	#[test]
 	fn extension_accepts_new_header() {
 		run_test(|| {
@@ -336,6 +571,8 @@ mod tests {
 				current_set_id: None,
 				extra_weight: Weight::zero(),
 				extra_size: 0,
+				is_mandatory: false,
+				is_free_execution_expected: false,
 			})
 		);
 
@@ -345,6 +582,7 @@ mod tests {
 				finality_target: Box::new(test_header(42)),
 				justification: make_default_justification(&test_header(42)),
 				current_set_id: 777,
+				is_free_execution_expected: false,
 			});
 		assert_eq!(
 			deprecated_call.submit_finality_proof_info(),
@@ -353,6 +591,8 @@ mod tests {
 				current_set_id: Some(777),
 				extra_weight: Weight::zero(),
 				extra_size: 0,
+				is_mandatory: false,
+				is_free_execution_expected: false,
 			})
 		);
 	}
@@ -370,6 +610,7 @@ mod tests {
 			finality_target: Box::new(small_finality_target),
 			justification: small_justification,
 			current_set_id: TEST_GRANDPA_SET_ID,
+			is_free_execution_expected: false,
 		});
 		assert_eq!(small_call.submit_finality_proof_info().unwrap().extra_size, 0);
 
@@ -387,6 +628,7 @@ mod tests {
 			finality_target: Box::new(large_finality_target),
 			justification: large_justification,
 			current_set_id: TEST_GRANDPA_SET_ID,
+			is_free_execution_expected: false,
 		});
 		assert_ne!(large_call.submit_finality_proof_info().unwrap().extra_size, 0);
 	}
@@ -406,6 +648,7 @@ mod tests {
 			finality_target: Box::new(finality_target.clone()),
 			justification,
 			current_set_id: TEST_GRANDPA_SET_ID,
+			is_free_execution_expected: false,
 		});
 		assert_eq!(call.submit_finality_proof_info().unwrap().extra_weight, Weight::zero());
 
@@ -420,7 +663,52 @@ mod tests {
 			finality_target: Box::new(finality_target),
 			justification,
 			current_set_id: TEST_GRANDPA_SET_ID,
+			is_free_execution_expected: false,
 		});
 		assert_eq!(call.submit_finality_proof_info().unwrap().extra_weight, call_weight);
 	}
+
+	#[test]
+	fn check_obsolete_submit_finality_proof_returns_correct_improved_by() {
+		run_test(|| {
+			fn make_call(number: u64) -> RuntimeCall {
+				RuntimeCall::Grandpa(crate::Call::<TestRuntime, ()>::submit_finality_proof_ex {
+					finality_target: Box::new(test_header(number)),
+					justification: make_default_justification(&test_header(number)),
+					current_set_id: 0,
+					is_free_execution_expected: false,
+				})
+			}
+
+			sync_to_header_10();
+
+			// when the difference between headers is 1
+			assert_eq!(
+				RuntimeCall::check_obsolete_submit_finality_proof(&make_call(11))
+					.unwrap()
+					.unwrap()
+					.improved_by,
+				1,
+			);
+
+			// when the difference between headers is 2
+			assert_eq!(
+				RuntimeCall::check_obsolete_submit_finality_proof(&make_call(12))
+					.unwrap()
+					.unwrap()
+					.improved_by,
+				2,
+			);
+		})
+	}
+
+	#[test]
+	fn check_obsolete_submit_finality_proof_ignores_other_calls() {
+		run_test(|| {
+			let call =
+				RuntimeCall::System(frame_system::Call::<TestRuntime>::remark { remark: vec![42] });
+
+			assert_eq!(RuntimeCall::check_obsolete_submit_finality_proof(&call), Ok(None));
+		})
+	}
 }
diff --git a/bridges/modules/grandpa/src/lib.rs b/bridges/modules/grandpa/src/lib.rs
index 9e095651ef81da1e5418d7532ae56ae0fb8ef564..cb536eb07ff6d6928e8d96c68c4493130eb30d25 100644
--- a/bridges/modules/grandpa/src/lib.rs
+++ b/bridges/modules/grandpa/src/lib.rs
@@ -44,6 +44,7 @@ use bp_header_chain::{
 };
 use bp_runtime::{BlockNumberOf, HashOf, HasherOf, HeaderId, HeaderOf, OwnedBridgeModule};
 use frame_support::{dispatch::PostDispatchInfo, ensure, DefaultNoBound};
+use sp_consensus_grandpa::SetId;
 use sp_runtime::{
 	traits::{Header as HeaderT, Zero},
 	SaturatedConversion,
@@ -57,6 +58,7 @@ mod storage_types;
 
 /// Module, containing weights for this pallet.
 pub mod weights;
+pub mod weights_ext;
 
 #[cfg(feature = "runtime-benchmarks")]
 pub mod benchmarking;
@@ -65,6 +67,7 @@ pub mod benchmarking;
 pub use call_ext::*;
 pub use pallet::*;
 pub use weights::WeightInfo;
+pub use weights_ext::WeightInfoExt;
 
 /// The target that will be used when publishing logs related to this pallet.
 pub const LOG_TARGET: &str = "runtime::bridge-grandpa";
@@ -101,17 +104,31 @@ pub mod pallet {
 		/// The chain we are bridging to here.
 		type BridgedChain: ChainWithGrandpa;
 
-		/// Maximal number of "free" mandatory header transactions per block.
+		/// Maximal number of "free" header transactions per block.
 		///
 		/// To be able to track the bridged chain, the pallet requires all headers that are
 		/// changing GRANDPA authorities set at the bridged chain (we call them mandatory).
-		/// So it is a common good deed to submit mandatory headers to the pallet. However, if the
-		/// bridged chain gets compromised, its validators may generate as many mandatory headers
-		/// as they want. And they may fill the whole block (at this chain) for free. This constants
-		/// limits number of calls that we may refund in a single block. All calls above this
-		/// limit are accepted, but are not refunded.
+		/// So it is a common good deed to submit mandatory headers to the pallet.
+		///
+		/// The pallet may be configured (see `[Self::FreeHeadersInterval]`) to import some
+		/// non-mandatory headers for free as well. It also may be treated as a common good
+		/// deed, because it may help to reduce bridge fees - this cost may be deducted from
+		/// bridge fees, paid by message senders.
+		///
+		/// However, if the bridged chain gets compromised, its validators may generate as many
+		/// "free" headers as they want. And they may fill the whole block (at this chain) for
+		/// free. This constants limits number of calls that we may refund in a single block.
+		/// All calls above this limit are accepted, but are not refunded.
+		#[pallet::constant]
+		type MaxFreeHeadersPerBlock: Get<u32>;
+
+		/// The distance between bridged chain headers, that may be submitted for free. The
+		/// first free header is header number zero, the next one is header number
+		/// `FreeHeadersInterval::get()` or any of its descendant if that header has not
+		/// been submitted. In other words, interval between free headers should be at least
+		/// `FreeHeadersInterval`.
 		#[pallet::constant]
-		type MaxFreeMandatoryHeadersPerBlock: Get<u32>;
+		type FreeHeadersInterval: Get<Option<u32>>;
 
 		/// Maximal number of finalized headers to keep in the storage.
 		///
@@ -124,7 +141,7 @@ pub mod pallet {
 		type HeadersToKeep: Get<u32>;
 
 		/// Weights gathered through benchmarking.
-		type WeightInfo: WeightInfo;
+		type WeightInfo: WeightInfoExt;
 	}
 
 	#[pallet::pallet]
@@ -133,12 +150,12 @@ pub mod pallet {
 	#[pallet::hooks]
 	impl<T: Config<I>, I: 'static> Hooks<BlockNumberFor<T>> for Pallet<T, I> {
 		fn on_initialize(_n: BlockNumberFor<T>) -> Weight {
-			FreeMandatoryHeadersRemaining::<T, I>::put(T::MaxFreeMandatoryHeadersPerBlock::get());
+			FreeHeadersRemaining::<T, I>::put(T::MaxFreeHeadersPerBlock::get());
 			Weight::zero()
 		}
 
 		fn on_finalize(_n: BlockNumberFor<T>) {
-			FreeMandatoryHeadersRemaining::<T, I>::kill();
+			FreeHeadersRemaining::<T, I>::kill();
 		}
 	}
 
@@ -155,7 +172,7 @@ pub mod pallet {
 		/// `submit_finality_proof_ex` instead. Semantically, this call is an equivalent of the
 		/// `submit_finality_proof_ex` call without current authority set id check.
 		#[pallet::call_index(0)]
-		#[pallet::weight(<T::WeightInfo as WeightInfo>::submit_finality_proof(
+		#[pallet::weight(T::WeightInfo::submit_finality_proof_weight(
 			justification.commit.precommits.len().saturated_into(),
 			justification.votes_ancestries.len().saturated_into(),
 		))]
@@ -175,6 +192,8 @@ pub mod pallet {
 				// the `submit_finality_proof_ex` also reads this value, but it is done from the
 				// cache, so we don't treat it as an additional db access
 				<CurrentAuthoritySet<T, I>>::get().set_id,
+				// cannot enforce free execution using this call
+				false,
 			)
 		}
 
@@ -250,8 +269,14 @@ pub mod pallet {
 		/// - verification is not optimized or invalid;
 		///
 		/// - header contains forced authorities set change or change with non-zero delay.
+		///
+		/// The `is_free_execution_expected` parameter is not really used inside the call. It is
+		/// used by the transaction extension, which should be registered at the runtime level. If
+		/// this parameter is `true`, the transaction will be treated as invalid, if the call won't
+		/// be executed for free. If transaction extension is not used by the runtime, this
+		/// parameter is not used at all.
 		#[pallet::call_index(4)]
-		#[pallet::weight(<T::WeightInfo as WeightInfo>::submit_finality_proof(
+		#[pallet::weight(T::WeightInfo::submit_finality_proof_weight(
 			justification.commit.precommits.len().saturated_into(),
 			justification.votes_ancestries.len().saturated_into(),
 		))]
@@ -260,6 +285,7 @@ pub mod pallet {
 			finality_target: Box<BridgedHeader<T, I>>,
 			justification: GrandpaJustification<BridgedHeader<T, I>>,
 			current_set_id: sp_consensus_grandpa::SetId,
+			_is_free_execution_expected: bool,
 		) -> DispatchResultWithPostInfo {
 			Self::ensure_not_halted().map_err(Error::<T, I>::BridgeModule)?;
 			ensure_signed(origin)?;
@@ -273,7 +299,8 @@ pub mod pallet {
 
 			// it checks whether the `number` is better than the current best block number
 			// and whether the `current_set_id` matches the best known set id
-			SubmitFinalityProofHelper::<T, I>::check_obsolete(number, Some(current_set_id))?;
+			let improved_by =
+				SubmitFinalityProofHelper::<T, I>::check_obsolete(number, Some(current_set_id))?;
 
 			let authority_set = <CurrentAuthoritySet<T, I>>::get();
 			let unused_proof_size = authority_set.unused_proof_size();
@@ -283,23 +310,16 @@ pub mod pallet {
 
 			let maybe_new_authority_set =
 				try_enact_authority_change::<T, I>(&finality_target, set_id)?;
-			let may_refund_call_fee = maybe_new_authority_set.is_some() &&
-				// if we have seen too many mandatory headers in this block, we don't want to refund
-				Self::free_mandatory_headers_remaining() > 0 &&
-				// if arguments out of expected bounds, we don't want to refund
-				submit_finality_proof_info_from_args::<T, I>(&finality_target, &justification, Some(current_set_id))
-					.fits_limits();
+			let may_refund_call_fee = may_refund_call_fee::<T, I>(
+				&finality_target,
+				&justification,
+				current_set_id,
+				improved_by,
+			);
 			if may_refund_call_fee {
-				FreeMandatoryHeadersRemaining::<T, I>::mutate(|count| {
-					*count = count.saturating_sub(1)
-				});
+				on_free_header_imported::<T, I>();
 			}
 			insert_header::<T, I>(*finality_target, hash);
-			log::info!(
-				target: LOG_TARGET,
-				"Successfully imported finalized header with hash {:?}!",
-				hash
-			);
 
 			// mandatory header is a header that changes authorities set. The pallet can't go
 			// further without importing this header. So every bridge MUST import mandatory headers.
@@ -311,6 +331,13 @@ pub mod pallet {
 			// to pay for the transaction.
 			let pays_fee = if may_refund_call_fee { Pays::No } else { Pays::Yes };
 
+			log::info!(
+				target: LOG_TARGET,
+				"Successfully imported finalized header with hash {:?}! Free: {}",
+				hash,
+				if may_refund_call_fee { "Yes" } else { "No" },
+			);
+
 			// the proof size component of the call weight assumes that there are
 			// `MaxBridgedAuthorities` in the `CurrentAuthoritySet` (we use `MaxEncodedLen`
 			// estimation). But if their number is lower, then we may "refund" some `proof_size`,
@@ -335,20 +362,18 @@ pub mod pallet {
 		}
 	}
 
-	/// Number mandatory headers that we may accept in the current block for free (returning
-	/// `Pays::No`).
+	/// Number of free header submissions that we may yet accept in the current block.
 	///
-	/// If the `FreeMandatoryHeadersRemaining` hits zero, all following mandatory headers in the
+	/// If the `FreeHeadersRemaining` hits zero, all following mandatory headers in the
 	/// current block are accepted with fee (`Pays::Yes` is returned).
 	///
-	/// The `FreeMandatoryHeadersRemaining` is an ephemeral value that is set to
-	/// `MaxFreeMandatoryHeadersPerBlock` at each block initialization and is killed on block
+	/// The `FreeHeadersRemaining` is an ephemeral value that is set to
+	/// `MaxFreeHeadersPerBlock` at each block initialization and is killed on block
 	/// finalization. So it never ends up in the storage trie.
 	#[pallet::storage]
 	#[pallet::whitelist_storage]
-	#[pallet::getter(fn free_mandatory_headers_remaining)]
-	pub(super) type FreeMandatoryHeadersRemaining<T: Config<I>, I: 'static = ()> =
-		StorageValue<_, u32, ValueQuery>;
+	pub type FreeHeadersRemaining<T: Config<I>, I: 'static = ()> =
+		StorageValue<_, u32, OptionQuery>;
 
 	/// Hash of the header used to bootstrap the pallet.
 	#[pallet::storage]
@@ -473,6 +498,68 @@ pub mod pallet {
 		/// The `current_set_id` argument of the `submit_finality_proof_ex` doesn't match
 		/// the id of the current set, known to the pallet.
 		InvalidAuthoritySetId,
+		/// The submitter wanted free execution, but we can't fit more free transactions
+		/// to the block.
+		FreeHeadersLimitExceded,
+		/// The submitter wanted free execution, but the difference between best known and
+		/// bundled header numbers is below the `FreeHeadersInterval`.
+		BelowFreeHeaderInterval,
+	}
+
+	/// Called when new free header is imported.
+	pub fn on_free_header_imported<T: Config<I>, I: 'static>() {
+		FreeHeadersRemaining::<T, I>::mutate(|count| {
+			*count = match *count {
+				None => None,
+				// the signed extension expects that `None` means outside of block
+				// execution - i.e. when transaction is validated from the transaction pool,
+				// so use `saturating_sub` and don't go from `Some(0)`->`None`
+				Some(count) => Some(count.saturating_sub(1)),
+			}
+		});
+	}
+
+	/// Return true if we may refund transaction cost to the submitter. In other words,
+	/// this transaction is considered as common good deed w.r.t to pallet configuration.
+	fn may_refund_call_fee<T: Config<I>, I: 'static>(
+		finality_target: &BridgedHeader<T, I>,
+		justification: &GrandpaJustification<BridgedHeader<T, I>>,
+		current_set_id: SetId,
+		improved_by: BridgedBlockNumber<T, I>,
+	) -> bool {
+		// if we have refunded too much at this block => not refunding
+		if FreeHeadersRemaining::<T, I>::get().unwrap_or(0) == 0 {
+			return false;
+		}
+
+		// if size/weight of call is larger than expected => not refunding
+		let call_info = submit_finality_proof_info_from_args::<T, I>(
+			&finality_target,
+			&justification,
+			Some(current_set_id),
+			// this function is called from the transaction body and we do not want
+			// to do MAY-be-free-executed checks here - they had to be done in the
+			// transaction extension before
+			false,
+		);
+		if !call_info.fits_limits() {
+			return false;
+		}
+
+		// if that's a mandatory header => refund
+		if call_info.is_mandatory {
+			return true;
+		}
+
+		// if configuration allows free non-mandatory headers and the header
+		// matches criteria => refund
+		if let Some(free_headers_interval) = T::FreeHeadersInterval::get() {
+			if improved_by >= free_headers_interval.into() {
+				return true;
+			}
+		}
+
+		false
 	}
 
 	/// Check the given header for a GRANDPA scheduled authority set change. If a change
@@ -692,8 +779,8 @@ pub fn initialize_for_benchmarks<T: Config<I>, I: 'static>(header: BridgedHeader
 mod tests {
 	use super::*;
 	use crate::mock::{
-		run_test, test_header, RuntimeEvent as TestEvent, RuntimeOrigin, System, TestBridgedChain,
-		TestHeader, TestNumber, TestRuntime, MAX_BRIDGED_AUTHORITIES,
+		run_test, test_header, FreeHeadersInterval, RuntimeEvent as TestEvent, RuntimeOrigin,
+		System, TestBridgedChain, TestHeader, TestNumber, TestRuntime, MAX_BRIDGED_AUTHORITIES,
 	};
 	use bp_header_chain::BridgeGrandpaCall;
 	use bp_runtime::BasicOperatingMode;
@@ -747,6 +834,7 @@ mod tests {
 			Box::new(header),
 			justification,
 			TEST_GRANDPA_SET_ID,
+			false,
 		)
 	}
 
@@ -766,6 +854,7 @@ mod tests {
 			Box::new(header),
 			justification,
 			set_id,
+			false,
 		)
 	}
 
@@ -794,6 +883,7 @@ mod tests {
 			Box::new(header),
 			justification,
 			set_id,
+			false,
 		)
 	}
 
@@ -1009,6 +1099,7 @@ mod tests {
 					Box::new(header.clone()),
 					justification.clone(),
 					TEST_GRANDPA_SET_ID,
+					false,
 				),
 				<Error<TestRuntime>>::InvalidJustification
 			);
@@ -1018,6 +1109,7 @@ mod tests {
 					Box::new(header),
 					justification,
 					next_set_id,
+					false,
 				),
 				<Error<TestRuntime>>::InvalidAuthoritySetId
 			);
@@ -1039,6 +1131,7 @@ mod tests {
 					Box::new(header),
 					justification,
 					TEST_GRANDPA_SET_ID,
+					false,
 				),
 				<Error<TestRuntime>>::InvalidJustification
 			);
@@ -1069,6 +1162,7 @@ mod tests {
 					Box::new(header),
 					justification,
 					TEST_GRANDPA_SET_ID,
+					false,
 				),
 				<Error<TestRuntime>>::InvalidAuthoritySet
 			);
@@ -1108,6 +1202,7 @@ mod tests {
 				Box::new(header.clone()),
 				justification.clone(),
 				TEST_GRANDPA_SET_ID,
+				false,
 			);
 			assert_ok!(result);
 			assert_eq!(result.unwrap().pays_fee, frame_support::dispatch::Pays::No);
@@ -1171,6 +1266,7 @@ mod tests {
 				Box::new(header.clone()),
 				justification,
 				TEST_GRANDPA_SET_ID,
+				false,
 			);
 			assert_ok!(result);
 			assert_eq!(result.unwrap().pays_fee, frame_support::dispatch::Pays::Yes);
@@ -1203,6 +1299,7 @@ mod tests {
 				Box::new(header.clone()),
 				justification,
 				TEST_GRANDPA_SET_ID,
+				false,
 			);
 			assert_ok!(result);
 			assert_eq!(result.unwrap().pays_fee, frame_support::dispatch::Pays::Yes);
@@ -1233,6 +1330,7 @@ mod tests {
 					Box::new(header),
 					justification,
 					TEST_GRANDPA_SET_ID,
+					false,
 				),
 				<Error<TestRuntime>>::UnsupportedScheduledChange
 			);
@@ -1259,6 +1357,7 @@ mod tests {
 					Box::new(header),
 					justification,
 					TEST_GRANDPA_SET_ID,
+					false,
 				),
 				<Error<TestRuntime>>::UnsupportedScheduledChange
 			);
@@ -1285,6 +1384,7 @@ mod tests {
 					Box::new(header),
 					justification,
 					TEST_GRANDPA_SET_ID,
+					false,
 				),
 				<Error<TestRuntime>>::TooManyAuthoritiesInSet
 			);
@@ -1350,12 +1450,13 @@ mod tests {
 					Box::new(header),
 					invalid_justification,
 					TEST_GRANDPA_SET_ID,
+					false,
 				)
 			};
 
 			initialize_substrate_bridge();
 
-			for _ in 0..<TestRuntime as Config>::MaxFreeMandatoryHeadersPerBlock::get() + 1 {
+			for _ in 0..<TestRuntime as Config>::MaxFreeHeadersPerBlock::get() + 1 {
 				assert_err!(submit_invalid_request(), <Error<TestRuntime>>::InvalidJustification);
 			}
 
@@ -1423,6 +1524,64 @@ mod tests {
 		})
 	}
 
+	#[test]
+	fn may_import_non_mandatory_header_for_free() {
+		run_test(|| {
+			initialize_substrate_bridge();
+
+			// set best finalized to `100`
+			const BEST: u8 = 12;
+			fn reset_best() {
+				BestFinalized::<TestRuntime, ()>::set(Some(HeaderId(
+					BEST as _,
+					Default::default(),
+				)));
+			}
+
+			// non-mandatory header is imported with fee
+			reset_best();
+			let non_free_header_number = BEST + FreeHeadersInterval::get() as u8 - 1;
+			let result = submit_finality_proof(non_free_header_number);
+			assert_eq!(result.unwrap().pays_fee, Pays::Yes);
+
+			// non-mandatory free header is imported without fee
+			reset_best();
+			let free_header_number = BEST + FreeHeadersInterval::get() as u8;
+			let result = submit_finality_proof(free_header_number);
+			assert_eq!(result.unwrap().pays_fee, Pays::No);
+
+			// another non-mandatory free header is imported without fee
+			let free_header_number = BEST + FreeHeadersInterval::get() as u8 * 2;
+			let result = submit_finality_proof(free_header_number);
+			assert_eq!(result.unwrap().pays_fee, Pays::No);
+
+			// now the rate limiter starts charging fees even for free headers
+			let free_header_number = BEST + FreeHeadersInterval::get() as u8 * 3;
+			let result = submit_finality_proof(free_header_number);
+			assert_eq!(result.unwrap().pays_fee, Pays::Yes);
+
+			// check that we can import for free if `improved_by` is larger
+			// than the free interval
+			next_block();
+			reset_best();
+			let free_header_number = FreeHeadersInterval::get() as u8 + 42;
+			let result = submit_finality_proof(free_header_number);
+			assert_eq!(result.unwrap().pays_fee, Pays::No);
+
+			// check that the rate limiter shares the counter between mandatory
+			// and free non-mandatory headers
+			next_block();
+			reset_best();
+			let free_header_number = BEST + FreeHeadersInterval::get() as u8 * 4;
+			let result = submit_finality_proof(free_header_number);
+			assert_eq!(result.unwrap().pays_fee, Pays::No);
+			let result = submit_mandatory_finality_proof(free_header_number + 1, 1);
+			assert_eq!(result.expect("call failed").pays_fee, Pays::No);
+			let result = submit_mandatory_finality_proof(free_header_number + 2, 2);
+			assert_eq!(result.expect("call failed").pays_fee, Pays::Yes);
+		});
+	}
+
 	#[test]
 	fn should_prune_headers_over_headers_to_keep_parameter() {
 		run_test(|| {
@@ -1519,9 +1678,23 @@ mod tests {
 					Box::new(header),
 					justification,
 					TEST_GRANDPA_SET_ID,
+					false,
 				),
 				DispatchError::BadOrigin,
 			);
 		})
 	}
+
+	#[test]
+	fn on_free_header_imported_never_sets_to_none() {
+		run_test(|| {
+			FreeHeadersRemaining::<TestRuntime, ()>::set(Some(2));
+			on_free_header_imported::<TestRuntime, ()>();
+			assert_eq!(FreeHeadersRemaining::<TestRuntime, ()>::get(), Some(1));
+			on_free_header_imported::<TestRuntime, ()>();
+			assert_eq!(FreeHeadersRemaining::<TestRuntime, ()>::get(), Some(0));
+			on_free_header_imported::<TestRuntime, ()>();
+			assert_eq!(FreeHeadersRemaining::<TestRuntime, ()>::get(), Some(0));
+		})
+	}
 }
diff --git a/bridges/modules/grandpa/src/mock.rs b/bridges/modules/grandpa/src/mock.rs
index e689e520c92ffcb230a83f7a728722a688729417..27df9d9c78f540d0d73f74c6a86ba19af30d4b6b 100644
--- a/bridges/modules/grandpa/src/mock.rs
+++ b/bridges/modules/grandpa/src/mock.rs
@@ -48,14 +48,16 @@ impl frame_system::Config for TestRuntime {
 }
 
 parameter_types! {
-	pub const MaxFreeMandatoryHeadersPerBlock: u32 = 2;
+	pub const MaxFreeHeadersPerBlock: u32 = 2;
+	pub const FreeHeadersInterval: u32 = 32;
 	pub const HeadersToKeep: u32 = 5;
 }
 
 impl grandpa::Config for TestRuntime {
 	type RuntimeEvent = RuntimeEvent;
 	type BridgedChain = TestBridgedChain;
-	type MaxFreeMandatoryHeadersPerBlock = MaxFreeMandatoryHeadersPerBlock;
+	type MaxFreeHeadersPerBlock = MaxFreeHeadersPerBlock;
+	type FreeHeadersInterval = FreeHeadersInterval;
 	type HeadersToKeep = HeadersToKeep;
 	type WeightInfo = ();
 }
diff --git a/bridges/modules/grandpa/src/weights_ext.rs b/bridges/modules/grandpa/src/weights_ext.rs
new file mode 100644
index 0000000000000000000000000000000000000000..66edea6fb6a64cfa530bd48b0dfd1762af9c545f
--- /dev/null
+++ b/bridges/modules/grandpa/src/weights_ext.rs
@@ -0,0 +1,58 @@
+// Copyright (C) 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/>.
+
+//! Weight-related utilities.
+
+use crate::weights::{BridgeWeight, WeightInfo};
+
+use frame_support::weights::Weight;
+
+/// Extended weight info.
+pub trait WeightInfoExt: WeightInfo {
+	// Our configuration assumes that the runtime has special signed extensions used to:
+	//
+	// 1) boost priority of `submit_finality_proof` transactions;
+	//
+	// 2) slash relayer if he submits an invalid transaction.
+	//
+	// We read and update storage values of other pallets (`pallet-bridge-relayers` and
+	// balances/assets pallet). So we need to add this weight to the weight of our call.
+	// Hence two following methods.
+
+	/// Extra weight that is added to the `submit_finality_proof` call weight by signed extensions
+	/// that are declared at runtime level.
+	fn submit_finality_proof_overhead_from_runtime() -> Weight;
+
+	// Functions that are directly mapped to extrinsics weights.
+
+	/// Weight of message delivery extrinsic.
+	fn submit_finality_proof_weight(precommits_len: u32, votes_ancestries_len: u32) -> Weight {
+		let base_weight = Self::submit_finality_proof(precommits_len, votes_ancestries_len);
+		base_weight.saturating_add(Self::submit_finality_proof_overhead_from_runtime())
+	}
+}
+
+impl<T: frame_system::Config> WeightInfoExt for BridgeWeight<T> {
+	fn submit_finality_proof_overhead_from_runtime() -> Weight {
+		Weight::zero()
+	}
+}
+
+impl WeightInfoExt for () {
+	fn submit_finality_proof_overhead_from_runtime() -> Weight {
+		Weight::zero()
+	}
+}
diff --git a/bridges/modules/parachains/src/call_ext.rs b/bridges/modules/parachains/src/call_ext.rs
index da91a40a2322393ee715bf1c61840e4b18df23b8..fe6b319205d41491ce2df36d8a1d112eb37f94b4 100644
--- a/bridges/modules/parachains/src/call_ext.rs
+++ b/bridges/modules/parachains/src/call_ext.rs
@@ -14,25 +14,45 @@
 // 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/>.
 
-use crate::{Config, Pallet, RelayBlockNumber};
+use crate::{Config, GrandpaPalletOf, Pallet, RelayBlockHash, RelayBlockNumber};
+use bp_header_chain::HeaderChain;
 use bp_parachains::BestParaHeadHash;
 use bp_polkadot_core::parachains::{ParaHash, ParaId};
-use bp_runtime::OwnedBridgeModule;
-use frame_support::{dispatch::CallableCallFor, traits::IsSubType};
+use bp_runtime::{HeaderId, OwnedBridgeModule};
+use frame_support::{
+	dispatch::CallableCallFor,
+	traits::{Get, IsSubType},
+};
+use pallet_bridge_grandpa::SubmitFinalityProofHelper;
 use sp_runtime::{
-	transaction_validity::{InvalidTransaction, TransactionValidity, ValidTransaction},
+	traits::Zero,
+	transaction_validity::{InvalidTransaction, TransactionValidityError},
 	RuntimeDebug,
 };
 
 /// Info about a `SubmitParachainHeads` call which tries to update a single parachain.
 #[derive(PartialEq, RuntimeDebug)]
 pub struct SubmitParachainHeadsInfo {
-	/// Number of the finalized relay block that has been used to prove parachain finality.
-	pub at_relay_block_number: RelayBlockNumber,
+	/// Number and hash of the finalized relay block that has been used to prove parachain
+	/// finality.
+	pub at_relay_block: HeaderId<RelayBlockHash, RelayBlockNumber>,
 	/// Parachain identifier.
 	pub para_id: ParaId,
 	/// Hash of the bundled parachain head.
 	pub para_head_hash: ParaHash,
+	/// If `true`, then the call must be free (assuming that everything else is valid) to
+	/// be treated as valid.
+	pub is_free_execution_expected: bool,
+}
+
+/// Verified `SubmitParachainHeadsInfo`.
+#[derive(PartialEq, RuntimeDebug)]
+pub struct VerifiedSubmitParachainHeadsInfo {
+	/// Base call information.
+	pub base: SubmitParachainHeadsInfo,
+	/// A difference between bundled bridged relay chain header and relay chain header number
+	/// used to prove best bridged parachain header, known to us before the call.
+	pub improved_by: RelayBlockNumber,
 }
 
 /// Helper struct that provides methods for working with the `SubmitParachainHeads` call.
@@ -41,40 +61,117 @@ pub struct SubmitParachainHeadsHelper<T: Config<I>, I: 'static> {
 }
 
 impl<T: Config<I>, I: 'static> SubmitParachainHeadsHelper<T, I> {
-	/// Check if the para head provided by the `SubmitParachainHeads` is better than the best one
-	/// we know.
-	pub fn is_obsolete(update: &SubmitParachainHeadsInfo) -> bool {
-		let stored_best_head = match crate::ParasInfo::<T, I>::get(update.para_id) {
-			Some(stored_best_head) => stored_best_head,
-			None => return false,
+	/// Check that is called from signed extension and takes the `is_free_execution_expected`
+	/// into account.
+	pub fn check_obsolete_from_extension(
+		update: &SubmitParachainHeadsInfo,
+	) -> Result<RelayBlockNumber, TransactionValidityError> {
+		// first do all base checks
+		let improved_by = Self::check_obsolete(update)?;
+
+		// if we don't expect free execution - no more checks
+		if !update.is_free_execution_expected {
+			return Ok(improved_by);
+		}
+
+		// reject if no more free slots remaining in the block
+		if !SubmitFinalityProofHelper::<T, T::BridgesGrandpaPalletInstance>::has_free_header_slots()
+		{
+			log::trace!(
+				target: crate::LOG_TARGET,
+				"The free parachain {:?} head can't be updated: no more free slots \
+				left in the block.",
+				update.para_id,
+			);
+
+			return Err(InvalidTransaction::Call.into());
+		}
+
+		// if free headers interval is not configured and call is expected to execute
+		// for free => it is a relayer error, it should've been able to detect that.
+		let free_headers_interval = match T::FreeHeadersInterval::get() {
+			Some(free_headers_interval) => free_headers_interval,
+			None => return Ok(improved_by),
 		};
 
-		if stored_best_head.best_head_hash.at_relay_block_number >= update.at_relay_block_number {
+		// reject if we are importing parachain headers too often
+		if improved_by < free_headers_interval {
 			log::trace!(
 				target: crate::LOG_TARGET,
-				"The parachain head can't be updated. The parachain head for {:?} \
-					was already updated at better relay chain block {} >= {}.",
+				"The free parachain {:?} head can't be updated: it improves previous
+				best head by {} while at least {} is expected.",
 				update.para_id,
-				stored_best_head.best_head_hash.at_relay_block_number,
-				update.at_relay_block_number
+				improved_by,
+				free_headers_interval,
 			);
-			return true
+
+			return Err(InvalidTransaction::Stale.into());
 		}
 
-		if stored_best_head.best_head_hash.head_hash == update.para_head_hash {
+		Ok(improved_by)
+	}
+
+	/// Check if the para head provided by the `SubmitParachainHeads` is better than the best one
+	/// we know.
+	pub fn check_obsolete(
+		update: &SubmitParachainHeadsInfo,
+	) -> Result<RelayBlockNumber, TransactionValidityError> {
+		// check if we know better parachain head already
+		let improved_by = match crate::ParasInfo::<T, I>::get(update.para_id) {
+			Some(stored_best_head) => {
+				let improved_by = match update
+					.at_relay_block
+					.0
+					.checked_sub(stored_best_head.best_head_hash.at_relay_block_number)
+				{
+					Some(improved_by) if improved_by > Zero::zero() => improved_by,
+					_ => {
+						log::trace!(
+							target: crate::LOG_TARGET,
+							"The parachain head can't be updated. The parachain head for {:?} \
+								was already updated at better relay chain block {} >= {}.",
+							update.para_id,
+							stored_best_head.best_head_hash.at_relay_block_number,
+							update.at_relay_block.0
+						);
+						return Err(InvalidTransaction::Stale.into())
+					},
+				};
+
+				if stored_best_head.best_head_hash.head_hash == update.para_head_hash {
+					log::trace!(
+						target: crate::LOG_TARGET,
+						"The parachain head can't be updated. The parachain head hash for {:?} \
+						was already updated to {} at block {} < {}.",
+						update.para_id,
+						update.para_head_hash,
+						stored_best_head.best_head_hash.at_relay_block_number,
+						update.at_relay_block.0
+					);
+					return Err(InvalidTransaction::Stale.into())
+				}
+
+				improved_by
+			},
+			None => RelayBlockNumber::MAX,
+		};
+
+		// let's check if our chain had no reorgs and we still know the relay chain header
+		// used to craft the proof
+		if GrandpaPalletOf::<T, I>::finalized_header_state_root(update.at_relay_block.1).is_none() {
 			log::trace!(
 				target: crate::LOG_TARGET,
-				"The parachain head can't be updated. The parachain head hash for {:?} \
-				was already updated to {} at block {} < {}.",
+				"The parachain {:?} head can't be updated. Relay chain header {}/{} used to create \
+				parachain proof is missing from the storage.",
 				update.para_id,
-				update.para_head_hash,
-				stored_best_head.best_head_hash.at_relay_block_number,
-				update.at_relay_block_number
+				update.at_relay_block.0,
+				update.at_relay_block.1,
 			);
-			return true
+
+			return Err(InvalidTransaction::Call.into())
 		}
 
-		false
+		Ok(improved_by)
 	}
 
 	/// Check if the `SubmitParachainHeads` was successfully executed.
@@ -83,7 +180,7 @@ impl<T: Config<I>, I: 'static> SubmitParachainHeadsHelper<T, I> {
 			Some(stored_best_head) =>
 				stored_best_head.best_head_hash ==
 					BestParaHeadHash {
-						at_relay_block_number: update.at_relay_block_number,
+						at_relay_block_number: update.at_relay_block.0,
 						head_hash: update.para_head_hash,
 					},
 			None => false,
@@ -98,22 +195,36 @@ pub trait CallSubType<T: Config<I, RuntimeCall = Self>, I: 'static>:
 	/// Create a new instance of `SubmitParachainHeadsInfo` from a `SubmitParachainHeads` call with
 	/// one single parachain entry.
 	fn one_entry_submit_parachain_heads_info(&self) -> Option<SubmitParachainHeadsInfo> {
-		if let Some(crate::Call::<T, I>::submit_parachain_heads {
-			ref at_relay_block,
-			ref parachains,
-			..
-		}) = self.is_sub_type()
-		{
-			if let &[(para_id, para_head_hash)] = parachains.as_slice() {
-				return Some(SubmitParachainHeadsInfo {
-					at_relay_block_number: at_relay_block.0,
+		match self.is_sub_type() {
+			Some(crate::Call::<T, I>::submit_parachain_heads {
+				ref at_relay_block,
+				ref parachains,
+				..
+			}) => match &parachains[..] {
+				&[(para_id, para_head_hash)] => Some(SubmitParachainHeadsInfo {
+					at_relay_block: HeaderId(at_relay_block.0, at_relay_block.1),
 					para_id,
 					para_head_hash,
-				})
-			}
+					is_free_execution_expected: false,
+				}),
+				_ => None,
+			},
+			Some(crate::Call::<T, I>::submit_parachain_heads_ex {
+				ref at_relay_block,
+				ref parachains,
+				is_free_execution_expected,
+				..
+			}) => match &parachains[..] {
+				&[(para_id, para_head_hash)] => Some(SubmitParachainHeadsInfo {
+					at_relay_block: HeaderId(at_relay_block.0, at_relay_block.1),
+					para_id,
+					para_head_hash,
+					is_free_execution_expected: *is_free_execution_expected,
+				}),
+				_ => None,
+			},
+			_ => None,
 		}
-
-		None
 	}
 
 	/// Create a new instance of `SubmitParachainHeadsInfo` from a `SubmitParachainHeads` call with
@@ -133,24 +244,23 @@ pub trait CallSubType<T: Config<I, RuntimeCall = Self>, I: 'static>:
 	/// block production, or "eat" significant portion of block production time literally
 	/// for nothing. In addition, the single-parachain-head-per-transaction is how the
 	/// pallet will be used in our environment.
-	fn check_obsolete_submit_parachain_heads(&self) -> TransactionValidity
+	fn check_obsolete_submit_parachain_heads(
+		&self,
+	) -> Result<Option<VerifiedSubmitParachainHeadsInfo>, TransactionValidityError>
 	where
 		Self: Sized,
 	{
 		let update = match self.one_entry_submit_parachain_heads_info() {
 			Some(update) => update,
-			None => return Ok(ValidTransaction::default()),
+			None => return Ok(None),
 		};
 
 		if Pallet::<T, I>::ensure_not_halted().is_err() {
-			return InvalidTransaction::Call.into()
+			return Err(InvalidTransaction::Call.into())
 		}
 
-		if SubmitParachainHeadsHelper::<T, I>::is_obsolete(&update) {
-			return InvalidTransaction::Stale.into()
-		}
-
-		Ok(ValidTransaction::default())
+		SubmitParachainHeadsHelper::<T, I>::check_obsolete_from_extension(&update)
+			.map(|improved_by| Some(VerifiedSubmitParachainHeadsInfo { base: update, improved_by }))
 	}
 }
 
@@ -164,9 +274,10 @@ where
 #[cfg(test)]
 mod tests {
 	use crate::{
-		mock::{run_test, RuntimeCall, TestRuntime},
-		CallSubType, PalletOperatingMode, ParaInfo, ParasInfo, RelayBlockNumber,
+		mock::{run_test, FreeHeadersInterval, RuntimeCall, TestRuntime},
+		CallSubType, PalletOperatingMode, ParaInfo, ParasInfo, RelayBlockHash, RelayBlockNumber,
 	};
+	use bp_header_chain::StoredHeaderData;
 	use bp_parachains::BestParaHeadHash;
 	use bp_polkadot_core::parachains::{ParaHash, ParaHeadsProof, ParaId};
 	use bp_runtime::BasicOperatingMode;
@@ -175,15 +286,37 @@ mod tests {
 		num: RelayBlockNumber,
 		parachains: Vec<(ParaId, ParaHash)>,
 	) -> bool {
-		RuntimeCall::Parachains(crate::Call::<TestRuntime, ()>::submit_parachain_heads {
-			at_relay_block: (num, Default::default()),
+		RuntimeCall::Parachains(crate::Call::<TestRuntime, ()>::submit_parachain_heads_ex {
+			at_relay_block: (num, [num as u8; 32].into()),
+			parachains,
+			parachain_heads_proof: ParaHeadsProof { storage_proof: Vec::new() },
+			is_free_execution_expected: false,
+		})
+		.check_obsolete_submit_parachain_heads()
+		.is_ok()
+	}
+
+	fn validate_free_submit_parachain_heads(
+		num: RelayBlockNumber,
+		parachains: Vec<(ParaId, ParaHash)>,
+	) -> bool {
+		RuntimeCall::Parachains(crate::Call::<TestRuntime, ()>::submit_parachain_heads_ex {
+			at_relay_block: (num, [num as u8; 32].into()),
 			parachains,
 			parachain_heads_proof: ParaHeadsProof { storage_proof: Vec::new() },
+			is_free_execution_expected: true,
 		})
 		.check_obsolete_submit_parachain_heads()
 		.is_ok()
 	}
 
+	fn insert_relay_block(num: RelayBlockNumber) {
+		pallet_bridge_grandpa::ImportedHeaders::<TestRuntime, crate::Instance1>::insert(
+			RelayBlockHash::from([num as u8; 32]),
+			StoredHeaderData { number: num, state_root: RelayBlockHash::from([10u8; 32]) },
+		);
+	}
+
 	fn sync_to_relay_header_10() {
 		ParasInfo::<TestRuntime, ()>::insert(
 			ParaId(1),
@@ -244,6 +377,7 @@ mod tests {
 			// when current best finalized is #10 and we're trying to import header#15 => tx is
 			// accepted
 			sync_to_relay_header_10();
+			insert_relay_block(15);
 			assert!(validate_submit_parachain_heads(15, vec![(ParaId(1), [2u8; 32].into())]));
 		});
 	}
@@ -260,4 +394,65 @@ mod tests {
 			));
 		});
 	}
+
+	#[test]
+	fn extension_rejects_initial_parachain_head_if_missing_relay_chain_header() {
+		run_test(|| {
+			// when relay chain header is unknown => "obsolete"
+			assert!(!validate_submit_parachain_heads(10, vec![(ParaId(1), [1u8; 32].into())]));
+			// when relay chain header is unknown => "ok"
+			insert_relay_block(10);
+			assert!(validate_submit_parachain_heads(10, vec![(ParaId(1), [1u8; 32].into())]));
+		});
+	}
+
+	#[test]
+	fn extension_rejects_free_parachain_head_if_missing_relay_chain_header() {
+		run_test(|| {
+			sync_to_relay_header_10();
+			// when relay chain header is unknown => "obsolete"
+			assert!(!validate_submit_parachain_heads(15, vec![(ParaId(2), [15u8; 32].into())]));
+			// when relay chain header is unknown => "ok"
+			insert_relay_block(15);
+			assert!(validate_submit_parachain_heads(15, vec![(ParaId(2), [15u8; 32].into())]));
+		});
+	}
+
+	#[test]
+	fn extension_rejects_free_parachain_head_if_no_free_slots_remaining() {
+		run_test(|| {
+			// when current best finalized is #10 and we're trying to import header#15 => tx should
+			// be accepted
+			sync_to_relay_header_10();
+			insert_relay_block(15);
+			// ... but since we have specified `is_free_execution_expected = true`, it'll be
+			// rejected
+			assert!(!validate_free_submit_parachain_heads(15, vec![(ParaId(1), [2u8; 32].into())]));
+			// ... if we have specify `is_free_execution_expected = false`, it'll be accepted
+			assert!(validate_submit_parachain_heads(15, vec![(ParaId(1), [2u8; 32].into())]));
+		});
+	}
+
+	#[test]
+	fn extension_rejects_free_parachain_head_if_improves_by_is_below_expected() {
+		run_test(|| {
+			// when current best finalized is #10 and we're trying to import header#15 => tx should
+			// be accepted
+			sync_to_relay_header_10();
+			insert_relay_block(10 + FreeHeadersInterval::get() - 1);
+			insert_relay_block(10 + FreeHeadersInterval::get());
+			// try to submit at 10 + FreeHeadersInterval::get() - 1 => failure
+			let relay_header = 10 + FreeHeadersInterval::get() - 1;
+			assert!(!validate_free_submit_parachain_heads(
+				relay_header,
+				vec![(ParaId(1), [2u8; 32].into())]
+			));
+			// try to submit at 10 + FreeHeadersInterval::get() => ok
+			let relay_header = 10 + FreeHeadersInterval::get();
+			assert!(validate_free_submit_parachain_heads(
+				relay_header,
+				vec![(ParaId(1), [2u8; 32].into())]
+			));
+		});
+	}
 }
diff --git a/bridges/modules/parachains/src/lib.rs b/bridges/modules/parachains/src/lib.rs
index 1363a637604d1202ffc4bf799bf7ced180d9fe53..61e04aed3770dcaa9cb611dc754aad21325e1b39 100644
--- a/bridges/modules/parachains/src/lib.rs
+++ b/bridges/modules/parachains/src/lib.rs
@@ -32,6 +32,7 @@ use bp_parachains::{parachain_head_storage_key_at_source, ParaInfo, ParaStoredHe
 use bp_polkadot_core::parachains::{ParaHash, ParaHead, ParaHeadsProof, ParaId};
 use bp_runtime::{Chain, HashOf, HeaderId, HeaderIdOf, Parachain, StorageProofError};
 use frame_support::{dispatch::PostDispatchInfo, DefaultNoBound};
+use pallet_bridge_grandpa::SubmitFinalityProofHelper;
 use sp_std::{marker::PhantomData, vec::Vec};
 
 #[cfg(feature = "runtime-benchmarks")]
@@ -92,7 +93,8 @@ pub mod pallet {
 		BoundedStorageValue<<T as Config<I>>::MaxParaHeadDataSize, ParaStoredHeaderData>;
 	/// Weight info of the given parachains pallet.
 	pub type WeightInfoOf<T, I> = <T as Config<I>>::WeightInfo;
-	type GrandpaPalletOf<T, I> =
+	/// Bridge GRANDPA pallet that is used to verify parachain proofs.
+	pub type GrandpaPalletOf<T, I> =
 		pallet_bridge_grandpa::Pallet<T, <T as Config<I>>::BridgesGrandpaPalletInstance>;
 
 	#[pallet::event]
@@ -192,6 +194,21 @@ pub mod pallet {
 		///
 		/// The GRANDPA pallet instance must be configured to import headers of relay chain that
 		/// we're interested in.
+		///
+		/// The associated GRANDPA pallet is also used to configure free parachain heads
+		/// submissions. The parachain head submission will be free if:
+		///
+		/// 1) the submission contains exactly one parachain head update that succeeds;
+		///
+		/// 2) the difference between relay chain block numbers, used to prove new parachain head
+		///    and previous best parachain head is larger than the `FreeHeadersInterval`, configured
+		///    at the associated GRANDPA pallet;
+		///
+		/// 3) there are slots for free submissions, remaining at the block. This is also configured
+		///    at the associated GRANDPA pallet using `MaxFreeHeadersPerBlock` parameter.
+		///
+		/// First parachain head submission is also free for the submitted, if free submissions
+		/// are yet accepted to this block.
 		type BridgesGrandpaPalletInstance: 'static;
 
 		/// Name of the original `paras` pallet in the `construct_runtime!()` call at the bridged
@@ -335,10 +352,83 @@ pub mod pallet {
 			at_relay_block: (RelayBlockNumber, RelayBlockHash),
 			parachains: Vec<(ParaId, ParaHash)>,
 			parachain_heads_proof: ParaHeadsProof,
+		) -> DispatchResultWithPostInfo {
+			Self::submit_parachain_heads_ex(
+				origin,
+				at_relay_block,
+				parachains,
+				parachain_heads_proof,
+				false,
+			)
+		}
+
+		/// Change `PalletOwner`.
+		///
+		/// May only be called either by root, or by `PalletOwner`.
+		#[pallet::call_index(1)]
+		#[pallet::weight((T::DbWeight::get().reads_writes(1, 1), DispatchClass::Operational))]
+		pub fn set_owner(origin: OriginFor<T>, new_owner: Option<T::AccountId>) -> DispatchResult {
+			<Self as OwnedBridgeModule<_>>::set_owner(origin, new_owner)
+		}
+
+		/// Halt or resume all pallet operations.
+		///
+		/// May only be called either by root, or by `PalletOwner`.
+		#[pallet::call_index(2)]
+		#[pallet::weight((T::DbWeight::get().reads_writes(1, 1), DispatchClass::Operational))]
+		pub fn set_operating_mode(
+			origin: OriginFor<T>,
+			operating_mode: BasicOperatingMode,
+		) -> DispatchResult {
+			<Self as OwnedBridgeModule<_>>::set_operating_mode(origin, operating_mode)
+		}
+
+		/// Submit proof of one or several parachain heads.
+		///
+		/// The proof is supposed to be proof of some `Heads` entries from the
+		/// `polkadot-runtime-parachains::paras` pallet instance, deployed at the bridged chain.
+		/// The proof is supposed to be crafted at the `relay_header_hash` that must already be
+		/// imported by corresponding GRANDPA pallet at this chain.
+		///
+		/// The call fails if:
+		///
+		/// - the pallet is halted;
+		///
+		/// - the relay chain block `at_relay_block` is not imported by the associated bridge
+		///   GRANDPA pallet.
+		///
+		/// The call may succeed, but some heads may not be updated e.g. because pallet knows
+		/// better head or it isn't tracked by the pallet.
+		///
+		/// The `is_free_execution_expected` parameter is not really used inside the call. It is
+		/// used by the transaction extension, which should be registered at the runtime level. If
+		/// this parameter is `true`, the transaction will be treated as invalid, if the call won't
+		/// be executed for free. If transaction extension is not used by the runtime, this
+		/// parameter is not used at all.
+		#[pallet::call_index(3)]
+		#[pallet::weight(WeightInfoOf::<T, I>::submit_parachain_heads_weight(
+			T::DbWeight::get(),
+			parachain_heads_proof,
+			parachains.len() as _,
+		))]
+		pub fn submit_parachain_heads_ex(
+			origin: OriginFor<T>,
+			at_relay_block: (RelayBlockNumber, RelayBlockHash),
+			parachains: Vec<(ParaId, ParaHash)>,
+			parachain_heads_proof: ParaHeadsProof,
+			_is_free_execution_expected: bool,
 		) -> DispatchResultWithPostInfo {
 			Self::ensure_not_halted().map_err(Error::<T, I>::BridgeModule)?;
 			ensure_signed(origin)?;
 
+			let total_parachains = parachains.len();
+			let free_headers_interval =
+				T::FreeHeadersInterval::get().unwrap_or(RelayBlockNumber::MAX);
+			// the pallet allows two kind of free submissions
+			// 1) if distance between all parachain heads is gte than the [`T::FreeHeadersInterval`]
+			// 2) if all heads are the first heads of their parachains
+			let mut free_parachain_heads = 0;
+
 			// we'll need relay chain header to verify that parachains heads are always increasing.
 			let (relay_block_number, relay_block_hash) = at_relay_block;
 			let relay_block = pallet_bridge_grandpa::ImportedHeaders::<
@@ -358,6 +448,7 @@ pub mod pallet {
 				parachains.len() as _,
 			);
 
+			let mut is_updated_something = false;
 			let mut storage = GrandpaPalletOf::<T, I>::storage_proof_checker(
 				relay_block_hash,
 				parachain_heads_proof.storage_proof,
@@ -414,6 +505,7 @@ pub mod pallet {
 				}
 
 				// convert from parachain head into stored parachain head data
+				let parachain_head_size = parachain_head.0.len();
 				let parachain_head_data =
 					match T::ParaStoredHeaderDataBuilder::try_build(parachain, &parachain_head) {
 						Some(parachain_head_data) => parachain_head_data,
@@ -430,13 +522,30 @@ pub mod pallet {
 
 				let update_result: Result<_, ()> =
 					ParasInfo::<T, I>::try_mutate(parachain, |stored_best_head| {
+						let is_free = parachain_head_size <
+							T::ParaStoredHeaderDataBuilder::max_free_head_size() as usize &&
+							match stored_best_head {
+								Some(ref best_head)
+									if at_relay_block.0.saturating_sub(
+										best_head.best_head_hash.at_relay_block_number,
+									) >= free_headers_interval =>
+									true,
+								Some(_) => false,
+								None => true,
+							};
 						let artifacts = Pallet::<T, I>::update_parachain_head(
 							parachain,
 							stored_best_head.take(),
-							relay_block_number,
+							HeaderId(relay_block_number, relay_block_hash),
 							parachain_head_data,
 							parachain_head_hash,
 						)?;
+
+						is_updated_something = true;
+						if is_free {
+							free_parachain_heads = free_parachain_heads + 1;
+						}
+
 						*stored_best_head = Some(artifacts.best_head);
 						Ok(artifacts.prune_happened)
 					});
@@ -467,28 +576,21 @@ pub mod pallet {
 				Error::<T, I>::HeaderChainStorageProof(HeaderChainError::StorageProof(e))
 			})?;
 
-			Ok(PostDispatchInfo { actual_weight: Some(actual_weight), pays_fee: Pays::Yes })
-		}
-
-		/// Change `PalletOwner`.
-		///
-		/// May only be called either by root, or by `PalletOwner`.
-		#[pallet::call_index(1)]
-		#[pallet::weight((T::DbWeight::get().reads_writes(1, 1), DispatchClass::Operational))]
-		pub fn set_owner(origin: OriginFor<T>, new_owner: Option<T::AccountId>) -> DispatchResult {
-			<Self as OwnedBridgeModule<_>>::set_owner(origin, new_owner)
-		}
+			// check if we allow this submission for free
+			let is_free = total_parachains == 1
+				&& free_parachain_heads == total_parachains
+				&& SubmitFinalityProofHelper::<T, T::BridgesGrandpaPalletInstance>::has_free_header_slots();
+			let pays_fee = if is_free {
+				log::trace!(target: LOG_TARGET, "Parachain heads update transaction is free");
+				pallet_bridge_grandpa::on_free_header_imported::<T, T::BridgesGrandpaPalletInstance>(
+				);
+				Pays::No
+			} else {
+				log::trace!(target: LOG_TARGET, "Parachain heads update transaction is paid");
+				Pays::Yes
+			};
 
-		/// Halt or resume all pallet operations.
-		///
-		/// May only be called either by root, or by `PalletOwner`.
-		#[pallet::call_index(2)]
-		#[pallet::weight((T::DbWeight::get().reads_writes(1, 1), DispatchClass::Operational))]
-		pub fn set_operating_mode(
-			origin: OriginFor<T>,
-			operating_mode: BasicOperatingMode,
-		) -> DispatchResult {
-			<Self as OwnedBridgeModule<_>>::set_operating_mode(origin, operating_mode)
+			Ok(PostDispatchInfo { actual_weight: Some(actual_weight), pays_fee })
 		}
 	}
 
@@ -545,18 +647,20 @@ pub mod pallet {
 		pub(super) fn update_parachain_head(
 			parachain: ParaId,
 			stored_best_head: Option<ParaInfo>,
-			new_at_relay_block_number: RelayBlockNumber,
+			new_at_relay_block: HeaderId<RelayBlockHash, RelayBlockNumber>,
 			new_head_data: ParaStoredHeaderData,
 			new_head_hash: ParaHash,
 		) -> Result<UpdateParachainHeadArtifacts, ()> {
 			// check if head has been already updated at better relay chain block. Without this
 			// check, we may import heads in random order
 			let update = SubmitParachainHeadsInfo {
-				at_relay_block_number: new_at_relay_block_number,
+				at_relay_block: new_at_relay_block,
 				para_id: parachain,
 				para_head_hash: new_head_hash,
+				// doesn't actually matter here
+				is_free_execution_expected: false,
 			};
-			if SubmitParachainHeadsHelper::<T, I>::is_obsolete(&update) {
+			if SubmitParachainHeadsHelper::<T, I>::check_obsolete(&update).is_err() {
 				Self::deposit_event(Event::RejectedObsoleteParachainHead {
 					parachain,
 					parachain_head_hash: new_head_hash,
@@ -596,7 +700,7 @@ pub mod pallet {
 				ImportedParaHashes::<T, I>::try_get(parachain, next_imported_hash_position);
 			let updated_best_para_head = ParaInfo {
 				best_head_hash: BestParaHeadHash {
-					at_relay_block_number: new_at_relay_block_number,
+					at_relay_block_number: new_at_relay_block.0,
 					head_hash: new_head_hash,
 				},
 				next_imported_hash_position: (next_imported_hash_position + 1) %
@@ -610,9 +714,10 @@ pub mod pallet {
 			ImportedParaHeads::<T, I>::insert(parachain, new_head_hash, updated_head_data);
 			log::trace!(
 				target: LOG_TARGET,
-				"Updated head of parachain {:?} to {}",
+				"Updated head of parachain {:?} to {} at relay block {}",
 				parachain,
 				new_head_hash,
+				new_at_relay_block.0,
 			);
 
 			// remove old head
@@ -696,14 +801,28 @@ impl<T: Config<I>, I: 'static, C: Parachain<Hash = ParaHash>> HeaderChain<C>
 pub fn initialize_for_benchmarks<T: Config<I>, I: 'static, PC: Parachain<Hash = ParaHash>>(
 	header: HeaderOf<PC>,
 ) {
+	use bp_runtime::HeaderIdProvider;
+	use sp_runtime::traits::Header;
+
+	let relay_head =
+		pallet_bridge_grandpa::BridgedHeader::<T, T::BridgesGrandpaPalletInstance>::new(
+			0,
+			Default::default(),
+			Default::default(),
+			Default::default(),
+			Default::default(),
+		);
 	let parachain = ParaId(PC::PARACHAIN_ID);
 	let parachain_head = ParaHead(header.encode());
 	let updated_head_data = T::ParaStoredHeaderDataBuilder::try_build(parachain, &parachain_head)
 		.expect("failed to build stored parachain head in benchmarks");
+	pallet_bridge_grandpa::initialize_for_benchmarks::<T, T::BridgesGrandpaPalletInstance>(
+		relay_head.clone(),
+	);
 	Pallet::<T, I>::update_parachain_head(
 		parachain,
 		None,
-		0,
+		relay_head.id(),
 		updated_head_data,
 		parachain_head.hash(),
 	)
@@ -714,9 +833,9 @@ pub fn initialize_for_benchmarks<T: Config<I>, I: 'static, PC: Parachain<Hash =
 pub(crate) mod tests {
 	use super::*;
 	use crate::mock::{
-		run_test, test_relay_header, BigParachainHeader, RegularParachainHasher,
-		RegularParachainHeader, RelayBlockHeader, RuntimeEvent as TestEvent, RuntimeOrigin,
-		TestRuntime, UNTRACKED_PARACHAIN_ID,
+		run_test, test_relay_header, BigParachain, BigParachainHeader, FreeHeadersInterval,
+		RegularParachainHasher, RegularParachainHeader, RelayBlockHeader,
+		RuntimeEvent as TestEvent, RuntimeOrigin, TestRuntime, UNTRACKED_PARACHAIN_ID,
 	};
 	use bp_test_utils::prepare_parachain_heads_proof;
 	use codec::Encode;
@@ -736,8 +855,9 @@ pub(crate) mod tests {
 	use frame_support::{
 		assert_noop, assert_ok,
 		dispatch::DispatchResultWithPostInfo,
+		pallet_prelude::Pays,
 		storage::generator::{StorageDoubleMap, StorageMap},
-		traits::{Get, OnInitialize},
+		traits::Get,
 		weights::Weight,
 	};
 	use frame_system::{EventRecord, Pallet as System, Phase};
@@ -749,6 +869,7 @@ pub(crate) mod tests {
 	type DbWeight = <TestRuntime as frame_system::Config>::DbWeight;
 
 	pub(crate) fn initialize(state_root: RelayBlockHash) -> RelayBlockHash {
+		pallet_bridge_grandpa::FreeHeadersRemaining::<TestRuntime, BridgesGrandpaPalletInstance>::set(Some(100));
 		pallet_bridge_grandpa::Pallet::<TestRuntime, BridgesGrandpaPalletInstance>::initialize(
 			RuntimeOrigin::root(),
 			bp_header_chain::InitializationData {
@@ -770,10 +891,6 @@ pub(crate) mod tests {
 		num: RelayBlockNumber,
 		state_root: RelayBlockHash,
 	) -> (ParaHash, GrandpaJustification<RelayBlockHeader>) {
-		pallet_bridge_grandpa::Pallet::<TestRuntime, BridgesGrandpaPalletInstance>::on_initialize(
-			0,
-		);
-
 		let header = test_relay_header(num, state_root);
 		let hash = header.hash();
 		let justification = make_default_justification(&header);
@@ -783,6 +900,7 @@ pub(crate) mod tests {
 				Box::new(header),
 				justification.clone(),
 				TEST_GRANDPA_SET_ID,
+				false,
 			)
 		);
 
@@ -908,7 +1026,7 @@ pub(crate) mod tests {
 		run_test(|| {
 			initialize(state_root);
 
-			// we're trying to update heads of parachains 1, 2 and 3
+			// we're trying to update heads of parachains 1 and 3
 			let expected_weight =
 				WeightInfo::submit_parachain_heads_weight(DbWeight::get(), &proof, 2);
 			let result = Pallet::<TestRuntime>::submit_parachain_heads(
@@ -918,9 +1036,10 @@ pub(crate) mod tests {
 				proof,
 			);
 			assert_ok!(result);
+			assert_eq!(result.expect("checked above").pays_fee, Pays::Yes);
 			assert_eq!(result.expect("checked above").actual_weight, Some(expected_weight));
 
-			// but only 1 and 2 are updated, because proof is missing head of parachain#2
+			// 1 and 3 are updated, because proof is missing head of parachain#2
 			assert_eq!(ParasInfo::<TestRuntime>::get(ParaId(1)), Some(initial_best_head(1)));
 			assert_eq!(ParasInfo::<TestRuntime>::get(ParaId(2)), None);
 			assert_eq!(
@@ -989,7 +1108,9 @@ pub(crate) mod tests {
 		run_test(|| {
 			// start with relay block #0 and import head#5 of parachain#1
 			initialize(state_root_5);
-			assert_ok!(import_parachain_1_head(0, state_root_5, parachains_5, proof_5));
+			let result = import_parachain_1_head(0, state_root_5, parachains_5, proof_5);
+			// first parachain head is imported for free
+			assert_eq!(result.unwrap().pays_fee, Pays::No);
 			assert_eq!(
 				ParasInfo::<TestRuntime>::get(ParaId(1)),
 				Some(ParaInfo {
@@ -1024,7 +1145,9 @@ pub(crate) mod tests {
 
 			// import head#10 of parachain#1 at relay block #1
 			let (relay_1_hash, justification) = proceed(1, state_root_10);
-			assert_ok!(import_parachain_1_head(1, state_root_10, parachains_10, proof_10));
+			let result = import_parachain_1_head(1, state_root_10, parachains_10, proof_10);
+			// second parachain head is imported for fee
+			assert_eq!(result.unwrap().pays_fee, Pays::Yes);
 			assert_eq!(
 				ParasInfo::<TestRuntime>::get(ParaId(1)),
 				Some(ParaInfo {
@@ -1647,4 +1770,143 @@ pub(crate) mod tests {
 			);
 		})
 	}
+
+	#[test]
+	fn may_be_free_for_submitting_filtered_heads() {
+		run_test(|| {
+			let (state_root, proof, parachains) =
+				prepare_parachain_heads_proof::<RegularParachainHeader>(vec![(2, head_data(2, 5))]);
+			// start with relay block #0 and import head#5 of parachain#2
+			initialize(state_root);
+			// first submission is free
+			let result = Pallet::<TestRuntime>::submit_parachain_heads(
+				RuntimeOrigin::signed(1),
+				(0, test_relay_header(0, state_root).hash()),
+				parachains.clone(),
+				proof.clone(),
+			);
+			assert_eq!(result.unwrap().pays_fee, Pays::No);
+			// next submission is NOT free, because we haven't updated anything
+			let result = Pallet::<TestRuntime>::submit_parachain_heads(
+				RuntimeOrigin::signed(1),
+				(0, test_relay_header(0, state_root).hash()),
+				parachains,
+				proof,
+			);
+			assert_eq!(result.unwrap().pays_fee, Pays::Yes);
+			// then we submit new head, proved at relay block `FreeHeadersInterval - 1` => Pays::Yes
+			let (state_root, proof, parachains) = prepare_parachain_heads_proof::<
+				RegularParachainHeader,
+			>(vec![(2, head_data(2, 50))]);
+			let relay_block_number = FreeHeadersInterval::get() - 1;
+			proceed(relay_block_number, state_root);
+			let result = Pallet::<TestRuntime>::submit_parachain_heads(
+				RuntimeOrigin::signed(1),
+				(relay_block_number, test_relay_header(relay_block_number, state_root).hash()),
+				parachains,
+				proof,
+			);
+			assert_eq!(result.unwrap().pays_fee, Pays::Yes);
+			// then we submit new head, proved after `FreeHeadersInterval` => Pays::No
+			let (state_root, proof, parachains) = prepare_parachain_heads_proof::<
+				RegularParachainHeader,
+			>(vec![(2, head_data(2, 100))]);
+			let relay_block_number = relay_block_number + FreeHeadersInterval::get();
+			proceed(relay_block_number, state_root);
+			let result = Pallet::<TestRuntime>::submit_parachain_heads(
+				RuntimeOrigin::signed(1),
+				(relay_block_number, test_relay_header(relay_block_number, state_root).hash()),
+				parachains,
+				proof,
+			);
+			assert_eq!(result.unwrap().pays_fee, Pays::No);
+			// then we submit new BIG head, proved after `FreeHeadersInterval` => Pays::Yes
+			// then we submit new head, proved after `FreeHeadersInterval` => Pays::No
+			let mut large_head = head_data(2, 100);
+			large_head.0.extend(&[42u8; BigParachain::MAX_HEADER_SIZE as _]);
+			let (state_root, proof, parachains) =
+				prepare_parachain_heads_proof::<RegularParachainHeader>(vec![(2, large_head)]);
+			let relay_block_number = relay_block_number + FreeHeadersInterval::get();
+			proceed(relay_block_number, state_root);
+			let result = Pallet::<TestRuntime>::submit_parachain_heads(
+				RuntimeOrigin::signed(1),
+				(relay_block_number, test_relay_header(relay_block_number, state_root).hash()),
+				parachains,
+				proof,
+			);
+			assert_eq!(result.unwrap().pays_fee, Pays::Yes);
+		})
+	}
+
+	#[test]
+	fn grandpa_and_parachain_pallets_share_free_headers_counter() {
+		run_test(|| {
+			initialize(Default::default());
+			// set free headers limit to `4`
+			let mut free_headers_remaining = 4;
+			pallet_bridge_grandpa::FreeHeadersRemaining::<TestRuntime, BridgesGrandpaPalletInstance>::set(
+				Some(free_headers_remaining),
+			);
+			// import free GRANDPA and parachain headers
+			let mut relay_block_number = 0;
+			for i in 0..2 {
+				// import free GRANDPA header
+				let (state_root, proof, parachains) = prepare_parachain_heads_proof::<
+					RegularParachainHeader,
+				>(vec![(2, head_data(2, 5 + i))]);
+				relay_block_number = relay_block_number + FreeHeadersInterval::get();
+				proceed(relay_block_number, state_root);
+				assert_eq!(
+					pallet_bridge_grandpa::FreeHeadersRemaining::<
+						TestRuntime,
+						BridgesGrandpaPalletInstance,
+					>::get(),
+					Some(free_headers_remaining - 1),
+				);
+				free_headers_remaining = free_headers_remaining - 1;
+				// import free parachain header
+				assert_ok!(Pallet::<TestRuntime>::submit_parachain_heads(
+					RuntimeOrigin::signed(1),
+					(relay_block_number, test_relay_header(relay_block_number, state_root).hash()),
+					parachains,
+					proof,
+				),);
+				assert_eq!(
+					pallet_bridge_grandpa::FreeHeadersRemaining::<
+						TestRuntime,
+						BridgesGrandpaPalletInstance,
+					>::get(),
+					Some(free_headers_remaining - 1),
+				);
+				free_headers_remaining = free_headers_remaining - 1;
+			}
+			// try to import free GRANDPA header => non-free execution
+			let (state_root, proof, parachains) =
+				prepare_parachain_heads_proof::<RegularParachainHeader>(vec![(2, head_data(2, 7))]);
+			relay_block_number = relay_block_number + FreeHeadersInterval::get();
+			let result = pallet_bridge_grandpa::Pallet::<TestRuntime, BridgesGrandpaPalletInstance>::submit_finality_proof_ex(
+				RuntimeOrigin::signed(1),
+				Box::new(test_relay_header(relay_block_number, state_root)),
+				make_default_justification(&test_relay_header(relay_block_number, state_root)),
+				TEST_GRANDPA_SET_ID,
+				false,
+			);
+			assert_eq!(result.unwrap().pays_fee, Pays::Yes);
+			// try to import free parachain header => non-free execution
+			let result = Pallet::<TestRuntime>::submit_parachain_heads(
+				RuntimeOrigin::signed(1),
+				(relay_block_number, test_relay_header(relay_block_number, state_root).hash()),
+				parachains,
+				proof,
+			);
+			assert_eq!(result.unwrap().pays_fee, Pays::Yes);
+			assert_eq!(
+				pallet_bridge_grandpa::FreeHeadersRemaining::<
+					TestRuntime,
+					BridgesGrandpaPalletInstance,
+				>::get(),
+				Some(0),
+			);
+		});
+	}
 }
diff --git a/bridges/modules/parachains/src/mock.rs b/bridges/modules/parachains/src/mock.rs
index d9cbabf850ec99ee13baa0f8bfc013b1192bd000..dbb62845392d5fd2f408744f4f8a2321ec4bd34d 100644
--- a/bridges/modules/parachains/src/mock.rs
+++ b/bridges/modules/parachains/src/mock.rs
@@ -70,6 +70,7 @@ impl Chain for Parachain1 {
 
 impl Parachain for Parachain1 {
 	const PARACHAIN_ID: u32 = 1;
+	const MAX_HEADER_SIZE: u32 = 1_024;
 }
 
 pub struct Parachain2;
@@ -96,6 +97,7 @@ impl Chain for Parachain2 {
 
 impl Parachain for Parachain2 {
 	const PARACHAIN_ID: u32 = 2;
+	const MAX_HEADER_SIZE: u32 = 1_024;
 }
 
 pub struct Parachain3;
@@ -122,6 +124,7 @@ impl Chain for Parachain3 {
 
 impl Parachain for Parachain3 {
 	const PARACHAIN_ID: u32 = 3;
+	const MAX_HEADER_SIZE: u32 = 1_024;
 }
 
 // this parachain is using u128 as block number and stored head data size exceeds limit
@@ -149,6 +152,7 @@ impl Chain for BigParachain {
 
 impl Parachain for BigParachain {
 	const PARACHAIN_ID: u32 = 4;
+	const MAX_HEADER_SIZE: u32 = 2_048;
 }
 
 construct_runtime! {
@@ -168,12 +172,14 @@ impl frame_system::Config for TestRuntime {
 
 parameter_types! {
 	pub const HeadersToKeep: u32 = 5;
+	pub const FreeHeadersInterval: u32 = 15;
 }
 
 impl pallet_bridge_grandpa::Config<pallet_bridge_grandpa::Instance1> for TestRuntime {
 	type RuntimeEvent = RuntimeEvent;
 	type BridgedChain = TestBridgedChain;
-	type MaxFreeMandatoryHeadersPerBlock = ConstU32<2>;
+	type MaxFreeHeadersPerBlock = ConstU32<2>;
+	type FreeHeadersInterval = FreeHeadersInterval;
 	type HeadersToKeep = HeadersToKeep;
 	type WeightInfo = ();
 }
@@ -181,7 +187,8 @@ impl pallet_bridge_grandpa::Config<pallet_bridge_grandpa::Instance1> for TestRun
 impl pallet_bridge_grandpa::Config<pallet_bridge_grandpa::Instance2> for TestRuntime {
 	type RuntimeEvent = RuntimeEvent;
 	type BridgedChain = TestBridgedChain;
-	type MaxFreeMandatoryHeadersPerBlock = ConstU32<2>;
+	type MaxFreeHeadersPerBlock = ConstU32<2>;
+	type FreeHeadersInterval = FreeHeadersInterval;
 	type HeadersToKeep = HeadersToKeep;
 	type WeightInfo = ();
 }
diff --git a/bridges/modules/parachains/src/weights_ext.rs b/bridges/modules/parachains/src/weights_ext.rs
index 393086a85690fcc2846c1708bc788e1d67a61d66..64dad625de08b3fd0cd96c255ee80fafa8df2be9 100644
--- a/bridges/modules/parachains/src/weights_ext.rs
+++ b/bridges/modules/parachains/src/weights_ext.rs
@@ -36,6 +36,20 @@ pub const EXTRA_STORAGE_PROOF_SIZE: u32 = 1024;
 
 /// Extended weight info.
 pub trait WeightInfoExt: WeightInfo {
+	// Our configuration assumes that the runtime has special signed extensions used to:
+	//
+	// 1) boost priority of `submit_parachain_heads` transactions;
+	//
+	// 2) slash relayer if he submits an invalid transaction.
+	//
+	// We read and update storage values of other pallets (`pallet-bridge-relayers` and
+	// balances/assets pallet). So we need to add this weight to the weight of our call.
+	// Hence two following methods.
+
+	/// Extra weight that is added to the `submit_finality_proof` call weight by signed extensions
+	/// that are declared at runtime level.
+	fn submit_parachain_heads_overhead_from_runtime() -> Weight;
+
 	/// Storage proof overhead, that is included in every storage proof.
 	///
 	/// The relayer would pay some extra fee for additional proof bytes, since they mean
@@ -65,7 +79,10 @@ pub trait WeightInfoExt: WeightInfo {
 		let pruning_weight =
 			Self::parachain_head_pruning_weight(db_weight).saturating_mul(parachains_count as u64);
 
-		base_weight.saturating_add(proof_size_overhead).saturating_add(pruning_weight)
+		base_weight
+			.saturating_add(proof_size_overhead)
+			.saturating_add(pruning_weight)
+			.saturating_add(Self::submit_parachain_heads_overhead_from_runtime())
 	}
 
 	/// Returns weight of single parachain head storage update.
@@ -95,12 +112,20 @@ pub trait WeightInfoExt: WeightInfo {
 }
 
 impl WeightInfoExt for () {
+	fn submit_parachain_heads_overhead_from_runtime() -> Weight {
+		Weight::zero()
+	}
+
 	fn expected_extra_storage_proof_size() -> u32 {
 		EXTRA_STORAGE_PROOF_SIZE
 	}
 }
 
 impl<T: frame_system::Config> WeightInfoExt for BridgeWeight<T> {
+	fn submit_parachain_heads_overhead_from_runtime() -> Weight {
+		Weight::zero()
+	}
+
 	fn expected_extra_storage_proof_size() -> u32 {
 		EXTRA_STORAGE_PROOF_SIZE
 	}
diff --git a/bridges/primitives/parachains/src/lib.rs b/bridges/primitives/parachains/src/lib.rs
index 692bbd99ecef38535bb65a18dac09a77f1f1eca2..142c6e9b08923fdd2934fb7f3b9c2d12788fc8b9 100644
--- a/bridges/primitives/parachains/src/lib.rs
+++ b/bridges/primitives/parachains/src/lib.rs
@@ -116,6 +116,10 @@ impl ParaStoredHeaderData {
 
 /// Stored parachain head data builder.
 pub trait ParaStoredHeaderDataBuilder {
+	/// Maximal parachain head size that we may accept for free. All heads above
+	/// this limit are submitted for a regular fee.
+	fn max_free_head_size() -> u32;
+
 	/// Return number of parachains that are supported by this builder.
 	fn supported_parachains() -> u32;
 
@@ -127,6 +131,10 @@ pub trait ParaStoredHeaderDataBuilder {
 pub struct SingleParaStoredHeaderDataBuilder<C: Parachain>(PhantomData<C>);
 
 impl<C: Parachain> ParaStoredHeaderDataBuilder for SingleParaStoredHeaderDataBuilder<C> {
+	fn max_free_head_size() -> u32 {
+		C::MAX_HEADER_SIZE
+	}
+
 	fn supported_parachains() -> u32 {
 		1
 	}
@@ -147,6 +155,17 @@ impl<C: Parachain> ParaStoredHeaderDataBuilder for SingleParaStoredHeaderDataBui
 #[impl_trait_for_tuples::impl_for_tuples(1, 30)]
 #[tuple_types_custom_trait_bound(Parachain)]
 impl ParaStoredHeaderDataBuilder for C {
+	fn max_free_head_size() -> u32 {
+		let mut result = 0_u32;
+		for_tuples!( #(
+			result = sp_std::cmp::max(
+				result,
+				SingleParaStoredHeaderDataBuilder::<C>::max_free_head_size(),
+			);
+		)* );
+		result
+	}
+
 	fn supported_parachains() -> u32 {
 		let mut result = 0;
 		for_tuples!( #(
diff --git a/bridges/primitives/runtime/src/chain.rs b/bridges/primitives/runtime/src/chain.rs
index 4ec5a001a99ecad21617ed0afc57d3edac383d0d..1b1c623104f9da5ccbbea40b0f787025995bfdb0 100644
--- a/bridges/primitives/runtime/src/chain.rs
+++ b/bridges/primitives/runtime/src/chain.rs
@@ -236,6 +236,12 @@ where
 pub trait Parachain: Chain {
 	/// Parachain identifier.
 	const PARACHAIN_ID: u32;
+	/// Maximal size of the parachain header.
+	///
+	/// This isn't a strict limit. The relayer may submit larger headers and the
+	/// pallet will accept the call. The limit is only used to compute whether
+	/// the refund can be made.
+	const MAX_HEADER_SIZE: u32;
 }
 
 impl<T> Parachain for T
@@ -244,6 +250,8 @@ where
 	<T as UnderlyingChainProvider>::Chain: Parachain,
 {
 	const PARACHAIN_ID: u32 = <<T as UnderlyingChainProvider>::Chain as Parachain>::PARACHAIN_ID;
+	const MAX_HEADER_SIZE: u32 =
+		<<T as UnderlyingChainProvider>::Chain as Parachain>::MAX_HEADER_SIZE;
 }
 
 /// Adapter for `Get<u32>` to access `PARACHAIN_ID` from `trait Parachain`
@@ -306,6 +314,11 @@ macro_rules! decl_bridge_finality_runtime_apis {
 				pub const [<BEST_FINALIZED_ $chain:upper _HEADER_METHOD>]: &str =
 					stringify!([<$chain:camel FinalityApi_best_finalized>]);
 
+				/// Name of the `<ThisChain>FinalityApi::free_headers_interval` runtime method.
+				pub const [<FREE_HEADERS_INTERVAL_FOR_ $chain:upper _METHOD>]: &str =
+					stringify!([<$chain:camel FinalityApi_free_headers_interval>]);
+
+
 				$(
 					/// Name of the `<ThisChain>FinalityApi::accepted_<consensus>_finality_proofs`
 					/// runtime method.
@@ -322,6 +335,13 @@ macro_rules! decl_bridge_finality_runtime_apis {
 						/// Returns number and hash of the best finalized header known to the bridge module.
 						fn best_finalized() -> Option<bp_runtime::HeaderId<Hash, BlockNumber>>;
 
+						/// Returns free headers interval, if it is configured in the runtime.
+						/// The caller expects that if his transaction improves best known header
+						/// at least by the free_headers_interval`, it will be fee-free.
+						///
+						/// See [`pallet_bridge_grandpa::Config::FreeHeadersInterval`] for details.
+						fn free_headers_interval() -> Option<BlockNumber>;
+
 						$(
 							/// Returns the justifications accepted in the current block.
 							fn [<synced_headers_ $consensus:lower _info>](
diff --git a/bridges/relays/client-substrate/src/test_chain.rs b/bridges/relays/client-substrate/src/test_chain.rs
index 77240d15884f4512458772b16f14f27b44f57f39..d1203a2c58eaec3dd758913a2ce3cf778290102d 100644
--- a/bridges/relays/client-substrate/src/test_chain.rs
+++ b/bridges/relays/client-substrate/src/test_chain.rs
@@ -110,6 +110,7 @@ impl bp_runtime::Chain for TestParachainBase {
 
 impl bp_runtime::Parachain for TestParachainBase {
 	const PARACHAIN_ID: u32 = 1000;
+	const MAX_HEADER_SIZE: u32 = 1_024;
 }
 
 /// Parachain that may be used in tests.
diff --git a/bridges/testing/environments/rococo-westend/bridge_hub_rococo_local_network.toml b/bridges/testing/environments/rococo-westend/bridge_hub_rococo_local_network.toml
index 52271f9442131923f8a758b16df7610e73813d15..f59f689bf6b5c40a09854b93eb7927fc4b5929c9 100644
--- a/bridges/testing/environments/rococo-westend/bridge_hub_rococo_local_network.toml
+++ b/bridges/testing/environments/rococo-westend/bridge_hub_rococo_local_network.toml
@@ -40,7 +40,7 @@ cumulus_based = true
 	rpc_port = 8933
 	ws_port = 8943
 	args = [
-		"-lparachain=debug,runtime::bridge-hub=trace,runtime::bridge=trace,runtime::bridge-dispatch=trace,bridge=trace,runtime::bridge-messages=trace,xcm=trace"
+		"-lparachain=debug,runtime::bridge=trace,xcm=trace,txpool=trace"
 	]
 
 	# run bob as parachain collator
@@ -51,7 +51,7 @@ cumulus_based = true
 	rpc_port = 8934
 	ws_port = 8944
 	args = [
-		"-lparachain=trace,runtime::bridge-hub=trace,runtime::bridge=trace,runtime::bridge-dispatch=trace,bridge=trace,runtime::bridge-messages=trace,xcm=trace"
+		"-lparachain=debug,runtime::bridge=trace,xcm=trace,txpool=trace"
 	]
 
 [[parachains]]
@@ -65,14 +65,14 @@ cumulus_based = true
 	ws_port = 9910
 	command = "{{POLKADOT_PARACHAIN_BINARY}}"
 	args = [
-		"-lparachain=debug,xcm=trace,runtime::bridge-transfer=trace"
+		"-lparachain=debug,xcm=trace,runtime::bridge=trace,txpool=trace"
 	]
 
 	[[parachains.collators]]
 	name = "asset-hub-rococo-collator2"
 	command = "{{POLKADOT_PARACHAIN_BINARY}}"
 	args = [
-		"-lparachain=debug,xcm=trace,runtime::bridge-transfer=trace"
+		"-lparachain=debug,xcm=trace,runtime::bridge=trace,txpool=trace"
 	]
 
 #[[hrmp_channels]]
diff --git a/bridges/testing/environments/rococo-westend/bridge_hub_westend_local_network.toml b/bridges/testing/environments/rococo-westend/bridge_hub_westend_local_network.toml
index f2550bcc9959638b21ea78043cca3bc12d3d23ea..6ab03ad5fe2c380ea4201bf8ef2a2cf405fe314b 100644
--- a/bridges/testing/environments/rococo-westend/bridge_hub_westend_local_network.toml
+++ b/bridges/testing/environments/rococo-westend/bridge_hub_westend_local_network.toml
@@ -40,7 +40,7 @@ cumulus_based = true
 	rpc_port = 8935
 	ws_port = 8945
 	args = [
-		"-lparachain=debug,runtime::mmr=info,substrate=info,runtime=info,runtime::bridge-hub=trace,runtime::bridge=trace,runtime::bridge-dispatch=trace,bridge=trace,runtime::bridge-messages=trace,xcm=trace"
+		"-lparachain=debug,runtime::bridge=trace,xcm=trace,txpool=trace"
 	]
 
 	# run bob as parachain collator
@@ -51,7 +51,7 @@ cumulus_based = true
 	rpc_port = 8936
 	ws_port = 8946
 	args = [
-		"-lparachain=trace,runtime::mmr=info,substrate=info,runtime=info,runtime::bridge-hub=trace,runtime::bridge=trace,runtime::bridge-dispatch=trace,bridge=trace,runtime::bridge-messages=trace,xcm=trace"
+		"-lparachain=debug,runtime::bridge=trace,xcm=trace,txpool=trace"
 	]
 
 [[parachains]]
@@ -65,14 +65,14 @@ cumulus_based = true
 	ws_port = 9010
 	command = "{{POLKADOT_PARACHAIN_BINARY}}"
 	args = [
-		"-lparachain=debug,xcm=trace,runtime::bridge-transfer=trace"
+		"-lparachain=debug,xcm=trace,runtime::bridge=trace,txpool=trace"
 	]
 
 	[[parachains.collators]]
 	name = "asset-hub-westend-collator2"
 	command = "{{POLKADOT_PARACHAIN_BINARY}}"
 	args = [
-		"-lparachain=debug,xcm=trace,runtime::bridge-transfer=trace"
+		"-lparachain=debug,xcm=trace,runtime::bridge=trace,txpool=trace"
 	]
 
 #[[hrmp_channels]]
diff --git a/bridges/testing/environments/rococo-westend/bridges_rococo_westend.sh b/bridges/testing/environments/rococo-westend/bridges_rococo_westend.sh
index 41aa862be5764ea93fbe09fa706621486131d4c6..2f11692d97b9d2ff1853b9e85f7f30f884702bc7 100755
--- a/bridges/testing/environments/rococo-westend/bridges_rococo_westend.sh
+++ b/bridges/testing/environments/rococo-westend/bridges_rococo_westend.sh
@@ -169,12 +169,107 @@ function run_relay() {
         --lane "${LANE_ID}"
 }
 
+function run_finality_relay() {
+    local relayer_path=$(ensure_relayer)
+
+    RUST_LOG=runtime=trace,rpc=trace,bridge=trace \
+        $relayer_path relay-headers rococo-to-bridge-hub-westend \
+        --only-free-headers \
+        --source-host localhost \
+        --source-port 9942 \
+        --target-host localhost \
+        --target-port 8945 \
+        --target-version-mode Auto \
+        --target-signer //Charlie \
+        --target-transactions-mortality 4&
+
+    RUST_LOG=runtime=trace,rpc=trace,bridge=trace \
+        $relayer_path relay-headers westend-to-bridge-hub-rococo \
+        --only-free-headers \
+        --source-host localhost \
+        --source-port 9945 \
+        --target-host localhost \
+        --target-port 8943 \
+        --target-version-mode Auto \
+        --target-signer //Charlie \
+        --target-transactions-mortality 4
+}
+
+function run_parachains_relay() {
+    local relayer_path=$(ensure_relayer)
+
+    RUST_LOG=runtime=trace,rpc=trace,bridge=trace \
+        $relayer_path relay-parachains rococo-to-bridge-hub-westend \
+        --only-free-headers \
+        --source-host localhost \
+        --source-port 9942 \
+        --target-host localhost \
+        --target-port 8945 \
+        --target-version-mode Auto \
+        --target-signer //Dave \
+        --target-transactions-mortality 4&
+
+    RUST_LOG=runtime=trace,rpc=trace,bridge=trace \
+        $relayer_path relay-parachains westend-to-bridge-hub-rococo \
+        --only-free-headers \
+        --source-host localhost \
+        --source-port 9945 \
+        --target-host localhost \
+        --target-port 8943 \
+        --target-version-mode Auto \
+        --target-signer //Dave \
+        --target-transactions-mortality 4
+}
+
+function run_messages_relay() {
+    local relayer_path=$(ensure_relayer)
+
+    RUST_LOG=runtime=trace,rpc=trace,bridge=trace \
+        $relayer_path relay-messages bridge-hub-rococo-to-bridge-hub-westend \
+        --source-host localhost \
+        --source-port 8943 \
+        --source-version-mode Auto \
+        --source-signer //Eve \
+        --source-transactions-mortality 4 \
+        --target-host localhost \
+        --target-port 8945 \
+        --target-version-mode Auto \
+        --target-signer //Eve \
+        --target-transactions-mortality 4 \
+        --lane $LANE_ID&
+
+    RUST_LOG=runtime=trace,rpc=trace,bridge=trace \
+        $relayer_path relay-messages bridge-hub-westend-to-bridge-hub-rococo \
+        --source-host localhost \
+        --source-port 8945 \
+        --source-version-mode Auto \
+        --source-signer //Ferdie \
+        --source-transactions-mortality 4 \
+        --target-host localhost \
+        --target-port 8943 \
+        --target-version-mode Auto \
+        --target-signer //Ferdie \
+        --target-transactions-mortality 4 \
+        --lane $LANE_ID
+}
+
 case "$1" in
   run-relay)
     init_wnd_ro
     init_ro_wnd
     run_relay
     ;;
+  run-finality-relay)
+    init_wnd_ro
+    init_ro_wnd
+    run_finality_relay
+    ;;
+  run-parachains-relay)
+    run_parachains_relay
+    ;;
+  run-messages-relay)
+    run_messages_relay
+    ;;
   init-asset-hub-rococo-local)
       ensure_polkadot_js_api
       # create foreign assets for native Westend token (governance call on Rococo)
@@ -386,6 +481,9 @@ case "$1" in
     echo "A command is require. Supported commands for:
     Local (zombienet) run:
           - run-relay
+          - run-finality-relay
+          - run-parachains-relay
+          - run-messages-relay
           - init-asset-hub-rococo-local
           - init-bridge-hub-rococo-local
           - init-asset-hub-westend-local
diff --git a/bridges/testing/environments/rococo-westend/explorers.sh b/bridges/testing/environments/rococo-westend/explorers.sh
new file mode 100755
index 0000000000000000000000000000000000000000..fb137726c93cb789c6a03fb22d913b4ee5a822bd
--- /dev/null
+++ b/bridges/testing/environments/rococo-westend/explorers.sh
@@ -0,0 +1,11 @@
+#!/bin/bash
+
+# Rococo AH
+xdg-open https://polkadot.js.org/apps/?rpc=ws://127.0.0.1:9910#/explorer&
+# Rococo BH
+xdg-open https://polkadot.js.org/apps/?rpc=ws://127.0.0.1:8943#/explorer&
+
+# Westend BH
+xdg-open https://polkadot.js.org/apps/?rpc=ws://127.0.0.1:8945#/explorer&
+# Westend AH
+xdg-open https://polkadot.js.org/apps/?rpc=ws://127.0.0.1:9010#/explorer&
diff --git a/bridges/testing/environments/rococo-westend/helper.sh b/bridges/testing/environments/rococo-westend/helper.sh
index 0a13ded213f5d3a0920cb466fc974c129e9ad79a..571c78fea584893b5c24c7f1b2b68335559bf26f 100755
--- a/bridges/testing/environments/rococo-westend/helper.sh
+++ b/bridges/testing/environments/rococo-westend/helper.sh
@@ -1,3 +1,9 @@
 #!/bin/bash
 
-$ENV_PATH/bridges_rococo_westend.sh "$@"
+if [ $1 == "auto-log" ]; then
+    shift # ignore "auto-log"
+    log_name=$1
+    $ENV_PATH/bridges_rococo_westend.sh "$@" >$TEST_DIR/logs/$log_name.log
+else
+    $ENV_PATH/bridges_rococo_westend.sh "$@"
+fi
diff --git a/bridges/testing/environments/rococo-westend/spawn.sh b/bridges/testing/environments/rococo-westend/spawn.sh
index cbd0b1bc623ab77876ed5ce3beefd7ab72db2d37..a0ab00be14448f92bf31f2eea2eba91c2ac5240e 100755
--- a/bridges/testing/environments/rococo-westend/spawn.sh
+++ b/bridges/testing/environments/rococo-westend/spawn.sh
@@ -59,12 +59,12 @@ if [[ $init -eq 1 ]]; then
 fi
 
 if [[ $start_relayer -eq 1 ]]; then
-  ${BASH_SOURCE%/*}/start_relayer.sh $rococo_dir $westend_dir relayer_pid
+  ${BASH_SOURCE%/*}/start_relayer.sh $rococo_dir $westend_dir finality_relayer_pid parachains_relayer_pid messages_relayer_pid
 fi
 
 echo $rococo_dir > $TEST_DIR/rococo.env
 echo $westend_dir > $TEST_DIR/westend.env
 echo
 
-wait -n $rococo_pid $westend_pid $relayer_pid
+wait -n $rococo_pid $westend_pid $finality_relayer_pid $parachains_relayer_pid $messages_relayer_pid
 kill -9 -$$
diff --git a/bridges/testing/environments/rococo-westend/start_relayer.sh b/bridges/testing/environments/rococo-westend/start_relayer.sh
index 7ddd312d395aa8733d2afea59277b48721c8a36b..9c57e4a6ab6e198e10e8c233c9c9e64a3499a0f4 100755
--- a/bridges/testing/environments/rococo-westend/start_relayer.sh
+++ b/bridges/testing/environments/rococo-westend/start_relayer.sh
@@ -7,17 +7,31 @@ source "$FRAMEWORK_PATH/utils/zombienet.sh"
 
 rococo_dir=$1
 westend_dir=$2
-__relayer_pid=$3
+__finality_relayer_pid=$3
+__parachains_relayer_pid=$4
+__messages_relayer_pid=$5
 
 logs_dir=$TEST_DIR/logs
 helper_script="${BASH_SOURCE%/*}/helper.sh"
 
-relayer_log=$logs_dir/relayer.log
-echo -e "Starting rococo-westend relayer. Logs available at: $relayer_log\n"
-start_background_process "$helper_script run-relay" $relayer_log relayer_pid
+# start finality relayer
+finality_relayer_log=$logs_dir/relayer_finality.log
+echo -e "Starting rococo-westend finality relayer. Logs available at: $finality_relayer_log\n"
+start_background_process "$helper_script run-finality-relay" $finality_relayer_log finality_relayer_pid
+
+# start parachains relayer
+parachains_relayer_log=$logs_dir/relayer_parachains.log
+echo -e "Starting rococo-westend parachains relayer. Logs available at: $parachains_relayer_log\n"
+start_background_process "$helper_script run-parachains-relay" $parachains_relayer_log parachains_relayer_pid
+
+# start messages relayer
+messages_relayer_log=$logs_dir/relayer_messages.log
+echo -e "Starting rococo-westend messages relayer. Logs available at: $messages_relayer_log\n"
+start_background_process "$helper_script run-messages-relay" $messages_relayer_log messages_relayer_pid
 
 run_zndsl ${BASH_SOURCE%/*}/rococo.zndsl $rococo_dir
 run_zndsl ${BASH_SOURCE%/*}/westend.zndsl $westend_dir
 
-eval $__relayer_pid="'$relayer_pid'"
-
+eval $__finality_relayer_pid="'$finality_relayer_pid'"
+eval $__parachains_relayer_pid="'$parachains_relayer_pid'"
+eval $__messages_relayer_pid="'$messages_relayer_pid'"
diff --git a/bridges/testing/framework/js-helpers/native-asset-balance.js b/bridges/testing/framework/js-helpers/native-asset-balance.js
new file mode 100644
index 0000000000000000000000000000000000000000..4869eba35d8dd53278793f89b1fd38d2d703aa3b
--- /dev/null
+++ b/bridges/testing/framework/js-helpers/native-asset-balance.js
@@ -0,0 +1,12 @@
+async function run(nodeName, networkInfo, args) {
+    const {wsUri, userDefinedTypes} = networkInfo.nodesByName[nodeName];
+    const api = await zombie.connect(wsUri, userDefinedTypes);
+
+    const accountAddress = args[0];
+    const accountData = await api.query.system.account(accountAddress);
+    const accountBalance = accountData.data['free'];
+    console.log("Balance of " + accountAddress + ": " + accountBalance);
+    return accountBalance;
+}
+
+module.exports = {run}
diff --git a/bridges/testing/tests/0001-asset-transfer/roc-reaches-westend.zndsl b/bridges/testing/tests/0001-asset-transfer/roc-reaches-westend.zndsl
index cdb7d28e940cf1ac90562e761cdbad00e95e1748..6e26632fd9f9cc30b108476ea414ef432254e32e 100644
--- a/bridges/testing/tests/0001-asset-transfer/roc-reaches-westend.zndsl
+++ b/bridges/testing/tests/0001-asset-transfer/roc-reaches-westend.zndsl
@@ -3,10 +3,10 @@ Network: {{ENV_PATH}}/bridge_hub_westend_local_network.toml
 Creds: config
 
 # send 5 ROC to //Alice from Rococo AH to Westend AH
-asset-hub-westend-collator1: run {{ENV_PATH}}/helper.sh with "reserve-transfer-assets-from-asset-hub-rococo-local 5000000000000" within 120 seconds
+asset-hub-westend-collator1: run {{ENV_PATH}}/helper.sh with "auto-log reserve-transfer-assets-from-asset-hub-rococo-local 5000000000000" within 120 seconds
 
 # check that //Alice received at least 4.8 ROC on Westend AH
 asset-hub-westend-collator1: js-script {{FRAMEWORK_PATH}}/js-helpers/wrapped-assets-balance.js with "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY,4800000000000,Rococo" within 600 seconds
 
-# check that the relayer //Charlie is rewarded by Westend AH
-bridge-hub-westend-collator1: js-script {{FRAMEWORK_PATH}}/js-helpers/relayer-rewards.js with "5FLSigC9HGRKVhB9FiEo4Y3koPsNmBmLJbpXg2mp1hXcS59Y,0x00000002,0x6268726F,ThisChain,0" within 30 seconds
+# relayer //Ferdie is rewarded for delivering messages from Rococo BH
+bridge-hub-westend-collator1: js-script {{FRAMEWORK_PATH}}/js-helpers/relayer-rewards.js with "5HGjWAeFDfFCWPsjFQdVV2Msvz2XtMktvgocEZcCj68kUMaw,0x00000002,0x6268726F,ThisChain,0" within 300 seconds
diff --git a/bridges/testing/tests/0001-asset-transfer/roc-relayer-balance-does-not-change.zndsl b/bridges/testing/tests/0001-asset-transfer/roc-relayer-balance-does-not-change.zndsl
new file mode 100644
index 0000000000000000000000000000000000000000..4839c19c0ff2b6343718711d117c86834f6fa6b8
--- /dev/null
+++ b/bridges/testing/tests/0001-asset-transfer/roc-relayer-balance-does-not-change.zndsl
@@ -0,0 +1,11 @@
+Description: Finality and parachain relays should have the constant balance, because their transactions are free
+Network: {{ENV_PATH}}/bridge_hub_rococo_local_network.toml
+Creds: config
+
+# local chain spec gives `1u64 << 60` tokens to every endowed account: if it'll ever
+# change, it'd need to be fixed here as well
+
+# //Charlie only submits free and mandatory relay chain headers, so the balance should stay the same
+bridge-hub-rococo-collator1: js-script {{FRAMEWORK_PATH}}/js-helpers/native-asset-balance.js with "5FLSigC9HGRKVhB9FiEo4Y3koPsNmBmLJbpXg2mp1hXcS59Y" return is 1152921504606846976 within 30 seconds
+# //Dave only submits free parachain headers, so the balance should stay the same
+bridge-hub-rococo-collator1: js-script {{FRAMEWORK_PATH}}/js-helpers/native-asset-balance.js with "5DAAnrj7VHTznn2AWBemMuyBwZWs6FNFjdyVXUeYum3PTXFy" return is 1152921504606846976 within 30 seconds
diff --git a/bridges/testing/tests/0001-asset-transfer/run.sh b/bridges/testing/tests/0001-asset-transfer/run.sh
index a7bb122919b40187c49e89c489d2271d646bff40..227069932f2d985da05c82b88247da0542e46c58 100755
--- a/bridges/testing/tests/0001-asset-transfer/run.sh
+++ b/bridges/testing/tests/0001-asset-transfer/run.sh
@@ -18,8 +18,14 @@ ensure_process_file $env_pid $TEST_DIR/westend.env 300
 westend_dir=`cat $TEST_DIR/westend.env`
 echo
 
+run_zndsl ${BASH_SOURCE%/*}/roc-relayer-balance-does-not-change.zndsl $rococo_dir
+run_zndsl ${BASH_SOURCE%/*}/wnd-relayer-balance-does-not-change.zndsl $westend_dir
+
 run_zndsl ${BASH_SOURCE%/*}/roc-reaches-westend.zndsl $westend_dir
 run_zndsl ${BASH_SOURCE%/*}/wnd-reaches-rococo.zndsl $rococo_dir
 
 run_zndsl ${BASH_SOURCE%/*}/wroc-reaches-rococo.zndsl $rococo_dir
 run_zndsl ${BASH_SOURCE%/*}/wwnd-reaches-westend.zndsl $westend_dir
+
+run_zndsl ${BASH_SOURCE%/*}/roc-relayer-balance-does-not-change.zndsl $rococo_dir
+run_zndsl ${BASH_SOURCE%/*}/wnd-relayer-balance-does-not-change.zndsl $westend_dir
diff --git a/bridges/testing/tests/0001-asset-transfer/wnd-reaches-rococo.zndsl b/bridges/testing/tests/0001-asset-transfer/wnd-reaches-rococo.zndsl
index dbc03864e2b6e5e10636532ad965860b381fa8f2..5a8d6dabc20e3060e92ef6feef8211b7353d23d1 100644
--- a/bridges/testing/tests/0001-asset-transfer/wnd-reaches-rococo.zndsl
+++ b/bridges/testing/tests/0001-asset-transfer/wnd-reaches-rococo.zndsl
@@ -3,10 +3,10 @@ Network: {{ENV_PATH}}/bridge_hub_rococo_local_network.toml
 Creds: config
 
 # send 5 WND to //Alice from Westend AH to Rococo AH
-asset-hub-rococo-collator1: run {{ENV_PATH}}/helper.sh with "reserve-transfer-assets-from-asset-hub-westend-local 5000000000000" within 120 seconds
+asset-hub-rococo-collator1: run {{ENV_PATH}}/helper.sh with "auto-log reserve-transfer-assets-from-asset-hub-westend-local 5000000000000" within 120 seconds
 
 # check that //Alice received at least 4.8 WND on Rococo AH
 asset-hub-rococo-collator1: js-script {{FRAMEWORK_PATH}}/js-helpers/wrapped-assets-balance.js with "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY,4800000000000,Westend" within 600 seconds
 
-# check that the relayer //Charlie is rewarded by Rococo AH
-bridge-hub-rococo-collator1: js-script {{FRAMEWORK_PATH}}/js-helpers/relayer-rewards.js with "5FLSigC9HGRKVhB9FiEo4Y3koPsNmBmLJbpXg2mp1hXcS59Y,0x00000002,0x62687764,ThisChain,0" within 30 seconds
+# relayer //Eve is rewarded for delivering messages from Westend BH
+bridge-hub-rococo-collator1: js-script {{FRAMEWORK_PATH}}/js-helpers/relayer-rewards.js with "5CiPPseXPECbkjWCa6MnjNokrgYjMqmKndv2rSnekmSK2DjL,0x00000002,0x62687764,ThisChain,0" within 300 seconds
diff --git a/bridges/testing/tests/0001-asset-transfer/wnd-relayer-balance-does-not-change.zndsl b/bridges/testing/tests/0001-asset-transfer/wnd-relayer-balance-does-not-change.zndsl
new file mode 100644
index 0000000000000000000000000000000000000000..d2563e1807869754cbe3153d973467826b1c71fd
--- /dev/null
+++ b/bridges/testing/tests/0001-asset-transfer/wnd-relayer-balance-does-not-change.zndsl
@@ -0,0 +1,11 @@
+Description: Finality and parachain relays should have the constant balance, because their transactions are free
+Network: {{ENV_PATH}}/bridge_hub_westend_local_network.toml
+Creds: config
+
+# local chain spec gives `1u64 << 60` tokens to every endowed account: if it'll ever
+# change, it'd need to be fixed here as well
+
+# //Charlie only submits free and mandatory relay chain headers, so the balance should stay the same
+bridge-hub-westend-collator1: js-script {{FRAMEWORK_PATH}}/js-helpers/native-asset-balance.js with "5FLSigC9HGRKVhB9FiEo4Y3koPsNmBmLJbpXg2mp1hXcS59Y" return is 1152921504606846976 within 30 seconds
+# //Dave only submits free parachain headers, so the balance should stay the same
+bridge-hub-westend-collator1: js-script {{FRAMEWORK_PATH}}/js-helpers/native-asset-balance.js with "5DAAnrj7VHTznn2AWBemMuyBwZWs6FNFjdyVXUeYum3PTXFy" return is 1152921504606846976 within 30 seconds
diff --git a/cumulus/parachains/runtimes/bridge-hubs/bridge-hub-rococo/Cargo.toml b/cumulus/parachains/runtimes/bridge-hubs/bridge-hub-rococo/Cargo.toml
index f5a75aa03acd01381666a30439789387ae699ac9..574406ab305f33d4266dca1a3229b92a80ab9e8c 100644
--- a/cumulus/parachains/runtimes/bridge-hubs/bridge-hub-rococo/Cargo.toml
+++ b/cumulus/parachains/runtimes/bridge-hubs/bridge-hub-rococo/Cargo.toml
@@ -22,6 +22,7 @@ scale-info = { version = "2.11.1", default-features = false, features = [
 	"derive",
 ] }
 serde = { optional = true, features = ["derive"], workspace = true, default-features = true }
+tuplex = { version = "0.1", default-features = false }
 
 # Substrate
 frame-benchmarking = { path = "../../../../../substrate/frame/benchmarking", default-features = false, optional = true }
@@ -218,6 +219,7 @@ std = [
 	"sp-version/std",
 	"substrate-wasm-builder",
 	"testnet-parachains-constants/std",
+	"tuplex/std",
 	"xcm-builder/std",
 	"xcm-executor/std",
 	"xcm/std",
diff --git a/cumulus/parachains/runtimes/bridge-hubs/bridge-hub-rococo/src/bridge_common_config.rs b/cumulus/parachains/runtimes/bridge-hubs/bridge-hub-rococo/src/bridge_common_config.rs
index 93ef9470363cd3dd41a92fe529226ad3fd7b2e00..5551b05e202547c99501b279e8839611efcc7f66 100644
--- a/cumulus/parachains/runtimes/bridge-hubs/bridge-hub-rococo/src/bridge_common_config.rs
+++ b/cumulus/parachains/runtimes/bridge-hubs/bridge-hub-rococo/src/bridge_common_config.rs
@@ -49,7 +49,8 @@ pub type BridgeGrandpaWestendInstance = pallet_bridge_grandpa::Instance3;
 impl pallet_bridge_grandpa::Config<BridgeGrandpaWestendInstance> for Runtime {
 	type RuntimeEvent = RuntimeEvent;
 	type BridgedChain = bp_westend::Westend;
-	type MaxFreeMandatoryHeadersPerBlock = ConstU32<4>;
+	type MaxFreeHeadersPerBlock = ConstU32<4>;
+	type FreeHeadersInterval = ConstU32<5>;
 	type HeadersToKeep = RelayChainHeadersToKeep;
 	type WeightInfo = weights::pallet_bridge_grandpa::WeightInfo<Runtime>;
 }
@@ -89,7 +90,8 @@ pub type BridgeGrandpaRococoBulletinInstance = pallet_bridge_grandpa::Instance4;
 impl pallet_bridge_grandpa::Config<BridgeGrandpaRococoBulletinInstance> for Runtime {
 	type RuntimeEvent = RuntimeEvent;
 	type BridgedChain = bp_polkadot_bulletin::PolkadotBulletin;
-	type MaxFreeMandatoryHeadersPerBlock = ConstU32<4>;
+	type MaxFreeHeadersPerBlock = ConstU32<4>;
+	type FreeHeadersInterval = ConstU32<5>;
 	type HeadersToKeep = RelayChainHeadersToKeep;
 	// Technically this is incorrect - we have two pallet instances and ideally we shall
 	// benchmark every instance separately. But the benchmarking engine has a flaw - it
diff --git a/cumulus/parachains/runtimes/bridge-hubs/bridge-hub-rococo/src/bridge_to_bulletin_config.rs b/cumulus/parachains/runtimes/bridge-hubs/bridge-hub-rococo/src/bridge_to_bulletin_config.rs
index 8845f0538b5c828f459431f1a01b54fefc98e9dc..94b936889b77c4460f9921956d6f7abef1ecb52c 100644
--- a/cumulus/parachains/runtimes/bridge-hubs/bridge-hub-rococo/src/bridge_to_bulletin_config.rs
+++ b/cumulus/parachains/runtimes/bridge-hubs/bridge-hub-rococo/src/bridge_to_bulletin_config.rs
@@ -20,17 +20,15 @@
 //! are reusing Polkadot Bulletin chain primitives everywhere here.
 
 use crate::{
-	bridge_common_config::{BridgeGrandpaRococoBulletinInstance, BridgeHubRococo},
-	weights,
-	xcm_config::UniversalLocation,
-	AccountId, BridgeRococoBulletinGrandpa, BridgeRococoBulletinMessages, PolkadotXcm, Runtime,
-	RuntimeEvent, XcmOverRococoBulletin, XcmRouter,
+	bridge_common_config::BridgeHubRococo, weights, xcm_config::UniversalLocation, AccountId,
+	BridgeRococoBulletinGrandpa, BridgeRococoBulletinMessages, PolkadotXcm, Runtime, RuntimeEvent,
+	XcmOverRococoBulletin, XcmRouter,
 };
 use bp_messages::LaneId;
 use bp_runtime::Chain;
 use bridge_runtime_common::{
 	extensions::refund_relayer_extension::{
-		ActualFeeRefund, RefundBridgedGrandpaMessages, RefundSignedExtensionAdapter,
+		ActualFeeRefund, RefundBridgedMessages, RefundSignedExtensionAdapter,
 		RefundableMessagesLane,
 	},
 	messages,
@@ -83,6 +81,9 @@ parameter_types! {
 	pub const RococoPeopleToRococoBulletinMessagesLane: bp_messages::LaneId
 		= XCM_LANE_FOR_ROCOCO_PEOPLE_TO_ROCOCO_BULLETIN;
 
+	// see the `FEE_BOOST_PER_RELAY_HEADER` constant get the meaning of this value
+	pub PriorityBoostPerRelayHeader: u64 = 58_014_163_614_163;
+
 	/// Priority boost that the registered relayer receives for every additional message in the message
 	/// delivery transaction.
 	///
@@ -169,9 +170,8 @@ impl messages::BridgedChainWithMessages for RococoBulletin {}
 /// Signed extension that refunds relayers that are delivering messages from the Rococo Bulletin
 /// chain.
 pub type OnBridgeHubRococoRefundRococoBulletinMessages = RefundSignedExtensionAdapter<
-	RefundBridgedGrandpaMessages<
+	RefundBridgedMessages<
 		Runtime,
-		BridgeGrandpaRococoBulletinInstance,
 		RefundableMessagesLane<
 			WithRococoBulletinMessagesInstance,
 			RococoPeopleToRococoBulletinMessagesLane,
@@ -244,6 +244,9 @@ mod tests {
 	/// operational costs and a faster bridge), so this value should be significant.
 	const FEE_BOOST_PER_MESSAGE: Balance = 2 * rococo::currency::UNITS;
 
+	// see `FEE_BOOST_PER_MESSAGE` comment
+	const FEE_BOOST_PER_RELAY_HEADER: Balance = 2 * rococo::currency::UNITS;
+
 	#[test]
 	fn ensure_bridge_hub_rococo_message_lane_weights_are_correct() {
 		check_message_lane_weights::<
@@ -273,7 +276,13 @@ mod tests {
 		// Bulletin chain - it has the same (almost) runtime for Polkadot Bulletin and Rococo
 		// Bulletin, so we have to adhere Polkadot names here
 
-		bridge_runtime_common::extensions::priority_calculator::ensure_priority_boost_is_sane::<
+		bridge_runtime_common::extensions::priority_calculator::per_relay_header::ensure_priority_boost_is_sane::<
+			Runtime,
+			BridgeGrandpaRococoBulletinInstance,
+			PriorityBoostPerRelayHeader,
+		>(FEE_BOOST_PER_RELAY_HEADER);
+
+		bridge_runtime_common::extensions::priority_calculator::per_message::ensure_priority_boost_is_sane::<
 			Runtime,
 			WithRococoBulletinMessagesInstance,
 			PriorityBoostPerMessage,
diff --git a/cumulus/parachains/runtimes/bridge-hubs/bridge-hub-rococo/src/bridge_to_westend_config.rs b/cumulus/parachains/runtimes/bridge-hubs/bridge-hub-rococo/src/bridge_to_westend_config.rs
index e5a00073407f8f754f58ef88e825ca598b7bde94..1681ac7f4687493c82c0a3233439b2a9d47a1ad0 100644
--- a/cumulus/parachains/runtimes/bridge-hubs/bridge-hub-rococo/src/bridge_to_westend_config.rs
+++ b/cumulus/parachains/runtimes/bridge-hubs/bridge-hub-rococo/src/bridge_to_westend_config.rs
@@ -29,8 +29,8 @@ use bp_messages::LaneId;
 use bp_runtime::Chain;
 use bridge_runtime_common::{
 	extensions::refund_relayer_extension::{
-		ActualFeeRefund, RefundBridgedParachainMessages, RefundSignedExtensionAdapter,
-		RefundableMessagesLane, RefundableParachain,
+		ActualFeeRefund, RefundBridgedMessages, RefundSignedExtensionAdapter,
+		RefundableMessagesLane,
 	},
 	messages,
 	messages::{
@@ -65,6 +65,10 @@ parameter_types! {
 		2,
 		[GlobalConsensus(WestendGlobalConsensusNetwork::get())]
 	);
+	// see the `FEE_BOOST_PER_RELAY_HEADER` constant get the meaning of this value
+	pub PriorityBoostPerRelayHeader: u64 = 32_007_814_407_814;
+	// see the `FEE_BOOST_PER_PARACHAIN_HEADER` constant get the meaning of this value
+	pub PriorityBoostPerParachainHeader: u64 = 1_396_340_903_540_903;
 	// see the `FEE_BOOST_PER_MESSAGE` constant to get the meaning of this value
 	pub PriorityBoostPerMessage: u64 = 182_044_444_444_444;
 
@@ -174,12 +178,8 @@ impl messages::BridgedChainWithMessages for BridgeHubWestend {}
 
 /// Signed extension that refunds relayers that are delivering messages from the Westend parachain.
 pub type OnBridgeHubRococoRefundBridgeHubWestendMessages = RefundSignedExtensionAdapter<
-	RefundBridgedParachainMessages<
+	RefundBridgedMessages<
 		Runtime,
-		RefundableParachain<
-			BridgeParachainWestendInstance,
-			bp_bridge_hub_westend::BridgeHubWestend,
-		>,
 		RefundableMessagesLane<
 			WithBridgeHubWestendMessagesInstance,
 			AssetHubRococoToAssetHubWestendMessagesLane,
@@ -246,6 +246,7 @@ mod tests {
 	use crate::bridge_common_config::BridgeGrandpaWestendInstance;
 	use bridge_runtime_common::{
 		assert_complete_bridge_types,
+		extensions::refund_relayer_extension::RefundableParachain,
 		integrity::{
 			assert_complete_bridge_constants, check_message_lane_weights,
 			AssertBridgeMessagesPalletConstants, AssertBridgePalletNames, AssertChainConstants,
@@ -266,6 +267,11 @@ mod tests {
 	/// operational costs and a faster bridge), so this value should be significant.
 	const FEE_BOOST_PER_MESSAGE: Balance = 2 * rococo::currency::UNITS;
 
+	// see `FEE_BOOST_PER_MESSAGE` comment
+	const FEE_BOOST_PER_RELAY_HEADER: Balance = 2 * rococo::currency::UNITS;
+	// see `FEE_BOOST_PER_MESSAGE` comment
+	const FEE_BOOST_PER_PARACHAIN_HEADER: Balance = 2 * rococo::currency::UNITS;
+
 	#[test]
 	fn ensure_bridge_hub_rococo_message_lane_weights_are_correct() {
 		check_message_lane_weights::<
@@ -318,7 +324,19 @@ mod tests {
 			},
 		});
 
-		bridge_runtime_common::extensions::priority_calculator::ensure_priority_boost_is_sane::<
+		bridge_runtime_common::extensions::priority_calculator::per_relay_header::ensure_priority_boost_is_sane::<
+			Runtime,
+			BridgeGrandpaWestendInstance,
+			PriorityBoostPerRelayHeader,
+		>(FEE_BOOST_PER_RELAY_HEADER);
+
+		bridge_runtime_common::extensions::priority_calculator::per_parachain_header::ensure_priority_boost_is_sane::<
+			Runtime,
+			RefundableParachain<WithBridgeHubWestendMessagesInstance, BridgeHubWestend>,
+			PriorityBoostPerParachainHeader,
+		>(FEE_BOOST_PER_PARACHAIN_HEADER);
+
+		bridge_runtime_common::extensions::priority_calculator::per_message::ensure_priority_boost_is_sane::<
 			Runtime,
 			WithBridgeHubWestendMessagesInstance,
 			PriorityBoostPerMessage,
diff --git a/cumulus/parachains/runtimes/bridge-hubs/bridge-hub-rococo/src/lib.rs b/cumulus/parachains/runtimes/bridge-hubs/bridge-hub-rococo/src/lib.rs
index 109b081f937d156393264e36c7981c2826370400..7c2aa49088610c7a3c842a2142fc4bcac528efef 100644
--- a/cumulus/parachains/runtimes/bridge-hubs/bridge-hub-rococo/src/lib.rs
+++ b/cumulus/parachains/runtimes/bridge-hubs/bridge-hub-rococo/src/lib.rs
@@ -35,6 +35,12 @@ pub mod bridge_to_westend_config;
 mod weights;
 pub mod xcm_config;
 
+use bridge_runtime_common::extensions::{
+	check_obsolete_extension::{
+		CheckAndBoostBridgeGrandpaTransactions, CheckAndBoostBridgeParachainsTransactions,
+	},
+	refund_relayer_extension::RefundableParachain,
+};
 use cumulus_pallet_parachain_system::RelayNumberMonotonicallyIncreases;
 use snowbridge_beacon_primitives::{Fork, ForkVersions};
 use snowbridge_core::{
@@ -63,7 +69,7 @@ use frame_support::{
 	dispatch::DispatchClass,
 	genesis_builder_helper::{build_state, get_preset},
 	parameter_types,
-	traits::{ConstBool, ConstU32, ConstU64, ConstU8, TransformOrigin},
+	traits::{ConstBool, ConstU32, ConstU64, ConstU8, Get, TransformOrigin},
 	weights::{ConstantMultiplier, Weight},
 	PalletId,
 };
@@ -740,10 +746,28 @@ pub type XcmOverRococoBulletin = XcmOverPolkadotBulletin;
 bridge_runtime_common::generate_bridge_reject_obsolete_headers_and_messages! {
 	RuntimeCall, AccountId,
 	// Grandpa
-	BridgeWestendGrandpa,
-	BridgeRococoBulletinGrandpa,
+	CheckAndBoostBridgeGrandpaTransactions<
+		Runtime,
+		bridge_common_config::BridgeGrandpaWestendInstance,
+		bridge_to_westend_config::PriorityBoostPerRelayHeader,
+		xcm_config::TreasuryAccount,
+	>,
+	CheckAndBoostBridgeGrandpaTransactions<
+		Runtime,
+		bridge_common_config::BridgeGrandpaRococoBulletinInstance,
+		bridge_to_bulletin_config::PriorityBoostPerRelayHeader,
+		xcm_config::TreasuryAccount,
+	>,
 	// Parachains
-	BridgeWestendParachains,
+	CheckAndBoostBridgeParachainsTransactions<
+		Runtime,
+		RefundableParachain<
+		bridge_common_config::BridgeParachainWestendInstance,
+			bp_bridge_hub_westend::BridgeHubWestend,
+		>,
+		bridge_to_westend_config::PriorityBoostPerParachainHeader,
+		xcm_config::TreasuryAccount,
+	>,
 	// Messages
 	BridgeWestendMessages,
 	BridgeRococoBulletinMessages
@@ -938,6 +962,11 @@ impl_runtime_apis! {
 		fn best_finalized() -> Option<HeaderId<bp_westend::Hash, bp_westend::BlockNumber>> {
 			BridgeWestendGrandpa::best_finalized()
 		}
+		fn free_headers_interval() -> Option<bp_westend::BlockNumber> {
+			<Runtime as pallet_bridge_grandpa::Config<
+				bridge_common_config::BridgeGrandpaWestendInstance
+			>>::FreeHeadersInterval::get()
+		}
 		fn synced_headers_grandpa_info(
 		) -> Vec<bp_header_chain::StoredHeaderGrandpaInfo<bp_westend::Header>> {
 			BridgeWestendGrandpa::synced_headers_grandpa_info()
@@ -950,6 +979,10 @@ impl_runtime_apis! {
 				bp_bridge_hub_westend::BridgeHubWestend
 			>().unwrap_or(None)
 		}
+		fn free_headers_interval() -> Option<bp_bridge_hub_westend::BlockNumber> {
+			// "free interval" is not currently used for parachains
+			None
+		}
 	}
 
 	// This is exposed by BridgeHubRococo
@@ -984,6 +1017,12 @@ impl_runtime_apis! {
 			BridgePolkadotBulletinGrandpa::best_finalized()
 		}
 
+		fn free_headers_interval() -> Option<bp_polkadot_bulletin::BlockNumber> {
+			<Runtime as pallet_bridge_grandpa::Config<
+				bridge_common_config::BridgeGrandpaRococoBulletinInstance
+			>>::FreeHeadersInterval::get()
+		}
+
 		fn synced_headers_grandpa_info(
 		) -> Vec<bp_header_chain::StoredHeaderGrandpaInfo<bp_polkadot_bulletin::Header>> {
 			BridgePolkadotBulletinGrandpa::synced_headers_grandpa_info()
diff --git a/cumulus/parachains/runtimes/bridge-hubs/bridge-hub-rococo/src/weights/mod.rs b/cumulus/parachains/runtimes/bridge-hubs/bridge-hub-rococo/src/weights/mod.rs
index aac39a4564fb600d9c4f623aa3ba27c78fc8f5fc..942f243141da9c1dcfa47d2e3a1ac7906eb22706 100644
--- a/cumulus/parachains/runtimes/bridge-hubs/bridge-hub-rococo/src/weights/mod.rs
+++ b/cumulus/parachains/runtimes/bridge-hubs/bridge-hub-rococo/src/weights/mod.rs
@@ -17,8 +17,10 @@
 
 //! Expose the auto generated weight files.
 
+use ::pallet_bridge_grandpa::WeightInfoExt as GrandpaWeightInfoExt;
 use ::pallet_bridge_messages::WeightInfoExt as MessagesWeightInfoExt;
 use ::pallet_bridge_parachains::WeightInfoExt as ParachainsWeightInfoExt;
+use ::pallet_bridge_relayers::WeightInfo as _;
 
 pub mod block_weights;
 pub mod cumulus_pallet_parachain_system;
@@ -56,6 +58,16 @@ use frame_support::weights::Weight;
 // import trait from dependency module
 use ::pallet_bridge_relayers::WeightInfoExt as _;
 
+impl GrandpaWeightInfoExt for pallet_bridge_grandpa::WeightInfo<crate::Runtime> {
+	fn submit_finality_proof_overhead_from_runtime() -> Weight {
+		// our signed extension:
+		// 1) checks whether relayer registration is active from validate/pre_dispatch;
+		// 2) may slash and deregister relayer from post_dispatch
+		// (2) includes (1), so (2) is the worst case
+		pallet_bridge_relayers::WeightInfo::<Runtime>::slash_and_deregister()
+	}
+}
+
 impl MessagesWeightInfoExt
 	for pallet_bridge_messages_rococo_to_rococo_bulletin::WeightInfo<crate::Runtime>
 {
@@ -94,4 +106,12 @@ impl ParachainsWeightInfoExt for pallet_bridge_parachains::WeightInfo<crate::Run
 	fn expected_extra_storage_proof_size() -> u32 {
 		bp_bridge_hub_westend::EXTRA_STORAGE_PROOF_SIZE
 	}
+
+	fn submit_parachain_heads_overhead_from_runtime() -> Weight {
+		// our signed extension:
+		// 1) checks whether relayer registration is active from validate/pre_dispatch;
+		// 2) may slash and deregister relayer from post_dispatch
+		// (2) includes (1), so (2) is the worst case
+		pallet_bridge_relayers::WeightInfo::<Runtime>::slash_and_deregister()
+	}
 }
diff --git a/cumulus/parachains/runtimes/bridge-hubs/bridge-hub-rococo/tests/tests.rs b/cumulus/parachains/runtimes/bridge-hubs/bridge-hub-rococo/tests/tests.rs
index 776c505fa640fa956ae07e9928ccf2f9293e80dd..b309232825db3aa964b2fa1a1d8d739f06ec3153 100644
--- a/cumulus/parachains/runtimes/bridge-hubs/bridge-hub-rococo/tests/tests.rs
+++ b/cumulus/parachains/runtimes/bridge-hubs/bridge-hub-rococo/tests/tests.rs
@@ -80,11 +80,10 @@ fn construct_and_apply_extrinsic(
 	r.unwrap()
 }
 
-fn construct_and_estimate_extrinsic_fee(batch: pallet_utility::Call<Runtime>) -> Balance {
-	let batch_call = RuntimeCall::Utility(batch);
-	let batch_info = batch_call.get_dispatch_info();
-	let xt = construct_extrinsic(Alice, batch_call);
-	TransactionPayment::compute_fee(xt.encoded_size() as _, &batch_info, 0)
+fn construct_and_estimate_extrinsic_fee(call: RuntimeCall) -> Balance {
+	let info = call.get_dispatch_info();
+	let xt = construct_extrinsic(Alice, call);
+	TransactionPayment::compute_fee(xt.encoded_size() as _, &info, 0)
 }
 
 fn collator_session_keys() -> bridge_hub_test_utils::CollatorSessionKeys<Runtime> {
@@ -376,20 +375,20 @@ mod bridge_hub_westend_tests {
 	}
 
 	#[test]
-	pub fn complex_relay_extrinsic_works() {
-		// for Westend
-		from_parachain::complex_relay_extrinsic_works::<RuntimeTestsAdapter>(
+	fn free_relay_extrinsic_works() {
+		// from Westend
+		from_parachain::free_relay_extrinsic_works::<RuntimeTestsAdapter>(
 			collator_session_keys(),
 			slot_durations(),
 			bp_bridge_hub_rococo::BRIDGE_HUB_ROCOCO_PARACHAIN_ID,
 			bp_bridge_hub_westend::BRIDGE_HUB_WESTEND_PARACHAIN_ID,
-			SIBLING_PARACHAIN_ID,
 			BridgeHubWestendChainId::get(),
+			SIBLING_PARACHAIN_ID,
 			Rococo,
 			XCM_LANE_FOR_ASSET_HUB_ROCOCO_TO_ASSET_HUB_WESTEND,
 			|| (),
 			construct_and_apply_extrinsic,
-		);
+		)
 	}
 
 	#[test]
@@ -414,12 +413,12 @@ mod bridge_hub_westend_tests {
 	}
 
 	#[test]
-	pub fn can_calculate_fee_for_complex_message_delivery_transaction() {
+	fn can_calculate_fee_for_standalone_message_delivery_transaction() {
 		bridge_hub_test_utils::check_sane_fees_values(
 			"bp_bridge_hub_rococo::BridgeHubRococoBaseDeliveryFeeInRocs",
 			bp_bridge_hub_rococo::BridgeHubRococoBaseDeliveryFeeInRocs::get(),
 			|| {
-				from_parachain::can_calculate_fee_for_complex_message_delivery_transaction::<
+				from_parachain::can_calculate_fee_for_standalone_message_delivery_transaction::<
 					RuntimeTestsAdapter,
 				>(collator_session_keys(), construct_and_estimate_extrinsic_fee)
 			},
@@ -433,12 +432,12 @@ mod bridge_hub_westend_tests {
 	}
 
 	#[test]
-	pub fn can_calculate_fee_for_complex_message_confirmation_transaction() {
+	fn can_calculate_fee_for_standalone_message_confirmation_transaction() {
 		bridge_hub_test_utils::check_sane_fees_values(
 			"bp_bridge_hub_rococo::BridgeHubRococoBaseConfirmationFeeInRocs",
 			bp_bridge_hub_rococo::BridgeHubRococoBaseConfirmationFeeInRocs::get(),
 			|| {
-				from_parachain::can_calculate_fee_for_complex_message_confirmation_transaction::<
+				from_parachain::can_calculate_fee_for_standalone_message_confirmation_transaction::<
 					RuntimeTestsAdapter,
 				>(collator_session_keys(), construct_and_estimate_extrinsic_fee)
 			},
@@ -581,28 +580,28 @@ mod bridge_hub_bulletin_tests {
 	}
 
 	#[test]
-	pub fn complex_relay_extrinsic_works() {
-		// for Bulletin
-		from_grandpa_chain::complex_relay_extrinsic_works::<RuntimeTestsAdapter>(
+	fn free_relay_extrinsic_works() {
+		// from Bulletin
+		from_grandpa_chain::free_relay_extrinsic_works::<RuntimeTestsAdapter>(
 			collator_session_keys(),
 			slot_durations(),
 			bp_bridge_hub_rococo::BRIDGE_HUB_ROCOCO_PARACHAIN_ID,
-			SIBLING_PARACHAIN_ID,
 			RococoBulletinChainId::get(),
+			SIBLING_PARACHAIN_ID,
 			Rococo,
 			XCM_LANE_FOR_ROCOCO_PEOPLE_TO_ROCOCO_BULLETIN,
 			|| (),
 			construct_and_apply_extrinsic,
-		);
+		)
 	}
 
 	#[test]
-	pub fn can_calculate_fee_for_complex_message_delivery_transaction() {
+	pub fn can_calculate_fee_for_standalone_message_delivery_transaction() {
 		bridge_hub_test_utils::check_sane_fees_values(
 			"bp_bridge_hub_rococo::BridgeHubRococoBaseDeliveryFeeInRocs",
 			bp_bridge_hub_rococo::BridgeHubRococoBaseDeliveryFeeInRocs::get(),
 			|| {
-				from_grandpa_chain::can_calculate_fee_for_complex_message_delivery_transaction::<
+				from_grandpa_chain::can_calculate_fee_for_standalone_message_delivery_transaction::<
 					RuntimeTestsAdapter,
 				>(collator_session_keys(), construct_and_estimate_extrinsic_fee)
 			},
@@ -617,12 +616,12 @@ mod bridge_hub_bulletin_tests {
 	}
 
 	#[test]
-	pub fn can_calculate_fee_for_complex_message_confirmation_transaction() {
+	pub fn can_calculate_fee_for_standalone_message_confirmation_transaction() {
 		bridge_hub_test_utils::check_sane_fees_values(
 			"bp_bridge_hub_rococo::BridgeHubRococoBaseConfirmationFeeInRocs",
 			bp_bridge_hub_rococo::BridgeHubRococoBaseConfirmationFeeInRocs::get(),
 			|| {
-				from_grandpa_chain::can_calculate_fee_for_complex_message_confirmation_transaction::<
+				from_grandpa_chain::can_calculate_fee_for_standalone_message_confirmation_transaction::<
 					RuntimeTestsAdapter,
 				>(collator_session_keys(), construct_and_estimate_extrinsic_fee)
 			},
diff --git a/cumulus/parachains/runtimes/bridge-hubs/bridge-hub-westend/Cargo.toml b/cumulus/parachains/runtimes/bridge-hubs/bridge-hub-westend/Cargo.toml
index 86560caca99ca344065373330638c3232a417f21..a7241cc6d10c45a292c3e0ffc0a8044e9b2fb706 100644
--- a/cumulus/parachains/runtimes/bridge-hubs/bridge-hub-westend/Cargo.toml
+++ b/cumulus/parachains/runtimes/bridge-hubs/bridge-hub-westend/Cargo.toml
@@ -18,6 +18,7 @@ hex-literal = { version = "0.4.1" }
 log = { workspace = true }
 scale-info = { version = "2.11.1", default-features = false, features = ["derive"] }
 serde = { optional = true, features = ["derive"], workspace = true, default-features = true }
+tuplex = { version = "0.1", default-features = false }
 
 # Substrate
 frame-benchmarking = { path = "../../../../../substrate/frame/benchmarking", default-features = false, optional = true }
@@ -180,6 +181,7 @@ std = [
 	"sp-version/std",
 	"substrate-wasm-builder",
 	"testnet-parachains-constants/std",
+	"tuplex/std",
 	"westend-runtime-constants/std",
 	"xcm-builder/std",
 	"xcm-executor/std",
diff --git a/cumulus/parachains/runtimes/bridge-hubs/bridge-hub-westend/src/bridge_to_rococo_config.rs b/cumulus/parachains/runtimes/bridge-hubs/bridge-hub-westend/src/bridge_to_rococo_config.rs
index d5da41cce2860c8b12db49f1defc5a6764bc6535..425b53da30fc8a176fcddfe145fab66a41b60f8a 100644
--- a/cumulus/parachains/runtimes/bridge-hubs/bridge-hub-westend/src/bridge_to_rococo_config.rs
+++ b/cumulus/parachains/runtimes/bridge-hubs/bridge-hub-westend/src/bridge_to_rococo_config.rs
@@ -26,8 +26,8 @@ use bp_parachains::SingleParaStoredHeaderDataBuilder;
 use bp_runtime::Chain;
 use bridge_runtime_common::{
 	extensions::refund_relayer_extension::{
-		ActualFeeRefund, RefundBridgedParachainMessages, RefundSignedExtensionAdapter,
-		RefundableMessagesLane, RefundableParachain,
+		ActualFeeRefund, RefundBridgedMessages, RefundSignedExtensionAdapter,
+		RefundableMessagesLane,
 	},
 	messages,
 	messages::{
@@ -70,6 +70,10 @@ parameter_types! {
 		2,
 		[GlobalConsensus(RococoGlobalConsensusNetwork::get())]
 	);
+	// see the `FEE_BOOST_PER_RELAY_HEADER` constant get the meaning of this value
+	pub PriorityBoostPerRelayHeader: u64 = 32_007_814_407_814;
+	// see the `FEE_BOOST_PER_PARACHAIN_HEADER` constant get the meaning of this value
+	pub PriorityBoostPerParachainHeader: u64 = 1_396_340_903_540_903;
 	// see the `FEE_BOOST_PER_MESSAGE` constant to get the meaning of this value
 	pub PriorityBoostPerMessage: u64 = 182_044_444_444_444;
 
@@ -191,9 +195,8 @@ impl ThisChainWithMessages for BridgeHubWestend {
 
 /// Signed extension that refunds relayers that are delivering messages from the Rococo parachain.
 pub type OnBridgeHubWestendRefundBridgeHubRococoMessages = RefundSignedExtensionAdapter<
-	RefundBridgedParachainMessages<
+	RefundBridgedMessages<
 		Runtime,
-		RefundableParachain<BridgeParachainRococoInstance, bp_bridge_hub_rococo::BridgeHubRococo>,
 		RefundableMessagesLane<
 			WithBridgeHubRococoMessagesInstance,
 			AssetHubWestendToAssetHubRococoMessagesLane,
@@ -210,7 +213,8 @@ pub type BridgeGrandpaRococoInstance = pallet_bridge_grandpa::Instance1;
 impl pallet_bridge_grandpa::Config<BridgeGrandpaRococoInstance> for Runtime {
 	type RuntimeEvent = RuntimeEvent;
 	type BridgedChain = bp_rococo::Rococo;
-	type MaxFreeMandatoryHeadersPerBlock = ConstU32<4>;
+	type MaxFreeHeadersPerBlock = ConstU32<4>;
+	type FreeHeadersInterval = ConstU32<5>;
 	type HeadersToKeep = RelayChainHeadersToKeep;
 	type WeightInfo = weights::pallet_bridge_grandpa::WeightInfo<Runtime>;
 }
@@ -281,6 +285,7 @@ mod tests {
 	use super::*;
 	use bridge_runtime_common::{
 		assert_complete_bridge_types,
+		extensions::refund_relayer_extension::RefundableParachain,
 		integrity::{
 			assert_complete_bridge_constants, check_message_lane_weights,
 			AssertBridgeMessagesPalletConstants, AssertBridgePalletNames, AssertChainConstants,
@@ -301,6 +306,11 @@ mod tests {
 	/// operational costs and a faster bridge), so this value should be significant.
 	const FEE_BOOST_PER_MESSAGE: Balance = 2 * westend::currency::UNITS;
 
+	// see `FEE_BOOST_PER_MESSAGE` comment
+	const FEE_BOOST_PER_RELAY_HEADER: Balance = 2 * westend::currency::UNITS;
+	// see `FEE_BOOST_PER_MESSAGE` comment
+	const FEE_BOOST_PER_PARACHAIN_HEADER: Balance = 2 * westend::currency::UNITS;
+
 	#[test]
 	fn ensure_bridge_hub_westend_message_lane_weights_are_correct() {
 		check_message_lane_weights::<
@@ -352,7 +362,19 @@ mod tests {
 			},
 		});
 
-		bridge_runtime_common::extensions::priority_calculator::ensure_priority_boost_is_sane::<
+		bridge_runtime_common::extensions::priority_calculator::per_relay_header::ensure_priority_boost_is_sane::<
+			Runtime,
+			BridgeGrandpaRococoInstance,
+			PriorityBoostPerRelayHeader,
+		>(FEE_BOOST_PER_RELAY_HEADER);
+
+		bridge_runtime_common::extensions::priority_calculator::per_parachain_header::ensure_priority_boost_is_sane::<
+			Runtime,
+			RefundableParachain<WithBridgeHubRococoMessagesInstance, BridgeHubRococo>,
+			PriorityBoostPerParachainHeader,
+		>(FEE_BOOST_PER_PARACHAIN_HEADER);
+
+		bridge_runtime_common::extensions::priority_calculator::per_message::ensure_priority_boost_is_sane::<
 			Runtime,
 			WithBridgeHubRococoMessagesInstance,
 			PriorityBoostPerMessage,
diff --git a/cumulus/parachains/runtimes/bridge-hubs/bridge-hub-westend/src/lib.rs b/cumulus/parachains/runtimes/bridge-hubs/bridge-hub-westend/src/lib.rs
index cf09a1acc548c94d7801b4332d1dcbc41bfe6ef3..640eaf881a571115357572ce39b5bd2daaad9db4 100644
--- a/cumulus/parachains/runtimes/bridge-hubs/bridge-hub-westend/src/lib.rs
+++ b/cumulus/parachains/runtimes/bridge-hubs/bridge-hub-westend/src/lib.rs
@@ -32,6 +32,12 @@ pub mod bridge_to_rococo_config;
 mod weights;
 pub mod xcm_config;
 
+use bridge_runtime_common::extensions::{
+	check_obsolete_extension::{
+		CheckAndBoostBridgeGrandpaTransactions, CheckAndBoostBridgeParachainsTransactions,
+	},
+	refund_relayer_extension::RefundableParachain,
+};
 use cumulus_pallet_parachain_system::RelayNumberMonotonicallyIncreases;
 use cumulus_primitives_core::ParaId;
 use sp_api::impl_runtime_apis;
@@ -57,7 +63,7 @@ use frame_support::{
 	dispatch::DispatchClass,
 	genesis_builder_helper::{build_state, get_preset},
 	parameter_types,
-	traits::{ConstBool, ConstU32, ConstU64, ConstU8, TransformOrigin},
+	traits::{ConstBool, ConstU32, ConstU64, ConstU8, Get, TransformOrigin},
 	weights::{ConstantMultiplier, Weight},
 	PalletId,
 };
@@ -502,9 +508,22 @@ construct_runtime!(
 bridge_runtime_common::generate_bridge_reject_obsolete_headers_and_messages! {
 	RuntimeCall, AccountId,
 	// Grandpa
-	BridgeRococoGrandpa,
+	CheckAndBoostBridgeGrandpaTransactions<
+		Runtime,
+		bridge_to_rococo_config::BridgeGrandpaRococoInstance,
+		bridge_to_rococo_config::PriorityBoostPerRelayHeader,
+		xcm_config::TreasuryAccount,
+	>,
 	// Parachains
-	BridgeRococoParachains,
+	CheckAndBoostBridgeParachainsTransactions<
+		Runtime,
+		RefundableParachain<
+			bridge_to_rococo_config::BridgeParachainRococoInstance,
+			bp_bridge_hub_rococo::BridgeHubRococo,
+		>,
+		bridge_to_rococo_config::PriorityBoostPerParachainHeader,
+		xcm_config::TreasuryAccount,
+	>,
 	// Messages
 	BridgeRococoMessages
 }
@@ -692,6 +711,11 @@ impl_runtime_apis! {
 		fn best_finalized() -> Option<HeaderId<bp_rococo::Hash, bp_rococo::BlockNumber>> {
 			BridgeRococoGrandpa::best_finalized()
 		}
+		fn free_headers_interval() -> Option<bp_rococo::BlockNumber> {
+			<Runtime as pallet_bridge_grandpa::Config<
+				bridge_to_rococo_config::BridgeGrandpaRococoInstance
+			>>::FreeHeadersInterval::get()
+		}
 		fn synced_headers_grandpa_info(
 		) -> Vec<bp_header_chain::StoredHeaderGrandpaInfo<bp_rococo::Header>> {
 			BridgeRococoGrandpa::synced_headers_grandpa_info()
@@ -704,6 +728,10 @@ impl_runtime_apis! {
 				bp_bridge_hub_rococo::BridgeHubRococo
 			>().unwrap_or(None)
 		}
+		fn free_headers_interval() -> Option<bp_bridge_hub_rococo::BlockNumber> {
+			// "free interval" is not currently used for parachains
+			None
+		}
 	}
 
 	impl bp_bridge_hub_rococo::FromBridgeHubRococoInboundLaneApi<Block> for Runtime {
diff --git a/cumulus/parachains/runtimes/bridge-hubs/bridge-hub-westend/src/weights/mod.rs b/cumulus/parachains/runtimes/bridge-hubs/bridge-hub-westend/src/weights/mod.rs
index a65ee31d3e55ff8135fdd7dec35120e0a463409b..245daaf8ed91b69db2a604c51e394c2d768b1c26 100644
--- a/cumulus/parachains/runtimes/bridge-hubs/bridge-hub-westend/src/weights/mod.rs
+++ b/cumulus/parachains/runtimes/bridge-hubs/bridge-hub-westend/src/weights/mod.rs
@@ -17,8 +17,10 @@
 
 //! Expose the auto generated weight files.
 
+use ::pallet_bridge_grandpa::WeightInfoExt as GrandpaWeightInfoExt;
 use ::pallet_bridge_messages::WeightInfoExt as MessagesWeightInfoExt;
 use ::pallet_bridge_parachains::WeightInfoExt as ParachainsWeightInfoExt;
+use ::pallet_bridge_relayers::WeightInfo as _;
 
 pub mod block_weights;
 pub mod cumulus_pallet_parachain_system;
@@ -51,6 +53,16 @@ use frame_support::weights::Weight;
 // import trait from dependency module
 use ::pallet_bridge_relayers::WeightInfoExt as _;
 
+impl GrandpaWeightInfoExt for pallet_bridge_grandpa::WeightInfo<crate::Runtime> {
+	fn submit_finality_proof_overhead_from_runtime() -> Weight {
+		// our signed extension:
+		// 1) checks whether relayer registration is active from validate/pre_dispatch;
+		// 2) may slash and deregister relayer from post_dispatch
+		// (2) includes (1), so (2) is the worst case
+		pallet_bridge_relayers::WeightInfo::<Runtime>::slash_and_deregister()
+	}
+}
+
 impl MessagesWeightInfoExt for pallet_bridge_messages::WeightInfo<crate::Runtime> {
 	fn expected_extra_storage_proof_size() -> u32 {
 		bp_bridge_hub_rococo::EXTRA_STORAGE_PROOF_SIZE
@@ -70,4 +82,12 @@ impl ParachainsWeightInfoExt for pallet_bridge_parachains::WeightInfo<crate::Run
 	fn expected_extra_storage_proof_size() -> u32 {
 		bp_bridge_hub_rococo::EXTRA_STORAGE_PROOF_SIZE
 	}
+
+	fn submit_parachain_heads_overhead_from_runtime() -> Weight {
+		// our signed extension:
+		// 1) checks whether relayer registration is active from validate/pre_dispatch;
+		// 2) may slash and deregister relayer from post_dispatch
+		// (2) includes (1), so (2) is the worst case
+		pallet_bridge_relayers::WeightInfo::<Runtime>::slash_and_deregister()
+	}
 }
diff --git a/cumulus/parachains/runtimes/bridge-hubs/bridge-hub-westend/tests/tests.rs b/cumulus/parachains/runtimes/bridge-hubs/bridge-hub-westend/tests/tests.rs
index 988b10e1e2d8fac610c2feaec41d018012ec9fc3..836594140b2328081ff6c0de8cac40ea82dfb6f7 100644
--- a/cumulus/parachains/runtimes/bridge-hubs/bridge-hub-westend/tests/tests.rs
+++ b/cumulus/parachains/runtimes/bridge-hubs/bridge-hub-westend/tests/tests.rs
@@ -94,11 +94,10 @@ fn construct_and_apply_extrinsic(
 	r.unwrap()
 }
 
-fn construct_and_estimate_extrinsic_fee(batch: pallet_utility::Call<Runtime>) -> Balance {
-	let batch_call = RuntimeCall::Utility(batch);
-	let batch_info = batch_call.get_dispatch_info();
-	let xt = construct_extrinsic(Alice, batch_call);
-	TransactionPayment::compute_fee(xt.encoded_size() as _, &batch_info, 0)
+fn construct_and_estimate_extrinsic_fee(call: RuntimeCall) -> Balance {
+	let info = call.get_dispatch_info();
+	let xt = construct_extrinsic(Alice, call);
+	TransactionPayment::compute_fee(xt.encoded_size() as _, &info, 0)
 }
 
 fn collator_session_keys() -> bridge_hub_test_utils::CollatorSessionKeys<Runtime> {
@@ -271,22 +270,6 @@ fn relayed_incoming_message_works() {
 	)
 }
 
-#[test]
-pub fn complex_relay_extrinsic_works() {
-	from_parachain::complex_relay_extrinsic_works::<RuntimeTestsAdapter>(
-		collator_session_keys(),
-		slot_durations(),
-		bp_bridge_hub_westend::BRIDGE_HUB_WESTEND_PARACHAIN_ID,
-		bp_bridge_hub_rococo::BRIDGE_HUB_ROCOCO_PARACHAIN_ID,
-		SIBLING_PARACHAIN_ID,
-		BridgeHubRococoChainId::get(),
-		Westend,
-		XCM_LANE_FOR_ASSET_HUB_WESTEND_TO_ASSET_HUB_ROCOCO,
-		|| (),
-		construct_and_apply_extrinsic,
-	);
-}
-
 #[test]
 pub fn can_calculate_weight_for_paid_export_message_with_reserve_transfer() {
 	bridge_hub_test_utils::check_sane_fees_values(
@@ -309,12 +292,12 @@ pub fn can_calculate_weight_for_paid_export_message_with_reserve_transfer() {
 }
 
 #[test]
-pub fn can_calculate_fee_for_complex_message_delivery_transaction() {
+pub fn can_calculate_fee_for_standalone_message_delivery_transaction() {
 	bridge_hub_test_utils::check_sane_fees_values(
 		"bp_bridge_hub_westend::BridgeHubWestendBaseDeliveryFeeInWnds",
 		bp_bridge_hub_westend::BridgeHubWestendBaseDeliveryFeeInWnds::get(),
 		|| {
-			from_parachain::can_calculate_fee_for_complex_message_delivery_transaction::<
+			from_parachain::can_calculate_fee_for_standalone_message_delivery_transaction::<
 				RuntimeTestsAdapter,
 			>(collator_session_keys(), construct_and_estimate_extrinsic_fee)
 		},
@@ -328,12 +311,12 @@ pub fn can_calculate_fee_for_complex_message_delivery_transaction() {
 }
 
 #[test]
-pub fn can_calculate_fee_for_complex_message_confirmation_transaction() {
+pub fn can_calculate_fee_for_standalone_message_confirmation_transaction() {
 	bridge_hub_test_utils::check_sane_fees_values(
 		"bp_bridge_hub_westend::BridgeHubWestendBaseConfirmationFeeInWnds",
 		bp_bridge_hub_westend::BridgeHubWestendBaseConfirmationFeeInWnds::get(),
 		|| {
-			from_parachain::can_calculate_fee_for_complex_message_confirmation_transaction::<
+			from_parachain::can_calculate_fee_for_standalone_message_confirmation_transaction::<
 				RuntimeTestsAdapter,
 			>(collator_session_keys(), construct_and_estimate_extrinsic_fee)
 		},
diff --git a/cumulus/parachains/runtimes/bridge-hubs/test-utils/src/test_cases/from_grandpa_chain.rs b/cumulus/parachains/runtimes/bridge-hubs/test-utils/src/test_cases/from_grandpa_chain.rs
index 8aaaa4f59d7884ff211855a925638317a3b722ea..bfa2f0f50f94ca3ba2f663f9646be3165dd48220 100644
--- a/cumulus/parachains/runtimes/bridge-hubs/test-utils/src/test_cases/from_grandpa_chain.rs
+++ b/cumulus/parachains/runtimes/bridge-hubs/test-utils/src/test_cases/from_grandpa_chain.rs
@@ -41,6 +41,7 @@ use frame_system::pallet_prelude::BlockNumberFor;
 use parachains_runtimes_test_utils::{
 	AccountIdOf, BasicParachainRuntime, CollatorSessionKeys, RuntimeCallOf, SlotDurations,
 };
+use sp_core::Get;
 use sp_keyring::AccountKeyring::*;
 use sp_runtime::{traits::Header as HeaderT, AccountId32};
 use xcm::latest::prelude::*;
@@ -162,7 +163,14 @@ pub fn relayed_incoming_message_works<RuntimeHelper>(
 				test_data::from_grandpa_chain::make_complex_relayer_delivery_proofs::<
 					RuntimeHelper::MB,
 					(),
-				>(lane_id, xcm.into(), message_nonce, message_destination, relay_header_number);
+				>(
+					lane_id,
+					xcm.into(),
+					message_nonce,
+					message_destination,
+					relay_header_number,
+					false,
+				);
 
 			let relay_chain_header_hash = relay_chain_header.hash();
 			vec![
@@ -202,6 +210,142 @@ pub fn relayed_incoming_message_works<RuntimeHelper>(
 	);
 }
 
+/// Test-case makes sure that Runtime can dispatch XCM messages submitted by relayer,
+/// with proofs (finality, message) independently submitted.
+/// Finality proof is submitted for free in this test.
+/// Also verifies relayer transaction signed extensions work as intended.
+pub fn free_relay_extrinsic_works<RuntimeHelper>(
+	collator_session_key: CollatorSessionKeys<RuntimeHelper::Runtime>,
+	slot_durations: SlotDurations,
+	runtime_para_id: u32,
+	bridged_chain_id: bp_runtime::ChainId,
+	sibling_parachain_id: u32,
+	local_relay_chain_id: NetworkId,
+	lane_id: LaneId,
+	prepare_configuration: impl Fn(),
+	construct_and_apply_extrinsic: fn(
+		sp_keyring::AccountKeyring,
+		RuntimeCallOf<RuntimeHelper::Runtime>,
+	) -> sp_runtime::DispatchOutcome,
+) where
+	RuntimeHelper: WithRemoteGrandpaChainHelper,
+	RuntimeHelper::Runtime: pallet_balances::Config,
+	AccountIdOf<RuntimeHelper::Runtime>: From<AccountId32>,
+	RuntimeCallOf<RuntimeHelper::Runtime>: From<BridgeGrandpaCall<RuntimeHelper::Runtime, RuntimeHelper::GPI>>
+		+ From<BridgeMessagesCall<RuntimeHelper::Runtime, RuntimeHelper::MPI>>,
+	UnderlyingChainOf<MessageBridgedChain<RuntimeHelper::MB>>: ChainWithGrandpa,
+	<RuntimeHelper::Runtime as BridgeMessagesConfig<RuntimeHelper::MPI>>::SourceHeaderChain:
+		SourceHeaderChain<
+			MessagesProof = FromBridgedChainMessagesProof<
+				HashOf<MessageBridgedChain<RuntimeHelper::MB>>,
+			>,
+		>,
+{
+	// ensure that the runtime allows free header submissions
+	let free_headers_interval = <RuntimeHelper::Runtime as BridgeGrandpaConfig<
+		RuntimeHelper::GPI,
+	>>::FreeHeadersInterval::get()
+	.expect("this test requires runtime, configured to accept headers for free; qed");
+
+	helpers::relayed_incoming_message_works::<
+		RuntimeHelper::Runtime,
+		RuntimeHelper::AllPalletsWithoutSystem,
+		RuntimeHelper::MPI,
+	>(
+		collator_session_key,
+		slot_durations,
+		runtime_para_id,
+		sibling_parachain_id,
+		local_relay_chain_id,
+		construct_and_apply_extrinsic,
+		|relayer_id_at_this_chain,
+		 relayer_id_at_bridged_chain,
+		 message_destination,
+		 message_nonce,
+		 xcm| {
+			prepare_configuration();
+
+			// start with bridged relay chain block#0
+			let initial_block_number = 0;
+			helpers::initialize_bridge_grandpa_pallet::<RuntimeHelper::Runtime, RuntimeHelper::GPI>(
+				test_data::initialization_data::<RuntimeHelper::Runtime, RuntimeHelper::GPI>(
+					initial_block_number,
+				),
+			);
+
+			// free relay chain header is `0 + free_headers_interval`
+			let relay_header_number = initial_block_number + free_headers_interval;
+
+			// relayer balance shall not change after relay and para header submissions
+			let initial_relayer_balance =
+				pallet_balances::Pallet::<RuntimeHelper::Runtime>::free_balance(
+					relayer_id_at_this_chain.clone(),
+				);
+
+			// initialize the `FreeHeadersRemaining` storage value
+			pallet_bridge_grandpa::Pallet::<RuntimeHelper::Runtime, RuntimeHelper::GPI>::on_initialize(
+				0u32.into(),
+			);
+
+			// generate bridged relay chain finality, parachain heads and message proofs,
+			// to be submitted by relayer to this chain.
+			let (relay_chain_header, grandpa_justification, message_proof) =
+				test_data::from_grandpa_chain::make_complex_relayer_delivery_proofs::<
+					RuntimeHelper::MB,
+					(),
+				>(
+					lane_id,
+					xcm.into(),
+					message_nonce,
+					message_destination,
+					relay_header_number.into(),
+					true,
+				);
+
+			let relay_chain_header_hash = relay_chain_header.hash();
+			vec![
+				(
+					BridgeGrandpaCall::<RuntimeHelper::Runtime, RuntimeHelper::GPI>::submit_finality_proof {
+						finality_target: Box::new(relay_chain_header),
+						justification: grandpa_justification,
+					}.into(),
+					Box::new((
+						helpers::VerifySubmitGrandpaFinalityProofOutcome::<RuntimeHelper::Runtime, RuntimeHelper::GPI>::expect_best_header_hash(
+							relay_chain_header_hash,
+						),
+						helpers::VerifyRelayerBalance::<RuntimeHelper::Runtime>::expect_relayer_balance(
+							relayer_id_at_this_chain.clone(),
+							initial_relayer_balance,
+						),
+					))
+				),
+				(
+					BridgeMessagesCall::<RuntimeHelper::Runtime, RuntimeHelper::MPI>::receive_messages_proof {
+						relayer_id_at_bridged_chain,
+						proof: message_proof,
+						messages_count: 1,
+						dispatch_weight: Weight::from_parts(1000000000, 0),
+					}.into(),
+					Box::new((
+						helpers::VerifySubmitMessagesProofOutcome::<RuntimeHelper::Runtime, RuntimeHelper::MPI>::expect_last_delivered_nonce(
+							lane_id,
+							1,
+						),
+						helpers::VerifyRelayerRewarded::<RuntimeHelper::Runtime>::expect_relayer_reward(
+							relayer_id_at_this_chain,
+							RewardsAccountParams::new(
+								lane_id,
+								bridged_chain_id,
+								RewardsAccountOwner::ThisChain,
+							),
+						),
+					)),
+				),
+			]
+		},
+	);
+}
+
 /// Test-case makes sure that Runtime can dispatch XCM messages submitted by relayer,
 /// with proofs (finality, message) batched together in signed extrinsic.
 /// Also verifies relayer transaction signed extensions work as intended.
@@ -265,7 +409,14 @@ pub fn complex_relay_extrinsic_works<RuntimeHelper>(
 				test_data::from_grandpa_chain::make_complex_relayer_delivery_proofs::<
 					RuntimeHelper::MB,
 					(),
-				>(lane_id, xcm.into(), message_nonce, message_destination, relay_header_number);
+				>(
+					lane_id,
+					xcm.into(),
+					message_nonce,
+					message_destination,
+					relay_header_number,
+					false,
+				);
 
 			let relay_chain_header_hash = relay_chain_header.hash();
 			vec![(
@@ -344,6 +495,7 @@ where
 				1,
 				[GlobalConsensus(Polkadot), Parachain(1_000)].into(),
 				1u32.into(),
+				false,
 			);
 
 		// generate batch call that provides finality for bridged relay and parachains + message
@@ -423,3 +575,109 @@ where
 		compute_extrinsic_fee(batch)
 	})
 }
+
+/// Estimates transaction fee for default message delivery transaction from bridged GRANDPA chain.
+pub fn can_calculate_fee_for_standalone_message_delivery_transaction<RuntimeHelper>(
+	collator_session_key: CollatorSessionKeys<RuntimeHelper::Runtime>,
+	compute_extrinsic_fee: fn(
+		<RuntimeHelper::Runtime as frame_system::Config>::RuntimeCall,
+	) -> u128,
+) -> u128
+where
+	RuntimeHelper: WithRemoteGrandpaChainHelper,
+	RuntimeCallOf<RuntimeHelper::Runtime>:
+		From<BridgeMessagesCall<RuntimeHelper::Runtime, RuntimeHelper::MPI>>,
+	UnderlyingChainOf<MessageBridgedChain<RuntimeHelper::MB>>: ChainWithGrandpa,
+	<RuntimeHelper::Runtime as BridgeMessagesConfig<RuntimeHelper::MPI>>::SourceHeaderChain:
+		SourceHeaderChain<
+			MessagesProof = FromBridgedChainMessagesProof<
+				HashOf<MessageBridgedChain<RuntimeHelper::MB>>,
+			>,
+		>,
+{
+	run_test::<RuntimeHelper::Runtime, _>(collator_session_key, 1000, vec![], || {
+		// generate bridged relay chain finality, parachain heads and message proofs,
+		// to be submitted by relayer to this chain.
+		//
+		// we don't care about parameter values here, apart from the XCM message size. But we
+		// do not need to have a large message here, because we're charging for every byte of
+		// the message additionally
+		let (_, _, message_proof) =
+			test_data::from_grandpa_chain::make_complex_relayer_delivery_proofs::<
+				RuntimeHelper::MB,
+				(),
+			>(
+				LaneId::default(),
+				vec![Instruction::<()>::ClearOrigin; 1_024].into(),
+				1,
+				[GlobalConsensus(Polkadot), Parachain(1_000)].into(),
+				1u32.into(),
+				false,
+			);
+
+		let call = test_data::from_grandpa_chain::make_standalone_relayer_delivery_call::<
+			RuntimeHelper::Runtime,
+			RuntimeHelper::GPI,
+			RuntimeHelper::MPI,
+		>(
+			message_proof,
+			helpers::relayer_id_at_bridged_chain::<RuntimeHelper::Runtime, RuntimeHelper::MPI>(),
+		);
+
+		compute_extrinsic_fee(call)
+	})
+}
+
+/// Estimates transaction fee for default message confirmation transaction (batched with required
+/// proofs) from bridged parachain.
+pub fn can_calculate_fee_for_standalone_message_confirmation_transaction<RuntimeHelper>(
+	collator_session_key: CollatorSessionKeys<RuntimeHelper::Runtime>,
+	compute_extrinsic_fee: fn(
+		<RuntimeHelper::Runtime as frame_system::Config>::RuntimeCall,
+	) -> u128,
+) -> u128
+where
+	RuntimeHelper: WithRemoteGrandpaChainHelper,
+	AccountIdOf<RuntimeHelper::Runtime>: From<AccountId32>,
+	MessageThisChain<RuntimeHelper::MB>:
+		bp_runtime::Chain<AccountId = AccountIdOf<RuntimeHelper::Runtime>>,
+	RuntimeCallOf<RuntimeHelper::Runtime>:
+		From<BridgeMessagesCall<RuntimeHelper::Runtime, RuntimeHelper::MPI>>,
+	UnderlyingChainOf<MessageBridgedChain<RuntimeHelper::MB>>: ChainWithGrandpa,
+	<RuntimeHelper::Runtime as BridgeMessagesConfig<RuntimeHelper::MPI>>::TargetHeaderChain:
+		TargetHeaderChain<
+			XcmAsPlainPayload,
+			AccountIdOf<RuntimeHelper::Runtime>,
+			MessagesDeliveryProof = FromBridgedChainMessagesDeliveryProof<
+				HashOf<UnderlyingChainOf<MessageBridgedChain<RuntimeHelper::MB>>>,
+			>,
+		>,
+{
+	run_test::<RuntimeHelper::Runtime, _>(collator_session_key, 1000, vec![], || {
+		// generate bridged relay chain finality, parachain heads and message proofs,
+		// to be submitted by relayer to this chain.
+		let unrewarded_relayers = UnrewardedRelayersState {
+			unrewarded_relayer_entries: 1,
+			total_messages: 1,
+			..Default::default()
+		};
+		let (_, _, message_delivery_proof) =
+			test_data::from_grandpa_chain::make_complex_relayer_confirmation_proofs::<
+				RuntimeHelper::MB,
+				(),
+			>(
+				LaneId::default(),
+				1u32.into(),
+				AccountId32::from(Alice.public()).into(),
+				unrewarded_relayers.clone(),
+			);
+
+		let call = test_data::from_grandpa_chain::make_standalone_relayer_confirmation_call::<
+			RuntimeHelper::Runtime,
+			RuntimeHelper::GPI,
+			RuntimeHelper::MPI,
+		>(message_delivery_proof, unrewarded_relayers);
+
+		compute_extrinsic_fee(call)
+	})
+}
diff --git a/cumulus/parachains/runtimes/bridge-hubs/test-utils/src/test_cases/from_parachain.rs b/cumulus/parachains/runtimes/bridge-hubs/test-utils/src/test_cases/from_parachain.rs
index 72ec0718acf7759aedb02e91356fea73ee73e7e7..12ab382d9e0f6518afb93f118199170acb5f8cc6 100644
--- a/cumulus/parachains/runtimes/bridge-hubs/test-utils/src/test_cases/from_parachain.rs
+++ b/cumulus/parachains/runtimes/bridge-hubs/test-utils/src/test_cases/from_parachain.rs
@@ -42,6 +42,7 @@ use frame_system::pallet_prelude::BlockNumberFor;
 use parachains_runtimes_test_utils::{
 	AccountIdOf, BasicParachainRuntime, CollatorSessionKeys, RuntimeCallOf, SlotDurations,
 };
+use sp_core::Get;
 use sp_keyring::AccountKeyring::*;
 use sp_runtime::{traits::Header as HeaderT, AccountId32};
 use xcm::latest::prelude::*;
@@ -188,6 +189,7 @@ pub fn relayed_incoming_message_works<RuntimeHelper>(
 				para_header_number,
 				relay_header_number,
 				bridged_para_id,
+				false,
 			);
 
 			let parachain_head_hash = parachain_head.hash();
@@ -241,6 +243,177 @@ pub fn relayed_incoming_message_works<RuntimeHelper>(
 	);
 }
 
+/// Test-case makes sure that Runtime can dispatch XCM messages submitted by relayer,
+/// with proofs (finality, para heads, message) independently submitted.
+/// Finality and para heads are submitted for free in this test.
+/// Also verifies relayer transaction signed extensions work as intended.
+pub fn free_relay_extrinsic_works<RuntimeHelper>(
+	collator_session_key: CollatorSessionKeys<RuntimeHelper::Runtime>,
+	slot_durations: SlotDurations,
+	runtime_para_id: u32,
+	bridged_para_id: u32,
+	bridged_chain_id: bp_runtime::ChainId,
+	sibling_parachain_id: u32,
+	local_relay_chain_id: NetworkId,
+	lane_id: LaneId,
+	prepare_configuration: impl Fn(),
+	construct_and_apply_extrinsic: fn(
+		sp_keyring::AccountKeyring,
+		<RuntimeHelper::Runtime as frame_system::Config>::RuntimeCall,
+	) -> sp_runtime::DispatchOutcome,
+) where
+	RuntimeHelper: WithRemoteParachainHelper,
+	RuntimeHelper::Runtime: pallet_balances::Config,
+	AccountIdOf<RuntimeHelper::Runtime>: From<AccountId32>,
+	RuntimeCallOf<RuntimeHelper::Runtime>: From<BridgeGrandpaCall<RuntimeHelper::Runtime, RuntimeHelper::GPI>>
+		+ From<BridgeParachainsCall<RuntimeHelper::Runtime, RuntimeHelper::PPI>>
+		+ From<BridgeMessagesCall<RuntimeHelper::Runtime, RuntimeHelper::MPI>>,
+	UnderlyingChainOf<MessageBridgedChain<RuntimeHelper::MB>>:
+		bp_runtime::Chain<Hash = ParaHash> + Parachain,
+	<RuntimeHelper::Runtime as BridgeGrandpaConfig<RuntimeHelper::GPI>>::BridgedChain:
+		bp_runtime::Chain<Hash = RelayBlockHash, BlockNumber = RelayBlockNumber> + ChainWithGrandpa,
+	<RuntimeHelper::Runtime as BridgeMessagesConfig<RuntimeHelper::MPI>>::SourceHeaderChain:
+		SourceHeaderChain<
+			MessagesProof = FromBridgedChainMessagesProof<
+				HashOf<MessageBridgedChain<RuntimeHelper::MB>>,
+			>,
+		>,
+{
+	// ensure that the runtime allows free header submissions
+	let free_headers_interval = <RuntimeHelper::Runtime as BridgeGrandpaConfig<
+		RuntimeHelper::GPI,
+	>>::FreeHeadersInterval::get()
+	.expect("this test requires runtime, configured to accept headers for free; qed");
+
+	helpers::relayed_incoming_message_works::<
+		RuntimeHelper::Runtime,
+		RuntimeHelper::AllPalletsWithoutSystem,
+		RuntimeHelper::MPI,
+	>(
+		collator_session_key,
+		slot_durations,
+		runtime_para_id,
+		sibling_parachain_id,
+		local_relay_chain_id,
+		construct_and_apply_extrinsic,
+		|relayer_id_at_this_chain,
+		 relayer_id_at_bridged_chain,
+		 message_destination,
+		 message_nonce,
+		 xcm| {
+			prepare_configuration();
+
+			// start with bridged relay chain block#0
+			let initial_block_number = 0;
+			helpers::initialize_bridge_grandpa_pallet::<RuntimeHelper::Runtime, RuntimeHelper::GPI>(
+				test_data::initialization_data::<RuntimeHelper::Runtime, RuntimeHelper::GPI>(
+					initial_block_number,
+				),
+			);
+
+			// free relay chain header is `0 + free_headers_interval`
+			let relay_header_number = initial_block_number + free_headers_interval;
+			// first parachain header is always submitted for free
+			let para_header_number = 1;
+
+			// relayer balance shall not change after relay and para header submissions
+			let initial_relayer_balance =
+				pallet_balances::Pallet::<RuntimeHelper::Runtime>::free_balance(
+					relayer_id_at_this_chain.clone(),
+				);
+
+			// initialize the `FreeHeadersRemaining` storage value
+			pallet_bridge_grandpa::Pallet::<RuntimeHelper::Runtime, RuntimeHelper::GPI>::on_initialize(
+				0u32.into(),
+			);
+
+			// generate bridged relay chain finality, parachain heads and message proofs,
+			// to be submitted by relayer to this chain.
+			let (
+				relay_chain_header,
+				grandpa_justification,
+				parachain_head,
+				parachain_heads,
+				para_heads_proof,
+				message_proof,
+			) = test_data::from_parachain::make_complex_relayer_delivery_proofs::<
+				<RuntimeHelper::Runtime as BridgeGrandpaConfig<RuntimeHelper::GPI>>::BridgedChain,
+				RuntimeHelper::MB,
+				(),
+			>(
+				lane_id,
+				xcm.into(),
+				message_nonce,
+				message_destination,
+				para_header_number,
+				relay_header_number,
+				bridged_para_id,
+				true,
+			);
+
+			let parachain_head_hash = parachain_head.hash();
+			let relay_chain_header_hash = relay_chain_header.hash();
+			let relay_chain_header_number = *relay_chain_header.number();
+			vec![
+				(
+					BridgeGrandpaCall::<RuntimeHelper::Runtime, RuntimeHelper::GPI>::submit_finality_proof {
+						finality_target: Box::new(relay_chain_header),
+						justification: grandpa_justification,
+					}.into(),
+					Box::new((
+						helpers::VerifySubmitGrandpaFinalityProofOutcome::<RuntimeHelper::Runtime, RuntimeHelper::GPI>::expect_best_header_hash(
+							relay_chain_header_hash,
+						),
+						helpers::VerifyRelayerBalance::<RuntimeHelper::Runtime>::expect_relayer_balance(
+							relayer_id_at_this_chain.clone(),
+							initial_relayer_balance,
+						),
+					)),
+				),
+				(
+					BridgeParachainsCall::<RuntimeHelper::Runtime, RuntimeHelper::PPI>::submit_parachain_heads {
+						at_relay_block: (relay_chain_header_number, relay_chain_header_hash),
+						parachains: parachain_heads,
+						parachain_heads_proof: para_heads_proof,
+					}.into(),
+					Box::new((
+						helpers::VerifySubmitParachainHeaderProofOutcome::<RuntimeHelper::Runtime, RuntimeHelper::PPI>::expect_best_header_hash(
+							bridged_para_id,
+							parachain_head_hash,
+						),
+						/*helpers::VerifyRelayerBalance::<RuntimeHelper::Runtime>::expect_relayer_balance(
+							relayer_id_at_this_chain.clone(),
+							initial_relayer_balance,
+						),*/
+					)),
+				),
+				(
+					BridgeMessagesCall::<RuntimeHelper::Runtime, RuntimeHelper::MPI>::receive_messages_proof {
+						relayer_id_at_bridged_chain,
+						proof: message_proof,
+						messages_count: 1,
+						dispatch_weight: Weight::from_parts(1000000000, 0),
+					}.into(),
+					Box::new((
+						helpers::VerifySubmitMessagesProofOutcome::<RuntimeHelper::Runtime, RuntimeHelper::MPI>::expect_last_delivered_nonce(
+							lane_id,
+							1,
+						),
+						helpers::VerifyRelayerRewarded::<RuntimeHelper::Runtime>::expect_relayer_reward(
+							relayer_id_at_this_chain,
+							RewardsAccountParams::new(
+								lane_id,
+								bridged_chain_id,
+								RewardsAccountOwner::ThisChain,
+							),
+						),
+					)),
+				),
+			]
+		},
+	);
+}
+
 /// Test-case makes sure that Runtime can dispatch XCM messages submitted by relayer,
 /// with proofs (finality, para heads, message) batched together in signed extrinsic.
 /// Also verifies relayer transaction signed extensions work as intended.
@@ -325,6 +498,7 @@ pub fn complex_relay_extrinsic_works<RuntimeHelper>(
 				para_header_number,
 				relay_header_number,
 				bridged_para_id,
+				false,
 			);
 
 			let parachain_head_hash = parachain_head.hash();
@@ -428,6 +602,7 @@ where
 			1,
 			5,
 			1_000,
+			false,
 		);
 
 		// generate batch call that provides finality for bridged relay and parachains + message
@@ -527,3 +702,126 @@ where
 		compute_extrinsic_fee(batch)
 	})
 }
+
+/// Estimates transaction fee for default message delivery transaction from bridged parachain.
+pub fn can_calculate_fee_for_standalone_message_delivery_transaction<RuntimeHelper>(
+	collator_session_key: CollatorSessionKeys<RuntimeHelper::Runtime>,
+	compute_extrinsic_fee: fn(
+		<RuntimeHelper::Runtime as frame_system::Config>::RuntimeCall,
+	) -> u128,
+) -> u128
+where
+	RuntimeHelper: WithRemoteParachainHelper,
+	RuntimeCallOf<RuntimeHelper::Runtime>:
+		From<BridgeMessagesCall<RuntimeHelper::Runtime, RuntimeHelper::MPI>>,
+	UnderlyingChainOf<MessageBridgedChain<RuntimeHelper::MB>>:
+		bp_runtime::Chain<Hash = ParaHash> + Parachain,
+	<RuntimeHelper::Runtime as BridgeGrandpaConfig<RuntimeHelper::GPI>>::BridgedChain:
+		bp_runtime::Chain<Hash = RelayBlockHash, BlockNumber = RelayBlockNumber> + ChainWithGrandpa,
+	<RuntimeHelper::Runtime as BridgeMessagesConfig<RuntimeHelper::MPI>>::SourceHeaderChain:
+		SourceHeaderChain<
+			MessagesProof = FromBridgedChainMessagesProof<
+				HashOf<MessageBridgedChain<RuntimeHelper::MB>>,
+			>,
+		>,
+{
+	run_test::<RuntimeHelper::Runtime, _>(collator_session_key, 1000, vec![], || {
+		// generate bridged relay chain finality, parachain heads and message proofs,
+		// to be submitted by relayer to this chain.
+		//
+		// we don't care about parameter values here, apart from the XCM message size. But we
+		// do not need to have a large message here, because we're charging for every byte of
+		// the message additionally
+		let (
+			_,
+			_,
+			_,
+			_,
+			_,
+			message_proof,
+		) = test_data::from_parachain::make_complex_relayer_delivery_proofs::<
+			<RuntimeHelper::Runtime as pallet_bridge_grandpa::Config<RuntimeHelper::GPI>>::BridgedChain,
+			RuntimeHelper::MB,
+			(),
+		>(
+			LaneId::default(),
+			vec![Instruction::<()>::ClearOrigin; 1_024].into(),
+			1,
+			[GlobalConsensus(Polkadot), Parachain(1_000)].into(),
+			1,
+			5,
+			1_000,
+			false,
+		);
+
+		let call = test_data::from_parachain::make_standalone_relayer_delivery_call::<
+			RuntimeHelper::Runtime,
+			RuntimeHelper::MPI,
+			_,
+		>(
+			message_proof,
+			helpers::relayer_id_at_bridged_chain::<RuntimeHelper::Runtime, RuntimeHelper::MPI>(),
+		);
+
+		compute_extrinsic_fee(call)
+	})
+}
+
+/// Estimates transaction fee for default message confirmation transaction (batched with required
+/// proofs) from bridged parachain.
+pub fn can_calculate_fee_for_standalone_message_confirmation_transaction<RuntimeHelper>(
+	collator_session_key: CollatorSessionKeys<RuntimeHelper::Runtime>,
+	compute_extrinsic_fee: fn(
+		<RuntimeHelper::Runtime as frame_system::Config>::RuntimeCall,
+	) -> u128,
+) -> u128
+where
+	RuntimeHelper: WithRemoteParachainHelper,
+	AccountIdOf<RuntimeHelper::Runtime>: From<AccountId32>,
+	MessageThisChain<RuntimeHelper::MB>:
+		bp_runtime::Chain<AccountId = AccountIdOf<RuntimeHelper::Runtime>>,
+	RuntimeCallOf<RuntimeHelper::Runtime>:
+		From<BridgeMessagesCall<RuntimeHelper::Runtime, RuntimeHelper::MPI>>,
+	UnderlyingChainOf<MessageBridgedChain<RuntimeHelper::MB>>:
+		bp_runtime::Chain<Hash = ParaHash> + Parachain,
+	<RuntimeHelper::Runtime as BridgeGrandpaConfig<RuntimeHelper::GPI>>::BridgedChain:
+		bp_runtime::Chain<Hash = RelayBlockHash, BlockNumber = RelayBlockNumber> + ChainWithGrandpa,
+	<RuntimeHelper::Runtime as BridgeMessagesConfig<RuntimeHelper::MPI>>::TargetHeaderChain:
+		TargetHeaderChain<
+			XcmAsPlainPayload,
+			AccountIdOf<RuntimeHelper::Runtime>,
+			MessagesDeliveryProof = FromBridgedChainMessagesDeliveryProof<
+				HashOf<UnderlyingChainOf<MessageBridgedChain<RuntimeHelper::MB>>>,
+			>,
+		>,
+{
+	run_test::<RuntimeHelper::Runtime, _>(collator_session_key, 1000, vec![], || {
+		// generate bridged relay chain finality, parachain heads and message proofs,
+		// to be submitted by relayer to this chain.
+		let unrewarded_relayers = UnrewardedRelayersState {
+			unrewarded_relayer_entries: 1,
+			total_messages: 1,
+			..Default::default()
+		};
+		let (_, _, _, _, _, message_delivery_proof) =
+			test_data::from_parachain::make_complex_relayer_confirmation_proofs::<
+				<RuntimeHelper::Runtime as BridgeGrandpaConfig<RuntimeHelper::GPI>>::BridgedChain,
+				RuntimeHelper::MB,
+				(),
+			>(
+				LaneId::default(),
+				1,
+				5,
+				1_000,
+				AccountId32::from(Alice.public()).into(),
+				unrewarded_relayers.clone(),
+			);
+
+		let call = test_data::from_parachain::make_standalone_relayer_confirmation_call::<
+			RuntimeHelper::Runtime,
+			RuntimeHelper::MPI,
+		>(message_delivery_proof, unrewarded_relayers);
+
+		compute_extrinsic_fee(call)
+	})
+}
diff --git a/cumulus/parachains/runtimes/bridge-hubs/test-utils/src/test_cases/helpers.rs b/cumulus/parachains/runtimes/bridge-hubs/test-utils/src/test_cases/helpers.rs
index 2b48f2e3d515f625532d9c5f50fabadb9a89517a..0ce049cd1c4630c55c244afbc8a72213cb83d6b9 100644
--- a/cumulus/parachains/runtimes/bridge-hubs/test-utils/src/test_cases/helpers.rs
+++ b/cumulus/parachains/runtimes/bridge-hubs/test-utils/src/test_cases/helpers.rs
@@ -193,6 +193,34 @@ where
 	}
 }
 
+/// Verifies that relayer balance is equal to given value.
+pub struct VerifyRelayerBalance<Runtime: pallet_balances::Config> {
+	relayer: Runtime::AccountId,
+	balance: Runtime::Balance,
+}
+
+impl<Runtime> VerifyRelayerBalance<Runtime>
+where
+	Runtime: pallet_balances::Config,
+{
+	/// Expect given relayer balance after transaction.
+	pub fn expect_relayer_balance(
+		relayer: Runtime::AccountId,
+		balance: Runtime::Balance,
+	) -> Box<dyn VerifyTransactionOutcome> {
+		Box::new(Self { relayer, balance })
+	}
+}
+
+impl<Runtime> VerifyTransactionOutcome for VerifyRelayerBalance<Runtime>
+where
+	Runtime: pallet_balances::Config,
+{
+	fn verify_outcome(&self) {
+		assert_eq!(pallet_balances::Pallet::<Runtime>::free_balance(&self.relayer), self.balance,);
+	}
+}
+
 /// Initialize bridge GRANDPA pallet.
 pub(crate) fn initialize_bridge_grandpa_pallet<Runtime, GPI>(
 	init_data: bp_header_chain::InitializationData<BridgedHeader<Runtime, GPI>>,
diff --git a/cumulus/parachains/runtimes/bridge-hubs/test-utils/src/test_data/from_grandpa_chain.rs b/cumulus/parachains/runtimes/bridge-hubs/test-utils/src/test_data/from_grandpa_chain.rs
index 017ec0fd54052ae0b00c19a2c474a8e265c768b0..e5d5e7cac96ba14f6abfdae792908352f40d3e31 100644
--- a/cumulus/parachains/runtimes/bridge-hubs/test-utils/src/test_data/from_grandpa_chain.rs
+++ b/cumulus/parachains/runtimes/bridge-hubs/test-utils/src/test_data/from_grandpa_chain.rs
@@ -121,6 +121,60 @@ where
 	}
 }
 
+/// Prepare a call with message proof.
+pub fn make_standalone_relayer_delivery_call<Runtime, GPI, MPI>(
+	message_proof: FromBridgedChainMessagesProof<HashOf<BridgedChain<Runtime, GPI>>>,
+	relayer_id_at_bridged_chain: AccountIdOf<BridgedChain<Runtime, GPI>>,
+) -> Runtime::RuntimeCall
+where
+	Runtime: pallet_bridge_grandpa::Config<GPI>
+		+ pallet_bridge_messages::Config<
+			MPI,
+			InboundPayload = XcmAsPlainPayload,
+			InboundRelayer = AccountIdOf<BridgedChain<Runtime, GPI>>,
+		>,
+	MPI: 'static,
+	<Runtime as pallet_bridge_messages::Config<MPI>>::SourceHeaderChain: SourceHeaderChain<
+		MessagesProof = FromBridgedChainMessagesProof<HashOf<BridgedChain<Runtime, GPI>>>,
+	>,
+	Runtime::RuntimeCall: From<pallet_bridge_messages::Call<Runtime, MPI>>,
+{
+	pallet_bridge_messages::Call::<Runtime, MPI>::receive_messages_proof {
+		relayer_id_at_bridged_chain,
+		proof: message_proof,
+		messages_count: 1,
+		dispatch_weight: Weight::from_parts(1000000000, 0),
+	}
+	.into()
+}
+
+/// Prepare a call with message delivery proof.
+pub fn make_standalone_relayer_confirmation_call<Runtime, GPI, MPI>(
+	message_delivery_proof: FromBridgedChainMessagesDeliveryProof<
+		HashOf<BridgedChain<Runtime, GPI>>,
+	>,
+	relayers_state: UnrewardedRelayersState,
+) -> Runtime::RuntimeCall
+where
+	Runtime: pallet_bridge_grandpa::Config<GPI>
+		+ pallet_bridge_messages::Config<MPI, OutboundPayload = XcmAsPlainPayload>,
+	MPI: 'static,
+	<Runtime as pallet_bridge_messages::Config<MPI>>::TargetHeaderChain: TargetHeaderChain<
+		XcmAsPlainPayload,
+		Runtime::AccountId,
+		MessagesDeliveryProof = FromBridgedChainMessagesDeliveryProof<
+			HashOf<BridgedChain<Runtime, GPI>>,
+		>,
+	>,
+	Runtime::RuntimeCall: From<pallet_bridge_messages::Call<Runtime, MPI>>,
+{
+	pallet_bridge_messages::Call::<Runtime, MPI>::receive_messages_delivery_proof {
+		proof: message_delivery_proof,
+		relayers_state,
+	}
+	.into()
+}
+
 /// Prepare storage proofs of messages, stored at the (bridged) source GRANDPA chain.
 pub fn make_complex_relayer_delivery_proofs<MB, InnerXcmRuntimeCall>(
 	lane_id: LaneId,
@@ -128,6 +182,7 @@ pub fn make_complex_relayer_delivery_proofs<MB, InnerXcmRuntimeCall>(
 	message_nonce: MessageNonce,
 	message_destination: Junctions,
 	header_number: BlockNumberOf<MessageBridgedChain<MB>>,
+	is_minimal_call: bool,
 ) -> (
 	HeaderOf<MessageBridgedChain<MB>>,
 	GrandpaJustification<HeaderOf<MessageBridgedChain<MB>>>,
@@ -153,7 +208,7 @@ where
 
 	let (header, justification) = make_complex_bridged_grandpa_header_proof::<
 		MessageBridgedChain<MB>,
-	>(state_root, header_number);
+	>(state_root, header_number, is_minimal_call);
 
 	let message_proof = FromBridgedChainMessagesProof {
 		bridged_header_hash: header.hash(),
@@ -200,8 +255,11 @@ where
 		StorageProofSize::Minimal(0),
 	);
 
-	let (header, justification) =
-		make_complex_bridged_grandpa_header_proof::<MB::BridgedChain>(state_root, header_number);
+	let (header, justification) = make_complex_bridged_grandpa_header_proof::<MB::BridgedChain>(
+		state_root,
+		header_number,
+		false,
+	);
 
 	let message_delivery_proof = FromBridgedChainMessagesDeliveryProof {
 		bridged_header_hash: header.hash(),
@@ -216,6 +274,7 @@ where
 pub fn make_complex_bridged_grandpa_header_proof<BridgedChain>(
 	state_root: HashOf<BridgedChain>,
 	header_number: BlockNumberOf<BridgedChain>,
+	is_minimal_call: bool,
 ) -> (HeaderOf<BridgedChain>, GrandpaJustification<HeaderOf<BridgedChain>>)
 where
 	BridgedChain: ChainWithGrandpa,
@@ -229,7 +288,9 @@ where
 	// `submit_finality_proof` call size would be close to maximal expected (and refundable)
 	let extra_bytes_required = maximal_expected_submit_finality_proof_call_size::<BridgedChain>()
 		.saturating_sub(header.encoded_size());
-	header.digest_mut().push(DigestItem::Other(vec![42; extra_bytes_required]));
+	if !is_minimal_call {
+		header.digest_mut().push(DigestItem::Other(vec![42; extra_bytes_required]));
+	}
 
 	let justification = make_default_justification(&header);
 	(header, justification)
diff --git a/cumulus/parachains/runtimes/bridge-hubs/test-utils/src/test_data/from_parachain.rs b/cumulus/parachains/runtimes/bridge-hubs/test-utils/src/test_data/from_parachain.rs
index 932ba231239973db8b46ccea56faacc5628a4ffb..5d3cba4e53b5ec7ec9cd2e6141e6e95aa8928970 100644
--- a/cumulus/parachains/runtimes/bridge-hubs/test-utils/src/test_data/from_parachain.rs
+++ b/cumulus/parachains/runtimes/bridge-hubs/test-utils/src/test_data/from_parachain.rs
@@ -159,6 +159,52 @@ where
 	}
 }
 
+/// Prepare a call with message proof.
+pub fn make_standalone_relayer_delivery_call<Runtime, MPI, InboundRelayer>(
+	message_proof: FromBridgedChainMessagesProof<ParaHash>,
+	relayer_id_at_bridged_chain: InboundRelayer,
+) -> Runtime::RuntimeCall where
+	Runtime: pallet_bridge_messages::Config<
+		MPI,
+		InboundPayload = XcmAsPlainPayload,
+		InboundRelayer = InboundRelayer,
+	>,
+	MPI: 'static,
+	Runtime::RuntimeCall: From<pallet_bridge_messages::Call::<Runtime, MPI>>,
+	<<Runtime as pallet_bridge_messages::Config<MPI>>::SourceHeaderChain as SourceHeaderChain>::MessagesProof:
+		From<FromBridgedChainMessagesProof<ParaHash>>,
+{
+	pallet_bridge_messages::Call::<Runtime, MPI>::receive_messages_proof {
+		relayer_id_at_bridged_chain: relayer_id_at_bridged_chain.into(),
+		proof: message_proof.into(),
+		messages_count: 1,
+		dispatch_weight: Weight::from_parts(1000000000, 0),
+	}
+	.into()
+}
+
+/// Prepare a call with message delivery proof.
+pub fn make_standalone_relayer_confirmation_call<Runtime, MPI>(
+	message_delivery_proof: FromBridgedChainMessagesDeliveryProof<ParaHash>,
+	relayers_state: UnrewardedRelayersState,
+) -> Runtime::RuntimeCall
+where
+	Runtime: pallet_bridge_messages::Config<MPI, OutboundPayload = XcmAsPlainPayload>,
+	MPI: 'static,
+	Runtime::RuntimeCall: From<pallet_bridge_messages::Call<Runtime, MPI>>,
+	<Runtime as pallet_bridge_messages::Config<MPI>>::TargetHeaderChain: TargetHeaderChain<
+		XcmAsPlainPayload,
+		Runtime::AccountId,
+		MessagesDeliveryProof = FromBridgedChainMessagesDeliveryProof<ParaHash>,
+	>,
+{
+	pallet_bridge_messages::Call::<Runtime, MPI>::receive_messages_delivery_proof {
+		proof: message_delivery_proof,
+		relayers_state,
+	}
+	.into()
+}
+
 /// Prepare storage proofs of messages, stored at the source chain.
 pub fn make_complex_relayer_delivery_proofs<BridgedRelayChain, MB, InnerXcmRuntimeCall>(
 	lane_id: LaneId,
@@ -168,6 +214,7 @@ pub fn make_complex_relayer_delivery_proofs<BridgedRelayChain, MB, InnerXcmRunti
 	para_header_number: u32,
 	relay_header_number: u32,
 	bridged_para_id: u32,
+	is_minimal_call: bool,
 ) -> (
 	HeaderOf<BridgedRelayChain>,
 	GrandpaJustification<HeaderOf<BridgedRelayChain>>,
@@ -201,6 +248,7 @@ where
 			para_header_number,
 			relay_header_number,
 			bridged_para_id,
+			is_minimal_call,
 		);
 
 	let message_proof = FromBridgedChainMessagesProof {
@@ -266,6 +314,7 @@ where
 			para_header_number,
 			relay_header_number,
 			bridged_para_id,
+			false,
 		);
 
 	let message_delivery_proof = FromBridgedChainMessagesDeliveryProof {
@@ -290,6 +339,7 @@ pub fn make_complex_bridged_parachain_heads_proof<BridgedRelayChain, MB>(
 	para_header_number: u32,
 	relay_header_number: BlockNumberOf<BridgedRelayChain>,
 	bridged_para_id: u32,
+	is_minimal_call: bool,
 ) -> (
 	HeaderOf<BridgedRelayChain>,
 	GrandpaJustification<HeaderOf<BridgedRelayChain>>,
@@ -319,9 +369,12 @@ where
 		)]);
 	assert_eq!(bridged_para_head.hash(), parachain_heads[0].1);
 
-	let (relay_chain_header, justification) = make_complex_bridged_grandpa_header_proof::<
-		BridgedRelayChain,
-	>(relay_state_root, relay_header_number);
+	let (relay_chain_header, justification) =
+		make_complex_bridged_grandpa_header_proof::<BridgedRelayChain>(
+			relay_state_root,
+			relay_header_number,
+			is_minimal_call,
+		);
 
 	(relay_chain_header, justification, bridged_para_head, parachain_heads, para_heads_proof)
 }
diff --git a/prdoc/pr_4102.prdoc b/prdoc/pr_4102.prdoc
new file mode 100644
index 0000000000000000000000000000000000000000..50c1ec23b2ac1a08c02181727b70bce49f80a0fa
--- /dev/null
+++ b/prdoc/pr_4102.prdoc
@@ -0,0 +1,43 @@
+title: "Bridge: make some headers submissions free"
+
+doc:
+  - audience: Runtime Dev
+    description: |
+      Adds `FreeHeadersInterval` configuration constant to the `pallet_bridge_grandpa`.
+      Transactions that improve best known header by at least `FreeHeadersInterval` headers
+      are now free for the submitter. Additionally, we allow single free parachain header
+      update per every free relay chain header. Bridge signed extensions are adjusted
+      to support that new scheme. Bridge runtime APIs are extended to support that new
+      scheme. Bridge fees are decreased by ~98% because now they do not include cost of
+      finality submissions - we assume relayers will be submitting finality transactions
+      for free.
+
+crates:
+  - name: bridge-runtime-common
+    bump: major
+  - name: bp-bridge-hub-cumulus
+    bump: patch
+  - name: bp-bridge-hub-kusama
+    bump: major
+  - name: bp-bridge-hub-polkadot
+    bump: major
+  - name: bp-bridge-hub-rococo
+    bump: major
+  - name: bp-bridge-hub-westend
+    bump: major
+  - name: pallet-bridge-grandpa
+    bump: major
+  - name: pallet-bridge-parachains
+    bump: major
+  - name: bp-parachains
+    bump: major
+  - name: bp-runtime
+    bump: major
+  - name: relay-substrate-client
+    bump: major
+  - name: bridge-hub-rococo-runtime
+    bump: major
+  - name: bridge-hub-westend-runtime
+    bump: major
+  - name: bridge-hub-test-utils
+    bump: minor