use codec::{Decode, DecodeWithMemTracking, Encode}; use core::marker::PhantomData; use frame_support::pallet_prelude::TypeInfo; use frame_support::traits::IsSubType; use frame_system::CheckMortality as CheckMortalitySubstrate; use pallet_shield::Call as ShieldCall; use sp_runtime::{ generic::Era, traits::{DispatchInfoOf, Dispatchable, Implication, TransactionExtension, ValidateResult}, transaction_validity::{InvalidTransaction, TransactionSource, TransactionValidityError}, }; use subtensor_macros::freeze_struct; /// Maximum allowed Era period (in blocks) for `submit_encrypted` transactions. /// /// Substrate's minimum mortal Era is 4 blocks (smallest power-of-two ≥ 4). /// Limiting encrypted txs to this value ensures stuck transactions evict from /// the fork-aware tx pool within a handful of blocks. const MAX_SHIELD_ERA_PERIOD: u64 = 8; /// A transparent wrapper around [`frame_system::CheckMortality`] that additionally /// enforces a short Era period for [`pallet_shield::Call::submit_encrypted`] transactions. /// /// Drop-in replacement for `frame_system::CheckMortality` in the runtime's /// transaction extension pipeline. Shares the same `IDENTIFIER = "CheckMortality"` /// and identical SCALE encoding, so existing clients require no changes. /// /// Any `submit_encrypted` call signed with an immortal Era or a mortal Era period /// longer than [`MAX_SHIELD_ERA_PERIOD`] is rejected immediately at pool submission /// with `InvalidTransaction::Stale`, preventing pool bloat from long-lived /// encrypted transactions that can never be decrypted. #[freeze_struct("3cb7a665d55d00e5")] #[derive(Encode, Decode, DecodeWithMemTracking, Clone, Eq, PartialEq, TypeInfo)] #[scale_info(skip_type_params(T))] pub struct CheckMortality(pub Era, PhantomData); impl CheckMortality { pub fn from(era: Era) -> Self { Self(era, PhantomData) } } impl core::fmt::Debug for CheckMortality { #[cfg(feature = "std")] fn fmt(&self, f: &mut core::fmt::Formatter) -> core::fmt::Result { write!(f, "CheckMortality({:?})", self.0) } #[cfg(not(feature = "std"))] fn fmt(&self, _: &mut core::fmt::Formatter) -> core::fmt::Result { Ok(()) } } impl TransactionExtension<::RuntimeCall> for CheckMortality where ::RuntimeCall: Dispatchable + IsSubType>, T: pallet_shield::Config, { const IDENTIFIER: &'static str = "CheckMortality"; type Implicit = as TransactionExtension< ::RuntimeCall, >>::Implicit; type Val = as TransactionExtension< ::RuntimeCall, >>::Val; type Pre = as TransactionExtension< ::RuntimeCall, >>::Pre; fn implicit(&self) -> Result { CheckMortalitySubstrate::::from(self.0).implicit() } fn weight(&self, call: &::RuntimeCall) -> sp_weights::Weight { CheckMortalitySubstrate::::from(self.0).weight(call) } fn validate( &self, origin: T::RuntimeOrigin, call: &::RuntimeCall, info: &DispatchInfoOf<::RuntimeCall>, len: usize, self_implicit: Self::Implicit, inherited_implication: &impl Implication, source: TransactionSource, ) -> ValidateResult::RuntimeCall> { if let Some(ShieldCall::submit_encrypted { .. }) = IsSubType::>::is_sub_type(call) { let era_too_long = match self.0 { Era::Immortal => true, Era::Mortal(period, _) => period > MAX_SHIELD_ERA_PERIOD, }; if era_too_long { return Err(InvalidTransaction::Stale.into()); } } CheckMortalitySubstrate::::from(self.0).validate( origin, call, info, len, self_implicit, inherited_implication, source, ) } fn prepare( self, val: Self::Val, origin: &T::RuntimeOrigin, call: &::RuntimeCall, info: &DispatchInfoOf<::RuntimeCall>, len: usize, ) -> Result { CheckMortalitySubstrate::::from(self.0).prepare(val, origin, call, info, len) } } #[allow(clippy::unwrap_used)] #[cfg(test)] mod tests { use super::*; use frame_support::pallet_prelude::{BoundedVec, ConstU32}; use sp_runtime::transaction_validity::InvalidTransaction; use crate::{Runtime, RuntimeCall, System}; use sp_runtime::BuildStorage; fn new_test_ext() -> sp_io::TestExternalities { let mut ext: sp_io::TestExternalities = crate::RuntimeGenesisConfig { sudo: pallet_sudo::GenesisConfig { key: None }, ..Default::default() } .build_storage() .unwrap() .into(); ext.execute_with(|| System::set_block_number(1)); ext } fn submit_encrypted_call() -> RuntimeCall { RuntimeCall::MevShield(pallet_shield::Call::submit_encrypted { ciphertext: BoundedVec::>::truncate_from(vec![0xAA; 64]), }) } fn remark_call() -> RuntimeCall { RuntimeCall::System(frame_system::Call::remark { remark: vec![] }) } /// Only tests the early-return path (era check). Does NOT call into /// CheckMortalitySubstrate which needs real block hashes. fn validate_era_check(era: Era, call: &RuntimeCall) -> Result<(), TransactionValidityError> { if let Some(ShieldCall::submit_encrypted { .. }) = IsSubType::>::is_sub_type(call) { let era_too_long = match era { Era::Immortal => true, Era::Mortal(period, _) => period > MAX_SHIELD_ERA_PERIOD, }; if era_too_long { return Err(InvalidTransaction::Stale.into()); } } Ok(()) } #[test] fn shield_tx_with_immortal_era_rejected() { new_test_ext().execute_with(|| { assert_eq!( validate_era_check(Era::Immortal, &submit_encrypted_call()), Err(InvalidTransaction::Stale.into()) ); }); } #[test] fn shield_tx_with_era_too_long_rejected() { new_test_ext().execute_with(|| { // Period 16 > MAX_SHIELD_ERA_PERIOD (8) assert_eq!( validate_era_check(Era::mortal(16, 1), &submit_encrypted_call()), Err(InvalidTransaction::Stale.into()) ); }); } #[test] fn shield_tx_with_max_allowed_era_accepted() { new_test_ext().execute_with(|| { assert!(validate_era_check(Era::mortal(8, 1), &submit_encrypted_call()).is_ok()); }); } #[test] fn shield_tx_with_short_era_accepted() { new_test_ext().execute_with(|| { assert!(validate_era_check(Era::mortal(4, 1), &submit_encrypted_call()).is_ok()); }); } #[test] fn non_shield_tx_with_immortal_era_passes_through() { new_test_ext().execute_with(|| { assert!(validate_era_check(Era::Immortal, &remark_call()).is_ok()); }); } #[test] fn non_shield_tx_with_long_era_passes_through() { new_test_ext().execute_with(|| { assert!(validate_era_check(Era::mortal(256, 1), &remark_call()).is_ok()); }); } }