diff --git a/Cargo.lock b/Cargo.lock index 7f3168d7a..8584ab2f1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9315,6 +9315,9 @@ dependencies = [ name = "pallets-common" version = "0.1.0" dependencies = [ + "pallet-proxy", + "pallet-sudo", + "pallet-utility", "parity-scale-codec", "polkadot-sdk-frame", "scale-info", diff --git a/pallets/common/Cargo.toml b/pallets/common/Cargo.toml index 0926df4ac..0121a3e4d 100644 --- a/pallets/common/Cargo.toml +++ b/pallets/common/Cargo.toml @@ -17,17 +17,29 @@ scale-info = { features = ["derive"], workspace = true } polkadot-sdk-frame = { workspace = true, default-features = false, features = [ "runtime", ] } +pallet-proxy = { workspace = true } +pallet-sudo = { workspace = true } +pallet-utility = { workspace = true } [features] default = ["std"] runtime-benchmarks = [ + "pallet-proxy/runtime-benchmarks", + "pallet-sudo/runtime-benchmarks", + "pallet-utility/runtime-benchmarks", "polkadot-sdk-frame/runtime-benchmarks", ] std = [ "codec/std", + "pallet-proxy/std", + "pallet-sudo/std", + "pallet-utility/std", "polkadot-sdk-frame/std", "scale-info/std", ] try-runtime = [ + "pallet-proxy/try-runtime", + "pallet-sudo/try-runtime", + "pallet-utility/try-runtime", "polkadot-sdk-frame/try-runtime", ] diff --git a/pallets/common/src/lib.rs b/pallets/common/src/lib.rs index d8579044a..9d3da90a6 100644 --- a/pallets/common/src/lib.rs +++ b/pallets/common/src/lib.rs @@ -15,6 +15,9 @@ #![cfg_attr(not(feature = "std"), no_std)] +extern crate alloc; + +use alloc::{vec, vec::Vec}; use codec::{Decode, Encode}; use polkadot_sdk_frame::{ deps::frame_support, @@ -324,3 +327,96 @@ impl ReservableCurrency for NoCurrency( + call: &pallet_utility::Call, +) -> Option::RuntimeCall>> { + let inner = utility_inner_calls(call); + if inner.is_empty() { + return None; + } + Some(inner) +} + +/// Inspect a sudo call for wrapper semantics: returns inner calls. +pub fn inspect_sudo_wrapper( + call: &pallet_sudo::Call, +) -> Option::RuntimeCall>> { + let inner = sudo_inner_calls(call); + if inner.is_empty() { + return None; + } + Some(inner) +} + +/// Inspect a proxy call for wrapper semantics: returns inner calls. +pub fn inspect_proxy_wrapper( + call: &pallet_proxy::Call, +) -> Option::RuntimeCall>> { + let inner = proxy_inner_calls(call); + if inner.is_empty() { + return None; + } + Some(inner) +} + +/// Extract inner calls from a utility call variant. +pub fn utility_inner_calls( + call: &pallet_utility::Call, +) -> Vec<&::RuntimeCall> { + match call { + pallet_utility::Call::batch { calls } | + pallet_utility::Call::batch_all { calls } | + pallet_utility::Call::force_batch { calls } => calls.iter().collect(), + pallet_utility::Call::as_derivative { call, .. } | + pallet_utility::Call::dispatch_as { call, .. } | + pallet_utility::Call::with_weight { call, .. } | + pallet_utility::Call::dispatch_as_fallible { call, .. } => vec![call.as_ref()], + // `if_else` executes only ONE branch (fallback runs only if main fails), + // but we return both so that authorization is validated and consumed for + // both paths during prepare. This over-charges by one branch's worth of + // authorization, but is safe — the alternative of only validating `main` + // would leave the `fallback` store unvalidated if it runs. + pallet_utility::Call::if_else { main, fallback, .. } => + vec![main.as_ref(), fallback.as_ref()], + // __Ignore is a phantom variant generated by FRAME (contains Never so is + // unreachable). Listing it explicitly instead of `_` ensures that new + // upstream variants cause a compile error, forcing a conscious decision. + pallet_utility::Call::__Ignore(..) => vec![], + } +} + +/// Extract inner calls from a proxy call variant. +pub fn proxy_inner_calls( + call: &pallet_proxy::Call, +) -> Vec<&::RuntimeCall> { + match call { + pallet_proxy::Call::proxy { call, .. } | + pallet_proxy::Call::proxy_announced { call, .. } => vec![call.as_ref()], + pallet_proxy::Call::add_proxy { .. } | + pallet_proxy::Call::remove_proxy { .. } | + pallet_proxy::Call::remove_proxies { .. } | + pallet_proxy::Call::create_pure { .. } | + pallet_proxy::Call::kill_pure { .. } | + pallet_proxy::Call::announce { .. } | + pallet_proxy::Call::remove_announcement { .. } | + pallet_proxy::Call::reject_announcement { .. } | + pallet_proxy::Call::poke_deposit { .. } => vec![], + pallet_proxy::Call::__Ignore(..) => vec![], + } +} + +/// Extract inner calls from a sudo call variant. +pub fn sudo_inner_calls( + call: &pallet_sudo::Call, +) -> Vec<&::RuntimeCall> { + match call { + pallet_sudo::Call::sudo { call } | + pallet_sudo::Call::sudo_as { call, .. } | + pallet_sudo::Call::sudo_unchecked_weight { call, .. } => vec![call.as_ref()], + pallet_sudo::Call::set_key { .. } | pallet_sudo::Call::remove_key {} => vec![], + pallet_sudo::Call::__Ignore(..) => vec![], + } +} diff --git a/pallets/transaction-storage/src/extension.rs b/pallets/transaction-storage/src/extension.rs index 190ef1444..edcf8828e 100644 --- a/pallets/transaction-storage/src/extension.rs +++ b/pallets/transaction-storage/src/extension.rs @@ -17,7 +17,8 @@ //! Custom transaction extension for the transaction storage pallet. -use crate::{pallet::Origin, weights::WeightInfo, Call, Config, Pallet}; +use crate::{pallet::Origin, weights::WeightInfo, Call, Config, Pallet, LOG_TARGET}; +use alloc::vec::Vec; use codec::{Decode, DecodeWithMemTracking, Encode}; use core::{fmt, marker::PhantomData}; use polkadot_sdk_frame::{ @@ -28,31 +29,144 @@ use polkadot_sdk_frame::{ type RuntimeCallOf = ::RuntimeCall; +/// Result of [`CallInspector::traverse_storage_calls`]: whether any TransactionStorage +/// pallet calls (management calls like authorize_*, refresh_*, remove_expired_*) were found. +#[derive(Default)] +pub struct TraverseResult { + pub found_storage: bool, +} + +/// Maximum recursion depth for inspecting wrapper calls. +pub const MAX_WRAPPER_DEPTH: u32 = 8; + +/// Tells [`ValidateStorageCalls`] how to find storage calls inside wrapper +/// extrinsics (e.g. `Utility::batch`, `Sudo::sudo_as`). +/// +/// The runtime implements this for its `RuntimeCall` type, allowing the pallet extension +/// to recursively inspect wrapper calls for storage-mutating operations (which are rejected) +/// and management calls (which are validated). +pub trait CallInspector: Clone + PartialEq + Eq + Default +where + RuntimeCallOf: IsSubType>, +{ + /// If `call` is a wrapper, return the inner calls to inspect for storage authorization. + /// + /// Returns `None` for non-wrapper calls. + fn inspect_wrapper(call: &RuntimeCallOf) -> Option>>; + + /// Returns `true` if `call` is a storage-mutating TransactionStorage call (store, + /// store_with_cid_config, renew) — either directly or nested inside wrappers. + /// + /// Intended for use in XCM `SafeCallFilter` implementations. The runtime's + /// [`CallInspector`] provides the wrapper-recursion logic, so this function + /// works for any runtime without duplicating the blocked-call list. + fn is_storage_mutating_call(call: &RuntimeCallOf, depth: u32) -> bool { + // Check direct pallet calls first — these are always identifiable regardless + // of depth, matching the ordering in `traverse_storage_calls`. + if let Some(inner_call) = call.is_sub_type() { + return matches!( + inner_call, + Call::store { .. } | Call::store_with_cid_config { .. } | Call::renew { .. } + ); + } + if depth >= MAX_WRAPPER_DEPTH { + // Fail-safe: treat excessively nested wrappers as storage-mutating rather + // than risk letting a hidden storage call bypass the filter. + tracing::debug!( + target: LOG_TARGET, + "Wrapper recursion limit exceeded (depth: {depth}), treating as storage-mutating", + ); + return true; + } + if let Some(inner_calls) = Self::inspect_wrapper(call) { + return inner_calls + .into_iter() + .any(|inner| Self::is_storage_mutating_call(inner, depth + 1)); + } + false + } + + /// Recursively traverse a call tree, applying `visitor` to each + /// TransactionStorage pallet call found. + /// + /// Returns [`TraverseResult`] with `found_storage` set if any pallet calls were visited. + /// Callers should use [`Self::is_storage_mutating_call`] first to reject wrappers + /// containing store/renew before calling this. + fn traverse_storage_calls( + call: &RuntimeCallOf, + depth: u32, + visitor: &mut impl FnMut(&Call) -> Result<(), TransactionValidityError>, + ) -> Result { + if let Some(inner_call) = call.is_sub_type() { + visitor(inner_call)?; + return Ok(TraverseResult { found_storage: true }); + } + if let Some(inner_calls) = Self::inspect_wrapper(call) { + if depth >= MAX_WRAPPER_DEPTH { + tracing::debug!( + target: LOG_TARGET, + "Wrapper recursion limit exceeded (depth: {depth}), rejecting call", + ); + return Err(InvalidTransaction::ExhaustsResources.into()); + } + let mut found_storage = false; + for inner in inner_calls { + found_storage |= + Self::traverse_storage_calls(inner, depth + 1, visitor)?.found_storage; + } + return Ok(TraverseResult { found_storage }); + } + // Not a storage call and not a wrapper — ignore. + Ok(TraverseResult::default()) + } +} + +/// No-op implementation — no wrapper inspection. Direct storage calls still work. +impl CallInspector for () +where + RuntimeCallOf: IsSubType>, +{ + fn inspect_wrapper(_: &RuntimeCallOf) -> Option>> { + None + } +} + /// Transaction extension that validates signed TransactionStorage calls. /// /// This extension handles **signed TransactionStorage transactions** via /// [`Pallet::validate_signed`]: -/// - **Store/renew calls**: Validates authorization in `validate()` and transforms the origin to -/// [`Origin::Authorized`] to carry authorization info. Then in `prepare()`, it consumes the -/// authorization extent (decrements remaining transactions/bytes) before the extrinsic executes. -/// This early consumption prevents large invalid store transactions from propagating through -/// mempools and the network — authorization is checked and spent at the extension level rather -/// than during dispatch. +/// - **Store/renew calls**: Must be submitted as **direct extrinsics** (not wrapped). Validates +/// authorization in `validate()` and transforms the origin to [`Origin::Authorized`] to carry +/// authorization info. Then in `prepare()`, it consumes the authorization extent (decrements +/// remaining transactions/bytes) before the extrinsic executes. This early consumption prevents +/// large invalid store transactions from propagating through mempools and the network — +/// authorization is checked and spent at the extension level rather than during dispatch. /// - **Authorization management calls** (authorize_*, refresh_*, remove_expired_*): Validates that -/// the signer satisfies the [`Config::Authorizer`] origin requirement. +/// the signer satisfies the [`Config::Authorizer`] origin requirement. These calls **can** be +/// wrapped (e.g. in `Utility::batch`). +/// - **Wrapper calls** (e.g. `Utility::batch`, `Sudo::sudo`): Uses `I: CallInspector` to +/// recursively inspect inner calls. Rejects any wrapper containing store/renew calls. Allows +/// wrappers containing only management calls. +/// +/// The `I` type parameter controls wrapper inspection. Use `()` (the default) for no wrapper +/// support, or provide a runtime-specific [`CallInspector`] implementation to enable recursive +/// validation inside batch, sudo, proxy, etc. /// /// All other calls and unsigned transactions are passed through unchanged. #[derive(Clone, PartialEq, Eq, Encode, Decode, DecodeWithMemTracking, scale_info::TypeInfo)] -#[scale_info(skip_type_params(T))] -pub struct ValidateStorageCalls(PhantomData); +#[codec(encode_bound())] +#[codec(decode_bound())] +#[codec(mel_bound())] +#[scale_info(skip_type_params(T, I))] +pub struct ValidateStorageCalls(PhantomData<(T, I)>); -impl Default for ValidateStorageCalls { +impl Default for ValidateStorageCalls { fn default() -> Self { Self(PhantomData) } } -impl fmt::Debug for ValidateStorageCalls { +impl fmt::Debug for ValidateStorageCalls { #[cfg(feature = "std")] fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { write!(f, "ValidateStorageCalls") @@ -64,7 +178,8 @@ impl fmt::Debug for ValidateStorageCalls { } } -impl TransactionExtension> for ValidateStorageCalls +impl + Send + Sync + 'static> + TransactionExtension> for ValidateStorageCalls where RuntimeCallOf: IsSubType>, T::RuntimeOrigin: OriginTrait + AsSystemOriginSigner + From>, @@ -77,11 +192,8 @@ where Ok(()) } - /// The signer for store/renew calls, passed from `validate()` to `prepare()`. - /// - /// For store/renew calls, `validate()` transforms the origin to [`Origin::Authorized`], - /// so `origin.as_system_origin_signer()` is no longer available in `prepare()`. The signer - /// is preserved here instead. `None` for all other calls. + /// `Some(who)` when this extension handled storage-related calls (direct or wrapped). + /// The signer is saved because the origin may be transformed to `Authorized`. type Val = Option; type Pre = (); @@ -107,47 +219,56 @@ where _inherited_implication: &impl Implication, _source: TransactionSource, ) -> ValidateResult> { - // Only handle TransactionStorage calls; pass through others - let Some(inner_call) = call.is_sub_type() else { - return Ok((ValidTransaction::default(), None, origin)); - }; - - // Get the signer from the origin + // Only handle signed transactions let who = match origin.as_system_origin_signer() { Some(who) => who.clone(), None => return Ok((ValidTransaction::default(), None, origin)), }; - // Validate the call - let (valid_tx, maybe_scope) = Pallet::::validate_signed(&who, inner_call)?; + // Direct storage call + if let Some(inner_call) = call.is_sub_type() { + let (valid_tx, maybe_scope) = Pallet::::validate_signed(&who, inner_call)?; + if let Some(scope) = maybe_scope { + origin.set_caller_from(Origin::::Authorized { who: who.clone(), scope }); + } + return Ok((valid_tx, Some(who), origin)); + } - // Transform origin only for store/renew calls (when scope is Some) - let val = maybe_scope.map(|scope| { - origin.set_caller_from(Origin::::Authorized { who: who.clone(), scope }); - who - }); + // Wrapper call — reject if it contains store/renew (must be direct extrinsics), + // then validate any management calls (authorize_*, refresh_*, remove_expired_*). + if I::is_storage_mutating_call(call, 0) { + return Err(InvalidTransaction::Call.into()); + } + let mut combined_valid = ValidTransaction::default(); + let result = I::traverse_storage_calls(call, 0, &mut |inner_call| { + let (valid_tx, _scope) = Pallet::::validate_signed(&who, inner_call)?; + combined_valid = core::mem::take(&mut combined_valid).combine_with(valid_tx); + Ok(()) + })?; + if result.found_storage { + return Ok((combined_valid, Some(who), origin)); + } - Ok((valid_tx, val, origin)) + // No TransactionStorage calls found in wrapper. + Ok((ValidTransaction::default(), None, origin)) } fn prepare( self, val: Self::Val, - origin: &T::RuntimeOrigin, + _origin: &T::RuntimeOrigin, call: &RuntimeCallOf, _info: &DispatchInfoOf>, _len: usize, ) -> Result { - let Some(inner_call) = call.is_sub_type() else { - return Ok(()); - }; + let Some(who) = val else { return Ok(()) }; + + // traverse_storage_calls handles both direct pallet calls (via is_sub_type) + // and wrapper calls (via inspect_wrapper), consuming authorization for each. + I::traverse_storage_calls(call, 0, &mut |inner_call| { + Pallet::::pre_dispatch_signed(&who, inner_call) + })?; - // For store/renew: origin was transformed to Authorized, so get `who` from val. - // For other calls: origin is still the system signer. - let who = val.as_ref().or_else(|| origin.as_system_origin_signer()); - if let Some(who) = who { - Pallet::::pre_dispatch_signed(who, inner_call)?; - } Ok(()) } } diff --git a/pallets/transaction-storage/src/lib.rs b/pallets/transaction-storage/src/lib.rs index af5e4f41b..aa88eea69 100644 --- a/pallets/transaction-storage/src/lib.rs +++ b/pallets/transaction-storage/src/lib.rs @@ -139,6 +139,8 @@ pub enum AuthorizedCaller { /// Convenience alias for [`AuthorizedCaller`] bound to a runtime's `AccountId`. pub type AuthorizedCallerFor = AuthorizedCaller<::AccountId>; +pub use extension::{CallInspector, MAX_WRAPPER_DEPTH}; + /// An authorization to store data. #[derive(Encode, Decode, scale_info::TypeInfo, MaxEncodedLen)] struct Authorization { diff --git a/pallets/transaction-storage/src/tests.rs b/pallets/transaction-storage/src/tests.rs index 32292daa0..49a899e7d 100644 --- a/pallets/transaction-storage/src/tests.rs +++ b/pallets/transaction-storage/src/tests.rs @@ -689,6 +689,10 @@ fn preimage_authorize_store_with_cid_config_and_renew() { // store_with_cid_config goes through check_unsigned → check_store_renew_unsigned. assert_ok!(TransactionStorage::pre_dispatch(&store_call)); + assert_eq!( + TransactionStorage::preimage_authorization_extent(sha2_hash), + AuthorizationExtent { transactions: 0, bytes: 0 } + ); assert_ok!(Into::::into(store_call).dispatch(RuntimeOrigin::none())); // Preimage authorization for sha2 hash should be consumed. @@ -1170,7 +1174,7 @@ fn authorize_storage_extension_transforms_origin() { // Verify the transaction is valid with correct priority assert_eq!(valid_tx.priority, StoreRenewPriority::get()); - // Verify val contains the authorization scope + // Verify val contains the signer assert_eq!(val, Some(caller)); // Verify the origin was transformed and can be extracted with ensure_authorized @@ -1231,7 +1235,7 @@ fn authorize_storage_extension_transforms_origin_with_preimage_auth() { assert!(result.is_ok()); let (_, val, transformed_origin) = result.unwrap(); - // Verify preimage authorization was used + // Verify val contains the signer assert_eq!(val, Some(caller)); // Verify the origin carries preimage authorization diff --git a/runtimes/bulletin-polkadot/src/lib.rs b/runtimes/bulletin-polkadot/src/lib.rs index e866b1167..c62ffc9e4 100644 --- a/runtimes/bulletin-polkadot/src/lib.rs +++ b/runtimes/bulletin-polkadot/src/lib.rs @@ -208,11 +208,20 @@ parameter_types! { pub const RemoveExpiredAuthorizationLongevity: TransactionLongevity = DAYS as TransactionLongevity; pub const SudoPriority: TransactionPriority = TransactionPriority::MAX; + pub const SudoLongevity: TransactionLongevity = HOURS as TransactionLongevity; + + pub const UpgradePriority: TransactionPriority = SudoPriority::get(); + pub const UpgradeLongevity: TransactionLongevity = DAYS as TransactionLongevity; pub const SetKeysCooldownBlocks: BlockNumber = 5 * MINUTES; pub const SetPurgeKeysPriority: TransactionPriority = SudoPriority::get() - 1; pub const SetPurgeKeysLongevity: TransactionLongevity = HOURS as TransactionLongevity; + pub const ProxyPriority: TransactionPriority = SetPurgeKeysPriority::get() - 1; + pub const ProxyLongevity: TransactionLongevity = HOURS as TransactionLongevity; + pub const UtilityPriority: TransactionPriority = SetPurgeKeysPriority::get() - 1; + pub const UtilityLongevity: TransactionLongevity = HOURS as TransactionLongevity; + pub const BridgeTxFailCooldownBlocks: BlockNumber = 5 * MINUTES; pub const BridgeTxPriority: TransactionPriority = StoreRenewPriority::get() - 1; pub const BridgeTxLongevity: TransactionLongevity = HOURS as TransactionLongevity; @@ -358,6 +367,36 @@ impl SortedMembers for TestAccounts { } } +/// Tells [`pallet_transaction_storage::extension::ValidateStorageCalls`] how to find storage +/// calls inside wrapper extrinsics so it can recursively validate and consume authorization. +/// +/// Also implements [`Contains`] returning `true` for storage-mutating calls +/// (store, store_with_cid_config, renew). Used with `EverythingBut` as the XCM +/// `SafeCallFilter` to block these calls from XCM dispatch — they require on-chain +/// authorization that XCM cannot provide. +#[derive(Clone, PartialEq, Eq, Default)] +pub struct StorageCallInspector; + +impl pallet_transaction_storage::CallInspector for StorageCallInspector { + fn inspect_wrapper(call: &RuntimeCall) -> Option> { + match call { + RuntimeCall::Utility(c) => inspect_utility_wrapper(c), + RuntimeCall::Proxy(c) => inspect_proxy_wrapper(c), + RuntimeCall::Sudo(c) => inspect_sudo_wrapper(c), + _ => None, + } + } +} + +/// Returns `true` for storage-mutating TransactionStorage calls (store, store_with_cid_config, +/// renew). Recursively inspects wrapper calls (Utility, Proxy, Sudo) to prevent bypass via +/// nesting. Used with `EverythingBut` as the XCM `SafeCallFilter`. +impl frame_support::traits::Contains for StorageCallInspector { + fn contains(call: &RuntimeCall) -> bool { + Self::is_storage_mutating_call(call, 0) + } +} + impl pallet_transaction_storage::Config for Runtime { type RuntimeEvent = RuntimeEvent; type RuntimeCall = RuntimeCall; @@ -516,6 +555,107 @@ fn validate_purge_keys(who: &AccountId) -> TransactionValidity { } } +use pallet_transaction_storage::{CallInspector, MAX_WRAPPER_DEPTH}; +use pallets_common::{ + inspect_proxy_wrapper, inspect_sudo_wrapper, inspect_utility_wrapper, proxy_inner_calls, + utility_inner_calls, +}; + +/// Extract the signer from an origin that may be either `Signed` or `Authorized`. +/// +/// `ValidateStorageCalls` transforms the origin to `Authorized` for direct store/renew calls +/// (not wrappers), so downstream extensions must handle both origin types. +fn extract_signer(origin: &RuntimeOrigin) -> Option { + if let Some(who) = origin.as_system_origin_signer() { + return Some(who.clone()); + } + match origin.caller() { + OriginCaller::TransactionStorage( + pallet_transaction_storage::pallet::Origin::::Authorized { who, .. }, + ) => Some(who.clone()), + _ => None, + } +} + +/// Validate that a signed call is allowed and the signer is authorized. +/// Recursively validates inner calls in wrappers (Utility, Proxy). +/// +/// This is the single source of truth for which calls are accepted and what +/// authorization checks apply. Used by [`AllowedSignedCalls::validate`] for both +/// direct and wrapper calls. +fn validate_signed_call( + who: &AccountId, + call: &RuntimeCall, + origin: &RuntimeOrigin, + depth: u32, +) -> Result<(), TransactionValidityError> { + if depth >= MAX_WRAPPER_DEPTH { + return Err(InvalidTransaction::ExhaustsResources.into()); + } + match call { + // Storage auth handled by ValidateStorageCalls extension + RuntimeCall::TransactionStorage(_) => Ok(()), + + // Session key management + RuntimeCall::Session(SessionCall::set_keys { .. }) => { + ValidatorSet::validate_set_keys(who)?; + Ok(()) + }, + RuntimeCall::Session(SessionCall::purge_keys {}) => { + validate_purge_keys(who)?; + Ok(()) + }, + + // Bridge-related calls + RuntimeCall::BridgePolkadotGrandpa(BridgeGrandpaCall::submit_finality_proof { .. }) | + RuntimeCall::BridgePolkadotGrandpa(BridgeGrandpaCall::submit_finality_proof_ex { + .. + }) | + RuntimeCall::BridgePolkadotParachains(BridgeParachainsCall::submit_parachain_heads { + .. + }) | + RuntimeCall::BridgePolkadotParachains( + BridgeParachainsCall::submit_parachain_heads_ex { .. }, + ) | + RuntimeCall::BridgePolkadotMessages(BridgeMessagesCall::receive_messages_proof { + .. + }) | + RuntimeCall::BridgePolkadotMessages( + BridgeMessagesCall::receive_messages_delivery_proof { .. }, + ) => { + RelayerSet::validate_bridge_tx(who)?; + Ok(()) + }, + + // Bridge-privileged calls + RuntimeCall::BridgePolkadotGrandpa(BridgeGrandpaCall::initialize { .. }) => { + BridgePolkadotGrandpa::ensure_owner_or_root(origin.clone()) + .map_err(|_| InvalidTransaction::BadSigner)?; + Ok(()) + }, + + // Wrapper calls — recursively validate inner calls + RuntimeCall::Utility(utility_call) => { + for inner in utility_inner_calls(utility_call) { + validate_signed_call(who, inner, origin, depth + 1)?; + } + Ok(()) + }, + RuntimeCall::Proxy(proxy_call) => { + for inner in proxy_inner_calls(proxy_call) { + validate_signed_call(who, inner, origin, depth + 1)?; + } + Ok(()) + }, + // Sudo can call anything; its own dispatch checks enforce authorization + RuntimeCall::Sudo(_) => Ok(()), + + RuntimeCall::System(SystemCall::apply_authorized_upgrade { .. }) => Ok(()), + + _ => Err(InvalidTransaction::Call.into()), + } +} + /// `ValidateUnsigned` equivalent for signed transactions. /// /// This chain has no transaction fees, so we require checks equivalent to those performed by @@ -540,7 +680,8 @@ impl TransactionExtension for AllowedSignedCalls { Ok(()) } - type Val = (); + /// `Some(who)` when the signer was extracted. + type Val = Option; /// `Some(who)` if the transaction is a bridge transaction. type Pre = Option; @@ -558,108 +699,66 @@ impl TransactionExtension for AllowedSignedCalls { _inherited_implication: &impl Implication, _source: TransactionSource, ) -> sp_runtime::traits::ValidateResult { - // TransactionStorage is handled by ValidateStorageCalls extension, which transforms - // the origin. If origin is no longer a system signed origin, pass through. - let who = match origin.as_system_origin_signer() { - Some(who) => who.clone(), - None => return Ok((ValidTransaction::default(), (), origin)), + let Some(who) = extract_signer(&origin) else { + return Ok((ValidTransaction::default(), None, origin)); }; + // Validate call allowlist and authorization (single source of truth). + validate_signed_call(&who, call, &origin, 0)?; + + // Assign priority/longevity based on the top-level call type. + // Authorization checks already passed in validate_signed_call above. let validity = match call { - // TransactionStorage calls are validated by ValidateStorageCalls extension. - // Store/renew origins are transformed so they won't reach here. - // Authorizer calls still have a signed origin and need to pass through. RuntimeCall::TransactionStorage(_) => ValidTransaction::default(), - - // Session key management - RuntimeCall::Session(SessionCall::set_keys { .. }) => { - ValidatorSet::validate_set_keys(&who)?; - ValidTransaction { - priority: SetPurgeKeysPriority::get(), - longevity: SetPurgeKeysLongevity::get(), - ..Default::default() - } - }, - RuntimeCall::Session(SessionCall::purge_keys {}) => validate_purge_keys(&who)?, - - // Bridge-related calls - RuntimeCall::BridgePolkadotGrandpa(BridgeGrandpaCall::submit_finality_proof { - .. - }) | - RuntimeCall::BridgePolkadotGrandpa(BridgeGrandpaCall::submit_finality_proof_ex { - .. - }) | - RuntimeCall::BridgePolkadotParachains( - BridgeParachainsCall::submit_parachain_heads { .. }, - ) | - RuntimeCall::BridgePolkadotParachains( - BridgeParachainsCall::submit_parachain_heads_ex { .. }, - ) | - RuntimeCall::BridgePolkadotMessages(BridgeMessagesCall::receive_messages_proof { - .. - }) | - RuntimeCall::BridgePolkadotMessages( - BridgeMessagesCall::receive_messages_delivery_proof { .. }, - ) => { - RelayerSet::validate_bridge_tx(&who)?; - ValidTransaction { - priority: BridgeTxPriority::get(), - longevity: BridgeTxLongevity::get(), - ..Default::default() - } - }, - - // Bridge-privileged calls - RuntimeCall::BridgePolkadotGrandpa(BridgeGrandpaCall::initialize { .. }) => { - BridgePolkadotGrandpa::ensure_owner_or_root(origin.clone()) - .map_err(|_| InvalidTransaction::BadSigner)?; - ValidTransaction { - priority: BridgeTxPriority::get(), - longevity: BridgeTxLongevity::get(), - ..Default::default() - } + RuntimeCall::Session(..) => ValidTransaction { + priority: SetPurgeKeysPriority::get(), + longevity: SetPurgeKeysLongevity::get(), + ..Default::default() }, - - // Sudo calls - RuntimeCall::Proxy(_call) => ValidTransaction { - priority: SudoPriority::get(), + RuntimeCall::BridgePolkadotGrandpa(..) | + RuntimeCall::BridgePolkadotParachains(..) | + RuntimeCall::BridgePolkadotMessages(..) => ValidTransaction { + priority: BridgeTxPriority::get(), longevity: BridgeTxLongevity::get(), ..Default::default() }, - RuntimeCall::Sudo(_call) => ValidTransaction { - priority: SudoPriority::get(), - longevity: BridgeTxLongevity::get(), + RuntimeCall::Proxy(..) => ValidTransaction { + priority: ProxyPriority::get(), + longevity: ProxyLongevity::get(), ..Default::default() }, - RuntimeCall::Utility(_call) => ValidTransaction { + RuntimeCall::Sudo(_) => ValidTransaction { priority: SudoPriority::get(), - longevity: BridgeTxLongevity::get(), + longevity: SudoLongevity::get(), + ..Default::default() + }, + RuntimeCall::Utility(..) => ValidTransaction { + priority: UtilityPriority::get(), + longevity: UtilityLongevity::get(), ..Default::default() }, RuntimeCall::System(SystemCall::apply_authorized_upgrade { .. }) => ValidTransaction { - priority: SudoPriority::get(), - longevity: BridgeTxLongevity::get(), + priority: UpgradePriority::get(), + longevity: UpgradeLongevity::get(), ..Default::default() }, - - // All other calls are invalid + // validate_signed_call already rejected unknown calls above _ => return Err(InvalidTransaction::Call.into()), }; - Ok((validity, (), origin)) + Ok((validity, Some(who), origin)) } fn prepare( self, - _val: Self::Val, + val: Self::Val, origin: &RuntimeOrigin, call: &RuntimeCall, _info: &DispatchInfoOf, _len: usize, ) -> Result { - // If origin is no longer a system signed origin, pass through. - // (Store/renew calls have their origin transformed by ValidateStorageCalls.) - let who = match origin.as_system_origin_signer() { + // Extract signer from either Signed or Authorized origin. + let who = match val.as_ref().or_else(|| origin.as_system_origin_signer()) { Some(who) => who, None => return Ok(None), }; @@ -699,12 +798,9 @@ impl TransactionExtension for AllowedSignedCalls { .map_err(|_| InvalidTransaction::BadSigner.into()) .map(|()| Some(who.clone())), - // Sudo calls - RuntimeCall::Proxy(_) => Ok(Some(who.clone())), - RuntimeCall::Sudo(_) => Ok(Some(who.clone())), - RuntimeCall::Utility(_) => Ok(Some(who.clone())), - RuntimeCall::System(SystemCall::apply_authorized_upgrade { .. }) => - Ok(Some(who.clone())), + // Wrapper calls — storage auth consumption handled by pallet extension + RuntimeCall::Proxy(_) | RuntimeCall::Sudo(_) | RuntimeCall::Utility(_) => Ok(None), + RuntimeCall::System(SystemCall::apply_authorized_upgrade { .. }) => Ok(None), // All other calls are invalid _ => Err(InvalidTransaction::Call.into()), @@ -751,7 +847,7 @@ pub type TxExtension = ( frame_system::CheckEra, frame_system::CheckNonce, frame_system::CheckWeight, - pallet_transaction_storage::extension::ValidateStorageCalls, + pallet_transaction_storage::extension::ValidateStorageCalls, AllowedSignedCalls, BridgeRejectObsoleteHeadersAndMessages, ); diff --git a/runtimes/bulletin-polkadot/src/xcm_config.rs b/runtimes/bulletin-polkadot/src/xcm_config.rs index f2ba097f8..edff18444 100644 --- a/runtimes/bulletin-polkadot/src/xcm_config.rs +++ b/runtimes/bulletin-polkadot/src/xcm_config.rs @@ -24,7 +24,7 @@ use crate::{ use codec::Encode; use frame_support::{ parameter_types, - traits::{Contains, Equals, Everything, Nothing}, + traits::{Contains, Equals, Everything, EverythingBut, Nothing}, weights::Weight, }; use sp_core::ConstU32; @@ -126,7 +126,7 @@ impl xcm_executor::Config for XcmConfig { type MessageExporter = ToBridgeHaulBlobExporter; type UniversalAliases = UniversalAliases; type CallDispatcher = WithOriginFilter; - type SafeCallFilter = Everything; + type SafeCallFilter = EverythingBut; type Aliasers = Nothing; type TransactionalProcessor = FrameTransactionalProcessor; type HrmpNewChannelOpenRequestHandler = (); @@ -206,13 +206,14 @@ pub(crate) mod tests { bp_people_polkadot::PEOPLE_POLKADOT_PARACHAIN_ID, tests::run_test, WithPeoplePolkadotMessagesInstance, XcmBlobMessageDispatchResult, XCM_LANE, }, - Runtime, + Runtime, RuntimeOrigin, }; use bp_messages::{ target_chain::{DispatchMessage, DispatchMessageData, MessageDispatch}, MessageKey, }; use codec::Encode; + use frame_support::assert_ok; use pallet_bridge_messages::Config as MessagesConfig; use sp_keyring::Sr25519Keyring as AccountKeyring; use xcm::{prelude::VersionedXcm, VersionedInteriorLocation}; @@ -381,4 +382,208 @@ pub(crate) mod tests { Ok(()) ); } + + /// Build an XCM bridge message containing `Transact` with an arbitrary + /// runtime call dispatched from People Polkadot. + fn encoded_xcm_transact_from_people_polkadot( + origin_kind: OriginKind, + call: RuntimeCall, + ) -> Vec { + let message = VersionedXcm::from( + Xcm::<()>::builder_unsafe() + .universal_origin(GlobalConsensus(BridgedNetwork::get())) + .descend_origin(Parachain(PEOPLE_POLKADOT_PARACHAIN_ID)) + .unpaid_execution(Unlimited, None) + .transact(origin_kind, None, call.encode()) + .build(), + ); + let universal_dest: VersionedInteriorLocation = GlobalConsensus(ThisNetwork::get()).into(); + BridgeMessage { universal_dest, message }.encode() + } + + /// XCM Transact with `store` from People Polkadot (Superuser) must be blocked + /// unconditionally — even if authorization exists. Storage operations must go + /// through signed extrinsics, never through XCM. + #[test] + fn xcm_transact_store_is_blocked() { + run_test(|| { + use pallet_transaction_storage::{AuthorizationExtent, Call as TxStorageCall}; + + let data = vec![42u8; 100]; + let content_hash = sp_io::hashing::blake2_256(&data); + + // Authorize the preimage so we can verify the filter blocks it + // regardless of authorization state. + assert_ok!(crate::TransactionStorage::authorize_preimage( + RuntimeOrigin::root(), + content_hash, + data.len() as u64, + )); + assert_ne!( + crate::TransactionStorage::preimage_authorization_extent(content_hash), + AuthorizationExtent { transactions: 0, bytes: 0 }, + ); + + let store_call = RuntimeCall::TransactionStorage(TxStorageCall::::store { + data: data.clone(), + }); + + let result = Dispatcher::dispatch(DispatchMessage { + key: MessageKey { lane_id: XCM_LANE, nonce: 1 }, + data: DispatchMessageData { + payload: Ok(encoded_xcm_transact_from_people_polkadot( + OriginKind::Superuser, + store_call, + )), + }, + }); + + // StorageCallInspector blocks store/store_with_cid_config/renew + // unconditionally — authorization does not matter for XCM. + assert_ne!( + result.dispatch_level_result, + XcmBlobMessageDispatchResult::Dispatched, + "XCM Transact store must be blocked even with authorization", + ); + + // Authorization must not have been consumed. + assert_ne!( + crate::TransactionStorage::preimage_authorization_extent(content_hash), + AuthorizationExtent { transactions: 0, bytes: 0 }, + "Authorization should remain unconsumed since XCM was blocked", + ); + }); + } + + /// XCM Transact with `store` wrapped in `utility::batch` must also be blocked. + /// The `StorageCallInspector` recursively inspects inner calls. + #[test] + fn xcm_transact_wrapped_store_is_blocked() { + run_test(|| { + use pallet_transaction_storage::{AuthorizationExtent, Call as TxStorageCall}; + + let data = vec![42u8; 100]; + let content_hash = sp_io::hashing::blake2_256(&data); + + // Authorize the preimage so we can verify the filter blocks it + // regardless of authorization state. + assert_ok!(crate::TransactionStorage::authorize_preimage( + RuntimeOrigin::root(), + content_hash, + data.len() as u64, + )); + assert_ne!( + crate::TransactionStorage::preimage_authorization_extent(content_hash), + AuthorizationExtent { transactions: 0, bytes: 0 }, + ); + + let store_call = RuntimeCall::TransactionStorage(TxStorageCall::::store { + data: data.clone(), + }); + + // Wrap store in utility::batch — the filter must recurse into it. + let batch_call = + RuntimeCall::Utility(pallet_utility::Call::batch { calls: vec![store_call] }); + + let result = Dispatcher::dispatch(DispatchMessage { + key: MessageKey { lane_id: XCM_LANE, nonce: 1 }, + data: DispatchMessageData { + payload: Ok(encoded_xcm_transact_from_people_polkadot( + OriginKind::Superuser, + batch_call, + )), + }, + }); + + assert_ne!( + result.dispatch_level_result, + XcmBlobMessageDispatchResult::Dispatched, + "XCM Transact batch(store) must be blocked by recursive filter", + ); + + // Authorization must not have been consumed. + assert_ne!( + crate::TransactionStorage::preimage_authorization_extent(content_hash), + AuthorizationExtent { transactions: 0, bytes: 0 }, + "Authorization should remain unconsumed since XCM was blocked", + ); + }); + } + + /// XCM Transact with `renew` must be blocked — same as `store`. + /// This documents the deliberate decision to block renew over XCM. + #[test] + fn xcm_transact_renew_is_blocked() { + run_test(|| { + use pallet_transaction_storage::Call as TxStorageCall; + + let renew_call = RuntimeCall::TransactionStorage(TxStorageCall::::renew { + block: 1, + index: 0, + }); + + let result = Dispatcher::dispatch(DispatchMessage { + key: MessageKey { lane_id: XCM_LANE, nonce: 1 }, + data: DispatchMessageData { + payload: Ok(encoded_xcm_transact_from_people_polkadot( + OriginKind::Superuser, + renew_call, + )), + }, + }); + + assert_ne!( + result.dispatch_level_result, + XcmBlobMessageDispatchResult::Dispatched, + "XCM Transact renew must be blocked by SafeCallFilter", + ); + }); + } + + /// People Polkadot can authorize storage accounts via XCM Transact. + /// The origin is converted to Root via `LocationAsSuperuser`, which satisfies + /// the `EnsureRoot` path in `Config::Authorizer`. + #[test] + fn xcm_transact_authorize_account_works() { + run_test(|| { + use pallet_transaction_storage::{AuthorizationExtent, Call as TxStorageCall}; + + let who = AccountKeyring::Ferdie.to_account_id(); + + // Verify no authorization exists yet. + assert_eq!( + crate::TransactionStorage::account_authorization_extent(who.clone()), + AuthorizationExtent { transactions: 0, bytes: 0 }, + ); + + let authorize_call = + RuntimeCall::TransactionStorage(TxStorageCall::::authorize_account { + who: who.clone(), + transactions: 10, + bytes: 1024, + }); + + let result = Dispatcher::dispatch(DispatchMessage { + key: MessageKey { lane_id: XCM_LANE, nonce: 1 }, + data: DispatchMessageData { + payload: Ok(encoded_xcm_transact_from_people_polkadot( + OriginKind::Superuser, + authorize_call, + )), + }, + }); + + assert_eq!( + result.dispatch_level_result, + XcmBlobMessageDispatchResult::Dispatched, + "XCM Transact authorize_account from People Polkadot must succeed", + ); + + // Authorization must have been created. + assert_eq!( + crate::TransactionStorage::account_authorization_extent(who), + AuthorizationExtent { transactions: 10, bytes: 1024 }, + ); + }); + } } diff --git a/runtimes/bulletin-polkadot/tests/tests.rs b/runtimes/bulletin-polkadot/tests/tests.rs index c7b0d43c8..68a69cb61 100644 --- a/runtimes/bulletin-polkadot/tests/tests.rs +++ b/runtimes/bulletin-polkadot/tests/tests.rs @@ -280,7 +280,10 @@ fn construct_extrinsic( frame_system::Pallet::::account(&account_id).nonce, ), frame_system::CheckWeight::::new(), - pallet_transaction_storage::extension::ValidateStorageCalls::::default(), + pallet_transaction_storage::extension::ValidateStorageCalls::< + Runtime, + runtime::StorageCallInspector, + >::default(), runtime::AllowedSignedCalls, runtime::BridgeRejectObsoleteHeadersAndMessages, ); @@ -1063,3 +1066,668 @@ fn allowed_signed_calls_preserves_storage_priority() { fn transaction_storage_weight_sanity() { pallet_transaction_storage::ensure_weight_sanity::(None); } + +// ============================================================================ +// Ensure calls wrapped in dispatch wrappers are subject to the same validation +// as direct submissions. Covers utility (batch, batch_all, force_batch, +// as_derivative), proxy, and sudo_as. +// +// XCM Transact wrapping is tested in xcm_config::tests. +// ============================================================================ + +/// Wrap a call in utility dispatcher variants (batch, batch_all, force_batch, as_derivative). +/// These are caught at validation time by `validate_inner_calls`. +fn wrap_call_utility_variants(call: RuntimeCall) -> Vec<(RuntimeCall, &'static str)> { + vec![ + ( + RuntimeCall::Utility(pallet_utility::Call::batch { calls: vec![call.clone()] }), + "utility::batch", + ), + ( + RuntimeCall::Utility(pallet_utility::Call::batch_all { calls: vec![call.clone()] }), + "utility::batch_all", + ), + ( + RuntimeCall::Utility(pallet_utility::Call::force_batch { calls: vec![call.clone()] }), + "utility::force_batch", + ), + ( + RuntimeCall::Utility(pallet_utility::Call::as_derivative { + index: 0, + call: Box::new(call), + }), + "utility::as_derivative", + ), + ] +} + +fn provision_account(who: AccountKeyring) { + frame_system::Pallet::::inc_providers(&who.to_account_id()); +} + +fn add_proxy(real: AccountKeyring, delegate: AccountKeyring) { + let call = RuntimeCall::Proxy(pallet_proxy::Call::add_proxy { + delegate: sp_runtime::MultiAddress::Id(delegate.to_account_id()), + proxy_type: Default::default(), + delay: 0, + }); + assert_ok_ok(construct_and_apply_extrinsic(real.pair(), call)); +} + +#[test] +fn wrapped_store_requires_authorization() { + run_test(|| { + advance_block(); + let attacker = non_relay_signer(); + provision_account(attacker); + let real = sudo_relayer_signer(); + add_proxy(real, attacker); + + let store_call = RuntimeCall::TransactionStorage(TxStorageCall::::store { + data: vec![42u8; 100], + }); + + // Direct: rejected for missing authorization. + assert_eq!( + construct_and_apply_extrinsic(attacker.pair(), store_call.clone()), + Err(TransactionValidityError::Invalid(InvalidTransaction::Payment)), + "store: direct", + ); + + // Utility wrappers: rejected because store is not allowed inside wrappers. + for (wrapped, name) in wrap_call_utility_variants(store_call.clone()) { + assert_eq!( + construct_and_apply_extrinsic(attacker.pair(), wrapped), + Err(TransactionValidityError::Invalid(InvalidTransaction::Call)), + "store: via {name}", + ); + } + + // sudo_as: store inside wrapper is rejected. + assert_eq!( + construct_and_apply_extrinsic( + attacker.pair(), + RuntimeCall::Sudo(pallet_sudo::Call::sudo_as { + who: sp_runtime::MultiAddress::Id(attacker.to_account_id()), + call: Box::new(store_call.clone()), + }), + ), + Err(TransactionValidityError::Invalid(InvalidTransaction::Call)), + ); + + // proxy: store inside wrapper is rejected. + assert_eq!( + construct_and_apply_extrinsic( + attacker.pair(), + RuntimeCall::Proxy(pallet_proxy::Call::proxy { + real: sp_runtime::MultiAddress::Id(real.to_account_id()), + force_proxy_type: None, + call: Box::new(store_call), + }), + ), + Err(TransactionValidityError::Invalid(InvalidTransaction::Call)), + ); + }); +} + +#[test] +fn wrapped_store_with_cid_config_requires_authorization() { + run_test(|| { + advance_block(); + let attacker = non_relay_signer(); + provision_account(attacker); + let real = sudo_relayer_signer(); + add_proxy(real, attacker); + + let store_call = + RuntimeCall::TransactionStorage(TxStorageCall::::store_with_cid_config { + cid: CidConfig { codec: 0x55, hashing: HashingAlgorithm::Blake2b256 }, + data: vec![42u8; 100], + }); + + // Direct: rejected for missing authorization. + assert_eq!( + construct_and_apply_extrinsic(attacker.pair(), store_call.clone()), + Err(TransactionValidityError::Invalid(InvalidTransaction::Payment)), + "store_with_cid_config: direct", + ); + + // Utility wrappers: rejected because store is not allowed inside wrappers. + for (wrapped, name) in wrap_call_utility_variants(store_call.clone()) { + assert_eq!( + construct_and_apply_extrinsic(attacker.pair(), wrapped), + Err(TransactionValidityError::Invalid(InvalidTransaction::Call)), + "store_with_cid_config: via {name}", + ); + } + + // sudo_as: store inside wrapper is rejected. + assert_eq!( + construct_and_apply_extrinsic( + attacker.pair(), + RuntimeCall::Sudo(pallet_sudo::Call::sudo_as { + who: sp_runtime::MultiAddress::Id(attacker.to_account_id()), + call: Box::new(store_call.clone()), + }), + ), + Err(TransactionValidityError::Invalid(InvalidTransaction::Call)), + ); + + // proxy: store inside wrapper is rejected. + assert_eq!( + construct_and_apply_extrinsic( + attacker.pair(), + RuntimeCall::Proxy(pallet_proxy::Call::proxy { + real: sp_runtime::MultiAddress::Id(real.to_account_id()), + force_proxy_type: None, + call: Box::new(store_call), + }), + ), + Err(TransactionValidityError::Invalid(InvalidTransaction::Call)), + ); + }); +} + +#[test] +fn wrapped_store_requires_authorization_even_for_relayer() { + run_test(|| { + advance_block(); + let relayer = sudo_relayer_signer(); + + assert_eq!( + runtime::TransactionStorage::account_authorization_extent(relayer.to_account_id()), + AuthorizationExtent { transactions: 0, bytes: 0 }, + ); + + let store_call = RuntimeCall::TransactionStorage(TxStorageCall::::store { + data: vec![99u8; 200], + }); + + // Direct: rejected for missing authorization. + assert_eq!( + construct_and_apply_extrinsic(relayer.pair(), store_call.clone()), + Err(TransactionValidityError::Invalid(InvalidTransaction::Payment)), + "relayer store without auth: direct", + ); + + // Utility wrappers: rejected because store is not allowed inside wrappers. + for (wrapped, name) in wrap_call_utility_variants(store_call.clone()) { + assert_eq!( + construct_and_apply_extrinsic(relayer.pair(), wrapped), + Err(TransactionValidityError::Invalid(InvalidTransaction::Call)), + "relayer store without auth: via {name}", + ); + } + + // sudo_as: store inside wrapper is rejected. + assert_eq!( + construct_and_apply_extrinsic( + relayer.pair(), + RuntimeCall::Sudo(pallet_sudo::Call::sudo_as { + who: sp_runtime::MultiAddress::Id(relayer.to_account_id()), + call: Box::new(store_call), + }), + ), + Err(TransactionValidityError::Invalid(InvalidTransaction::Call)), + ); + }); +} + +#[test] +fn wrapped_renew_requires_authorization() { + // Use standalone externalities with a non-zero RetentionPeriod so that + // stored transactions survive into the next block. + sp_tracing::try_init_simple(); + let mut t = frame_system::GenesisConfig::::default().build_storage().unwrap(); + pallet_relayer_set::GenesisConfig:: { + initial_relayers: vec![relayer_signer().into(), sudo_relayer_signer().into()], + } + .assimilate_storage(&mut t) + .unwrap(); + pallet_sudo::GenesisConfig:: { key: Some(sudo_relayer_signer().into()) } + .assimilate_storage(&mut t) + .unwrap(); + pallet_transaction_storage::GenesisConfig:: { + retention_period: 100, + byte_fee: 0, + entry_fee: 0, + account_authorizations: vec![], + preimage_authorizations: vec![], + } + .assimilate_storage(&mut t) + .unwrap(); + + sp_io::TestExternalities::new(t).execute_with(|| { + advance_block(); + + let authorized = sudo_relayer_signer(); + let data = vec![42u8; 100]; + assert_ok!(runtime::TransactionStorage::authorize_account( + RuntimeOrigin::root(), + authorized.to_account_id(), + 1, + data.len() as u64, + )); + assert_ok_ok(construct_and_apply_extrinsic( + authorized.pair(), + RuntimeCall::TransactionStorage(TxStorageCall::::store { data }), + )); + let stored_block = System::block_number(); + + advance_block(); + let attacker = non_relay_signer(); + provision_account(attacker); + add_proxy(authorized, attacker); + + let renew_call = RuntimeCall::TransactionStorage(TxStorageCall::::renew { + block: stored_block, + index: 0, + }); + + // Direct: rejected for missing authorization. + assert_eq!( + construct_and_apply_extrinsic(attacker.pair(), renew_call.clone()), + Err(TransactionValidityError::Invalid(InvalidTransaction::Payment)), + "renew: direct", + ); + + // Utility wrappers: rejected because renew is not allowed inside wrappers. + for (wrapped, name) in wrap_call_utility_variants(renew_call.clone()) { + assert_eq!( + construct_and_apply_extrinsic(attacker.pair(), wrapped), + Err(TransactionValidityError::Invalid(InvalidTransaction::Call)), + "renew: via {name}", + ); + } + + // sudo_as: renew inside wrapper is rejected. + assert_eq!( + construct_and_apply_extrinsic( + attacker.pair(), + RuntimeCall::Sudo(pallet_sudo::Call::sudo_as { + who: sp_runtime::MultiAddress::Id(attacker.to_account_id()), + call: Box::new(renew_call.clone()), + }), + ), + Err(TransactionValidityError::Invalid(InvalidTransaction::Call)), + ); + + // proxy: renew inside wrapper is rejected. + assert_eq!( + construct_and_apply_extrinsic( + attacker.pair(), + RuntimeCall::Proxy(pallet_proxy::Call::proxy { + real: sp_runtime::MultiAddress::Id(authorized.to_account_id()), + force_proxy_type: None, + call: Box::new(renew_call), + }), + ), + Err(TransactionValidityError::Invalid(InvalidTransaction::Call)), + ); + }); +} + +#[test] +fn wrapped_authorize_account_requires_authorizer_origin() { + run_test(|| { + advance_block(); + let attacker = non_relay_signer(); + provision_account(attacker); + + let call = RuntimeCall::TransactionStorage(TxStorageCall::::authorize_account { + who: attacker.to_account_id(), + transactions: 5, + bytes: 1024, + }); + + // Direct: rejected at validation (BadSigner). + assert_eq!( + construct_and_apply_extrinsic(attacker.pair(), call.clone()), + Err(TransactionValidityError::Invalid(InvalidTransaction::BadSigner)), + ); + + // Via batch: batch itself is valid, but the inner authorize_account must + // fail at dispatch (origin is not Authorizer). Verify via storage state. + let batch_call = RuntimeCall::Utility(pallet_utility::Call::batch { calls: vec![call] }); + let _ = construct_and_apply_extrinsic(attacker.pair(), batch_call); + assert_eq!( + runtime::TransactionStorage::account_authorization_extent(attacker.to_account_id()), + AuthorizationExtent { transactions: 0, bytes: 0 }, + "authorize_account via batch must not succeed for non-Authorizer", + ); + }); +} + +/// Wrapping `authorize_account` in `batch_all` must not break the authorization. +/// The origin must remain `Signed` (not transformed to `Authorized`) so that +/// `T::Authorizer::ensure_origin()` succeeds at dispatch time. +#[test] +fn wrapped_authorize_account_succeeds() { + run_test(|| { + advance_block(); + let signer = sudo_relayer_signer(); + let target: AccountId = non_relay_signer().to_account_id(); + + let call = RuntimeCall::TransactionStorage(TxStorageCall::::authorize_account { + who: target.clone(), + transactions: 5, + bytes: 1024, + }); + + let batch_call = + RuntimeCall::Utility(pallet_utility::Call::batch_all { calls: vec![call] }); + let res = construct_and_apply_extrinsic(signer.pair(), batch_call); + assert!(res.is_ok(), "apply_extrinsic failed: {res:?}"); + assert!(res.unwrap().is_ok(), "dispatch failed"); + + assert_eq!( + runtime::TransactionStorage::account_authorization_extent(target), + AuthorizationExtent { transactions: 5, bytes: 1024 }, + "authorize_account via batch_all must create authorization", + ); + }); +} + +/// Store calls inside wrappers (batch, batch_all, force_batch) are rejected even when +/// authorized. Store/renew must be submitted as direct extrinsics. +#[test] +fn authorized_wrapped_store_rejected() { + run_test(|| { + advance_block(); + let signer = sudo_relayer_signer(); + let who: AccountId = signer.to_account_id(); + let data = vec![42u8; 100]; + + // Authorize enough for several calls. + assert_ok!(runtime::TransactionStorage::authorize_account( + RuntimeOrigin::root(), + who.clone(), + 4, + 4 * data.len() as u64, + )); + + let store_call = + RuntimeCall::TransactionStorage(TxStorageCall::::store { data: data.clone() }); + + // Direct store should succeed. + assert_ok_ok(construct_and_apply_extrinsic(signer.pair(), store_call.clone())); + + // Batch-wrapped store must be rejected. + for (wrapped, name) in wrap_call_utility_variants(store_call) { + assert_eq!( + construct_and_apply_extrinsic(signer.pair(), wrapped), + Err(TransactionValidityError::Invalid(InvalidTransaction::Call)), + "{name}: wrapped store must be rejected", + ); + } + + // Only the direct store consumed authorization (1 tx, data.len() bytes). + assert_eq!( + runtime::TransactionStorage::account_authorization_extent(who), + AuthorizationExtent { transactions: 3, bytes: 3 * data.len() as u64 }, + ); + }); +} + +/// Batch containing store calls is rejected — store must be submitted as direct extrinsics. +#[test] +fn batch_store_with_mixed_preimage_and_account_auth_rejected() { + run_test(|| { + advance_block(); + let signer = sudo_relayer_signer(); + let who: AccountId = signer.to_account_id(); + + let data_a = vec![42u8; 100]; + let data_b = vec![99u8; 200]; + let content_hash_a = sp_io::hashing::blake2_256(&data_a); + + // Authorize preimage for data_a only. + assert_ok!(runtime::TransactionStorage::authorize_preimage( + RuntimeOrigin::root(), + content_hash_a, + data_a.len() as u64, + )); + + // Authorize account for data_b (1 transaction, enough bytes). + assert_ok!(runtime::TransactionStorage::authorize_account( + RuntimeOrigin::root(), + who.clone(), + 1, + data_b.len() as u64, + )); + + let store_a = + RuntimeCall::TransactionStorage(TxStorageCall::::store { data: data_a }); + let store_b = + RuntimeCall::TransactionStorage(TxStorageCall::::store { data: data_b }); + + let batch = + RuntimeCall::Utility(pallet_utility::Call::batch { calls: vec![store_a, store_b] }); + + // Batch containing store calls is rejected. + assert_eq!( + construct_and_apply_extrinsic(signer.pair(), batch), + Err(TransactionValidityError::Invalid(InvalidTransaction::Call)), + ); + + // Authorizations were NOT consumed (rejected before prepare). + assert_eq!( + runtime::TransactionStorage::preimage_authorization_extent(content_hash_a), + AuthorizationExtent { transactions: 1, bytes: 100 }, + "Preimage authorization should not be consumed", + ); + assert_eq!( + runtime::TransactionStorage::account_authorization_extent(who), + AuthorizationExtent { transactions: 1, bytes: 200 }, + "Account authorization should not be consumed", + ); + }); +} + +#[test] +fn wrapped_call_respects_validate_signed_allowlist() { + run_test(|| { + advance_block(); + let signer = sudo_relayer_signer(); + + let remark = RuntimeCall::System(frame_system::Call::remark { remark: vec![1, 2, 3] }); + + // System::remark is not in the ValidateSigned allowlist — rejected direct. + assert_eq!( + construct_and_apply_extrinsic(signer.pair(), remark.clone()), + Err(TransactionValidityError::Invalid(InvalidTransaction::Call)), + "System::remark: direct", + ); + + // Also rejected inside utility wrappers. + for (wrapped, name) in wrap_call_utility_variants(remark) { + assert_eq!( + construct_and_apply_extrinsic(signer.pair(), wrapped), + Err(TransactionValidityError::Invalid(InvalidTransaction::Call)), + "System::remark: via {name}", + ); + } + }); +} + +/// Batch containing store is rejected — store must be submitted as direct extrinsics, +/// regardless of what else is in the batch. +#[test] +fn mixed_batch_store_and_authorize_rejected() { + run_test(|| { + advance_block(); + let signer = sudo_relayer_signer(); + let who: AccountId = signer.to_account_id(); + let target: AccountId = non_relay_signer().to_account_id(); + let data = vec![42u8; 100]; + + // Authorize one store. + assert_ok!(runtime::TransactionStorage::authorize_account( + RuntimeOrigin::root(), + who.clone(), + 1, + data.len() as u64, + )); + + let store_call = + RuntimeCall::TransactionStorage(TxStorageCall::::store { data: data.clone() }); + let authorize_call = + RuntimeCall::TransactionStorage(TxStorageCall::::authorize_account { + who: target.clone(), + transactions: 5, + bytes: 1024, + }); + + // Mixing store + authorize_account in a batch is rejected at validation. + for batch_variant in [ + RuntimeCall::Utility(pallet_utility::Call::batch { + calls: vec![store_call.clone(), authorize_call.clone()], + }), + RuntimeCall::Utility(pallet_utility::Call::batch_all { + calls: vec![store_call.clone(), authorize_call.clone()], + }), + RuntimeCall::Utility(pallet_utility::Call::force_batch { + calls: vec![store_call.clone(), authorize_call.clone()], + }), + ] { + assert_eq!( + construct_and_apply_extrinsic(signer.pair(), batch_variant), + Err(TransactionValidityError::Invalid(InvalidTransaction::Call)), + ); + } + + // Authorization was NOT consumed (rejected before prepare). + assert_eq!( + runtime::TransactionStorage::account_authorization_extent(who), + AuthorizationExtent { transactions: 1, bytes: data.len() as u64 }, + ); + }); +} + +/// Batch containing store with a non-storage call is rejected — store must be direct. +#[test] +fn mixed_batch_store_and_non_storage_call_rejected() { + run_test(|| { + advance_block(); + let signer = sudo_relayer_signer(); + let who: AccountId = signer.to_account_id(); + let data = vec![42u8; 100]; + + assert_ok!(runtime::TransactionStorage::authorize_account( + RuntimeOrigin::root(), + who.clone(), + 1, + data.len() as u64, + )); + + let store_call = + RuntimeCall::TransactionStorage(TxStorageCall::::store { data: data.clone() }); + let session_call = RuntimeCall::Session(pallet_session::Call::purge_keys {}); + + let batch_call = RuntimeCall::Utility(pallet_utility::Call::batch { + calls: vec![store_call, session_call], + }); + + assert_eq!( + construct_and_apply_extrinsic(signer.pair(), batch_call), + Err(TransactionValidityError::Invalid(InvalidTransaction::Call)), + ); + + // Authorization was NOT consumed. + assert_eq!( + runtime::TransactionStorage::account_authorization_extent(who), + AuthorizationExtent { transactions: 1, bytes: data.len() as u64 }, + ); + }); +} + +/// Deeply nested wrapper calls exceeding MAX_WRAPPER_DEPTH must be rejected. +#[test] +fn max_recursion_depth_is_enforced() { + run_test(|| { + advance_block(); + let signer = sudo_relayer_signer(); + let who: AccountId = signer.to_account_id(); + let data = vec![42u8; 100]; + + // Authorize. + assert_ok!(runtime::TransactionStorage::authorize_account( + RuntimeOrigin::root(), + who.clone(), + 1, + data.len() as u64, + )); + + // Nest store inside MAX_WRAPPER_DEPTH+1 batch wrappers. + let mut call: RuntimeCall = + RuntimeCall::TransactionStorage(TxStorageCall::::store { data: data.clone() }); + for _ in 0..=pallet_transaction_storage::MAX_WRAPPER_DEPTH { + call = RuntimeCall::Utility(pallet_utility::Call::batch { calls: vec![call] }); + } + + // Should fail with Call — store inside wrapper is rejected (the depth limit + // in is_storage_mutating_call treats excessively nested calls as storage-mutating). + assert_eq!( + construct_and_apply_extrinsic(signer.pair(), call), + Err(TransactionValidityError::Invalid(InvalidTransaction::Call)), + ); + }); +} + +// ============================================================================ +// Priority and longevity assertions — ensure the declared priority hierarchy +// is correctly enforced end-to-end through `Executive::validate_transaction`. +// +// Expected priority order (highest to lowest): +// Sudo > SetPurgeKeys = Proxy = Utility > RemoveExpiredAuthorization > StoreRenew > BridgeTx +// ============================================================================ + +/// Verify that a `store` extrinsic gets `StoreRenewPriority` and `StoreRenewLongevity` +/// from the ValidateStorageCalls extension. +#[test] +fn store_extrinsic_has_expected_priority_and_longevity() { + run_test(|| { + advance_block(); + + let signer = sudo_relayer_signer(); // Alice is a TestAccount / Authorizer + let who: runtime::AccountId = signer.to_account_id(); + let data = vec![42u8; 100]; + + // Authorize so the store call passes validation. + assert_ok!(runtime::TransactionStorage::authorize_account( + RuntimeOrigin::root(), + who.clone(), + 1, + data.len() as u64, + )); + + let call = RuntimeCall::TransactionStorage(TxStorageCall::::store { + data: data.clone(), + }); + let xt = construct_extrinsic(signer.pair(), call).unwrap(); + let validity = + Executive::validate_transaction(TransactionSource::External, xt, Hash::default()) + .unwrap(); + + assert_eq!(validity.priority, runtime::StoreRenewPriority::get()); + assert_eq!(validity.longevity, runtime::StoreRenewLongevity::get()); + }); +} + +/// Verify the declared priority hierarchy: +/// Sudo > SetPurgeKeys > Proxy = Utility = RemoveExpired > StoreRenew > Bridge +#[test] +fn priority_hierarchy_is_correct() { + assert!(runtime::SudoPriority::get() > runtime::SetPurgeKeysPriority::get()); + assert!( + runtime::SetPurgeKeysPriority::get() > runtime::RemoveExpiredAuthorizationPriority::get() + ); + assert!( + runtime::RemoveExpiredAuthorizationPriority::get() > runtime::StoreRenewPriority::get() + ); + assert!(runtime::StoreRenewPriority::get() > runtime::BridgeTxPriority::get()); + + // Proxy, Utility, and RemoveExpiredAuthorization all sit one level below SetPurgeKeys. + assert_eq!(runtime::ProxyPriority::get(), runtime::RemoveExpiredAuthorizationPriority::get()); + assert_eq!(runtime::UtilityPriority::get(), runtime::RemoveExpiredAuthorizationPriority::get()); +} diff --git a/runtimes/bulletin-westend/src/lib.rs b/runtimes/bulletin-westend/src/lib.rs index 627a6042d..2fa57518b 100644 --- a/runtimes/bulletin-westend/src/lib.rs +++ b/runtimes/bulletin-westend/src/lib.rs @@ -114,7 +114,10 @@ pub type TxExtension = cumulus_pallet_weight_reclaim::StorageWeightReclaim< Runtime, pallet_transaction_payment::ChargeTransactionPayment, >, - pallet_transaction_storage::extension::ValidateStorageCalls, + pallet_transaction_storage::extension::ValidateStorageCalls< + Runtime, + storage::StorageCallInspector, + >, frame_metadata_hash_extension::CheckMetadataHash, ), >; diff --git a/runtimes/bulletin-westend/src/storage.rs b/runtimes/bulletin-westend/src/storage.rs index 23e2c459c..c7ef38fff 100644 --- a/runtimes/bulletin-westend/src/storage.rs +++ b/runtimes/bulletin-westend/src/storage.rs @@ -21,11 +21,12 @@ use crate::xcm_config::PeopleNextLocation; use alloc::vec::Vec; use frame_support::{ parameter_types, - traits::{EitherOfDiverse, Equals, SortedMembers}, + traits::{Contains, EitherOfDiverse, Equals, SortedMembers}, }; use frame_system::EnsureSignedBy; +use pallet_transaction_storage::CallInspector; use pallet_xcm::EnsureXcm; -use pallets_common::NoCurrency; +use pallets_common::{inspect_utility_wrapper, NoCurrency}; use sp_keyring::Sr25519Keyring; use sp_runtime::transaction_validity::{TransactionLongevity, TransactionPriority}; use testnet_parachains_constants::westend::locations::PeopleLocation; @@ -43,13 +44,43 @@ parameter_types! { // Priorities and longevities used by the transaction storage pallet extrinsics. pub const SudoPriority: TransactionPriority = TransactionPriority::MAX; pub const SetPurgeKeysPriority: TransactionPriority = SudoPriority::get() - 1; - pub const SetPurgeKeysLongevity: TransactionLongevity = crate::HOURS as TransactionLongevity; pub const RemoveExpiredAuthorizationPriority: TransactionPriority = SetPurgeKeysPriority::get() - 1; pub const RemoveExpiredAuthorizationLongevity: TransactionLongevity = crate::DAYS as TransactionLongevity; pub const StoreRenewPriority: TransactionPriority = RemoveExpiredAuthorizationPriority::get() - 1; pub const StoreRenewLongevity: TransactionLongevity = crate::DAYS as TransactionLongevity; } +/// Tells [`pallet_transaction_storage::extension::ValidateStorageCalls`] how to find storage +/// calls inside wrapper extrinsics so it can recursively validate and consume authorization. +/// +/// Also implements [`Contains`] returning `true` for storage-mutating calls +/// (store, store_with_cid_config, renew). Used with `EverythingBut` as the XCM +/// `SafeCallFilter` to block these calls from XCM dispatch — they require on-chain +/// authorization that XCM cannot provide. +#[derive(Clone, PartialEq, Eq, Default)] +pub struct StorageCallInspector; + +impl pallet_transaction_storage::CallInspector for StorageCallInspector { + fn inspect_wrapper(call: &RuntimeCall) -> Option> { + match call { + RuntimeCall::Utility(c) => inspect_utility_wrapper(c), + // Sudo is intentionally not inspected: the sudo key holder can store + // data via `sudo(store)` without authorization, as Root origin is + // accepted by `ensure_authorized`. + _ => None, + } + } +} + +/// Returns `true` for storage-mutating TransactionStorage calls (store, store_with_cid_config, +/// renew). Recursively inspects wrapper calls (Utility) to prevent bypass via nesting. +/// Used with `EverythingBut` as the XCM `SafeCallFilter`. +impl Contains for StorageCallInspector { + fn contains(call: &RuntimeCall) -> bool { + Self::is_storage_mutating_call(call, 0) + } +} + /// The main business of the Bulletin chain. impl pallet_transaction_storage::Config for Runtime { type RuntimeEvent = RuntimeEvent; diff --git a/runtimes/bulletin-westend/src/xcm_config.rs b/runtimes/bulletin-westend/src/xcm_config.rs index d390a024d..ebfcaa9d0 100644 --- a/runtimes/bulletin-westend/src/xcm_config.rs +++ b/runtimes/bulletin-westend/src/xcm_config.rs @@ -23,7 +23,7 @@ use frame_support::{ parameter_types, traits::{ fungible::HoldConsideration, tokens::imbalance::ResolveTo, ConstU32, Contains, Equals, - Everything, LinearStoragePrice, Nothing, + Everything, EverythingBut, LinearStoragePrice, Nothing, }, }; use frame_system::EnsureRoot; @@ -270,7 +270,7 @@ impl xcm_executor::Config for XcmConfig { type MessageExporter = (); type UniversalAliases = Nothing; type CallDispatcher = RuntimeCall; - type SafeCallFilter = Everything; + type SafeCallFilter = EverythingBut; type Aliasers = TrustedAliasers; type TransactionalProcessor = FrameTransactionalProcessor; type HrmpNewChannelOpenRequestHandler = (); diff --git a/runtimes/bulletin-westend/tests/tests.rs b/runtimes/bulletin-westend/tests/tests.rs index 2e72938d8..0854ced10 100644 --- a/runtimes/bulletin-westend/tests/tests.rs +++ b/runtimes/bulletin-westend/tests/tests.rs @@ -32,8 +32,9 @@ use parachains_runtimes_test_utils::{ExtBuilder, GovernanceOrigin, RuntimeHelper use sp_core::{crypto::Ss58Codec, Encode, Pair}; use sp_keyring::Sr25519Keyring; use sp_runtime::{ - transaction_validity, transaction_validity::InvalidTransaction, ApplyExtrinsicResult, - BuildStorage, Either, + transaction_validity, + transaction_validity::{InvalidTransaction, TransactionValidityError}, + ApplyExtrinsicResult, BuildStorage, Either, }; use std::collections::HashMap; use testnet_parachains_constants::westend::{fee::WeightToFee, locations::PeopleLocation}; @@ -83,7 +84,10 @@ fn construct_extrinsic( pallet_skip_feeless_payment::SkipCheckIfFeeless::from( pallet_transaction_payment::ChargeTransactionPayment::::from(0u128), ), - pallet_transaction_storage::extension::ValidateStorageCalls::::default(), + pallet_transaction_storage::extension::ValidateStorageCalls::< + Runtime, + bulletin_westend_runtime::storage::StorageCallInspector, + >::default(), frame_metadata_hash_extension::CheckMetadataHash::::new(false), ); let tx_ext: TxExtension = @@ -699,3 +703,824 @@ fn transaction_storage_weight_sanity() { Some(85), ); } + +// ============================================================================ +// Ensure calls wrapped in dispatch wrappers are subject to the same validation +// as direct submissions. Covers utility (batch, batch_all, force_batch, +// as_derivative) and sudo. +// ============================================================================ + +/// Wrap a call in utility dispatcher variants. +fn wrap_call_utility_variants(call: RuntimeCall) -> Vec<(RuntimeCall, &'static str)> { + vec![ + ( + RuntimeCall::Utility(pallet_utility::Call::batch { calls: vec![call.clone()] }), + "utility::batch", + ), + ( + RuntimeCall::Utility(pallet_utility::Call::batch_all { calls: vec![call.clone()] }), + "utility::batch_all", + ), + ( + RuntimeCall::Utility(pallet_utility::Call::force_batch { calls: vec![call.clone()] }), + "utility::force_batch", + ), + ( + RuntimeCall::Utility(pallet_utility::Call::as_derivative { + index: 0, + call: Box::new(call), + }), + "utility::as_derivative", + ), + ] +} + +/// Assert that direct and utility-wrapper variants are rejected at validation time. +#[test] +fn wrapped_store_requires_authorization() { + sp_io::TestExternalities::new(RuntimeGenesisConfig::default().build_storage().unwrap()) + .execute_with(|| { + advance_block(); + let account = Sr25519Keyring::Alice; + let who: AccountId = account.to_account_id(); + + // Fund Alice so fee checks pass and ValidateStorageCalls can reject. + use frame_support::traits::fungible::Mutate; + Balances::mint_into(&who, 1_000_000_000_000).unwrap(); + + let store_call = RuntimeCall::TransactionStorage(TxStorageCall::::store { + data: vec![42u8; 100], + }); + + // Direct: rejected for missing authorization. + assert_eq!( + construct_and_apply_extrinsic(Some(account.pair()), store_call.clone()), + Err(TransactionValidityError::Invalid(InvalidTransaction::Payment)), + "store: direct", + ); + + // Utility wrappers: rejected because store is not allowed inside wrappers. + for (wrapped, name) in wrap_call_utility_variants(store_call.clone()) { + assert_eq!( + construct_and_apply_extrinsic(Some(account.pair()), wrapped), + Err(TransactionValidityError::Invalid(InvalidTransaction::Call)), + "store: via {name}", + ); + } + + // sudo_as: passes validation (sudo not inspected) but fails at dispatch + // because no sudo key is configured in default genesis. + let sudo_as_result = construct_and_apply_extrinsic( + Some(account.pair()), + RuntimeCall::Sudo(pallet_sudo::Call::sudo_as { + who: sp_runtime::MultiAddress::Id(account.to_account_id()), + call: Box::new(store_call), + }), + ); + assert!(sudo_as_result.is_ok(), "sudo_as should pass validation"); + assert!(sudo_as_result.unwrap().is_err(), "sudo_as should fail at dispatch"); + }); +} + +#[test] +fn wrapped_store_with_cid_config_requires_authorization() { + sp_io::TestExternalities::new(RuntimeGenesisConfig::default().build_storage().unwrap()) + .execute_with(|| { + advance_block(); + let account = Sr25519Keyring::Alice; + let who: AccountId = account.to_account_id(); + + // Fund Alice so fee checks pass and ValidateStorageCalls can reject. + use frame_support::traits::fungible::Mutate; + Balances::mint_into(&who, 1_000_000_000_000).unwrap(); + + let store_call = + RuntimeCall::TransactionStorage(TxStorageCall::::store_with_cid_config { + cid: CidConfig { codec: 0x55, hashing: HashingAlgorithm::Blake2b256 }, + data: vec![42u8; 100], + }); + + // Direct: rejected for missing authorization. + assert_eq!( + construct_and_apply_extrinsic(Some(account.pair()), store_call.clone()), + Err(TransactionValidityError::Invalid(InvalidTransaction::Payment)), + "store_with_cid_config: direct", + ); + + // Utility wrappers: rejected because store is not allowed inside wrappers. + for (wrapped, name) in wrap_call_utility_variants(store_call.clone()) { + assert_eq!( + construct_and_apply_extrinsic(Some(account.pair()), wrapped), + Err(TransactionValidityError::Invalid(InvalidTransaction::Call)), + "store_with_cid_config: via {name}", + ); + } + + // sudo_as: passes validation (sudo not inspected) but fails at dispatch + // because no sudo key is configured in default genesis. + let sudo_as_result = construct_and_apply_extrinsic( + Some(account.pair()), + RuntimeCall::Sudo(pallet_sudo::Call::sudo_as { + who: sp_runtime::MultiAddress::Id(account.to_account_id()), + call: Box::new(store_call), + }), + ); + assert!(sudo_as_result.is_ok(), "sudo_as should pass validation"); + assert!(sudo_as_result.unwrap().is_err(), "sudo_as should fail at dispatch"); + }); +} + +/// Store calls inside wrappers (batch, batch_all, force_batch) are rejected even when +/// authorized. Store/renew must be submitted as direct extrinsics. +#[test] +fn authorized_wrapped_store_rejected() { + sp_io::TestExternalities::new(RuntimeGenesisConfig::default().build_storage().unwrap()) + .execute_with(|| { + advance_block(); + let account = Sr25519Keyring::Alice; + let who: AccountId = account.to_account_id(); + let data = vec![42u8; 100]; + + // Fund Alice for fees. + use frame_support::traits::fungible::Mutate; + Balances::mint_into(&who, 1_000_000_000_000).unwrap(); + + // Authorize enough for several calls. + assert_ok!(TransactionStorage::authorize_account( + RuntimeOrigin::root(), + who.clone(), + 4, + 4 * data.len() as u64, + )); + + let store_call = RuntimeCall::TransactionStorage(TxStorageCall::::store { + data: data.clone(), + }); + + // Direct store should succeed. + assert_ok_ok(construct_and_apply_extrinsic(Some(account.pair()), store_call.clone())); + + // Batch-wrapped store must be rejected. + for (wrapped, name) in wrap_call_utility_variants(store_call) { + assert_eq!( + construct_and_apply_extrinsic(Some(account.pair()), wrapped), + Err(TransactionValidityError::Invalid(InvalidTransaction::Call)), + "{name}: wrapped store must be rejected", + ); + } + + // Only the direct store consumed authorization (1 tx, data.len() bytes). + assert_eq!( + TransactionStorage::account_authorization_extent(who), + AuthorizationExtent { transactions: 3, bytes: 3 * data.len() as u64 }, + ); + }); +} + +/// Batch containing store calls is rejected — store must be submitted as direct extrinsics. +#[test] +fn batch_store_with_mixed_preimage_and_account_auth_rejected() { + sp_io::TestExternalities::new(RuntimeGenesisConfig::default().build_storage().unwrap()) + .execute_with(|| { + advance_block(); + let account = Sr25519Keyring::Alice; + let who: AccountId = account.to_account_id(); + + // Fund Alice for fees. + use frame_support::traits::fungible::Mutate; + Balances::mint_into(&who, 1_000_000_000_000).unwrap(); + + let data_a = vec![42u8; 100]; + let data_b = vec![99u8; 200]; + let content_hash_a = sp_io::hashing::blake2_256(&data_a); + + // Authorize preimage for data_a only. + assert_ok!(TransactionStorage::authorize_preimage( + RuntimeOrigin::root(), + content_hash_a, + data_a.len() as u64, + )); + + // Authorize account for data_b (1 transaction, enough bytes). + assert_ok!(TransactionStorage::authorize_account( + RuntimeOrigin::root(), + who.clone(), + 1, + data_b.len() as u64, + )); + + let store_a = + RuntimeCall::TransactionStorage(TxStorageCall::::store { data: data_a }); + let store_b = + RuntimeCall::TransactionStorage(TxStorageCall::::store { data: data_b }); + + let batch = + RuntimeCall::Utility(pallet_utility::Call::batch { calls: vec![store_a, store_b] }); + + // Batch containing store calls is rejected. + assert_eq!( + construct_and_apply_extrinsic(Some(account.pair()), batch), + Err(TransactionValidityError::Invalid(InvalidTransaction::Call)), + ); + + // Authorizations were NOT consumed (rejected before prepare). + assert_eq!( + TransactionStorage::preimage_authorization_extent(content_hash_a), + AuthorizationExtent { transactions: 1, bytes: 100 }, + "Preimage authorization should not be consumed", + ); + assert_eq!( + TransactionStorage::account_authorization_extent(who), + AuthorizationExtent { transactions: 1, bytes: 200 }, + "Account authorization should not be consumed", + ); + }); +} + +// NOTE: The following preimage/renew/authorize tests mirror those in +// bulletin-polkadot/tests/tests.rs. Keep in sync when modifying. + +/// Preimage authorization allows anyone to store pre-authorized content. +#[test] +fn preimage_authorized_storage_transactions_work() { + sp_io::TestExternalities::new(RuntimeGenesisConfig::default().build_storage().unwrap()) + .execute_with(|| { + advance_block(); + let account = Sr25519Keyring::Alice; + let who: AccountId = account.to_account_id(); + + // Fund Alice for transaction fees (westend has fees unlike polkadot). + use frame_support::traits::fungible::Mutate; + Balances::mint_into(&who, 1_000_000_000_000).unwrap(); + + let data = vec![0u8; 24]; + let content_hash = sp_io::hashing::blake2_256(&data); + let call = RuntimeCall::TransactionStorage(TxStorageCall::::store { + data: data.clone(), + }); + + // Not authorized (no account or preimage auth) should fail to store. + assert_eq!( + construct_and_apply_extrinsic(Some(account.pair()), call.clone()), + Err(TransactionValidityError::Invalid(InvalidTransaction::Payment)) + ); + + // Authorize preimage (not account). + assert_ok!(TransactionStorage::authorize_preimage( + RuntimeOrigin::root(), + content_hash, + data.len() as u64, + )); + + // Now should work via preimage authorization. + assert_ok_ok(construct_and_apply_extrinsic(Some(account.pair()), call)); + + // Verify preimage authorization was consumed. + assert_eq!( + TransactionStorage::preimage_authorization_extent(content_hash), + AuthorizationExtent { transactions: 0, bytes: 0 }, + ); + }); +} + +/// When both preimage and account authorizations exist, preimage takes priority. +#[test] +fn signed_store_prefers_preimage_authorization_over_account() { + sp_io::TestExternalities::new(RuntimeGenesisConfig::default().build_storage().unwrap()) + .execute_with(|| { + advance_block(); + let account = Sr25519Keyring::Alice; + let who: AccountId = account.to_account_id(); + let data = vec![0u8; 100]; + let content_hash = sp_io::hashing::blake2_256(&data); + + // Setup: authorize both account and preimage + assert_ok!(TransactionStorage::authorize_account( + RuntimeOrigin::root(), + who.clone(), + 5, + 500, + )); + assert_ok!(TransactionStorage::authorize_preimage( + RuntimeOrigin::root(), + content_hash, + data.len() as u64, + )); + + // Store data + let call = RuntimeCall::TransactionStorage(TxStorageCall::::store { + data: data.clone(), + }); + assert_ok_ok(construct_and_apply_extrinsic(Some(account.pair()), call)); + + // Verify: preimage authorization was consumed, account authorization unchanged + assert_eq!( + TransactionStorage::preimage_authorization_extent(content_hash), + AuthorizationExtent { transactions: 0, bytes: 0 }, + "Preimage authorization should be consumed" + ); + assert_eq!( + TransactionStorage::account_authorization_extent(who), + AuthorizationExtent { transactions: 5, bytes: 500 }, + "Account authorization should remain unchanged when preimage auth is used" + ); + }); +} + +/// Renew calls wrapped in utility/sudo require authorization, same as store. +#[test] +fn wrapped_renew_requires_authorization() { + let mut t = RuntimeGenesisConfig::default().build_storage().unwrap(); + pallet_transaction_storage::GenesisConfig:: { + retention_period: 100, + byte_fee: 0, + entry_fee: 0, + account_authorizations: vec![], + preimage_authorizations: vec![], + } + .assimilate_storage(&mut t) + .unwrap(); + + sp_io::TestExternalities::new(t).execute_with(|| { + advance_block(); + let account = Sr25519Keyring::Alice; + let who: AccountId = account.to_account_id(); + let data = vec![42u8; 100]; + + // Fund for fees and authorize a store. + use frame_support::traits::fungible::Mutate; + Balances::mint_into(&who, 1_000_000_000_000).unwrap(); + assert_ok!(TransactionStorage::authorize_account( + RuntimeOrigin::root(), + who.clone(), + 1, + data.len() as u64, + )); + assert_ok_ok(construct_and_apply_extrinsic( + Some(account.pair()), + RuntimeCall::TransactionStorage(TxStorageCall::::store { data }), + )); + let stored_block = System::block_number(); + + advance_block(); + + let renew_call = RuntimeCall::TransactionStorage(TxStorageCall::::renew { + block: stored_block, + index: 0, + }); + + // Direct: rejected for missing authorization. + assert_eq!( + construct_and_apply_extrinsic(Some(account.pair()), renew_call.clone()), + Err(TransactionValidityError::Invalid(InvalidTransaction::Payment)), + "renew: direct", + ); + + // Utility wrappers: rejected because renew is not allowed inside wrappers. + for (wrapped, name) in wrap_call_utility_variants(renew_call.clone()) { + assert_eq!( + construct_and_apply_extrinsic(Some(account.pair()), wrapped), + Err(TransactionValidityError::Invalid(InvalidTransaction::Call)), + "renew: via {name}", + ); + } + + // sudo_as: passes validation (sudo not inspected) but fails at dispatch + // because no sudo key is configured. + let sudo_as_result = construct_and_apply_extrinsic( + Some(account.pair()), + RuntimeCall::Sudo(pallet_sudo::Call::sudo_as { + who: sp_runtime::MultiAddress::Id(who), + call: Box::new(renew_call), + }), + ); + assert!(sudo_as_result.is_ok(), "sudo_as should pass validation"); + assert!(sudo_as_result.unwrap().is_err(), "sudo_as should fail at dispatch"); + }); +} + +/// Non-authorizers cannot authorize_account even via batch. +#[test] +fn wrapped_authorize_account_requires_authorizer_origin() { + sp_io::TestExternalities::new(RuntimeGenesisConfig::default().build_storage().unwrap()) + .execute_with(|| { + advance_block(); + // Bob is not an Authorizer (only Alice is in TestAccounts). + let attacker = Sr25519Keyring::Bob; + let who: AccountId = attacker.to_account_id(); + + // Fund for fees. + use frame_support::traits::fungible::Mutate; + Balances::mint_into(&who, 1_000_000_000_000).unwrap(); + + let call = + RuntimeCall::TransactionStorage(TxStorageCall::::authorize_account { + who: who.clone(), + transactions: 5, + bytes: 1024, + }); + + // Direct: rejected at validation (BadSigner). + assert_eq!( + construct_and_apply_extrinsic(Some(attacker.pair()), call.clone()), + Err(TransactionValidityError::Invalid(InvalidTransaction::BadSigner)), + ); + + // Via batch: batch itself is valid, but the inner authorize_account must + // fail at dispatch (origin is not Authorizer). Verify via storage state. + let batch_call = + RuntimeCall::Utility(pallet_utility::Call::batch { calls: vec![call] }); + let _ = construct_and_apply_extrinsic(Some(attacker.pair()), batch_call); + assert_eq!( + TransactionStorage::account_authorization_extent(who), + AuthorizationExtent { transactions: 0, bytes: 0 }, + "authorize_account via batch must not succeed for non-Authorizer", + ); + }); +} + +/// Wrapping `authorize_account` in `batch_all` must not break the authorization. +/// The origin must remain `Signed` (not transformed to `Authorized`) so that +/// `T::Authorizer::ensure_origin()` succeeds at dispatch time. +#[test] +fn wrapped_authorize_account_succeeds() { + sp_io::TestExternalities::new(RuntimeGenesisConfig::default().build_storage().unwrap()) + .execute_with(|| { + advance_block(); + let account = Sr25519Keyring::Alice; + let who: AccountId = account.to_account_id(); + let target: AccountId = Sr25519Keyring::Bob.to_account_id(); + + // Fund Alice for batch fee overhead. + use frame_support::traits::fungible::Mutate; + Balances::mint_into(&who, 1_000_000_000_000).unwrap(); + + // Wrap authorize_account inside batch_all — this is what the JS integration + // test does. The origin must stay Signed(Alice) so the Authorizer check passes. + let authorize_call = + RuntimeCall::TransactionStorage(TxStorageCall::::authorize_account { + who: target.clone(), + transactions: 10, + bytes: 10 * 1024, + }); + let batch_call = RuntimeCall::Utility(pallet_utility::Call::batch_all { + calls: vec![authorize_call], + }); + + assert_ok_ok(construct_and_apply_extrinsic(Some(account.pair()), batch_call)); + + // Authorization must have been created. + assert_eq!( + TransactionStorage::account_authorization_extent(target.clone()), + AuthorizationExtent { transactions: 10, bytes: 10 * 1024 }, + ); + + // Now verify that the authorized target can actually store data. + let data = vec![42u8; 100]; + let store_call = RuntimeCall::TransactionStorage(TxStorageCall::::store { + data: data.clone(), + }); + assert_ok_ok(construct_and_apply_extrinsic( + Some(Sr25519Keyring::Bob.pair()), + store_call, + )); + }); +} + +/// Batch containing store is rejected — store must be submitted as direct extrinsics, +/// regardless of what else is in the batch. +#[test] +fn mixed_batch_store_and_authorize_rejected() { + sp_io::TestExternalities::new(RuntimeGenesisConfig::default().build_storage().unwrap()) + .execute_with(|| { + advance_block(); + let account = Sr25519Keyring::Alice; + let who: AccountId = account.to_account_id(); + let target: AccountId = Sr25519Keyring::Bob.to_account_id(); + let data = vec![42u8; 100]; + + use frame_support::traits::fungible::Mutate; + Balances::mint_into(&who, 1_000_000_000_000).unwrap(); + + // Authorize Alice for one store. + assert_ok!(TransactionStorage::authorize_account( + RuntimeOrigin::root(), + who.clone(), + 1, + data.len() as u64, + )); + + let store_call = RuntimeCall::TransactionStorage(TxStorageCall::::store { + data: data.clone(), + }); + let authorize_call = + RuntimeCall::TransactionStorage(TxStorageCall::::authorize_account { + who: target.clone(), + transactions: 5, + bytes: 1024, + }); + + // Mixing store + authorize_account in a batch is rejected at validation. + for batch_variant in [ + RuntimeCall::Utility(pallet_utility::Call::batch { + calls: vec![store_call.clone(), authorize_call.clone()], + }), + RuntimeCall::Utility(pallet_utility::Call::batch_all { + calls: vec![store_call.clone(), authorize_call.clone()], + }), + RuntimeCall::Utility(pallet_utility::Call::force_batch { + calls: vec![store_call.clone(), authorize_call.clone()], + }), + ] { + assert_err!( + construct_and_apply_extrinsic(Some(account.pair()), batch_variant), + TransactionValidityError::Invalid(InvalidTransaction::Call), + ); + } + + // Authorization was NOT consumed (rejected before prepare). + assert_eq!( + TransactionStorage::account_authorization_extent(who), + AuthorizationExtent { transactions: 1, bytes: data.len() as u64 }, + ); + }); +} + +/// Batch containing store with a non-storage call is rejected — store must be direct. +#[test] +fn mixed_batch_store_and_non_storage_call_rejected() { + sp_io::TestExternalities::new(RuntimeGenesisConfig::default().build_storage().unwrap()) + .execute_with(|| { + advance_block(); + let account = Sr25519Keyring::Alice; + let who: AccountId = account.to_account_id(); + let data = vec![42u8; 100]; + + use frame_support::traits::fungible::Mutate; + Balances::mint_into(&who, 1_000_000_000_000).unwrap(); + + assert_ok!(TransactionStorage::authorize_account( + RuntimeOrigin::root(), + who.clone(), + 1, + data.len() as u64, + )); + + let store_call = RuntimeCall::TransactionStorage(TxStorageCall::::store { + data: data.clone(), + }); + let remark_call = + RuntimeCall::System(frame_system::Call::remark { remark: vec![1, 2, 3] }); + + let batch_call = RuntimeCall::Utility(pallet_utility::Call::batch { + calls: vec![store_call, remark_call], + }); + + assert_err!( + construct_and_apply_extrinsic(Some(account.pair()), batch_call), + TransactionValidityError::Invalid(InvalidTransaction::Call), + ); + + // Authorization was NOT consumed. + assert_eq!( + TransactionStorage::account_authorization_extent(who), + AuthorizationExtent { transactions: 1, bytes: data.len() as u64 }, + ); + }); +} + +/// Deeply nested wrapper calls exceeding MAX_WRAPPER_DEPTH must be rejected. +#[test] +fn max_recursion_depth_is_enforced() { + sp_io::TestExternalities::new(RuntimeGenesisConfig::default().build_storage().unwrap()) + .execute_with(|| { + advance_block(); + let account = Sr25519Keyring::Alice; + let who: AccountId = account.to_account_id(); + let data = vec![42u8; 100]; + + use frame_support::traits::fungible::Mutate; + Balances::mint_into(&who, 1_000_000_000_000).unwrap(); + + // Authorize Alice. + assert_ok!(TransactionStorage::authorize_account( + RuntimeOrigin::root(), + who.clone(), + 1, + data.len() as u64, + )); + + // Nest store inside MAX_WRAPPER_DEPTH+1 batch wrappers. + let mut call: RuntimeCall = + RuntimeCall::TransactionStorage(TxStorageCall::::store { + data: data.clone(), + }); + for _ in 0..=pallet_transaction_storage::MAX_WRAPPER_DEPTH { + call = RuntimeCall::Utility(pallet_utility::Call::batch { calls: vec![call] }); + } + + // Should fail with Call — store inside wrapper is rejected (the depth limit + // in is_storage_mutating_call treats excessively nested calls as storage-mutating). + assert_err!( + construct_and_apply_extrinsic(Some(account.pair()), call), + TransactionValidityError::Invalid(InvalidTransaction::Call) + ); + }); +} + +/// The sudo key holder can store data via `sudo(store)` without authorization. +/// Sudo dispatches with Root origin, and `ensure_authorized` accepts Root. +#[test] +fn sudo_store_works_for_sudo_key_holder() { + let mut t = RuntimeGenesisConfig::default().build_storage().unwrap(); + let sudo_account = Sr25519Keyring::Alice; + pallet_sudo::GenesisConfig:: { key: Some(sudo_account.to_account_id()) } + .assimilate_storage(&mut t) + .unwrap(); + + sp_io::TestExternalities::new(t).execute_with(|| { + advance_block(); + let who: AccountId = sudo_account.to_account_id(); + + use frame_support::traits::fungible::Mutate; + Balances::mint_into(&who, 1_000_000_000_000).unwrap(); + + let data = vec![42u8; 100]; + let store_call = + RuntimeCall::TransactionStorage(TxStorageCall::::store { data: data.clone() }); + + // sudo(store) should work — Root origin is accepted by ensure_authorized. + let sudo_call = RuntimeCall::Sudo(pallet_sudo::Call::sudo { call: Box::new(store_call) }); + assert_ok_ok(construct_and_apply_extrinsic(Some(sudo_account.pair()), sudo_call)); + + // No account authorization was needed or consumed. + assert_eq!( + TransactionStorage::account_authorization_extent(who), + AuthorizationExtent { transactions: 0, bytes: 0 }, + ); + }); +} + +// ============================================================================ +// XCM SafeCallFilter tests — verify that storage-mutating calls are blocked +// when dispatched via XCM Transact, even with valid authorization. +// ============================================================================ + +/// XCM Transact with `store` must be blocked by the SafeCallFilter +/// (`EverythingBut`). Storage operations must go through +/// signed extrinsics, never through XCM. +#[test] +fn xcm_transact_store_is_blocked() { + sp_io::TestExternalities::new(RuntimeGenesisConfig::default().build_storage().unwrap()) + .execute_with(|| { + advance_block(); + + let account = Sr25519Keyring::Alice; + let who: AccountId = account.to_account_id(); + let data = vec![42u8; 100]; + + // Authorize the account so we can verify the filter blocks the call + // regardless of authorization state. + assert_ok!(TransactionStorage::authorize_account( + RuntimeOrigin::root(), + who.clone(), + 1, + data.len() as u64, + )); + assert_ne!( + TransactionStorage::account_authorization_extent(who.clone()), + AuthorizationExtent { transactions: 0, bytes: 0 }, + ); + + let store_call = RuntimeCall::TransactionStorage(TxStorageCall::::store { + data: data.clone(), + }); + + // Build an XCM message: UnpaidExecution + Transact(Superuser, store). + // GovernanceLocation (relay chain) has LocationAsSuperuser in the + // OriginConverter, so origin conversion would succeed — but SafeCallFilter + // must block the call before that matters. + let message: Xcm = Xcm::builder_unsafe() + .unpaid_execution(Unlimited, None) + .transact(OriginKind::Superuser, None, store_call.encode()) + .build(); + + let mut id = [0u8; 32]; + let outcome = xcm_executor::XcmExecutor::< + bulletin_westend_runtime::xcm_config::XcmConfig, + >::prepare_and_execute( + GovernanceLocation::get(), message, &mut id, Weight::MAX, Weight::MAX + ); + + // SafeCallFilter returns false for store → XcmError::NoPermission + assert!( + outcome.clone().ensure_complete().is_err(), + "XCM Transact store must be blocked by SafeCallFilter, got: {outcome:?}", + ); + + // Authorization must not have been consumed. + assert_ne!( + TransactionStorage::account_authorization_extent(who), + AuthorizationExtent { transactions: 0, bytes: 0 }, + "Authorization should remain unconsumed since XCM was blocked", + ); + }); +} + +/// XCM Transact with `store` wrapped in `utility::batch` must also be blocked. +/// The `StorageCallInspector` recursively inspects inner calls. +#[test] +fn xcm_transact_wrapped_store_is_blocked() { + sp_io::TestExternalities::new(RuntimeGenesisConfig::default().build_storage().unwrap()) + .execute_with(|| { + advance_block(); + + let account = Sr25519Keyring::Alice; + let who: AccountId = account.to_account_id(); + let data = vec![42u8; 100]; + + assert_ok!(TransactionStorage::authorize_account( + RuntimeOrigin::root(), + who.clone(), + 1, + data.len() as u64, + )); + + let store_call = RuntimeCall::TransactionStorage(TxStorageCall::::store { + data: data.clone(), + }); + let batch_call = + RuntimeCall::Utility(pallet_utility::Call::batch { calls: vec![store_call] }); + + let message: Xcm = Xcm::builder_unsafe() + .unpaid_execution(Unlimited, None) + .transact(OriginKind::Superuser, None, batch_call.encode()) + .build(); + + let mut id = [0u8; 32]; + let outcome = xcm_executor::XcmExecutor::< + bulletin_westend_runtime::xcm_config::XcmConfig, + >::prepare_and_execute( + GovernanceLocation::get(), message, &mut id, Weight::MAX, Weight::MAX + ); + + assert!( + outcome.clone().ensure_complete().is_err(), + "XCM Transact batch(store) must be blocked by recursive SafeCallFilter, got: {outcome:?}", + ); + + // Authorization must not have been consumed. + assert_ne!( + TransactionStorage::account_authorization_extent(who), + AuthorizationExtent { transactions: 0, bytes: 0 }, + ); + }); +} + +/// XCM Transact with `authorize_account` must succeed — management calls are +/// allowed through XCM (they are not storage-mutating). +#[test] +fn xcm_transact_authorize_account_works() { + sp_io::TestExternalities::new(RuntimeGenesisConfig::default().build_storage().unwrap()) + .execute_with(|| { + advance_block(); + + let target: AccountId = Sr25519Keyring::Ferdie.to_account_id(); + + // Verify no authorization exists yet. + assert_eq!( + TransactionStorage::account_authorization_extent(target.clone()), + AuthorizationExtent { transactions: 0, bytes: 0 }, + ); + + let authorize_call = + RuntimeCall::TransactionStorage(TxStorageCall::::authorize_account { + who: target.clone(), + transactions: 10, + bytes: 1024, + }); + + let message: Xcm = Xcm::builder_unsafe() + .unpaid_execution(Unlimited, None) + .transact(OriginKind::Superuser, None, authorize_call.encode()) + .build(); + + let mut id = [0u8; 32]; + let outcome = xcm_executor::XcmExecutor::< + bulletin_westend_runtime::xcm_config::XcmConfig, + >::prepare_and_execute( + GovernanceLocation::get(), message, &mut id, Weight::MAX, Weight::MAX + ); + + assert!( + outcome.clone().ensure_complete().is_ok(), + "XCM Transact authorize_account must succeed, got: {outcome:?}", + ); + + // Authorization must have been created. + assert_eq!( + TransactionStorage::account_authorization_extent(target), + AuthorizationExtent { transactions: 10, bytes: 1024 }, + ); + }); +}